The relayer
Why private transfers and unshields go through a relayer, what it checks, what it can and cannot do, its fee quote, its API, and the buyback crank it runs.
Every Solana transaction has a fee payer, and the fee payer is public. If a user's wallet paid for a private transfer or an unshield, the transaction would show that wallet. So the app sends those transactions to a relayer, which submits them as the fee payer and is paid back from the notes. Shields need no relayer: the depositor's wallet is public in a shield anyway, and signs it once.
The relayer is run by the Voidcash team. The app uses the one it was built with.
A request
Quote. The app reads GET /v1/info: the relayer's address and its fee, fees.transact, in lamports.
Prove. The app builds the transaction's ext data with relayer set to that address and relayer_fee to the
quote, and proves it. Both are inside extDataHash.
Submit. The app posts the proof, the nullifiers, the commitments and the ext data to POST /v1/transact.
Check, simulate, send. The relayer checks the request, rebuilds the transaction itself, simulates it, and sends it only if the simulation passes. It answers with the signature once the transaction is confirmed.
What it checks
| Check | Refused with |
|---|---|
The JSON is well formed: a 256-byte proof, a root_index from 0 to 255, two nullifiers and two commitments, amounts within 2^60, a fee rate from 0 to 10,000, two 88-byte ciphertexts | 400 |
| The body is at most 16 KiB | 413 |
The request is not a shield (extAmount above 0): a depositor sends its own | 400 |
The relayer field is this relayer's address | 400 |
| The relayer fee is at least the current quote | 400 |
| The deadline has not passed, and is at most 900 slots ahead by default | 400 |
| The simulation succeeds: the program itself runs, so a bad proof, a spent note, an unknown root, a changed fee rate or a paused pool are all caught | 422 |
A refused request costs the relayer nothing: a simulation is free.
It never signs a transaction the user built. It rebuilds the transact instruction from the request, in a
version 0 transaction with itself as fee payer, and adds only a compute budget (a 400,000-unit limit and its priority
fee). It may compress the program's fixed accounts with its own address lookup tables.
The quote
quote = 2 × rent(8-byte account) the two nullifier accounts, whose rent stays locked
+ 5,000 + ceil(priority × 400,000 / 1,000,000) the network fee, priority fee included
+ margin| Term | Default |
|---|---|
| Priority fee, per compute unit | 50,000 micro-lamports |
| Margin | 100,000 lamports |
The rent is read from the chain on every quote, and GET /v1/info always gives the fee in force. The relayer fee is the same for a transfer, a merge and an unshield.
What it can and cannot do
The relayer cannot:
- change the recipient, the amount, the relayer account, the fee, the fee rate, the deadline or the ciphertexts: all
are bound in
extDataHash, and any change makes the proof fail; - send the transaction after its
deadline_slot: the program refuses it (Expired); - spend a note or learn which notes a proof spends: it has no keys, and the proof shows only nullifiers;
- take more than its quoted fee, which the user's proof fixed.
The relayer can:
- refuse a request, or hold it until the deadline. The app then shows the error, and the notes stay unspent;
- see the request, which becomes public on-chain when it is sent, and the network address it came from.
It keeps no request logs: no IP addresses and no bodies. CORS is open only to the origins it is configured for.
Since the relayer account is bound in the proof, anyone who obtained a request could submit it themselves; the fee would still go to the relayer it names.
API
| Method | Path | Body | Result |
|---|---|---|---|
| GET | /v1/info | { relayer, programId, fees: { transact } }, the fee in lamports as a decimal string | |
| POST | /v1/transact | The request as JSON: bytes as hex, amounts as decimal strings. See Relayer API | { signature } |
Errors come back as { error } with the status codes above.
The buyback crank
The relayer also runs the buyback's crank, from the same key. Every 4 seconds it reads the buyback state, the config and the buyback account, and:
- sends nothing while no pool is pinned, the buyback is paused, or the buyback holds less than one minimum chunk (0.01 SOL above its rent);
- sends
observeonce per observation interval (30 slots); - sends
buybackonce a buy is due: a full ring of 15 observations, the latest within 90 slots, and 150 slots since the last buy.
The program pays callers nothing, so every crank transaction costs the relayer its own fee. At the default priority fee that is about 6,000 lamports for an observation (20,000 compute units) and 15,000 for a buy (200,000). Every transaction is simulated first and sent only if it passes, so a pumped price, a raised pool fee or a disabled pool costs nothing but the read.
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 indexer
What the indexer stores and serves, how it checks itself against the chain, the alerts it sends on large exits, pauses, parameter changes and upgrades, and its API.