Skip to main content

Zero-Loss Phantom Key System

Ghost Protocol's fundamental property — the phantom key is the only proof of ownership — creates a critical engineering challenge: if the key is lost before the on-chain transaction confirms, the committed assets are permanently inaccessible. The zero-loss system is a set of design patterns that ensure a user never loses assets due to application or infrastructure failures.

Core Principle: Save Before Transaction

                     ┌─────────────────────────────────────┐
│ THE IRON RULE │
│ │
│ The phantom key must be durably │
│ saved BEFORE any on-chain │
│ transaction is submitted. │
│ │
│ If the save fails, the transaction │
│ is never sent. │
└─────────────────────────────────────┘

This is not a best practice or a recommendation — it is a hard gate enforced by the application architecture. The transaction submission button is disabled until the key export step is confirmed complete.

Full Vanish Flow

The full vanish (commit) flow converts tokens into a phantom key. This is the highest-risk operation because the user is about to burn tokens irreversibly.

Step-by-Step

Step 1: GENERATE KEY

│ Generate random secrets (secret, nullifierSecret, blinding)
│ Compute commitment = Poseidon7(...)
│ Assemble phantom key JSON
│ Key exists only in memory at this point


Step 2: RENDER PNG

│ Pre-generate the phantom key PNG image
│ Embed key JSON into PNG metadata
│ PNG is rendered in memory (canvas → blob)
│ The PNG is ready for download but NOT YET SAVED


Step 3: GATE — USER SAVES KEY

│ Display the PNG with a download button
│ Display confirmation checkbox:
│ ☐ "I have saved my phantom key and understand
│ it cannot be recovered"
│ The "Vanish" button is DISABLED until:
│ 1. The PNG download has been triggered
│ 2. The checkbox is checked


Step 4: SUBMIT TRANSACTION

│ Call commit() on CommitRevealVault
│ Wait for transaction confirmation
│ Update key with leafIndex from event
│ Offer to re-download updated PNG (with leafIndex)


Step 5: DONE

Why Pre-Generate?

The PNG is generated before the user clicks "Vanish" — not after. This matters because:

  1. Canvas rendering can fail. If the PNG generation fails after tokens are burned, the user has no key. By pre-generating, we catch rendering failures before any irreversible action.
  2. Browser tab can close. If the user accidentally closes the tab after the transaction but before PNG generation, the key is lost. Pre-generation ensures the key is already saved.
  3. Memory pressure. On mobile devices, canvas rendering under memory pressure can produce corrupt output. Pre-generation allows integrity verification before proceeding.

Partial Summon Flow

The partial summon (partial reveal) flow is more complex because it involves two keys: the original key being consumed and the change key being created.

Step-by-Step

Step 1: IMPORT ORIGINAL KEY

│ User imports existing phantom key (PNG/QR/NFC)
│ Validate key integrity
│ Compute original commitment, verify it exists in tree


Step 2: GENERATE PROOF + CHANGE KEY

│ Compute changeAmount = originalAmount - withdrawAmount
│ Generate new blinding for change commitment
│ Compute changeCommitment = Poseidon7(
│ secret, nullifierSecret, tokenId, changeAmount,
│ newBlinding, policyId, policyParamsHash)
│ Assemble change phantom key JSON
│ Generate Groth16 proof (includes change commitment)


Step 3: RENDER CHANGE KEY PNG

│ Pre-generate the change key PNG image
│ Embed change key JSON into PNG metadata
│ PNG is rendered in memory, ready for download


Step 4: GATE — USER SAVES CHANGE KEY

│ Display the change key PNG with download button
│ Display confirmation checkbox:
│ ☐ "I have saved my change key and understand
│ it cannot be recovered"
│ The "Summon" button is DISABLED until:
│ 1. The change key PNG download has been triggered
│ 2. The checkbox is checked


Step 5: SUBMIT TRANSACTION

│ Call reveal() on CommitRevealVault
│ Wait for transaction confirmation
│ Update change key with leafIndex from event
│ Offer to re-download updated change key PNG


Step 6: DONE

│ withdrawAmount tokens minted to recipient
│ changeAmount tokens committed under change key
│ Original key is now spent (nullifier consumed)

Why Gate the Change Key?

The change key represents the unspent portion of the original commitment. If the user reveals 30 out of 100 GHOST, the change key holds the remaining 70 GHOST. Losing the change key means losing 70 GHOST permanently. The gating mechanism ensures the change key is saved before the reveal transaction is submitted.

