Skip to main content

Commit / Reveal Flow

This page describes the complete lifecycle of a Ghost Protocol transaction — from commitment creation through on-chain commit, ZK proof generation, and on-chain reveal. Every step is covered, including the cryptographic operations, smart contract logic, and data flow.

Overview

┌─────────────────────────────────────────────────────────────────┐
│ COMMIT PHASE │
│ │
│ 1. Generate secrets (secret, nullifierSecret, blinding) │
│ 2. Derive tokenId = Poseidon2(tokenAddress, 0) │
│ 3. Compute commitment = Poseidon7(secret, nullifierSecret, │
│ tokenId, amount, blinding, policyId, policyParamsHash) │
│ 4. Save phantom key (PNG/PDF/QR) │
│ 5. Submit commitment on-chain → tokens burned │
│ │
├──────────────────── time passes ────────────────────────────────┤
│ │
│ REVEAL PHASE │
│ │
│ 6. Generate Merkle proof (off-chain tree) │
│ 7. Compute nullifier = Poseidon2(Poseidon2(nullifierSecret, │
│ commitment), leafIndex) │
│ 8. Generate Groth16 proof (WASM/native prover) │
│ 9. Submit proof on-chain → tokens minted to recipient │
│ │
└─────────────────────────────────────────────────────────────────┘

Commitment Structure

The CommitRevealVault commitment encodes seven field elements into a single Poseidon hash:

commitment = Poseidon7(
secret, // Random 31-byte secret
nullifierSecret, // Random 31-byte nullifier secret
tokenId, // Poseidon2(tokenAddress, 0)
amount, // Token amount in base units
blinding, // Random blinding factor
policyId, // Policy contract identifier (0 = none)
policyParamsHash // keccak256(policyParams) % BN254_FIELD (0 = none)
)

Token ID Derivation

Ethereum addresses are 160 bits, which fit comfortably within the BN254 scalar field. However, to maintain a consistent hashing scheme and avoid domain separation issues, token addresses are converted to field-safe token IDs:

tokenId = Poseidon2(uint256(tokenAddress), 0)

The second input is fixed at 0, making this a one-to-one mapping from addresses to field elements. The on-chain contract stores the mapping and verifies it during both commit and reveal:

mapping(uint256 => address) public tokenIdToAddress;

Commit Phase (On-Chain)

Inputs

The user submits the following to the commit() function:

ParameterTypeDescription
commitmentuint256Poseidon7 hash of the preimage
amountuint256Token amount in base units
tokenAddressaddressERC-20 token address (or sentinel for native GHOST)
quantumCommitmentbytes32keccak256(quantumSecret) or 0x0 if unused
policyIduint256Policy contract ID (0 for no policy)
policyParamsHashuint256keccak256(policyParams) % BN254_FIELD (0 for no policy)

Validation Steps

The contract executes the following checks in order:

1. VALIDATE FIELD ELEMENT
└─ require(commitment < BN254_FIELD)
└─ require(commitment != 0)

2. VALIDATE AMOUNT
└─ require(amount > 0)

3. CHECK ASSET GUARD
└─ AssetGuard.checkCommit(tokenAddress, amount, msg.sender)
└─ Enforces token allowlists and per-token limits

4. RATE LIMIT
└─ require(block.timestamp >= lastCommitTime[msg.sender] + 5 seconds)
└─ Prevents spam and front-running attacks
└─ Updates lastCommitTime[msg.sender]

5. BURN TOKENS
└─ For native GHOST: NativeAssetHandler.burn(amount) via ghostmint precompile
└─ For ERC-20: transferFrom(msg.sender, address(this), amount) then burn
└─ Tokens are destroyed — they leave circulation entirely

6. RECORD COMMITMENT
└─ CommitmentTree.insert(commitment) → returns leafIndex
└─ Merkle tree updated, new root pushed to history buffer

7. STORE QUANTUM COMMITMENT (if provided)
└─ quantumCommitments[commitment] = quantumCommitment

8. STORE POLICY BINDING (if provided)
└─ commitmentPolicies[commitment] = PolicyBinding(policyId, policyParamsHash)

9. EMIT EVENT
└─ emit Committed(commitment, leafIndex, tokenId, amount, timestamp)

Rate Limiting

The 5-second cooldown per sender address prevents:

  • Transaction spam: Flooding the Merkle tree with commitments
  • Front-running: Rapidly committing/revealing to exploit timing
  • Denial of service: Exhausting the tree's 1M leaf capacity
mapping(address => uint256) public lastCommitTime;

modifier rateLimited() {
require(
block.timestamp >= lastCommitTime[msg.sender] + COMMIT_COOLDOWN,
"Rate limited"
);
lastCommitTime[msg.sender] = block.timestamp;
_;
}

Token Burning

When tokens are committed, they are burned — not locked or escrowed. This is the fundamental mechanism that severs the on-chain link between commit and reveal:

// Native GHOST token
function _burnNative(uint256 amount) internal {
// Calls the ghostmint precompile (0x0808) via NativeAssetHandler
// The precompile instructs x/bank to destroy aghost from the contract's account
nativeAssetHandler.burn{value: amount}();
}

