Skip to content

Instantly share code, notes, and snippets.

@MdSadiqMd
Created February 27, 2026 14:29
Show Gist options
  • Select an option

  • Save MdSadiqMd/b8741cad3b19410e5966c6daf9707c66 to your computer and use it in GitHub Desktop.

Select an option

Save MdSadiqMd/b8741cad3b19410e5966c6daf9707c66 to your computer and use it in GitHub Desktop.
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)
        D->>R: GET /info → RSA pubkey (n, e) + treasury Solana address
        D->>D: token_id = CSPRNG(256 bits)
        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
        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)
        R->>R: signed_blinded = RSA_BlindSign(blinded_token, RSA_privkey)
        R-->>D: signed_blinded_token
        D->>D: signature = RSA_Unblind(signed_blinded, r, RSA_pubkey)
        D->>D: Encrypt & store (token_id, signature) in localStorage
        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)
        D->>D: nullifier = CSPRNG(256 bits)
        D->>D: secret = CSPRNG(256 bits)
        D->>D: commitment = Poseidon(DOMAIN_COMMIT, nullifier, secret, amount)
        D->>D: ECDH shared_secret = X25519(ephemeral_priv, relayer_ecdh_pub)
        D->>D: ciphertext = AES-256-GCM(payload, shared_secret)
        D->>Tor: Encrypted {token_id, signature, commitment, amount}
        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: Assert H(token_id) ∉ UsedTokenStore (disk-persisted)
        R->>R: Persist H(token_id) to UsedTokenStore (atomic write + SHA-256 checksum)
        R->>R: Insert commitment into local Poseidon Merkle tree (depth=20)
        R->>R: merkle_root = recompute root
        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.
        S->>P: Update on-chain Merkle root + next_index
        S->>S: Init UsedToken PDA [seeds: "used_token", token_hash]
        S->>S: Init EncryptedNote PDA [seeds: "note", pool, index]
        R-->>Tor: {tx_signature, leaf_index}
        Tor-->>D: Forward response
        D->>D: Encrypt & store (nullifier, secret, leaf_index) in localStorage
    end

    rect rgb(80, 50, 50)
        Note over D,SA: Phase 3 — Withdrawal (Groth16 ZK proof, zero-knowledge of depositor)
        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)
        D->>R: GET /pool/{bucket_id} → current merkle_root
        D->>R: GET /proof/{bucket_id}/{leaf_index} → siblings[], pathIndices[]
        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)
        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
        Tor->>R: Forward (different exit node)
        R->>S: request_withdrawal(proof, nullifier_hash, stealth_addr, binding_hash, delay)
        S->>S: CPI → ZK Verifier: Groth16 verify (proof_a negated, VK, public_inputs)
        S->>S: Assert nullifier_hash ∉ NullifierRegistry
        S->>S: Init PendingWithdrawal PDA [seeds: "pending", pool, tx_id]
        S->>S: Set execute_after = Clock::get() + random_delay
        Note over S: Timelock: 1-24 hours (user-chosen random delay)
        R->>R: Pre-fund recipient if balance < rent-exempt minimum (890,880 lamports)
        R->>R: Pre-fund treasury PDA if needed
        R->>S: execute_withdrawal(tx_id)
        S->>S: Assert Clock::get() ≥ execute_after
        S->>S: Init Nullifier PDA [seeds: "nullifier", hash] (marks spent)
        P->>SA: Credit (amount - fee) lamports via try_borrow_mut_lamports
        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)
        D->>D: Load stealth secret key from 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."
    end
Loading

On-Chain Trace Analysis

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]
    B2[Groth16 ZK-SNARK<br/>+ Nullifier + Binding Hash]
    B3[X25519 ECDH Stealth Address<br/>+ BN254 Field Reduction]

    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

    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 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
Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment