Skip to main content

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 blinding factor (254-bit)
  • The tokenId = Poseidon2(tokenAddress, 0) (for native GHOST, tokenAddress = address(0))
  • The amount in aghost
  • Optional policyId and policyParamsHash

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:

  1. Verifies the caller is the authorized NativeAssetHandler
  2. Calls x/bank.SendCoinsFromAccountToModule() to move tokens from the precompile address to the ghostmint module account
  3. Calls x/bank.BurnCoins() to destroy the tokens from total supply
  4. Increments totalBurned in 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 HappenedWhere
GHOST tokens destroyedx/bank total supply decreased
Commitment hash storedCommitmentTree leaf array
Depositor recordedcommitmentDepositors[commitment] = msg.sender
Native committed total updated_totalNativeCommitted += msg.value
Event emittedCommitted(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, and policyParamsHash match 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:

  1. Public input count: Exactly 8 public inputs required
  2. Token ID match: publicInputs[5] must match the expected token ID for the specified token
  3. Merkle root validity: publicInputs[0] must be a known root in the CommitmentTree
  4. Nullifier unspent: publicInputs[1] must not exist in the NullifierRegistry
  5. 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):

  1. Compute keccak256(policyParams) % BN254_SCALAR_FIELD
  2. Verify it matches publicInputs[7]
  3. Verify the policy address has deployed code
  4. Call policy.validate() via staticcall with 100K gas
  5. 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:

  1. Verifies the caller is the authorized NativeAssetHandler
  2. Checks netSupply + amount <= MaxMintSupply
  3. Calls x/bank.MintCoins() to create fresh tokens in the ghostmint module account
  4. Calls x/bank.SendCoinsFromModuleToAccount() to send tokens to the recipient
  5. Increments totalMinted in the KVStore

7. State After Reveal

What HappenedWhere
GHOST tokens createdx/bank total supply increased
Nullifier recordedNullifierRegistry (prevents double-reveal)
Recipient received GHOSTRecipient's account balance
Event emittedRevealed(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:

  1. Reveal 60 GHOST to the recipient (fresh tokens minted)
  2. Create a new commitment for the remaining 40 GHOST (recorded in the tree)
  3. The original commitment's nullifier is spent (preventing re-use)
  4. 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.