Private transfers, trades, and swaps for your users.
Sirius gives the people who use your app private, USDC-backed balances they can hold, send, and trade. Your users trade the listed assets, or you list your own token or product with verified inventory and they trade that privately too. Balances are private, every transaction is verified onchain, and the whole thing sits behind a small signed HTTP API. You do not build a chain, a prover, or a key stack. You call an endpoint.
What is live today
-
Private transfers & holdsLive
Move a balance between accounts, or simply hold one. Balances and nonces are readable only by the account owner.
-
Private swaps & tradesLive
Swap between the USDC-backed balance and any listed backed asset at an oracle-referenced price.
-
Partner-listed assetsLive
Define your own token or product, verify its inventory, and your users hold and swap it privately over the same endpoints. See List your own asset.
-
Agent payments over x402Live
Settle standard x402 payments privately. An agent pays over the open standard and the amount stays off the public ledger.
The API at a glance
Nine endpoints cover the whole integration. Everything under /api/tx
carries a user signature; your server adds the bearer token on every call except the public receipt.
| Endpoint | What it does |
|---|---|
GET /api/status | Health check. |
GET /api/account/by-id/:hex | Resolve an account_id to its numeric index. |
GET /api/account/:index | Read a balance and the current spend nonce. |
POST /api/tx/deposit | Credit a test account (production funding is a Solana USDC deposit). |
POST /api/tx/transfer | Private transfer between two accounts. |
GET /api/quote | Oracle-referenced price, spread, and effective fee for a swap. |
POST /api/tx/trade | Private swap between SSD and a listed asset. |
POST /api/tx/withdraw | Send a balance back out to Solana. |
GET /api/tx/:hash/receipt | Confirmation status. Public, no token needed. |
Quickstart
The shortest path to a first private transfer: point at the API, create a user account, fund it, then sign and submit a transfer and confirm it.
1. Point at the API
Your server authenticates every call with a bearer token. Keep it server-side: if you call Sirius from a browser, put a same-origin proxy in front that attaches the token upstream, and never ship it in a public bundle. Every transaction is additionally signed with the owning account's key, which is the real spend authorization, one house key or per-user keys, your choice (see Accounts).
export SIRIUS_API_BASE="https://demo.siriusprotocol.xyz/txapi"
export SIRIUS_TOKEN="<your bearer token>"
# Health check
curl -s "$SIRIUS_API_BASE/api/status" \
-H "Authorization: Bearer $SIRIUS_TOKEN"
2. Create an account
An account is an Ed25519 keypair. The public key is the
account_id. This can be one house account for your whole app, or one
account per user (see Accounts); the snippet is the same either way. About 1
in 8 random keys fails a canonicality check the system requires, so generate in a loop until one
passes; the snippet below handles it. Store the secret key: it is the only thing that can authorize
that account's spends.
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;
};
const hex = (b: Uint8Array) =>
[...b].map((x) => x.toString(16).padStart(2, "0")).join("");
// Regenerate until the pubkey passes the canonicality check.
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 };
}
}
const alice = await createAccount();
console.log(hex(alice.accountId)); // 64 hex chars = account_id
// Store alice.secret for this user. It is their ONLY spend authority.
3. Fund it
An account exists once it is funded, at which point it is assigned a numeric
account_index. A USDC deposit on Solana credits the account automatically;
test accounts can be credited directly (see Fund a user).
# account_id -> account_index (once funded)
curl -s "$SIRIUS_API_BASE/api/account/by-id/<alice_account_id_hex>" \
-H "Authorization: Bearer $SIRIUS_TOKEN"
4. Sign and submit a transfer
The user's key signs the transaction; your server POSTs it. Build the canonical bytes (see
Signing), sign with the sender's secret, and include the
auth object.
curl -s -X POST "$SIRIUS_API_BASE/api/tx/transfer" \
-H "Authorization: Bearer $SIRIUS_TOKEN" \
-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 }
5. Confirm it
Poll the receipt until it finalizes (see Confirming).
curl -s "$SIRIUS_API_BASE/api/tx/<tx_hash>/receipt"
# status progresses: included -> proved -> finalized
Accounts
Two integration models
-
House accountSimplest
One Sirius account that you own. Your users' balances live in your own ledger; every hold, transfer, and swap happens from your account and is private to the outside world. No per-user keys to manage. One account means one nonce stream, so serialize your submissions.
-
Per-user accountsSelf-custodial
Each user gets their own keypair (the snippet in the Quickstart), and that key is the only thing that can spend their balance, so your users custody their own funds. Sirius never sees your user list, only account ids.
Both models work today, for the listed assets and for your own listed products. Start with the house account and move users to their own keys later if self-custody is part of your product.
The account model itself
Three things to hold in your head, and the rest of the doc is operations on top of them.
- account_id is an Ed25519 public key (32 bytes), and the matching secret key is the only thing that can authorize that account's spends. In the house model that key is yours; in the per-user model each user's key stays with them and you never hold it. Either way, your integration submits signed transactions, and Sirius rejects a spend signed by anyone other than the account owner.
- Balances are USDC-backed shares. A balance is a share count; USD value is
shares * r_global / 1e9 / 1e6. A deposit credits the base asset SSD (asset_id 0). Swaps move between SSD and any listed backed asset, each identified by its ownasset_id. The listed set spans crypto majors and beyond and keeps growing, and you can list your own product alongside them. - account_index is assigned on first deposit. Most endpoints take this numeric
index;
GET /api/account/by-id/:hexresolves anaccount_idto it. Before an account is funded it has no index yet.
Fund a user
Use this to put a starting balance into a user's account. Production funding is a USDC deposit on Solana: the user (or you on their behalf) deposits to the Sirius vault, a watcher sees the event, and the account is credited automatically as SSD. For test accounts, a direct credit endpoint is available so you can fund without touching Solana.
# Test-account credit. Production funding is a real Solana USDC deposit.
curl -s -X POST "$SIRIUS_API_BASE/api/tx/deposit" \
-H "Authorization: Bearer $SIRIUS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"account_id": "<alice account_id hex>",
"amount": "5000000000"
}'
# -> { "success": true, "message": "queued", "mempool_size": 3 }
# Once credited, resolve the assigned index:
curl -s "$SIRIUS_API_BASE/api/account/by-id/<alice account_id hex>" \
-H "Authorization: Bearer $SIRIUS_TOKEN"
Read a balance
Use this to show a user their balance, and to read the spend nonce you need before signing.
Resolve the account to its index, then read the account. The response carries the balance and the
current nonce.
curl -s "$SIRIUS_API_BASE/api/account/7" \
-H "Authorization: Bearer $SIRIUS_TOKEN"
# -> {
# "index": 7,
# "balance_shares": "5000000000",
# "nonce": 3
# }
Balances are private: the plaintext balance and nonce are readable only by the account's owner,
through an owner-read flow the account's key unlocks by signing a challenge. The
nonce you read here is the value you put in the next signed
transaction.
Private transfer
Use this to move a balance from one user to another, or to let a user simply hold one. A transfer moves shares between two accounts. Holding shares in an account is the private hold: the balance and nonce are readable only by the owner.
- 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_API_BASE/api/tx/transfer" \
-H "Authorization: Bearer $SIRIUS_TOKEN" \
-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 }
A Transfer names from_index and to_index, so
the sender-to-recipient link is visible to the operator. Sirius is not a mixer. Do not describe
transfers as unlinkable to your users.
Private swap
Use this to let a user trade between their USDC-backed balance and a listed asset. A swap
is an oracle-priced conversion between SSD (asset_id 0) and one listed
backed asset. Every swap pairs SSD with exactly one asset, so asset-to-asset routes through SSD. Each
asset has its own asset_id; 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. The quote is fail-closed, so a stale or missing oracle returns an error, not a price. -
Build the Trade
Set
oracle_price_fpto the quote'sprice_fp. Use the price the quote just gave you: an off-side or out-of-band price is rejected, and a small minimum trade size applies. In practice, quote then trade, and you never hit either. - 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_API_BASE/api/quote?asset=sbtc&side=buy" \
-H "Authorization: Bearer $SIRIUS_TOKEN"
# -> {
# "asset": 1,
# "side": "buy",
# "mid_fp": 63942500000000,
# "bid_fp": 63878557500000,
# "ask_fp": 64006442500000,
# "price_fp": 64006442500000, // use this as oracle_price_fp
# "spread_bps": 10,
# "oracle_age_secs": 3
# }
# 2. Submit the signed trade (oracle_price_fp = price_fp from the quote)
curl -s -X POST "$SIRIUS_API_BASE/api/tx/trade" \
-H "Authorization: Bearer $SIRIUS_TOKEN" \
-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 Solana withdrawal to
l1_destination. The user's key signs it, exactly like a transfer.
curl -s -X POST "$SIRIUS_API_BASE/api/tx/withdraw" \
-H "Authorization: Bearer $SIRIUS_TOKEN" \
-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>"
}
}'
Two backstop exit paths (claim-exit and force-withdraw) guarantee a user can always exit to Solana, even if their transactions stop being sequenced.
List your own asset or product
This is the part most partners come for. The listed assets are just the defaults: you can
define your own token or product, verify its inventory, and your users hold, transfer, and swap it
privately over the exact same endpoints, with your asset's own
asset_id. A fund share, a wrapped position, an RWA, a token of your own:
if you can verify the backing, Sirius can carry it privately.
How listing works
By talking to us, once. There is no self-serve listing API: we assign the
asset_id, wire the price source, and connect your inventory verification.
After that one-time setup, everything is API — your users quote and trade your asset like any listed
one. Verification then runs one of two ways, depending on the asset: for an onchain reserve we poll it
for you (nothing to run); for a custodian-held or NAV-priced asset, you keep it live with two small
feeds (see Keeping a listed asset live).
- Define the product The asset, its unit, where its reserve lives,
and where its price comes from (a market venue, a NAV feed, or a fixed peg). We assign it an
asset_idand wire the price source. - Verify the inventory Pick whichever route fits the asset (below). This is what makes your users' balances backed rather than an IOU.
- We enforce the cap Issuance of your asset is capped at its verified backing. A transaction that would push the outstanding balance past verified inventory is refused, and issuance fails closed if the backing figure goes stale.
- Your users trade it The same quote and
POST /api/tx/tradecalls, with yourasset_id. Private holds and private swaps from day one; value moves between users as private SSD transfers.
What your integration looks like after listing
Say your asset goes live as asset_id 7. It behaves exactly like the
default assets, same endpoints, same signing, your id:
# Quote your asset by its numeric id
curl -s "$SIRIUS_API_BASE/api/quote?asset=7&side=buy" \
-H "Authorization: Bearer $SIRIUS_TOKEN"
# A user buys your asset with their SSD balance
# (same Trade call and signing as the swap section, to_asset = 7)
curl -s -X POST "$SIRIUS_API_BASE/api/tx/trade" \
-H "Authorization: Bearer $SIRIUS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"account_index": 7,
"account_id": "<account_id hex>",
"from_asset": 0,
"to_asset": 7,
"input_amount": "5000000",
"oracle_price_fp": "<price_fp from the quote>",
"oracle_timestamp": <from the quote>,
"nonce": 4,
"auth": {
"sender_pubkey": "<account_id hex>",
"signature": "<128 hex chars>"
}
}'
# Selling back is the same call with from_asset 7, to_asset 0.
The three verification routes
-
Onchain proof of reserves
You register a backing address on chain, and an oracle or watcher polls its balance. Best for assets that already live on a public chain.
-
Custodian attestation
A custodian signs a feed of the inventory it holds for you. Best for off-chain or real-world assets where the reserve is not directly observable on chain.
-
ZK proof of reserves
You prove that inventory is greater than or equal to liabilities without revealing the underlying positions, using the same proving stack Sirius already runs.
Keeping a listed asset live
An onchain-reserve asset needs nothing from you after listing: we poll the reserve. A custodian-held or NAV-priced asset is kept live by two feeds you push on a heartbeat, each authenticated with a per-asset feed token we issue at listing (not your main bearer token, so a feed can only ever write its own asset). Both fail closed: if a feed goes stale, new buys of that asset are refused until it resumes, while sells and holds stay open.
Price / NAV feed
Push the current price whenever it moves (for a fixed peg, on a slow heartbeat). The
depth_usd figure is the notional the price is good for; a larger single
trade is refused as unpriceable.
curl -s -X POST "$SIRIUS_API_BASE/api/feed/7/price" \
-H "Authorization: Bearer $SIRIUS_FEED_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"price_fp": "10000000000", # price in 1e9 fixed-point ($10.00)
"depth_notional_usd": 25000 # optional; this price is good for $25k
}'
# -> { "success": true, "asset": 7, "price_fp": "10000000000", ... }
Inventory attestation
Push the current verified inventory, in the asset's base units, on your attestation cadence (a custodian report, a NAV cut). Issuance of the asset is capped at the freshest figure: a buy that would push outstanding past it is refused.
curl -s -X POST "$SIRIUS_API_BASE/api/feed/7/inventory" \
-H "Authorization: Bearer $SIRIUS_FEED_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "inventory_units": "1000000000" }' # verified backing, base units
# -> { "success": true, "asset": 7, "inventory_units": "1000000000", ... }
Agent payments over x402 Live
Use this to let an agent pay for something without publishing the amount.
x402 is an open payment standard, governed
by the Linux Foundation, that revives HTTP 402 Payment Required: a server
answers a request with payment requirements, the client pays, and retries with proof of payment in a
header. x402 specifies the handshake. It does not specify where value moves, so
settlement is delegated to a facilitator that submits on a configured chain.
That settlement layer is what Sirius is. The agent speaks standard x402, and the payment settles on the private rail instead of a public one: the amount and balance stay encrypted, the transaction is proven and verified onchain, and the operator keeps a full record through a viewing key. Solana is already a supported x402 network, so this is an integration rather than a migration.
Nothing about the agent's code changes. It is an ordinary x402 client. The difference is that the settlement it points at does not publish what it paid.
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 The signature, the nonce, and the balance are checked before anything settles.
- Facilitator settles The transfer lands on the rail, acknowledged in under 100ms, proven and verified onchain behind it.
- Server returns the resource With the settlement receipt in
X-PAYMENT-RESPONSE.
The facilitator endpoints
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, no token. |
POST /api/x402/verify | Check a payment without settling it. |
POST /api/x402/settle | Verify, then settle on the private rail. |
curl -s "$SIRIUS_API_BASE/api/x402/supported"
# -> { "kinds": [ { "x402Version": 1,
# "scheme": "sirius-private",
# "network": "solana-devnet" } ] }
The SDK
Both halves of the handshake ship as @sirius/x402. The paywall import is
free of the signing code, so a server that never signs carries no crypto in its bundle.
import { siriusFetch, readSettlement } from "@sirius/x402";
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_API_BASE,
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, picks the
sirius-private offer, resolves the payer's index and nonce, signs a transfer,
and replays the request with X-PAYMENT. One payment per call: if the retry is
also a 402, that response is returned rather than paid again.
import { siriusPaywall } from "@sirius/x402/paywall";
const paywall = siriusPaywall({
price: "$0.02", // or base units: "20000"
payTo: process.env.SIRIUS_ACCOUNT_ID, // your account_id hex
facilitator: process.env.SIRIUS_API_BASE,
network: "solana-devnet",
});
app.get("/v1/inference", paywall.middleware, (req, res) => {
res.json({ answer: 42 }); // only runs once the payment has settled
});
middleware is a thin binding over handle, which
is data in and data out, so any framework works. The requirements sent to the facilitator are always the
server's own and never the client's copy, so a client cannot pay itself, or pay less, and have the receipt
accepted.
The payment payload
Underneath the SDK, the sirius-private payload is a signed Sirius
transfer: the same fields and the same canonical signing bytes as
Private transfer. If you can already spend on Sirius, you can pay over x402 with no
new signing code.
curl -s -X POST "$SIRIUS_API_BASE/api/x402/settle" \
-H "Content-Type: application/json" \
-d '{
"x402Version": 1,
"paymentPayload": {
"scheme": "sirius-private",
"network": "solana-devnet",
"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-devnet",
"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-devnet",
# "payer": "<agent account_id hex>" }
/api/x402/verify takes the identical body and returns
{ "isValid": true, "payer": "..." }, or
{ "isValid": false, "invalidReason": "..." } with HTTP 200 — a verdict, not a
transport error. Settlement returns an L2 transaction hash; poll
GET /api/tx/:hash/receipt if you need finality.
What gets checked
-
The payment is yours
The recipient must match
payTo, so a client cannot pay itself and present the receipt as proof. The amount must be at leastmaxAmountRequired. -
The payer authorised it
The Ed25519 signature over the canonical transfer bytes is enforced exactly as on the direct spend routes, so an x402 payment is never a weaker authorisation than a plain transfer. The facilitator holds no funds and cannot move value the payer did not sign for.
-
It can actually settle
Nonce and balance are checked before submission, along with the per-account submission guardrail.
verify is a pre-flight check, not a lock. Balance and nonce are read at
call time, so a concurrent spend from the same account can invalidate a payment between
verify and settle. Release the resource on a
successful settle.
Agent accounts Live
Use this to give an agent its own money without giving it yours. An agent needs a key it controls and a balance it can spend, and an operator needs that to be bounded and revocable. A Sirius agent account is an ordinary account you register as a sub-account of yours: it holds its own key, signs its own transactions, and spends only within the limits you set.
Fund the agent's account_id first so it exists on the rail, then register
it. Limits are micro-USD and accept fractions, so micropayments are expressible.
curl -s -X POST "$SIRIUS_API_BASE/api/agent/create" \
-H "Authorization: Bearer $SIRIUS_TOKEN" \
-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 ends an agent's authority from the next spend
check onward, and is terminal. GET /api/agent/:index returns the limits,
rolling-period spend, and revocation status; GET /api/agent/list/:parent_index
lists the agents beneath a parent.
-
Bounded before submission
A payment over the limit is refused before it reaches the mempool, not left to the agent's own good behaviour. The check runs after signature verification, so a forged request cannot burn an agent's budget.
-
Concurrency safe
Each agent account carries its own nonce stream, so agents running in parallel do not serialize behind one another the way a shared house account does.
-
Rolling window
daily_notional_usdis a rolling 24 hours, not a calendar day. Re-registering an agent updates its limits but preserves the current window, so a limit cannot be reset by re-posting. -
You see what your agents did
Every payment, exit, or swap Sirius admits or refuses for an agent is recorded, and the parent account reads that record by signing a single-use challenge with its own key. Each entry carries the timestamp, the asset and amount, the USD value, the counterparty, the transaction hash, and whether it was allowed or refused with the reason. A parent can read only its own agents.
# 1. get a single-use challenge for the parent
curl -s "$SIRIUS_API_BASE/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_API_BASE/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 authorises value to move.
| Route | What counts against the limit |
|---|---|
POST /api/tx/transfer | the amount transferred |
POST /api/tx/withdraw | the amount withdrawn. An exit to Solana charges the same daily budget a payment does. |
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 is a turnover budget rather than a payments budget, so
swaps and perp margin count against it. Size a trading agent's limit for the volume it will move.
POST /api/perp/close is not limited, because it returns value to the agent: a
revoked agent can always close an open position, since revocation stops new spending and never traps
funds.
Limits are applied before a transaction is admitted, and the activity record is kept by the operator and released to the parent on request. Neither is part of the zero-knowledge state transition. The record holds the most recent 256 entries per agent and reports what it truncated, and it clears on restart, so pull anything you need to keep.
Listing is coordinated with us during the beta: send the listing request through the request-access form and we will set it up with you, usually within days, not weeks.
Signing
This is the one part you have to implement, and it is the same for every spend: build the canonical
bytes of the transaction, Ed25519-sign them with the account's secret key, and put the signature in the
auth object. The layout below 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 enforces sender_pubkey == account_id for the transaction (for a
Transfer, the from_account_id), so a user can only ever sign for their own
account.
The canonical layout
All integers little-endian, fields concatenated with no separators:
"SIRIUS_L2_V1" (12 ASCII bytes) || tx_type:u8 || <per-variant fields>
The domain tag SIRIUS_L2_V1
(hex 5349524955535f4c325f5631) prevents cross-network replay; the
tx_type byte prevents lifting a signature from one variant to 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, dependency-light encoder using @noble/ed25519:
import { sign } from "@noble/ed25519";
const DOMAIN = new TextEncoder().encode("SIRIUS_L2_V1"); // 12 bytes
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 same approach for the Trade layout. It reuses the helpers above, plus 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, which you read
from the account (Read a balance). The sequencer checks it, so a signature over a
stale nonce is rejected.
Confirming transactions
Every accepted spend returns { "success": true, ... } and a
tx_hash. That means queued, not settled. Poll the receipt until it finalizes;
it carries the inclusion proof and, once anchored, the Solana signature.
curl -s "$SIRIUS_API_BASE/api/tx/<tx_hash>/receipt"
# status progresses: included -> proved -> finalized
# -> { "status": "finalized", "epoch": 128, "solana_signature": "<sig>", ... }
Treat a balance as spendable once its funding transaction reaches
finalized. The receipt endpoint is public, so you can poll it without the
bearer token.