Skip to content
Launch App >

This guide walks through a complete private transfer flow: authentication, OFAC compliance, depositing tokens, and withdrawing to a different address.

// Required packages
import { Connection, PublicKey, Transaction } from '@solana/web3.js';
import { useWallet } from '@solana/wallet-adapter-react';
import bs58 from 'bs58';
const CONFIG = {
// API endpoints
API_URL: 'https://worker.turbine.cash',
// Program addresses
PROGRAM_ID: 'FGKoWNsvTTDCGW9JyR2DJWNzSXpejWk7yXcKsFFj9GQp',
// Merkle tree depth (only 32 supported)
DEPTH: 32,
// Network
NETWORK: 'mainnet', // or 'devnet'
// Token mints
USDC_MINT: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
};

Sign a timestamp with your wallet to authenticate with the relay API.

interface LoginRequest {
wallet: string;
signature: string;
message: number;
}
interface LoginResponse {
id: string; // UUID auth token
}
async function login(wallet: WalletContextState): Promise<string> {
// Generate timestamp (Unix seconds)
const message = Math.floor(Date.now() / 1000);
// Sign the timestamp
const messageBytes = new TextEncoder().encode(String(message));
const signatureBytes = await wallet.signMessage!(messageBytes);
const signature = bs58.encode(signatureBytes);
// Call login endpoint
const response = await fetch(`${CONFIG.API_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wallet: wallet.publicKey!.toBase58(),
signature,
message,
} as LoginRequest),
});
if (!response.ok) {
throw new Error(`Login failed: ${await response.text()}`);
}
const { id } = await response.json() as LoginResponse;
return id; // This is your auth token
}

Before depositing, obtain an OFAC compliance signature from the server.

interface OfacSignature {
signature: number[]; // 64-byte Ed25519 signature
message: number; // Block timestamp
}
async function getOfacSignature(authToken: string): Promise<OfacSignature> {
const response = await fetch(
`${CONFIG.API_URL}/get_ofac_signature/${CONFIG.NETWORK}/${authToken}`,
{ method: 'POST' }
);
if (!response.ok) {
const error = await response.text();
if (error.includes('high risk')) {
throw new Error('Wallet flagged by OFAC screening. Cannot proceed.');
}
throw new Error(`OFAC check failed: ${error}`);
}
return await response.json();
}

// Types for deposit data
interface DepositCredentials {
id: string;
secret: bigint;
nullifier: bigint;
index: number;
mint: string;
amount: number;
depth: number;
network: string;
timestamp: number;
}
// Generate random 256-bit values
function generateRandomBigInt(): bigint {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return BigInt('0x' + Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''));
}
async function generateCommitment(): Promise<{
secret: bigint;
nullifier: bigint;
commitment: Uint8Array;
}> {
// Generate random secret and nullifier
const secret = generateRandomBigInt();
const nullifier = generateRandomBigInt();
// Compute commitment = Poseidon(secret, nullifier)
// Note: Use the Poseidon implementation that matches the circuit
const commitment = await poseidonHash([secret, nullifier]);
return { secret, nullifier, commitment };
}
async function deposit(
wallet: WalletContextState,
connection: Connection,
mint: string,
amount: number,
ofacSignature: OfacSignature,
): Promise<{ txSignature: string; credentials: DepositCredentials }> {
// Generate commitment
const { secret, nullifier, commitment } = await generateCommitment();
// Get current merkle tree state to find next leaf index
const merkleState = await getMerkleState(mint, CONFIG.DEPTH);
const index = merkleState.nextIndex;
// Generate deposit proof (Groth16)
// This proves you know the preimage of the commitment
const depositProof = await generateDepositProof({
secret,
nullifier,
commitment,
// ... other circuit inputs
});
// Build deposit instruction
const depositInstruction = await buildDepositInstruction({
signer: wallet.publicKey!,
mint: new PublicKey(mint),
index,
commitment: Array.from(commitment),
depositSize: amount,
signature: ofacSignature.signature,
message: ofacSignature.message,
proof: depositProof,
depth: CONFIG.DEPTH,
});
// Create and send transaction
const transaction = new Transaction().add(depositInstruction);
transaction.feePayer = wallet.publicKey!;
transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
const signedTx = await wallet.signTransaction!(transaction);
const txSignature = await connection.sendRawTransaction(signedTx.serialize());
await connection.confirmTransaction(txSignature);
// Save credentials locally
const credentials: DepositCredentials = {
id: crypto.randomUUID(),
secret,
nullifier,
index,
mint,
amount,
depth: CONFIG.DEPTH,
network: CONFIG.NETWORK,
timestamp: Date.now(),
};
saveDepositLocally(credentials);
return { txSignature, credentials };
}
function saveDepositLocally(credentials: DepositCredentials): void {
// Store in encrypted localStorage
const existing = JSON.parse(localStorage.getItem('turbine_deposits') || '[]');
existing.push({
...credentials,
secret: credentials.secret.toString(),
nullifier: credentials.nullifier.toString(),
});
localStorage.setItem('turbine_deposits', JSON.stringify(existing));
}

After waiting for your deposit to join the anonymity set (recommended: wait for several other deposits), you can withdraw.

function loadDeposit(depositId: string): DepositCredentials | null {
const deposits = JSON.parse(localStorage.getItem('turbine_deposits') || '[]');
const deposit = deposits.find((d: any) => d.id === depositId);
if (!deposit) return null;
return {
...deposit,
secret: BigInt(deposit.secret),
nullifier: BigInt(deposit.nullifier),
};
}
async function withdraw(
depositId: string,
recipientAddress: string,
): Promise<string> {
// Load deposit credentials
const deposit = loadDeposit(depositId);
if (!deposit) {
throw new Error('Deposit not found');
}
// Compute nullifier hash
const nullifierHash = await poseidonHash([deposit.nullifier]);
// Check if already spent
const isSpent = await checkNullifierSpent(
deposit.mint,
deposit.depth,
nullifierHash,
);
if (isSpent) {
throw new Error('Deposit already withdrawn');
}
// Fetch merkle proof from indexer
const merkleProof = await getMerkleProof(
deposit.mint,
deposit.depth,
deposit.index,
);
// Generate withdrawal proof (Groth16)
// Proves: "I know secret & nullifier for a commitment in this merkle tree"
const withdrawProof = await generateWithdrawProof({
secret: deposit.secret,
nullifier: deposit.nullifier,
pathElements: merkleProof.pathElements,
pathIndices: merkleProof.pathIndices,
root: merkleProof.root,
recipient: recipientAddress,
nullifierHash,
});
// Submit to relay
const response = await fetch(`${CONFIG.API_URL}/relay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nullifierHash: Array.from(nullifierHash),
proof: Array.from(withdrawProof),
root: Array.from(merkleProof.root),
recipient: recipientAddress,
depth: deposit.depth,
depositSize: deposit.amount,
mint: deposit.mint,
network: CONFIG.NETWORK,
}),
});
const result = await response.json();
if (result.status !== 200) {
throw new Error(`Withdrawal failed: ${result.error}`);
}
// Mark deposit as spent locally
markDepositAsSpent(depositId);
return result.signature;
}
async function getMerkleState(mint: string, depth: number) {
const response = await fetch(
`${CONFIG.API_URL}/get_merkle/${mint}/${depth}/${CONFIG.NETWORK}`,
{ method: 'POST' }
);
const { json } = await response.json();
return json;
}
async function getMerkleProof(mint: string, depth: number, index: number) {
const response = await fetch(
`${CONFIG.API_URL}/get_proof/${mint}/${depth}/${index}/${CONFIG.NETWORK}`,
{ method: 'POST' }
);
const { json } = await response.json();
return {
pathElements: json.path,
pathIndices: json.indices,
root: await getCurrentRoot(mint, depth),
};
}
async function checkNullifierSpent(
mint: string,
depth: number,
nullifierHash: Uint8Array,
): Promise<boolean> {
const response = await fetch(
`${CONFIG.API_URL}/get_nullifier/${mint}/${depth}/${CONFIG.NETWORK}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nullifierHash: Array.from(nullifierHash) }),
}
);
const { json } = await response.json();
return json !== null;
}

async function privateTransfer(
wallet: WalletContextState,
connection: Connection,
amount: number,
recipientAddress: string,
) {
console.log('1. Logging in...');
const authToken = await login(wallet);
console.log('2. Getting OFAC signature...');
const ofacSignature = await getOfacSignature(authToken);
console.log('3. Depositing...');
const { txSignature, credentials } = await deposit(
wallet,
connection,
CONFIG.USDC_MINT,
amount,
ofacSignature,
);
console.log(` Deposit tx: ${txSignature}`);
console.log(` Deposit ID: ${credentials.id}`);
console.log('4. Waiting for anonymity set...');
// In production, wait for more deposits to join the pool
// This is crucial for privacy
await new Promise(resolve => setTimeout(resolve, 60000));
console.log('5. Withdrawing...');
const withdrawTx = await withdraw(credentials.id, recipientAddress);
console.log(` Withdraw tx: ${withdrawTx}`);
console.log('Transfer complete!');
}

ErrorCauseSolution
Login failedInvalid signature or stale timestampRe-sign with fresh timestamp
high riskOFAC screening flagged walletCannot use this wallet
NullifierAlreadySpentAlready withdrawnCheck deposit status
InvalidProofProof verification failedVerify secret/nullifier match
InvalidRootMerkle root not recognizedWait for indexer sync, retry
RelayFailedTransaction submission errorCheck Solana network status

  • Secret and nullifier are generated client-side only
  • Credentials are stored encrypted in browser storage
  • Credentials are backed up (e.g., encrypted file download)
  • Never transmit secret/nullifier to any server
  • Wait for sufficient anonymity set before withdrawing
  • Use a fresh wallet address for receiving withdrawals