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 marginThe 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
};
};| Field | Type | Accepted |
|---|---|---|
spend.proof | hex string | Exactly 256 bytes |
spend.rootIndex | integer | 0 to 255; the program accepts 0 to 191 |
spend.nullifiers, spend.commitments | two decimal strings each | 0 to 2^254 − 1 |
ext.recipient, ext.relayer | string | A base58 address, 32 to 44 characters |
ext.extAmount | decimal string | −2^60 to 2^60; zero for a transfer or a merge, negative for an unshield |
ext.relayerFee | decimal string | 0 to 2^60 |
ext.deadlineSlot | decimal string | A u64 |
ext.feeBps | integer | 0 to 10,000 |
ext.ciphertext0, ext.ciphertext1 | hex string | Exactly 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.
| Status | When |
|---|---|
| 400 | Invalid JSON, or a field that fails the checks above |
| 400 | A deposit (extAmount above zero) |
| 400 | ext.relayer is not this relayer |
| 400 | ext.relayerFee is below the quote; the message gives the quote in lamports |
| 400 | ext.deadlineSlot has passed, or is further ahead than the relayer allows (900 slots by default) |
| 400 | The built transaction is larger than 1,232 bytes |
| 413 | The body is larger than 16 KiB |
| 422 | The 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 |
| 404 | Any other path or method |
| 500 | An 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.