voidcashdocs

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:

HjQyQZ3xewjsBb3w8JFGJSh2ztauV4f6X4o5CrZTobZm

The 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.

AccountSeedsOwnerSizeHolds
Config"config"Pool program280 bytesThe admin, the pending admin, the guardian, the VOID mint, the pause flags and the parameters
Tree"tree"Pool program9,400 bytesThe note tree and its root history, as a raw account
Vault"vault"Pool program8 bytesThe SOL behind every note, as its lamports
Treasury"treasury"System program0 bytesThe treasury's share of the fees
Buyback"buyback"System program0 bytesThe buyback's share of the fees, until a buy spends it
Buyback state"bbstate"Pool program450 bytesThe pinned pool and the price observations
Nullifier"nf", then the nullifier's 32 bytesPool program8 bytesNothing: 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

OffsetFieldType
0Anchor discriminator8 bytes
8bumpu8
9adminPubkey
41pending_adminPubkey
73guardianPubkey
105void_mintPubkey
137pausedu8, the pause flags
138params.min_depositu64, lamports
146params.shield_fee_bpsu16
148params.exit_fee_bpsu16
150params.buyback_share_bpsu16, the buyback's share of every fee
152reserved128 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.

OffsetField
0Magic, voidtree (8 bytes)
8Next leaf index, u64
16Current index in the root history, u64
24Filled subtrees, 26 × 32 bytes
856Zero hashes, 27 × 32 bytes
1,720Root 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

OffsetFieldType
0Anchor discriminator8 bytes
8bumpu8
9poolPubkey, the pinned cp-amm pool
41sol_is_abool
42token_a_vaultPubkey
74token_b_vaultPubkey
106buyback_wsolPubkey
138buyback_tokenPubkey
170last_buy_slotu64
178last_obs_slotu64
186obs_countu64
194obs_headu64
202obs15 × u128, square-root prices
442burnedu64, 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'
InstructionDiscriminatorArgumentsSigner
initializeafaf6d1f0d989bedadmin: Pubkey, guardian: Pubkey, void_mint: Pubkey, params: ParamsThe program's upgrade authority
transactd995828fdd34fc77See transactThe fee payer
init_buyback0bc3019ab872ad63noneThe admin
observecc4eb273c293414anoneNone
buyback6a75401e3845072dnoneAny caller
set_params1beab2349302bb8dparams: ParamsThe admin
set_void_minte2f10bb486154901none (the mint is an account)The admin
set_guardian93f332799aa4321eguardian: PubkeyThe admin
propose_admin79d6c7d4572775eaadmin: PubkeyThe admin
accept_admin702a2d5a74b50daanoneThe pending admin
paused316ddfb4a79c12fflags: u8The guardian or the admin
unpausea99004260a8dbcffflags: u8The admin
withdraw_treasury283f7a9e90d85360amount: u64The 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:

OffsetBytesField
08Discriminator d995828fdd34fc77
8256proof: A negated (64), B as x.c1, x.c0, y.c1, y.c0 (128), C (64)
2641root_index, an index into the root history (0 to 191)
26564nullifiers, two field elements
32964commitments, the two output commitments
3938ext_amount, i64
4018relayer_fee, u64
4098deadline_slot, u64
4172fee_bps, u16
41988ciphertext0
50788ciphertext1

Accounts

In this order:

#AccountWritableSigner
0Fee payer: the depositor for a shield, the relayer otherwiseyesyes
1Config PDAyes
2Tree PDAyes
3Vault PDAyes
4Treasury PDAyes
5Buyback PDAyes
6Nullifier PDA for nullifiers[0], created hereyes
7Nullifier PDA for nullifiers[1], created hereyes
8Recipient, bound in the ext-data hashyes
9Relayer, bound in the ext-data hashyes
10System program

The fee payer pays the two nullifier accounts' rent, and a shield's amount and fee.

What the program checks

In order:

  1. The current slot is at most deadline_slot (Expired).
  2. |ext_amount| and relayer_fee are at most 2^60 (AmountTooLarge).
  3. The pause flag for the direction is clear: deposits for ext_amount > 0, transfers for 0, exits for < 0, and exits for any relayer_fee > 0 (Paused).
  4. A deposit is at least min_deposit (DepositTooSmall) and pays no relayer (DepositFee).
  5. fee_bps equals the pool's rate for the direction: shield_fee_bps, 0 or exit_fee_bps (FeeChanged).
  6. The Groth16 proof verifies (BadProof) against seven public inputs, in this order: the root at root_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:

PartBytes
"voidcash/transact/v1"20
Program id32
Recipient32
ext_amount, i64 LE8
Relayer32
relayer_fee, u64 LE8
deadline_slot, u64 LE8
fee_bps, u16 LE2
ciphertext088
ciphertext188

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.

DirectionSOL 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>").

EventDiscriminatorFieldsEmitted
Inserted9b645eacc7fdac9dindex: u64, the first of the two leaves; commitments: [[u8; 32]; 2]By every transact
Exiteddb616bb8c63032f3amount: u64, the SOL that left the vault in this instruction (exit, fees and relayer fee); vault_after: u64, the vault's lamports after itBy 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.

CodeNameMessage
6000BadTreebad tree account
6001BadRootunknown root
6002AmountTooLargeamount too large
6003BadProofinvalid proof
6004TreeFulltree full
6005Poseidonposeidon syscall failed
6006Lamportsnot enough lamports
6007Expiredexpired
6008BadTokenAccountbad token account
6009InputNotSpentswap did not spend the whole input
6010WrongPoolwrong pool
6011TooSoontoo soon
6012NotEnoughObservationsnot enough observations
6013StaleObservationsobservations are stale
6014PoolDisabledpool disabled
6015PricePumpedprice above the band of the reference
6016ChunkTooSmallchunk too small
6017Pausedpaused
6018DepositTooSmalldeposit below the minimum
6019DepositFeea deposit cannot pay a relayer
6020Unauthorizednot authorized
6021BadParamsbad parameters
6022FeeChangedthe fee rate changed since the transaction was built
6023BuybackPinnedthe buyback's pool is pinned, so the VOID mint is fixed
6024BadMintnot an SPL Token mint

Account constraints that fail report Anchor's own codes, such as 2012 (ConstraintAddress) for a wrong pinned account.

On this page