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:
| Parameter | Type | Description |
|---|---|---|
commitment | uint256 | Poseidon7 hash of the preimage |
amount | uint256 | Token amount in base units |
tokenAddress | address | ERC-20 token address (or sentinel for native GHOST) |
quantumCommitment | bytes32 | keccak256(quantumSecret) or 0x0 if unused |
policyId | uint256 | Policy contract ID (0 for no policy) |
policyParamsHash | uint256 | keccak256(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):
| Input | Description |
|---|---|
secret | The random secret from the commitment |
nullifierSecret | The nullifier secret from the commitment |
tokenId | Token ID (verified against public input) |
amount | Full committed amount |
blinding | Blinding factor |
policyId | Policy ID (verified against public input) |
policyParamsHash | Policy params hash (verified against public input) |
leafIndex | Position 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):
| Index | Input | Description |
|---|---|---|
| 0 | root | Merkle root the proof is generated against |
| 1 | nullifier | Unique nullifier derived from the commitment |
| 2 | withdrawAmount | Amount to withdraw (may be less than committed) |
| 3 | recipient | Address receiving the revealed tokens |
| 4 | changeCommitment | Commitment for the remaining balance (or 0) |
| 5 | tokenId | Token ID being withdrawn |
| 6 | policyId | Policy contract ID |
| 7 | policyParamsHash | Hash 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:
| Parameter | Type | Description |
|---|---|---|
proof | uint256[8] | Groth16 proof (a, b, c points) |
root | uint256 | Merkle root used in proof generation |
nullifier | uint256 | Computed nullifier |
withdrawAmount | uint256 | Amount to withdraw |
recipient | address | Receiving address |
changeCommitment | uint256 | Change commitment (0 for full withdrawal) |
tokenId | uint256 | Token ID |
policyId | uint256 | Policy contract ID |
policyParamsHash | uint256 | Policy parameters hash |
policyParams | bytes | Raw policy parameters (for policy contract) |
quantumSecret | bytes | Quantum 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
| Field | Carried Forward? | Reason |
|---|---|---|
secret | Yes | The same phantom key controls the change |
nullifierSecret | Yes | Needed for the change commitment's future nullifier |
tokenId | Yes | Change must be the same token |
policyId | Yes | Policy is inescapable — cannot be removed by partial withdrawal |
policyParamsHash | Yes | Policy parameters are bound to the commitment |
What Changes
| Field | Changed? | Reason |
|---|---|---|
amount | Yes | Reduced by withdrawAmount |
blinding | Yes | New random blinding prevents linkability |
leafIndex | Yes | Change 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
| Error | Cause | Resolution |
|---|---|---|
"Invalid field element" | Commitment >= BN254_FIELD or == 0 | Recompute commitment with valid inputs |
"Rate limited" | Less than 5 seconds since last commit | Wait and retry |
"Asset not allowed" | Token not in AssetGuard allowlist | Use an allowed token |
"Root not known" | Proof generated against a root older than 100 insertions | Regenerate proof with current tree state |
"Nullifier already spent" | Commitment has already been revealed | Cannot reveal twice — this is by design |
"Invalid proof" | Groth16 verification failed | Regenerate proof (likely stale inputs) |
"Policy validation failed" | Policy contract rejected the reveal | Check policy requirements (time, recipient, etc.) |
"Invalid quantum preimage" | Quantum secret does not match stored hash | Provide correct quantum secret from phantom key |