Architecture
The parts of Voidcash, what each one holds and sees, and how a shield, a private transfer and an unshield move through them.
Voidcash is a private SOL pool on Solana. SOL enters the pool as encrypted notes, moves between pool addresses without showing amounts or parties, and leaves to any Solana address. One Anchor program holds the SOL and checks a zero-knowledge proof for every move. Everything else is off-chain and replaceable: the app in the user's browser, a relayer that pays network fees for private actions, and an indexer that serves the pool's public data.
Parts
| Part | Where it runs | What it holds | What it does |
|---|---|---|---|
| App | The user's browser | The vault root, sealed with a passphrase in localStorage; the keys and the user's notes, in memory while unlocked | Derives keys, finds the user's notes, builds every transaction and proves it in a Web Worker |
| Wallet | The user's browser wallet (Wallet Standard) | The user's public SOL | Signs a shield, once. Can be the destination of an unshield. Never signs anything inside the pool |
| Pool program | Solana, program ID HjQyQZ3xewjsBb3w8JFGJSh2ztauV4f6X4o5CrZTobZm | The SOL behind every note (the vault PDA), the note tree, one account per spent nullifier, the fees, the buyback | Verifies each Groth16 proof, records nullifiers, inserts commitments, moves SOL. See The pool program |
| Relayer | A server | A fee-payer key | Submits private transfers and unshields as the fee payer, so no user wallet appears in them. Runs the buyback crank. See The relayer |
| Indexer | A server | Postgres: every leaf with its ciphertext, every nullifier, the public activity | Serves the pool's public data to every client alike, and alerts on large exits, pauses, parameter changes and upgrades. See The indexer |
| Solana RPC | A public endpoint, set when the app is built | Nothing of the user's | Gives the app the tree account, the config account and balances, and carries the wallet's shield transaction |
the user's browser servers Solana
+---------------------------+ all leaves, +-------------+ reads +----------------------+
| app | all nullifiers | indexer | <------- | pool program |
| vault (sealed root) | <------------------- | + Postgres | | config |
| keys, notes (memory) | +-------------+ | tree (roots) |
| prover (Web Worker) | proof + ext data +-------------+ sends | vault (SOL) |
| | -------------------> | relayer | -------> | nullifier PDAs |
+-------------+-------------+ +-------------+ | treasury, buyback |
| +----------------------+
| shield: one transaction signed by the user's wallet ^
+------------------------------------------------------------------------+
| tree root and config, read before every proof |
+------------------------------------------------------------------------+What each part sees
| Part | Sees | Does not see |
|---|---|---|
| Solana (anyone reading the chain) | For every transaction: two nullifiers, two commitments, two 88-byte ciphertexts, the relayer and its fee. For a shield: the depositor's wallet and the amount. For an unshield: the recipient and the amount | Which notes a transaction spent, who owns any note, the amount of a private transfer, the sender or recipient of a private transfer |
| Relayer | The request it submits, which becomes public on-chain anyway, and the network address the request came from. It keeps no request logs | The user's keys, notes or balance, and which notes the proof spends |
| Indexer | What the chain shows. Clients download every leaf and every nullifier, so their requests do not say which notes are theirs | Which leaves belong to whom |
| App | Everything about the user's own notes | Other users' notes: it can open only ciphertexts encrypted to its own viewing key |
A shield
Sync. The app downloads new leaves and every nullifier from the indexer, rebuilds the tree, and checks that the tree's root is in the program's root history (see The Merkle tree). It reads the pool's fees and minimum deposit from the config account.
Plan. The app creates a note for the amount, owned by the user's note public key (or by another
void address), and a zero note. It encrypts the first note to its owner's viewing key and fills the second
ciphertext with random bytes. The ext data names the depositor's wallet as recipient and relayer, the amount as
ext_amount, no relayer fee, a deadline 150 slots ahead, and the pool's shield fee rate.
Prove. The Web Worker proves the transact circuit with two dummy inputs: nothing is spent, two notes are created,
and publicAmount is the amount.
Sign once. The wallet signs one transaction with the transact instruction, as its payer. The program checks the
deadline, the pause flags, the minimum deposit and the fee rate, verifies the proof, creates the two nullifier
accounts, inserts the two commitments, and moves the amount from the wallet into the vault, plus the shield fee to the
treasury and the buyback.
Find. The indexer stores the two new leaves. On its next sync the owner's app decrypts the ciphertext and finds the note.
Public: the depositor's wallet, the amount, the two commitments. Private: the void address that owns the note.
A private transfer
Sync and combine. As for a shield. If no two notes cover the amount plus the relayer fee, the app first merges notes through the relayer, two into one per transaction (see Notes).
Plan. The app picks one or two notes, creates a note for the recipient (encrypted to the recipient's
viewing key, from the void address) and a change note back to the sender (encrypted to the sender's own viewing
key). ext_amount is 0, the fee rate is 0, and the relayer fee comes out of the notes: publicAmount is minus the
relayer fee.
Prove. The worker proves the spend. The proof binds the relayer's account and fee, the deadline and both
ciphertexts through extDataHash.
Relay. The app posts the proof and ext data to the relayer's /v1/transact. The relayer checks the request,
rebuilds the transaction itself, simulates it and sends it as the fee payer.
Settle. The program verifies the proof, records both nullifiers, inserts both commitments, and pays the relayer fee from the vault. The recipient's app finds the new note on its next sync.
Public: two nullifiers, two commitments, two ciphertexts, the relayer and its fee. Private: the sender, the recipient and the amount.
An unshield
Sync and combine. As for a transfer.
Plan. The app spends one or two notes into a change note and a zero note. ext_amount is minus the amount,
the recipient is any Solana address, the fee rate is the pool's exit fee, and the relayer fee comes out of the notes.
Prove and relay. As for a transfer.
Pay out. The program verifies the proof and moves SOL out of the vault: the amount less the exit fee to the
recipient, the exit fee to the treasury and the buyback, the relayer fee to the relayer. It emits Exited with the
SOL that left and the vault's balance after it.
Public: the recipient and the amount. Private: which notes paid for it, and who owned them.
Who runs what
The program is the only part that holds funds or decides anything. Keys and proofs stay in the user's browser. The relayer cannot change a transaction it submits, and the indexer's data is checked against the program's own roots before any proof is built. The relayer and the indexer are run by the Voidcash team; the app uses the endpoints it was built with.