NimbusProtocol
Fees and relayers

Relayers

Why relayers exist, what a relayer sees, what it can and cannot do to your transaction, how it is paid, and the policy Nimbus's own relayer applies.

A relayer submits your transaction to Solana for you and pays its SOL fee, so the wallet that lands a private transfer or a withdrawal is the relayer's, not yours. A relayer cannot change what the transaction does. It can refuse to submit it.

Why they exist

A private transfer spends notes and creates notes without moving any public token balance. If you submitted it from your own wallet, your wallet address would sit on every one of your private transactions, and anyone could count them and time them. A relayer breaks that link: the chain shows only the relayer as the signer and fee payer.

A value transaction names its submitter when it is built: the context digest binds the submitter's address, so only that wallet can land it. Any wallet can be the submitter, your own included, so a relayer is a privacy and convenience service, not a gatekeeper. If one does not send, you can build the transaction again for another relayer or send it yourself.

What a relayer sees

The relayer receives the finished transaction: its public payload, its zero-knowledge proof and the combined attester signature. From that it learns the transaction's public values (deposit and withdrawal addresses, tokens and amounts, nullifiers, the tip) and the moment you asked for it to be sent. It also sees the network address you sent it from. It never sees who owns the notes or how much moves between them, because those are inside the proof.

What a relayer cannot change

The context digest is a Keccak-256 hash of the payload's public part and every account address the instruction names: 39 addresses, from the submitter and the trees to the pool token accounts and each lane's depositor and destination. The attesters sign that digest, and the proof commits to it. A relayer that changed a recipient, an amount or any of those accounts would produce a different digest, so the combined attester signature and the proof would no longer verify.

Only what the relayer fills in stays outside the digest: the nullifier-freshness proof, which it makes itself against the nullifier root current when it submits, and the sequence number that names that root. The shielded pool checks that proof on its own. See nullifier freshness.

What a relayer can do is decide whether, and when, to send. It can hold a transaction back until its proof is too old to be accepted.

How a relayer is paid

A transaction can name a tip for its submitter, in one token. The tip's amount and token are public inputs to the proof. The program credits the tip to an accrual account derived from the submitter's address and the token, and the relayer collects its accrued tips later.

The accrual account's address is in the digest too, and when a tip is paid the program checks that the account belongs to the submitter that signed. The digest binds the submitter itself, tip or no tip, so a relayer that copied a transaction and sent it itself would fail the combined attester signature and the proof.

Nimbus's relayer

Nimbus runs a relayer service. The policy below is what that service does, and other relayers need not share it. The chart shows the path a relayed transaction takes through it.

sequenceDiagram
participant U as Your device
participant R as Relayer
participant N as Nullifier service
participant S as Solana
U->>R: finished transaction
R->>R: policy checks
R->>S: read accounts the transaction names
R->>S: simulate with a placeholder freshness proof
R->>N: non-membership witnesses
R->>R: nullifier-freshness proof
R->>S: simulate, then send
R-->>U: outcome

It checks its own policy first

The relayer refuses, before reading the chain, any transaction that:

  • deposits. A deposit needs the depositor's own signature over a blockhash the relayer would choose, so the relayer cannot carry it. You submit deposits yourself.
  • spends a program-owned note. The service is configured to refuse these.
  • names another submitter. The transaction must name the relayer's own submitter address for that pool in the submitter slot.
  • moves a token outside its list. Every token in the transaction's token table must be on the relayer's list of relayable tokens.
  • pays no tip, or too little. What a transaction must pay depends on whether the relayer's fees are on and on the tokens the transaction shows, described below. A transaction that owes nothing may pay no tip, and the relayer pays its Solana fee itself; one that owes a tip and pays none is refused with FEE_REQUIRED. The chosen submitter remains bound and screened either way. A tip must be credited to the accrual account the relayer has set up for its token in that pool, and is refused with TIP_ACCRUAL_NOT_PROVISIONED in a token it has none for. A transaction that owes a token's minimum must pay it in a token the relayer accepts as payment, and a tip in a token the relayer prices live is refused while it has no fresh price for that token.
  • is out of date. The transaction's execution slot must not be ahead of the live slot and must leave enough of the proof-age window to land.

