voidcashdocs

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

PartWhere it runsWhat it holdsWhat it does
AppThe user's browserThe vault root, sealed with a passphrase in localStorage; the keys and the user's notes, in memory while unlockedDerives keys, finds the user's notes, builds every transaction and proves it in a Web Worker
WalletThe user's browser wallet (Wallet Standard)The user's public SOLSigns a shield, once. Can be the destination of an unshield. Never signs anything inside the pool
Pool programSolana, program ID HjQyQZ3xewjsBb3w8JFGJSh2ztauV4f6X4o5CrZTobZmThe SOL behind every note (the vault PDA), the note tree, one account per spent nullifier, the fees, the buybackVerifies each Groth16 proof, records nullifiers, inserts commitments, moves SOL. See The pool program
RelayerA serverA fee-payer keySubmits private transfers and unshields as the fee payer, so no user wallet appears in them. Runs the buyback crank. See The relayer
IndexerA serverPostgres: every leaf with its ciphertext, every nullifier, the public activityServes the pool's public data to every client alike, and alerts on large exits, pauses, parameter changes and upgrades. See The indexer
Solana RPCA public endpoint, set when the app is builtNothing of the user'sGives 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

PartSeesDoes 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 amountWhich notes a transaction spent, who owns any note, the amount of a private transfer, the sender or recipient of a private transfer
RelayerThe request it submits, which becomes public on-chain anyway, and the network address the request came from. It keeps no request logsThe user's keys, notes or balance, and which notes the proof spends
IndexerWhat the chain shows. Clients download every leaf and every nullifier, so their requests do not say which notes are theirsWhich leaves belong to whom
AppEverything about the user's own notesOther 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.

On this page