System Components
This page details how each component in the Specter architecture connects to the others and walks through the complete data flow for both commit and reveal operations.
Component Interaction Map
Commit Flow — Step by Step
The commit operation destroys tokens and records an on-chain commitment that can later be claimed by anyone who knows the corresponding secret.
Step 1: Client-Side Computation
The user's client (web browser or mobile app) performs all sensitive computation locally:
- Generate random secret — a 256-bit random value known only to the user.
- Derive nullifier — a deterministic value derived from the secret. This will be used at reveal time to prevent double-spending.
- Compute Poseidon7 commitment:
commitment = Poseidon7(secret, nullifier, assetId, amount, chainId, phantom, aux)
The seven inputs encode everything about what is being committed: the secret (for ownership), the nullifier (for spend tracking), the asset type, the amount, the chain ID (for cross-chain safety), a phantom key component, and auxiliary data.
- Compute keccak256 quantum commitment:
quantumCommitment = keccak256(abi.encodePacked(secret, nullifier, amount, ...))
This redundant commitment uses a hash function resistant to quantum attacks, providing a second authentication factor at reveal time.
- Generate phantom key — a compact representation of all the data needed to reconstruct the secret and perform a reveal. This key is what gets stored (on device, exported as QR code, or written to an NFC card).
Step 2: On-Chain Commit Transaction
The client (or the Commitment Relayer on behalf of the client) calls CommitRevealVault.commit():
function commit(
bytes32 commitment,
uint256 amount,
bytes32 quantumCommitment,
address asset,
bytes calldata auxData
) external payable;
The vault executes the following sequence:
-
Token burn. For native GHOST commits, the vault calls
NativeAssetHandler.burn(), which invokes theghostmintprecompile at0x0808to destroyamountofaghostvia the Cosmosx/bankmodule. For ERC-20 commits, the tokens are transferred to and locked in the vault. -
Tree insertion. The vault calls
CommitmentTree.insert(commitment), which appends the commitment as a new leaf in the on-chain Merkle tree and returns theleafIndex. -
Quantum commitment storage. The
quantumCommitmentis stored in a mapping indexed by the Poseidon commitment. It will be checked at reveal time. -
Event emission. A
Commitevent is emitted containing the commitment hash, leaf index, asset, amount, and timestamp. Clients and the Indexer listen for this event.
Step 3: Key Delivery
After a successful commit, the user's phantom key is the sole credential needed to reveal. It can be:
- Stored on device — encrypted in the webapp's local storage or the mobile app's secure enclave.
- Exported as QR code — scanned by another device to transfer reveal capability.
- Written to NFC card — the Card Relayer writes the key to an NTAG 424 DNA card, creating a physical bearer instrument.
Reveal Flow — Step by Step
The reveal operation proves knowledge of a valid commitment and mints fresh tokens to a designated recipient.
Step 1: Key Loading
The user loads their phantom key by one of:
- Opening the webapp/mobile app where the key is stored.
- Scanning a QR code.
- Tapping an NFC card (the Card Relayer validates the NTAG 424 DNA SUN signature before extracting the key).
From the phantom key, the client reconstructs the secret, nullifier, amount, assetId, and all other commitment parameters.
Step 2: Proof Generation
The client must generate a Groth16 zero-knowledge proof demonstrating:
- Knowledge of a valid commitment. The prover knows values
(secret, nullifier, assetId, amount, chainId, phantom, aux)such thatPoseidon7(...)equals a leaf in the Merkle tree. - Merkle membership. The commitment exists in the tree under the current root. The prover supplies the Merkle path (sibling hashes) as private inputs.
- Correct nullifier derivation. The nullifier in the public signals was correctly derived from the secret.
Public signals (visible on-chain): root, nullifier, recipient, amount, assetId, chainId.
Private inputs (never leave the prover): secret, phantom, aux, merklePath, merklePathIndices.
Client-Side vs. Relayer-Side Proving
| Environment | Proving approach | Trade-off |
|---|---|---|
| Web browser | WASM Groth16 prover runs in-browser. All private inputs stay local. | Maximum privacy; slower (~15-30 seconds on modern hardware). |
| Mobile device | Private inputs sent to the Proof Relayer over TLS. Relayer generates proof and returns it. | Faster; requires trusting the relayer with secret inputs during proof generation. |
| Server/CLI | Native Groth16 prover. All private inputs stay local. | Fastest; full privacy. |
Step 3: On-Chain Reveal Transaction
The Reveal Relayer (or the client directly) calls CommitRevealVault.reveal():
function reveal(
bytes calldata proof,
bytes32 root,
bytes32 nullifier,
address recipient,
uint256 amount,
address asset,
bytes32 quantumPreimage,
bytes calldata auxData
) external;
The vault executes the following sequence:
-
Proof verification. The vault calls
ProofVerifier.verifyProof(proof, publicSignals). The verifier checks the Groth16 proof against the verification key hardcoded in the contract. If invalid, the transaction reverts. -
Quantum preimage verification. The vault computes
keccak256(quantumPreimage)and checks it against the stored quantum commitment. If it does not match, the transaction reverts. This is the quantum defense: even if an attacker forges a Groth16 proof (hypothetically, via a quantum computer breaking BN254), they cannot supply the correct keccak256 preimage without knowing the original secret. -
Nullifier check. The vault calls
NullifierRegistry.checkAndRecord(nullifier). If this nullifier has been seen before, the transaction reverts (preventing double-spend). Otherwise, the nullifier is recorded permanently. -
Policy enforcement. The vault calls
AssetGuard.checkPolicy(recipient, amount, asset, auxData). AssetGuard iterates over registered policy contracts and calls each one. If any policy rejects the operation, the transaction reverts. Policies can enforce sanctions screening, time locks, amount limits, or custom business logic. -
Token minting. For native GHOST reveals, the vault calls
NativeAssetHandler.mint(recipient, amount), which invokes theghostmintprecompile to mint freshaghostviax/bank. For ERC-20 reveals, locked tokens are transferred from the vault to the recipient. -
Event emission. A
Revealevent is emitted containing the nullifier, recipient, amount, and timestamp. The link to the original commitment is not recorded — this is the privacy guarantee.
Supporting Services
Root Updater
The Merkle root changes every time a new commitment is inserted into the CommitmentTree. The Root Updater service:
- Listens for
LeafInsertedevents from the CommitmentTree contract. - Recomputes the Merkle root from the full set of leaves.
- Caches the latest root and recent historical roots (proofs may reference a slightly stale root if a new commitment was inserted between proof generation and reveal submission).
- Serves the current root and Merkle path data to clients via a REST API.
Without the Root Updater, clients would need to reconstruct the entire Merkle tree client-side to generate proofs — which is impractical for mobile devices.
Commitment Relayer
Mobile clients face two constraints that make direct commitment difficult:
-
Poseidon computation. The Poseidon hash function requires large prime field arithmetic that is expensive on mobile CPUs. The Commitment Relayer accepts the raw commitment inputs and computes
Poseidon7(...)server-side. -
Gas payment. New users may not have GHOST for gas. The Commitment Relayer can submit the commit transaction on behalf of the user and include the gas cost in the committed amount.
The workflow:
Mobile → HTTPS POST to Commitment Relayer (secret, nullifier, amount, ...)
Commitment Relayer → Compute Poseidon7 → Submit commit tx → Return commitment hash + leaf index
Security note: When using the Commitment Relayer, the user's secret inputs are transmitted to the relayer. The relayer sees the raw secret. This is a trust trade-off accepted for mobile usability. Users who require maximum privacy should use the web application with in-browser Poseidon computation.
Proof Relayer
The Proof Relayer generates Groth16 proofs for clients that cannot run the prover locally:
Client → HTTPS POST to Proof Relayer (secret, nullifier, amount, merklePath, ...)
Proof Relayer → Run Groth16 prover → Return proof + publicSignals
The prover requires the full proving key (~50-100 MB) and significant memory. Mobile devices cannot efficiently run this. The Proof Relayer maintains the proving key in memory and generates proofs in 2-5 seconds.
Reveal Relayer
The Reveal Relayer submits reveal transactions so that users do not need GHOST in the recipient address for gas:
- Client sends the proof, public signals, and recipient address to the Reveal Relayer.
- The Reveal Relayer submits the
reveal()transaction, paying gas from its own funded account. - The relayer is reimbursed by deducting a small fee from the revealed amount (the recipient receives
amount - relayerFee).
This is critical for the privacy model: if the user had to fund the recipient address with gas before revealing, the funding transaction would link the recipient to an existing address, potentially deanonymizing the reveal.
Card Relayer
The Card Relayer manages the NFC card lifecycle:
| Operation | Description |
|---|---|
| Card provisioning | Writes a phantom key to a blank NTAG 424 DNA card. Configures the card's SUN authentication parameters. |
| Card reading | When a user taps a card, the relayer receives the SUN message (a cryptographic MAC over a rolling counter and card UID). It validates the MAC against the card's known key, confirming the tap is authentic and the card has not been cloned. |
| Card-bound reveal | After validating the card tap, the relayer extracts the phantom key, generates a proof (or delegates to the Proof Relayer), and initiates the reveal flow. |
Data Flow Summary
The fundamental privacy property is that the path from G to H is off-chain and unlinkable. There is no on-chain transaction connecting a specific commit to a specific reveal. The only on-chain evidence is that some commitment was made and some reveal was executed, with the ZK proof guaranteeing that the reveal corresponds to a valid (but unidentified) commitment.