Skip to main content

Creating UTXOs

UTXOs are the fundamental building blocks for private transactions. Each UTXO represents an unspent output with an amount and cryptographic commitments.

Empty UTXO

Create an empty UTXO (used as placeholder in transactions):
import { Utxo } from '@privacy-cash/sdk';
import { WasmFactory } from '@lightprotocol/hasher.rs';
import BN from 'bn.js';

const lightWasm = await WasmFactory.getInstance();

const emptyUtxo = new Utxo({ lightWasm });
// Amount defaults to 0

UTXO with Amount

Create a UTXO with a specific amount:
const utxo = new Utxo({
  lightWasm,
  amount: new BN(1000000), // 0.001 SOL (in lamports)
});

SPL Token UTXO

Create a UTXO for SPL tokens:
import { PublicKey } from '@solana/web3.js';

const usdcMint = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');

const splUtxo = new Utxo({
  lightWasm,
  amount: new BN(1000000), // 1 USDC (6 decimals)
  mintAddress: usdcMint.toBase58(),
});

UTXO Properties

Access UTXO properties:
console.log('Amount:', utxo.amount.toString());
console.log('Public Key:', utxo.keypair.pubkey.toString());
console.log('Blinding:', utxo.blinding.toString());
console.log('Index:', utxo.index);
console.log('Mint:', utxo.mintAddress);

Generating Commitments

Commitments are Poseidon hashes that hide UTXO details while allowing verification:
const commitment = await utxo.getCommitment();
console.log('Commitment:', commitment);
// Output: "12345678901234567890123456789012" (32-byte hash as string)
The commitment formula:
commitment = PoseidonHash(amount, pubkey, blinding, mintAddress)

Generating Nullifiers

Nullifiers mark UTXOs as spent, preventing double-spending:
const nullifier = await utxo.getNullifier();
console.log('Nullifier:', nullifier);
// Output: "98765432109876543210987654321098" (32-byte hash as string)
The nullifier formula:
signature = PoseidonSign(privateKey, commitment, index)
nullifier = PoseidonHash(commitment, index, signature)
Nullifiers are unique per UTXO and must be tracked to prevent double-spending.

Building Merkle Trees

Merkle trees track all commitments in the privacy pool:
import { MerkleTree } from '@privacy-cash/sdk';

const DEFAULT_HEIGHT = 26; // Tree depth
const merkleTree = new MerkleTree(DEFAULT_HEIGHT, lightWasm);

// Insert commitments
const commitment1 = await utxo1.getCommitment();
const commitment2 = await utxo2.getCommitment();

merkleTree.insert(commitment1);
merkleTree.insert(commitment2);

// Get root
const root = merkleTree.root();
console.log('Merkle root:', root);

// Get Merkle path for a commitment
const index = merkleTree.indexOf(commitment1);
const path = merkleTree.path(index);
console.log('Path elements:', path.pathElements);
console.log('Path indices:', path.pathIndices);

Merkle Path Structure

From test files at ~/workspace/source/anchor/tests/sol_tests.ts:966-982:
const withdrawalInputMerklePathIndices = [];
const withdrawalInputMerklePathElements = [];

for (let i = 0; i < inputs.length; i++) {
  const input = inputs[i];
  if (input.amount.gt(new BN(0))) {
    // For non-empty UTXOs, get the actual Merkle path
    const commitment = outputCommitments[i];
    input.index = merkleTree.indexOf(commitment);
    
    withdrawalInputMerklePathIndices.push(input.index);
    withdrawalInputMerklePathElements.push(merkleTree.path(input.index).pathElements);
  } else {
    // For empty UTXOs, use zero path
    withdrawalInputMerklePathIndices.push(0);
    withdrawalInputMerklePathElements.push(new Array(merkleTree.levels).fill(0));
  }
}

Calculating External Data Hash

The external data hash binds transaction metadata to the proof:
import { getExtDataHash } from '@privacy-cash/sdk';
import { PublicKey } from '@solana/web3.js';
import BN from 'bn.js';

const extData = {
  recipient: new PublicKey('RecipientAddress...'),
  extAmount: new BN(1000000), // Positive for deposits, negative for withdrawals
  encryptedOutput1: Buffer.from('encrypted_data_1'),
  encryptedOutput2: Buffer.from('encrypted_data_2'),
  fee: new BN(3500), // 0.35% of withdrawal amount
  feeRecipient: new PublicKey('AWexibGxNFKTa1b5R5MN4PJr9HWnWRwf8EW9g8cLx3dM'),
  mintAddress: new PublicKey('11111111111111111111111111111112'), // SOL
};

const extDataHash = getExtDataHash(extData);
console.log('ExtData Hash:', Buffer.from(extDataHash).toString('hex'));

External Data Structure

FieldTypeDescription
recipientPublicKeyDestination address for funds
extAmountBNPositive for deposits, negative for withdrawals
encryptedOutput1BufferEncrypted UTXO data for output 1
encryptedOutput2BufferEncrypted UTXO data for output 2
feeBNTransaction fee amount
feeRecipientPublicKeyFee recipient address
mintAddressPublicKeyToken mint address

Generating Zero-Knowledge Proofs

