Sirius Integration Docs
Sirius Protocol · Integration Docs

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.

Quickstart →

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.

DoorWho signsWhat it does
/api/trade-api/v1an API key the account owner createdTrade perps and spot, set protection, read positions, orders, balances and fills, stream account events. No money out, ever.
/api/txproxy/apithe account's own keyEverything the owner can do: register and revoke API keys, transfer, withdraw and claim to Solana, owner reads.
GET /api/tx/:hash/receiptnobodyConfirmation 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 doorhttps://app.siriusprotocol.xyz/api/trade-api/v1
Owner doorhttps://app.siriusprotocol.xyz/api/txproxy/api (signed-in session)
Chain idsolana: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).
bash
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

  1. Open the app Go to https://app.siriusprotocol.xyz, sign in and fund the account (see Fund a user).
  2. 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

bash
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

typescript
// 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 registerApiKey in 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 signature
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

TagLayout after the domain tagUsed by
SIRIUS_PERP_OPEN_ORDER_V102, 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 u32opening limit order
SIRIUS_PERP_ORDER_V101, the same body with reduce_only = 1, no leveragereduce-only limit
SIRIUS_PERP_ORDER_CANCEL_V101, account_id[32], order_nonce u64, not_after u64perp cancel
SIRIUS_USER_PERP_OPEN_V1 + SIRIUS_USER_SLIPPAGE_V1market open, plus a required signed slippage bound when signed by a keymarket open
SIRIUS_USER_PERP_CLOSE_V1account_index u64, account_id[32], challenge_nonce[32], asset_id u32market close
SIRIUS_PERP_TRIGGER_V1 / _REVOKE_V1 / _AMEND_V1stop-loss and take-profit: set, cancel, moveprotection
SIRIUS_PERP_TWAP_V1 / _CANCEL_V1TWAP order (sign the preview's disclosure_id) and its cancelTWAP
SIRIUS_SPOT_ORDER_V1 / _CANCEL_V101, 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 u64spot limit and cancel
SIRIUS_SPOT_TRIGGER_V1spot entry, take-profit and stop-loss (not available yet)spot triggers
SIRIUS_OWNER_READ_V1account_index u64, state_version u64, nonce[32]every signed read, and the stream
SIRIUS_API_KEY_REGISTER_V2 / SIRIUS_API_KEY_REVOKE_V1register 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.

RoutePermWhat it does
GET configMarkets, minimums, fee schedule, limits. Public.
GET account/{index}/state/challengeSingle-use read challenge, valid 120 s.
POST perp/order, perp/order/cancelTResting limit order (gtc or post-only) and its cancel.
GET perp/order/{cloid}One resting order.
POST perp/open, perp/closeTMarket 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/amendTStop-loss and take-profit: set, set both, cancel, move.
POST perp/twap, perp/twap/cancel; GET perp/twap/preview, perp/twap/{ref}TTWAP order, preview and status.
POST spot/order, spot/order/cancelTSpot 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/amendTSpot entry, take-profit and stop-loss. Not available yet
POST perp/positions, perp/orders/{index}, perp/protections/{index}, spot/orders/{index}, spot/triggers/{index}RSigned reads of positions, orders and protections.
POST account/{index}/state, account/{index}/tradesRBalances; fills and funding history, paginated.
POST apikeys/list/{index}, apikeys/revokeRYour keys, caps and usage; a key revokes itself.
GET apikeys/stream/{index}RServer-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.

typescript
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
python
# 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.

CodeStatusMeaning
KEY_NOT_REGISTERED401Unknown key, or a key for the other network.
KEY_REVOKED / KEY_EXPIRED401Permanent: make a new key.
ACCOUNT_NOT_ENABLED401API keys are not switched on for this account yet.
IP_NOT_ALLOWED401Client IP is not on the key's allowlist.
BAD_SIGNATURE400/401The signature does not verify over these bytes.
CHALLENGE_INVALID401Read challenge unknown, used or expired: fetch a fresh one.
RATE_LIMITED429Over the key's per-minute budget. Retry after Retry-After.
ORDER_CAP_EXCEEDED / DAY_CAP_EXCEEDED400/429Over the per-order or rolling 24 hour cap.
DUPLICATE_ORDER400This order nonce already rests, was cancelled or filled.
SLIPPAGE_REQUIRED / SLIPPAGE_EXCEEDED400/422Market open bound missing or breached.
LEVERAGE_TOO_HIGH / MIXED_LEVERAGE400Above the market's maximum, or conflicts with the open position.
INSUFFICIENT_BALANCE400/422Not enough spendable balance.
VENUE_UNAVAILABLE / SERVICE_UNAVAILABLE400/503Retryable.
LimitValue
Requests per keyA per-minute budget per key; read yours from apikeys/list
Per-order and rolling 24 hour notionalThe lower of the network cap and the key's own cap
Keys10 active per account, 365 days at most
Streams2 per key, 6 hours each, then reconnect
Read challenges64 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 is shares * r_global / 1e9 / 1e6. Swaps move between SSD and a listed asset. Each listed asset has its own asset_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/:hex looks up the index for an account_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.

typescript
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.

GET /api/account/by-id/:hex
# 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.

GET state/challenge → POST state
# 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.

  1. Resolve indices GET /api/account/by-id/:hex for both sender and recipient.
  2. Read the sender nonce From Read a balance.
  3. Sign The sender's key signs the Transfer canonical bytes (Signing).
  4. Submit POST /api/tx/transfer with the auth object.
  5. Confirm Poll the receipt (Confirming).
POST /api/tx/transfer
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..." }
Transfers are private, not unlinkable

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.

  1. Get a quote GET /api/quote?asset=sbtc&side=buy, where asset is the wire symbol (sbtc, seth, ssol) or the numeric asset_id, and side is buy or sell. It returns price_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.
  2. Build the Trade Set oracle_price_fp to the quote's price_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.
  3. Sign The user's key signs the Trade canonical bytes (Signing).
  4. Submit POST /api/tx/trade with the auth object.
  5. Confirm Poll the receipt (Confirming).
GET /api/quote → POST /api/tx/trade
# 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.

POST /api/tx/withdraw
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

ActionFeeMinimum
First deposit$2, once$12
Later depositsNone$12
Transfers between accountsNone
Withdraw to Solana0.20%, deducted from the payout$2
TradeSee 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.

Not on the public doors yet

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

  1. Agent requests a resource Your server answers 402 with payment requirements naming the Sirius scheme.
  2. Agent pays It signs a Sirius transfer and puts the payload in the X-PAYMENT header.
  3. Facilitator verifies It checks the signature, the nonce and the balance before anything settles.
  4. Facilitator settles The transfer goes through on Sirius. It is then proven and verified on Solana.
  5. 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.

EndpointWhat it does
GET /api/x402/supportedScheme and network discovery. Public.
POST /api/x402/verifyCheck a payment without settling it.
POST /api/x402/settleVerify, then settle as a Sirius transfer.
GET /api/x402/supported
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.

agent · paying for a resource
// 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.

server · selling to agents
// 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.

POST /api/x402/settle
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 least maxAmountRequired.

  • 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.

Treat settle, not verify, as proof of payment

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.

POST /api/agent/create
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_usd covers 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.

POST /api/agent/:index/activity
# 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": ... }

POST /api/agent/list/:parent_index/activity returns the same record for every agent under a parent.

What the limits cover

Limits apply on every route where the agent's own key moves value.

RouteWhat counts against the limit
POST /api/tx/transferthe amount transferred
POST /api/tx/withdrawthe amount withdrawn. A withdrawal to Solana uses the same daily budget as a payment.
POST /api/tx/tradethe USD notional of the leg being sold. Both the asset sold and the asset bought must be in allowed_assets.
POST /api/perp/openthe margin posted, not the leveraged notional
POST /api/x402/settlethe 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.

Limits are not part of the proof

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.

the auth object
{
  "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:

canonical bytes
"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

field layouts
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:

typescript
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:

typescript
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.

GET /api/tx/:hash/receipt
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.

Sirius Protocol · Integration Docs siriusprotocol.xyz Contact: billy@siriusprotocol.xyz