Minimum Viable Build: One-Time Access Token
This section shows one complete application. Follow it exactly.
Use Case
A one-time access token. The committer creates a token. Anyone with the token can redeem it exactly once to grant access to a recipient address.
Step 1: Deploy the Application Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IGhostApplication} from "./interfaces/IGhostApplication.sol";
contract OneTimeAccessToken is IGhostApplication {
address public immutable vault;
// Track which dataHashes have been redeemed
mapping(bytes32 => bool) public accessGranted;
mapping(bytes32 => address) public accessRecipient;
event AccessGranted(bytes32 indexed dataHash, address indexed recipient);
constructor(address _vault) {
vault = _vault;
}
modifier onlyVault() {
require(msg.sender == vault, "Only vault");
_;
}
// Called BEFORE commitment is recorded
// Use this to validate or lock resources
function onCommit(address user, bytes32 dataHash, bytes calldata data) external onlyVault {
// For access tokens, we don't need to lock anything
// Just validate the data format if needed
require(data.length == 32, "Data must be 32-byte access code");
}
// Called AFTER proof is verified and BEFORE nullifier is recorded
// This WILL execute if the proof is valid
function onReveal(address recipient, bytes32 dataHash, bytes calldata data) external onlyVault {
require(!accessGranted[dataHash], "Access already granted");
accessGranted[dataHash] = true;
accessRecipient[dataHash] = recipient;
emit AccessGranted(dataHash, recipient);
}
// MUST match exactly how you compute dataHash off-chain
function computeDataHash(bytes calldata data) external pure returns (bytes32) {
require(data.length == 32, "Data must be 32-byte access code");
bytes32 accessCode = abi.decode(data, (bytes32));
// Using keccak256 mod field for simplicity.
// NOTE: If your circuit enforces Poseidon(data), you MUST use Poseidon off-chain.
return bytes32(uint256(keccak256(abi.encode(accessCode))) %
21888242871839275222246405745257275088548364400416034343698204186575808495617);
}
function canCommit(address, bytes32) external pure returns (bool) {
return true;
}
function applicationInfo() external pure returns (string memory, string memory) {
return ("OneTimeAccessToken", "1.0.0");
}
// Check if someone has access
function hasAccess(bytes32 dataHash, address addr) external view returns (bool) {
return accessGranted[dataHash] && accessRecipient[dataHash] == addr;
}
}
Step 2: Create an Access Token (Off-Chain + Commit)
import { createWalletClient, createPublicClient, http, parseEther } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { buildPoseidon } from 'circomlibjs';
const VAULT_ADDRESS = '0x9A8F98916d324153B61F1C4B6D7d85F52988b3b1';
const APP_ADDRESS = '<your deployed OneTimeAccessToken address>';
const RPC_URL = 'https://testnet-rpc.umbraline.com';
const FIELD_SIZE = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
async function createAccessToken() {
const poseidon = await buildPoseidon();
const F = poseidon.F;
// STEP 2a: Generate random secrets (SAVE THESE)
const secret = crypto.getRandomValues(new Uint8Array(32));
const nullifierSecret = crypto.getRandomValues(new Uint8Array(32));
const blinding = crypto.getRandomValues(new Uint8Array(32));
const accessCode = crypto.getRandomValues(new Uint8Array(32)); // The "password"
// Convert to bigints for Poseidon
const secretBn = BigInt('0x' + Buffer.from(secret).toString('hex')) % FIELD_SIZE;
const nullifierSecretBn = BigInt('0x' + Buffer.from(nullifierSecret).toString('hex')) % FIELD_SIZE;
const blindingBn = BigInt('0x' + Buffer.from(blinding).toString('hex')) % FIELD_SIZE;
// STEP 2b: Compute dataHash (MUST match contract's computeDataHash)
const accessCodeHex = '0x' + Buffer.from(accessCode).toString('hex');
const dataHashRaw = BigInt(keccak256(encodePacked(accessCodeHex)));
const dataHash = dataHashRaw % FIELD_SIZE;
// STEP 2c: Compute commitment
const inner1 = F.toString(poseidon([secretBn, nullifierSecretBn]));
const inner2 = F.toString(poseidon([dataHash, blindingBn]));
const commitment = '0x' + BigInt(F.toString(poseidon([BigInt(inner1), BigInt(inner2)]))).toString(16).padStart(64, '0');
// STEP 2d: Call commitWithCallback on-chain
const account = privateKeyToAccount('<your private key>');
const walletClient = createWalletClient({
account,
chain: { id: 47474, name: 'Umbraline Testnet', rpcUrls: { default: { http: [RPC_URL] } } },
transport: http(RPC_URL),
});
const appData = encodePacked(['bytes32'], [accessCodeHex]); // 32 bytes
const txHash = await walletClient.writeContract({
address: VAULT_ADDRESS,
abi: VAULT_ABI,
functionName: 'commitWithCallback',
args: [commitment, APP_ADDRESS, appData],
value: parseEther('0.001'), // MIN_COMMITMENT_FEE
});
// STEP 2e: Get leafIndex from event
const publicClient = createPublicClient({ transport: http(RPC_URL) });
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
const committedEvent = receipt.logs.find(log => /* parse Committed event */);
const leafIndex = committedEvent.args.leafIndex;
// STEP 2f: SAVE THE TOKEN (this is what you give to the redeemer)
const token = {
secret: '0x' + Buffer.from(secret).toString('hex'),
nullifierSecret: '0x' + Buffer.from(nullifierSecret).toString('hex'),
blinding: '0x' + Buffer.from(blinding).toString('hex'),
accessCode: accessCodeHex,
dataHash: '0x' + dataHash.toString(16).padStart(64, '0'),
commitment,
leafIndex: Number(leafIndex),
appAddress: APP_ADDRESS,
};
console.log('ACCESS TOKEN (save this securely):');
console.log(JSON.stringify(token, null, 2));
return token;
}
Step 3: Wait for Root Update
The commitment must be included in a Merkle root before it can be revealed. Root updates typically occur within the next block or shortly after.
async function waitForRootUpdate(leafIndex: number): Promise<string> {
const publicClient = createPublicClient({ transport: http(RPC_URL) });
// Poll until leafIndex is included in a known root
while (true) {
const leafCount = await publicClient.readContract({
address: '0xe382a7C7a5CE3B9250D73aE6ab97931E5798e6F7', // CommitmentTree
abi: TREE_ABI,
functionName: 'leafCount',
});
const currentRoot = await publicClient.readContract({
address: '0xe382a7C7a5CE3B9250D73aE6ab97931E5798e6F7',
abi: TREE_ABI,
functionName: 'currentRoot',
});
// Root is updated when leafCount includes our commitment
if (Number(leafCount) > leafIndex) {
return currentRoot;
}
console.log(`Waiting for root update. Current leaves: ${leafCount}, need: ${leafIndex + 1}`);
await new Promise(r => setTimeout(r, 5000)); // Wait 5 seconds
}
}
Step 4: Redeem the Access Token (Generate Proof + Reveal)
async function redeemAccessToken(token: AccessToken, recipientAddress: string) {
const poseidon = await buildPoseidon();
const F = poseidon.F;
// STEP 4a: Compute nullifier
const intermediate = F.toString(poseidon([
BigInt(token.nullifierSecret),
BigInt(token.commitment)
]));
const nullifier = '0x' + BigInt(F.toString(poseidon([
BigInt(intermediate),
BigInt(token.leafIndex)
]))).toString(16).padStart(64, '0');
// STEP 4b: Check nullifier isn't already spent
const publicClient = createPublicClient({ transport: http(RPC_URL) });
const isSpent = await publicClient.readContract({
address: '0x03972E4453fD143A5203602020DbE2f7DcF6e0db', // NullifierRegistry
abi: REGISTRY_ABI,
functionName: 'isSpent',
args: [nullifier],
});
if (isSpent) {
throw new Error('Token already redeemed');
}
// STEP 4c: Get a valid root
const root = await publicClient.readContract({
address: '0xe382a7C7a5CE3B9250D73aE6ab97931E5798e6F7',
abi: TREE_ABI,
functionName: 'currentRoot',
});
// STEP 4d: Generate ZK proof (see Proof Generation docs)
// For testnet with mock verifier, use passthrough proof
const proof = {
a: [0n, 0n],
b: [[0n, 0n], [0n, 0n]],
c: [0n, 0n],
};
// STEP 4e: Call revealWithCallback
const account = privateKeyToAccount('<redeemer private key>');
const walletClient = createWalletClient({
account,
chain: { id: 47474, name: 'Umbraline Testnet', rpcUrls: { default: { http: [RPC_URL] } } },
transport: http(RPC_URL),
});
const appData = encodePacked(['bytes32'], [token.accessCode]);
const txHash = await walletClient.writeContract({
address: VAULT_ADDRESS,
abi: VAULT_ABI,
functionName: 'revealWithCallback',
args: [proof, nullifier, root, token.appAddress, appData, recipientAddress],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
console.log('Access granted! TX:', txHash);
// STEP 4f: Verify access was granted
const hasAccess = await publicClient.readContract({
address: token.appAddress,
abi: APP_ABI,
functionName: 'hasAccess',
args: [token.dataHash, recipientAddress],
});
console.log('Access verified:', hasAccess);
}
Testnet Only
The mock verifier accepts zero proofs. For production, you must generate real Groth16 proofs. See Proof Generation.
Complete Flow Summary
- Deploy
OneTimeAccessTokencontract pointing to vault - Generate random secrets off-chain
- Compute
dataHashmatching contract'scomputeDataHashexactly - Compute
commitmentusing Poseidon formula - Call
vault.commitWithCallback(commitment, app, data)with 0.001 ETH - Save the token (secrets + leafIndex) securely
- Wait for relayer to update Merkle root
- Compute
nullifierusing formula - Generate ZK proof (or use mock for testnet)
- Call
vault.revealWithCallback(proof, nullifier, root, app, data, recipient) - Verify application state was updated
Next Steps
- Safety Rules - Critical rules for building applications
- IGhostApplication Interface - Full interface reference
- Proof Generation - Generate real proofs for production