DOCUMENTATION

Privacy-Proxy: Architecture Decisions

This document captures all key architectural decisions made during the design of Privacy-Proxy, including alternatives considered, why they were rejected, and the final approach chosen.


Table of Contents

  1. Core Privacy Model
  2. Deposit Privacy Strategy
  3. Network Privacy Layer
  4. Wallet Integration
  5. Cryptographic Choices
  6. Relayer Architecture
  7. Recipient Privacy
  8. SDK Security Enforcement
  9. Technology Stack

1. Core Privacy Model

Decision: Complete Sender Untraceability

Goal: After a transaction completes, it should be impossible to trace back to the sender using Solscan or any blockchain explorer.

Key Insight: Network encryption (Tor) hides your IP, NOT your blockchain transactions. Any on-chain transaction from a user’s wallet is visible on Solscan regardless of how it was submitted.

Final Approach: User’s wallet must NEVER appear in any transaction related to the privacy pool deposits.


2. Deposit Privacy Strategy

Decision: Blinded Credits Model

Problem: How do we let users deposit into the privacy pool without their wallet appearing in the deposit transaction?

❌ Rejected: Direct Deposit

User Wallet → Pool

Why rejected: User’s wallet directly visible on-chain. Zero privacy.

❌ Rejected: Shield/Intermediary Account

User Wallet → Shield Account → Pool

Why rejected: User’s wallet still visible in the first transaction (User → Shield). Attacker can trace the chain.

❌ Rejected: Escrow Model (PDA-based)

User Wallet → Escrow PDA → Pool (via Relayer)

Why rejected: Escrow PDA is derived from user’s public key using seeds like [b"escrow", user_pubkey]. This means:

  • Anyone can compute the escrow address from the user’s wallet
  • Direct mathematical link between user and escrow
  • Attacker sees: User funded Escrow X → Escrow X used for Pool deposit → User identified

Critical flaw identified: PDA derivation creates a deterministic, traceable link.

❌ Rejected: Relayer Reimbursement Model

Relayer deposits using own funds → User reimburses relayer later

Why rejected: How does the relayer know WHO to credit? Any identification mechanism creates a link.

✅ Chosen: Blinded Credits with Blind Signatures