Failure Scenario Analysis

The following table enumerates every failure point and its outcome under the zero-loss system:

Failure PointTimingAssets at Risk?Outcome
Browser crash before PNG saveBefore Step 3 gateNoNo transaction was sent. Secrets existed only in memory. User restarts and tries again. No tokens were burned.
Browser crash after PNG save, before txAfter Step 3, before Step 4NoUser has the PNG but no transaction was sent. The key contains a valid commitment but no leafIndex. The user can either retry the commit or discard the key. No tokens were burned.
Browser crash after tx sent, before tx confirmsDuring Step 4NoThe PNG was saved (Step 3 gate passed). If the tx confirms, the key is valid — the user can re-derive the leafIndex from chain events. If the tx fails/reverts, no tokens were burned.
Browser crash after tx confirmsAfter Step 4NoThe PNG was saved. The key may lack the leafIndex, but it can be recovered by scanning chain events for the commitment. All secrets are in the saved PNG.
MetaMask rejectionStep 4NoUser declined the transaction. No tokens burned. Key PNG was already saved but is inert (no on-chain commitment). User can retry or discard.
Network failure during txStep 4NoTransaction was not broadcast or did not confirm. Same as MetaMask rejection — retry with the same key.
PNG rendering failsStep 2NoThe gate (Step 3) is never reached. The "Vanish" button remains disabled. User cannot submit the transaction. Alert the user to retry or switch browsers.
PNG download failsStep 3NoThe download trigger did not fire. The checkbox cannot be checked. The "Vanish" button remains disabled. User retries the download.
Partial reveal: change key PNG failsStep 3 (summon)NoThe gate blocks the reveal transaction. User cannot proceed without saving the change key.
Partial reveal: crash after tx, before change key re-downloadAfter Step 5NoThe change key PNG was saved (gate passed in Step 4). The saved PNG may lack the new leafIndex, but it can be recovered from chain events.

Key Insight

In every failure scenario, the answer to "are assets at risk?" is No. The zero-loss system achieves this through a single invariant: the gate never opens until the key is saved.

Technical Implementation

Ref-Based Context Stashing

The application uses React refs (not state) to hold the phantom key data during the commit/reveal flow. This is deliberate:

// React state would trigger re-renders and risk losing data during unmount
const phantomKeyRef = useRef<PhantomKeyData | null>(null);
const pngBlobRef = useRef<Blob | null>(null);
const proofRef = useRef<Groth16Proof | null>(null);

Why refs instead of state?

ApproachRisk
React state (useState)Re-renders during proof generation can cause flickering, race conditions, or loss of intermediate state if a parent component unmounts
Context APIContext value changes trigger re-renders in all consumers — same risks as state
Refs (useRef)Values persist across renders without triggering re-renders. No risk of loss during re-render cycles. Accessible synchronously.

The refs hold the key data from generation through PNG rendering, gating, and transaction submission. At no point is the key data stored only in a closure or callback that could be garbage collected.

PNG Pre-Generation

The PNG is generated eagerly — as soon as the commitment is computed, before the user interacts with any UI:

async function prepareVanish(amount: bigint, token: address) {
// 1. Generate all secrets
const key = generatePhantomKey(amount, token);
phantomKeyRef.current = key;

// 2. Pre-render PNG immediately
const pngBlob = await renderPhantomKeyPNG(key);
pngBlobRef.current = pngBlob;

// 3. Verify PNG integrity
const reimported = await extractKeyFromPNG(pngBlob);
if (reimported.commitment !== key.commitment) {
throw new Error("PNG integrity check failed");
}

// 4. Enable the download button (gate is still closed)
setReadyToSave(true);
}

The integrity check (step 3) re-extracts the key from the generated PNG and verifies the commitment matches. This catches encoding errors, canvas corruption, and metadata embedding failures before the user ever sees the download button.

Gated Progression

The transaction button is controlled by two boolean flags, both of which must be true:

const [downloadTriggered, setDownloadTriggered] = useState(false);
const [checkboxChecked, setCheckboxChecked] = useState(false);

const canSubmit = downloadTriggered && checkboxChecked;

function handleDownload() {
// Trigger browser download of the pre-generated PNG
const url = URL.createObjectURL(pngBlobRef.current!);
const a = document.createElement('a');
a.href = url;
a.download = `phantom-key-${Date.now()}.png`;
a.click();
URL.revokeObjectURL(url);

setDownloadTriggered(true);
}
<button onClick={handleDownload} disabled={!readyToSave}>
Download Phantom Key
</button>

