voidcashdocs

The pool program

The Anchor program that holds the pool's SOL - its accounts and seeds, every instruction and who may call it, fees, pause flags, events, limits and upgrades.

The pool is one Anchor program (Anchor 1.2.1). It holds the SOL behind every note, verifies a Groth16 proof for every transaction, and runs the buyback of $VOID. Its declared program ID is HjQyQZ3xewjsBb3w8JFGJSh2ztauV4f6X4o5CrZTobZm.

Accounts

Every account is a PDA of the program, with a fixed seed: anyone can derive each address from the seeds below and the program ID.

AccountSeedsKindHolds
Config["config"]Config, 280 bytesAdmin, pending admin, guardian, $VOID mint, pause flags, parameters, 128 reserved bytes
Tree["tree"]Raw account owned by the program, 9,400 bytesThe note tree and its root history. See The Merkle tree
Vault["vault"]Vault, 8 bytes, owned by the programThe SOL behind every note, as its lamport balance
Treasury["treasury"]System accountThe treasury's share of the fees
Buyback["buyback"]System accountThe buyback's share of the fees, until it is spent on $VOID
Nullifier["nf", nullifier]Spent, 8 bytesNothing: its existence marks the nullifier spent. The seed is the nullifier's 32 big-endian bytes
Buyback state["bbstate"]BuybackState, 450 bytesThe pinned pool, the buyback's token accounts, its price observations, the $VOID burned so far. See Buyback and burn

Notes hold native lamports, so the pool itself has no token accounts. The treasury and the buyback are funded with their rent-exempt minimum at initialize, so small fee credits to them are accepted.

Config

FieldTypeMeaning
bumpu8
adminPubkeySets parameters, unpauses, moves treasury SOL, sets the $VOID mint, pins the buyback pool
pending_adminPubkeySet by propose_admin, takes effect when it signs accept_admin
guardianPubkeyCan only pause
void_mintPubkeyThe mint the buyback buys and burns
pausedu8Pause flags (below)
paramsParamsmin_deposit (u64, lamports), shield_fee_bps, exit_fee_bps, buyback_share_bps (u16 each)
reserved128 bytesZero, so a later upgrade can add parameters without migrating the account

The launch parameters are a minimum deposit of 0.01 SOL (10,000,000 lamports), no shield fee, a 0.5% exit fee (50 bps) and a buyback share of 5,000 bps (half of every fee). The values in force are always the config account's: the app reads them on every sync.

Instructions

InstructionWho may callWhat it does
initialize(admin, guardian, void_mint, params)The program's upgrade authority, onceChecks the parameters, creates the config, tree and vault, funds the treasury and buyback rent. The program checks the upgrade authority against its own ProgramData, so nobody can front-run it
transact(proof, root_index, nullifiers, commitments, ext)Anyone; the payer signsEvery shield, private transfer, merge and unshield (below)
init_buybackAdmin, oncePins the $VOID/SOL Meteora DAMM v2 pool and the buyback's token accounts
observeAnyoneRecords the pinned pool's price, at most once per interval. Pays nothing
buybackAnyoneBuys $VOID with one chunk of the buyback's SOL and burns it. Pays the caller nothing
set_void_mintAdmin, until init_buybackSets the $VOID mint: an initialized SPL Token mint only
set_params(params)AdminReplaces the parameters, within the limits below
set_guardian(guardian)AdminReplaces the guardian
propose_admin(admin)AdminNames a pending admin
accept_adminThe pending adminBecomes the admin; clears the pending admin
pause(flags)Guardian or adminSets pause flags. flags must be non-zero and only known flags
unpause(flags)AdminClears pause flags
withdraw_treasury(amount)AdminMoves treasury SOL to any destination, keeping the account's rent

At initialization one key is the upgrade authority, the admin and the guardian.

transact

Accounts, in order: payer (signer, writable), config, tree, vault, treasury, buyback, nullifier 0, nullifier 1 (both created here), recipient, relayer, system program.

Data, after the 8-byte discriminator: the proof (256 bytes), root_index (u8), two nullifiers and two commitments (32 bytes each, big-endian), then ExtData:

ExtData fieldTypeMeaning
ext_amounti64Lamports entering the pool (positive) or leaving it (negative), before the exit fee. 0 for a private transfer
relayer_feeu64Lamports paid to the relayer from the notes
deadline_slotu64The last slot the transaction may land in
fee_bpsu16The fee rate the user was shown
ciphertext0, ciphertext188 bytes eachThe output notes, encrypted. See Notes

Checks, in order:

  1. The current slot is at most deadline_slot (Expired).
  2. The absolute ext_amount and the relayer_fee are at most MAX_AMOUNT (AmountTooLarge).
  3. By direction: a shield needs deposits open, at least min_deposit (DepositTooSmall) and no relayer fee (DepositFee); a private transfer needs transfers open; an unshield needs exits open. Any relayer fee also needs exits open (Paused).
  4. fee_bps equals the pool's current rate for the direction (FeeChanged).
  5. The program computes extDataHash from the accounts and the ext data, and publicAmount = ext_amount - relayer_fee, reads the root at root_index, and verifies the proof (BadRoot, BadProof). See The circuit.
  6. Anchor creates both nullifier accounts, paid by the payer. A spent nullifier's account already exists, so the transaction fails.