It also recomputes the context digest from the payload and accounts and refuses the transaction if the digest differs from the one the client proved against.

How it sets the minimum tip

The relayer's costs are in SOL: the transaction fee, the priority fee and the rent of any account it creates. An operator can fix a token's minimum tip in that token's own units, which suits a token pegged to a currency, or have the relayer price it live. A live minimum starts from costs the operator sets in lamports: one that every paid transaction carries, one for each destination account the relayer creates, and one for each client instruction. The relayer adds a margin to them, converts the sum into the token at the current SOL and token prices, and rounds up to a whole unit of the token. A tip in SOL needs no price, because SOL's smallest unit is the lamport.

The prices come from Jupiter's price API. However many instances of the relayer run, one of them reads the prices, every 30 seconds unless configured otherwise: each instance that could read them asks the database for a lock, and only the holder reads. If it stops, the lock is released and another instance takes over. With an API key configured, the relayer sends it with each request and reads from Jupiter's keyed endpoint; without one it reads the keyless endpoint. When Jupiter answers that it is busy or failing, the relayer waits twice as long before each new attempt, up to ten minutes, and never less than Jupiter asked.

It checks each answer before using it. The token's decimals must match its mint account on chain, which the relayer reads the first time it prices the token. Where the operator sets a minimum liquidity, the price must come from at least that much. Jupiter reports each price with the slot of the last trade that moved it, and that trade must be no more than a configured number of slots behind the relayer's own slot, an hour's worth unless configured otherwise. The bound is loose because a token that trades rarely keeps a current price between trades: a token with real liquidity has gone half an hour without one. A token whose last trade is older than that has no live market, and the relayer stops pricing it. SOL's price passes the same checks, and when it fails them no token is priced, because every live minimum converts through it.

How fresh a price is depends on when the relayer fetched it, not on when the token last traded. The relayer charges against a price for a configured number of slots after the fetch that read it, two minutes' worth unless configured otherwise. A fetch that fails, or one an instance never makes, does not renew the price, so it ages out on time. A price that fails a check makes the token unavailable at once, and so does one that has not been fetched again within that age. The relayer then refuses a tip in that token with PRICE_UNAVAILABLE and publishes it as unavailable rather than with a stale minimum, so it never charges against an old price. The reason it publishes names the bound the price failed: the trade lag, the age since the fetch, the liquidity or the decimals.

A client reads the minimum, proves the transaction with the tip inside the proof, and only then submits it, so the price can move in between. The relayer therefore keeps the minimums it published over a grace window, two minutes unless configured otherwise, and accepts a tip that meets the lowest of them. The minimums are kept in the relayer's database, not in each instance, so every instance accepts the same lowest minimum, including one that started after that minimum was published. The relayer keeps a bounded number of them and drops each once the window has passed. Beside each token's current minimum it publishes whether the minimum is fixed or live, and for a live one the slot of the trade behind its price, when the relayer fetched that price and published the minimum, and the grace window, together with the lamport costs, the margin, the maximum age and the trade lag.

Which tokens it accepts

The relayer keeps the tokens it relays, the tokens it takes tips in and any fixed minimums in a registry in its database. Every instance reloads the registry every five seconds unless configured otherwise, so a change reaches all of them without a restart. The instance that takes an admin change also reloads before it answers, so a transaction it judges after the answer already sees the change; every other instance sees it within the reload period. When the relayer starts, it adds the tokens its configuration lists, but only those the registry does not hold yet; after that the registry decides. A token that takes tips must also be relayable, must have a fixed minimum unless the relayer prices tips live, and must have a tip accrual account in every pool. While fees are on, every relayable token must also take tips.

An operator changes the registry through three endpoints on the relayer's API, and the fee mode through two more, described in the next section. They exist only when the operator has configured an admin public key; otherwise each answers 404.

  • GET /v1/admin/mints lists every registered token, enabled or not, with its current live minimum.
  • PUT /v1/admin/mints/{mint} replaces one token's setting. The body is JSON: relayable and payment as booleans, and fixed_fee_units, fixed_destination_creation_units and fixed_client_instruction_units as decimal strings in the token's smallest unit, at most 9223372036854775807, or absent for none. Any other field is refused with 400.
  • DELETE /v1/admin/mints/{mint} disables a token. Its record stays, with every flag off, so the next start does not add it back from the configuration.

