Mint & Burn Flow
The private GHOST token transfer lifecycle is a burn-on-commit, mint-on-reveal cycle. Real tokens are destroyed when a commitment is created and recreated when a commitment is revealed. This page documents the complete flow through every layer of the stack.
Complete Lifecycle
Vanish (Commit) -- Step by Step
1. User Prepares Commitment
Off-chain, the user generates:
- A random
secret(254-bit) - A random
nullifierSecret(254-bit) - A random
blindingfactor (254-bit) - The
tokenId=Poseidon2(tokenAddress, 0)(for native GHOST,tokenAddress = address(0)) - The
amountin aghost - Optional
policyIdandpolicyParamsHash
The user computes the commitment:
commitment = Poseidon7(secret, nullifierSecret, tokenId, amount, blinding, policyId, policyParamsHash)
2. User Calls CommitRevealVault
The user sends a transaction to commitNative() (or commitNativeWithPolicy() if a policy is attached), sending the GHOST amount as msg.value:
vault.commitNative{value: 1 ether}(commitment, quantumCommitment);
3. Vault Burns Tokens
The vault calls NativeAssetHandler.burnNativeFrom{value: msg.value}(msg.sender, msg.value). The NativeAssetHandler forwards this to the ghostmint precompile, which:
- Verifies the caller is the authorized NativeAssetHandler
- Calls
x/bank.SendCoinsFromAccountToModule()to move tokens from the precompile address to the ghostmint module account - Calls
x/bank.BurnCoins()to destroy the tokens from total supply - Increments
totalBurnedin the KVStore
After this step, the GHOST tokens no longer exist. The total supply of aghost has decreased by the committed amount.
4. Vault Records Commitment
The vault calls commitmentTree.recordCommitment(commitment), which stores the commitment hash as a leaf in the Merkle tree. A root operator later includes this leaf in the computed Merkle root.
5. State After Commit
| What Happened | Where |
|---|---|
| GHOST tokens destroyed | x/bank total supply decreased |
| Commitment hash stored | CommitmentTree leaf array |
| Depositor recorded | commitmentDepositors[commitment] = msg.sender |
| Native committed total updated | _totalNativeCommitted += msg.value |
| Event emitted | Committed(token, depositor, commitment, amount, leafIndex) |
The tokens are gone. The only record that anything was committed is the commitment hash -- a single 254-bit field element that reveals nothing about the sender, amount, recipient, or policy.
Summon (Reveal) -- Step by Step
1. User Generates ZK Proof
Off-chain, the user uses the Groth16 prover with the redemption circuit to generate a proof that:
- They know the preimage of a commitment (
secret,nullifierSecret,tokenId,amount,blinding,policyId,policyParamsHash) - That commitment exists as a leaf in the Merkle tree (valid Merkle path to a known root)
- The nullifier is correctly derived:
nullifier = Poseidon2(nullifierSecret, leafIndex) - The
amount,recipient,tokenId,policyId, andpolicyParamsHashmatch what is encoded in the commitment
The proof is a compact Groth16 proof (~256 bytes) with 8 public inputs.
2. User Calls CommitRevealVault
vault.reveal(token, proof, publicInputs, commitment, quantumProof, changeQuantumCommitment, policyParams);
3. Vault Verifies the Proof
The vault performs these checks in sequence:
- Public input count: Exactly 8 public inputs required
- Token ID match:
publicInputs[5]must match the expected token ID for the specified token - Merkle root validity:
publicInputs[0]must be a known root in the CommitmentTree - Nullifier unspent:
publicInputs[1]must not exist in the NullifierRegistry - Groth16 verification: The proof must verify against the first 6 public inputs via the ProofVerifier contract
4. Vault Enforces Policy
If publicInputs[6] != 0 (policy is present):
- Compute
keccak256(policyParams) % BN254_SCALAR_FIELD - Verify it matches
publicInputs[7] - Verify the policy address has deployed code
- Call
policy.validate()viastaticcallwith 100K gas - Require the policy returns
true
5. Vault Records Nullifier
The nullifier (publicInputs[1]) is recorded in the NullifierRegistry. This prevents the same commitment from being revealed twice.
6. Vault Mints Tokens
The vault calls NativeAssetHandler.mintNativeTo(recipient, amount). The NativeAssetHandler forwards this to the ghostmint precompile, which:
- Verifies the caller is the authorized NativeAssetHandler
- Checks
netSupply + amount <= MaxMintSupply - Calls
x/bank.MintCoins()to create fresh tokens in the ghostmint module account - Calls
x/bank.SendCoinsFromModuleToAccount()to send tokens to the recipient - Increments
totalMintedin the KVStore
7. State After Reveal
| What Happened | Where |
|---|---|
| GHOST tokens created | x/bank total supply increased |
| Nullifier recorded | NullifierRegistry (prevents double-reveal) |
| Recipient received GHOST | Recipient's account balance |
| Event emitted | Revealed(token, recipient, nullifier, amount) |
Virtual Tokens
The key insight of this design is that tokens in the Merkle tree are virtual. Between commit and reveal, the tokens do not exist as a balance held by any account or contract. They exist only as Poseidon commitment hashes -- mathematical objects with no corresponding token balance.
This has a critical consequence: there is no custodial pool. Unlike pool-based privacy protocols (e.g., Tornado Cash) where tokens are deposited into a contract, the Specter protocol holds zero token balance. There is no contract that can be:
- Frozen by a regulator (no funds to freeze)
- Sanctioned as a custodian (no custody occurs)
- Drained by an exploit (no balance to drain)
The total supply of GHOST decreases when commitments are made and increases when commitments are revealed. The conservation invariant (totalRevealed <= totalCommitted per token) ensures that reveals never exceed commits.
Partial Reveals and Change Commitments
The redemption circuit supports partial reveals. If a user committed 100 GHOST but wants to reveal only 60, the remaining 40 are automatically committed as a new "change commitment" in the same transaction:
- Reveal 60 GHOST to the recipient (fresh tokens minted)
- Create a new commitment for the remaining 40 GHOST (recorded in the tree)
- The original commitment's nullifier is spent (preventing re-use)
- The change commitment gets its own new phantom key
The changeCommitment is publicInputs[4]. If it is non-zero, the vault records it as a new leaf in the Merkle tree. This enables exact-amount transfers without requiring the committed amount to match the transfer amount.
Solvency Invariant
At the smart contract level, the vault tracks:
totalRevealed[token] <= totalCommitted[token]
This ensures the vault never mints more tokens than were burned. Combined with the consensus-level supply invariant (checked every block), this provides two independent layers of supply conservation enforcement.