Skip to main content

Reveal Flow

The reveal operation proves knowledge of a commitment's preimage using a Groth16 ZK proof and mints fresh tokens to the recipient.

The reveal function

interface ICommitRevealVault {
function reveal(
address token,
uint256[8] calldata proof,
uint256[] calldata publicInputs,
bytes32 commitment,
bytes calldata quantumProof,
bytes32 changeQuantumCommitment,
bytes calldata policyParams
) external;
}

Parameters

ParameterDescription
tokenToken address (or address(0) for native GHOST)
proofGroth16 proof (8 uint256 values = 3 EC points)
publicInputs8 public circuit inputs (see below)
commitmentThe original commitment being revealed
quantumProofQuantum resistance proof (empty bytes if unused)
changeQuantumCommitmentQuantum commitment for change (zero hash if unused)
policyParamsABI-encoded policy parameters (empty if no policy)

Public inputs (8 values)

IndexFieldDescription
0rootMerkle tree root the proof was generated against
1nullifierDerived nullifier (prevents double-reveal)
2withdrawAmountAmount being withdrawn (in aghost)
3recipientAddress receiving the minted tokens
4changeCommitmentNew commitment for remaining funds
5tokenIdToken identifier hash
6policyIdPolicy contract address (0 if none)
7policyParamsHashHash of policy parameters (0 if none)

Step-by-step reveal process

1. Generate the ZK proof

Using the proof relayer:

curl -X POST https://relayer.specterchain.com/api/proof/generate \
-H "Content-Type: application/json" \
-H "X-HMAC-Signature: $HMAC_SIG" \
-d '{
"secret": "0x...",
"nullifierSecret": "0x...",
"amount": "1000000000000000000",
"blinding": "0x...",
"tokenIdHash": "0x...",
"recipient": "0xRECIPIENT_ADDRESS",
"withdrawAmount": "1000000000000000000",
"newBlinding": "0x..."
}'

Or generate client-side with snarkjs (see Generating Proofs).

2. Submit the reveal transaction

const vault = new ethers.Contract(
'0x443434113980Ab9d5Eef0Ace7d1A29AB68Af6c70',
['function reveal(address,uint256[8],uint256[],bytes32,bytes,bytes32,bytes)'],
signer
);

const tx = await vault.reveal(
ethers.ZeroAddress, // native GHOST
proof, // 8 uint256 values from proof generation
publicInputs, // 8 public inputs
originalCommitment, // the commitment being revealed
'0x', // no quantum proof
ethers.ZeroHash, // no quantum change commitment
'0x', // no policy params
{ gasPrice: 1_000_000_000n }
);
await tx.wait();

What happens on-chain

  1. Root check — verifies publicInputs[0] (root) is in the commitment tree's history window (last 100 roots)
  2. Nullifier check — verifies publicInputs[1] (nullifier) has not been spent
  3. Proof verification — calls the Groth16ProofVerifier to verify the proof against public inputs
  4. Policy validation — if the commitment has a policy, calls policy.validate() via staticcall with 100K gas cap
  5. Nullifier registration — marks the nullifier as spent in the NullifierRegistry
  6. Token minting — calls the Ghostmint precompile to mint fresh tokens to the recipient
  7. Change commitment — if changeCommitment != 0, inserts it into the tree for the remaining balance
  8. Event emission — emits Revealed(commitment, token, amount, recipient)

Partial reveals and change

You don't have to reveal the full committed amount. For example, if you committed 10 GHOST, you can reveal 3 GHOST and create a change commitment for 7 GHOST:

  • withdrawAmount = 3 GHOST
  • changeCommitment = Poseidon hash of a new commitment with 7 GHOST
  • The circuit verifies: withdrawAmount + changeAmount == originalAmount

The change commitment goes back into the tree and can be revealed later with a new proof.

Common errors

ErrorCause
InvalidRootThe root used in the proof is no longer in the history window. Generate a fresh proof.
NullifierAlreadySpentThis commitment has already been revealed.
InvalidProofThe proof is invalid — check inputs match the commitment preimage exactly.
PolicyValidationFailedThe reveal policy's validate() returned false.
InvalidAmountWithdraw amount doesn't match the proof's public inputs.