Before it enables a token, the relayer checks the chain. The mint account must exist and belong to the SPL Token or Token-2022 program. The pool must hold a token account for it, or, for wrapped SOL, the pool's lamport account. For a token that takes tips, each pool's tip accrual account must exist. A failed check refuses the change with 422 and a code naming it. While fees are on, the relayer refuses with 409 and RELAYABLE_NOT_PAYMENT a change that makes a token relayable without taking tips. Making a token neither, or disabling it, is accepted. When a pool's address lookup table lacks the token's pool token account or mint allowance, the change is accepted with a warning: relays in that token still work, but their Solana transactions are larger. Every change is logged with the admin key that signed it.

Fees on or off

One switch decides what a transaction pays: off or on, for the whole relayer and every token alike. It is held in the same database as the token registry and reloaded with it, so the instance that takes a change follows it at once and every other instance within one reload. There is no setting per token. A token either takes tips, with a minimum and a tip accrual account, or the relayer only relays it.

While fees are off, nothing is charged. The relayer accepts any transaction that pays no tip, whatever tokens it moves, and pays its Solana fee, the destination accounts it creates and the client instructions it carries itself. A transaction that pays a tip anyway is accepted at any amount, in any relayable token the pool has a tip accrual account for.

While fees are on, every transaction pays at least the published minimum tip of the token it pays in, whatever tokens it moves or spends. A transaction that pays no tip is refused with FEE_REQUIRED. One that pays in a token the relayer does not take tips in is refused with FEE_MINT_NOT_ACCEPTED, one below the minimum with FEE_BELOW_FLOOR, and one whose tip token is priced live and has no fresh price with PRICE_UNAVAILABLE.

The relayer also never pays for a destination account it creates or a client instruction it carries while fees are on. A transaction pays for each in the token it pays its tip in, at the creation fee and the client instruction surcharge the relayer publishes for that token, on top of the minimum tip. One whose tip token publishes no such fee is refused with DESTINATION_CREATION_NOT_OFFERED or CLIENT_INSTRUCTIONS_NOT_OFFERED. The relayer applies this when it first accepts the transaction, counting the destination accounts the client names an owner for and its client instructions, and again when it checks it against the chain, counting only the accounts that are still missing, so a transaction that pays too little is refused before the relayer signs or sends anything.

A live minimum is the relayer's lamport cost converted into the token at its current price, plus the configured margin. So whichever token a transaction pays in, it pays about the same value.

Why one switch for every token

A private transfer or a merge moves no token in or out of the pool, so the transaction does not show which token it spends. A transaction can also spend notes of more than one token and pay its tip in only one of them. If some tokens charged and others did not, a transaction spending a charging token could pay a tip of one unit in a token that charges nothing and skip the minimum. With one switch, the token a transaction pays in tells the relayer nothing it needs: every token it could pay in carries a minimum of about the same value.

That is why, while fees are on, every relayable token must take tips. The relayer will not start with fees on, will not switch fees on, and will not make a token relayable without tips while fees are on, so no relayable token is one a transaction could spend without paying.

A withdrawal that pays its tip from the token table pays it in the token it withdraws. In a transaction carrying two withdrawals, each withdrawal pays its own minimum and the destination accounts created for it, and the first pays for the client instructions.

What the relayer publishes

/v1/info publishes the switch as fee_mode, on or off. Each token it takes tips in carries charges beside its minimums, and assets_relayable_charges maps every relayable token to whether it charges. Both equal the switch, the same for every token. For older clients, accepts_zero_fee is true exactly while fees are off. sponsors_destination_creation and sponsors_client_instructions are true exactly while fees are off too, and they are exact: while fees are on, no token's destination accounts or client instructions are paid for by the relayer. All of these change as soon as an instance reloads after a switch, so a client that reads them before proving pays what the relayer now charges.

