voidcashdocs

Threat model

The attacks Voidcash is built against, how each one is handled, and the tests that check it.

Each row names an attack, the rule that handles it, and what checks that rule. The test suites are described under The tests.

Proofs and the circuit

ThreatHow it is handledChecked by
Non-canonical public inputs. A value plus a multiple of the field order r verifies like the value itself, so a nullifier plus r would spend a note againThe program builds every public input itself: the root from its own history (never from the caller), the ext-data hash on-chain, masked below 2^253, and the public amount from the ext data. groth16-solana's verify() rejects any input of r or more; verify_unchecked is never used. Nullifier account seeds are the nullifiers' canonical bytesCircuit tests: every public input tampered by +1 and by +r fails
The same note as both inputs, counting its amount twiceThe circuit requires the two nullifiers to differCircuit tests: the same note as both inputs cannot be proven
An unbound public input. A public input no constraint uses is not fixed by the proofextDataHash is squared in the circuit so the proof binds itOn-chain tests: a changed recipient, amount or relayer fee is refused with BadProof
Value created from nothing through a field wrapEvery input and output amount is range-checked to 64 bits, and the program bounds every amount to 2^60 lamports (MAX_AMOUNT)Circuit tests: value not conserved, and an output amount at 2^64, cannot be proven
Spending someone else's note, or a real note at the wrong indexThe circuit proves knowledge of the spending key behind the note's owner field, and the nullifier binds the commitment and its leaf indexCircuit tests: a spend with the wrong private key, and a real note at the wrong index, cannot be proven
Proof malleability: a different proof for the same statementReplay protection keys on nullifiers only, never on proof bytes: a nullifier account can be created onceOn-chain tests: replaying a spend is refused
A forged proof from the setup's secretOne contribution on the public Hermez phase 1, closed with a Solana block hash, with a published transcript. The build embeds the published verifying key and its SHA-256The published transcript and verifying key; see Trusted setup
Replay across deploymentsThe ext-data hash starts with the domain voidcash/transact/v1 and the program idThe program computes the ext-data hash itself, with its own program id, so a proof made for another deployment does not verify

The pool program

ThreatHow it is handledChecked by
Front-running initialize with another admin or parametersOnly the program's upgrade authority can initialize, checked through this program's own ProgramData. Lamports sent to the tree's address beforehand do not block itAdmin tests: a non-authority, another program's ProgramData and a pre-funded tree address
A changed recipient, amount, relayer or fee after provingAll of them, with the deadline, the fee rate and both ciphertexts, are hashed into the ext-data hash, a public inputOn-chain tests: a changed recipient, amount, relayer fee, root index or one bit of the proof is refused with BadProof
A fee changed after a transaction was builtExtData.fee_bps must equal the pool's current rate for that direction (FeeChanged), and the rate is in the ext-data hashOn-chain tests: an exit built at 0.5% after the admin moved the fee to 0.6% is refused, and so is the same exit relabelled at 0.6%
A relayer holding a transactionA deadline slot in the ext data; the program refuses a transaction after it (Expired)On-chain tests: a transaction after its deadline slot is refused
A deposit that pays a relayer from someone else's notesA deposit's relayer fee must be zero (DepositFee)On-chain tests: a deposit that pays a relayer is refused
Freezing a note by pre-funding its nullifier accountNullifier accounts are created with Anchor init, which tolerates lamports sent ahead; they are never closedOn-chain tests: with lamports sent to the nullifier's address, the spend still goes through
Roots pushed out of the history by a burst of inserts during a proofThe history keeps one root per slot that had an insert, 192 slots of them; inserts within a slot replace that slot's entry. The client passes an index into the historyOn-chain tests: two deposits in one slot, the second proven against the first's root
A full treeThe tree has depth 26 (2^26 leaves); a full tree refuses every transact (TreeFull), since each inserts two leavesThe program: once the tree is full, every transact is refused with TreeFull
Dust filling the treeA minimum deposit, 0.01 SOL at launch, and two nullifier accounts' rent paid by every transactionOn-chain tests: a deposit below the minimum is refused
Notes lost with the indexerCommitments are in the Inserted event, ciphertexts and nullifiers in the instruction data, so the tree and the notes can be rebuilt from the chainIndexer tests: a second instance rebuilds the same tree from the chain
Unauthorized admin actionsEvery admin instruction checks the Config.admin signer; pause accepts the guardian or the admin, unpause only the admin; a new admin must sign accept_adminAdmin tests: parameters, pause and unpause, the two-step handover and the treasury
Fees set out of boundsset_params refuses parameters out of bounds: a minimum deposit above zero, each fee at most 1%, the buyback share at most 100%Program unit tests and admin tests
The whole balance leaving at once through a fault in the verifier, the setup, the program or the dev's keyExits are not rate-limited and the pool has no size cap. Every SOL path out of the vault emits Exited; the indexer alerts on exits above 2% of the vault or 5% within about an hour, on pauses, parameter changes and upgrades; each path has its own pause flagIndexer tests: the alerts for a large exit, a pause, a parameter change and an upgrade

