Skip to content

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

threatdefense
Replay / double-spend of a payment authorizationthree 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 authorizationthe 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 substitutionthe 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 serviceavailability 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 walletper-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 payloadsstrict 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 leakagethe 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 ideip155:8453 / 8453eip155:84532 / 84532
USDC contract0x833589fCD6eDb6E08f4c7C32D4f71b54bdA029130x036CbD53842c5426634e7929541eC2318f3dCF7e
EIP-712 domain{name: "USD Coin", version: "2"}{name: "USDC", version: "2"}
decimals6 ($0.01 = 10000)6
explorerbasescan.orgsepolia.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_total increments. This holds across facilitator restarts (the ledger is on disk) and even across a facilitator swap (the chain’s authorizationState is 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-Key after 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 resource id; per-product audit comes from the ledger’s resource column (revenue CLI 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

productroutepricewhat you get
random_intPOST /x402/random/int$0.01uniform integers in [min,max], ≤ 1,000 per call
random_shufflePOST /x402/random/shuffle$0.02Fisher-Yates permutation of your list or of 1..N, ≤ 10,000 items
random_pickPOST /x402/random/pick$0.02k picks with/without replacement, optional integer weights (indices_only for large items)
random_bulkPOST /x402/qrng/bulk$0.056–10 INDEPENDENT draws (own attestation each) × ≤ 1,024 bytes in ONE settlement
random_commitPOST /x402/random/commit$0.02a 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:

  1. commitment == sha3_256(secret || salt) — the commit-reveal property;
  2. secret || salt == the first 64 bytes of the DRNG stream over the revealed randomness with kind="commit" and your request_id — proof the secret is a deterministic function of THAT disclosed, node-signed draw (so nothing was substituted at reveal time);
  3. 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):

ruledefinition
account digestbech32m 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.
directionfrom == 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.
cursornext_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.
amountsvalue/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:

  1. PRIORITY_INFERENCE_ENABLED=1 — ships as 0; do not enable while serving capacity is broken.
  2. Live capacity ≥ PRIORITY_INFERENCE_MIN_SERVING_WORKERS (default 2), measured every 15 s by polling aicf.workerStatus for each wallet in X402_INFERENCE_WORKER_WALLETS. A worker counts when registered, last_seen ≤ 5 min (ms/s normalized) and serving X402_INFERENCE_TIER (default standard; 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’s BRIDGE_KNOWN_WORKER_WALLETS — the gate must never sell priority the router cannot deliver. (There is no global worker enumeration on-chain; aicf.estimateJobCost.providers counts 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.

idroute(s)pricewhat it sellsavailability ruleverification rule
qrngGET /x402/qrng/draw, POST /x402/qrng$0.011–1,024 random bytes from the node’s randomness service with its signed digest attestation and entropy-health reporta live draw succeeds AND health.passed (probe every ≤5 s); the live source/attested fields are published unpaid in the catalog and the 402attestation.digest_hex == sha3_256(randomness) and ed25519_verify(public_key_hex, raw digest bytes, signature_hex)
random_intPOST /x402/random/int$0.01up to 1,000 uniform integers in [min,max] from ONE draw, by rejection sampling (no modulo bias)same shared entropy probe as qrngrebuild 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_shufflePOST /x402/random/shuffle$0.02Fisher-Yates permutation of your list or of 1..N, ≤10,000 itemssame shared entropy probefor i = N-1..1 { j = randbelow(i+1); swap } over [0..N-1]; derivation.recompute verifies against verify.js (kind: shuffle)
random_pickPOST /x402/random/pick$0.02k picks with/without replacement, optional integer weights (raffles, sortition, A/B splits); indices_only for large itemssame 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_bulkPOST /x402/qrng/bulk$0.056–10 independent draws (one node call + one attestation each) settled onceshared 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_commitPOST /x402/random/commit$0.02a published commitment sha3_256(secret‖salt) now, sealed for 0–7 dayssame shared entropy probeat 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}freethe public, idempotent disclosure of that commitmentroute exists whenever random_commit is enabled; 425 while sealed, 404 past the 90-day retentionas above — anyone can run it, no payment, no account
bulk_chainGET /x402/chain/export|blocks|transactions$0.05block/tx range exports, ≤1,000 blocks and ≤10,000 tx rows per call, NDJSON/JSON, gzip, cursor paginationnode 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_historyPOST /x402/chain/address-history$0.05full account history from the gateway’s own head-following index, ≤500 rows/call with a stable cursorindex must be caught up: chain_index_backfilling / _stale / _walker_stalled / _never_ran / _node_unreachable all fail closedpublished 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_balancesPOST /x402/chain/balances$0.02≤500 balances in one batched RPC, deduped, BigInt-exact nANM stringsnode answers chain.getHeadre-query any address on the free state.getAddressBalance; as_of.entry_head_min/max + consistent say whether a block landed mid-batch
priority_inferencePOST /x402/v1/chat/completions$0.10priority admission to the AICF chat bridge, no per-IP limit, 180 s budgetDISABLED (PRIORITY_INFERENCE_ENABLED=0) and additionally gated on ≥2 live serving workers; re-checked immediately before settlementnone beyond the settlement receipt — this is a proxied service, not a verifiable computation (stated rather than dressed up)
echoGET/POST /x402/paid/echo$0.005development-only settlement smoke markeroff when X402_ENV=production unless X402_ENABLE_ECHO=1n/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=1 and X402_TREASURY_COLD_ADDRESS is 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 approve per 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 hard X402_TREASURY_DAILY_SWAP_BUDGET_USDC (default $10/day) that no volume of settlement triggers can exceed. A failed attempt uses the much shorter X402_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_estimateGas on 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). amountOutMinimum is 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-set X402_ETH_USD_PRICE, and the realised rate of our own last confirmed sip (ignored once older than X402_TREASURY_RATE_REFERENCE_MAX_AGE_S, so a stale reference can never deadlock the refuel loop). The tolerated band is X402_TREASURY_MAX_QUOTE_DEVIATION_BPS (default 5000 = 50%).
  • Verification. A receipt with status 1 is not enough: the module requires WETH9’s own Withdrawal log in our receipt with wad ≥ amountOutMinimum, plus a USDC Transfer of the sip amount out of the wallet. eth_getBalance right 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:

  1. exactly EIP-55 checksummed (an all-lowercase address is rejected);
  2. not in the reserved low-address range — the zero address, the precompiles 0x…01-0x…09 and 0x…dEaD are 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 to 0x0, which used to disable sweeping after two ticks and leave every dollar on the hot key);
  3. not a contract, verified once with eth_getCode before the first sweep. A safe or multisig is a legitimate destination — set X402_TREASURY_COLD_ALLOW_CONTRACT=1 to 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):

