Program
The pool program's on-chain interface: its accounts and PDAs, instructions, the transact layout, events and error codes.
The pool is one Anchor program (Anchor 1.2.1). Its address:
HjQyQZ3xewjsBb3w8JFGJSh2ztauV4f6X4o5CrZTobZmThe program is upgradeable, so the deployed program is the reference: an upgrade changes the last-deployed slot in
its ProgramData account. The Voidcash app builds and proves every transact. This page describes what it sends and
what the program stores, so you can read the pool's accounts, decode its transactions and check them.
Accounts and PDAs
Every PDA is derived from the program id with one fixed seed, except the nullifier accounts.
| Account | Seeds | Owner | Size | Holds |
|---|---|---|---|---|
| Config | "config" | Pool program | 280 bytes | The admin, the pending admin, the guardian, the VOID mint, the pause flags and the parameters |
| Tree | "tree" | Pool program | 9,400 bytes | The note tree and its root history, as a raw account |
| Vault | "vault" | Pool program | 8 bytes | The SOL behind every note, as its lamports |
| Treasury | "treasury" | System program | 0 bytes | The treasury's share of the fees |
| Buyback | "buyback" | System program | 0 bytes | The buyback's share of the fees, until a buy spends it |
| Buyback state | "bbstate" | Pool program | 450 bytes | The pinned pool and the price observations |
| Nullifier | "nf", then the nullifier's 32 bytes | Pool program | 8 bytes | Nothing: its existence marks a spent note |
With @solana/kit, a fixed-seed PDA is one call. A nullifier's account takes the nullifier as 32 big-endian
bytes:
import { address, getProgramDerivedAddress } from '@solana/kit';
const programAddress = address('HjQyQZ3xewjsBb3w8JFGJSh2ztauV4f6X4o5CrZTobZm');
const [config] = await getProgramDerivedAddress({ programAddress, seeds: ['config'] });
const [tree] = await getProgramDerivedAddress({ programAddress, seeds: ['tree'] });
const be32 = (x: bigint) => Uint8Array.from(x.toString(16).padStart(64, '0').match(/../g)!, (h) => parseInt(h, 16));
const [spent] = await getProgramDerivedAddress({ programAddress, seeds: ['nf', be32(nullifier)] });
// The note behind `nullifier` is spent if and only if `spent` exists.The buyback also uses two SPL Token associated token accounts owned by the buyback PDA: one for wSOL, one for VOID.
Their addresses are fixed in the buyback state by init_buyback.
Field elements (roots, nullifiers, commitments) are 32 bytes, big-endian. Integers are little-endian.
Config
| Offset | Field | Type |
|---|---|---|
| 0 | Anchor discriminator | 8 bytes |
| 8 | bump | u8 |
| 9 | admin | Pubkey |
| 41 | pending_admin | Pubkey |
| 73 | guardian | Pubkey |
| 105 | void_mint | Pubkey |
| 137 | paused | u8, the pause flags |
| 138 | params.min_deposit | u64, lamports |
| 146 | params.shield_fee_bps | u16 |
| 148 | params.exit_fee_bps | u16 |
| 150 | params.buyback_share_bps | u16, the buyback's share of every fee |
| 152 | reserved | 128 zero bytes, for parameters a later version adds |
The pause flags are 1 deposits, 2 private transfers, 4 exits and relayer fees, 8 the buyback.
Tree
The tree account is not an Anchor account: it starts with the magic voidtree.
| Offset | Field |
|---|---|
| 0 | Magic, voidtree (8 bytes) |
| 8 | Next leaf index, u64 |
| 16 | Current index in the root history, u64 |
| 24 | Filled subtrees, 26 × 32 bytes |
| 856 | Zero hashes, 27 × 32 bytes |
| 1,720 | Root history: 192 entries of a root (32 bytes) and the slot it was recorded in (u64) |
The tree is an append-only Poseidon Merkle tree of depth 26 whose empty leaf is 0. Each transact appends two
leaves. The history keeps one root per slot that had an insert: a later insert in the same slot replaces that slot's
entry, and a new slot takes the next one.
Buyback state
| Offset | Field | Type |
|---|---|---|
| 0 | Anchor discriminator | 8 bytes |
| 8 | bump | u8 |
| 9 | pool | Pubkey, the pinned cp-amm pool |
| 41 | sol_is_a | bool |
| 42 | token_a_vault | Pubkey |
| 74 | token_b_vault | Pubkey |
| 106 | buyback_wsol | Pubkey |
| 138 | buyback_token | Pubkey |
| 170 | last_buy_slot | u64 |
| 178 | last_obs_slot | u64 |
| 186 | obs_count | u64 |
| 194 | obs_head | u64 |
| 202 | obs | 15 × u128, square-root prices |
| 442 | burned | u64, VOID burned so far |
Instructions
Discriminators are Anchor's: the first 8 bytes of sha256("global:<name>"). Arguments follow in Borsh.
import { createHash } from 'node:crypto';
const discriminator = (name: string) => createHash('sha256').update(`global:${name}`).digest().subarray(0, 8);
discriminator('transact').toString('hex'); // 'd995828fdd34fc77'| Instruction | Discriminator | Arguments | Signer |
|---|---|---|---|
initialize | afaf6d1f0d989bed | admin: Pubkey, guardian: Pubkey, void_mint: Pubkey, params: Params | The program's upgrade authority |
transact | d995828fdd34fc77 | See transact | The fee payer |
init_buyback | 0bc3019ab872ad63 | none | The admin |
observe | cc4eb273c293414a | none | None |
buyback | 6a75401e3845072d | none | Any caller |
set_params | 1beab2349302bb8d | params: Params | The admin |
set_void_mint | e2f10bb486154901 | none (the mint is an account) | The admin |
set_guardian | 93f332799aa4321e | guardian: Pubkey | The admin |
propose_admin | 79d6c7d4572775ea | admin: Pubkey | The admin |
accept_admin | 702a2d5a74b50daa | none | The pending admin |
pause | d316ddfb4a79c12f | flags: u8 | The guardian or the admin |
unpause | a99004260a8dbcff | flags: u8 | The admin |
withdraw_treasury | 283f7a9e90d85360 | amount: u64 | The admin |
Params is min_deposit: u64, shield_fee_bps: u16, exit_fee_bps: u16, buyback_share_bps: u16, 14 bytes.
pause takes a non-zero combination of the four flags.
transact
transact shields, sends privately and unshields. Its direction comes from the sign of ext_amount.
pub fn transact(
ctx: Context<Transact>,
proof: [u8; 256],
root_index: u8,
nullifiers: [[u8; 32]; 2],
commitments: [[u8; 32]; 2],
ext: ExtData,
) -> Result<()>
pub struct ExtData {
pub ext_amount: i64, // lamports entering (> 0) or leaving (< 0) the pool, before the exit fee
pub relayer_fee: u64, // lamports, paid from the notes
pub deadline_slot: u64, // the last slot the transaction may land in
pub fee_bps: u16, // the pool's fee rate for this direction, as shown to the user
pub ciphertext0: [u8; 88],
pub ciphertext1: [u8; 88],
}Instruction data
595 bytes:
| Offset | Bytes | Field |
|---|---|---|
| 0 | 8 | Discriminator d995828fdd34fc77 |
| 8 | 256 | proof: A negated (64), B as x.c1, x.c0, y.c1, y.c0 (128), C (64) |
| 264 | 1 | root_index, an index into the root history (0 to 191) |
| 265 | 64 | nullifiers, two field elements |
| 329 | 64 | commitments, the two output commitments |
| 393 | 8 | ext_amount, i64 |
| 401 | 8 | relayer_fee, u64 |
| 409 | 8 | deadline_slot, u64 |
| 417 | 2 | fee_bps, u16 |
| 419 | 88 | ciphertext0 |
| 507 | 88 | ciphertext1 |
Accounts
In this order:
| # | Account | Writable | Signer |
|---|---|---|---|
| 0 | Fee payer: the depositor for a shield, the relayer otherwise | yes | yes |
| 1 | Config PDA | yes | |
| 2 | Tree PDA | yes | |
| 3 | Vault PDA | yes | |
| 4 | Treasury PDA | yes | |
| 5 | Buyback PDA | yes | |
| 6 | Nullifier PDA for nullifiers[0], created here | yes | |
| 7 | Nullifier PDA for nullifiers[1], created here | yes | |
| 8 | Recipient, bound in the ext-data hash | yes | |
| 9 | Relayer, bound in the ext-data hash | yes | |
| 10 | System program |
The fee payer pays the two nullifier accounts' rent, and a shield's amount and fee.
What the program checks
In order:
- The current slot is at most
deadline_slot(Expired). |ext_amount|andrelayer_feeare at most 2^60 (AmountTooLarge).- The pause flag for the direction is clear: deposits for
ext_amount > 0, transfers for0, exits for< 0, and exits for anyrelayer_fee > 0(Paused). - A deposit is at least
min_deposit(DepositTooSmall) and pays no relayer (DepositFee). fee_bpsequals the pool's rate for the direction:shield_fee_bps,0orexit_fee_bps(FeeChanged).- The Groth16 proof verifies (
BadProof) against seven public inputs, in this order: the root atroot_index, the public amount, the ext-data hash, the two nullifiers, the two commitments.
The nullifier accounts are created before the handler runs, so a spent note fails there: its account already exists.
The public amount is ext_amount - relayer_fee as a field element: x, or r − |x| when negative. The ext-data hash
is SHA-256 over these parts, concatenated, with its first byte masked by 0x1f so it is below 2^253:
| Part | Bytes |
|---|---|
"voidcash/transact/v1" | 20 |
| Program id | 32 |
| Recipient | 32 |
ext_amount, i64 LE | 8 |
| Relayer | 32 |
relayer_fee, u64 LE | 8 |
deadline_slot, u64 LE | 8 |
fee_bps, u16 LE | 2 |
ciphertext0 | 88 |
ciphertext1 | 88 |
Where the SOL goes
The fee is |ext_amount| × fee_bps / 10,000, rounded down. The buyback gets fee × buyback_share_bps / 10,000,
rounded down; the treasury gets the rest.
| Direction | SOL movements |
|---|---|
Shield (ext_amount > 0) | The fee payer pays ext_amount into the vault, and the fee on top to the treasury and the buyback. The note holds ext_amount |
Private transfer (ext_amount = 0) | The vault pays relayer_fee to the relayer |
Unshield (ext_amount < 0) | The vault pays the amount unshielded less the fee to the recipient, the fee to the treasury and the buyback, and relayer_fee to the relayer |
Events
Anchor events, logged as Program data: while the pool program runs. The discriminator is the first 8 bytes of
sha256("event:<Name>").
| Event | Discriminator | Fields | Emitted |
|---|---|---|---|
Inserted | 9b645eacc7fdac9d | index: u64, the first of the two leaves; commitments: [[u8; 32]; 2] | By every transact |
Exited | db616bb8c63032f3 | amount: u64, the SOL that left the vault in this instruction (exit, fees and relayer fee); vault_after: u64, the vault's lamports after it | By every transact that moves SOL out of the vault |
The ciphertexts and nullifiers are not in the events: read them from the instruction data. Pauses, parameter changes and upgrades emit no event; read them from the config account and the program's ProgramData.
Error codes
Anchor numbers the program's custom errors from 6000. A failed transaction's logs carry the code, the name and the message.
| Code | Name | Message |
|---|---|---|
| 6000 | BadTree | bad tree account |
| 6001 | BadRoot | unknown root |
| 6002 | AmountTooLarge | amount too large |
| 6003 | BadProof | invalid proof |
| 6004 | TreeFull | tree full |
| 6005 | Poseidon | poseidon syscall failed |
| 6006 | Lamports | not enough lamports |
| 6007 | Expired | expired |
| 6008 | BadTokenAccount | bad token account |
| 6009 | InputNotSpent | swap did not spend the whole input |
| 6010 | WrongPool | wrong pool |
| 6011 | TooSoon | too soon |
| 6012 | NotEnoughObservations | not enough observations |
| 6013 | StaleObservations | observations are stale |
| 6014 | PoolDisabled | pool disabled |
| 6015 | PricePumped | price above the band of the reference |
| 6016 | ChunkTooSmall | chunk too small |
| 6017 | Paused | paused |
| 6018 | DepositTooSmall | deposit below the minimum |
| 6019 | DepositFee | a deposit cannot pay a relayer |
| 6020 | Unauthorized | not authorized |
| 6021 | BadParams | bad parameters |
| 6022 | FeeChanged | the fee rate changed since the transaction was built |
| 6023 | BuybackPinned | the buyback's pool is pinned, so the VOID mint is fixed |
| 6024 | BadMint | not an SPL Token mint |
Account constraints that fail report Anchor's own codes, such as 2012 (ConstraintAddress) for a wrong pinned
account.