Starting, switching and the grace window

When the relayer first starts against an empty database, it takes the switch from its configuration: off unless the configuration explicitly turns zero tips off, and in that case on. After that the database decides, as it does for tokens, and a restart with different configuration does not change the switch. A relayer that starts with fees on never published a zero minimum, so the grace window below does not apply to it.

A client can prove a transaction while fees are off and submit it after they go on. The relayer therefore records when fees went on, and for the fee grace window after that, two minutes unless configured otherwise, it judges every transaction as if fees were still off. The rule applies both when the relayer first accepts a transaction and when it checks it against the chain, so a zero-tip transaction already queued at the switch is still sent within the window and refused with FEE_REQUIRED after it. Setting fees on while they are on keeps the original switch time. Switching fees off needs no window, because with fees off every transaction is accepted.

While fees are on, the relayer checks at start that every relayable token takes tips, and that every token that takes tips has a fixed or live minimum and a tip accrual account in every pool. It refuses to start otherwise. With fees off it skips that check, and switching fees on makes it instead.

Two admin endpoints read and set the switch. They are signed like the token endpoints.

  • GET /v1/admin/fee-mode returns the switch as mode, when fees went on (on_since) and until when transactions are still judged as if fees were off (grace_until), both in Unix milliseconds or null while fees are off, and when and by which admin key it last changed.
  • PUT /v1/admin/fee-mode sets the switch, which the instance that takes it applies before it answers. The body is {"mode": "on"} or {"mode": "off"}; the earlier names charging and sponsored are accepted for them. The answer has the same form as the GET, plus any warnings from reloading the registry. The relayer refuses to switch fees on with 409 and a code naming the first token that fails: a relayable token that does not take tips (RELAYABLE_NOT_PAYMENT), a token that takes tips with no fixed minimum while the relayer does not price tips live (NO_FEE_FLOOR), when it has no tip accrual account for a pool in the relayer's configuration (NO_TIP_ACCRUAL), or when that account does not exist on chain (NO_TIP_ACCRUAL_ON_CHAIN); it reads every such account in one request. Each switch is logged with the admin key that signed it and the time of the switch, and recorded in the database beside the switch it replaced.

Signing an admin request

Each admin request carries three headers:

  • x-umbra-admin-expiry: when the request stops being valid, in Unix seconds. It must not have passed, and it must be at most 60 seconds ahead of the relayer's clock.
  • x-umbra-admin-nonce: 16 to 64 random bytes, in hex. The relayer accepts each nonce once.
  • x-umbra-admin-signature: an ed25519 signature by the admin key, in base58, over the canonical message below.

The canonical message is six lines of text joined by a single newline (\n), with no newline after the last:

  1. umbra-relayer-admin-v1
  2. the HTTP method in upper case: GET, PUT or DELETE
  3. the request path exactly as sent, without a query string, such as /v1/admin/mints/6Ckm2BrnXxsSjyG5b17kQQRjoECVrts92RKXVGT8XeqS
  4. the SHA-256 of the request body in lowercase hex; for a request with no body, the SHA-256 of zero bytes
  5. the expiry, in decimal, as sent in its header
  6. the nonce in lowercase hex

The relayer checks the expiry and the signature before it records the nonce, so a request it refuses never uses one up. It answers every refused signature, expired request and reused nonce with the same 401 and no detail. A body may be at most 4 KiB.

To check a signer, use this request. Its private key is the 32-byte ed25519 seed of 32 bytes of 0x07, so its public key is GmaDrppBC7P5ARKV8g3djiwP89vz1jLK23V2GBjuAEGB. The request is PUT /v1/admin/mints/6Ckm2BrnXxsSjyG5b17kQQRjoECVrts92RKXVGT8XeqS with the body {"relayable":true,"payment":false}, expiry 1800000030 and the 16 nonce bytes 0x00 to 0x0f. Its canonical message is:

umbra-relayer-admin-v1
PUT
/v1/admin/mints/6Ckm2BrnXxsSjyG5b17kQQRjoECVrts92RKXVGT8XeqS
efd207b27bfe99ec84b3161c80f090671ac93d276bdfb20e63712ae59cadcbed
1800000030
000102030405060708090a0b0c0d0e0f

