Skip to main content

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

  1. Deploy OneTimeAccessToken contract pointing to vault
  2. Generate random secrets off-chain
  3. Compute dataHash matching contract's computeDataHash exactly
  4. Compute commitment using Poseidon formula
  5. Call vault.commitWithCallback(commitment, app, data) with 0.001 ETH
  6. Save the token (secrets + leafIndex) securely
  7. Wait for relayer to update Merkle root
  8. Compute nullifier using formula
  9. Generate ZK proof (or use mock for testnet)
  10. Call vault.revealWithCallback(proof, nullifier, root, app, data, recipient)
  11. Verify application state was updated

Next Steps