// ERC-20 tokens
function _burnERC20(address token, uint256 amount) internal {
IERC20(token).transferFrom(msg.sender, address(this), amount);
IERC20Burnable(token).burn(amount);
}

Proof Generation (Off-Chain)

Between commit and reveal, the user generates a Groth16 zero-knowledge proof. This happens entirely off-chain (in the browser via WASM, or via a native prover).

Circuit Inputs

The ZK circuit takes the following inputs:

Private inputs (known only to the prover):

InputDescription
secretThe random secret from the commitment
nullifierSecretThe nullifier secret from the commitment
tokenIdToken ID (verified against public input)
amountFull committed amount
blindingBlinding factor
policyIdPolicy ID (verified against public input)
policyParamsHashPolicy params hash (verified against public input)
leafIndexPosition of the commitment in the Merkle tree
pathElements[20]Sibling hashes along the Merkle path
pathIndices[20]Left/right indicators for the Merkle path

Public inputs (visible to everyone, verified on-chain):

IndexInputDescription
0rootMerkle root the proof is generated against
1nullifierUnique nullifier derived from the commitment
2withdrawAmountAmount to withdraw (may be less than committed)
3recipientAddress receiving the revealed tokens
4changeCommitmentCommitment for the remaining balance (or 0)
5tokenIdToken ID being withdrawn
6policyIdPolicy contract ID
7policyParamsHashHash of policy parameters

Circuit Constraints

The circuit enforces the following:

1. COMMITMENT INTEGRITY
└─ Poseidon7(secret, nullifierSecret, tokenId, amount, blinding,
policyId, policyParamsHash) == computed commitment

2. MERKLE MEMBERSHIP
└─ MerkleProof(commitment, leafIndex, pathElements, pathIndices) == root

3. NULLIFIER DERIVATION
└─ innerNullifier = Poseidon2(nullifierSecret, commitment)
└─ nullifier = Poseidon2(innerNullifier, leafIndex)

4. AMOUNT BALANCE
└─ withdrawAmount <= amount
└─ withdrawAmount > 0

5. CHANGE COMMITMENT (if partial withdrawal)
└─ changeAmount = amount - withdrawAmount
└─ changeCommitment = Poseidon7(secret, nullifierSecret, tokenId,
changeAmount, newBlinding, policyId, policyParamsHash)

6. TOKEN ID BINDING
└─ tokenId == public tokenId input

7. POLICY BINDING
└─ policyId == public policyId input
└─ policyParamsHash == public policyParamsHash input

Reveal Phase (On-Chain)

Inputs

The user (or relayer) submits the following to the reveal() function:

ParameterTypeDescription
proofuint256[8]Groth16 proof (a, b, c points)
rootuint256Merkle root used in proof generation
nullifieruint256Computed nullifier
withdrawAmountuint256Amount to withdraw
recipientaddressReceiving address
changeCommitmentuint256Change commitment (0 for full withdrawal)
tokenIduint256Token ID
policyIduint256Policy contract ID
policyParamsHashuint256Policy parameters hash
policyParamsbytesRaw policy parameters (for policy contract)
quantumSecretbytesQuantum secret preimage (if quantum-protected)

Verification Steps

1. VERIFY TOKEN ID
└─ require(tokenIdToAddress[tokenId] != address(0))
└─ tokenAddress = tokenIdToAddress[tokenId]

2. VERIFY ROOT IN HISTORY
└─ require(isKnownRoot(root))
└─ Checks the 100-entry ring buffer of historical roots
└─ Allows proofs generated against recent (but not current) roots

3. CHECK NULLIFIER NOT SPENT
└─ require(!NullifierRegistry.isSpent(nullifier))
└─ If spent, the commitment has already been revealed (double-spend attempt)

4. VERIFY GROTH16 PROOF
└─ publicInputs = [root, nullifier, withdrawAmount, recipient,
changeCommitment, tokenId, policyId, policyParamsHash]
└─ require(ProofVerifier.verify(proof, publicInputs))
└─ The proof cryptographically guarantees all circuit constraints hold

5. ENFORCE POLICY
└─ if (policyId != 0):
│ policyContract = PolicyRegistry.getPolicy(policyId)
│ (bool success, bytes memory result) = policyContract.staticcall{gas: 100000}(
│ abi.encodeCall(IRevealPolicy.validate,
│ (commitment, nullifier, recipient, withdrawAmount, tokenAddress, policyParams))
│ )
│ require(success && abi.decode(result, (bool)))
└─ Policy cannot modify state (staticcall) and has a 100k gas cap

6. VERIFY QUANTUM PREIMAGE (if applicable)
└─ if (quantumCommitments[commitment] != bytes32(0)):
│ require(keccak256(abi.encodePacked(quantumSecret))
│ == quantumCommitments[commitment])
└─ Provides post-quantum authentication

7. MARK NULLIFIER SPENT
└─ NullifierRegistry.markSpent(nullifier)

