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.
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.
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.
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.
# 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
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/: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. 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.
# 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"
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
# }
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.
- 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 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.
-
Get a quote
GET /api/quote?asset=sbtc&side=buy, whereassetis the synthetic (sbtc,seth,ssol) 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. 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. - 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 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/withdrawburns shares and queues a Solana withdrawal tol1_destination. The user's key signs it, exactly like a transfer. This is the normal way out. - Claim-exit.
POST /api/tx/claim-exitbuilds 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.
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.
{
"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 = 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.
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.
| Status | What it means | What to do |
|---|---|---|
400 | Bad request: non-canonical account id, malformed body, or a bad field. | Fix the payload. For a non-canonical id, regenerate the key. |
401 | Auth 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. |
409 | Nonce mismatch (InvalidNonce): the signed nonce is stale. | Re-read the nonce (Read a balance) and re-sign. |
422 | Rejected 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. |
429 | Rate limited (default 100 req/s per client IP). | Back off and retry. |
{ "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:
- Request an admin bearer token for your server-side integration.
- If you call Sirius from your users' browsers, put a same-origin proxy in front so the token stays server-side.
- Generate canonical BN254-valid account keys for your users and implement the canonical signing bytes.
- The beta is invite-gated, so coordinate with us for an invite code and token.