revertmeaningpolicy
Too little received / Insufficient WETH9the quote went staleretry next tick, not a strike
STFour approve did not landstrike
STEthe ETH recipient rejects ETHhard-disable immediately (misconfiguration)
transport error / no receiptunknown outcome — it may have landedleave 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_action skip);
  • 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 → WETH9 Withdrawal / USDC Transfer logs → 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 by X402_MAX_FEE_PER_GAS_WEI, at most X402_TREASURY_MAX_TX_BUMPS times). 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|reconcile takes 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 --confirm was 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:

boundvalueenforced by
worst credible sandwich at this size~$0.05an 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% belowX402_ETH_USD_PRICE / last realised rate (optional; see Price references)
total daily exposure$10/dayX402_TREASURY_DAILY_SWAP_BUDGET_USDC — this is the real, code-enforced ceiling
per-sip size$5X402_TREASURY_SIP_USDC
sip frequency1 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)

componentweiUSD
approve 56,240 @ 0.006 gwei337,440,000,000$0.00063
swap+unwrap multicall 165,389 @ 0.006 gwei992,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.

  1. 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 with eth_getCode before the first sweep.
  2. 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_WEI by 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 | jq should show gas_balance: low: … (expected — the wallet is empty) and nothing else wrong.
  3. Confirm the wiring. animica-x402 treasury status prints the policy, the cold address, the contracts and the breaker state, all from the same config the service loaded.
  4. 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. /readyz should now be 200.
  5. Watch the first sweep and the first sip. animica-x402 treasury history shows 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.
  6. 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|reconcile while 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 to X402_TREASURY_MAX_TX_BUMPS times) and warns on /readyz while x402_treasury_unresolved_actions > 0. If it gives up (the fee cap leaves no room to bump), the log line treasury_tx_stuck carries the nonce: stop the unit and replace that exact nonce manually at a higher fee, then start it again and run animica-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)

contractaddress
USDC (FiatTokenProxy)0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
WETH90x4200000000000000000000000000000000000006
UniswapV3Factory0x33128a8fC17869897dcE68Ed026d694621f6FDfD
SwapRouter020x2626664c2603336E57B271c5C0b26F421741e481
QuoterV20x3d4e44Eb1374240CE5F1B871ab261CD16335B76a
USDC/WETH pool, fee 5000xd0b53d9277642d899df5c87a3966a349a798f224

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 runs src/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 — the animica.dev/x402/ location set; replaces the current simple /x402/ location (demo :4656, prefix-stripped) with per-product locations, request-size caps, three limit_req zones (the http-context limit_req_zone lines 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):

  1. Drain: wait for settlements list --status submitting to be empty (or restart the facilitator once — recovery resolves stragglers).
  2. Env: X402_FACILITATOR_MODE=remote, X402_FACILITATOR_URL=<url>; restart the gateway. Offers and products are unchanged.
  3. 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.
  4. 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_disabledANM_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 require extra.name).
  • invalid_transaction_state — replay; sign a fresh nonce.
  • 502 facilitator_unreachable — facilitator down / wrong URL; check 127.0.0.1:8743/healthz and /readyz (its checks object 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-sqlite3 must stay pinned at 12.11.1 on this Node 20 host (13.x is Node-22-only and segfaults here).
  • Catalog available:false — read unavailable_reason. The catalog and the 503 body use different strings on purpose: the catalog says priority_inference_disabled (operator gate off) or insufficient_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_id are 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.