@vaduno/x402
v0.7.1
Published
x402 rail adapter for Vaduno. Governs HTTP 402 stablecoin payments with policy, mandates, and a tamper-evident audit trail. Never signs or holds keys to funds — you plug in your own payer.
Maintainers
Readme
@vaduno/x402
⚠️ Experimental — never run against a real x402 server
This adapter implements x402 v1 and v2 (the HTTP transport). It has never run against a live x402 server or facilitator. The demo and every test mock both the server and the payer in-process — the v2 suite includes the spec's own wire examples as frozen conformance vectors (
spec/vectors/x402-http-v2.json) — so what is verified is that the code agrees with the specification's text and examples, not that it interoperates with anything deployed. Treat it as a reference implementation, exactly like the Stripe adapter's caveat.
Policy + audit for x402 stablecoin payments.
x402 revives HTTP 402 Payment Required: a server answers with a price, your agent pays, and retries. This package wraps that flow so every payment passes a @vaduno/guard spend firewall first, and lands in a tamper-evident audit ledger.
Vaduno never sees your keys. You supply the signer; the guard only decides whether it may run. That holds for both protocol versions — v2 adds no signing on Vaduno's side (the v2 PaymentPayload, and any sign-in-with-x identity challenge, are your payer's to build).
npm install @vaduno/x402 @vaduno/guardUse it like fetch
import { createX402Fetch } from "@vaduno/x402";
const fetchWithPay = createX402Fetch({
guard, // your VadunoGuard
agentId: "researcher-agent-1",
pay: (req) => myWallet.signX402(req), // v1 signer — keys stay yours
v2: {
// v2 is OPT-IN. Without this block, a v2 server (PAYMENT-REQUIRED
// header) is refused with V2_NOT_CONFIGURED and nothing is paid.
pay: (req, ctx) => myWallet.signX402V2(req, ctx),
},
assets: [ // bind spend to the REAL token, not a label
// v1 names and v2 CAIP-2 ids are SEPARATE keys — author both if you serve both.
{ network: "base", asset: "0x833589...2913", symbol: "USDC", decimals: 6 },
{ network: "eip155:8453", asset: "0x833589...2913", symbol: "USDC", decimals: 6 },
],
});
// 402s are paid under policy; everything else passes straight through.
const res = await fetchWithPay("https://api.example.com/premium");Which protocol version runs, exactly
Version routing is per-response, total, and single-carrier:
PAYMENT-REQUIREDheader present on the 402 → v2. The header (base64 JSON, size-capped) is parsed; the response body is never read — so a server cannot present one price in a v1 body and another in the v2 header and have different layers read different carriers. The paid retry sendsPAYMENT-SIGNATURE; settlement is read fromPAYMENT-RESPONSE(including the specced failure form,402+{success: false}).- No header → v1. The JSON body is parsed exactly as before.
x402Versionis checked totally. On the body carrier only absent (back-compat) and the integer1parse;2in a body,"2",1.5,0, negative,null— every one is a named refusal, never coerced. On the header carrier only the integer2parses. Versions above 2 raiseX402VersionUnsupportedError.- v2 without the
v2option is refused (V2_NOT_CONFIGURED), not half-paid: a spend firewall must not silently start paying a protocol version you never configured a signer for.
Rail-specific security notes
These are the sharp edges of x402 specifically — read them before going live:
merchant.urlis the endpoint you actually contacted, not the server'sresourceclaim (per-requirement in v1, body-levelresource.urlin v2), so host allowlists bind where you really connect. A server claiming a different origin than the one reached is refused (RESOURCE_ORIGIN_MISMATCH) beforepay()is called.This will refuse the official x402 reference endpoint, and that is not a bug. Fetching
https://x402.org/protectedreturns a requirement whoseresource.urlishttps://x402.vercel.app/protected— the public domain fronts a Vercel deployment, and the server reports the deployment URL. Verified live 2026-08-05: the refusal fires andpay()is never reached.The check stays strict by default because "the host I paid is the host I asked" is exactly the property it exists to enforce, and a client cannot tell a friendly CDN alias from a hostile redirection of funds. If you have independently satisfied yourself that an alias is legitimate, set
requireResourceOriginMatch: falseand pin the recipient another way — anid:<payTo>merchant pattern binds the address that actually receives the money, which is the thing you care about.Money goes to
payTo, decoupled from the request host — and a host allowlist is now REFUSED, not just discouraged.merchants.allowis disjunctive (an intent passes if any entry matches), so a host-form entry there can only widen the recipient constraint:["host:api.example.com", "id:0x…"]means "this recipient or anyone that host names", and the conjunction is not expressible. SincepayTois what the authorization actually commits to, this adapter refuses a policy carrying a host-formallowentry withRECIPIENT_UNGATEDbefore the payer runs. Measured before the gate:merchants.allow: ["host:api.example.com"]against a server on that host naming an arbitrarypayTopaid, 200 OK. Pin recipients withid:<payTo>entries; put host patterns inmerchants.block(where a match always denies, so disjunction only tightens); or passallowHostOnlyMerchantPolicy: trueto accept host-based allowlisting explicitly. In v2,payTomay be a role constant (e.g."merchant") resolved out of band. Values matching^[a-z]{1,16}$are refused by default (PAYTO_ROLE_REFUSED) because an unresolvable recipient cannot be allowlisted; opt in per role viav2.allowPayToRoles. That is a shape heuristic, not a list of roles —MERCHANT,merchant1andmerchant_walletdo not match it and are treated as addresses. Pin the recipient with anid:<payTo>pattern if you need it constrained rather than merely sniffed.intent.networkcarries the settlement chain — constrain it withpolicy.networks. Currency is not a chain: USDC on Base Sepolia and USDC on Ethereum Sepolia produce the identical intent shape and the same currency code, and before 0.6.1 the network lived only inmetadata, which no policy rule reads. This adapter now setsintent.network(v1: the x402 network name,"base-sepolia"; v2: the CAIP-2 id,"eip155:84532"). For the EVM chains the x402 registry defines,policy.networkscanonicalizes both spellings to one chain via a curated alias table —block: ["eip155:84532"]blocks"base-sepolia"and vice versa, so a seller cannot answer in whichever spelling your list did not name (non-EVM names are not aliased; name both spellings). Theassetsregistry keeps v1 names and CAIP-2 ids as separate keys by design — author v2 entries explicitly. Matching is case-insensitive with no wildcards, and CAIP-2 ids are compared structurally — the id comes from an untrusted seller, soeip155:084532is the same chain aseip155:84532(leading zeros cannot spell around a blocklist) and a colon-bearing id that does not parse as CAIP-2 is denied (NETWORK_UNPARSEABLE), never passed through. A deployment where nothing positively constrains the chain is refused (NETWORK_UNGATED) before the payer runs: on this rail the settlement network is always known, so chain-blindness is never a deliberate configuration. Satisfy the gate with anassetsregistry (recommended — it also pins the token contract), apolicy.networks.allowlist, or the explicitallowChainBlind: true— anetworks.blocklist alone does not satisfy it, because a blocklist admits every chain it does not name. The gate re-reads the guard's live policy, so asetPolicy()that drops the allow list re-closes payment rather than silently reopening the hole.Pass the
assetsregistry. Without it,currencycomes from the server's spoofableextra.symbol. With it, a token that isn't on your list is refused. v2 entries are keyed by CAIP-2 id and matched case-sensitively (asset case-insensitive only oneip155:EVM networks); an entry for v1's"base"does not trust"eip155:8453".Redirects are never followed (
redirect: "manual"), on both versions. A 3xx could otherwise divert the probe — or the paid retry, leaking theX-PAYMENT/PAYMENT-SIGNATUREbearer — to another origin.Spend is counted from the moment the guard authorizes it, and a failure never gives it back. Once the payment header is transmitted it is a bearer authorization the server can still settle while returning an error — but the accounting is stricter than that: ANY thrown executor keeps the spend counted, including a signer that failed before sending and a transport that died mid-request, because a throw cannot distinguish "never sent" from "sent, then the connection died". Over-hold, never overspend. If you can PROVE the rail did not charge,
guard.releaseSpend(intentId)reclaims it explicitly; this adapter never calls it for you. Under v2'suptoscheme the counted amount is the authorized maximum (what your signature permits); the smaller settled amount an untrustedPAYMENT-RESPONSEmay later report is never reconciled downward. Bind a consume-once mandate (maxUses) to bound retries.The v2 schemes this adapter was built against carry no reusable authorizations.
exactis single-use by EIP-3009 nonce anduptosettles at most once, so per-authorization counting matches them exactly; sign-in-with-x re-access is a server-side identity grace where no payment occurs. Abatch-settlementscheme also exists in the spec tree — a signed running total redeemed at session end — and was not analysed for this work. Counting stays conservative under it rather than complete: each transmitted signature is counted at its stated amount, so a batch would be over-counted, never under. Read this as the schemes examined, not as a claim about every scheme the spec may define.Untrusted input is bounded on every path. v1: the 402 body is byte-capped (64 KB, counted as received, abort at the cap). v2: the
PAYMENT-REQUIREDheader is size-capped before decoding,extensions(which your payer must echo) are structurally validated, size-capped, and defensively copied, and the 402 body is not read at all.acceptsis capped on both.extrais validated by key and type and defensively copied on both.What is validated is what is paid. The requirement handed to your
pay()/v2.pay()is the same fixed-allowlist object the guard policed — unknown keys from the wire are dropped, and v2 requirements carrying v1 money/binding fields (maxAmountRequired, per-requirementresource) are refused as mixed-version shapes.
Errors
| Thrown | Meaning |
|---|---|
| X402ProtocolError | The 402 response was malformed, over a size cap, wrong-typed, or version-confused — no money moved |
| X402VersionUnsupportedError | The server declared a version this adapter refuses on that carrier (v3+, or v2 declared inside a JSON body), or sent v2-SHAPED data with no version declared at all — the undeclared arm is what turns a misleading field error into a named version error — no money moved |
| X402RequirementRefusedError | Refused before paying (V2_NOT_CONFIGURED, PAYTO_ROLE_REFUSED, ASSET_NOT_ALLOWED, RESOURCE_ORIGIN_MISMATCH, NO_REQUIREMENT_SELECTED, NETWORK_UNGATED, RECIPIENT_UNGATED) — no money moved |
| X402PaymentBlockedError | Policy / mandate / freeze blocked it — no money moved |
| X402PaymentFailedError | Payer ran; check .transmitted — if true, spend is counted because the server may still settle |
Security
Read SECURITY.md for the full threat model and known limitations.
License
MIT