Then it inserts both commitments, emits Inserted, and moves SOL.

Fees

fee              = |ext_amount| × fee_bps / 10,000                 rounded down
buyback share    = fee × buyback_share_bps / 10,000                rounded down
treasury share   = fee − buyback share                             the rounding goes to the treasury

fee_bps is shield_fee_bps for a shield, exit_fee_bps for an unshield, and 0 for a private transfer.

ActionInto the vaultOut of the vault
Shieldext_amount from the payer. The shield fee is on top: the payer also pays the fee to the treasury and the buyback, and the note holds the full amountNothing
Private transfer or mergeNothingrelayer_fee to the relayer
UnshieldNothingThe amount less the fee to the recipient, the fee to the treasury and the buyback (it comes out of the exit), relayer_fee to the relayer

The notes pay |ext_amount| + relayer_fee for an unshield and relayer_fee for a transfer, which is what publicAmount makes the circuit balance. The payer of every transaction also pays the rent of its two nullifier accounts; for a relayed transaction the relayer is the payer, and its fee covers that rent.

The rate is part of the proof's ext data and must match the pool's: a fee change after a transaction was built makes the program refuse it, so no transaction is ever charged a rate the user was not shown.

Pause flags

FlagValueStops
PAUSE_DEPOSITS1Shields
PAUSE_TRANSFERS2Private transfers and merges
PAUSE_EXITS4Unshields, and every transaction that pays a relayer fee: every path that takes SOL out of the vault for a user
PAUSE_BUYBACK8buyback

The guardian or the admin sets flags; only the admin clears them. A relayed private transfer needs both transfers and exits open, since it pays the relayer from the vault. observe is not paused: it moves no SOL.

Events

EventFieldsEmitted
Insertedindex (u64, the first of the two leaves), commitments (two 32-byte values)By every transact
Exitedamount (u64, all SOL that left the vault in the instruction: exit, fee and relayer fee), vault_after (u64, the vault's balance after it)By every transact that moves SOL out of the vault

The indexer reads both, and alerts on Exited amounts above a share of the vault.

Limits

LimitValueEnforced
MAX_AMOUNT2^60 lamports, for ext_amount and relayer_feeBy transact (AmountTooLarge). Keeps the circuit's field sums from wrapping
MAX_FEE_BPS100 (1%), for the shield and the exit feeBy initialize and set_params (BadParams). Higher takes a program upgrade
buyback_share_bpsat most 10,000By initialize and set_params (BadParams)
min_depositabove 0By initialize and set_params (BadParams). Keeps dust notes out of the tree
deadline_slotthe transaction must land by itBy transact (Expired). The app sets 150 slots ahead; the relayer accepts at most 900 by default
CIPHERTEXT_LEN88 bytesBy the instruction layout: each ciphertext is a fixed 88-byte field
Tree2^26 leaves, 192 recent rootsBy transact (TreeFull, BadRoot)

There is no cap on how much can leave the vault per transaction or per time window, and no cap on the pool's size. Every exit emits Exited for monitoring.

Errors

ErrorMeaning
BadTreeThe tree account's magic or size is wrong
BadRootroot_index is out of range or names an unwritten entry
AmountTooLargeAn amount is above MAX_AMOUNT, or the buyback's expected output does not fit
BadProofThe proof does not verify
TreeFullNo room for two more leaves
PoseidonThe Poseidon syscall failed
LamportsNot enough lamports in the account being debited
ExpiredPast deadline_slot
PausedThe flag for this path is set
DepositTooSmallA shield below min_deposit
DepositFeeA shield with a relayer fee
FeeChangedfee_bps is not the pool's current rate
UnauthorizedThe signer may not call this instruction
BadParamsParameters or pause flags out of range
BadTokenAccount, InputNotSpent, WrongPool, TooSoon, NotEnoughObservations, StaleObservations, PoolDisabled, PricePumped, ChunkTooSmall, BuybackPinned, BadMintThe buyback's checks. See Buyback and burn

Upgrades

The program is upgradeable, so a bug can be fixed in place without moving funds to a new program. The upgrade authority and the config's admin are one key, the developer's, with no multisig and no timelock.

That key can change every rule: the fees (within MAX_FEE_BPS with set_params, or beyond it with an upgrade), the verifying key, and where funds go. With an upgrade it can move the vault's SOL. Users of the pool trust whoever holds it. The guardian can only pause.

Every upgrade is visible on-chain: the indexer alerts when the program's ProgramData changes, as it does on every pause and parameter change. The 128 reserved bytes in Config let an upgrade add parameters without an account migration.

On this page