and its signature is 3af7kok927TZmafRQeHp3XcRw8g5nkEknet4PjctnCpmpV8mu4Xj5MMPE2a1QVu3wmvUMpewjH3VnccYb8dTPwvY. Ed25519 signing is deterministic, so a correct signer produces exactly this signature.

It checks the transaction against the chain

The relayer then reads every account the transaction names and refuses it if the program would. It checks the guardian cluster, the screener and the fee enclave; that the commitment and identity roots the transaction names are still recent and the zone-signer root is the live one, holding the transaction for a retry while the zone registry waits for a rebuild; that the trees have room; the token accounts and token programs; the fee policy; and that the pool token accounts, and for SOL the lamport account above its rent reserve, hold enough to pay out. This is so the relayer does not pay to send a transaction that will fail.

Last, the relayer has the program check the transaction as far as it can before it spends anything on it. It simulates each withdrawal on its own with a placeholder freshness proof: a well-formed proof of an unrelated statement. The program verifies the proof, the freshness proof and the combined attester signature together, in one pairing, so no simulation can show that the signature or the proof holds on its own. A simulation that fails only at that pairing shows that everything before it passed: the accounts, the roots, and that both proofs and the signature are well formed. The relayer refuses a transaction whose proof or attester signature the program rejects as malformed before it makes a freshness proof for it. A well-formed forgery costs it one freshness proof and is refused when the relayer simulates the finished transaction, and the program stays the only judge of both.

A withdrawal can pay an associated token account that does not exist yet. The client names the owner, and the relayer creates the account in the same Solana transaction, but only if it is exactly that owner's associated token account for the token. While fees are on, each account it creates costs the transaction a per-token creation fee the relayer publishes, on top of any minimum tip; while fees are off, the relayer pays for the account.

It completes and sends the transaction

The relayer asks an off-chain copy of the nullifier tree for non-membership witnesses for the transaction's nullifiers, checks that they prove absence under the copy's current root, and makes the nullifier-freshness proof against that root. The pool accepts any nullifier root still in its recent history, so the copy's root can be newer than the one the relayer read when it checked the transaction; where the two name the same point in that history they must agree. A nullifier that is already in the tree means the notes are spent, and the relayer refuses the transaction. It then builds the Solana transaction and simulates it to size its compute budget: it refuses a transaction the simulation says would use more than 1,350,000 compute units, and otherwise sets the limit to 1.2 times the simulated amount plus 50,000, at most 1,400,000. It signs the transaction as submitter and fee payer, and refuses to sign a priority fee above its highest bid at that compute limit. It then simulates the transaction again and sends it. Until the transaction lands or its proof expires, the relayer sends the same signed transaction again.

Several withdrawals in one transaction

One relayed transaction can carry up to two withdrawals and, after them, instructions the client builds and signs itself. The relayer admits a short list and refuses anything else: the associated token account creations it builds for the withdrawals, the withdrawals themselves, and the two instructions that register a new key, the user registry's bind_platform_wallet and the pool's register. They always run in that order, so a client instruction can use what the withdrawals paid out.

Each withdrawal is checked as it would be alone, and then the withdrawals are checked together. Two withdrawals that each fit a pool token account can still overdraw it, so their payouts are added up per pool token account before the relayer accepts them.

Client instructions add signatures and compute the relayer pays for, so while fees are on a transaction pays a per-token surcharge for each one, on top of any minimum tip; the relayer publishes it beside the minimum, and takes no client instructions paid in a token it publishes none for. While fees are off, the relayer pays for them.

Withdrawing wrapped USDv as USDv

Wrapped USDv is a Token-2022 token the USDv wrapper program mints against USDv it holds. The relayer can carry one withdrawal of wrapped USDv followed by the wrapper's unwrap, so a holder withdraws wrapped USDv from the pool and receives USDv in the same transaction. The holder signs the unwrap, as the wrapper requires of whoever burns the shares, and the relayer pays the Solana fee as for any relayed transaction. The transaction runs in this order:

1. The wrapped USDv account is created

The relayer creates the holder's associated token account for wrapped USDv, if it does not exist yet, as it does for any withdrawal destination.

2. The USDv account is created

The relayer creates the holder's Token-2022 associated token account for USDv, if it does not exist yet, so the unwrap has an account to pay.

3. The withdrawal pays wrapped USDv

The withdrawal pays wrapped USDv out of the pool to the holder's wrapped USDv account.

4. The holder unwraps

The holder's unwrap burns shares from that account and pays the USDv they are worth to the holder's USDv account.

The relayer admits this only when its operator has configured the wrapper: its program, its state, the two authorities and the two accounts it pays out of, the USDv mint, the wrapped USDv mint, and any transfer-hook accounts USDv requires. None of those addresses is built into the relayer, so a change of deployment is a change of configuration. Without that configuration the relayer refuses an unwrap like any other instruction off its list, and it refuses to start with only part of it.

The relayer checks the unwrap against the configuration and against the withdrawal, both when it accepts the relay and again before it signs the transaction:

  • Every fixed account is the configured one, in the order the wrapper takes them, with the writability the wrapper expects. The token program is Token-2022 and the memo program is SPL Memo.
  • The holder alone signs, and the holder is the only key the client names to sign the transaction. The holder cannot be the relayer.
  • It burns what the withdrawal paid the holder. The account it burns from must be the holder's associated token account for wrapped USDv, and a lane of the withdrawal must pay that account wrapped USDv. It burns at least one share and no more than those lanes pay.
  • It pays the holder. The account it pays must be the holder's Token-2022 associated token account for USDv, and no lane of the withdrawal may pay that account.
  • It is alone. The relay carries one withdrawal and one unwrap, with no registration beside it. A withdrawal that would need more account creations than fit ahead of it is refused with COMPOSITION_TOO_LARGE.

Before it proves the relay, the relayer reads both of the holder's accounts. Each one that exists must be the holder's Token-2022 account for its token; each one that does not exist is created as above. A refusal names what it broke: UNWRAP_UNBOUND when the unwrap does not burn what the withdrawal pays its holder, UNWRAP_NOT_ALONE when the relay carries more than the template allows, and INSTRUCTION_MALFORMED when the unwrap itself is not the configured one.

The template follows the relayer's fees like any withdrawal. While fees are on, it pays its tip in wrapped USDv, from the withdrawal's own token table entry, and owes wrapped USDv's minimum, plus the creation fee once for each of the two accounts the relayer creates for it, plus the client instruction surcharge once for the unwrap. While fees are off, the relayer pays for both accounts and the unwrap itself.

/v1/info publishes the template under composition.templates while it is configured, as an entry named unwrap_uusdv with every address the unwrap must carry, the wrapped mint's mint_authority when the operator sets it, client_instruction_count 1 and max_creations 2. While it is not configured, composition is absent.

A client predicts which of the two accounts are missing from its own read of the chain just before it submits, and pays for those. The relayer charges only for the accounts it actually creates, so a tip at or above that amount is accepted, and a client that counted an account someone else created in the meantime overpays rather than being refused.

The relayer's key signs only as the fee payer and for the account creations it built. A client-built instruction may not name the relayer's address in any role. A program can move SOL out of an account only through an instruction that names that account as a signer, so no client instruction can reach the relayer's balance.

Signing in two steps

The client's keys sign the whole transaction, including the blockhash and the nullifier-freshness proof the relayer adds, so a transaction with client instructions cannot be signed before the relayer has built it. It takes two requests. The relayer answers the first with the message to sign once it has built it, and sends the transaction after the client posts its signatures. Each signature must come from one of the keys the client named and must verify over that message.

A relay waiting for signatures has already cost the relayer its nullifier-freshness proofs, so the relayer bounds the wait. It holds such a relay for a configured number of slots and then expires it, which frees its notes for another attempt, and it refuses a new one while too many are waiting.

If the relayer refuses

Use another relayer, or submit the transaction yourself and accept that your wallet appears on it as the signer.

Last updated

On this page