Proofs verify transaction validity without revealing details:
import { prove } from '@privacy-cash/sdk';
import path from 'path';

// Prepare circuit inputs
const circuitInput = {
  root: merkleTree.root(),
  publicAmount: new BN(1000000).toString(),
  extDataHash: extDataHash,
  mintAddress: '11111111111111111111111111111112',
  
  inputNullifier: await Promise.all(inputs.map(x => x.getNullifier())),
  inAmount: inputs.map(x => x.amount.toString(10)),
  inPrivateKey: inputs.map(x => x.keypair.privkey),
  inBlinding: inputs.map(x => x.blinding.toString(10)),
  inPathIndices: inputMerklePathIndices,
  inPathElements: inputMerklePathElements,
  
  outputCommitment: await Promise.all(outputs.map(x => x.getCommitment())),
  outAmount: outputs.map(x => x.amount.toString(10)),
  outBlinding: outputs.map(x => x.blinding.toString(10)),
  outPubkey: outputs.map(x => x.keypair.pubkey),
};

// Generate proof
const keyBasePath = path.resolve('./circuits/transaction2');
const { proof, publicSignals } = await prove(circuitInput, keyBasePath);

Parse Proof for On-Chain Submission

From test files at ~/workspace/source/anchor/tests/sol_tests.ts:772-791:
import { parseProofToBytesArray, parseToBytesArray } from '@privacy-cash/sdk';

const proofInBytes = parseProofToBytesArray(proof);
const inputsInBytes = parseToBytesArray(publicSignals);

const proofToSubmit = {
  proofA: proofInBytes.proofA,
  proofB: proofInBytes.proofB.flat(),
  proofC: proofInBytes.proofC,
  root: inputsInBytes[0],
  publicAmount: inputsInBytes[1],
  extDataHash: inputsInBytes[2],
  inputNullifiers: [
    inputsInBytes[3],
    inputsInBytes[4]
  ],
  outputCommitments: [
    inputsInBytes[5],
    inputsInBytes[6]
  ],
};

Calculating Fees

Fee calculation from test files at ~/workspace/source/anchor/tests/sol_tests.ts:22-38:
const DEPOSIT_FEE_RATE = 0; // 0% - Free deposits
const WITHDRAW_FEE_RATE = 35; // 0.35%

function calculateFee(amount: number, feeRate: number): number {
  return Math.floor((amount * feeRate) / 10000);
}

function calculateDepositFee(amount: number): number {
  return calculateFee(amount, DEPOSIT_FEE_RATE); // Returns 0
}

function calculateWithdrawalFee(amount: number): number {
  return calculateFee(amount, WITHDRAW_FEE_RATE);
}

// Example: Withdraw 1,000,000 lamports (0.001 SOL)
const withdrawAmount = 1000000;
const fee = calculateWithdrawalFee(withdrawAmount);
console.log('Withdrawal fee:', fee); // 3,500 lamports (0.0000035 SOL)

Finding Program-Derived Addresses

Nullifier accounts use PDAs to prevent double-spending:
import { PublicKey } from '@solana/web3.js';

function findNullifierPDAs(programId: PublicKey, proof: any) {
  const [nullifier0PDA] = PublicKey.findProgramAddressSync(
    [Buffer.from('nullifier0'), Buffer.from(proof.inputNullifiers[0])],
    programId
  );
  
  const [nullifier1PDA] = PublicKey.findProgramAddressSync(
    [Buffer.from('nullifier1'), Buffer.from(proof.inputNullifiers[1])],
    programId
  );
  
  return { nullifier0PDA, nullifier1PDA };
}

// Cross-check nullifiers (anti-double-spend protection)
function findCrossCheckNullifierPDAs(programId: PublicKey, proof: any) {
  const [nullifier2PDA] = PublicKey.findProgramAddressSync(
    [Buffer.from('nullifier0'), Buffer.from(proof.inputNullifiers[1])],
    programId
  );

  const [nullifier3PDA] = PublicKey.findProgramAddressSync(
    [Buffer.from('nullifier1'), Buffer.from(proof.inputNullifiers[0])],
    programId
  );

  return { nullifier2PDA, nullifier3PDA };
}
The cross-check nullifiers prevent attackers from reusing the same UTXO in different input positions.

Constants and Configuration

From ~/workspace/source/anchor/tests/lib/constants.ts:
import { PublicKey } from '@solana/web3.js';
import BN from 'bn.js';

export const ROOT_HISTORY_SIZE = 100;
export const DEFAULT_HEIGHT = 26; // Merkle tree depth
export const FIELD_SIZE = new BN(
  '21888242871839275222246405745257275088548364400416034343698204186575808495617'
);

export const FEE_RECIPIENT_ACCOUNT = new PublicKey(
  'AWexibGxNFKTa1b5R5MN4PJr9HWnWRwf8EW9g8cLx3dM'
);

export const DEPOSIT_FEE_RATE = 0; // 0%
export const WITHDRAW_FEE_RATE = 35; // 0.35%

// SOL mint address constant
const SOL_ADDRESS = new PublicKey('11111111111111111111111111111112');

Next Steps

Examples

View complete transaction examples

API Reference

Explore detailed API documentation

Build docs developers (and LLMs) love