voidcashdocs

Relayer API

The relayer's HTTP API: its fee quote, the transact request, and the errors it returns.

The relayer submits proven private transfers and unshields as the fee payer, so the user's wallet never appears on them. The examples use <relayer> for its base URL.

The API has two endpoints, both under /v1. Responses are JSON with cache-control: no-store. The server keeps no request logs: no IP addresses and no bodies.

GET /v1/info

The relayer's address, the program it serves, and its current fee for a transact.

curl <relayer>/v1/info
{
  "relayer": "<the relayer's address>",
  "programId": "HjQyQZ3xewjsBb3w8JFGJSh2ztauV4f6X4o5CrZTobZm",
  "fees": { "transact": "<lamports>" }
}

fees.transact is a decimal string of lamports. It is the quote a request must pay at least:

2 × rent of an 8-byte account (the two nullifier accounts, kept forever)
+ 5,000 lamports (the base fee)
+ ceil(priority fee per compute unit × 400,000 / 1,000,000)
+ the margin

The operator sets the priority fee and the margin; by default they are 50,000 micro-lamports per compute unit and 100,000 lamports. The rent is read from the chain on every quote.

POST /v1/transact

A private transfer, a note merge or an unshield, already proven. Deposits are signed by the depositor and do not go through a relayer. The Voidcash app builds and proves the request; the relayer only checks it and submits it.

The proof binds the relayer's address and its fee through the ext-data hash (see transact), so a request is made for one relayer: read relayer and fees.transact from /v1/info first.

The body carries the transact instruction's arguments. Bytes are lowercase hex, amounts and field elements are decimal strings:

{
  "spend": {
    "proof": "<256 bytes, hex>",
    "rootIndex": 17,
    "nullifiers": ["<decimal>", "<decimal>"],
    "commitments": ["<decimal>", "<decimal>"]
  },
  "ext": {
    "recipient": "<base58 address>",
    "extAmount": "-500000000",
    "relayer": "<the relayer's address, from /v1/info>",
    "relayerFee": "<lamports, at least the quote>",
    "deadlineSlot": "<slot>",
    "feeBps": 50,
    "ciphertext0": "<88 bytes, hex>",
    "ciphertext1": "<88 bytes, hex>"
  }
}
type TransactRequest = {
  spend: {
    proof: string; // 256 bytes, hex: A negated, B, C, as in the instruction data
    rootIndex: number;
    nullifiers: [string, string]; // decimal field elements
    commitments: [string, string]; // decimal field elements
  };
  ext: {
    recipient: string; // base58
    extAmount: string; // decimal lamports, i64
    relayer: string; // base58
    relayerFee: string; // decimal lamports
    deadlineSlot: string; // decimal slot
    feeBps: number;
    ciphertext0: string; // 88 bytes, hex
    ciphertext1: string; // 88 bytes, hex
  };
};
FieldTypeAccepted
spend.proofhex stringExactly 256 bytes
spend.rootIndexinteger0 to 255; the program accepts 0 to 191
spend.nullifiers, spend.commitmentstwo decimal strings each0 to 2^254 − 1
ext.recipient, ext.relayerstringA base58 address, 32 to 44 characters
ext.extAmountdecimal string−2^60 to 2^60; zero for a transfer or a merge, negative for an unshield
ext.relayerFeedecimal string0 to 2^60
ext.deadlineSlotdecimal stringA u64
ext.feeBpsinteger0 to 10,000
ext.ciphertext0, ext.ciphertext1hex stringExactly 88 bytes each

Hex is lowercase with no 0x prefix. A decimal string is digits with an optional leading -, at most 80 characters. The nullifiers and commitments go into the instruction as 32 big-endian bytes each.

A private transfer or a merge sets recipient to the relayer, since no SOL leaves to a recipient.

The relayer then checks the request, rebuilds the transact instruction from it with itself as the fee payer, adds a compute budget of 400,000 units at its priority fee, applies its address lookup tables for the pool's fixed accounts, and simulates the transaction. It sends only what the simulation accepts, and answers once the transaction is confirmed, which can take up to about a minute.

{ "signature": "<base58 transaction signature>" }
const res = await fetch('<relayer>/v1/transact', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify(request), // a TransactRequest
});
const body = await res.json();
if (!res.ok) throw new Error(body.error);
console.log(body.signature);

Errors

Every error is { "error": "<message>" } with one of these statuses.

StatusWhen
400Invalid JSON, or a field that fails the checks above
400A deposit (extAmount above zero)
400ext.relayer is not this relayer
400ext.relayerFee is below the quote; the message gives the quote in lamports
400ext.deadlineSlot has passed, or is further ahead than the relayer allows (900 slots by default)
400The built transaction is larger than 1,232 bytes
413The body is larger than 16 KiB
422The simulation failed: a bad proof, a spent note, an unknown root, a changed fee rate, a paused pool, an expired deadline. The message carries the program's error lines
404Any other path or method
500An internal error, such as a send that failed or did not confirm in time

A refused request costs the relayer nothing: the simulation runs the program before anything is sent.

After a 500, the transaction may still land. Before you retry, check whether the nullifier accounts exist (see Accounts and PDAs): if they do, the notes were spent and the transaction went through.

CORS

The relayer answers cross-origin requests only from the origins its operator allows. For those it sets access-control-allow-origin to the request's origin, allows the content-type header, and varies on origin. OPTIONS returns 204.

The buyback crank

The relayer also runs the buyback's crank with the same key. While the buyback is not paused and holds enough SOL for a buy, it sends observe once per observation interval and buyback when a buy is due, simulating each first and sending only what the program will take. See The buyback.

On this page