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
| Threat | How it is handled | Checked 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 again | The 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 bytes | Circuit tests: every public input tampered by +1 and by +r fails |
| The same note as both inputs, counting its amount twice | The circuit requires the two nullifiers to differ | Circuit 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 proof | extDataHash is squared in the circuit so the proof binds it | On-chain tests: a changed recipient, amount or relayer fee is refused with BadProof |
| Value created from nothing through a field wrap | Every 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 index | The circuit proves knowledge of the spending key behind the note's owner field, and the nullifier binds the commitment and its leaf index | Circuit 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 statement | Replay protection keys on nullifiers only, never on proof bytes: a nullifier account can be created once | On-chain tests: replaying a spend is refused |
| A forged proof from the setup's secret | One 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-256 | The published transcript and verifying key; see Trusted setup |
| Replay across deployments | The ext-data hash starts with the domain voidcash/transact/v1 and the program id | The 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
| Threat | How it is handled | Checked by |
|---|---|---|
Front-running initialize with another admin or parameters | Only 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 it | Admin tests: a non-authority, another program's ProgramData and a pre-funded tree address |
| A changed recipient, amount, relayer or fee after proving | All of them, with the deadline, the fee rate and both ciphertexts, are hashed into the ext-data hash, a public input | On-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 built | ExtData.fee_bps must equal the pool's current rate for that direction (FeeChanged), and the rate is in the ext-data hash | On-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 transaction | A 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 notes | A 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 account | Nullifier accounts are created with Anchor init, which tolerates lamports sent ahead; they are never closed | On-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 proof | The 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 history | On-chain tests: two deposits in one slot, the second proven against the first's root |
| A full tree | The tree has depth 26 (2^26 leaves); a full tree refuses every transact (TreeFull), since each inserts two leaves | The program: once the tree is full, every transact is refused with TreeFull |
| Dust filling the tree | A minimum deposit, 0.01 SOL at launch, and two nullifier accounts' rent paid by every transaction | On-chain tests: a deposit below the minimum is refused |
| Notes lost with the indexer | Commitments are in the Inserted event, ciphertexts and nullifiers in the instruction data, so the tree and the notes can be rebuilt from the chain | Indexer tests: a second instance rebuilds the same tree from the chain |
| Unauthorized admin actions | Every admin instruction checks the Config.admin signer; pause accepts the guardian or the admin, unpause only the admin; a new admin must sign accept_admin | Admin tests: parameters, pause and unpause, the two-step handover and the treasury |
| Fees set out of bounds | set_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 key | Exits 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 flag | Indexer 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.
| Threat | How it is handled | Checked by |
|---|---|---|
| A decoy pool | The 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 accounts | Buyback 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 buyback | The 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 transaction | A 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 slots | Buyback tests: after a stale ring, no buy until the ring refills |
| Oversized buys that move the price out of the band | The 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 buy | Buyback tests on a 100 SOL pool, with SOL as either token of the pool |
| Pool fee raised or pool disabled by Meteora | A disabled pool is refused (PoolDisabled); a raised fee fails the minimum output | Buyback tests: a disabled pool is refused; a 1% pool fee buys and a 3% pool fee buys nothing |
| Draining through caller payments | Neither 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 fees | Buyback 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 burn | set_void_mint accepts only an initialized SPL Token mint, and only until a pool is pinned | Buyback tests: a non-admin, a non-mint, a Token-2022 mint, and a change after pinning are refused |
The relayer
| Threat | How it is handled | Checked by |
|---|---|---|
| Relabelling the recipient, amount or fee | Bound in the ext-data hash; the program refuses any change | On-chain tests: each tampered field is refused with BadProof |
| Replay with another fee payer | The fee goes to the relayer account bound in the proof, not to whoever pays the transaction | The program: the relayer account is part of the ext-data hash |
| The user adding an instruction that spends the relayer's SOL | The relayer never signs a transaction the user built: it rebuilds it from the request's fields, adds only a compute budget, and simulates before sending | Relayer tests: the relayer refuses what it should not send |
| Requests that cost the relayer | Fees 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 sent | Relayer tests |
| Oversized transactions | A transaction above 1,232 bytes is refused before sending | The relayer's size check, before simulation |
| Logging users | The HTTP server keeps no request logs: no IP addresses, no bodies. The RPC URL is replaced with <rpc> in the log | The relayer's log, which records the signature of each transaction it sends |
| A crank wasting SOL | The 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 passes | Crank tests |
| Write-lock contention on the tree account | Every transact writes the tree, so they are serialized. The relayer pays a priority fee | The relayer's default priority fee, 50,000 micro-lamports per compute unit |
The indexer and the app
| Threat | How it is handled | Checked by |
|---|---|---|
| An indexer hiding or inventing leaves | The 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 mismatch | Indexer tests: a tampered row stops the service |
| An indexer hiding a nullifier, so a spent note looks spendable | The spend fails: its nullifier account already exists, and the relayer's simulation refuses it | On-chain tests: replaying a spend is refused |
| An altered ciphertext | The app accepts a decrypted note only if it recomputes the leaf's commitment | Client tests: scanning |
| Learning which notes are whose from queries | The app downloads every leaf and every nullifier; it never asks for a single note | The indexer API, which serves pages of leaves and nullifiers, not single notes |
| Events from another program in the same transaction | A Program data: line counts only while the pool program itself is executing; truncated logs stop indexing | Indexer tests: only events the pool program itself emitted are counted |
| A phished wallet signature | Keys come from a random root generated in the browser, not from the wallet | Client tests: keys |
| Injected scripts reading keys or sending them out | A 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 relayer | The app's smoke test fails on any Content-Security-Policy violation |
| Swapped circuit files | The prover worker checks the proving key and the circuit's WASM against SHA-256 hashes pinned in the build | The pinned hashes are in Constants; the files the app serves under /circuit/ can be hashed and compared |
| A keyed RPC URL shipped to every browser | The app's endpoints carry no key | The app's network requests, which anyone can inspect in the browser |
| A stolen browser profile | The root is sealed with the passphrase: PBKDF2-SHA256 with 600,000 rounds, then AES-256-GCM | Client tests: the sealed root opens with the passphrase only |
| Supply chain | Every dependency is pinned to an exact version and checked against advisories before adoption and before each deploy; install scripts are off in the test run | The 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.