Pay Stellar reserves and fees with any token. An account that holds nothing but USDC — or any asset with a route to XLM — can be created, open trustlines and transact, without ever touching a lumen.
Stellar needs XLM for two different things, and Reserve covers both:
| what it is | how it is covered | |
|---|---|---|
| Reserves | XLM locked: 1 XLM per account (2 × 0.5 base reserve) + 0.5 per subentry | sponsored reserves (CAP-33): the sponsor carries them, the user's minimum balance stays at 0 |
| Fees | XLM spent: 100 stroops per operation | fee-bump (CAP-15): the sponsor pays, the user's transaction is untouched |
The user pays for both in their own token, in the same transaction: a
PathPaymentStrictReceive buys exactly the XLM we are owed through the SDEX and
liquidity pools. No oracle — the rate is the route the payment itself takes. If
liquidity moves beyond the quoted sendMax, the whole transaction fails and the
user is charged nothing.
- Reserve never holds user keys. It signs only the outer fee-bump envelope, which cannot alter the inner transaction.
- Sponsorship is a reserve obligation, not authority: the service never adds
itself as a signer and never touches
setOptions. The request type simply cannot express it. - Reclaiming reserves cannot brick an account —
RevokeSponsorshipfails withREVOKE_SPONSORSHIP_LOW_RESERVErather than stripping anything. - The remaining risk is blind signing of a server-built XDR, which is why the client SDK must rebuild and verify the transaction locally before signing.
POST /{network}/v1/quote { source, fee_token, ops[] } -> signed quote + price
POST /{network}/v1/build { quote } -> unsigned inner tx XDR
POST /{network}/v1/submit { quote, signed_xdr } -> hash
POST /{network}/v1/challenge { address } -> prove an address is yours
POST /{network}/v1/keys { signed_xdr } -> a key, for a bigger budget
GET /{network}/v1/tokens /{network}/v1/status/{hash}
GET / (the product page) /health /{network}/health /{network}/metrics
network is testnet or mainnet. Point the SDK at the prefix
(https://reserve.cavos.xyz/testnet). A process with one sponsor still
answers /v1 at the root so local RESERVE_URL=http://127.0.0.1:8080 works.
Quotes are not stored: they travel as an HMAC-signed payload, so the hot path does no database writes. At submit time the service rebuilds the transaction from the quote and compares XDR byte for byte — that comparison, not an operation allowlist, is the security gate.
Payment is only ever taken in an allowlisted asset, and the list is never empty. An asset code means nothing on its own: mainnet carries 430 different issuers of something called "USDC" and 149 of "SHX", so a service that accepted whatever a caller named would accept worthless look-alikes as payment.
The default mainnet list, verified on chain — dominant issuer by holders, home domain declared by the issuing account, and a direct SDEX route to XLM without which the fee cannot settle:
| asset | issuer domain | slippage | why |
|---|---|---|---|
| XLM | stellar.org | 0 | the native asset; nothing is converted |
| USDC | circle.com | 100 bps | 2.4M holders, ~$271M — the default on Stellar |
| USDT0 | usdt0.to | 500 bps | how Tether's USDT exists here: a LayerZero OFT, not a native issuance |
| EURC | circle.com | 100 bps | Circle's euro stablecoin, 33k holders |
| PYUSD | token-metadata.paxos.com | 100 bps | PayPal USD via Paxos, 9.7k holders |
| USDGLO | app.glodollar.org | 500 bps | small, but a real stablecoin with a direct route |
| AQUA | aqua.network | 200 bps | 192k holders — the most held non-stablecoin |
| SHX | stronghold.co | 200 bps | 92k holders |
| yXLM | ultracapital.xyz | 50 bps | tracks XLM one to one, 54k holders |
| yUSDC | ultracapital.xyz | 150 bps | 35k holders, liquidity in pools |
Left out on purpose: BENJI, EUTBL and USTBL have real volume but strict-receive path finding returns nothing for them — no route to XLM means the fee cannot be collected, and they are permissioned besides.
Twenty per cent over cost, with a floor of RESERVE_MIN_CHARGE_STROOPS
(56,000 stroops, about $0.001 at XLM near $0.18). The floor is the actual
price: a margin on Stellar's network fee is a margin on $0.000006, which
rounds to nothing. What a user pays for here is not a network fee, it is not
having to hold XLM at all.
Reserves are passed on in full and never discounted — they are money handed over, not a service. A new account with one trustline costs 1.5 XLM of reserves, so it is charged 1.5 XLM plus margin, out of the funds it claims.
The floor is set in stroops rather than dollars deliberately: no price feed, no hidden dependency. It is worth revisiting if XLM moves a long way.
Two different things need protecting, and they need different limits.
The upstream quota is the fragile one. Quoting is free for the caller and costly for us: every quote runs path finding against Horizon. A loop of unsigned quote requests never touches a key, never spends a stroop, and can still exhaust the rate limit the whole service shares. It gets the tightest limit, per calling address, with a global ceiling behind it.
The sponsor's XLM was the other candidate, and it did not survive the arithmetic. A failed submission costs the fee bump, about 600 stroops, and the attacker needs a funded account and a signed transaction for each one. Burning ten dollars of ours takes a million transactions, which the limits above already throttle. So there is no separate breaker: the money is defended by the same limit that defends everything else, and by watching a balance that only falls when something is wrong.
A key is an identity, never an authorisation. Unkeyed callers are served too, from a smaller budget, so "paste the URL and it works" stays true and nobody has to ask permission to try the service. Nobody is billed for a key either.
Keys are signed by whoever issues them and verified here offline, with two consequences on purpose:
- the hot path never calls the issuer, so issuing being down cannot take this service down, and a self-hosted deployment needs nothing from anybody;
- the signature is asymmetric. This service holds only the public half, so compromising it, or any self-hosted copy, does not let anyone mint keys. A shared secret would have.
Getting one takes a Stellar wallet and no account. GET / is the whole
interface: connect, sign a challenge, keep the key.
POST /v1/challenge { address } -> a transaction to sign
POST /v1/keys { signed_xdr } -> cav_…
The challenge is shaped after SEP-10: sequence number zero, so the network can
never accept it, and one ManageData operation sourced by the account being
proved. Every wallet can sign a transaction, including through WalletConnect,
which exposes only stellar_signXDR; message signing is not available
everywhere. It carries its own MAC, so no nonces are stored.
Nothing about the key is stored either. It is a signed statement about an address, so signing the challenge again hands back the same key: there is no account, no session, and nothing to recover.
Set RESERVE_KEY_ISSUER_SECRET where keys are handed out;
RESERVE_KEY_ISSUER_PUBKEY alone is enough to accept them, which is what a
self-hosted copy wants. scripts/mint-key.mjs mints one from the command line
for cases with no browser.
Worth being plain about what this login is not. Stellar addresses are free, so proving one gives identity, not scarcity: nothing stops somebody generating a thousand and asking for a thousand keys. The global limit is what actually protects the service, and per-key budgets divide it rather than enlarge it. There is no revocation list either, for the same reason: a leaked key buys a larger rate-limit bucket and nothing else.
RESERVE_TRUST_PROXY must stay false unless a proxy in front sets
X-Forwarded-For. Believing that header without one lets a caller pick their
own rate-limit bucket, which is the same as having no limit.
The fee payment is strict receive: the sponsor is credited exactly the quoted
XLM, and the user's balance is debited whatever the market asks, up to
sendMax. So the price risk between quote and submit is the user's — capped,
visible before signing, and checked by the SDK, which refuses to sign a
sendMax other than the quoted one. When the market moves in their favour they
pay less; sendMax is a ceiling, not a price.
Past that ceiling the whole inner transaction fails, the user pays nothing, and
the sponsor is still charged the fee-bump fee — the fee account is debited
even when the inner transaction fails. The cost of a band set too tight lands on
the service, which is why the numbers above are rounded up rather than down.
They come from measuring each asset's price impact at 100 XLM — four orders of
magnitude above any fee charged here — plus room for XLM moving during a quote's
~60 second life. RESERVE_SLIPPAGE_BPS only applies to assets an operator has
added by hand, since there is no basis for guessing at their market.
One consequence of fees this small: the rate is never read at the size being bought. Asking Horizon what buys 0.0000360 XLM returns rounding artefacts — routes priced at a sixtieth of the real rate — so pricing reads the rate at one lumen and scales down, rounding up. Both the margin and the slippage band round up too; at 65 stroops, 1% rounded down is nothing at all.
RESERVE_TOKENS overrides the list with explicit CODE:ISSUER entries. *
accepts anything with a route and is refused on mainnet; it is
there for development against throwaway assets. GET /v1/tokens returns the
live list with issuer and domain for each entry.
- sponsored — the account exists, sources its own transaction, pays us in its token, and we fee-bump.
- bootstrap — the account does not exist yet, so it cannot source a
transaction. The sponsor sources it and co-signs. It only works alongside
money arriving: the request must include a
claim_balancefor funds someone left at that address, and the reserves and fees are paid out of what is claimed, in the same transaction.
Creating an empty account means handing over 1 XLM, plus 0.5 for each
trustline, that lives inside the user's account from then on. It comes back
only if they close the trustline or merge the account, and there is no way to
take it back: releasing a sponsored reserve requires the sponsored account to
cover it, and an empty one never can — RevokeSponsorship fails with
REVOKE_SPONSORSHIP_LOW_RESERVE. An address that is created and never used is
money gone for good.
So accounts are created at the moment they are funded, never before. Stellar
already has the mechanism for sending to someone who has no account: a
claimable balance. pay builds one when the destination cannot receive a
Payment — no account, or no trustline — and names the sender as a second
claimant so an unclaimed balance can be taken back after seven days. The
recipient's first transaction creates the account, opens the trustline, claims
the money and pays for all of it out of that money.
sdk/ts — @cavos/reserve, wallet-agnostic:
const reserve = new Reserve({ url, networkPassphrase, sponsor });
const quote = await reserve.quote({
source,
feeToken: "USDC:GA5ZSEJ…",
maxSendStroops,
ops,
});
const { hash } = await reserve.send(quote, wallet.signTransaction);The SDK keeps the request you made and refuses a quote — or a built
transaction — that does not match it, including a fee above maxSendStroops.
Comparing the built XDR only against the quote is not enough: a compromised
service can make those two agree. See sdk/ts/README.md.
One static binary, one small VM, and no database at all. Fee-bumps do not
consume the sponsor's sequence, so sponsored submissions run in parallel
without extra accounts. Bootstrap does consume a sequence — the sponsor's,
unless RESERVE_CHANNEL_SECRETS names extra lanes. Without extras, only one
bootstrap quote is in flight at a time; a second caller gets 503 bootstrap_busy
until the first submits or the quote's time bound passes.
There is nothing to persist. Quotes travel as signed payloads, so the hot path
writes nothing. How much XLM the sponsor has immobilised is answered by the
account's own num_sponsoring field, and a copy of that could only ever be
wrong. GET /health and GET /metrics report it, along with the spendable balance.
That balance is the number to alert on, and the useful alert is not a floor. In normal operation it only rises: every transaction charges more than it immobilises. So a balance that falls for hours means a bug, a fee token that lost its route to XLM, or abuse, and it says so long before the service runs dry and fails for everyone at once.
cp .env.example .env # fill in the two secrets
docker compose up -dor without Docker:
RESERVE_TESTNET_SPONSOR_SECRET=S... \
RESERVE_MAINNET_SPONSOR_SECRET=S... \
RESERVE_QUOTE_KEY=<32+ bytes> \
cargo run -p reserve-apiA single network still works: RESERVE_NETWORK=testnet plus
RESERVE_SPONSOR_SECRET.
| variable | default |
|---|---|
RESERVE_TESTNET_SPONSOR_SECRET / _FILE |
unset (enables /testnet) |
RESERVE_MAINNET_SPONSOR_SECRET / _FILE |
unset (enables /mainnet) |
RESERVE_TESTNET_HORIZON_URL / RESERVE_MAINNET_HORIZON_URL |
SDF Horizon for that network |
RESERVE_TESTNET_TOKENS / RESERVE_MAINNET_TOKENS |
curated list for that network |
RESERVE_TESTNET_CHANNEL_SECRETS / RESERVE_MAINNET_CHANNEL_SECRETS |
unset |
RESERVE_NETWORK |
testnet (legacy single-lane, with RESERVE_SPONSOR_SECRET) |
RESERVE_HORIZON_URL |
SDF Horizon (legacy single-lane) |
RESERVE_BIND |
0.0.0.0:8080 |
RESERVE_MARGIN_BPS |
2000 |
RESERVE_MIN_CHARGE_STROOPS |
56000 (~$0.001) |
RESERVE_SLIPPAGE_BPS |
100 (only for assets outside the curated list) |
RESERVE_QUOTE_LEDGERS |
12 |
RESERVE_RATE_ANON_PER_MIN |
30 |
RESERVE_RATE_KEYED_PER_MIN |
600 |
RESERVE_RATE_GLOBAL_PER_MIN |
3000 |
RESERVE_KEY_ISSUER_PUBKEY |
unset (hex, 32 bytes; no keys accepted without it) |
RESERVE_KEY_ISSUER_SECRET / _FILE |
unset (set only where keys are issued) |
RESERVE_QUOTE_KEY / _FILE |
required, 32+ bytes |
RESERVE_TRUST_PROXY |
false |
cargo test # unit
(cd sdk/ts && npm test) # the client-side verifier
RESERVE_SPONSOR_SECRET=S... RESERVE_URL=http://127.0.0.1:8080 \
cargo run -p reserve-e2e # end-to-end against testnetIt needs RESERVE_TOKENS=* on the service, since it mints its own throwaway
asset. The run stands up an issuer and a market maker on testnet, creates a
zero-XLM account through the API, funds it with the token only, makes it pay for
its own transaction, and checks that a tampered submission is rejected.