@siriusprotocol/x402
v0.3.2
Published
x402 payments settled privately on Sirius — siriusFetch (agent side) and siriusPaywall (resource-server side)
Maintainers
Readme
@siriusprotocol/x402
x402 payments settled privately on the Sirius rail.
x402 (Linux Foundation) revives HTTP 402 Payment Required: a
resource server answers an unpaid request with payment requirements, the client
pays, and retries with the payment in an X-PAYMENT header. x402 specifies the
handshake only — where value actually moves is delegated to a facilitator.
This package is both halves of that handshake against the Sirius facilitator
(crates/services-api/src/routes/x402.rs):
| entry point | side | what it does |
| --- | --- | --- |
| siriusFetch(url, opts) | agent | fetch, but pays a 402 and retries |
| siriusPaywall(config) | resource server | 402s, settles, releases |
The scheme is sirius-private, and its payload is an ordinary signed Sirius
L2 transfer — same fields, same canonical signing bytes as
POST /api/tx/transfer. The amount and balances stay encrypted; the epoch is
proven and verified natively on Solana.
npm install @siriusprotocol/x402@^0.2.0Pin
^0.2.0.0.1.0is deprecated as of 2026-08-03. In0.1.0a settlement carried only the facilitator'ssuccessboolean, andfalsecovers both a permanent refusal and a settle timeout. Only one of those means the money is safe, so a caller that readfalseas "failed" could retry and pay twice.settlementOutcome()in0.2.0recovers the third state and is biased toward"unknown".0.1.0also lacks the owner-authenticated nonce read, sosiriusFetchcould not pay at all against a rail run the correct way, withSIRIUS_PUBLIC_ACCOUNT_READoff.
Agent side — siriusFetch
Drop-in for fetch. A response that is not 402 is returned untouched; a 402
is paid once and the request replayed.
import { siriusFetch, readSettlement } from "@siriusprotocol/x402";
const res = await siriusFetch("https://api.example.com/v1/inference", {
account: {
privateKey: process.env.AGENT_SECRET_KEY!, // 32-byte Ed25519 seed, hex or bytes
// index / accountId are optional — resolved from the API when omitted
},
apiBase: process.env.SIRIUS_API_BASE!, // e.g. https://api.siriusprotocol.xyz
maxAmount: 50_000n, // refuse anything dearer, sign nothing
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt: "hello" }),
});
const data = await res.json();
const receipt = readSettlement(res); // { success, transaction, network, payer }What it does on a 402:
- reads the payment requirements from the body (
{ accepts: [...] }, or a bare requirements object), - picks the first
sirius-privateoffer (override withselect/network), - resolves the payer's tree index + nonce and the recipient's tree index from
the Sirius API (skipped entirely if you pass
index,nonce,payToIndex), - signs a Sirius transfer with the payer's key,
- replays the request with
X-PAYMENT: base64(JSON(payload)).
Notes:
- One payment per call. If the retry is also a 402, that response is returned rather than paid again.
- The request is sent TWICE — once to draw the 402, once carrying the
payment — so a paid endpoint cannot take a streaming body. Use a string,
Uint8Array,URLSearchParams,BloborFormData. AReadableStreamor async iterable is rejected before the first request, so this fails as a plain error rather than as a signed payment against a body the server never received. Note the unpaid first request does reach the server. - Nonces resolve automatically, including on a private rail. Three sources,
in order: the
nonceyou pass; the public account read; and — when the operator has (correctly) leftSIRIUS_PUBLIC_ACCOUNT_READoff — the owner-authenticated confidential read, which proves ownership of the index with the same key that signs the payment and reveals nothing to anyone who cannot already sign for it. That challenge is domain-separated (SIRIUS_OWNER_READ_V1) from the tx-signing tag, so the signature can never be replayed as a spend authorisation. PassownerRead: falseto opt out, ornonceto keep your own stream authoritative across concurrent payments. - Refusals are
X402Errorwith acode:malformed-402,no-acceptable-offer,over-max-amount,missing-nonce.
Resource-server side — siriusPaywall
import express from "express";
import { siriusPaywall } from "@siriusprotocol/x402";
// server-only import, no crypto in the bundle:
// import { siriusPaywall } from "@siriusprotocol/x402/paywall";
const paywall = siriusPaywall({
price: "$0.02", // or base units: "20000" / 20000n
payTo: process.env.SIRIUS_ACCOUNT_ID!, // your account_id (32-byte hex)
facilitator: process.env.SIRIUS_API_BASE!,
network: "solana-devnet", // "solana" on mainnet
asset: "0",
});
const app = express();
app.get("/v1/inference", paywall.middleware, (req, res) => {
res.json({ answer: 42 }); // only runs once the payment has settled
});Unpaid request → 402 with the requirements:
{
"x402Version": 1,
"accepts": [{
"scheme": "sirius-private",
"network": "solana-devnet",
"maxAmountRequired": "20000",
"payTo": "<your account_id hex>",
"asset": "0",
"resource": "https://api.example.com/v1/inference"
}]
}Request carrying X-PAYMENT → the payload is decoded, POST /api/x402/settle
is called, and on success next() runs with the receipt on
X-PAYMENT-RESPONSE. On failure the response is 402 again with the
facilitator's reason in error.
The requirements sent to the facilitator are always the server's own — never the client's copy — so a client cannot pay itself, or pay less, and have the receipt accepted.
Framework-agnostic core
middleware is a thin binding over handle, which is just data in, data out:
const result = await paywall.handle({
paymentHeader: request.headers.get("x-payment"),
resource: request.url,
});
if (result.kind === "payment-required") {
return new Response(JSON.stringify(result.body), { status: 402, headers: result.headers });
}
// result.settlement — the receipt; result.headers — X-PAYMENT-RESPONSE
return new Response(body, { headers: result.headers });Pricing
maxAmountRequired is a u128 decimal string in base units of the paying
asset (Sirius shares, asset 0).
| price | meaning |
| --- | --- |
| "20000", 20000n, { baseUnits: "20000" } | base units, used verbatim |
| "$0.02", { usd: "0.02" } | US dollars, converted at the live share price |
The USD form reads r_global_raw from GET /api/solana/epoch and computes
ceil(micro_usd * 1e9 / r_global_raw) — rounding up, so the server never
under-charges. The rate is cached for 30s (rateTtlMs). Pass usdToBaseUnits
to take the conversion over entirely. A bare "0.02" is rejected as ambiguous.
Settle, not verify, is proof of payment
POST /api/x402/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. The paywall therefore settles and
releases on the settle response. paywall.facilitator.verify(...) is exposed for
pre-flight probes.
settlement.success is a boolean over THREE outcomes — use settlementOutcome
Since 2026-08-03 POST /api/x402/settle waits for the L2 transfer to apply
before answering, so success: true means the payer was really debited. (It
used to answer on mempool admission, which the sequencer can still refuse
permanently — the same misreading that released $45 of USDC out of
/api/withdraw/onchain.)
But success: false still covers two very different things:
| outcome | success | what it means |
| --- | --- | --- |
| applied | true | the payer WAS debited. Terminal. |
| dropped | false | permanently refused at apply; nothing was debited. Terminal. |
| timed out | false | UNKNOWN. It may still apply. NOT terminal, and NOT a failure. |
Reading a timeout as a failure is the expensive direction — the natural response to a failed payment is to send it again. So do not branch on the boolean:
import { readSettlement, settlementOutcome } from "@siriusprotocol/x402";
const receipt = readSettlement(res);
if (receipt) {
switch (settlementOutcome(receipt)) {
case "applied": break; // debited
case "dropped": break; // nothing moved; safe to retry
case "unknown": break; // DO NOT retry — reconcile instead
}
}settlementOutcome is biased toward "unknown": only a reason that positively
identifies a permanent refusal is called terminal. Resolve an "unknown" with
GET /api/tx/:hash/receipt at settlement.transaction, which the facilitator
sets in all three cases for exactly that purpose.
Proving and Solana verification follow asynchronously after application.
Operator flags this SDK is affected by
| flag | effect |
| --- | --- |
| SIRIUS_PERMISSIONLESS_TX=1 | verify/settle are open; otherwise pass facilitatorToken |
| SIRIUS_PUBLIC_ACCOUNT_READ=1 | nonces are readable directly. Leave it OFF (it exposes every account's balance sheet); siriusFetch then falls back to the owner-authenticated read |
| SIRIUS_REAL_PRIVACY=1 | mounts the owner-authenticated read, which is what makes the nonce fallback available |
| SIRIUS_REQUIRE_ACCOUNT_AUTH=1 | account reads need a bearer — pass apiToken |
| SIRIUS_SOLANA_CLUSTER | decides the network label (solana-devnet / solana) and the Solana cluster every signature is bound to |
| SIRIUS_CHAIN_ID | names a chain outside the solana namespace (e.g. eip155:56). Outranks SIRIUS_SOLANA_CLUSTER |
Signing
The Ed25519 public key is the account id. The signature covers
"SIRIUS_L2_V1" || 0x1F || <chain id, ASCII> || 0x01 ||
from_index:u64 || to_index:u64 || from_account_id:32 || to_account_id:32 ||
amount:u128 || nonce:u64, all integers little-endian —
a byte-exact port of canonical_signing_bytes in
crates/services-api/src/tx_signing.rs, pinned in test/canonical.test.ts
against a vector captured from the Rust function. The network you pay on is now inside the signed bytes, as a
"<namespace>:<reference>" chain id — solana:<genesis hash> for Solana,
eip155:<chain id> for an EVM chain. Before 2026-08-06 network rode entirely
outside the signature and was only string-compared by the facilitator, so a
payment signed against a devnet facilitator was byte-identical to one for
mainnet; until 2026-08-07 the component was a bare Solana genesis hash, which
separates Solana's clusters and not chains. Every signature minted under either
older format is now rejected. siriusFetch takes the chain from the 402 offer's
network, so callers need do nothing; a caller building payloads by hand passes
{ cluster } or { chainId } to authFor.
It is the same encoder
@siriusprotocol/sdk ships, narrowed to the Transfer variant so this package stays free
of @solana/web3.js; if you already hold a @siriusprotocol/sdk account you can pay
over x402 with no new signing code.
Packaging
ESM + CJS, TypeScript strict, no any in the public surface. The only runtime
dependencies are @noble/ed25519 and @noble/hashes, both of which the paywall
half avoids — import @siriusprotocol/x402/paywall on a server that never signs.
@noble/ed25519 v2 is ESM-only, so the CJS build relies on Node's require(esm)
support: Node >= 20.19 (or >= 22.12). Pure-ESM consumers have no such floor.
npm install
npm run build # dist/esm + dist/cjs
npm test # vitest
npx tsc --noEmit # typecheck, incl. testsMIT.