Phase 1: User pays relayer + sends blinded token (visible, but UNLINKABLE)
Phase 2: User unblinds token, redeems via Tor (relayer can't link to Phase 1)
Phase 3: Relayer deposits using its own funds (user wallet NEVER in pool TX)

Why this works:

  1. Payment to relayer is visible but looks like any generic service payment
  2. Blind signature cryptographically prevents linking payment to deposit
  3. Relayer signs a blinded token WITHOUT seeing the actual token_id
  4. User unblinds to get valid signature, redeems later via Tor
  5. Relayer verifies signature but CANNOT correlate with any previous payment
  6. User’s wallet NEVER appears in any pool-related transaction

Cryptographic guarantee: Without knowing the blinding factor r, linking the blinded token to the unblinded token is mathematically impossible.


3. Network Privacy Layer

Decision: Tor via Docker with HTTP Gateway

Problem: How do we hide the user’s IP address when communicating with the relayer?

❌ Rejected: Arti Library (Rust Tor implementation)

Why rejected:

  • Adds complexity to the dApp
  • Requires WASM compilation for browser
  • Less mature than official Tor

❌ Rejected: Direct SOCKS5 in Browser

// THIS DOESN'T WORK
fetch(url, { agent: socksProxyAgent })

Why rejected: Browser fetch() API does NOT support the agent option. SOCKS5 proxy agents only work in Node.js, not browsers.

❌ Rejected: Electron App as Primary

Why rejected:

  • Higher barrier to entry for users
  • Requires download and installation
  • Web app is more accessible

✅ Chosen: Backend Proxy Gateway (HTTP → SOCKS5 Bridge)

Browser → HTTP localhost:3080 → Tor Gateway → SOCKS5 :9050 → Tor Network → .onion Relayer

Implementation:

  • Tor runs as Docker container (SOCKS5 on port 9050)
  • Tor Gateway runs as second Docker container (HTTP on port 3080)
  • Gateway accepts HTTP requests, forwards through SOCKS5 to Tor
  • Browser makes standard HTTP requests to localhost
  • Single docker-compose up -d starts everything

Why this works:

  • Works in ANY browser (Chrome, Firefox, Safari)
  • No browser extensions or special configuration needed
  • Standard HTTP requests from browser perspective
  • All complexity hidden in Docker containers

Decision: What Goes Through Tor

ActionThrough Tor?Reason
Connect Phantom wallet❌ NOLocal browser extension
Sign messages❌ NOLocal cryptographic operation
Purchase credits (on-chain TX)❌ NOTX submitted directly, visible anyway
Request deposit from relayer✅ YESHides IP, prevents correlation
Request withdrawal from relayer✅ YESHides IP, prevents correlation
Fetch Merkle proofs✅ YESHides which deposit user is interested in

4. Wallet Integration

Decision: Use Existing Wallets (Phantom/Backpack)

❌ Rejected: Custom Privacy Wallet

Why rejected:

  • Users trust established wallets
  • Security audits already done for Phantom/Backpack
  • Seed phrase management is a solved problem
  • Browser extension ecosystem already exists
  • Building a new wallet is massive scope creep

✅ Chosen: Integrate with Phantom/Backpack

How it works:

  • User connects wallet locally (no Tor needed)
  • For credit purchase: User signs TX directly (visible but unlinkable)
  • For deposits: User sends token via Tor, no wallet signature needed
  • For withdrawals: ZK proof IS the authorization, no wallet signature needed

Key insight: The wallet is only used for the initial credit purchase. All subsequent privacy-critical operations don’t require wallet signatures.


5. Cryptographic Choices

Decision: RSA Blind Signatures (RFC 9474)

❌ Rejected: BLS Blind Signatures

Why rejected: More complex, less library support in TypeScript/browser

❌ Rejected: Schnorr Blind Signatures

Why rejected: Requires more rounds of interaction

✅ Chosen: RSA Blind Signatures

Library: @cloudflare/blindrsa-ts

  • RFC 9474 compliant
  • Works in browser via WebCrypto API
  • Well-tested by Cloudflare

Browser compatibility verified:

OperationChrome Support
Blind (client)✅ Works
Unblind (client)✅ Works
BlindSign (server)✅ Server-side
Verify (server)✅ Server-side

Note: Partially Blind RSA verification doesn’t work in browsers due to large exponent limitations, but we don’t need it - verification happens server-side.

Decision: Groth16 for ZK Proofs

❌ Rejected: PLONK

Why rejected: Larger proof size, higher verification cost on Solana

❌ Rejected: STARKs

Why rejected: Much larger proof size, not practical for on-chain verification

✅ Chosen: Groth16

Library: groth16-solana

  • Verification takes <200,000 compute units on Solana
  • Small proof size (256 bytes)
  • Well-established, battle-tested

Decision: Poseidon Hash Function

❌ Rejected: SHA-256

Why rejected: Not ZK-friendly, expensive in circuits

❌ Rejected: MiMC

Why rejected: Less established, potential security concerns

✅ Chosen: Poseidon (with hybrid on-chain approach)

Libraries:

  • light-poseidon (by Light Protocol) - Used in SDK and relayer
  • circomlibjs - Used in frontend for browser compatibility

Architecture Decision (Security Audit v2):

The light-poseidon crate causes 30KB+ stack usage on Solana BPF, exceeding the 4KB limit. After security audit, we adopted a hybrid approach:

ComponentHash FunctionReason
ZK CircuitsPoseidonZK-friendly, required for proofs
SDK/RelayerPoseidonMust match circuits exactly
On-chain MerkleSHA256Stack-efficient, relayer is authoritative
Binding HashPoseidon (off-chain)Computed in frontend, verified by ZK proof

Key insight: The on-chain program doesn’t need to compute Poseidon hashes. The relayer maintains the authoritative Poseidon-based Merkle tree, and the ZK proof cryptographically guarantees correctness.


6. Relayer Architecture

Decision: Split Relayer Architecture

Problem: A single relayer handling both deposits and withdrawals can correlate timing and patterns.

❌ Rejected: Single Relayer

Why rejected: Can correlate deposit requests with withdrawal requests based on timing, even through Tor.

✅ Chosen: Split Relayers

  • Deposit relayers: Handle credit redemption and pool deposits
  • Withdrawal relayers: Handle withdrawal requests
  • User connects via DIFFERENT Tor circuits for each
  • Relayers don’t share logs

Privacy benefit: Even if one relayer is compromised, it only sees half the picture.

Decision: Relayer Pays All Pool Transaction Fees

Rationale: If user pays fees, their wallet appears as fee_payer on-chain. Relayer must pay all fees for pool-related transactions.

Economics:

User pays: deposit_amount + fee to treasury wallet (e.g., 0.5%)
Treasury receives: amount + fee
Deposit wallet pays: Solana TX fees (~0.000005 SOL)
Relayer operator periodically transfers funds from treasury → deposit wallet (off-chain)
Relayer profit: fee - TX_cost ≈ fee

Decision: Separate Treasury Wallet for Credit Payments (v7.2)

Problem: The transaction tracer revealed that an attacker can trace withdrawal → pool → relayer wallet → incoming payments → user wallets. When the relayer uses the SAME wallet to receive credit payments AND deposit to the pool, the entire trace chain is connected. With a small anonymity set, this reveals the sender even though blind signatures prevent the relayer from linking internally.

❌ Rejected: Same Wallet for Everything

Why rejected: Creates a direct on-chain trace chain from pool deposits back to user credit payments. The blind signature protects against the relayer correlating, but an external attacker tracing the blockchain can walk: pool → deposit wallet → all incoming payments → user wallets.

❌ Rejected: Off-Chain Credit Payments

Why rejected: Requires external payment infrastructure (Stripe, etc.), adds complexity, reduces decentralization.

❌ Rejected: Multiple Rotating Relayer Wallets

Why rejected: Adds operational complexity, still creates trace chains per wallet, harder to manage.

✅ Chosen: Separate Treasury Wallet

Implementation: The relayer operates two wallets:

WalletEnv VarPurposeOn-Chain Activity
Deposit WalletKEYPAIR_PATHSigns pool deposits, pays TX feesDeposit Wallet → Pool (no user link)
Treasury WalletTREASURY_KEYPAIR_PATHReceives credit payments from usersUser → Treasury (visible, unlinkable)

Why this works:

Attacker traces backward from withdrawal:
  withdrawal → pool → deposit wallet → ???
  
  Deposit wallet has NO incoming payments from users.
  Users paid the treasury wallet instead.
  Treasury wallet is a completely separate on-chain address.
  No on-chain link between treasury and deposit wallet.
  Trace chain is broken.

Setup:

# Generate separate treasury wallet
solana-keygen new -o treasury.json

# Run relayer with both wallets
KEYPAIR_PATH=~/.config/solana/id.json \
TREASURY_KEYPAIR_PATH=./treasury.json \
cargo run -p relayer

Backward compatibility: If TREASURY_KEYPAIR_PATH is not set, falls back to main keypair with a warning. NOT recommended for production.

Files changed:

  • crates/relayer/src/config.rs — Added treasury_keypair field
  • crates/relayer/src/server.rs/info returns treasury pubkey, /sign verifies payment against treasury
  • crates/relayer/src/deposit.rs — Unchanged (still uses main keypair for pool deposits)
  • app/src/hooks/useBlindSignature.ts — Unchanged (reads payment address from /info)

7. Recipient Privacy

Decision: Stealth Addresses with Off-Chain Ephemeral Keys

Problem: How do we hide the recipient’s identity?

❌ Rejected: Ephemeral Keys On-Chain

On-chain: stealth_address + ephemeral_pubkey

Why rejected: Publishing ephemeral keys creates a scannable pattern. Anyone can scan the chain and attempt to derive stealth addresses.

✅ Chosen: Ephemeral Keys Off-Chain Only

How it works:

  1. Recipient generates stealth meta-address (spend_pub, view_pub)
  2. Sender generates ephemeral keypair off-chain
  3. Sender computes stealth address using ECDH
  4. Sender sends ephemeral_pubkey to recipient via encrypted channel (NOT on-chain)
  5. Only stealth_address goes on-chain
  6. Recipient uses private notification to detect payments

Privacy benefit: No scannable pattern on-chain. Recipient detection happens off-chain.

Decision: Fixed Denomination Pools

Problem: Amount correlation can link deposits to withdrawals.

✅ Chosen: 7 Fixed Buckets

BucketAmount (SOL)
00.1
10.5
21
35
410
550
6100

Rationale: All deposits/withdrawals in a pool look identical. Larger anonymity set = better privacy.


8. SDK Security Enforcement

Decision: Mandatory Tor Verification Before Sensitive Requests

Problem: What if a developer accidentally sends sensitive data without Tor?

❌ Rejected: Trust the Developer

Why rejected: Human error is inevitable. A single mistake exposes:

  • Destination URL (relayer address)
  • Auth tokens
  • Request body (commitment hashes, wallet addresses)

❌ Rejected: Documentation-Only Warning

Why rejected: Developers skip docs. Runtime enforcement is the only guarantee.

✅ Chosen: SDK Refuses to Send Without Verified Tor

Implementation (privacy-proxy-sdk):

async fn ensure_tor(&mut self) -> Result<()> {
    if self.tor_verified { return Ok(()); }
    
    let is_tor = self.tor_client.verify_tor_connection().await?;
    if !is_tor {
        return Err(SdkError::TorRequired(
            "Tor connection required. Refusing to send sensitive data."
        ));
    }
    self.tor_verified = true;
    Ok(())
}

Behavior:

  • submit_deposit() calls ensure_tor() first → fails if no Tor
  • submit_withdrawal() calls ensure_tor() first → fails if no Tor
  • Direct HTTP client is #[cfg(test)] only → cannot be used in production

Privacy benefit: Impossible to accidentally leak data. SDK enforces the rules.

Decision: Payload Encryption (Defense in Depth)

Problem: Tor exit nodes can see unencrypted traffic. What if exit node is malicious?

❌ Rejected: Trust Tor Encryption Alone

Why rejected: Tor encrypts between nodes, but exit node sees plaintext to destination. Malicious exit node = data exposed.

✅ Chosen: Encrypt Payloads Before Sending

Implementation:

// All sensitive requests are encrypted with shared secret
let encrypted = encrypt_payload(&plaintext, &self.config.encryption_secret);
self.tor_client.post_json(&url, &encrypted).await

Layers of protection:

  1. Tor hides IP address
  2. Payload encryption hides content from exit nodes
  3. Only relayer (with shared secret) can decrypt

What an attacker sees at each layer:

PositionWithout Payload EncryptionWith Payload Encryption
ISPTor traffic to entry nodeTor traffic to entry node
Tor Exit Node{"commitment": "0xabc", "token": "xyz"}{"ciphertext": "encrypted_blob", "nonce": "..."}
RelayerFull requestFull request (decrypted)

Decision: No Direct Client in Production

Problem: Test code uses direct HTTP client to prove what gets exposed. What if it leaks to production?

✅ Chosen: Compile-Time Restriction

Implementation (tracezero crate):

// Only available in test builds
#[cfg(any(test, feature = "test-utils"))]
pub fn direct_client() -> Result<TorHttpClient> {
    TorHttpClient::new_direct()
}

Guarantees:

  • cargo build --release → no direct_client() available
  • cargo testdirect_client() available for proving exposure
  • Feature flag test-utils must be explicitly enabled

9. Technology Stack

Decision: Anchor Framework for Solana Programs

❌ Rejected: Native Solana (raw Rust)

Why rejected: More boilerplate, harder to maintain, no built-in safety checks

✅ Chosen: Anchor

Rationale:

  • Industry standard for Solana development
  • Built-in account validation
  • Automatic PDA derivation
  • Better developer experience

Decision: Circom for ZK Circuits

❌ Rejected: Noir

Why rejected: Less mature tooling, smaller ecosystem

❌ Rejected: Halo2

Why rejected: More complex, overkill for our circuit complexity

✅ Chosen: Circom + snarkjs

Rationale:

  • Mature ecosystem
  • Good browser support via WASM
  • Extensive documentation
  • Used by Tornado Cash (battle-tested)

Decision: Next.js for Frontend

✅ Chosen: Next.js + React

Rationale:

  • Standard for web3 dApps
  • Good Solana wallet adapter support
  • SSR capabilities if needed
  • Large ecosystem

Decision: Yarn for Package Management

✅ Chosen: Yarn

Rationale: User preference, workspace support, deterministic installs.


Summary: Final Architecture

┌─────────────────────────────────────────────────────────────────┐
│                         USER FLOW                                │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. PURCHASE CREDITS (Visible, Unlinkable)                      │
│     Browser → Phantom → Solana                                   │
│     User pays relayer + blinded_token                           │
│     Relayer signs (can't see token_id)                          │
│     User unblinds → has valid credit                            │
│                                                                  │
│  2. REQUEST DEPOSIT (Via Tor, Unlinkable)                       │
│     Browser → HTTP :3080 → Tor Gateway → SOCKS5 → Tor → Relayer │
│     User sends: token_id + signature + commitment               │
│     Relayer verifies (can't link to purchase)                   │
│                                                                  │
│  3. EXECUTE DEPOSIT (User NOT in TX)                            │
│     Relayer → Solana                                            │
│     Relayer's funds → Pool                                      │
│     User wallet NEVER appears                                   │
│                                                                  │
│  4. WITHDRAW (ZK Proof, Anonymous)                              │
│     Browser generates ZK proof (WASM)                           │
│     Browser → Tor Gateway → Relayer                             │
│     Relayer → Solana (verifies proof, sends to stealth addr)    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      TECHNOLOGY STACK                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Blockchain:        Solana (Anchor framework)                   │
│  ZK Proofs:         Groth16 (groth16-solana, <200k CU)          │
│  ZK Circuits:       Circom + snarkjs (WASM in browser)          │
│  Hash Function:     Poseidon (off-chain) + SHA256 (on-chain)    │
│  Blind Signatures:  RSA RFC-9474 (@cloudflare/blindrsa-ts)      │
│  Network Privacy:   Tor via Docker + HTTP Gateway               │
│  Frontend:          TanStack Start + React + Vite               │
│  Wallet:            Phantom/Backpack (existing wallets)         │
│  Package Manager:   Yarn                                        │
│  Secret Storage:    AES-256-GCM + PBKDF2 (100k iterations)      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                    PRIVACY GUARANTEES                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ✅ User wallet NEVER in pool deposit TX                        │
│  ✅ Credit purchase unlinkable to deposit (blind signature)     │
│  ✅ IP hidden via Tor                                           │
│  ✅ Withdrawal unlinkable to deposit (ZK proof + nullifier)     │
│  ✅ Recipient hidden (stealth addresses)                        │
│  ✅ Amount hidden (fixed denomination pools)                    │
│  ✅ Timing decorrelated (random 1-24h delays)                   │
│  ✅ Relayer can't correlate (split architecture)                │
│  ✅ SDK enforces Tor (refuses to send without verification)     │
│  ✅ Payloads encrypted (exit nodes can't read content)          │
│  ✅ Direct client disabled in production builds                 │
│  ✅ Binding hash prevents proof reuse (Security v2)             │
│  ✅ Client-side merkle proof verification (Security v2)         │
│  ✅ Encrypted local storage for secrets (Security v2)           │
│  ✅ Stealth keys encrypted in localStorage (v7.3)               │
│  ✅ Password-protected key storage with AES-256-GCM (v7.3)      │
│  ✅ Merkle state persisted with checksums (Security v2)         │
│  ✅ Stealth keypair saved locally for fund recovery (v6)        │
│  ✅ Claim page for sweeping stealth → destination (v6)          │
│  ✅ Payment verification before blind signatures (v6.2)         │
│  ✅ Rent-exempt account pre-funding (v7)                        │
│  ✅ Fast deposit sync with smart scanning (v7.1)                │
│  ✅ Treasury wallet separation breaks trace chain (v7.2)        │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Decision Log

#DecisionAlternatives ConsideredChosenReason
1Deposit privacyDirect, Shield, Escrow PDA, ReimbursementBlinded CreditsOnly approach with TRUE unlinkability
2Blind signature schemeBLS, SchnorrRSA (RFC 9474)Browser support, library maturity
3Tor integrationArti, Direct SOCKS5, ElectronHTTP GatewayWorks in all browsers
4Wallet strategyCustom walletExisting (Phantom)Trust, security, UX
5ZK proof systemPLONK, STARKsGroth16Small proofs, low verification cost
6Hash functionSHA-256, MiMCPoseidon (hybrid)ZK-friendly off-chain, SHA256 on-chain
7Stealth addressesEph key on-chainEph key off-chainNo scannable pattern
8Relayer architectureSingle relayerSplit relayersPrevents correlation
9Solana frameworkNative RustAnchorDeveloper experience
10ZK circuit languageNoir, Halo2CircomMaturity, browser support
11Tor enforcementTrust developer, Docs warningSDK refuses without TorPrevents accidental leaks
12Exit node protectionTrust Tor alonePayload encryptionDefense in depth
13Direct client accessAlways availableTest-only (#[cfg(test)])No production leaks
14ZK verifier libraryCustom implementation, ark-groth16groth16-solana 0.2.0<200k CU, Solana syscalls
15On-chain Poseidonlight-poseidon on-chainSHA256 on-chain + Poseidon off-chainStack overflow (30KB > 4KB limit)
16Historical roots storageLarge single accountChained small accounts (32×32)BPF stack limits
17Frontend secret storagelocalStorageAES-256-GCM encrypted storageXSS protection
18Binding hash computationOn-chain PoseidonOff-chain + ZK verificationStack efficiency
19Merkle state persistenceIn-memory onlyJSON files + checksumsCrash recovery
20ECDH key exchange fallbackRandom secret fallbackHard errorSecurity (no silent failures)
21Deposit payload encryptionUnencrypted via TorECDH + AES-256-GCMExit node protection
22Withdrawal request formatMixed formats (hex/base58/decimal)Consistent byte arraysRelayer deserialization
23Binding hash computationNo field reductionReduce pubkeys to BN254 fieldHash matching
24Dev mode privacySkip both delay and TorSkip delay only, Tor alwaysPrevent accidental leaks
25Fee computation for ZK proofsHardcoded fee=0 in frontendCompute from relayer’s feeBps at runtimeProof public inputs match on-chain
26IDL address after anchor buildManual fixPost-build script (fix-idl.sh)Prevents InstructionFallbackNotFound
27Stealth fund recoveryRecipient-based sweep, no recoverySelf-withdrawal (A=B) + local keypair storage + Claim pageUser can always sweep funds from stealth addresses
28Stealth address BN254 compatibilityRandom Ed25519 keys (may exceed field)Regenerate until pubkey fits BN254 field (top 3 bits = 0)On-chain recipient matches keypair we control
29Credit payment enforcementFree blind signaturesRequire on-chain payment before signingEconomic sustainability
30Withdrawal rent-exemptionAllocate from withdrawal, system_program CPIRelayer pre-funds accounts with rent-exempt minimumUser receives full amount, no failures
31Deposit performance on devnetScan all transactionsSkip scan if >50 transactions, only scan last 2010x faster deposits (2-3s vs 20+s)
32Credit payment tracingSame wallet for payments + depositsSeparate treasury wallet for credit paymentsBreaks on-chain trace chain from pool to users
33Stealth key storage securityPlaintext localStorageAES-256-GCM encrypted localStorage with passwordProtects against XSS, malware, file system access
34Recipient address validationEd25519 order checkBN254 field + basic checksEd25519 order applies to scalars, not points
35Anonymity set trackingDecrement on executeDecrement on request, restore on cancelAccurately reflects available deposits
36Relayer URL configurationHardcoded URLsEnvironment variablesPrevents DNS leaks, allows Tor routing
37Version exposureExpose in health endpointRemove version fieldPrevents fingerprinting
38Timing delay randomnessMath.random()crypto.getRandomValues()Cryptographically secure
39IDL address fixingManual Python scriptAutomated JustfilePrevents deployment errors
40Tor gateway monitoringNo health checkDocker health checkAutomatic restart on failure
41Domain tagsMagic numbersComprehensive specificationPrevents hash divergence
42Deposit retry handling”Already redeemed” errorIdempotency checkSafe retries after timeout
43Build automationMakefileJustfileBetter UX, cross-platform
44Claim privacySecond mixing layerDocument trade-offs + user guideBalanced privacy with complexity

10. Security Audit Findings & Fixes (v2)

Security Audit Summary

A comprehensive security audit was conducted identifying critical, high, and medium severity issues. All issues have been addressed.

Critical Issues Fixed

IDIssueFix Applied
C1Ownership circuit VK mismatch (2 IC points, needs 4)Script created: circuits/scripts/recompile_ownership.sh
C2Merkle tree zero values not properly initializedSDK now computes zeros at runtime using Poseidon with once_cell::Lazy
C3Relayer merkle tree not persisted (loses state on restart)Full persistence with JSON files + SHA256 checksums

High Priority Issues Fixed

IDIssueFix Applied
H1Historical roots limited to 32 entriesChained accounts: 32 accounts × 32 roots = 1024 capacity
H2Deposit note storage leaks timing informationAddressed in relayer architecture
H3Frontend stores sensitive data in localStorageNew SecureDepositStorage class with AES-256-GCM encryption

Medium Priority Issues Fixed

IDIssueFix Applied
M1ECDH fallback to random secretNow throws error instead of silent fallback
M3Client-side merkle proof verification missingAdded verifyMerkleProof() in frontend

Architecture Changes from Audit

On-Chain Hash Function Change

Problem: light-poseidon causes 30KB stack overflow on Solana BPF (limit: 4KB)

Solution: Hybrid approach

  • On-chain: SHA256 for Merkle tree (stack-efficient)
  • Off-chain: Poseidon for ZK compatibility
  • Binding hash: Computed off-chain, passed as parameter, verified by ZK proof
// Before (caused stack overflow)
let binding_hash = poseidon_hash(&[...]);  // 30KB stack

// After (stack-efficient)
// binding_hash computed off-chain, passed as instruction parameter
pub fn request_withdrawal(..., binding_hash: [u8; 32]) -> Result<()>

Historical Roots Scaling

Problem: 256 roots per account exceeded stack limits (8KB > 4KB)

Solution: Smaller accounts with chaining

pub const ROOTS_PER_ACCOUNT: usize = 32;      // 1KB per account
pub const MAX_CHAINED_ACCOUNTS: u8 = 32;      // 32 × 32 = 1024 roots

Secure Frontend Storage

Problem: localStorage exposed sensitive deposit data to XSS attacks

Solution: SecureDepositStorage class

// AES-256-GCM encryption with PBKDF2 key derivation
const PBKDF2_ITERATIONS = 100000;

class SecureDepositStorage {
  async initialize(password: string): Promise<boolean>
  async addDeposit(deposit: SecureDeposit): Promise<void>
  async exportBackup(backupPassword: string): Promise<string>
}

11. Implementation Status

Completed Components

ComponentStatusTests
crates/network (tracezero)✅ Complete5 passing
crates/tor-gateway✅ Complete8 passing
crates/privacy-proxy-sdk✅ Complete15 passing
crates/relayer✅ Complete7 passing
programs/privacy_proxy✅ CompleteBuild passing
programs/zk_verifier✅ CompleteBuild passing
app (frontend)✅ CompleteTypecheck + build passing

Privacy-Proxy Program Instructions

InstructionPurposePrivacy Guarantee
initializeSetup global configAdmin-only
purchase_creditsUser buys credits with blinded tokenVisible but UNLINKABLE
depositRelayer deposits to poolUser wallet NEVER in TX
request_withdrawalSubmit ZK proof + binding_hashAnonymous via proof
execute_withdrawalExecute after timelockPermissionless
cancel_withdrawalCancel pending withdrawalRequires ownership proof

ZK Verifier Program Instructions

InstructionPurposeNotes
verify_withdrawalVerify Groth16 proof for withdrawal6 public inputs + binding_hash output
verify_ownershipVerify ownership proof for cancellation2 public inputs + binding_hash output

Security v2: Both instructions now verify binding hashes that cryptographically bind proofs to specific parameters (recipient, relayer, fee, withdrawal ID).

Note: The verifying key is currently a placeholder. After trusted setup ceremony, replace constants in verifying_key.rs with real values generated by snarkjs. Use circuits/scripts/recompile_ownership.sh to regenerate ownership VK.

Build Commands

# Build network crate
cargo build -p tracezero

# Build SDK
cargo build -p privacy-proxy-sdk

# Build Relayer
cargo build -p relayer

# Build Anchor programs
cd programs/privacy_proxy && anchor build

# Build Frontend
cd app && npm run build

# Run all tests
cargo test -p tracezero --lib --tests
cargo test -p privacy-proxy-sdk --lib
cargo test -p relayer
cd app && npm run typecheck
cd programs/privacy_proxy && anchor test

12. Request Encryption & Withdrawal Format (v3)

Decision: ECDH-Based Payload Encryption for Deposits

Problem: Deposits sent via Tor are still visible to exit nodes. How do we hide the payload?

✅ Chosen: ECDH Key Exchange + AES-256-GCM

Implementation:

  1. Relayer generates X25519 keypair, publishes public key via /info endpoint
  2. Frontend generates ephemeral X25519 keypair
  3. Both derive shared secret via ECDH (X25519)
  4. Frontend encrypts deposit request with AES-256-GCM using shared secret
  5. Frontend sends encrypted payload + client’s public key to relayer
  6. Relayer derives same shared secret, decrypts payload

Libraries:

  • Frontend: Web Crypto API (native browser support)
  • Relayer: aes-gcm crate (Rust)
  • Key exchange: x25519-dalek (both sides)

Privacy benefit: Exit nodes see only encrypted blobs, not deposit details.

Decision: Withdrawal Request Format Standardization

Problem: Frontend was sending withdrawal request data in inconsistent formats (hex strings vs byte arrays vs base58 strings).

✅ Chosen: Consistent Byte Array Format

Standardization:

// All 32-byte values sent as arrays of numbers
public_inputs: {
  root: Array.from(hexToBytes(merkleRoot)),           // [u8; 32]
  nullifier_hash: Array.from(hexToBytes(nullifierHash)), // [u8; 32]
  recipient: Array.from(base58ToBytes(recipient)),    // [u8; 32] (Pubkey)
  relayer: Array.from(base58ToBytes(relayer)),        // [u8; 32] (Pubkey)
  binding_hash: Array.from(hexToBytes(bindingHash)),  // [u8; 32]
  amount: number,
  fee: number,
}

Conversion helpers:

  • hexToBytes(): Handles 0x prefix, converts hex to byte array
  • base58ToBytes(): Decodes base58 (Solana pubkeys) to byte array
  • bigIntToBytes(): Converts decimal bigint (from snarkjs) to byte array

Decision: Binding Hash Field Reduction

Problem: Solana Pubkey values can exceed BN254 field modulus, causing binding hash mismatch.

✅ Chosen: Field Reduction on Both Sides

Implementation:

// Frontend: Reduce pubkeys before hashing
function reduceToField(value: Uint8Array): Uint8Array {
  const result = new Uint8Array(value)
  result[0] &= 0x1F  // Clear top 3 bits
  return result
}

// SDK (Rust): Same reduction
pub fn reduce_to_field(value: &[u8; 32]) -> [u8; 32] {
  let mut result = *value
  result[0] &= 0x1F
  result
}

Why needed: BN254 field modulus is ~2^254. Solana pubkeys are 32 bytes (256 bits), which can exceed the field. Masking the top bits ensures both sides compute the same binding hash.

Security guarantee: Binding hash now matches between frontend and relayer, preventing “Invalid binding hash” errors.


13. Dev Mode Privacy Enforcement

Decision: Timing Delay Skippable, Tor and Encryption Always Required

Problem: Dev mode was skipping both timing delays AND Tor verification, making it too easy to accidentally leak data. Additionally, the relayer accepted unencrypted deposit requests.

✅ Chosen: Mandatory Security, Optional Timing

Dev Mode Behavior:

  • ✅ Skip timing delay (1-24 hours) for faster testing
  • ❌ Tor verification ALWAYS required - no bypass option exists
  • ❌ Payload encryption ALWAYS required - no plaintext fallback
  • ❌ Cannot disable security in any mode

Security Guarantees (same in dev and prod):

  1. All deposits routed through Tor (IP hidden)
  2. All deposit payloads encrypted with ECDH + AES-256-GCM
  3. Relayer rejects unencrypted requests
  4. Frontend refuses to send without encryption

What CAN be skipped in dev:

  • Timing delay (1-24 hours) - for faster testing only

Implementation:

// Dev mode only skips delay - NO skipTor option exists
const result = await deposit(selectedCredit, { 
  skipDelay: devMode,      // ✅ Can skip in dev
  testMode: devMode,       // ✅ Can skip in dev
  // skipTor removed - Tor is ALWAYS required
})

// Button disabled until Tor verified
disabled={isDepositing || !torVerified}  // Tor always required

Relayer Changes:

// REMOVED: DepositPayload::Plain variant
// All deposits MUST be encrypted
struct DepositPayload {
    encrypted: bool,
    ciphertext: Vec<u8>,
    nonce: Vec<u8>,
    client_pubkey: String,  // Required, not optional
}

14.1 Stealth Fund Recovery (v6)

Decision: Self-Withdrawal Model (A = B) with Local Keypair Storage

Problem: After execute_withdrawal, funds land on a one-time stealth address. The stealth keypair was generated ephemerally and discarded — funds were effectively locked forever.

❌ Rejected: Recipient-Based Model (A → B)

Why rejected: The current protocol has no concept of a separate “receiver B”. There’s no UI field for a recipient’s stealth meta-address, no off-chain notification channel, and no way for B to scan for incoming payments. Building this would require a full messaging layer.

❌ Rejected: No Recovery (Accept the Loss)

Why rejected: Users lose all withdrawn funds. Unacceptable.

✅ Chosen: Self-Withdrawal + Local Keypair Storage + Claim Page

Model: The depositor is the same person who withdraws (like Tornado Cash). After withdrawal, the stealth keypair is saved in localStorage. A separate “Claim” page lets the user sweep funds to any destination wallet.

Full user flow:

Step 1: Buy credits (wallet visible, unlinkable due to blind signatures)
Step 2: Deposit via Tor (wallet hidden, commitment in Merkle tree)
Step 3: Withdraw via Tor (ZK proof, funds go to stealth address, keypair saved)
Step 4: Claim/Sweep (plain SOL transfer from stealth → destination wallet)

What gets stored per withdrawal (in localStorage under privacy-proxy-stealth-keys):

{
  id: string,
  stealthAddress: string,        // base58 public key
  stealthSecretKey: string,      // base64-encoded 64-byte Ed25519 secret key
  ephemeralPubkey: string,       // base64-encoded
  amount: number,                // lamports
  createdAt: number,
  swept: boolean,
  sweepTxSignature?: string
}

Claim page features:

  • Lists all unswept stealth addresses with on-chain balances (fetched via RPC)
  • Destination wallet input (defaults to connected wallet)
  • “Claim” button sends a plain SystemProgram.transfer signed with the stealth private key
  • Export/import stealth keys as JSON backup
  • Explorer + Solscan links for sweep transactions

Privacy analysis:

  • The claim/sweep is a plain SOL transfer — no ZK proof, no relayer, no Tor
  • This is intentional: the stealth address is already unlinkable to the original deposit
  • The sweep creates a new link (stealth → destination), but stealth → deposit is broken by ZK
  • An observer sees: “some random address sent SOL to destination” — no link to the privacy pool

Security considerations:

  • Stealth keys in localStorage are vulnerable to XSS (same as deposit secrets)
  • Users should export keys via the backup feature before clearing localStorage
  • Future improvement: encrypt stealth keys with the same AES-256-GCM scheme used for deposits

No changes required:

  • No on-chain program changes
  • No relayer changes
  • No ZK circuit changes
  • No Tor changes

Files added/modified:

  • app/src/lib/crypto/secureStorage.ts — stealth key storage functions
  • app/src/hooks/useWithdraw.ts — saves stealth keypair after successful withdrawal
  • app/src/hooks/useClaim.ts — new hook for listing/claiming stealth balances
  • app/src/routes/claim.tsx — new Claim page
  • app/src/routes/__root.tsx — added Claim nav link
  • app/src/lib/stealth/index.ts — fixed BN254 compatibility (see below)

Critical Fix: BN254-Compatible Stealth Addresses (v6.1)

Problem discovered: The ZK circuit operates in the BN254 field (~2^254). Solana pubkeys are 32 bytes (256 bits), which can exceed the field modulus. When a pubkey exceeds the field, the circuit reduces it by clearing the top 3 bits. This creates a DIFFERENT address that we don’t have the private key for!

Symptom: Funds sent to on-chain recipient 4Chqb5Y... but we only have the private key for DyQgV6t.... The claim fails because we can’t sign for the reduced address.

Root cause: Keypair.generate() creates random Ed25519 keys. ~12.5% of keys have the top 3 bits set (first byte & 0xE0 != 0), causing field reduction.

Fix applied: Regenerate stealth keypairs until the pubkey is within BN254 field:

// In generateStealthKeypair() and generateStealthAddress()
while ((keypair.publicKey.toBytes()[0] & 0xE0) !== 0 && attempts < 100) {
  keypair = Keypair.generate()  // or regenerate from modified seed
  attempts++
}

Result: The stealth address saved in localStorage now ALWAYS matches the on-chain recipient. Claims work correctly.


14. Security Considerations

Remaining Items for Production

  1. Trusted Setup Ceremony: Run ceremony for withdrawal and ownership circuits, update verifying_key.rs
  2. Circuit Recompilation: Execute circuits/scripts/recompile_ownership.sh after any circuit changes
  3. Merkle Zero Values: Verify SDK zeros match circuit zeros exactly (use circuits/scripts/compute_zeros.js)
  4. Stack Warnings: The Anchor build shows stack warnings - these are for account deserialization and may cause issues with very large accounts. Monitor in production.
  5. ECDH Key Rotation: Consider rotating relayer X25519 keypair periodically (currently persisted in memory)
  6. Payload Encryption Testing: Verify encrypted payloads work with all Tor exit nodes

Security Audit Documents

  • docs/SECURITY_AUDIT_V2.md - Full audit report
  • docs/SECURITY_FIXES.md - Detailed fix implementations
  • docs/PRIVACY_AUDIT_REPORT.md - Privacy-specific analysis

Latest Fixes (v3)

IssueFixImpact
Unencrypted deposits via TorECDH + AES-256-GCM encryptionExit nodes can’t read payload
Inconsistent request formatsStandardized to byte arraysRelayer deserialization works
Binding hash mismatchesField reduction on both sidesWithdrawal proofs now verify
Dev mode too permissiveTor always requiredPrevents accidental leaks
Nullifier hash formatConvert decimal to hex properlyWithdrawal requests parse correctly
Stealth address generationUse viewPubkey not viewKeyX25519 key handling fixed

Latest Fixes (v4 - Proof Verification)

IssueFixImpact
Proof_a negation using wrong ark versionUpdated to ark-bn254 0.5 + ark-serialize 0.5Matches groth16-solana dependencies
Proof_a deserialization formatUse deserialize_uncompressed on 64-byte arrayCorrect G1 point parsing
Proof_a negation processchange_endianness → deserialize → negate → serialize → change_endiannessProper big-endian/little-endian conversion
Groth16Verifier input formatPass negated proof_a directly to verifierVerifier expects pre-negated proof

Technical Details:

  • groth16-solana 0.2.0 uses ark-bn254 0.5 and ark-serialize 0.5
  • Proof_a processing: Convert big-endian → little-endian → deserialize → negate → serialize → convert back to big-endian
  • change_endianness reverses each 32-byte chunk within the 64-byte proof_a
  • G1::deserialize_uncompressed expects exactly 64 bytes (no flag byte)
  • Negation uses standard ark negation: -point
  • Result passed to Groth16Verifier::new which expects pre-negated proof_a

Latest Fixes (v5 - Fee Mismatch / Full Withdrawal Flow)

IssueFixImpact
Frontend generated ZK proof with fee=0Frontend now computes fee = amount * feeBps / 10000 from relayer infoProof public inputs match on-chain computation
WithdrawForm hardcoded fee: 0nRemoved hardcoded fee; fee is now always derived from relayer’s feeBpsCorrect fee flows through entire pipeline
useWithdraw.ts accepted manual fee optionRemoved fee from options interface to prevent misuseFee always computed, never manually set
IDL address bug after anchor buildanchor build sets privacy_proxy IDL address to zk_verifier’s addressMust run IDL fix script after every build
Types file wrong addresstarget/types/privacy_proxy.ts had zk_verifier addressMust run sed fix after every build
Debug logging left in on-chain codeRemoved msg!("DEBUG: ...") from request_withdrawal.rsCleaner logs, fewer compute units

Root Cause Analysis:

The withdrawal flow involves three components that must agree on the fee value:

Frontend (ZK proof generation) → Relayer (transaction relay) → On-chain program (verification)

The on-chain request_withdrawal handler always computes fee deterministically:

let fee = amount
    .checked_mul(config.fee_bps as u64)  // fee_bps = 50 (0.5%)
    .checked_div(10000);
// For 1 SOL: fee = 1_000_000_000 * 50 / 10000 = 5_000_000

This computed fee is passed to the zk_verifier as public input [6]. The ZK proof must have been generated with the same fee value, or Groth16 verification fails (ProofVerificationFailed / error code 0x1770).

The frontend was generating the proof with fee=0 (hardcoded default), creating a mismatch:

  • Proof generated with: publicInputs[6] = 0
  • On-chain verifier expected: publicInputs[6] = 5_000_000

Fix applied in app/src/hooks/useWithdraw.ts:

// Before (BUG):
const fee = options.fee || 0n  // Always 0!

// After (FIXED):
const relayerInfo = await relayerClient.getRelayerInfo()
const feeBps = BigInt(relayerInfo.feeBps)
const fee = (BigInt(deposit.amount) * feeBps) / 10000n
// For 1 SOL: fee = 1000000000n * 50n / 10000n = 5000000n ✓

Debugging journey summary:

StepWhat was testedResultConclusion
1Rust unit test with known proof✅ PassProof, VK, negation all correct
2Direct CPI to zk_verifier (bypassing privacy_proxy)✅ Pass (144k CU)Proof verifies on-chain
3Full flow through privacy_proxyProofVerificationFailedSomething in the CPI path differs
4IDL address inspection🐛 Wrong addressFixed with post-build script
5Types file inspection🐛 Wrong addressFixed with sed
6Fee value comparison🐛 Frontend=0, On-chain=5000000Root cause found
7Frontend fee computation fix✅ PassFull withdrawal flow works

Key Program IDs:

  • privacy_proxy: Dzpj74oeEhpyXwaiLUFKgzVz1Dcj4ZobsoczYdHiMaB3
  • zk_verifier: AL6EfrDUdBdwqwrrA1gsq3KwfSJs4wLq4BKyABAzsqvA

Post-build checklist (required after every anchor build):

cd programs/privacy_proxy

# Fix IDL address (anchor incorrectly sets it to zk_verifier's address)
python3 -c "import json; f=open('target/idl/privacy_proxy.json','r'); d=json.load(f); f.close(); d['address']='Dzpj74oeEhpyXwaiLUFKgzVz1Dcj4ZobsoczYdHiMaB3'; f=open('target/idl/privacy_proxy.json','w'); json.dump(d,f,indent=2); f.close()"

# Fix types file address
sed -i '' 's/"address": "AL6EfrDUdBdwqwrrA1gsq3KwfSJs4wLq4BKyABAzsqvA"/"address": "Dzpj74oeEhpyXwaiLUFKgzVz1Dcj4ZobsoczYdHiMaB3"/' target/types/privacy_proxy.ts

15. Execution Order (How to Run Everything)

The system has strict ordering dependencies. Running things out of order will cause failures.

Full Cold Start (from scratch)

Step 1: Build programs
Step 2: Fix IDL addresses
Step 3: Start validator with --bpf-program flags
Step 4: Initialize protocol (creates config + 7 pools)
Step 5: Start Tor + Gateway (Docker)
Step 6: Start relayer
Step 7: Start frontend
Step 8: Clear browser localStorage
Step 9: Use the app (purchase credits → deposit → withdraw)

Detailed Commands

# ── Step 1: Build Solana programs ──
cd programs/privacy_proxy
anchor build

# ── Step 2: Fix IDL addresses (MANDATORY after every anchor build) ──
python3 -c "
import json
f = open('target/idl/privacy_proxy.json', 'r')
d = json.load(f)
f.close()
d['address'] = 'Dzpj74oeEhpyXwaiLUFKgzVz1Dcj4ZobsoczYdHiMaB3'
f = open('target/idl/privacy_proxy.json', 'w')
json.dump(d, f, indent=2)
f.close()
"
sed -i '' 's/"address": "AL6EfrDUdBdwqwrrA1gsq3KwfSJs4wLq4BKyABAzsqvA"/"address": "Dzpj74oeEhpyXwaiLUFKgzVz1Dcj4ZobsoczYdHiMaB3"/' target/types/privacy_proxy.ts

# ── Step 3: Start validator ──
# (in a separate terminal)
solana-test-validator \
  --bpf-program Dzpj74oeEhpyXwaiLUFKgzVz1Dcj4ZobsoczYdHiMaB3 target/deploy/privacy_proxy.so \
  --bpf-program AL6EfrDUdBdwqwrrA1gsq3KwfSJs4wLq4BKyABAzsqvA target/deploy/zk_verifier.so \
  --reset

# ── Step 4: Initialize protocol ──
# (wait ~5 seconds for validator to be ready)
npx ts-node scripts/init-program.ts

# ── Step 5: Start Tor + Gateway ──
# (in a separate terminal)
cd ../../crates/network
docker compose up -d
# Wait 30-60 seconds for Tor to bootstrap
curl http://localhost:3080/health

# ── Step 6: Clear relayer state and start relayer ──
# (in a separate terminal, from project root)
rm -rf merkle_state/ used_tokens.dat used_tokens.checksum
# Generate treasury wallet if you don't have one yet:
#   solana-keygen new -o treasury.json
TREASURY_KEYPAIR_PATH=./treasury.json cargo run --release -p relayer

# ── Step 7: Start frontend ──
# (in a separate terminal)
cd app
yarn dev

# ── Step 8: Clear browser localStorage ──
# Open browser console (F12) on http://localhost:3000 and run:
# localStorage.clear()
# Then refresh the page

After Validator Reset (most common scenario)

When you restart the validator with --reset, all on-chain state is wiped. You must:

# 1. Kill relayer (it has stale merkle state)
pkill -f relayer

# 2. Clear relayer state files
rm -rf merkle_state/ used_tokens.dat used_tokens.checksum

# 3. Re-initialize protocol
cd programs/privacy_proxy
npx ts-node scripts/init-program.ts

# 4. Restart relayer
cd ../..
TREASURY_KEYPAIR_PATH=./treasury.json cargo run --release -p relayer

# 5. Clear browser localStorage (old deposits are invalid now)
# In browser console: localStorage.clear()

After Code Changes

What changedWhat to redo
Solana program code (.rs in programs/)anchor build → fix IDL → restart validator → init → clear relayer state → restart relayer → clear localStorage
Relayer code (.rs in crates/relayer/)cargo build --release -p relayer → restart relayer
Frontend code (.ts/.tsx in app/)Hot reload (automatic with yarn dev)
Circuit code (.circom)Recompile circuit → new trusted setup → copy artifacts → rebuild programs → full restart
Relayer config (fee_bps, etc.)Restart relayer → clear localStorage (fee is baked into ZK proofs)
Treasury wallet setupsolana-keygen new -o treasury.json → set TREASURY_KEYPAIR_PATH → restart relayer

16. Common Mistakes and How to Avoid Them

Mistake 1: Not fixing IDL address after anchor build

Symptom: InstructionFallbackNotFound error when calling privacy_proxy instructions.

Why it happens: anchor build generates the IDL with the zk_verifier’s program ID (AL6Efr...) instead of privacy_proxy’s ID (Dzpj74...). This is a bug in how Anchor handles workspaces with multiple programs.

Fix: Always run the IDL fix commands after every anchor build. See Step 2 above.

Mistake 2: Using anchor deploy instead of --bpf-program

Symptom: invalid digit found in string error, or authority mismatch errors.

Why it happens: Anchor 0.32.x is incompatible with Solana CLI 3.x for deployment. The anchor deploy command passes arguments in a format the new CLI doesn’t understand.

Fix: Always use --bpf-program flags when starting the validator. Never use anchor deploy.

Mistake 3: Not clearing relayer state after validator reset

Symptom: InvalidMerkleRoot errors on withdrawal. Relayer reports a merkle root that doesn’t exist on-chain.

Why it happens: The relayer persists its merkle tree to disk (merkle_state/ directory). After a validator reset, the on-chain state is empty but the relayer still has old commitments. The merkle roots don’t match.

Fix: Always delete merkle_state/, used_tokens.dat, and used_tokens.checksum before restarting the relayer after a validator reset.

Mistake 4: Not clearing browser localStorage after validator reset

Symptom: Deposit shows in the UI but withdrawal fails with commitment mismatch or invalid merkle proof.

Why it happens: The browser stores deposit secrets (nullifier, secret, commitment, leafIndex) in localStorage. After a validator reset, these deposits no longer exist on-chain. The old data is useless.

Fix: Run localStorage.clear() in the browser console after any validator reset, then refresh.

Mistake 5: Forgetting to initialize the protocol after validator start

Symptom: Any transaction fails with AccountNotInitialized or similar PDA errors.

Why it happens: The validator starts with the programs loaded but no accounts created. The init-program.ts script creates the GlobalConfig account and all 7 pool accounts.

Fix: Always run npx ts-node scripts/init-program.ts after starting a fresh validator.

Mistake 6: Hardcoding fee values in the frontend

Symptom: ProofVerificationFailed (error code 0x1770) on withdrawal.

Why it happens: The ZK proof includes the fee as a public input. The on-chain program computes fee = amount * fee_bps / 10000 independently. If the frontend uses a different fee value when generating the proof, the public inputs don’t match and Groth16 verification fails.

Fix: The frontend must fetch feeBps from the relayer’s /info endpoint and compute the fee using the exact same formula as the on-chain program: fee = amount * feeBps / 10000. Never hardcode fee = 0 or any other value.

Mistake 7: Running relayer from the wrong directory

Symptom: Relayer can’t find rsa_signing_key.der, or merkle state is saved in unexpected location.

Why it happens: The relayer looks for rsa_signing_key.der relative to the current working directory. Merkle state is also saved relative to CWD.

Fix: Run the relayer from the project root (cargo run --release -p relayer) or from crates/relayer/.

Mistake 8: Starting Tor gateway before Docker is running

Symptom: docker compose up fails or containers exit immediately.

Fix: Make sure Docker Desktop is running first. On macOS, open Docker.app or run open -a Docker.

Mistake 9: Not waiting for Tor to bootstrap

Symptom: Tor verification fails, deposits/withdrawals fail with connection errors.

Why it happens: Tor takes 30-60 seconds to establish circuits after container start. Requests during this window fail.

Fix: Wait at least 30 seconds after docker compose up -d, then verify with curl http://localhost:3080/verify-tor.

Mistake 10: Changing fee_bps without regenerating proofs

Symptom: All withdrawals fail with ProofVerificationFailed after changing the relayer’s fee.

Why it happens: Existing deposits have ZK proofs generated with the old fee. The on-chain program computes the new fee, creating a mismatch.

Fix: After changing fee_bps, all existing deposits become unwithdrawable with the old proofs. Users must make new deposits. In production, fee changes should be announced well in advance.

Quick Diagnostic Checklist

If something isn’t working, check these in order:

1. Is the validator running?           → solana cluster-version
2. Are programs loaded?                → solana program show Dzpj74oeEhpyXwaiLUFKgzVz1Dcj4ZobsoczYdHiMaB3
3. Is the protocol initialized?        → Check for config PDA
4. Is the relayer running?             → curl http://localhost:8080/health
5. Is the relayer state fresh?         → Check if merkle_state/ matches current validator
6. Is Tor running?                     → curl http://localhost:3080/verify-tor
7. Is localStorage clean?              → Check browser console for stale deposits
8. Was IDL fixed after last build?     → Check target/idl/privacy_proxy.json address field
9. Does fee match?                     → curl http://localhost:8080/info | jq .fee_bps

15. Credit Payment Enforcement (v6.2)

Decision: Require On-Chain Payment Before Blind Signature

Problem: The blind signature endpoint (/sign) was signing tokens for free. Users could get unlimited credits without paying, breaking the economic model.

Previous (broken) flow:

1. User sends blinded token to relayer
2. Relayer signs it immediately (no payment check)
3. User gets free credit

New (enforced) flow:

1. User sends SOL payment to relayer's wallet (on-chain, visible)
2. User sends blinded token + payment tx signature to relayer
3. Relayer fetches transaction from RPC, verifies:
   - Transaction exists and succeeded
   - Payer matches claimed payer
   - Relayer received at least (amount + fee) lamports
4. Only then does relayer sign the blinded token

Why This Preserves Privacy

The payment is visible on-chain: “Wallet X paid Y SOL to relayer”. But the blind signature makes it mathematically impossible to link this payment to any future deposit:

  • Payment: “Wallet X paid for something” (visible)
  • Deposit: “Anonymous user deposited commitment Z” (via Tor, different IP)
  • The blinded token cannot be correlated to the payment

If we had required payment at deposit time instead, the relayer would see both the wallet AND the commitment in the same request, breaking privacy.

Implementation Details

Relayer (/sign endpoint):

  • Requires payment_tx (transaction signature) and payer (public key)
  • Fetches transaction from RPC with retries (devnet can be slow)
  • Verifies pre/post balances to confirm relayer received funds
  • Only signs after payment verification

Frontend:

  • Sends SOL transfer to relayer before requesting signature
  • Waits for ‘finalized’ confirmation (more reliable on devnet)
  • Adds 2-second delay after confirmation before calling relayer
  • Sends payment tx signature along with blinded token

Retry Logic:

  • Relayer retries transaction fetch 10 times with 2-second delays
  • Total wait time: up to 20 seconds for devnet propagation

16. Devnet Support (v6.2)

Decision: Configurable RPC URL with Devnet Defaults

Problem: The app was hardcoded to localhost, making it impossible to test with Phantom wallet (which doesn’t support localhost).

Changes:

  1. app/src/lib/constants.ts - Added SOLANA_RPC_URL constant
  2. app/src/components/SolanaProvider.tsx - Uses devnet by default
  3. crates/relayer/src/config.rs - Defaults to devnet RPC
  4. programs/privacy_proxy/scripts/init-program.ts - Uses RPC_URL env var
  5. Explorer links updated for devnet cluster

Network Selection:

  • For localhost: Set RPC_URL=http://localhost:8899 when running relayer/init
  • For devnet: Use defaults (no env var needed)

Deployment Costs (devnet):

  • zk_verifier: ~1.5 SOL
  • privacy_proxy: ~3 SOL
  • Pool initialization: ~0.5 SOL
  • Total: ~5 SOL minimum

Gotcha: Program IDs may differ between localhost and devnet if using different keypairs. Always verify program IDs match in:

  • Anchor.toml
  • crates/relayer/src/config.rs
  • Hardcoded declare_id!() in Rust code

17. Withdrawal Execution & Rent-Exempt Accounts (v7)

Decision: Pre-Fund and Top-Up Accounts to Ensure Rent-Exemption

Problem: When executing withdrawals, the program transfers funds to recipient and relayer treasury accounts via direct lamport manipulation. If these accounts don’t exist or have insufficient balance, the runtime enforces rent-exemption post-transaction. If the final balance is below rent-exempt minimum (890,880 lamports for 0-byte accounts), the transaction fails with “insufficient funds for rent” (error 0x0).

Root Cause Analysis:

  • Recipient account: May not exist if user hasn’t received funds before
  • Relayer treasury PDA: Doesn’t exist until first withdrawal execution
  • Existing accounts: May have balance < rent-exempt minimum from previous operations
  • Direct lamport credit via try_borrow_mut_lamports() works on any account
  • BUT: Runtime checks rent-exemption after transaction completes
  • Fee amount (500,000 lamports = 0.0005 SOL) < rent-exempt minimum (890,880 lamports)
  • Result: Transaction fails even though withdrawal amount (99,500,000 lamports) would be sufficient

❌ Rejected: Use system_program::transfer CPI

Why rejected:

  • system_program::transfer requires the from account to be owned by system program
  • Pool PDA is owned by our program, not system program
  • CPI transfer from program-owned accounts fails with “invalid program argument”

❌ Rejected: Allocate rent from withdrawal amount

Why rejected:

  • Reduces withdrawal amount received by user
  • Breaks the fixed denomination pool model
  • User expects to receive exactly the pool amount

✅ Chosen: Relayer Pre-Funds and Tops Up Accounts

Implementation:

  1. Before calling execute_withdrawal, relayer checks if recipient exists
  2. If not, relayer sends system_program::transfer to fund with rent-exempt minimum (890,880 lamports)
  3. If exists but balance < rent-exempt minimum, relayer tops up the difference
  4. Same check and top-up for relayer treasury PDA
  5. Small delay (500ms) after pre-funding to ensure settlement on devnet
  6. Program then credits both accounts with withdrawal amount and fee respectively
  7. Final balances: recipient has (rent_exempt + withdrawal_amount), treasury has (rent_exempt + fee)

Code (crates/relayer/src/withdrawal.rs):

let rent_exempt_minimum: u64 = 890_880;
let mut prefunded = false;

// Check recipient
let recipient_exists = self.rpc_client.get_account(&record.recipient).await.is_ok();
if !recipient_exists {
    // Pre-fund new account
    let prefund_tx = Transaction::new_signed_with_payer(
        &[solana_sdk::system_instruction::transfer(
            &relayer.pubkey(),
            &record.recipient,
            rent_exempt_minimum,
        )],
        Some(&relayer.pubkey()),
        &[relayer.as_ref()],
        self.rpc_client.get_latest_blockhash().await?,
    );
    self.rpc_client.send_and_confirm_transaction(&prefund_tx).await?;
    prefunded = true;
} else {
    // Check if existing account needs top-up
    let account = self.rpc_client.get_account(&record.recipient).await?;
    if account.lamports < rent_exempt_minimum {
        let needed = rent_exempt_minimum - account.lamports;
        let topup_tx = Transaction::new_signed_with_payer(
            &[solana_sdk::system_instruction::transfer(
                &relayer.pubkey(),
                &record.recipient,
                needed,
            )],
            Some(&relayer.pubkey()),
            &[relayer.as_ref()],
            self.rpc_client.get_latest_blockhash().await?,
        );
        self.rpc_client.send_and_confirm_transaction(&topup_tx).await?;
        prefunded = true;
    }
}

// Same logic for treasury...

// Delay to ensure settlement
if prefunded {
    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
}

Privacy Impact: None. The pre-funding transfers are visible on-chain but:

  • Relayer treasury is a PDA (not user’s wallet)
  • Recipient is a stealth address (not user’s wallet)
  • Transfers are small amounts (890,880 lamports ≈ 0.00089 SOL)
  • No correlation to user identity

Cost Impact: Relayer pays additional ~0.000005 SOL per pre-fund transfer (Solana TX fee). Negligible compared to fee revenue.

Guarantees:

  • Withdrawal execution never fails due to rent-exemption
  • Recipient receives full withdrawal amount (no deduction)
  • Treasury receives full fee amount
  • Both accounts persist on-chain after transaction
  • Works for both new accounts and existing accounts with low balance

19. LocalStorage Encryption for Stealth Keys (v7.3)

Decision: Encrypt Stealth Keys in localStorage with Password

Problem: Stealth private keys were stored in plaintext in localStorage, exposing Ed25519 secret keys to XSS attacks, malware, and file system access. Export files were also plaintext JSON.

❌ Rejected: Keep Plaintext Storage

Why rejected:

  • XSS attacks can steal all stealth keys instantly
  • Malware with file system access can read browser storage
  • Cloud sync of browser data exposes keys
  • Shared computers allow anyone to access keys

❌ Rejected: Encrypt Only Export Files

Why rejected:

  • Keys still vulnerable in localStorage during normal use
  • XSS can steal keys before user exports
  • Doesn’t protect against most attack vectors

❌ Rejected: Browser’s Built-in Encryption

Why rejected:

  • No browser API for encrypted localStorage
  • IndexedDB encryption not standardized
  • Would require complex key management

✅ Chosen: AES-256-GCM Encryption with Password-Derived Key

Implementation:

class SecureStealthStorage {
  // Encryption: AES-256-GCM with PBKDF2 key derivation
  // - 100,000 iterations (slows brute force)
  // - Random 128-bit salt per storage
  // - Random 96-bit IV per encryption
  // - GCM authentication tag for integrity
  
  async initialize(password: string): Promise<boolean>
  async addKey(entry: StoredStealthKey): Promise<void>
  async exportBackup(backupPassword: string): Promise<string>
  lock(): void  // Clear password from memory
}

User Flow:

  1. First withdrawal: User sets password on Withdraw page (visible UI, not hidden prompt)
  2. Password stored in sessionStorage for auto-unlock during session
  3. Stealth keys encrypted with AES-256-GCM before saving to localStorage
  4. Claim page: Lock/unlock UI if password needed
  5. Export: Encrypted .enc files with same encryption scheme

Migration Strategy:

  • Detect old plaintext data on initialization
  • Automatically migrate to encrypted format
  • Log warning about migration
  • No user action required

Security Properties:

  • Confidentiality: AES-256-GCM (256-bit key, no known attacks)
  • Integrity: GCM authentication tag detects tampering
  • Key derivation: PBKDF2-SHA256 (100k iterations, ~100ms per attempt)
  • Salt: Random 128-bit per storage (prevents rainbow tables)
  • IV: Random 96-bit per encryption (prevents ciphertext reuse)
  • Brute force resistance: 8-char password = ~660 years to crack

Attack Resistance:

Attack VectorBeforeAfter
XSS attack❌ Instant key theft✅ Needs password
Malware❌ Direct file access✅ Encrypted at rest
Cloud sync❌ Keys exposed✅ Safe to sync
Shared computer❌ Anyone can read✅ Password protected
File system access❌ Plaintext visible✅ Encrypted blob

Why This Works:

  • Password never leaves browser (not sent to relayer)
  • Encryption happens client-side using Web Crypto API
  • Session auto-unlock provides good UX without compromising security
  • Lock feature clears password from memory when needed
  • Export files use same encryption (can be stored anywhere safely)

Files Modified:

  • app/src/lib/crypto/secureStorage.ts — Added SecureStealthStorage class
  • app/src/hooks/useWithdraw.ts — Password setup UI, save encrypted keys
  • app/src/hooks/useClaim.ts — Lock/unlock functionality
  • app/src/routes/withdraw.tsx — Password setup form
  • app/src/routes/claim.tsx — Lock/unlock UI

Backward Compatibility:

  • Old plaintext data automatically migrated on first access
  • No breaking changes to existing users
  • Export format changed from .json to .enc (intentional)

18. Deposit Performance Optimization (v7.1)

Decision: Skip Slow Transaction History Scans on Devnet

Problem: When the relayer starts fresh (no merkle_state/), it attempts to sync with on-chain state by fetching all transaction signatures for the pool and parsing logs for deposit commitments. On devnet with 60+ transactions and pruned logs, this scan takes 20+ seconds, causing frontend timeouts (120s limit).

Root Cause Analysis:

  • Relayer detects local tree out of sync (local=0, on-chain=14)
  • Fetches all transaction signatures for pool (60+ transactions)
  • Fetches each transaction’s full data to parse logs
  • Looks for “Program log: Deposit: commitment=” in logs
  • On devnet, logs are often pruned, so scan finds nothing after 20+ seconds
  • Frontend times out waiting for deposit response

❌ Rejected: Increase Frontend Timeout

Why rejected:

  • Doesn’t solve the root cause (slow scan)
  • Poor UX (users wait 2+ minutes for deposits)
  • Masks the underlying performance issue

❌ Rejected: Scan All Transactions

Why rejected:

  • Too slow on devnet (20+ seconds)
  • Logs are often pruned anyway (scan finds nothing)
  • Blocks new deposits unnecessarily

✅ Chosen: Smart Scanning with Transaction Limits

Implementation: Skip full history scan if pool has >50 transactions. Only scan last 20 transactions for recent deposits.

Performance Impact:

  • Before: 20+ seconds (scan all 60+ transactions)
  • After: 2-3 seconds (scan last 20 transactions)
  • 10x faster deposits on devnet

19. LocalStorage Encryption for Stealth Keys (v7.3)

Decision: Encrypt Stealth Keys in localStorage with Password

Problem: Stealth private keys were stored in plaintext in localStorage, exposing Ed25519 secret keys to XSS attacks, malware, and file system access.

✅ Chosen: AES-256-GCM Encryption with Password-Derived Key

Implementation:

  • Encryption: AES-256-GCM with PBKDF2 key derivation
  • 100,000 iterations (slows brute force)
  • Random 128-bit salt per storage
  • Random 96-bit IV per encryption
  • GCM authentication tag for integrity

Security Properties:

  • Confidentiality: AES-256-GCM (256-bit key)
  • Integrity: GCM authentication tag
  • Key derivation: PBKDF2-SHA256 (100k iterations)
  • Brute force resistance: 8-char password = ~660 years to crack

20. Security Audit Fixes (Session 2)

This section documents all security fixes implemented during the second security audit review session, addressing medium and low severity issues, advisory concerns, and user experience improvements.

20.1 Medium Severity Fixes

M-13: Recipient Address Validation ✅

Issue: Field elements from ZK proofs were used as Solana addresses without proper validation. Values exceeding Ed25519 order or BN254 field modulus could cause fund loss.

Initial Fix (Incorrect): Checked Ed25519 order constraint

// INCORRECT - Ed25519 order applies to scalars (private keys), not points (public keys)
if value >= ED25519_ORDER { return Err(...) }

Corrected Understanding: Solana addresses are Ed25519 points (public keys), not scalars (private keys). The Ed25519 order constraint only applies to private keys. Public keys can be any valid point on the curve.

Final Fix: Proper validation for address usage

pub fn validate_recipient_address(value: &[u8; 32]) -> Result<()> {
    // 1. Not zero address
    if value.iter().all(|&b| b == 0) {
        return Err(PrivacyProxyError::InvalidRecipient.into());
    }
    
    // 2. Less than BN254 field modulus (circuit constraint)
    if !is_within_bn254_field(value) {
        return Err(PrivacyProxyError::InvalidRecipient.into());
    }
    
    // 3. Not system program
    let pubkey = Pubkey::new_from_array(*value);
    if pubkey == solana_program::system_program::ID {
        return Err(PrivacyProxyError::InvalidRecipient.into());
    }
    
    // 4. Can receive lamports (checked during execution)
    Ok(())
}

Deployment:

  • Devnet: 3YZCxGxeaQJiqhrxQSQY3z4BNkvHa27WpgaW1ei9NkTa8KTus2bXv6zyiKaDxFwCPx6YscFwUU3917vFUbyzuQTy
  • Documentation: M13_RECIPIENT_VALIDATION_COMPLETE.md, M13_VALIDATION_FIX_ADDENDUM.md

M-14: Anonymity Set Size Metric ✅

Issue: Anonymity set counter was decremented on withdrawal execution instead of withdrawal request. Cancelled withdrawals didn’t restore the counter, causing metric drift.

Root Cause: Misunderstanding of when deposits become “unavailable” for privacy purposes.

Fix: Correct tracking logic

// Deposit: +1 (unchanged)
pool.anonymity_set_size = pool.anonymity_set_size.saturating_add(1);

// Request Withdrawal: -1 (NEW)
pool.anonymity_set_size = pool.anonymity_set_size.saturating_sub(1);

// Execute Withdrawal: no change (REMOVED)
// (was incorrectly here)

// Cancel Withdrawal: +1 (NEW)
pool.anonymity_set_size = pool.anonymity_set_size.saturating_add(1);

Impact: Users now see accurate anonymity set sizes for privacy decisions.

Deployment:

  • Devnet: 57KNAQ3dDguBycVXv7mTAdXEsfx2dkcmpJVNptB1dQRvUdqdzxAbKyMiHcVbXM7fsxMkgbTp7DFhWCUeSRLBtbri
  • Documentation: M14_ANONYMITY_SET_METRIC_COMPLETE.md

20.2 Low Severity Fixes

L-02: Hardcoded Relayer URLs ✅

Issue: Hardcoded URLs prevented configuration and caused DNS leaks.

Fix: Environment variable configuration

export const TOR_GATEWAY_URL = 
  typeof window !== 'undefined' && (window as any).__TOR_GATEWAY_URL__ 
    ? (window as any).__TOR_GATEWAY_URL__ 
    : import.meta.env.VITE_TOR_GATEWAY_URL || "http://localhost:3080";

Files: app/src/lib/constants.ts, app/.env.example

L-03: Version Information Exposed ✅

Issue: /health endpoint exposed relayer version, aiding fingerprinting.

Fix: Removed version field from HealthResponse

// Before: version: env!("CARGO_PKG_VERSION")
// After: version field removed entirely

Files: crates/relayer/src/server.rs

L-04: Math.random() for Timing Delays ✅

Issue: Math.random() is not cryptographically secure, making timing delays predictable.

Fix: Use crypto.getRandomValues()

const randomArray = new Uint32Array(1);
crypto.getRandomValues(randomArray);
const randomFloat = randomArray[0] / 0xffffffff;
const delay = Math.floor(randomFloat * (maxDelay - minDelay)) + minDelay;

Files: app/src/hooks/useDeposit.ts

L-06: IDL Address Manual Fix ✅

Issue: anchor build produces incorrect IDL addresses, requiring manual Python script fix.

Fix: Automated with Justfile

# Just recipes for automated builds
build:          # Build + fix + verify IDLs
verify-idl:     # Verify IDL addresses
ci-build:       # CI build with strict verification
deploy-devnet:  # Deploy with verification
watch:          # Watch mode - rebuild on changes
full-check:     # Format, lint, build, test

Files: programs/privacy_proxy/justfile, programs/privacy_proxy/scripts/verify-idl.sh

L-08: Tor Gateway Health Check ✅

Issue: Tor gateway container had no health check, preventing proper restart on crashes.

Fix: Added health check to docker-compose

tor-gateway:
  healthcheck:
    test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3080/health"]
    interval: 10s
    timeout: 5s
    retries: 3
    start_period: 10s

Files: crates/network/docker-compose.yml

L-09: Domain Tags Documentation ✅

Issue: Domain tags were undocumented magic numbers, risking silent hash divergence.

Fix: Created comprehensive specification

  • DOMAIN_NULLIFIER = 1853189228 ("null")
  • DOMAIN_COMMIT = 1668246637 ("comm")
  • DOMAIN_BIND = 1651076196 ("bind")
  • DOMAIN_OWNER_BIND = 1869771618 ("ownb")

Files: docs/DOMAIN_TAGS.md

L-01: Used Token Expiration ⚠️

Status: Documented for future implementation

Issue: UsedToken PDAs grow indefinitely, causing state bloat at scale.

Recommendation: Time-bounded expiration (90 days)

  • Priority: Medium (implement before mainnet at scale)
  • Trigger: Before reaching 10k tokens or mainnet launch

Files: L01_USED_TOKEN_EXPIRATION_RECOMMENDATION.md

L-05: No Slashing for Incorrect Merkle Roots ⚠️

Status: Mitigated by M-09 (cryptographic verification)

Analysis: M-09 fix provides cryptographic prevention of incorrect roots. Slashing not needed when attack is impossible.

Files: L05_MERKLE_ROOT_SLASHING_NOTE.md

20.3 Advisory Issues

A-03: Claim Transaction Privacy ⚠️

Issue: Stealth → destination claim is a plain SOL transfer with no privacy protection.

Status: By design (documented)

Analysis:

  • Current design: Pool → Stealth (PRIVATE) → Destination (PUBLIC)
  • Stealth → destination link is visible, but deposit anonymity is preserved
  • Comparable to other Solana privacy solutions
  • Trade-off: Good privacy with reasonable complexity

Recommendations for Users:

  1. Maximum Privacy: Withdraw to fresh wallet, wait, then move to final destination
  2. Good Privacy: Use semi-anonymous wallet with delays
  3. Reduced Privacy: Direct to known exchange (not recommended)

Files: A03_CLAIM_PRIVACY_ANALYSIS.md, docs/PRIVACY_GUIDE.md, ADVISORY_ISSUES_COMPLETE.md

20.4 User Experience Improvements

Deposit Idempotency ✅

Issue: “Token already redeemed” error on timeout retries, even though deposit succeeded on-chain.

Fix: Idempotency check before processing

// Check if this commitment already exists on-chain (idempotency)
let token_hash = hash_token_id(&request.credit.token_id);
if let Some(existing) = self.check_existing_deposit(bucket_id, &request.commitment, &token_hash).await? {
    info!("Deposit already exists (idempotent retry): bucket={}, leaf_index={:?}", 
          bucket_id, existing.leaf_index);
    return Ok(existing);
}

Flow:

  1. Check if UsedToken PDA exists on-chain
  2. If exists, search for commitment in local Merkle tree
  3. If found, return existing deposit info (idempotent success)
  4. If not found, return error (possible sync issue)

Files: crates/relayer/src/deposit.rs, DEPOSIT_IDEMPOTENCY_FIX.md

20.5 Development Tooling

Justfile Migration ✅

Replaced: Makefile with Justfile (modern command runner)

Benefits:

  • Better error messages with colored output
  • Cross-platform compatibility
  • Simpler syntax (no tab/space issues)
  • Modern features (built-in help, tab completion, interactive selection)
  • Active development and large community

Available Commands:

just              # Build (default)
just build        # Build + fix + verify IDLs
just watch        # Auto-rebuild on changes
just test         # Run tests
just deploy-devnet # Deploy to devnet
just full-check   # Format, lint, build, test
just list         # Show all commands

Files: programs/privacy_proxy/justfile, JUSTFILE_MIGRATION.md

20.6 Security Improvements Summary

On-Chain Program:

  • ✅ Correct recipient validation (M-13)
  • ✅ Accurate anonymity metrics (M-14)
  • ✅ All previous fixes (C01-C05, H01-H11, M01-M12)

Off-Chain Components:

  • ✅ Configurable URLs (L-02)
  • ✅ No version exposure (L-03)
  • ✅ Secure random delays (L-04)
  • ✅ Deposit idempotency (UX fix)

Development:

  • ✅ Automated IDL verification (L-06)
  • ✅ Tor gateway health checks (L-08)
  • ✅ Domain tags documented (L-09)
  • ✅ Modern build system (Justfile)

Documentation:

  • ✅ Privacy guide for users
  • ✅ Claim privacy analysis
  • ✅ Domain tags specification
  • ✅ Complete fix documentation

20.7 Overall Progress

SeverityTotalFixedDocumentedMitigated
Critical55 ✅--
High1111 ✅--
Medium1412 ✅1 ⚠️1 ⚠️
Low96 ✅2 ⚠️1 ⚠️
Advisory1-1 ⚠️-
Total403442

Completion: 85% fixed, 10% documented, 5% mitigated = 100% addressed

20.8 Remaining Work Before Mainnet

  1. M-06: Trusted Setup Ceremony (CRITICAL)

    • Conduct MPC ceremony
    • Minimum 3-5 independent participants
    • Replace test keys with ceremony output
    • Estimated effort: 2-4 weeks
  2. L-01: Token Expiration (Optional, at scale)

    • Implement time-bounded expiration
    • Deploy when approaching 10k tokens
    • Estimated effort: 2-3 weeks
  3. UI Warnings (A-03)

    • Add privacy warnings in claim UI
    • Show privacy level indicators
    • Suggest best practices
  4. Final Security Audit

    • Review all fixes with external auditor
    • Verify no regressions
    • Test all edge cases

21. Chosen: Smart Scan with Early Bailout

Implementation:

  1. If there are >50 transactions, skip the scan entirely (logs likely pruned)
  2. If ≤50 transactions, only scan the last 20 (recent deposits more likely to have logs)
  3. If no commitments found, continue with empty tree instead of blocking
  4. Log warnings instead of errors (not a fatal condition)
  5. New deposits work immediately even if old ones can’t be recovered

Code (crates/relayer/src/deposit.rs):

// OPTIMIZATION: If there are too many transactions (>50), skip the slow scan
if signatures.len() > 50 {
    warn!(
        "Too many transactions ({}) to scan efficiently. Skipping history scan.",
        signatures.len()
    );
    warn!("⚠ CONTINUING WITH EMPTY TREE - Old deposits (if any) will NOT be withdrawable!");
    warn!("⚠ The relayer will track new deposits from this point forward.");
    
    // Reset the tree to empty and continue
    self.merkle_service.sync_from_chain(bucket_id, vec![]).await?;
    return Ok(());
}

// Parse deposit events from transaction logs (only scan recent transactions)
for sig_info in signatures.iter().rev().take(20) {  // Only last 20 transactions
    // ... parse logs ...
}

if commitments.is_empty() {
    warn!("Could not find any commitments in transaction history");
    warn!("⚠ CONTINUING WITH EMPTY TREE - Old deposits (if any) will NOT be withdrawable!");
    
    // Reset and continue instead of returning error
    self.merkle_service.sync_from_chain(bucket_id, vec![]).await?;
    return Ok(());
}

Performance Impact:

  • Before: 20+ seconds for deposit (timeout)
  • After: 2-3 seconds for deposit (success)
  • Improvement: 10x faster, no timeouts

Trade-offs:

  • Old deposits (before relayer restart) may not be recoverable
  • This is acceptable because:
    • Devnet is for testing, not production
    • Users can export/backup deposit secrets
    • New deposits work immediately
    • Production relayers should maintain persistent state

Production Considerations:

  • Production relayers should never clear merkle_state/
  • If state is lost, restore from backup
  • The 50-transaction threshold can be adjusted for production
  • Consider implementing incremental sync instead of full scan

Guarantees:

  • Deposits complete in <5 seconds even on slow devnet
  • Frontend never times out
  • Relayer can start fresh and accept deposits immediately
  • New deposits are always tracked correctly

Last updated: Session 2 (May 2026) — Added security audit fixes (M-13, M-14, L-01 through L-09, A-03), deposit idempotency, Justfile migration, and comprehensive documentation