The buyback

The buyback buys VOID with its share of the fees on a pinned Meteora DAMM v2 pool and burns it. Anyone can call observe and buyback; the relayer runs a crank that does. See The buyback.

ThreatHow it is handledChecked by
A decoy poolThe admin pins one pool with init_buyback: owned by cp-amm, 1,112 bytes, VOID against wSOL. Every later call must pass that pool, its vaults and the buyback's own token accountsBuyback tests: a non-admin, an account that is not a pool, and another account in place of the pinned pool are refused
A pumped price, bought into by the buybackThe reference is the median of the program's own 15 observations, at most one per 30 slots. A buy runs only if the price is within 0.5% of the reference on the square root, about 1% on the price, before and after the swap, and its minimum output is the reference's less 2%Buyback tests: a ~2% pump, one poisoned observation, an in-band sandwich that loses the bot money, a rally, a sell-off and chop
A stale ring: after a quiet hour and a fall, one fresh observation plus a pump back to the old median in the buy's own transactionA gap of more than 90 slots between observations restarts the ring, so a buy needs 15 fresh observations in a row, the latest within 90 slotsBuyback tests: after a stale ring, no buy until the ring refills
Oversized buys that move the price out of the bandThe program sizes each chunk: the lowest of 1 SOL, 0.5% of the pool's SOL reserve, what the buyback holds, and 90% of what fits in the band from the current price. One chunk per 150 slots; under 0.01 SOL, no buyBuyback tests on a 100 SOL pool, with SOL as either token of the pool
Pool fee raised or pool disabled by MeteoraA disabled pool is refused (PoolDisabled); a raised fee fails the minimum outputBuyback tests: a disabled pool is refused; a 1% pool fee buys and a 3% pool fee buys nothing
Draining through caller paymentsNeither observe nor buyback pays its caller anything. The only SOL that leaves the buyback is the chunk, into the swap, and what it buys is burned in the same instruction. The crank pays its own feesBuyback tests: 300 calls in every state, with SOL as either token of the pool, check that no call gains its caller a lamport and that the buyback loses SOL only into a burned buy
A Token-2022 VOID mint the buyback cannot burnset_void_mint accepts only an initialized SPL Token mint, and only until a pool is pinnedBuyback tests: a non-admin, a non-mint, a Token-2022 mint, and a change after pinning are refused

The relayer

ThreatHow it is handledChecked by
Relabelling the recipient, amount or feeBound in the ext-data hash; the program refuses any changeOn-chain tests: each tampered field is refused with BadProof
Replay with another fee payerThe fee goes to the relayer account bound in the proof, not to whoever pays the transactionThe program: the relayer account is part of the ext-data hash
The user adding an instruction that spends the relayer's SOLThe relayer never signs a transaction the user built: it rebuilds it from the request's fields, adds only a compute budget, and simulates before sendingRelayer tests: the relayer refuses what it should not send
Requests that cost the relayerFees below its quote, another relayer's field, a past deadline or one more than 900 slots ahead, and deposits are refused with 400; a failed simulation with 422, before anything is sentRelayer tests
Oversized transactionsA transaction above 1,232 bytes is refused before sendingThe relayer's size check, before simulation
Logging usersThe HTTP server keeps no request logs: no IP addresses, no bodies. The RPC URL is replaced with <rpc> in the logThe relayer's log, which records the signature of each transaction it sends
A crank wasting SOLThe crank sends nothing while no pool is pinned, the buyback is paused or holds less than 0.01 SOL; it simulates every transaction and sends only what passesCrank tests
Write-lock contention on the tree accountEvery transact writes the tree, so they are serialized. The relayer pays a priority feeThe relayer's default priority fee, 50,000 micro-lamports per compute unit

