x402 agent payments for Animica
The complete x402 agent-payments reference for Animica: architecture, threat model, settlement lifecycle, the product catalog, the 402 on the wire, client examples, configuration, deployment and troubleshooting.
52 min read 11,333 words
View docs/x402.md on GitHub
Source: docs/x402.md — this page mirrors the repository documentation.
Status (2026-08-15): implemented in apps/x402-gateway/ — paid product
gateway + SELF-HOSTED Base-USDC facilitator (X402_FACILITATOR_MODE=self,
no third-party settlement dependency, no Coinbase services anywhere). NOT
yet deployed: the live animica-x402.service still runs the dev entry, and
the systemd/nginx files ship in the repo as examples
(apps/x402-gateway/{systemd,nginx}/) for a separate human-approved
runbook step. Everything is hard-gated behind ANM_X402_ENABLED=1. The
scaffold’s wANM/Solana lane is retired (kept in-tree, never configured).
x402 (github.com/x402-foundation/x402, spec v2 in force since 2025-12-09,
Apache-2.0) is the emerging default for agentic HTTP clients paying
per-call: the server answers 402 with machine-readable payment
requirements, the client signs a payment locally and retries with a
header, a facilitator verifies and settles on-chain, the server delivers
the resource with a receipt header. Animica sells randomness, bulk chain
data and (later) priority inference this way — per call, no accounts, no
API keys, and the payer never spends gas (EIP-3009 is gasless for them).
Architecture
agent ──402 / PAYMENT-SIGNATURE──► GATEWAY src/server.js (127.0.0.1:8742)
│ discovery /x402 + /.well-known/x402
│ paywall: validate → availability →
│ 402 → tamper-proof match → idempotency
│ → verify → [readiness] → settle → execute
│ products: echo(dev) · qrng · bulk_chain ·
│ chain_address_history ·
│ chain_batch_balances ·
│ priority_inference (gated OFF)
├──x402 v2 §7 /verify /settle──► FACILITATOR
│ mode=self: src/facilitator-evm/
│ (127.0.0.1:8743, Base USDC,
│ EIP-3009, persistent ledger)
│ mode=remote: any §7 URL (e.g. PayAI)
└──JSON-RPC──► local Animica node
(127.0.0.1:8545/rpc only)
▲
src/chain-index/ walker ──────────┘ head-following address index
(own sqlite file; polite 100-block batches through the SAME
single-flight node client; started only by the gateway process)
Two separable layers. The gateway never touches chain state — it talks
the spec-§7 facilitator contract (/verify, /settle, /supported) and
is byte-identical in self and remote modes. The facilitator owns
verification, settlement, gas policy, and the persistent replay ledger.
Threat model
What the system defends against, and with which mechanism (paths in
apps/x402-gateway/; every item has tests in test/):
| threat | defense |
|---|---|
| Replay / double-spend of a payment authorization | three independent layers: USDC’s own on-chain nonce ledger (authorizationState checked at verify AND enforced by the contract at settle), the facilitator’s persistent UNIQUE authorization_hash ledger (survives restarts), and eth_estimateGas simulation which reverts on a consumed nonce before any broadcast |
| Concurrent duplicate settles of one authorization | the DB claim is one atomic INSERT (better-sqlite3 is synchronous — no check-then-act window); exactly one concurrent settle wins, the loser gets a replay rejection or, if the winner already settled, the stored settled answer (idempotent, no second transfer) |
| Unknown-outcome settlements (crash / RPC timeout mid-broadcast) | the signed raw tx is persisted BEFORE broadcast; unknown outcomes stay submitting and are never blindly re-sent; startup recovery resolves them from chain truth (authorizationState → find tx via stored hash or AuthorizationUsed logs → settle; vanished + unexpired → rebroadcast the SAME signed bytes; expired → expired) |
| Amount / token / network / recipient substitution | the client must echo terms the gateway itself offered, compared canonically (requirementsEqual) — client input can never define price or payTo; the facilitator then independently re-checks asset, payTo, chain and exact amount against its OWN allowlisted config (defense in depth against a compromised gateway); payTo comes from server config only |
| Taking payment for an unavailable service | availability hooks answer 503 WITHOUT emitting a 402 (the readiness probe for qrng is a real health-gated randomness fetch; for inference it is the live worker-capacity gate); settle-first products re-check readiness immediately before settlement |
| Gas drain against the facilitator wallet | per-settlement gas cap (est > X402_MAX_GAS_PER_SETTLEMENT ⇒ reject), fee-per-gas cap (never underbid below base fee either — that would strand a nonce), optional trailing-24h budget breaker that refuses BEFORE claiming, and eth_sendRawTransaction is never retried by the RPC layer |
| Malformed payloads | strict structural validation with the spec’s exact error codes; 64 KB facilitator body cap, per-product gateway body caps; unknown fields in money positions reject (^\d+$ atomic strings, 32-byte nonce regex, 65-byte signature, low-s enforced) |
| Idempotency abuse / double charge | (Idempotency-Key, payment fingerprint) PK; only delivered outcomes are stored; a replay answers from the store with zero facilitator round-trips; a different payment under the same key misses the store and runs (and its fresh nonce settles separately — keys never unlock someone else’s result) |
| Secret leakage | the private key exists only in the 0600 env file and inside key.js closures (never a property, guarded toJSON/inspect); structured logs pass through a name-based redactor (`private |
| Refund/reconciliation edges (paid but not served, served but settle unknown) | signed HMAC error receipts referencing the settled payment + an incidents ledger (`open → refunded |
Out of scope / accepted risks: Base sequencer reorgs (settled = receipt +
log match + X402_CONFIRMATIONS, default 2 ≈ 4 s — Base reorgs are
practically nonexistent; a safe/finalized-tag mode is a documented
future option for higher-value products), and a malicious payer’s USDC
being frozen by Circle after settlement (ordinary stablecoin risk).
Base / USDC setup — live-verified facts
All values below were verified against the live chains on 2026-08-15
(eth_call on mainnet.base.org / sepolia.base.org) and are encoded in
the allowlist in src/config.js (EVM_NETWORKS); nothing else can be
configured — a non-allowlisted network/token refuses to start.
Base mainnet (base) | Base Sepolia (base-sepolia) | |
|---|---|---|
| CAIP-2 / chain id | eip155:8453 / 8453 | eip155:84532 / 84532 |
| USDC contract | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | 0x036CbD53842c5426634e7929541eC2318f3dCF7e |
| EIP-712 domain | {name: "USD Coin", version: "2"} | {name: "USDC", version: "2"} |
| decimals | 6 ($0.01 = 10000) | 6 |
| explorer | basescan.org | sepolia.basescan.org |
The mainnet domain name is "USD Coin", not "USDC" — the x402 spec’s
examples show "USDC" because they use Sepolia; using it on mainnet makes
every signature fail. The facilitator eliminates this whole misconfig
class at startup: it computes the domain separator locally from config AND
reads DOMAIN_SEPARATOR() from the live contract — any mismatch (wrong
token, wrong chain, wrong name/version) refuses to serve, and /readyz
re-checks it continuously.
Settlement lifecycle
State machine of one payment in the facilitator ledger
(state/x402.db, payments.status):
POST /settle
│
full re-verify (structure, config, sig recovery, chain-time window,
balance, on-chain nonce unused) ──reject──► spec error code, no row
│
gas budget breaker ──open──► refused BEFORE claiming (authorization
│ stays fully usable elsewhere)
▼
ATOMIC CLAIM INSERT … UNIQUE(authorization_hash)
│ └─ duplicate ──► replay rejected / already-settled row ⇒
│ idempotent success with the stored tx (no 2nd transfer)
▼
[pending] ── fees + eth_estimateGas (THE simulation: consumed nonce,
│ bad sig, paused token, blacklist all revert here)
│ └─reject──► [failed] (raw_tx NULL ⇒ the same
│ authorization may be re-claimed ONCE later)
▼
sign EIP-1559 tx → persist raw_tx + hash + account-nonce ◄─ crash after
▼ this point is
[submitting] ── broadcast eth_sendRawTransaction recoverable
│ ├─ node rejects outright ──► [failed] (raw_tx kept ⇒ NEVER
│ │ reclaimable — safe beats convenient)
│ ├─ transport unknown ──► keep polling; outcome owned by recovery
▼
receipt: status 0x1 AND AuthorizationUsed(payer, nonce) log
AND Transfer(payer→payTo, exact value) log
AND ≥ X402_CONFIRMATIONS blocks ──► [settled]
│ ├─ status 0x0 ──► [failed] (gas still accounted)
│ └─ no receipt / no confirmations in budget ──► stays [submitting]
│
(restart) recoverInFlight() runs BEFORE the listener opens:
├─ authorizationState(payer, nonce) consumed ──► find tx (stored hash,
│ else AuthorizationUsed logs) ──► [settled]
├─ unconsumed + tx vanished + unexpired ──► rebroadcast the SAME
│ signed bytes (same nonce — never a new tx)
├─ unconsumed + authorization expired ──► [expired]
└─ tx still in mempool ──► stays [submitting] for the next pass
“A tx merely submitted to RPC is NOT settled”: only the full
receipt+logs+confirmations chain of evidence flips a row to settled, and
only settled rows count as revenue (x402_revenue_usdc, revenue CLI).
The gateway sequences around this per product:
- execute-then-settle (qrng, echo — cheap reads): produce the resource first (a failure charges nobody), settle, deliver the already-obtained result. Failed settlement withholds delivery: payer keeps the money, we eat the compute.
- settle-then-execute (bulk export, inference): mandatory readiness re-check → settle → execute with one bounded retry → on persistent failure a signed error receipt + incident row (below). Never silently keep money.
Replay behavior (what a client sees)
- Reusing a settled authorization: gateway verify fails
(
invalid_transaction_state— chain nonce consumed + ledger row) →402.x402_replays_rejected_totalincrements. This holds across facilitator restarts (the ledger is on disk) and even across a facilitator swap (the chain’sauthorizationStateis the ultimate arbiter). - Two simultaneous submissions of one authorization: the DB serializes them; exactly one settles, the other gets the replay answer. At most one on-chain transfer can ever exist per authorization (EIP-3009 nonce).
- A failed attempt that never signed a tx (gas rejection, crash before submission): the authorization was never touched on-chain; the row is reclaimable exactly once, so an honest retry works.
- Same payment +
Idempotency-Keyafter a delivered outcome: the gateway replays the stored response (idempotent-replay: true, original settlement header) with no facilitator round-trip — never a second charge. - Every payment is bound to a
resourceid; per-product audit comes from the ledger’sresourcecolumn (revenueCLI groups by it).
Product catalog
Free, unpaid discovery: GET /x402 and GET /.well-known/x402 →
{x402Version, name, network, asset, scheme, products: [{id, path, price, price_atomic, currency, description, available, unavailable_reason?, endpoints, free_endpoints?, mimeType, outputSchema}], updated_at} —
available is LIVE from each product’s readiness hook, and Bazaar-style
extensions.bazaar.info.{input,output} metadata rides in every 402 offer.
free_endpoints advertises the parts of a product that cost nothing (today:
the commit-reveal disclosure) so an indexer never lists them as paid.
qrng — signed random bytes, $0.01
GET /x402/qrng/draw?bytes=32 (1..1024; alias n) or POST /x402/qrng
with the same parameter as a JSON body ({"bytes":256}) — the body is
read, not ignored; a query parameter and a body field that disagree are a
400 conflicting_params, and an unknown body field is a 400 rather than a
silently discarded parameter.
Wraps the node’s real rand.quantumRandomBytes — nothing reimplemented,
no fields invented. Response: randomness (hex), bytes, source,
health {passed, min_entropy_per_byte}, attestation {alg, public_key_hex, digest_hex, signature_hex, attested}, verification
(the rules below), payment (settlement metadata). The gateway enforces
health.passed itself — the node RPC answers 200 even for a sick source —
and an unhealthy source is a 503 with no payment requested.
Verification example (validated against a live node response; the
sha3_256 comes from the repo’s dependency-free
randomness/beacon_api/static/verify.js):
const crypto = require('node:crypto');
const { sha3_256 } = require('./randomness/beacon_api/static/verify.js'); // animica repo
function verifyQrngDraw(res) {
// rule 1: attestation.digest_hex == sha3_256(randomness bytes)
const digest = Buffer.from(sha3_256(Buffer.from(res.randomness, 'hex')));
if (digest.toString('hex') !== res.attestation.digest_hex) return false;
// rule 2: ed25519_verify(public_key_hex, message = RAW digest bytes, signature_hex)
const spki = Buffer.concat([
Buffer.from('302a300506032b6570032100', 'hex'), // ed25519 SPKI prefix
Buffer.from(res.attestation.public_key_hex, 'hex'),
]);
const key = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
return crypto.verify(null, digest, key, Buffer.from(res.attestation.signature_hex, 'hex'));
}
Trust model (stated verbatim in every response’s verification block):
this is signed by the serving node, not client-recomputable — you trust
the node’s entropy source and verify it signed exactly these bytes; check
health.passed and attestation.attested before relying on it. Honesty:
today the source is software-fallback (os.urandom) with a software
ed25519 signer, so attested: false and is_quantum: false everywhere.
The product is signed, health-gated randomness with a stated trust model
— never “quantum randomness”; those fields flip truthfully if a hardware
provider is ever connected.
You can check that before paying. The gateway’s readiness probe already
draws for free every few seconds, so the honesty triple it observed is
published unpaid in two places: the free catalog entry
(products[].entropy) and the extensions.bazaar.info.entropy block of
every 402 offer:
"entropy": { "source": "software-fallback", "vendor": "os",
"model": "os.urandom CSPRNG", "is_hardware": false,
"is_quantum": false, "attested": false,
"signer_backend": "software", "health_passed": true,
"min_entropy_per_byte": 7.8078,
"observed_at": "2026-08-15T…Z" }
The free
rand.* RPC surface stays free; $0.01 buys packaging, the health gate and
the receipt. The client-recomputable beacon+draw lane (animica beacon serve sidecar, hash-chained rounds) is the documented upgrade once that
sidecar is deployed.
The randomness family — int / shuffle / pick / bulk / commit-reveal
| product | route | price | what you get |
|---|---|---|---|
random_int | POST /x402/random/int | $0.01 | uniform integers in [min,max], ≤ 1,000 per call |
random_shuffle | POST /x402/random/shuffle | $0.02 | Fisher-Yates permutation of your list or of 1..N, ≤ 10,000 items |
random_pick | POST /x402/random/pick | $0.02 | k picks with/without replacement, optional integer weights (indices_only for large items) |
random_bulk | POST /x402/qrng/bulk | $0.05 | 6–10 INDEPENDENT draws (own attestation each) × ≤ 1,024 bytes in ONE settlement |
random_commit | POST /x402/random/commit | $0.02 | a commitment now… |
GET /x402/random/reveal/{commit_id} | free | …and a public, idempotent reveal later |
Every one of them buys exactly one rand.quantumRandomBytes draw per
request — never one node call per output item — and derives the answer from
those bytes. They share the qrng health gate: a sick entropy source refuses
the whole family with 503 and no 402, and oversized requests are
400ed before any payment is demanded.
The derivation rules (published in every response as derivation).
They are byte-for-byte the canonical Animica DRNG
(randomness/qrng/public.py, mirrored dependency-free in
randomness/beacon_api/static/verify.js), so anyone can recompute offline:
seed = sha3_256("animica/qrng/public/v1" || "|k:" || kind ||
"|r:" || request_id || "|b:" || bytes(randomness))
stream = sha3_256(seed || be8(0)) || sha3_256(seed || be8(1)) || …
consumed left to right, never reused
randbelow(n) # uniform in [0,n) — REJECTION SAMPLING
k = floor((bitlen(n)+7)/8) + 1 # bytes per attempt
limit = 2^(8k); threshold = limit - (limit mod n)
repeat: x = big-endian uint of the next k stream bytes # a rejected
until x < threshold # attempt still
return x mod n # consumes its k bytes
# n <= 1 consumes nothing and returns 0.
# Plain `x mod n` is NOT used: it biases small values whenever n does not
# divide 2^(8k), and a biased lottery is not a lottery.
shuffle(N) for i = N-1 down to 1 { j = randbelow(i+1); swap a[i], a[j] }
sample(N,k) for i = 0..k-1 { j = i + randbelow(N-i); swap a[i], a[j]; emit a[i] }
weighted(w) total = sum(w); r = randbelow(total);
answer = smallest i with r < w[0]+…+w[i]
(without replacement: remove the winner, keep the rest in
order, recompute the total, repeat)
Shuffle and pick derive over the index list [0..N-1] and return the
permutation/indices next to the applied items: the swap sequence is
identical to shuffling your items directly, the verifiable object stays
small, and you apply items[permutation[i]] yourself.
Output caps are enforced with the input caps, before settlement. Input
limits alone do not bound the answer: pick with replace: true
deliberately lifts the k ≤ n rule, so one large item and k = 1000
would be a ~1000× amplifier (a 512 KB request asking for a ~512 MB
response — for $0.02). The estimated response size is checked against
X402_RANDOM_MAX_RESPONSE_BYTES (default 4 MB) and refused with a 400 response_too_large carrying caps.estimated_response_bytes and a hint,
so nothing is charged. When your items are large, send
"indices_only": true: you get the indices (and the full derivation) and
apply items[indices[i]] yourself.
derivation.kind is part of the seed, so two products can never derive the
same numbers from the same bytes. Where a stock verify.js kind reproduces
the answer exactly, the response also carries derivation.recompute, which
plugs straight into the repo verifier:
const AnimicaBeacon = require('./randomness/beacon_api/static/verify.js');
const res = await (await fetch(url, { headers: { 'payment-signature': … } })).json();
AnimicaBeacon.verifyResult(res.derivation.recompute); // true
// `beacon_hex` there is verify.js's field name for the seed material — here
// it is the raw `randomness` bytes, NOT a beacon round.
Or recompute by hand from derivation.rules + derivation.steps +
randomness; derivation.stream_bytes_consumed tells you how much of the
stream the answer used.
random_bulk sells INDEPENDENT draws, not slices. Each of the draws
entries is its own rand.quantumRandomBytes call with its own health
report and its own signed digest attestation
(result.draws[i].attestation.digest_hex == sha3_256(draws[i].randomness));
there is no concatenation and no single signature pretending to cover one.
That is the only unit on which a volume claim is honest here — slices of a
single draw are something you can cut yourself out of one $0.01 qrng call,
so selling slices at $0.05 would be a premium wearing a discount’s label.
The discount is therefore enforced, not asserted. The minimum accepted
draw count is derived from the price table — floor(bulk / single) + 1, so
6 at the defaults — and anything below it is a 400 below_bulk_minimum naming the cheaper endpoint, before any payment:
{ "error": "below_bulk_minimum", "min_draws": 6,
"cheaper_alternative": { "endpoint": "GET /x402/qrng/draw",
"price_usd": "0.01", "price_atomic": "10000" } }
At 6+ draws the call is strictly cheaper than the same number of single
draws (50,000 atomic for 6 against 60,000), and the response publishes the
whole comparison: price_atomic_per_draw,
equivalent_single_draw_cost_atomic, savings_atomic,
min_draws_for_discount. It also states plainly which product is cheaper
per byte — usually the single draw, because you are paying for
independent attestations, not for volume of bytes. If the configured prices
make a discount impossible at any draw count, the product reports
available:false with random_bulk_price_not_a_discount instead of
selling one.
random_commit / reveal is the provably-fair primitive:
# 1. pay $0.02, publish the commitment to your players BEFORE the round
curl -sX POST https://animica.dev/x402/random/commit \
-H 'content-type: application/json' -H "payment-signature: $SIG" \
-d '{"memo":"round-7","reveal_after_seconds":300}'
# → {"commit_id":"rc_…","commitment":"<sha3_256(secret||salt)>",
# "reveal_after":1786762706,"algorithm":"sha3_256(secret||salt)", …}
# 2. anyone — not just the buyer — opens it afterwards, for free
curl -s https://animica.dev/x402/random/reveal/rc_…
The reveal is free, public, idempotent and never asks for payment; while a
commitment is still sealed it answers 425 Too Early with the commitment
and seconds_remaining (never the secret), and an unknown id is a plain
404 that states the retention window. Three independent checks are
published with it:
commitment == sha3_256(secret || salt)— the commit-reveal property;secret || salt== the first 64 bytes of the DRNG stream over the revealedrandomnesswithkind="commit"and yourrequest_id— proof the secret is a deterministic function of THAT disclosed, node-signed draw (so nothing was substituted at reveal time);attestation.digest_hex == sha3_256(randomness)+ the ed25519 signature — proof the node signed the draw the secret came from.
Trust model — what this does NOT prove. The draw is signed but it is
not bound to a round, a clock or a sequence number, and the node’s
randomness RPC is free and unlimited, so the three checks above do not
prove that the operator did not draw repeatedly and commit only to a draw
it preferred. What the construction gives you is: nothing can change after
the commitment is published, and the secret provably comes from a
node-signed draw. Derive your fairness from the publication ordering —
publish the commitment to your players before the round opens. If you need
pre-commit grinding to be detectable, anchor the round yourself: put a
public round id or a block hash your players can see into request_id /
memo before committing. The same paragraph ships in every commit and
reveal response as derivation.trust_model, and in the product
description.
Sealed material lives only in the gateway’s own sqlite
(random_commitments, retention X402_RANDOM_COMMIT_TTL_SECONDS, default
90 days) and never in the commit response.
Honesty, identical to qrng and enforced by tests: source, health,
attestation and verification ride on every response verbatim; today
they say software-fallback, is_quantum: false, attested: false, and
no description or example in this family implies hardware or quantum
attestation. What the price buys is the packaging, the health gate, the
signed receipt and a derivation you can recompute — not exclusivity: the
node’s rand.* RPC stays free.
bulk_chain — bulk chain data, $0.05
GET /x402/chain/blocks?from=70000&count=500 # NDJSON block export
GET /x402/chain/transactions?from=70000&to=70999&address=anim1…&format=json
GET /x402/chain/export?type=blocks|transactions&… # umbrella route
Params: from (required; also the pagination cursor), to or count
(window ≤ 1,000 blocks — enforced with a 400 BEFORE any payment),
address (tx export only; anim1… bech32m or 32-byte hex digest, matches
from/to), format=ndjson|json (or via Accept). Output: a meta line
(pinned head, window, unit: "nANM", payment metadata), one block/tx per
line, and a summary line with next_cursor when any budget truncated
the export (bytes ≤ 16 MB uncompressed, exec ≤ 25 s, ≤ 10,000 tx rows) —
feed next_cursor back as from. Truncation is always at a block
boundary; a failed fetch is the resume point, never a skipped hole.
Amounts are decimal strings in nANM (live values already exceed 2^53 —
never let JSON.parse make floats of them). Accept-Encoding: gzip is
honored (~15× on block exports). Exports pin to head − 6 so they cannot
straddle a reorg — and because they do, a window that starts above
head − X402_BULK_HEAD_MARGIN can only ever return zero rows, so it is
refused before settlement rather than sold empty:
{ "error": "window_not_yet_final", "head_height": 200, "head_margin": 6,
"max_exportable_height": 194, "retry_after_blocks": 3 }
(The check runs both in param validation and again against the freshly
pinned head immediately before settlement, so a head that moved between the
402 and the paid retry cannot slip through.) next_cursor is likewise
never lower than the from you asked for — a cursor that points backward
would make a paging agent re-buy ground it already paid for. The free per-block RPC/explorer APIs are untouched: the
paid path reads the loopback node in polite chunked single-flight batches
precisely so the shared node loop (miners, wallets) stays healthy.
chain_address_history — account transaction history, $0.05
curl -s -X POST https://animica.dev/x402/chain/address-history \
-H 'content-type: application/json' -H "payment-signature: $PAY" \
-d '{"address":"anim1…","limit":100}'
Body fields: address (required — anim1… bech32m, or a 32-byte hex
account digest), limit (1..500, default 100), cursor, order
(desc default | asc), direction (any | in | out | self),
from_height, to_height. Every cap or shape error is a 400 before the
402 — an over-cap limit comes back as
{"error":"limit_too_large","caps":{"max_limit":500}}.
Why this one is worth money. There is no account-history index anywhere
else on this box: the node RPC has no history method at all, and
explorer.animica.org/api/address/:bech32 is a live reverse block scan
capped at 250 blocks / 3.5 s per call (measured 2026-08-15: 3.54 s to
return zero txs for the chain’s most active sender, whose transactions
sit ~3,000 blocks back — full history there is ~300 paged calls). So the
gateway builds and owns one: a head-following sqlite index
(X402_CHAIN_INDEX_DB_PATH, its own file) filled by a walker that
backfills from genesis in ~5–7 minutes and then tracks the head.
Published derivation (every response carries it, so a buyer can recompute the answer from raw blocks):
| rule | definition |
|---|---|
| account digest | bech32m payload = alg_id (2 bytes) || sha3_256(pubkey) (32 bytes); the join key is payload[2:34]. Chain tx.from/tx.to are those digests. The alg id is not recoverable from a digest, so responses echo your input form and match on the digest. |
| direction | from == to → exactly ONE row, direction:"self"; otherwise the sender gets "out" and the recipient "in". A transfer appears once in each participant’s history, never twice in one. |
| ordering | (height, tx_index), tx_index = position in the block’s transactions array as chain.getBlockByHeight returns it. |
| cursor | next_cursor = "<as_of>:<height>:<tx_index>" of the LAST row returned; the next page is every row strictly after it in the chosen order, within the same as_of snapshot — no skips, no repeats while the index advances. |
| amounts | value/tip are nANM decimal strings, never JS Numbers. |
Freshness is a hard gate, not a disclaimer. A history page from an
incomplete index is not stale, it is wrong — it omits transactions the
buyer paid to see. So availability fails closed (503, no 402, nothing
charged) with a machine-readable reason and backfill progress:
chain_index_disabled, chain_index_node_unreachable,
chain_index_never_ran, chain_index_walker_stalled (no walker pass for
X402_CHAIN_INDEX_MAX_TICK_AGE_MS, default 5 min),
chain_index_backfilling (never caught up yet) or chain_index_stale
(was live, now lagging). The walker only indexes up to head − 6, so
X402_CHAIN_INDEX_MAX_LAG_BLOCKS (default 12) must exceed
X402_CHAIN_INDEX_HEAD_MARGIN — the config refuses to load otherwise.
Walker politeness is not optional: the node serializes all RPC on one event
loop shared with miner getwork and wallets. It reuses the same
single-flight node client as the bulk export (so a backfill can never run
concurrently with a paid export), fetches 100-block batches (~0.43 s each;
a 1,000-block batch would hold the loop 5.8–8.6 s), pauses between chunks,
and is started only by the gateway process — never by importing the module.
A parentHash break rewinds 64 blocks and re-indexes rather than stitching
two histories together. Operator view: animica-x402 index status.
chain_batch_balances — bulk balances, $0.02
curl -s -X POST https://animica.dev/x402/chain/balances \
-H 'content-type: application/json' -H "payment-signature: $PAY" \
-d '{"addresses":["anim1…","anim1…"]}'
Up to 500 addresses per call (measured ~5 ms/address batched → ~2.5 s),
resolved in ONE batched JSON-RPC round trip against state.getAddressBalance
on the loopback node. The cap and every address’ validity are checked
before the 402 ({"error":"too_many_addresses","caps":{…}} /
{"error":"invalid_address","index":N}). Duplicates are collapsed to one
lookup but still answered in place, and total_balance sums unique
accounts only. Balances are nANM decimal strings normalised through BigInt
(the rank-1 account already holds ~4.0e16 nANM — a float would corrupt it).
A single address the node rejects becomes an error on that entry rather
than sinking the whole settled call into the incident path. Because the
node dispatches a batch sequentially on one loop, a block can land
mid-batch: the response reports as_of.entry_head_min/max and
consistent:false instead of pretending to an atomic snapshot.
Single-address balance lookups stay free on the public API — this SKU sells the batch, and the listing says so.
priority_inference — $0.10, exists but DISABLED
POST /x402/v1/chat/completions (OpenAI-compatible; stream: true is
rejected before payment — settlement proof travels in response headers).
Two gates, both must pass, both fail closed:
PRIORITY_INFERENCE_ENABLED=1— ships as0; do not enable while serving capacity is broken.- Live capacity ≥
PRIORITY_INFERENCE_MIN_SERVING_WORKERS(default 2), measured every 15 s by pollingaicf.workerStatusfor each wallet inX402_INFERENCE_WORKER_WALLETS. A worker counts whenregistered,last_seen≤ 5 min (ms/s normalized) and servingX402_INFERENCE_TIER(defaultstandard; the synthetic"pipeline"flag is discarded). A stale (> 60 s) or failed probe counts as zero. Set the wallet list to the SAME value as the chat bridge’sBRIDGE_KNOWN_WORKER_WALLETS— the gate must never sell priority the router cannot deliver. (There is no global worker enumeration on-chain;aicf.estimateJobCost.providerscounts unpruned history and must never be used.)
Below the floor: catalog available: false, endpoint answers
503 {"error":"priority_inference_unavailable","serving_workers":N, "required":M,…}, and no 402 is ever emitted — payment is never taken
for a service known unavailable. Capacity is re-checked immediately before
settlement; a worker drop between the 402 and the paid retry refuses at
zero cost to the payer. Enablement runbook: configure the wallet list,
watch x402_inference_serving_workers hold ≥ the floor, set
PRIORITY_INFERENCE_ENABLED=1, restart the gateway, and confirm the
catalog flips available: true.
What paid honestly adds over the free animica.dev/v1: capacity-gated
admission, direct-to-bridge routing with no per-IP rate limit (free tier:
30 req/min/IP), a 180 s budget sized for community-GPU latency, and the
failure-receipt guarantee. What it does NOT add yet: reserved capacity or
exclusive models — the same workers serve both lanes, which is exactly why
the gate exists.
echo — $0.005, development only
/x402/paid/echo is the settlement smoke marker (spec: keep). Disabled
when X402_ENV=production unless X402_ENABLE_ECHO=1.
Operator sheet: the product table
Prices come from the registry (src/products/registry.js + src/config.js)
in USDC atomic units; nothing below is duplicated anywhere else in code.
“Availability rule” is the condition under which the product answers at all
— when it fails, the endpoint returns 503 and no 402 is ever emitted, so
payment is never taken for something known unavailable.
| id | route(s) | price | what it sells | availability rule | verification rule |
|---|---|---|---|---|---|
qrng | GET /x402/qrng/draw, POST /x402/qrng | $0.01 | 1–1,024 random bytes from the node’s randomness service with its signed digest attestation and entropy-health report | a live draw succeeds AND health.passed (probe every ≤5 s); the live source/attested fields are published unpaid in the catalog and the 402 | attestation.digest_hex == sha3_256(randomness) and ed25519_verify(public_key_hex, raw digest bytes, signature_hex) |
random_int | POST /x402/random/int | $0.01 | up to 1,000 uniform integers in [min,max] from ONE draw, by rejection sampling (no modulo bias) | same shared entropy probe as qrng | rebuild the DRNG from the published randomness (seed = sha3_256(domain‖kind‖request_id‖bytes)), then ints[i] = min + randbelow(max-min+1); derivation.recompute verifies in randomness/beacon_api/static/verify.js |
random_shuffle | POST /x402/random/shuffle | $0.02 | Fisher-Yates permutation of your list or of 1..N, ≤10,000 items | same shared entropy probe | for i = N-1..1 { j = randbelow(i+1); swap } over [0..N-1]; derivation.recompute verifies against verify.js (kind: shuffle) |
random_pick | POST /x402/random/pick | $0.02 | k picks with/without replacement, optional integer weights (raffles, sortition, A/B splits); indices_only for large items | same shared entropy probe; response-size estimate must be ≤ X402_RANDOM_MAX_RESPONSE_BYTES (checked pre-settlement) | partial Fisher-Yates (no replacement), randbelow(n) per pick (with replacement), or cumulative-weight search over randbelow(total); recompute for the single-draw kinds, exact steps for the multi-draw ones |
random_bulk | POST /x402/qrng/bulk | $0.05 | 6–10 independent draws (one node call + one attestation each) settled once | shared entropy probe AND a price table where the batch beats the same number of single draws (else available:false) | apply the qrng rule to EACH result.draws[i] separately; draws[i].sha3_256 == draws[i].attestation.digest_hex |
random_commit | POST /x402/random/commit | $0.02 | a published commitment sha3_256(secret‖salt) now, sealed for 0–7 days | same shared entropy probe | at reveal: commitment == sha3_256(secret‖salt), secret‖salt == first 64 DRNG bytes over the disclosed draw, plus the node signature over that draw (see the trust model — it does not prove the operator discarded no draws) |
GET /x402/random/reveal/{commit_id} | free | the public, idempotent disclosure of that commitment | route exists whenever random_commit is enabled; 425 while sealed, 404 past the 90-day retention | as above — anyone can run it, no payment, no account | |
bulk_chain | GET /x402/chain/export|blocks|transactions | $0.05 | block/tx range exports, ≤1,000 blocks and ≤10,000 tx rows per call, NDJSON/JSON, gzip, cursor pagination | node answers chain.getHead; window must start at or below head − 6 (else 400 window_not_yet_final, pre-settlement) | re-fetch any exported height from the free chain.getBlockByHeight RPC and compare; meta pins the head and margin the export was cut against |
chain_address_history | POST /x402/chain/address-history | $0.05 | full account history from the gateway’s own head-following index, ≤500 rows/call with a stable cursor | index must be caught up: chain_index_backfilling / _stale / _walker_stalled / _never_ran / _node_unreachable all fail closed | published derivation: account digest = bech32m payload[2:34], direction rule, ordering (height, tx_index), cursor <as_of>:<height>:<tx_index> — recomputable from raw blocks |
chain_batch_balances | POST /x402/chain/balances | $0.02 | ≤500 balances in one batched RPC, deduped, BigInt-exact nANM strings | node answers chain.getHead | re-query any address on the free state.getAddressBalance; as_of.entry_head_min/max + consistent say whether a block landed mid-batch |
priority_inference | POST /x402/v1/chat/completions | $0.10 | priority admission to the AICF chat bridge, no per-IP limit, 180 s budget | DISABLED (PRIORITY_INFERENCE_ENABLED=0) and additionally gated on ≥2 live serving workers; re-checked immediately before settlement | none beyond the settlement receipt — this is a proxied service, not a verifiable computation (stated rather than dressed up) |
echo | GET/POST /x402/paid/echo | $0.005 | development-only settlement smoke marker | off when X402_ENV=production unless X402_ENABLE_ECHO=1 | n/a |
Landing copy (one honest paragraph per product)
Written to be pasted into a landing page or a directory listing. Every number is one the code enforces; nothing here claims hardware, quantum, exclusivity or a guarantee the tests do not prove.
qrng — signed random bytes, $0.01. Buy 1–1,024 random bytes over HTTP
with no account and no API key: pay per call in USDC on Base and the
response carries the bytes, the node’s entropy-health report and an ed25519
signature over sha3_256(bytes) that anyone can check offline. The entropy
source today is a software CSPRNG (os.urandom) with a software signer, so
is_quantum and attested are false — you can read that in the free
catalog and in the 402 offer before you pay, and those fields will flip
truthfully if a hardware provider is ever connected. The node’s underlying
rand.* RPC is free and public; what $0.01 buys is the health gate, the
packaging, the settlement receipt and the per-call payment rail.
random_int — uniform integers, $0.01. Up to 1,000 integers in a range
you choose, derived from one signed draw by rejection sampling, never by
x mod n — a biased lottery is not a lottery. The response publishes the
raw draw, the exact rule and the number of stream bytes consumed, so you
can recompute every integer offline; where the repo’s own verifier applies,
a ready-made derivation.recompute object drops straight into it. Same
trust model as qrng: signed by the serving node, stated in every
response.
random_shuffle — verifiable shuffle, $0.02. A Fisher-Yates permutation
of your list (up to 10,000 items) or of 1..N, from one signed draw. You
get the permutation of 0-based indices next to the shuffled items, plus the
exact swap order and byte-consumption rule, so a third party can replay the
shuffle from the published bytes and confirm you did not reorder anything
afterwards.
random_pick — weighted pick, $0.02. Draw k winners from your list, with
or without replacement, optionally weighted by integer weights (floats are
rejected because the cumulative search has to be recomputable exactly
across languages). Built for raffles, sortition and A/B splits. The rule
and the raw draw ride along, so entrants can check the result. Large items?
Send indices_only and apply the indices yourself — the response size cap
is enforced before you are charged, never after.
random_bulk — batched independent draws, $0.05. Six to ten independent draws, each its own node call with its own signed attestation, settled in a single payment: fewer settlements for the same number of verifiable draws. It is priced per draw, not per byte, and it says so: below six draws the endpoint refuses with a 400 that points you at the cheaper single-draw endpoint, and the response prints the per-draw and per-byte comparison so you can see which product is actually cheaper for what you asked.
random_commit — commit-reveal, $0.02 (reveal free). Publish a
commitment sha3_256(secret‖salt) to your players before a round, then let
anyone — not just you — open it afterwards at a free, public, idempotent
URL that returns the secret, the salt, the raw draw and the node’s
signature. While it is sealed the endpoint answers 425 Too Early with the
seconds remaining, never the secret. What this proves: nothing changed
after you published, and the secret comes from a node-signed draw. What it
does not prove: that the operator discarded no draws before committing —
so publish the commitment before the round, and anchor the round in your
own memo if you need pre-commit grinding to be detectable.
bulk_chain — chain exports, $0.05. Block-range and transaction-range exports from the Animica L1 in NDJSON or JSON, up to 1,000 blocks or 10,000 transaction rows per call, gzip-compressed, with cursor pagination and amounts as exact decimal strings. Reads are pinned six blocks below the head so an export can never straddle a reorg, and a window above that bound is refused before payment instead of being sold empty. Single-block and single-transaction lookups stay free on the public API — this sells volume, shaping and pagination.
chain_address_history — account history, $0.05. Full transaction history for an Animica address, up to 500 rows per call with a stable cursor, served from an index this gateway builds and owns. It exists because nothing else on the network has one: the node RPC has no history method and the public explorer scans at most 250 blocks per call. The index fails closed — while it is backfilling, stalled or lagging, the product reports itself unavailable and refuses payment rather than selling an incomplete history. Every response publishes the derivation (digest join, direction, ordering, cursor) so you can recompute it from raw blocks.
chain_batch_balances — bulk balances, $0.02. Up to 500 address balances in one batched call, deduplicated, with exact decimal amounts. Single balance lookups stay free on the public API; this sells the batch. Because the node dispatches a batch sequentially, the response reports the head range it observed and whether the batch was consistent, instead of pretending to an atomic snapshot.
priority_inference — $0.10, currently unavailable. Priority admission
to Animica’s AI compute layer exists in the catalog and is deliberately
switched off: it requires at least two live serving workers, and the
capacity is not there. While that is true the endpoint returns 503 and no
payment is ever requested. The free keyless endpoint at animica.dev/v1
remains the way to use inference today.
The 402, on the wire (curl)
$ curl -si https://animica.dev/x402/qrng/draw
HTTP/1.1 402 Payment Required
x-request-id: req_…
payment-required: eyJ4NDAyVmVyc2lvbiI6Miwic… # base64(PaymentRequired), v2 wire
content-type: application/json
{ "x402Version": 1, "error": "Payment required", # v1 rendering in the body
"accepts": [ { "scheme": "exact", "network": "base",
"maxAmountRequired": "10000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x<settlement address>",
"resource": "https://animica.dev/x402/qrng/draw",
"mimeType": "application/json", "maxTimeoutSeconds": 60,
"outputSchema": { "input": { "type": "http", "method": "GET", … },
"output": { … } } } ] }
Decoded, the payment-required header is the v2 object:
{ "x402Version": 2,
"resource": { "url": "https://animica.dev/x402/qrng/draw",
"description": "…", "mimeType": "application/json",
"serviceName": "Animica" },
"accepts": [ { "scheme": "exact", "network": "eip155:8453",
"amount": "10000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x<settlement address>", "maxTimeoutSeconds": 60 } ],
"extensions": { "bazaar": { "info": { "input": …, "output": … } } } }
Paying with raw curl means signing an EIP-3009 authorization and sending
payment-signature: base64({x402Version:2, resource, accepted, payload: {signature, authorization}}) — exactly what the bundled payer client
does; use it instead:
SMOKE_PRIVATE_KEY=0x… node apps/x402-gateway/test/manual/smoke-pay.mjs \
https://animica.dev/x402/qrng/draw
# → prints the offer, the signed nonce, HTTP 200, the settlement tx hash,
# the basescan URL and the response body
A successful paid response carries payment-response: base64({success:true, transaction:"0x<tx hash>", network:"eip155:8453", payer:"0x…", amount:"10000"}) (v1 clients also get x-payment-response
with the network slug), and JSON products embed the same metadata in the
body’s payment object.
JS client example (current x402 packages)
Current npm packages, checked against the registry 2026-08-15:
@x402/fetch 2.22.0 (dist-tag latest) with @x402/evm 2.22.0
(the exact-EVM scheme client; signer from viem):
import { wrapFetchWithPaymentFromConfig, decodePaymentResponseHeader } from "@x402/fetch"; // 2.22.0
import { ExactEvmScheme } from "@x402/evm"; // 2.22.0
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.PAYER_KEY);
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{ network: "eip155:8453", client: new ExactEvmScheme(account) }],
});
const res = await fetchWithPayment("https://animica.dev/x402/qrng/draw?bytes=32");
console.log(await res.json());
console.log(decodePaymentResponseHeader(res.headers.get("PAYMENT-RESPONSE"))); // tx hash etc.
Known gap: extra.{name,version} is not yet advertised
The exact-EVM scheme spec REQUIRES the offer’s extra.name/extra.version
(the token’s EIP-712 domain) in each accepts entry, and @x402/evm 2.22.0
throws (EIP-712 domain parameters (name, version) are required…)
when they are absent. As built, the gateway’s EVM accepts entries
(src/middleware.js buildAccepts) do not include extra, so the stock
@x402/fetch client cannot sign against the current offers. Until
buildAccepts adds extra: {name, version} for the configured network
(the facilitator already validates it when present — verify.js rejects a
wrong extra.name), use the bundled smoke-pay.mjs client, which prefers
the advertised extra and falls back to its own live-verified USDC domain
table. The facilitator side is unaffected (it never needs extra to
verify — its domain comes from its own allowlist).
Environment / configuration
Every variable is documented in
apps/x402-gateway/.env.example —
copy to a root-owned 0600 /etc/animica-x402.env; both units read it;
no secret is ever committed. Startup is fail-closed: contradictions
(chain id vs network, non-allowlisted asset/token, malformed key, garbage
price, negative caps) refuse to boot with a precise message and never
hot-loop (systemd StartLimit in the example units). Three defaults are
load-bearing and deliberate: X402_FACILITATOR_MODE defaults to self
(and remote requires an explicit X402_FACILITATOR_URL — there is no
fallback endpoint, because a default would route real payments through
whoever it named); X402_NETWORK_EVM defaults to Base mainnet
eip155:8453 and must agree with the facilitator’s X402_NETWORK in
self mode; and with X402_ENV=production both
X402_RESOURCE_BASE_URL (public, non-loopback — it is published in every
offer and in the commit-reveal reveal_url) and X402_RECEIPT_HMAC_KEY
are required. Key groups: network
(X402_NETWORK, X402_CHAIN_ID, X402_ASSET, X402_RPC_URL,
X402_SETTLEMENT_ADDRESS), facilitator (X402_FACILITATOR_MODE=self|remote,
X402_FACILITATOR_URL, X402_FACILITATOR_PRIVATE_KEY), gas policy, gateway
bind/ports (gateway 127.0.0.1:8742, facilitator 127.0.0.1:8743), product
prices/caps, inference gate. The full annotated table is in the app
README.
Facilitator wallet: powers + funding
The facilitator key can only (a) spend its own ETH on gas — bounded by the
per-settlement cap, fee cap and optional daily budget breaker — and (b)
broadcast user-signed USDC authorizations whose amount and destination it
cannot alter. Revenue lands directly at X402_SETTLEMENT_ADDRESS, which in
the two-wallet posture is a cold address needing no hot key at all. (In
single-wallet mode that address is the facilitator — see
Treasury below for what that
trades away and what keeps the balance small.) Measured cost per settlement
≈ 5.4×10¹¹ wei ≈
$0.002 (execution + OP-stack L1 data fee, both accounted). Hold
0.005–0.01 ETH on Base (≈ 9k–18k settlements), alarm on the
x402_facilitator_gas_balance_wei gauge, floor at
X402_MIN_GAS_BALANCE_WEI (default 0.0001 ETH ≈ 185 settlements —
readyz fails below it). In single-wallet mode the treasury’s refuel
trigger must sit meaningfully above that floor and startup refuses the
combination otherwise; see Treasury.
Key generation and the rotation procedure (stop → swap env → start →
/supported shows the new signer; nothing user-facing changes) are in the
app README’s runbook section. No user key ever reaches Animica: customers
sign locally and send only signatures.
Treasury: single-wallet mode + “sweep and sip”
The trade-off, stated plainly
The paragraph above describes the two-wallet posture: payTo is a cold
address the facilitator cannot touch, and the hot key holds nothing but gas.
That is the safest arrangement and it is still fully supported — it is what
you get by default.
The operator’s chosen deployment is the other one. Single-wallet mode
sets X402_SETTLEMENT_ADDRESS to the facilitator’s own address, so USDC
revenue lands on the same hot key that signs settlements.
- What it buys: the wallet can refuel itself. Gas is only ever burned by settlements, and every settlement collects more USDC than the gas it costs (at the $0.01 QRNG price, ~10× more), so the loop is self-sustaining. Fund it once with ~$2 of ETH and it runs unattended.
- What it costs: revenue transits a hot key. Anyone who obtains that key gets whatever is sitting on it at that moment — not just the gas float.
- The compensating control is this module: everything above a small
ceiling is continuously transferred to a cold address the hot key cannot
change, so “whatever is sitting on it” stays pocket change. Without that
control the trade is simply “all revenue on a hot key”, which is not a
trade at all — so the facilitator refuses to start in single-wallet mode
unless
X402_TREASURY_ENABLED=1andX402_TREASURY_COLD_ADDRESSis set (src/treasury/index.js,assertTreasuryPolicy).
If you would rather not make that trade: point X402_SETTLEMENT_ADDRESS at a
wallet the facilitator does not control and leave the treasury off. Nothing
else changes.
Sip — adaptive auto-refuel
When the facilitator’s ETH drops below X402_TREASURY_ETH_FLOOR_WEI
(default 0.0005 ETH ≈ 900 settlements of remaining runway), the module
swaps a little accrued USDC to ETH on Uniswap v3:
size = min(X402_TREASURY_SIP_USDC, USDC balance) # default $5.00
but never below X402_TREASURY_SIP_MIN_USDC # default $0.50
Why adaptive. A fixed $5 minimum deadlocks. Consider the intended
bootstrap: the operator funds ~$2 of ETH once and walks away. A fee spike
burns that at ~75 settlements, by which point only ~$0.75 of $0.01 revenue
has accrued. With a $5 floor there is now no ETH to settle with, therefore no
new revenue, therefore never $5 to swap — a permanent stall with money on the
table. A $0.50 sip buys ~490 settlements of gas and breaks the deadlock. The
scenario is a test, not a footnote: see BOOTSTRAP STALL in
test/treasury.test.js, which also asserts that the fixed-minimum variant
really does stall.
The rest of the sip policy:
- One transaction.
multicall(deadline, [exactInputSingle(USDC→WETH, recipient = the router itself), unwrapWETH9(minOut, facilitator)])against SwapRouter02. Atomic: if either leg reverts, no USDC moves. The deadline (default 180 s) is the only thing that stops a stuck sip from executing hours later at a stale price. - Slippage.
amountOutMinimum = QuoterV2 quote − X402_TREASURY_MAX_SLIPPAGE_BPS(default 100 = 1%), computed in BigInt atomic units from a quote taken immediately before signing. Both the 0.05% and 0.01% USDC/WETH pools are quoted and the better fill wins; the 1% tier is not allowlisted at all. - Approval. Exact-amount
approveper sip, never max-approve. The swap consumes it exactly, so a hot wallet holding live revenue leaves zero standing allowance to a third-party contract. The ~56k gas this costs at most once per cooldown is $0.0006. - Rate limits. One sip per
X402_TREASURY_SIP_COOLDOWN_S(default 24 h), halved while ETH is below floor/2 (the emergency path), and a hardX402_TREASURY_DAILY_SWAP_BUDGET_USDC(default $10/day) that no volume of settlement triggers can exceed. A failed attempt uses the much shorterX402_TREASURY_RETRY_COOLDOWN_S(default 15 min) instead: a stale-quote revert is caught by the pre-flight estimate and costs no gas at all, so losing a day of refuelling over one unlucky tick would be the expensive choice — and the two-strike breaker still bounds real failures. - Pre-flight.
eth_estimateGason the exact final calldata before signing: a sip that would revert on slippage, allowance or a blacklist is skipped for free instead of burning gas to discover it. - Economic sanity. A sip is skipped when the ETH it buys is not worth
≥
X402_TREASURY_MIN_ETH_OUT_GAS_RATIO(default 4) × the gas it costs. The cost is priced on the measured gas of the legs this attempt will actually send — 180k for the swap, plus 60k only when an approve is needed — at the fee the transaction actually pays (baseFee + tip). The earlier version priced the caps (100k + 300k) at the fee ceiling (2·baseFee + tip) and demanded 20× that: ~90× the true cost, which silently vetoed the $0.50 adaptive sip from ~0.012 gwei upwards and vetoed a sip of any size above ~0.17 gwei — i.e. it disabled the bootstrap-stall cure exactly in the gas spike that needs it. Regression:test/adv-treasury-econ.test.js. - Price references (optional but recommended).
amountOutMinimumis derived from the very pool it protects, so a manipulated, thin or stale quote drags the bound down with it and the fill looks like a clean success. Two independent references reject such a quote (skip, no strike): the operator-setX402_ETH_USD_PRICE, and the realised rate of our own last confirmed sip (ignored once older thanX402_TREASURY_RATE_REFERENCE_MAX_AGE_S, so a stale reference can never deadlock the refuel loop). The tolerated band isX402_TREASURY_MAX_QUOTE_DEVIATION_BPS(default 5000 = 50%). - Verification. A receipt with status 1 is not enough: the module
requires WETH9’s own
Withdrawallog in our receipt withwad ≥ amountOutMinimum, plus a USDCTransferof the sip amount out of the wallet.eth_getBalanceright after a receipt can still read the pre-transaction value, so balances update the gauges and the logs decide.
Sweep — continuous auto-drain
When the USDC balance exceeds X402_TREASURY_USDC_CEILING (default $20), the
surplus (balance − ceiling) is ERC-20-transferred to
X402_TREASURY_COLD_ADDRESS. The ceiling stays behind as operating float —
it is deliberately larger than a sip, so the wallet can always buy its own
gas back (startup refuses a ceiling below the sip minimum). Sweeps below
X402_TREASURY_MIN_SWEEP_USDC (default $0.10) are skipped so a balance
hovering at the ceiling does not pay gas to move fractions of a cent.
Sweeps have no cooldown (draining fast is the point), so they carry a
per-day count cap instead: X402_TREASURY_MAX_SWEEPS_PER_DAY (default 24).
Their gas also counts against the daily ETH ceiling — see Gas budget below.
The destination is immutable at runtime. It is read from server config
once, at construction, and captured in a closure. There is no destination
parameter on any function, no request field, and no re-read of the
environment; mutating the config object afterwards changes nothing. The test
SWEEP DESTINATION IMMUTABILITY proves it against the signed transaction
bytes, not against an intermediate value.
The amount is equally uncontrollable. attemptSweep reads the balance it
sizes from, itself, immediately before signing; the same is true of the sip.
Neither entry point accepts a balance from its caller any more — a stale or
forged number used to produce a transfer for money the wallet did not hold
(reverting, and walking the two-strike breaker one step closer to disabling
the drain).
The cold address is checked three ways, because a checksum alone is a weak guard on the one value where every swept dollar ends up:
- exactly EIP-55 checksummed (an all-lowercase address is rejected);
- not in the reserved low-address range — the zero address, the
precompiles
0x…01-0x…09and0x…dEaDare all-digit and therefore have no checksum to fail, which makes them exactly the truncated-paste / unset-template accident a checksum cannot catch (real USDC reverts on a transfer to0x0, which used to disable sweeping after two ticks and leave every dollar on the hot key); - not a contract, verified once with
eth_getCodebefore the first sweep. A safe or multisig is a legitimate destination — setX402_TREASURY_COLD_ALLOW_CONTRACT=1to declare it. An address that cannot be read is a skip, never a sweep into the unknown.
When it breaks
Two consecutive swap failures disable sipping, set
x402_treasury_sipping_enabled 0, and surface in /readyz as a WARNING
that does not fail readiness. Settlements keep running on whatever ETH is
left; the failure mode is “operator tops up manually”, never “block paid
traffic” and never “loop swaps”. The state is persisted, so a restart does
not silently re-arm it — clear it with animica-x402 treasury resume --confirm after fixing the cause.
Failure classes are not equal (recon-verified revert strings):
| revert | meaning | policy |
|---|---|---|
Too little received / Insufficient WETH9 | the quote went stale | retry next tick, not a strike |
STF | our approve did not land | strike |
STE | the ETH recipient rejects ETH | hard-disable immediately (misconfiguration) |
| transport error / no receipt | unknown outcome — it may have landed | leave in flight, no strike, still counted against the budget |
USDC.paused() and isBlacklisted(facilitator) are checked before acting;
both are outside our control and both are a skip, never a strike.
Unknown outcomes and stuck transactions
A treasury transaction whose outcome we could not establish (transport error,
or no receipt inside X402_RECEIPT_TIMEOUT_MS) is not an ending. The row
keeps its signed bytes and its fee parameters, and:
- nothing new is signed while it is unresolved — its nonce sits in front of
every settlement in the same lane, so piling another transaction on top
would deepen an outage rather than shorten it (
unresolved_actionskip); treasury.recover()runs at startup and at the top of every tick (and on demand:animica-x402 treasury reconcile) and resolves the row from chain truth — receipt → WETH9Withdrawal/ USDCTransferlogs → confirm, with the gas the transaction really burned;- a transaction that vanished from the mempool is rebroadcast byte for byte (same nonce, same intent — never a different transaction);
- a transaction still pending after
X402_TREASURY_STUCK_TX_S(default 180 s) is fee-bumped on the same nonce (+25%, capped byX402_MAX_FEE_PER_GAS_WEI, at mostX402_TREASURY_MAX_TX_BUMPStimes). This is the escape from the worst failure this module can cause: without it, one treasury transaction stuck behind a base-fee jump blocked every later settlement indefinitely, and nothing in the process could clear it.
While anything is unresolved, /readyz carries the warning and
x402_treasury_unresolved_actions is non-zero. It is still a WARNING: paid
traffic that can settle, settles.
”Cannot refuel” is now visible
A sip that keeps being skipped is not a failure — no strike, no breaker —
but it is the condition that ends in an empty wallet. After
X402_TREASURY_REFUEL_ALERT_TICKS (default 3) consecutive checks under the
ETH floor with the sip skipped for any liquidity/economics/budget reason, the
module logs treasury_refuel_blocked at error level, sets
x402_treasury_refuel_blocked 1 and adds a /readyz warning naming the
reason. A single skip, and any skip while the balance is healthy, are silent.
Gas budget
X402_DAILY_GAS_BUDGET_WEI is documented as a daily ETH ceiling for the
facilitator account, so it counts treasury gas too: the breaker sums the
payments ledger and the treasury ledger, and the treasury checks it before
signing (approve, sip, sweep). While the breaker is open the treasury spends
nothing — previously settlements were refused to stop the bleeding while the
treasury kept swapping.
It never touches the settlement path
The treasury runs on its own timer (X402_TREASURY_CHECK_INTERVAL_S, default
300 s) plus a coalesced post-settlement trigger that the settlement path
fires and forgets — the hook returns synchronously and swallows every
error, and a hook that throws cannot fail a payment (both are tests).
The one genuinely shared resource is the facilitator account’s transaction
nonce. The treasury takes the settlement engine’s FIFO submit lock for
exactly one sign + eth_sendRawTransaction and releases it before it
polls for a receipt, so a settlement arriving mid-sip queues behind one RPC
round trip rather than behind a 30-second confirmation.
The lock alone is not enough, and this was a real defect: it serialises
our code, not the RPC front-end’s view of the mempool. A load-balanced public
endpoint can answer eth_getTransactionCount(addr,'pending') from a node that
has not yet seen the transaction we broadcast a millisecond ago, so both
writers derived the same nonce; the node then rejected the settlement as an
underpriced replacement, and a non-transport send error is treated as a
DEFINITIVE rejection — the payer’s authorization burned, for a sip. Three
things prevent it now:
- One allocator (
src/facilitator-evm/nonce.js) used by BOTH writers inside the submit lock:nonce = max(remote pending, last issued + 1). The high-water mark is committed the moment a transaction is signed (a transport error may still have landed it) and handed back only on a definitive rejection, so the lane neither duplicates nor gaps. The mark expires after 120 s, at which point chain truth wins again — that is also the correct repair for a transaction that was dropped everywhere. - No second signer. The treasury holds an exclusive lease row in the
shared DB (
X402_TREASURY_LEASE_TTL_S, renewed every tick).animica-x402 treasury sip|sweep|reconciletakes that lease first and refuses to run while the service holds it. Previously the CLI signed from a second process with no interlock at all — only a warning printed when--confirmwas missing, i.e. it disappeared the moment the operator did the documented thing. - No pile-up. Nothing new is signed while a treasury transaction is unresolved (above).
MEV / slippage stance
Sips are tiny and the bound is explicit: amountOutMinimum is the quote
minus ≤1%, so a sandwich against a $5 swap in a pool holding $2.7M + 3,880
WETH cannot extract more than ~$0.05, realistically ~$0.00 — real swaps
during the recon filled at exactly the quoted amount, zero observed slippage
at this size. Base’s sequencer orders transactions first-come-first-served
and exposes no public mempool, which further limits opportunistic
sandwiching.
Be precise about what is assumed and what is enforced:
| bound | value | enforced by |
|---|---|---|
| worst credible sandwich at this size | ~$0.05 | an assumption about pool depth + Base’s private mempool, not code |
| slippage vs the quote | ≤1% | amountOutMinimum in the signed calldata |
| quote vs an independent price | ≤50% below | X402_ETH_USD_PRICE / last realised rate (optional; see Price references) |
| total daily exposure | $10/day | X402_TREASURY_DAILY_SWAP_BUDGET_USDC — this is the real, code-enforced ceiling |
| per-sip size | $5 | X402_TREASURY_SIP_USDC |
| sip frequency | 1 per 24 h (12 h under floor/2) | X402_TREASURY_SIP_COOLDOWN_S |
The slippage bound alone is anchored to the pool it protects: without a price
reference configured, a manipulated or stale quote moves the bound with it and
the loss is capped only by the daily swap budget. Set X402_ETH_USD_PRICE
(the knob the settlement path already uses) to get an absolute floor as well.
Do not “harden” the slippage below ~50 bps: tighter bounds cause spurious
Too little received reverts, which cost gas and walk the two-strike breaker
toward disabling the refuel loop. 100 bps is the right default.
Cost of a sip (live-measured, Base)
| component | wei | USD |
|---|---|---|
approve 56,240 @ 0.006 gwei | 337,440,000,000 | $0.00063 |
| swap+unwrap multicall 165,389 @ 0.006 gwei | 992,334,000,000 | $0.00187 |
| OP-stack L1 data fee (both txs) | ~992,000,000 | $0.0000019 |
| pool fee 0.05% on $5 | — | $0.00250 |
| total | ≈1.33×10¹² | ≈$0.005 = 0.10% of the sip |
$5.00 buys ≈0.002658951 ETH ≈ 4,900 settlements. The $0.50 adaptive minimum buys ≈490. Sipping is economically sound by roughly three orders of magnitude.
First enable — runbook
Do this once, in this order. Steps 1-3 cost nothing and catch every configuration mistake this module can make before any money is at risk.
- Pick the cold address and check it twice. It must be EIP-55
checksummed exactly, outside the reserved low-address range, and an EOA
you control (or a safe/multisig declared with
X402_TREASURY_COLD_ALLOW_CONTRACT=1). Startup refuses the first two; the third is checked witheth_getCodebefore the first sweep. - Dry-run the config. Set the env file and start the facilitator with
the treasury enabled but no money on the wallet. Startup is fail-closed:
a bad cold address, a floor that does not clear
X402_MIN_GAS_BALANCE_WEIby 3×, a lease TTL shorter than two check intervals, or an impossible budget all refuse to boot with a precise message.curl -s 127.0.0.1:8743/readyz | jqshould showgas_balance: low: …(expected — the wallet is empty) and nothing else wrong. - Confirm the wiring.
animica-x402 treasury statusprints the policy, the cold address, the contracts and the breaker state, all from the same config the service loaded. - Fund it once. Send ~$2 of ETH (≈0.001 ETH) to the facilitator
address on Base — ~2,000 settlements of gas, far more than enough to reach
the first sip — and leave
X402_SETTLEMENT_ADDRESS= the facilitator address./readyzshould now be200. - Watch the first sweep and the first sip.
animica-x402 treasury historyshows both with transaction hashes; each one is also a structured log line with a basescan URL. Verify the first sweep landed at the cold address on the explorer, once, by eye. - Set the alarms listed under Metrics below.
From then on: revenue accrues → surplus above $20 is swept to cold → when gas
drops below 0.0005 ETH a $5 (or smaller) sip refuels it. You never need to
send ETH again unless a breaker opens. If one does, the facilitator keeps
settling until the remaining ETH runs out — the floor gives you roughly 900
settlements of warning, and readyz fails outright at
X402_MIN_GAS_BALANCE_WEI (0.0001 ETH) before the wallet is truly empty.
Operator note: the nonce lane
The facilitator account has ONE transaction-nonce lane, shared by settlements and treasury transactions. Two rules follow, and both are enforced in code — they are written down here because violating them by hand is easy:
- Never run a second signer against the same key. That includes
animica-x402 treasury sip|sweep|reconcilewhile the unit is up (refused by the lease), a second facilitator process on the same key, and any manual transaction from a wallet app using the facilitator key. A duplicate nonce gets a live settlement rejected as an underpriced replacement, which burns the payer’s authorization at this facilitator. - A stuck treasury transaction is a settlement outage until it clears,
because inclusion is nonce-ordered. The module bumps its own stuck
transactions (
X402_TREASURY_STUCK_TX_S, up toX402_TREASURY_MAX_TX_BUMPStimes) and warns on/readyzwhilex402_treasury_unresolved_actions > 0. If it gives up (the fee cap leaves no room to bump), the log linetreasury_tx_stuckcarries the nonce: stop the unit and replace that exact nonce manually at a higher fee, then start it again and runanimica-x402 treasury reconcile.
Operating it
animica-x402 treasury status # policy, balances, breaker state, totals
animica-x402 treasury history --kind sip # every sip/sweep/approve with tx hashes
animica-x402 treasury reconcile # resolve in-flight txs from chain truth
animica-x402 treasury sip --confirm # force one adaptive sip now
animica-x402 treasury sweep --confirm # force one sweep of the surplus now
animica-x402 treasury resume --confirm # re-arm after a two-strike disable
Manual sip/sweep bypass the ETH floor, the cooldown and an existing
disable — they do not bypass the daily budget, the minimum size, the
slippage bound, the price references or the economic sanity check.
Every signing command takes the treasury lease first and refuses to run while the facilitator service holds it:
error: the treasury signing lease is held by another process (facilitator).
That is the intended answer, not an obstacle to work around: two signers on
one nonce lane is how a manual sip takes a live settlement’s nonce. Stop
animica-x402-facilitator.service, run the command, start the service again
(the lease is released on exit, and expires by itself after
X402_TREASURY_LEASE_TTL_S if the process died).
Metrics: x402_treasury_sips_total{result}, x402_treasury_sweeps_total{result},
x402_treasury_swept_usdc_total, x402_treasury_sipped_usdc_total,
x402_treasury_sip_eth_received_wei, x402_treasury_gas_spent_wei{kind},
x402_treasury_eth_balance_wei, x402_treasury_usdc_balance,
x402_treasury_sipping_enabled, x402_treasury_sweeping_enabled,
x402_treasury_refuel_blocked, x402_treasury_unresolved_actions. Treasury
gas is counted separately from x402_gas_spent_wei so settlement economics
stay readable (the daily-budget breaker sums both).
Alarm on: x402_treasury_sipping_enabled == 0,
x402_treasury_sweeping_enabled == 0, x402_treasury_refuel_blocked == 1,
x402_treasury_unresolved_actions > 0 for more than a few minutes, and
x402_treasury_eth_balance_wei trending toward X402_MIN_GAS_BALANCE_WEI.
Contract set (Base mainnet, live-verified 2026-08-15)
| contract | address |
|---|---|
| USDC (FiatTokenProxy) | 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| WETH9 | 0x4200000000000000000000000000000000000006 |
| UniswapV3Factory | 0x33128a8fC17869897dcE68Ed026d694621f6FDfD |
| SwapRouter02 | 0x2626664c2603336E57B271c5C0b26F421741e481 |
| QuoterV2 | 0x3d4e44Eb1374240CE5F1B871ab261CD16335B76a |
| USDC/WETH pool, fee 500 | 0xd0b53d9277642d899df5c87a3966a349a798f224 |
Verified behaviourally, not from memory: each router/quoter answers
factory() and WETH9() with the addresses above, bytecode hashes matched
on two independent RPC providers, and the exact multicall the module sends
was simulated with eth_call/eth_estimateGas state overrides before
anything was written. UniversalRouter is deliberately not used (three
separate deployments exist on Base and it routes token pulls through Permit2,
a second approval surface we do not need). The test suite recomputes every
selector from its signature and re-checksums every address, so a
transcription slip fails CI rather than a real swap.
Deployment
Files in the repo (installing them is the deliberate runbook step — nothing activates by existing):
apps/x402-gateway/systemd/animica-x402.service— the gateway; replaces the currently-installed unit of the same name that still runssrc/demo-server.js.apps/x402-gateway/systemd/animica-x402-facilitator.service— the facilitator (loopback :8743, never behind nginx).apps/x402-gateway/nginx/animica-dev-x402.conf+nginx/INSTALL.md— theanimica.dev/x402/location set; replaces the current simple/x402/location (demo :4656, prefix-stripped) with per-product locations, request-size caps, threelimit_reqzones (the http-contextlimit_req_zonelines are documented in the file), request-id forwarding, and no buffering on the inference route.
Order: Base Sepolia manual pass
(test/manual/base-sepolia.md)
→ mainnet 0600 env file → facilitator unit + readyz all-true → gateway
unit → nginx cutover → smoke-pay.mjs against production (echo, then
qrng) → animica-x402 reconcile shows the settlement OK. That last
on-chain proof on mainnet is the acceptance step that closes the project.
Reconciliation
Ground truth lives in three places that must always agree: the facilitator
ledger (state/x402.db), the chain, and the gateway’s incident table.
bin/animica-x402 reconcile [--limit N] # every settled row vs its receipt:
# status 0x1 + AuthorizationUsed(payer,nonce)
# + Transfer(payer→payTo, exact value);
# exit 1 on any MISMATCH/NO_RECEIPT
bin/animica-x402 settlements list --status submitting # should be empty at rest;
# persistent rows = unknown outcomes the
# next facilitator restart will resolve
bin/animica-x402 revenue --since 24h # settled sums per product (= metric)
bin/animica-x402 gas report --since 7d # spend incl. L1 data fees (= metric)
bin/animica-x402 incidents list --status open
bin/animica-x402 incidents resolve inc_… --status refunded|resolved
bin/animica-x402 commitments list --state sealed # commit-reveal ledger; the
# secret prints as <sealed> until the
# free reveal route would disclose it
bin/animica-x402 commitments prune --older-than 90d
Incident kinds: downstream_failed (payment settled, service failed after
the bounded retry — the client already holds the signed HMAC receipt
referencing the settlement; refund out of the settlement address, then
mark refunded), delivery_failed (payment settled and the resource was
produced, but the response could not be delivered — a body too large to
serialize, a full or locked DB in the idempotency write. Same compensation
as downstream_failed: signed receipt + 502 delivery_failed, never a
bare 500 that reads as “you were not charged”. For execute-then-settle
products the body is serialized BEFORE settlement precisely so this stays
rare) and settle_unknown (transport failure mid-settle; check
payment get <fingerprint/tx> and the chain — if the transfer landed, the
client was NOT served: refund or honor the receipt). Signed receipts
verify against X402_RECEIPT_HMAC_KEY (key_id = key hash prefix, so
rotations stay attributable). Accounting invariants (tested): one
delivered success == exactly one settlement; x402_revenue_usdc == sum of
settled rows; failed/replayed/idempotent-replayed payments never touch
revenue.
Migrating self ↔ remote facilitator
The gateway/product layer never changes — both modes speak the same v2 §7
client (verify/settle/supported). payTo is our settlement address
in both cases.
self → remote (e.g. onto PayAI at https://facilitator.payai.network):
- Drain: wait for
settlements list --status submittingto be empty (or restart the facilitator once — recovery resolves stragglers). - Env:
X402_FACILITATOR_MODE=remote,X402_FACILITATOR_URL=<url>; restart the gateway. Offers and products are unchanged. - Stop the facilitator unit; KEEP its ledger DB — it remains the audit
history for
reconcile/revenue, and its replay rows still document every past authorization. - Note what you lose remotely: our replay ledger no longer arbitrates new payments (the remote facilitator’s does, plus the chain’s nonce ledger, which makes double-settles impossible regardless); gas/readyz metrics stop (the remote operator sponsors gas).
remote → self (back to the default): fund + configure the facilitator
(env above), start its unit, wait for /readyz all-true, flip
X402_FACILITATOR_MODE=self, restart the gateway. Verification hardens
immediately (asset/payTo/chain re-checked against our own allowlist).
Authorizations that were consumed under the remote facilitator stay
unusable — authorizationState on-chain is facilitator-independent.
Troubleshooting
The app README carries the full symptom table. The short list:
503 x402_disabled—ANM_X402_ENABLED≠ 1 (kill switch).- Repeated 402 with
invalid_exact_evm_payload_signature— EIP-712 domain mismatch: mainnet is"USD Coin", Sepolia is"USDC"; also see “Known gap” above (clients that requireextra.name). invalid_transaction_state— replay; sign a fresh nonce.502 facilitator_unreachable— facilitator down / wrong URL; check127.0.0.1:8743/healthzand/readyz(itschecksobject names the failing leg: rpc, chain_id, usdc_domain, db, gas_balance).- Facilitator refuses to start — fail-closed config or key validation; the printed reason is precise; never bypass it.
better-sqlite3must stay pinned at 12.11.1 on this Node 20 host (13.x is Node-22-only and segfaults here).- Catalog
available:false— readunavailable_reason. The catalog and the 503 body use different strings on purpose: the catalog sayspriority_inference_disabled(operator gate off) orinsufficient_serving_capacity(gate on, serving workers below the floor), while the endpoint keeps one stable 503 code,priority_inference_unavailable. Either way it is the capacity gate doing its job — and it refuses without ever emitting payment requirements. paid_service_failed(502) — money settled, service failed: the body’s signed receipt +incident_idare the client’s refund claim; see Reconciliation.
This page mirrors a file in the animicaorg/all repository. If the repository and this page ever disagree, the repository is authoritative. For long-form explainers written for newcomers, see Learn.