Sirius Integration Docs
Sirius Protocol · Integration Guide

Give your users private balances and private swaps, over one HTTP API.

Sirius makes transactions private and secure over verified inventory: private, USDC-backed balances that the people who use your app can hold, send, and swap for synthetic assets. It is settlement-rail agnostic and currently settles and collateralizes on Solana. With one signed HTTP API you get all of it. You do not build a chain, a prover, or a key stack. You call an endpoint.

Who authorizes what

The thing to get straight up front is where authorization lives. Each of your users has their own Ed25519 keypair, and that key is the real authorization: a spend is valid only if it carries the account owner's signature over that exact transaction, and Sirius rejects a spend signed by anyone else. You never hold or send a user's secret key. Your integration submits the user-signed transaction to Sirius, which sequences it.

Current deployment note: the API also expects an admin bearer token on requests from your server. That is a deployment-level gate, not the spend authorization, and the integration model is being finalized. The examples below include it; treat it as current-deployment-only.

Privacy is a mainnet property, where a balance and nonce are readable only by the account's owner. The public devnet demo runs in the clear so you can inspect state while integrating, so do not make privacy claims to your users against the demo.

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

On the current deployment your server also sends the admin bearer token on every call (see the note above). 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.

bash
export SIRIUS_API_BASE="https://demo.siriusprotocol.xyz/txapi"
export SIRIUS_TOKEN="<your admin bearer token>"

# Health check
curl -s "$SIRIUS_API_BASE/api/status" \
  -H "Authorization: Bearer $SIRIUS_TOKEN"

2. Create a user account

An account is just an Ed25519 keypair. The public key is the account_id. It has to be a canonical BN254 field element, which a random key is about 7 times in 8, so regenerate until it passes. Store the secret key for your user. It is the only thing that can authorize their spends.

typescript
import { getPublicKeyAsync, utils } from "@noble/ed25519";

// BN254 scalar field modulus.
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 is a canonical BN254 field element.
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. On the demo you can credit one directly (see Fund a user); on mainnet a deposit of USDC on Solana is credited automatically.

bash
# 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.

bash
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).

bash
curl -s "$SIRIUS_API_BASE/api/tx/<tx_hash>/receipt"
# status progresses: included -> proved -> finalized

The account model

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). The matching secret key is the only thing that can authorize that account's spends. The id must be a canonical BN254 field element, which is why you regenerate until it passes the check in the Quickstart. Its owner never shares the secret with you or with Sirius.
  • Balances are USDC-backed shares. A balance is a share count; USD value is shares * r_global / 1e9 / 1e6. A deposit credits the native asset sSSD (asset_id 0). Swaps move between sSSD and the synthetics sBTC (1), sETH (2), and sSOL (3).
  • account_index is assigned on first deposit. Most endpoints take this numeric index; GET /api/account/by-id/:hex resolves an account_id to 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. On mainnet, funding is a real USDC deposit on Solana: the user (or you on their behalf) deposits to the SSD-mint program, a watcher sees the event, and the account is credited automatically as sSSD. On the demo, a direct credit endpoint is available so you can fund test accounts without touching Solana.

bash · demo direct credit
# Demo only. On mainnet this comes from 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"

The direct-credit route is demo-only. Confirm the network with your operator before you rely on it.

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.

bash
curl -s "$SIRIUS_API_BASE/api/account/7" \
  -H "Authorization: Bearer $SIRIUS_TOKEN"

# -> {
#      "index": 7,
#      "balance_shares": "5000000000",
#      "nonce": 3
#    }

On the public demo the balance and nonce come back in the clear. On mainnet balances are private, so the plaintext balance and spendNonce come back only through the owner-read flow, which the account's key signs a challenge to unlock. Either way, 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: on mainnet the balance and nonce are readable only by the owner.

  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).
bash
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 }
Transfers are not unlinkable

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 swap between their USDC-backed balance and a synthetic asset. A swap is an oracle-priced conversion between the native asset sSSD (asset_id 0) and one synthetic (sBTC=1, sETH=2, sSOL=3). Every trade pairs sSSD with exactly one synthetic, so synthetic-to-synthetic is not supported.

  1. Get a quote GET /api/quote?asset=sbtc&side=buy, where asset is the synthetic (sbtc, seth, ssol) 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. The quote is fail-closed, so a stale or missing oracle returns an error, not a price.
  2. Build the Trade Set oracle_price_fp to the quote's price_fp. An off-side price is rejected. Per-asset bounds apply (sBTC $1k to $1M, sETH $100 to $100k, sSOL $1 to $10k) and a minimum trade size of 200 base units.
  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).
bash
# 1. Quote a buy of 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 & exit

Use this to send a user's balance back out to Solana. Withdraw is the everyday path; two backstops exist for exiting under unusual conditions.

  • Withdraw. POST /api/tx/withdraw burns shares and queues a Solana withdrawal to l1_destination. The user's key signs it, exactly like a transfer. This is the normal way out.
  • Claim-exit. POST /api/tx/claim-exit builds an exit proof and submits the on-chain claim for an exit already committed on-chain. The claim is authorized by the user's key over a distinct domain (SIRIUS_CLAIM_EXIT_V1).
  • Force-withdraw. the Solana-anchored, censorship-resistant exit path, so a user can still exit even if their spends stop being sequenced.
bash
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>"
        }
      }'

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.

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

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

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, dependency-light encoder using @noble/ed25519:

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

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 = sSSD)
  u32le(1),                       // to_asset   (1 = sBTC)
  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 epoch Merkle inclusion proof and, once anchored, the Solana signature.

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

Errors

Branch on the HTTP status first, then read the message as human-readable detail. The exact error keys are not a stable contract during the beta.

StatusWhat it meansWhat to do
400Bad request: non-canonical account id, malformed body, or a bad field.Fix the payload. For a non-canonical id, regenerate the key.
401Auth failed: missing or invalid bearer token, or a signature that does not verify or does not match the account.Check the token, and re-sign over the exact canonical bytes with the right key.
409Nonce mismatch (InvalidNonce): the signed nonce is stale.Re-read the nonce (Read a balance) and re-sign.
422Rejected by a rule: off-side or out-of-bounds trade price, below the 200-unit minimum, or insufficient balance.Re-quote and rebuild the trade, or lower the amount.
429Rate limited (default 100 req/s per client IP).Back off and retry.
json · rejection example
{ "success": false, "error": "InvalidNonce", "message": "expected nonce 4, got 3" }

Get access

Sirius gives you private balances and oracle-priced swaps without building a chain, a prover, a key stack, or an oracle pipeline. To integrate:

  1. Request an admin bearer token for your server-side integration.
  2. If you call Sirius from your users' browsers, put a same-origin proxy in front so the token stays server-side.
  3. Generate canonical BN254-valid account keys for your users and implement the canonical signing bytes.
  4. The beta is invite-gated, so coordinate with us for an invite code and token.
Sirius Protocol · Integration Guide Privacy is a mainnet property Contact: billy@siriusprotocol.xyz