The indexer and the app

ThreatHow it is handledChecked by
An indexer hiding or inventing leavesThe app rebuilds the tree and finds its root in the program's history before proving; on a mismatch it starts over and proves nothing. The indexer checks the same itself and answers 503 on a mismatchIndexer tests: a tampered row stops the service
An indexer hiding a nullifier, so a spent note looks spendableThe spend fails: its nullifier account already exists, and the relayer's simulation refuses itOn-chain tests: replaying a spend is refused
An altered ciphertextThe app accepts a decrypted note only if it recomputes the leaf's commitmentClient tests: scanning
Learning which notes are whose from queriesThe app downloads every leaf and every nullifier; it never asks for a single noteThe indexer API, which serves pages of leaves and nullifiers, not single notes
Events from another program in the same transactionA Program data: line counts only while the pool program itself is executing; truncated logs stop indexingIndexer tests: only events the pool program itself emitted are counted
A phished wallet signatureKeys come from a random root generated in the browser, not from the walletClient tests: keys
Injected scripts reading keys or sending them outA nonce-based Content-Security-Policy with 'strict-dynamic', no 'unsafe-inline' for scripts, and connect-src limited to the app, the RPC, the indexer and the relayerThe app's smoke test fails on any Content-Security-Policy violation
Swapped circuit filesThe prover worker checks the proving key and the circuit's WASM against SHA-256 hashes pinned in the buildThe pinned hashes are in Constants; the files the app serves under /circuit/ can be hashed and compared
A keyed RPC URL shipped to every browserThe app's endpoints carry no keyThe app's network requests, which anyone can inspect in the browser
A stolen browser profileThe root is sealed with the passphrase: PBKDF2-SHA256 with 600,000 rounds, then AES-256-GCMClient tests: the sealed root opens with the passphrase only
Supply chainEvery dependency is pinned to an exact version and checked against advisories before adoption and before each deploy; install scripts are off in the test runThe pinned versions and the record of each check

The tests

The tests use real Groth16 proofs. The program runs in LiteSVM, a Solana VM inside the test process, so no local validator is started.

The circuit. Proofs for a deposit and a two-input spend verify; every public input tampered by +1 and by +r fails; invalid witnesses cannot be proven: a wrong root, value not conserved, the same note twice, the wrong private key, an amount at 2^64, a note at the wrong index.

The client. Poseidon against circomlibjs and circomlib's test vector, the tree against a reference, keys, addresses and recovery keys with their checksums, the sealed root, note encryption, scanning, merge planning, request JSON, PDAs and account layouts, and real proofs.

The program, in LiteSVM. The built program with real proofs, and Meteora's mainnet cp-amm program with real cp-amm pool accounts (USDC stands in for VOID), with SOL as either token of the pool. It checks every error code, initialize, every refusal in front of a proof, deposits, a spend and its replay, a private transfer, the shield and exit fees with their split, the admin instructions, and the buyback in every state, including the stale ring and the 300-call loop.

The relayer. The real program in LiteSVM: withdrawals, private transfers and note merges through the relayer, every refusal, and the HTTP API. The crank against the program and Meteora's mainnet cp-amm: one observation per interval, the buy when due, nothing while paused or without SOL, no transaction the program would refuse, and exactly one base fee per transaction.

The indexer. The real program in LiteSVM and Postgres in-process: deposits, a withdrawal and a transfer through the relayer, a failed transaction; the stored leaves, ciphertexts and nullifiers against the clients' own view, the root against the chain, the activity and stats, the alerts, a second instance rebuilding the tree, and a tampered row stopping the service.

The app. The built site in headless Chrome: the landing renders; create a vault, see the recovery key once, lock, unlock with the passphrase, refuse a wrong one, restore from the recovery key; and no Content-Security-Policy violation or page error along the way.

On this page