<label>
<input
type="checkbox"
checked={checkboxChecked}
onChange={(e) => setCheckboxChecked(e.target.checked)}
disabled={!downloadTriggered}
/>
I have saved my phantom key and understand it cannot be recovered
</label>

<button onClick={submitTransaction} disabled={!canSubmit}>
Vanish
</button>

The checkbox is disabled until the download has been triggered. The submit button is disabled until both conditions are met. This creates a strict linear progression: download, confirm, then transact.

Null LeafIndex Tolerance

The leafIndex is assigned by the Merkle tree contract during the commit transaction. This means the phantom key is generated before the leafIndex is known. The system handles this gracefully:

interface PhantomKeyData {
// ... other fields ...
leafIndex: number | null; // null until transaction confirms
}

A phantom key with leafIndex: null is valid — it contains all the secrets needed to generate a proof. The leafIndex can be recovered by:

  1. Scanning Committed events for the matching commitment value
  2. Querying the relayer's off-chain tree for the commitment's position

The import flow handles null-index keys by performing this recovery automatically:

async function importPhantomKey(key: PhantomKeyData): Promise<PhantomKeyData> {
if (key.leafIndex === null || key.leafIndex === undefined) {
// Recover leafIndex from chain events
const events = await vault.queryFilter(
vault.filters.Committed(key.commitment)
);
if (events.length > 0) {
key.leafIndex = events[0].args.leafIndex.toNumber();
}
// If no event found, the commitment was never submitted on-chain
// The key is valid but uncommitted — user can retry the commit
}
return key;
}

Flow Diagrams

Full Vanish — Success Path

    Time ──────────────────────────────────────────────────►

┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────┐
│ Gen │ │Render│ │ Save │ │ Confirm │ │ TX │
│ Key │─►│ PNG │─►│ PNG │─►│ Checkbox │─►│ Send │
│ │ │ │ │ ↓ │ │ ↓ │ │ │
└──────┘ └──────┘ │ gate │ │ gate │ └──┬───┘
│ open │ │ open │ │
└──────┘ └──────────┘ │

┌──────────┐
│ Confirm │
│ + update │
│ leafIdx │
└──────────┘

Full Vanish — Failure at Each Stage

    Gen Key fails     → No secrets generated. Retry. No risk.

Render PNG fails → Gate stays closed. Cannot transact. No risk.

Save PNG fails → Gate stays closed. Cannot transact. No risk.

Checkbox skipped → Gate stays closed. Cannot transact. No risk.

TX rejected → Key saved but no on-chain commitment. No risk.

TX reverts → Key saved but tokens not burned. No risk.

TX confirms → Key saved + tokens burned. SUCCESS.

Browser crash → Key already saved (gate passed). Recover leafIndex
from chain events. No risk.

Partial Summon — Success Path

    Time ──────────────────────────────────────────────────────────────►

┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────┐
│Import│ │ Gen │ │Render│ │ Save │ │Confirm│ │ TX │ │Update│
│ Orig │─►│Proof │─►│Change│─►│Change│─►│Chkbox │─►│ Send │─►│Change│
│ Key │ │+ Chg │ │ PNG │ │ PNG │ │ │ │ │ │ Idx │
└──────┘ │ Key │ └──────┘ │ ↓ │ │ ↓ │ └────────┘ └──────┘
└──────┘ │ gate │ │ gate │
│ open │ │ open │
└──────┘ └───────┘

Edge Cases

Multiple Browser Tabs

If the user opens multiple tabs and attempts concurrent commits, each tab independently generates keys and gates transactions. Since each key has unique secrets, there is no collision risk. The rate limiter (5-second cooldown) prevents the second tab's transaction from being front-run by the first.

Mobile Safari Restrictions

iOS Safari restricts programmatic downloads. The PNG is presented as an inline image that the user long-presses to save to their camera roll. The downloadTriggered flag is set when the user taps the "Save to Photos" button, which triggers the native iOS share sheet.

Relayer-Submitted Transactions

When transactions are submitted via a relayer (for gas abstraction), the same gating applies. The client generates the proof and key, saves the key, then sends the signed proof to the relayer. If the relayer fails to submit, the user retries with the same proof — the key is already saved.

Stale PNG After LeafIndex Update

The initial PNG (saved before the transaction) does not contain the leafIndex. After the transaction confirms, the application offers to re-download an updated PNG with the leafIndex included. If the user declines, the original PNG is still valid — the leafIndex can be recovered from chain events during import.