Private transfers, trades, and swaps for your users.
Sirius gives your users private balances backed by USDC. They can hold them, send them, and trade perps and spot with them. Only the account owner can read a balance. Every transaction is proven and verified on Solana. You reach all of it through a signed HTTP API.
What is live today
-
Trading API: perps and spot by API keyLive
Trade from your own servers with an API key. Place, cancel and protect orders, open and close at market, and run a TWAP. Read positions, orders, balances and fills. A key can trade and read. It can never move money out. See API keys.
-
Private perpsLive
Perpetuals with up to 20x leverage, take-profit and stop-loss, scale orders and TWAP. No one can see your positions.
-
Private spot and swapsLive
Buy and sell listed assets with your USDC balance, at a live market price. Limit orders work on spot too.
-
Private transfers & holdsLive
Move a balance between accounts, or hold one privately.
-
Partner-listed assetsBy arrangement
Bring your own token or product, and your users hold and trade it privately. We set up each listing with you. See List your own asset.
-
Agent payments over x402 and agent accountsOn request
Let agents pay over standard x402, privately. Give each agent its own account with limits you set. Ask us to switch both on for your app.
The API at a glance
The API has two base URLs, which these docs call doors. Each request is signed with an Ed25519 key. There are no shared secrets.
| Door | Who signs | What it does |
|---|---|---|
/api/trade-api/v1 | an API key the account owner created | Trade perps and spot, set protection, read positions, orders, balances and fills, stream account events. No money out, ever. |
/api/txproxy/api | the account's own key | Everything the owner can do: register and revoke API keys, transfer, withdraw and claim to Solana, owner reads. |
GET /api/tx/:hash/receipt | nobody | Confirmation status of a submitted transaction. |
Networks and access
Sirius runs on Solana mainnet. Every signature includes the chain id, so a signature made for one network cannot be used on another.
| Mainnet | |
|---|---|
| Trading door | https://app.siriusprotocol.xyz/api/trade-api/v1 |
| Owner door | https://app.siriusprotocol.xyz/api/txproxy/api (signed-in session) |
| Chain id | solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d |
- The trading door is open. It answers only requests signed by a registered API key. Any route not on its list returns 404.
- Any account can create a key in Settings, API keys, once it has made its first deposit.
- The owner door needs a signed-in app session. Anyone can sign in. The account owner creates keys in the app (Settings, API keys).
export SIRIUS_DOOR="https://app.siriusprotocol.xyz/api/trade-api/v1" # API key requests
export SIRIUS_OWNER="https://app.siriusprotocol.xyz/api/txproxy/api" # owner-signed requests
curl -s "$SIRIUS_DOOR/config" # public: markets, minimums, fees, limits
Quickstart
This walks you to your first signed order. You open an account, get a key, then place, read and cancel an order that rests on the book. Until the SDK is out, sign each request with the schemes in Signing a request.
1. An account and a key
- Open the app Go to
https://app.siriusprotocol.xyz, sign in and fund the account (see Fund a user). - Create the key Go to Settings, API keys, Create API key. Choose the trade and read permissions, an expiry, and your server's IP. The app shows the secret once. Keep it on your server, never in a browser bundle.
2. Check the door
curl -s -o /dev/null -w '%{http_code}\n' "$SIRIUS_DOOR/config" # 200
curl -s "$SIRIUS_DOOR/account/<index>/state/challenge" # {"nonce": ...}
3. Place, read and cancel an order
// Sirius TypeScript SDK (coming soon): SiriusTradingClient, Ed25519Signer, AccountStream, TradingApiError
const c = new SiriusTradingClient({
network: "mainnet",
auth: "apiKey",
accountIndex: BigInt(process.env.SIRIUS_ACCOUNT_INDEX!),
accountId: process.env.SIRIUS_ACCOUNT_ID!, // 64 hex: the account's public key
signer: new Ed25519Signer(process.env.SIRIUS_API_SECRET!),
});
const mid = Number(await c.mid("BTC"));
const price = String(Math.floor(mid * 0.6)); // far below the market: it rests
const o = await c.placePerpOrder({ market: "BTC", side: "buy", size: "0.0003", price, leverage: 3 });
console.log(o.cloid); // known before sending
console.log(await c.orderStatus(o.cloid));
console.log(await c.cancel(o));
console.log(await c.positions(), await c.balances());
The SDK throws typed errors: catch (e) { if (e instanceof TradingApiError) console.log(e.status, e.code) }.
API keys
An API key lets your servers trade and read for an account without holding the account's own key. It is a separate Ed25519 key pair that the account owner registers.
- A key can trade, read, or both. No key can withdraw, transfer, claim, deposit or create another
key. Those permissions do not exist, and the money-out routes ignore
api_key. - A key expires within 365 days. You can also give it an IP allowlist (up to 16 exact addresses) and its own caps per order and per rolling 24 hours.
- The fee on every trade, by key or in the app, is published at siriusprotocol.xyz/fees. It is shown before you confirm.
- An account can have up to 10 active keys. The owner can revoke any key. A key can always revoke itself.
- Create a key in the app (Settings, API keys). Or create it in code with the SDK's
registerApiKeyin owner mode, through the owner door. It sends the same signed request the app sends.
Signing a request
To sign a trading request with an API key, start from the domain tag. Every signed message starts
with a domain tag that includes the network: domain_tag(T) = T || 0x1F || chain_id
(ASCII). All integers are little-endian. A key signature wraps the exact bytes the owner would sign
for the same action:
key_message = domain_tag("SIRIUS_API_KEY_SIG_V1") || 0x01 || api_pubkey[32] || route_bytes
signature = Ed25519(api_secret, key_message) // 64 bytes, sent as 128 hex
route_bytes = the owner's message for that action: same tag, the OWNER's account_id, same nonce
The body carries "api_key": "<key public key, 64 hex>" and the
signature. An owner signing directly sends route_bytes
alone and no api_key.
Schemes you sign
| Tag | Layout after the domain tag | Used by |
|---|---|---|
SIRIUS_PERP_OPEN_ORDER_V1 | 02, account_index u64, account_id[32], asset u32, side u8, reduce_only u8 (0), rest_style u8 (0 gtc, 1 alo), size_base u128, limit_price_fp u128, tp_fp u128, sl_fp u128, not_before u64, not_after u64, order_nonce u64, leverage u32 | opening limit order |
SIRIUS_PERP_ORDER_V1 | 01, the same body with reduce_only = 1, no leverage | reduce-only limit |
SIRIUS_PERP_ORDER_CANCEL_V1 | 01, account_id[32], order_nonce u64, not_after u64 | perp cancel |
SIRIUS_USER_PERP_OPEN_V1 + SIRIUS_USER_SLIPPAGE_V1 | market open, plus a required signed slippage bound when signed by a key | market open |
SIRIUS_USER_PERP_CLOSE_V1 | account_index u64, account_id[32], challenge_nonce[32], asset_id u32 | market close |
SIRIUS_PERP_TRIGGER_V1 / _REVOKE_V1 / _AMEND_V1 | stop-loss and take-profit: set, cancel, move | protection |
SIRIUS_PERP_TWAP_V1 / _CANCEL_V1 | TWAP order (sign the preview's disclosure_id) and its cancel | TWAP |
SIRIUS_SPOT_ORDER_V1 / _CANCEL_V1 | 01, account_index u64, account_id[32], asset u32, side u8, rest_style u8, size_micro_coin u128, limit_price_fp u128, not_before u64, not_after u64, order_nonce u64 | spot limit and cancel |
SIRIUS_SPOT_TRIGGER_V1 | spot entry, take-profit and stop-loss (not available yet) | spot triggers |
SIRIUS_OWNER_READ_V1 | account_index u64, state_version u64, nonce[32] | every signed read, and the stream |
SIRIUS_API_KEY_REGISTER_V2 / SIRIUS_API_KEY_REVOKE_V1 | register a key with its own caps (owner) / revoke (owner or the key itself) | key management |
The SDKs below build every scheme byte for byte, and test against the engine's own signing vectors.
Your order_nonce is your client order id. The order's
cloid is derived from it, so you know the cloid before you send.
A resting order's not_after may be up to 30 days ahead. A cancel,
registration or revocation may set it at most 600 seconds ahead. Set not_before = now - 30
to allow for clock skew.
Routes
Paths are relative to the trading door URL. The Perm column shows the key permission each route needs: T for trade, R for read.
| Route | Perm | What it does |
|---|---|---|
GET config | Markets, minimums, fee schedule, limits. Public. | |
GET account/{index}/state/challenge | Single-use read challenge, valid 120 s. | |
POST perp/order, perp/order/cancel | T | Resting limit order (gtc or post-only) and its cancel. |
GET perp/order/{cloid} | One resting order. | |
POST perp/open, perp/close | T | Market open sized by margin and leverage, with a signed slippage bound; market close of the whole position. |
POST perp/protection, perp/protection/pair, perp/protection/cancel, perp/trigger/amend | T | Stop-loss and take-profit: set, set both, cancel, move. |
POST perp/twap, perp/twap/cancel; GET perp/twap/preview, perp/twap/{ref} | T | TWAP order, preview and status. |
POST spot/order, spot/order/cancel | T | Spot limit order and cancel. For a market buy, send a marketable limit at your worst price. |
POST spot/trigger, spot/tpsl, spot/trigger/cancel, spot/trigger/amend | T | Spot entry, take-profit and stop-loss. Not available yet |
POST perp/positions, perp/orders/{index}, perp/protections/{index}, spot/orders/{index}, spot/triggers/{index} | R | Signed reads of positions, orders and protections. |
POST account/{index}/state, account/{index}/trades | R | Balances; fills and funding history, paginated. |
POST apikeys/list/{index}, apikeys/revoke | R | Your keys, caps and usage; a key revokes itself. |
GET apikeys/stream/{index} | R | Server-Sent Events: snapshot, then positions, orders, balances and fills as they change. Resumes with Last-Event-ID. |
To retry safely, send an Idempotency-Key header on every trading POST. If
the first request succeeded within 24 hours, the retry gets the original answer with
Idempotent-Replayed: true. Nothing is sent twice.
Reuse the same order_nonce on a retry. Never make a new one.
SDKs
SDK coming soon. The SDKs are not on npm or PyPI yet, and any package there that claims to be one is not ours. Until they are, sign requests yourself with the HTTP API reference above: the signing schemes and every route. The examples below show what the SDKs will look like.
The TypeScript and Python clients cover every route above. They support API key and owner modes, key registration and revocation, limit and market orders, take-profit and stop-loss, TWAP, spot triggers (not available yet), reads, the account stream, typed errors and safe retries.
Both SDKs test against the engine's own signing vectors. Each reads the lines Settings shows when you
create a key: SiriusTradingClient.fromEnv() in TypeScript,
Client.from_env() in Python.
await c.openMarket({ market: "ETH", side: "buy", leverage: 2, marginUsd: "10", maxSlippageBps: 100 });
await c.setTpsl("ETH", "4200", "2900"); // take-profit and stop-loss
console.log(await c.protections());
await c.closePosition("ETH"); // market close of the whole position
for await (const fill of c.iterTrades({ limit: 50 })) console.log(fill);
const s = new AccountStream(c).subscribe("fills", console.log).subscribe("positions", console.log);
await s.run({ maxEvents: 5 }); // SSE, resumes with Last-Event-ID
await c.revokeApiKey(); // a key can always revoke itself
# Sirius Python SDK (coming soon): Client, Ed25519Key
c = Client("mainnet", account_index, account_id_hex, Ed25519Key.from_hex(api_secret_hex), auth="api_key")
o = c.place_perp_limit("BTC", "buy", "0.0003", "45000", leverage=3)
print(o["cloid"], c.order_status(o["cloid"]))
print(c.cancel_perp(o["nonce"]), c.positions())
Errors and limits
Every error to a key-signed request has the body
{"error", "message", "code", "retryable"} and the header
X-Sirius-Error-Code. Write your logic against code.
Show message to people. New codes may be added. Existing codes are never removed.
| Code | Status | Meaning |
|---|---|---|
KEY_NOT_REGISTERED | 401 | Unknown key, or a key for the other network. |
KEY_REVOKED / KEY_EXPIRED | 401 | Permanent: make a new key. |
ACCOUNT_NOT_ENABLED | 401 | API keys are not switched on for this account yet. |
IP_NOT_ALLOWED | 401 | Client IP is not on the key's allowlist. |
BAD_SIGNATURE | 400/401 | The signature does not verify over these bytes. |
CHALLENGE_INVALID | 401 | Read challenge unknown, used or expired: fetch a fresh one. |
RATE_LIMITED | 429 | Over the key's per-minute budget. Retry after Retry-After. |
ORDER_CAP_EXCEEDED / DAY_CAP_EXCEEDED | 400/429 | Over the per-order or rolling 24 hour cap. |
DUPLICATE_ORDER | 400 | This order nonce already rests, was cancelled or filled. |
SLIPPAGE_REQUIRED / SLIPPAGE_EXCEEDED | 400/422 | Market open bound missing or breached. |
LEVERAGE_TOO_HIGH / MIXED_LEVERAGE | 400 | Above the market's maximum, or conflicts with the open position. |
INSUFFICIENT_BALANCE | 400/422 | Not enough spendable balance. |
VENUE_UNAVAILABLE / SERVICE_UNAVAILABLE | 400/503 | Retryable. |
| Limit | Value |
|---|---|
| Requests per key | A per-minute budget per key; read yours from apikeys/list |
| Per-order and rolling 24 hour notional | The lower of the network cap and the key's own cap |
| Keys | 10 active per account, 365 days at most |
| Streams | 2 per key, 6 hours each, then reconnect |
| Read challenges | 64 per client IP and a few per account: fetch one per read and use it at once |
| Minimum trade | $10 |
Accounts
Choose how your users' money is held. The three terms below are used everywhere else in these docs.
Two integration models
-
House accountSimplest
You own one Sirius account. You track each user's share in your own ledger. Every hold, transfer and swap runs from your account. Outsiders cannot see it. You manage no per-user keys. One account has one nonce sequence, so send its transactions one at a time.
-
Per-user accountsSelf-custodial
Each user gets their own key pair (see Create an account key in code). Only that key can spend the user's balance, so your users hold their own funds. Sirius sees account ids, never your user list.
Both models work today. You can start with a house account and move users to their own keys later.
Three terms
account_id- The account's Ed25519 public key (32 bytes). Only the matching secret key can spend from the account. In the house model that key is yours. In the per-user model it stays with the user and you never hold it. Sirius rejects a spend signed by anyone other than the account owner.
- SSD
- The account's USDC-backed balance, the base asset with
asset_id 0. A deposit credits SSD. A balance is a count of shares. Its USD value isshares * r_global / 1e9 / 1e6. Swaps move between SSD and a listed asset. Each listed asset has its ownasset_id. account_index- A number the account gets on its first deposit. Most endpoints take it. An account that has never
been funded has no index.
GET /api/account/by-id/:hexlooks up the index for anaccount_id.
Create an account key in code
Use this to create per-user accounts yourself. Users who sign in through the app get their key there instead.
An account is an Ed25519 key pair, and the public key is the account_id.
The public key, read as a little-endian number, must be below the BN254 field modulus. About 4 in 5
random keys fail this check. So generate keys in a loop until one passes. That takes about five tries
on average.
Store the secret key safely. It is the only key that can spend from the account.
import { getPublicKeyAsync, utils } from "@noble/ed25519";
// Accounts must be canonical BN254 field elements.
const BN254_R =
21888242871839275222246405745257275088548364400416034343698204186575808495617n;
const leToBig = (b: Uint8Array) => {
let x = 0n;
for (let i = b.length - 1; i >= 0; i--) x = (x << 8n) | BigInt(b[i]);
return x;
};
async function createAccount() {
for (;;) {
const secret = utils.randomPrivateKey(); // 32-byte secret
const accountId = await getPublicKeyAsync(secret); // 32-byte account_id
if (leToBig(accountId) < BN254_R) return { secret, accountId };
}
}
Fund a user
Use this to put a starting balance in a user's account. The user deposits USDC in the app's deposit sheet. An API key cannot deposit, so send your user to the app.
- From Solana, the user sends USDC from their own Solana wallet.
- From BNB Chain, the user signs in with a BNB Chain wallet and deposits from it. The app bridges it to Solana.
- From Hyperliquid, the user links their Hyperliquid address in the app. Then they send USDC from their Hyperliquid Spot balance to the account the app shows. It is credited in minutes.
- The deposit sheet lists any other chains it accepts today.
The deposit is credited to the account as SSD. The first deposit also gives the account its
account_index.
Every deposit must be at least $12. The first deposit has a one-time $2 account fee. Later deposits have no fee.
# Once credited, resolve the assigned index (owner door)
curl -s "$SIRIUS_OWNER/account/by-id/<account_id hex>"
Read a balance
Use this to show a user their balance. It also gives you the spend nonce you need before you sign a transfer, swap or withdrawal. The spend nonce is a number that each signed spend must carry. A stale one is rejected.
Balances are private. Sirius returns a balance and nonce only to a request signed by the account
owner, or by one of its API keys with read permission. Fetch a single-use challenge, sign
SIRIUS_OWNER_READ_V1 || account_index || state_version || nonce, and post it.
# 1. A single-use challenge, valid 120 s
curl -s "$SIRIUS_DOOR/account/7/state/challenge"
# -> { "account_index": 7, "exists": true, "domain": "SIRIUS_OWNER_READ_V1",
# "nonce": "<64 hex>", "state_version": 11, "ttl_secs": 120 }
# 2. Sign it and read the balances
curl -s -X POST "$SIRIUS_DOOR/account/7/state" \
-H "Content-Type: application/json" \
-d '{ "nonce": "<64 hex>", "signature": "<128 hex>", "api_key": "<64 hex>" }'
The SDKs do both steps in one call: c.balances(). Put the spend
nonce you read into the next signed transfer, swap or withdrawal.
Private transfer
Use this to move a balance from one user to another. A transfer moves shares between two accounts. A balance that stays in an account is a private hold: only the owner can read it.
- Resolve indices
GET /api/account/by-id/:hexfor both sender and recipient. - Read the sender nonce From Read a balance.
- Sign The sender's key signs the Transfer canonical bytes (Signing).
- Submit
POST /api/tx/transferwith theauthobject. - Confirm Poll the receipt (Confirming).
curl -s -X POST "$SIRIUS_OWNER/tx/transfer" \
-H "Content-Type: application/json" \
-d '{
"from_index": 7,
"to_index": 42,
"from_account_id": "<alice account_id hex>",
"to_account_id": "<bob account_id hex>",
"amount": "1000000",
"nonce": 3,
"auth": {
"sender_pubkey": "<alice account_id hex>",
"signature": "<128 hex chars>"
}
}'
# -> { "success": true, "message": "queued", "mempool_size": 12, "tx_hash": "0x..." }
A Transfer names from_index and to_index, so
Sirius can see who paid whom. Sirius is not a mixer. Do not tell your users that transfers
are unlinkable.
Private swap
Use this to let a user swap between their SSD balance and a listed asset. The price comes from an oracle. Every swap pairs SSD with exactly one asset. To go from one asset to another, swap through SSD.
To offer swaps in an asset that is not listed yet, see List your own asset.
-
Get a quote
GET /api/quote?asset=sbtc&side=buy, whereassetis the wire symbol (sbtc,seth,ssol) or the numericasset_id, andsideisbuyorsell. It returnsprice_fp, the price for your side (ask for a buy, bid for a sell), as a u128 at 1e9 fixed-point. Use it in the next step. If the oracle is stale or missing, the quote returns an error, not a price. The quote also carries your fee rate and the market's minimum trade. -
Build the Trade
Set
oracle_price_fpto the quote'sprice_fp. A price for the wrong side, or too far from the market, is rejected. The minimum trade is $10. Trade right after you quote and neither will happen. - Sign The user's key signs the Trade canonical bytes (Signing).
- Submit
POST /api/tx/tradewith theauthobject. - Confirm Poll the receipt (Confirming).
# 1. Quote a buy of BTC (wire symbol: sbtc)
curl -s "$SIRIUS_OWNER/quote?asset=sbtc&side=buy"
# -> {
# "asset": 1,
# "side": "buy",
# "mid_fp": 64006442500000,
# "bid_fp": 64006442500000,
# "ask_fp": 64006442500000,
# "price_fp": 64006442500000, // use this as oracle_price_fp
# "oracle_age_secs": 3,
# "min_trade_usd": 10.0,
# ...
# }
# 2. Submit the signed trade (oracle_price_fp = price_fp from the quote)
curl -s -X POST "$SIRIUS_OWNER/tx/trade" \
-H "Content-Type: application/json" \
-d '{
"account_index": 7,
"account_id": "<account_id hex>",
"from_asset": 0,
"to_asset": 1,
"input_amount": "5000000",
"oracle_price_fp": "64006442500000",
"oracle_timestamp": 1737000000,
"nonce": 4,
"auth": {
"sender_pubkey": "<account_id hex>",
"signature": "<128 hex chars>"
}
}'
Withdraw
Use this to send a user's balance back out to Solana.
POST /api/tx/withdraw burns shares and queues a withdrawal to the Solana
address in l1_destination. The user's key signs it, the same way as a transfer.
curl -s -X POST "$SIRIUS_OWNER/tx/withdraw" \
-H "Content-Type: application/json" \
-d '{
"account_index": 7,
"account_id": "<account_id hex>",
"amount": "1000000",
"l1_destination": "<32-byte Solana address hex>",
"nonce": 5,
"auth": {
"sender_pubkey": "<account_id hex>",
"signature": "<128 hex chars>"
}
}'
The withdrawal debits the account. Sirius then owes that amount to the Solana address. Sirius proves transactions in batches called epochs. Once the epoch with the withdrawal is proven, the USDC can be claimed on Solana.
To claim, call tx/claim-exit. It returns a Solana transaction, with the
fee sponsored by default. The destination wallet signs it, then you send it to
tx/claim-exit/submit. The app runs this claim for your users.
- The fee is 0.20% (20 bps), taken from the payout. A $50 withdrawal pays out $49.90.
- The minimum withdrawal is $2.
- A withdrawal arrives in minutes, once the funds return from the venue. In the app, a user can also send it on to BNB Chain.
- Only the account's own key can sign a withdrawal, transfer or claim. An API key can never move money out.
Trading fees are published at siriusprotocol.xyz/fees. Trading also earns rewards: see siriusprotocol.xyz/points.
Money in and out
| Action | Fee | Minimum |
|---|---|---|
| First deposit | $2, once | $12 |
| Later deposits | None | $12 |
| Transfers between accounts | None | |
| Withdraw to Solana | 0.20%, deducted from the payout | $2 |
| Trade | See siriusprotocol.xyz/fees | $10 |
List your own asset or product By arrangement
Use this to offer your own token or product to your users next to the listed markets. It can be a fund share, a wrapped position, a real-world asset or a token of your own. Its backing must be verifiable.
Contact us to arrange a listing for your asset. There is no self-serve listing. We work out each one with you.
To start, tell us:
- the asset and its unit;
- where its reserve is held;
- how the reserve can be verified: an onchain reserve, a custodian attestation or a proof of reserves;
- where its price comes from: a market venue, a NAV or a fixed peg.
Agent payments over x402 On request
Use this to let an agent pay for something without making the amount public.
Ask us and we'll switch it on for your app. Use the request-access form.
x402 is an open payment standard built on
HTTP 402 Payment Required. A server answers a request with what it costs. The
client pays, then retries with proof of payment in a header. x402 defines this exchange. It leaves the
actual payment to a facilitator, a service that settles it on a chain.
Sirius is that facilitator. The agent uses standard x402. The payment settles as a Sirius transfer, so no one can see the amount or the balance. The transfer is proven and verified on Solana.
Once it is on for your app, we send you the facilitator URL ($SIRIUS_FACILITATOR below) and the
x402 client and paywall packages. The agent's code stays an ordinary x402 client.
The flow
- Agent requests a resource Your server answers
402with payment requirements naming the Sirius scheme. - Agent pays It signs a Sirius transfer and puts the payload in the
X-PAYMENTheader. - Facilitator verifies It checks the signature, the nonce and the balance before anything settles.
- Facilitator settles The transfer goes through on Sirius. It is then proven and verified on Solana.
- Server returns the resource With the settlement receipt in
X-PAYMENT-RESPONSE.
The facilitator endpoints
The facilitator has three endpoints. supported is public. The other two
carry the payer's signature inside the payload.
| Endpoint | What it does |
|---|---|
GET /api/x402/supported | Scheme and network discovery. Public. |
POST /api/x402/verify | Check a payment without settling it. |
POST /api/x402/settle | Verify, then settle as a Sirius transfer. |
curl -s "$SIRIUS_FACILITATOR/api/x402/supported"
# -> { "kinds": [ { "x402Version": 1,
# "scheme": "sirius-private",
# "network": "solana" } ] }
The SDK
The Sirius x402 SDK covers both sides: the agent that pays and the server that charges. It is not published yet. The paywall import has no signing code, so a server that never signs ships no crypto code.
// Sirius x402 SDK (coming soon): siriusFetch, readSettlement
const res = await siriusFetch("https://api.example.com/v1/inference", {
account: { privateKey: process.env.AGENT_SECRET_KEY }, // 32-byte Ed25519 seed
apiBase: process.env.SIRIUS_FACILITATOR,
maxAmount: 50000n, // refuse anything dearer, sign nothing
method: "POST",
body: JSON.stringify({ prompt }),
});
const data = await res.json();
const receipt = readSettlement(res); // { success, transaction, network, payer }
On a 402, it reads the requirements and picks the
sirius-private offer. It looks up the payer's index and nonce, signs a
transfer, and sends the request again with X-PAYMENT.
It pays at most once per call. If the retry also gets a 402, it returns that response and does not pay again.
// Sirius x402 SDK, paywall part (coming soon): siriusPaywall
const paywall = siriusPaywall({
price: "$0.02", // or base units: "20000"
payTo: process.env.SIRIUS_ACCOUNT_ID, // your account_id hex
facilitator: process.env.SIRIUS_FACILITATOR,
network: "solana",
});
app.get("/v1/inference", paywall.middleware, (req, res) => {
res.json({ answer: 42 }); // only runs once the payment has settled
});
middleware is a thin wrapper over handle, which
takes data and returns data, so it works with any framework.
The paywall always sends the facilitator its own requirements, never the client's copy. So a client cannot pay itself, or pay less, and have the receipt accepted.
The payment payload
Under the SDK, the sirius-private payload is a signed Sirius transfer. It
has the same fields and the same signing bytes as a Private transfer. If you
can already send a transfer, you need no new signing code for x402.
curl -s -X POST "$SIRIUS_FACILITATOR/api/x402/settle" \
-H "Content-Type: application/json" \
-d '{
"x402Version": 1,
"paymentPayload": {
"scheme": "sirius-private",
"network": "solana",
"payload": {
"from_index": 7,
"to_index": 42,
"from_account_id": "<agent account_id hex>",
"to_account_id": "<your account_id hex>",
"amount": "20000",
"nonce": 3,
"auth": {
"sender_pubkey": "<agent account_id hex>",
"signature": "<128 hex chars>"
}
}
},
"paymentRequirements": {
"scheme": "sirius-private",
"network": "solana",
"maxAmountRequired": "20000",
"payTo": "<your account_id hex>",
"asset": "0",
"resource": "https://api.example.com/v1/inference"
}
}'
# -> { "success": true,
# "transaction": "<l2 tx hash hex>",
# "network": "solana",
# "payer": "<agent account_id hex>" }
/api/x402/verify takes the same body. It returns
{ "isValid": true, "payer": "..." }, or
{ "isValid": false, "invalidReason": "..." }, with HTTP 200 either way. A
false result is an answer, not a transport error.
Settlement returns a Sirius transaction hash. Poll
GET /api/tx/:hash/receipt if you need to know it is final.
What gets checked
-
The payment is yours
The recipient must match
payTo, so a client cannot pay itself and show the receipt as proof. The amount must be at leastmaxAmountRequired. -
The payer authorised it
The facilitator checks the Ed25519 signature over the transfer bytes the same way the transfer route does. An x402 payment is never easier to authorise than a plain transfer. The facilitator holds no funds. It cannot move money the payer did not sign for.
-
It can actually settle
The nonce, the balance and the per-account submission limit are checked before the payment is sent.
verify is a check, not a lock. It reads the balance and nonce at that
moment. Another spend from the same account can make the payment fail between
verify and settle. Release the resource only
after a successful settle.
Agent accounts On request
Use this to give an agent its own money without giving it yours. An agent account is an ordinary Sirius account that you register under your own account, the parent. It has its own key and signs its own transactions. It can spend only within the limits you set. You can revoke it.
Ask us and we'll switch it on for your app. Use the request-access form.
First fund the agent's account_id so the account exists. Then register it
with your limits. We run the registration for your parent account (the call below), and your parent
key's signature is checked as well.
Limits are in USD and can be fractions, so you can set limits for very small payments.
curl -s -X POST "$SIRIUS_FACILITATOR/api/agent/create" \
-H "Content-Type: application/json" \
-d '{
"parent_index": 7,
"agent_pubkey": "<agent account_id hex>",
"limits": {
"daily_notional_usd": 500,
"max_payment_usd": 5,
"allowed_assets": [0]
}
}'
# -> { "success": true, "agent_index": 118, "revocable": true, "persisted": true }
POST /api/agent/revoke stops the agent from spending, starting with its
next spend. A revoked agent cannot be restored.
GET /api/agent/:index returns the agent's limits, what it has spent in the
current window, and whether it is revoked. GET /api/agent/list/:parent_index
lists the agents under a parent.
-
Limits are checked first
Sirius refuses a payment over the limit before it is queued. You do not rely on the agent to behave. The check runs after the signature check, so a forged request cannot use up an agent's budget.
-
Agents run in parallel
Each agent account has its own nonce sequence. Agents running in parallel do not wait for each other, as they would on one shared house account.
-
Rolling window
daily_notional_usdcovers a rolling 24 hours, not a calendar day. Registering an agent again updates its limits but keeps the current window, so posting again does not reset what it has spent. -
An activity record
Sirius records every payment, withdrawal or swap it allows or refuses for an agent. The parent reads the record by signing a single-use challenge with its own key. Each entry has the time, the asset and amount, the USD value, the counterparty, the transaction hash, and whether it was allowed. A refusal includes the reason. A parent can read only its own agents.
# 1. get a single-use challenge for the parent
curl -s "$SIRIUS_FACILITATOR/api/agent/activity/challenge/7"
# -> { "nonce": "<hex>", ... }
# 2. sign it with the PARENT key and read the agent's record
curl -s -X POST "$SIRIUS_FACILITATOR/api/agent/118/activity" \
-H "Content-Type: application/json" \
-d '{ "nonce": "<hex>", "signature": "<128 hex chars>" }'
# -> { "entries": [ { "at": ..., "kind": "spend", "asset_id": 0,
# "amount": "20000", "usd": "0.02", "to_index": 42,
# "tx_hash": "<hex>", "allowed": true } ],
# "truncated": false, "durable": false, "recorded_since": ... }
What the limits cover
Limits apply on every route where the agent's own key moves value.
| Route | What counts against the limit |
|---|---|
POST /api/tx/transfer | the amount transferred |
POST /api/tx/withdraw | the amount withdrawn. A withdrawal to Solana uses the same daily budget as a payment. |
POST /api/tx/trade | the USD notional of the leg being sold. Both the asset sold and the asset bought must be in allowed_assets. |
POST /api/perp/open | the margin posted, not the leveraged notional |
POST /api/x402/settle | the amount settled. /verify reports the verdict without consuming budget. |
daily_notional_usd limits everything the agent moves, not only payments.
Swaps and perp margin count against it. Set a trading agent's limit to the volume it will trade.
POST /api/perp/close has no limit, because it returns value to the agent.
A revoked agent can always close an open position. Revoking stops new spending and never traps funds.
Sirius applies the limits before it accepts a transaction. Sirius keeps the activity record and gives it to the parent on request. Neither is part of the zero-knowledge proof.
The record holds the latest 256 entries per agent and says when it dropped older ones. It clears when the service restarts, so save anything you need to keep.
Spend signing
The account's own key signs every transfer, swap and withdrawal. (Trading by API key uses the schemes in Signing a request.)
Every spend works the same way. Build the transaction's canonical bytes, using the layout
below. Sign them with the account's Ed25519 secret key. Put the signature in the
auth object. The layout is the complete spec, so you can build it in any
language.
{
"sender_pubkey": "<64 hex chars = the account's 32-byte Ed25519 pubkey>",
"signature": "<128 hex chars = the 64-byte Ed25519 signature>"
}
The server requires sender_pubkey == account_id for the transaction (for a
Transfer, the from_account_id). So a user can only sign for their own
account.
The canonical layout
All integers little-endian, fields concatenated with no separators:
"SIRIUS_L2_V1" (12 ASCII bytes) || 0x1F || chain_id (ASCII) || tx_type:u8 || <per-variant fields>
chain_id = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"
The version tag SIRIUS_L2_V1
(hex 5349524955535f4c325f5631), the separator byte and the chain id tie
every signature to one network.
The tx_type byte stops a signature for one kind of transaction being
reused for another. Transfer is 1, Withdraw is 2, Trade is 4.
Per-variant fields
Transfer (tx_type = 1)
from_index : u64 (8 LE)
to_index : u64 (8 LE)
from_account_id : [u8; 32]
to_account_id : [u8; 32]
amount : u128 (16 LE)
nonce : u64 (8 LE)
Withdraw (tx_type = 2)
account_index : u64 (8 LE)
account_id : [u8; 32]
amount : u128 (16 LE)
l1_destination : [u8; 32]
nonce : u64 (8 LE)
Trade (tx_type = 4)
account_index : u64 (8 LE)
account_id : [u8; 32]
from_asset : u32 (4 LE)
to_asset : u32 (4 LE)
input_amount : u128 (16 LE)
oracle_price_fp : u128 (16 LE)
oracle_timestamp : u64 (8 LE)
nonce : u64 (8 LE)
Worked example: Transfer
A complete encoder that needs only @noble/ed25519:
import { sign } from "@noble/ed25519";
const CHAIN_ID = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d"; // Solana mainnet
const DOMAIN = new TextEncoder().encode("SIRIUS_L2_V1\x1f" + CHAIN_ID); // tag, 0x1F, chain id
function u64le(n: bigint) { const b = new Uint8Array(8); new DataView(b.buffer).setBigUint64(0, n, true); return b; }
function u128le(n: bigint) {
const b = new Uint8Array(16), dv = new DataView(b.buffer);
dv.setBigUint64(0, n & 0xffffffffffffffffn, true);
dv.setBigUint64(8, n >> 64n, true);
return b;
}
const concat = (...a: Uint8Array[]) => { const o = new Uint8Array(a.reduce((s,x)=>s+x.length,0)); let i=0; for (const x of a){o.set(x,i);i+=x.length;} return o; };
// Transfer (tx_type = 1)
const bytes = concat(
DOMAIN,
Uint8Array.of(1), // tx_type
u64le(7n), // from_index
u64le(42n), // to_index
aliceIdBytes, // from_account_id (32)
bobIdBytes, // to_account_id (32)
u128le(1_000_000n), // amount
u64le(3n), // nonce
);
const signature = await sign(bytes, aliceSecret32); // 64-byte Ed25519 sig
Worked example: Trade
The Trade layout reuses the helpers above. It adds a 4-byte encoder for the two
u32 asset ids:
import { sign } from "@noble/ed25519";
// DOMAIN, u64le, u128le, concat are reused from the Transfer example above.
function u32le(n: number) { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n, true); return b; }
// Trade (tx_type = 4)
const bytes = concat(
DOMAIN,
Uint8Array.of(4), // tx_type
u64le(7n), // account_index
accountIdBytes, // account_id (32)
u32le(0), // from_asset (0 = SSD)
u32le(1), // to_asset (1 = BTC)
u128le(5_000_000n), // input_amount
u128le(64_006_442_500_000n), // oracle_price_fp (= quote price_fp)
u64le(1_737_000_000n), // oracle_timestamp
u64le(4n), // nonce
);
const signature = await sign(bytes, accountSecret32); // 64-byte Ed25519 sig
The nonce in the bytes is the account's current spend nonce. Read it with
Read a balance. A signature over a stale nonce is rejected.
Confirming transactions
Use this to learn when a transaction is final. Every accepted spend returns
{ "success": true, ... } and a tx_hash. That
means queued, not settled.
Poll the receipt until it reaches finalized. The receipt has the inclusion
proof and, once the batch is recorded on Solana, the Solana signature.
curl -s "$SIRIUS_OWNER/tx/<tx_hash>/receipt"
# status: pending -> included -> proved -> finalized (or dropped, with a reason)
# -> { "tx_hash": "0x...", "status": "finalized", "epoch_number": 128,
# "inclusion_proof": { ... }, "solana_tx_signature": "<sig>", ... }
Treat a balance as spendable once its funding transaction is finalized.
pending or a 404 means not yet. dropped means it
will never apply. Never move value on the submit response alone.