voidcashdocs

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

CheckRefused 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 ciphertexts400
The body is at most 16 KiB413
The request is not a shield (extAmount above 0): a depositor sends its own400
The relayer field is this relayer's address400
The relayer fee is at least the current quote400
The deadline has not passed, and is at most 900 slots ahead by default400
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 caught422

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
TermDefault
Priority fee, per compute unit50,000 micro-lamports
Margin100,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

MethodPathBodyResult
GET/v1/info{ relayer, programId, fees: { transact } }, the fee in lamports as a decimal string
POST/v1/transactThe 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 observe once per observation interval (30 slots);
  • sends buyback once 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.

On this page