8. MINT TOKENS TO RECIPIENT
└─ For native GHOST: NativeAssetHandler.mint(recipient, withdrawAmount)
└─ For ERC-20: ERC20.mint(recipient, withdrawAmount)
└─ Fresh tokens are created — no link to the burned tokens

9. INSERT CHANGE COMMITMENT (if partial withdrawal)
└─ if (changeCommitment != 0):
│ CommitmentTree.insert(changeCommitment)
│ emit Committed(changeCommitment, newLeafIndex, tokenId,
│ amount - withdrawAmount, timestamp)
└─ The change commitment is a new leaf in the Merkle tree

The 8 Public Inputs

The public inputs array is the bridge between the off-chain proof and the on-chain verification. Each element serves a specific purpose:

uint256[8] memory publicInputs = [
root, // [0] Which Merkle root the proof was generated against
nullifier, // [1] Unique spend tag — prevents double-reveal
withdrawAmount, // [2] How many tokens to mint to recipient
uint256(uint160(recipient)), // [3] Who receives the tokens
changeCommitment, // [4] Commitment for leftover balance (0 = full)
tokenId, // [5] Which token is being withdrawn
policyId, // [6] Which policy governs this reveal
policyParamsHash // [7] Hash of the policy parameters
];

All 8 values are verified by the Groth16 proof. An attacker cannot modify any of these values without invalidating the proof.

Partial Withdrawals

Ghost Protocol supports partial withdrawals — revealing less than the full committed amount and generating a new commitment for the remainder.

How It Works

When withdrawAmount < amount, the circuit computes a change commitment:

changeAmount = amount - withdrawAmount

changeCommitment = Poseidon7(
secret, // Same secret as original
nullifierSecret, // Same nullifier secret as original
tokenId, // Same token
changeAmount, // Reduced amount
newBlinding, // NEW random blinding factor
policyId, // Same policy (carried forward)
policyParamsHash // Same policy params (carried forward)
)

What Stays the Same

FieldCarried Forward?Reason
secretYesThe same phantom key controls the change
nullifierSecretYesNeeded for the change commitment's future nullifier
tokenIdYesChange must be the same token
policyIdYesPolicy is inescapable — cannot be removed by partial withdrawal
policyParamsHashYesPolicy parameters are bound to the commitment

What Changes

FieldChanged?Reason
amountYesReduced by withdrawAmount
blindingYesNew random blinding prevents linkability
leafIndexYesChange commitment gets a new position in the tree

Change Commitment Lifecycle

Original commitment (leaf #42, 100 GHOST)

▼ Partial reveal: withdraw 30 GHOST

├──► 30 GHOST minted to recipient

└──► Change commitment (leaf #1057, 70 GHOST)
inserted into Merkle tree
same secret, nullifierSecret, tokenId, policy
new blinding, new leafIndex

▼ Later: full reveal of 70 GHOST

└──► 70 GHOST minted to recipient
No change commitment (full withdrawal)

Blinding Uniqueness

The new blinding factor for the change commitment is critical. If the same blinding were reused, the change commitment would be identical to a predictable transformation of the original, potentially allowing an observer to link the original commit to the partial reveal. A fresh random blinding ensures the change commitment is indistinguishable from any other commitment in the tree.

Transaction Flow Diagram

    User (Browser/Mobile)                  Specter Chain
───────────────────── ─────────────
│ │
│ 1. Generate secrets │
│ 2. Compute commitment │
│ 3. Save phantom key │
│ │
│──── commit(commitment, amount) ────►│
│ │ 4. Validate inputs
│ │ 5. Burn tokens
│ │ 6. Insert into Merkle tree
│ │ 7. Store quantum commitment
│◄──── tx receipt + leafIndex ────────│ 8. Store policy binding
│ │ 9. Emit event
│ │
│ ... time passes ... │
│ │
│ 10. Fetch Merkle proof │
│ 11. Generate ZK proof │
│ │
│──── reveal(proof, inputs) ─────────►│
│ │ 12. Verify root
│ │ 13. Check nullifier
│ │ 14. Verify Groth16 proof
│ │ 15. Enforce policy
│ │ 16. Verify quantum preimage
│ │ 17. Mark nullifier spent
│ │ 18. Mint tokens
│◄──── tx receipt ───────────────────│ 19. Insert change (if partial)
│ │

Error Conditions

ErrorCauseResolution
"Invalid field element"Commitment >= BN254_FIELD or == 0Recompute commitment with valid inputs
"Rate limited"Less than 5 seconds since last commitWait and retry
"Asset not allowed"Token not in AssetGuard allowlistUse an allowed token
"Root not known"Proof generated against a root older than 100 insertionsRegenerate proof with current tree state
"Nullifier already spent"Commitment has already been revealedCannot reveal twice — this is by design
"Invalid proof"Groth16 verification failedRegenerate proof (likely stale inputs)
"Policy validation failed"Policy contract rejected the revealCheck policy requirements (time, recipient, etc.)
"Invalid quantum preimage"Quantum secret does not match stored hashProvide correct quantum secret from phantom key