Privacy-preserving transactions on Solana using ZK proofs, blind signatures, and Tor routing
sequenceDiagram
autonumber
participant W as User Wallet
participant D as dApp (Browser)
participant Tor as Tor Network
participant R as Relayer
participant T as Treasury Wallet
participant DW as Deposit Wallet
participant S as Solana Program
participant P as Pool PDA
participant SA as Stealth Address
participant Dst as Destination Wallet
rect rgb(60, 60, 90)
Note over W,T: Phase 1 — Credit Acquisition (visible on-chain, cryptographically unlinkable)<br/>RSA Auth | Blind Sig Verification
D->>R: GET /info → RSA pubkey (n, e) + treasury Solana address
Note over D: No version exposure
D->>D: token_id = CSPRNG(256 bits) Secure random
D->>D: r = random blinding factor
D->>D: blinded_token = RSA_Blind(token_id, r, RSA_pubkey)
W->>T: SystemProgram.transfer(amount + fee_bps) to Treasury Wallet
Note over W,T: Treasury Wallet ≠ Deposit Wallet<br/>Separate keypairs, no on-chain link<br/> Wallet separation (anti-correlation)
D->>D: Await TX finalized confirmation + RPC propagation delay
D->>R: POST /sign {blinded_token, payment_tx_sig, payer_pubkey}
R->>S: Fetch TX, verify pre/post balances (treasury received ≥ expected)
Note over R: Payment verification
R->>R: signed_blinded = RSA_BlindSign(blinded_token, RSA_privkey)
Note over R: RSA signature
R-->>D: signed_blinded_token
D->>D: signature = RSA_Unblind(signed_blinded, r, RSA_pubkey)
D->>D: Verify signature
D->>D: Encrypt & store (token_id, signature) in localStorage
Note over D: AES-256-GCM encryption (C01)<br/>PBKDF2 100k iterations
Note over R: Relayer signed blinded_token without seeing token_id.<br/>Blinding factor r is never transmitted.<br/>Linking blinded ↔ unblinded is computationally infeasible.
end
rect rgb(40, 80, 60)
Note over D,P: Phase 2 — Deposit via Tor (unlinkable to Phase 1, can be hours/days later)<br/>Tor Security (H06-H11) | Idempotency | Rate Limiting
D->>D: nullifier = CSPRNG(256 bits) Secure random
D->>D: secret = CSPRNG(256 bits)
D->>D: commitment = Poseidon(DOMAIN_COMMIT, nullifier, secret, amount)
Note over D: Poseidon consistency<br/>Domain tags documented
D->>D: ECDH shared_secret = X25519(ephemeral_priv, relayer_ecdh_pub)
Note over D: Per-request ephemeral keys <br/>Pinned relayer pubkey
D->>D: ciphertext = AES-256-GCM(payload, shared_secret)
Note over D: E2E encryption
D->>Tor: Encrypted {token_id, signature, commitment, amount}
Note over Tor: Circuit isolation <br/>Multi-gateway <br/>Health checks <br/>SOCKS hardening <br/>Tor-only mode <br/>TLS enforcement
Tor->>R: Forward (exit IP ≠ user IP)
R->>R: Decrypt payload via ECDH + AES-256-GCM
R->>R: RSA_Verify(token_id, signature, RSA_pubkey)
R->>R: Check idempotency: commitment exists?
R->>R: Assert H(token_id) ∉ UsedTokenStore (disk-persisted)
Note over R: Token replay prevention
R->>R: Persist H(token_id) to UsedTokenStore (atomic write + SHA-256 checksum)
R->>R: Check rate limits
R->>R: Insert commitment into local Poseidon Merkle tree (depth=20)
R->>R: merkle_root = recompute root
R->>R: Check balance ≥ required
DW->>S: deposit(bucket_id, commitment, token_hash, encrypted_note, merkle_root)
Note over DW,S: Deposit Wallet is signer + fee payer.<br/>User wallet NEVER appears in this TX.<br/>Atomic operations
S->>S: Verify merkle_root on-chain
S->>P: Update on-chain Merkle root + next_index
S->>S: Init CommitmentRecord PDA
S->>S: Init UsedToken PDA [seeds: "used_token", token_hash]
S->>S: Init EncryptedNote PDA [seeds: "note", pool, index]
S->>P: Increment anonymity_set_size
R-->>Tor: {tx_signature, leaf_index}
Tor-->>D: Forward response
D->>D: Encrypt & store (nullifier, secret, leaf_index) in localStorage
Note over D: AES-256-GCM encryption
end
rect rgb(80, 50, 50)
Note over D,SA: Phase 3 — Withdrawal (Groth16 ZK proof, zero-knowledge of depositor)<br/>Proof Verification | Point Validation | Recipient Validation
D->>D: stealth_seed = SHA-256(X25519_ECDH(eph_priv, view_pub) ‖ spend_pub)
D->>D: stealth_keypair = Ed25519_FromSeed(stealth_seed)
D->>D: Assert stealth_pub[0] & 0xE0 == 0 (BN254 field compatibility)
Note over D: Field reduction
D->>R: GET /pool/{bucket_id} → current merkle_root
D->>R: GET /proof/{bucket_id}/{leaf_index} → siblings[], pathIndices[]
Note over R: Multi-account root validation<br/>35 historical roots supported
D->>D: Verify Merkle proof locally (recompute root from commitment)
D->>D: fee = amount × fee_bps ÷ 10000
D->>D: Groth16 prove (WASM, ~10s in browser)
Note over D: Public inputs: root, nullifierHash, recipient, amount, relayer, fee<br/>Public output: bindingHash = Poseidon(DOMAIN_BIND, nullifierHash, recipient, relayer, fee)<br/>Private inputs: nullifier, secret, pathElements[20], pathIndices[20]<br/>Proves: ∃ leaf ∈ MerkleTree s.t. leaf = Poseidon(DOMAIN_COMMIT, nullifier, secret, amount)<br/>Domain tags (L09)
D->>D: Verify proof locally before submission (snarkjs.groth16.verify)
D->>Tor: {proof_a, proof_b, proof_c, public_inputs, nullifier_hash, recipient, binding_hash, delay_hours}
Note over D,Tor: Different Tor circuit than Phase 2<br/>Circuit isolation (H06)<br/>Secure random delay
Tor->>R: Forward (different exit node)
R->>R: Check rate limits
R->>S: request_withdrawal(proof, nullifier_hash, stealth_addr, binding_hash, delay)
S->>S: Validate recipient address
Note over S: BN254 field constraint + not zero + not system program
S->>S: CPI → ZK Verifier: Groth16 verify (proof_a negated, VK, public_inputs)
Note over S: Proof verification<br/>Point validation
S->>S: Assert nullifier_hash ∉ NullifierRegistry
S->>S: Init PendingWithdrawal PDA [seeds: "pending", pool, tx_id]
S->>P: Decrement anonymity_set_size
Note over S: Deposit now "locked" for withdrawal
S->>S: Set execute_after = Clock::get() + random_delay
Note over S: Timelock: 1-24 hours (user-chosen random delay)<br/>Secure random
R->>R: Pre-fund recipient if balance < rent-exempt minimum (890,880 lamports)
R->>R: Pre-fund treasury PDA if needed
R->>R: Wait 500ms for settlement
R->>S: execute_withdrawal(tx_id)
S->>S: Assert Clock::get() ≥ execute_after
S->>S: Validate recipient can receive lamports
S->>S: Init Nullifier PDA [seeds: "nullifier", hash] (marks spent)
P->>SA: Credit (amount - fee) lamports via try_borrow_mut_lamports
Note over P,SA: Atomic transfer <br/> Balance conservation
P->>T: Credit fee lamports to treasury
end
rect rgb(70, 70, 40)
Note over SA,Dst: Phase 4 — Claim / Sweep (plain SOL transfer, no protocol involvement)<br/>Stealth keys encrypted
D->>D: Unlock encrypted storage with password
D->>D: Load stealth secret key from encrypted localStorage
D->>D: Reconstruct Ed25519 Keypair from stored secret key
SA->>Dst: SystemProgram.transfer(balance - 5000 lamports TX fee)
Note over SA,Dst: Stealth → Destination is visible on-chain,<br/>but Stealth → Deposit link is broken by ZK proof.<br/>Observer sees: "unknown address sent SOL to destination."<br/>Privacy guide recommends fresh wallet + delays
end
flowchart LR
subgraph TX1["TX1: Credit Purchase"]
UW[User Wallet] -->|SOL + fee| TW[Treasury Wallet]
end
subgraph TX2["TX2: Pool Deposit"]
DWallet[Deposit Wallet] -->|deposit instruction| Pool[Pool PDA]
end
subgraph TX3["TX3: Withdrawal Execution"]
Pool -->|ZK-verified transfer| Stealth[Stealth Address]
end
subgraph TX4["TX4: Claim / Sweep"]
Stealth -->|SystemProgram.transfer| Dest[Destination Wallet]
end
TX1 ~~~ TX2
TX2 ~~~ TX3
TX3 ~~~ TX4
B1["🔒 RSA Blind Signature RFC 9474<br/>+ Treasury ≠ Deposit Wallet<br/>+ Blind Sig Verification (M01)<br/>+ Encrypted Storage (C01)"]
B2["🔒 Groth16 ZK-SNARK<br/>+ Nullifier Tracking (C02)<br/>+ Binding Hash<br/>+ Multi-Account Roots (M11)<br/>+ Field Reduction (M02)"]
B3["🔒 X25519 ECDH Stealth Address<br/>+ BN254 Field Reduction<br/>+ Recipient Validation (M13)<br/>+ Stealth Address (H03)"]
B4["🔒 Network Security<br/>+ Tor Circuit Isolation <br/>+ Multi-Gateway <br/>+ E2E Encryption <br/>+ Tor-Only Mode <br/>+ TLS Enforcement <br/>+ Health Checks"]
TX1 -.-|link broken by| B1
B1 -.-|unlinkable| TX2
TX2 -.-|link broken by| B2
B2 -.-|zero-knowledge| TX3
TX3 -.-|link broken by| B3
B3 -.-|one-time address| TX4
B4 -.->|protects all phases| TX1
B4 -.->|protects all phases| TX2
B4 -.->|protects all phases| TX3
style B1 fill:#c0392b,color:#fff,stroke:none
style B2 fill:#c0392b,color:#fff,stroke:none
style B3 fill:#c0392b,color:#fff,stroke:none
style B4 fill:#16a085,color:#fff,stroke:none
style UW fill:#2c3e50,color:#fff
style TW fill:#2c3e50,color:#fff
style DWallet fill:#27ae60,color:#fff
style Pool fill:#8e44ad,color:#fff
style Stealth fill:#d35400,color:#fff
style Dest fill:#2c3e50,color:#fff
What Blockchain Explorers See:
- TX1: User paid treasury (looks like any service payment)
- TX2: Deposit wallet deposited to pool (no user link, different wallet)
- TX3: Pool transferred to stealth address (ZK proof valid, no link to deposit)
- TX4: Stealth transferred to destination (no link to pool)
What They CANNOT See:
- Link between TX1 and TX2 (blind signature + wallet separation)
- Link between TX2 and TX3 (ZK proof reveals nothing)
- Link between TX3 and TX4 (stealth address one-time use)
- User's IP address (Tor network)
- Deposit secrets (encrypted storage)
- Which deposit corresponds to which withdrawal (anonymity set)
| Primitive | Purpose | Where Used |
|---|---|---|
| RSA-2048 Blind Signatures (RFC 9474) | Cryptographic unlinkability between credit payment and token redemption | Phase 1 → Phase 2 boundary |
| Poseidon Hash (domain-separated) | ZK-friendly commitment: H(DOMAIN, nullifier, secret, amount) |
Commitment, nullifier hash, binding hash, Merkle tree |
| Groth16 ZK-SNARK (Circom, depth-20 Merkle) | Prove Merkle membership without revealing leaf index or commitment | Withdrawal proof (~200k CU on-chain verification) |
| Nullifier Registry (on-chain PDA) | Prevent double-spend without revealing which deposit was consumed | Withdrawal execution |
| Binding Hash (Poseidon public output) | Cryptographically bind proof to specific recipient, relayer, and fee | Prevents proof front-running and parameter substitution |
| X25519 ECDH + Ed25519 Stealth Addresses | One-time unlinkable recipient addresses (BN254 field-compatible) | Withdrawal recipient generation |
| ECDH + AES-256-GCM Payload Encryption | End-to-end encryption of deposit requests (Tor exit node protection) | Deposit request payload |
| Tor Onion Routing (Docker SOCKS5 gateway) | Network-layer IP anonymity, different circuits per phase | Deposit + withdrawal request submission |
| Treasury / Deposit Wallet Separation | Break on-chain trace chain: pool → deposit wallet → ??? (dead end) | Relayer dual-wallet architecture |
| Randomized Timelock (1-24h) | Prevent timing correlation between request and execution | Withdrawal PendingWithdrawal PDA |
| Fixed Denomination Pools (7 buckets) | Prevent amount-based correlation across deposits/withdrawals | All pool operations |
- Blockchain: Solana (Anchor framework)
- ZK Proofs: Groth16 (Circom + snarkjs)
- Blind Signatures: RSA-2048 (RFC 9474)
- Network Privacy: Tor (Docker)
- Frontend: TanStack Start + React
- Backend: Rust (Axum)
TraceZero implements secure key management practices:
- RSA Private Key: Stored in
crates/relayer/rsa_signing_key.derwith600permissions (owner read/write only) - Treasury Keypair: Stored in
~/.config/tracezero/treasury.jsonoutside project directory with600permissions - Automatic Permission Setting: Keys are automatically created with secure permissions
- Minimal Logging: Key paths are logged at debug level only, not in production logs
Run the security test suite:
./scripts/test-security-fixes.shMIT