The $1 Default: Auditing x402's Client-Side Spend Controls
As of version 2.23.0, every x402 client built on the official SDKs refuses to pay more than one dollar per request unless someone explicitly raises the limit. We read the filter's source in all three languages, drove eleven scenarios against the published npm package, and mapped exactly where the new guardrail protects an agent — and where it quietly does not.
In last week's roundup we covered the announcement: spend controls landed in the TypeScript SDK on August 13 (PR #3124, 146 files, +3,795/−1,317, author phdargen), followed by Python (#3154) and Go (#3156) on August 18, merged eleven minutes apart. This post is the code-level audit. We cloned x402-foundation/x402 at HEAD 6557149b (August 24, 2026) and installed @x402/core 2.23.0 and @x402/evm 2.23.0 from npm — the versions any agent building today gets. The stakes are not small: @x402/core recorded 889,128 npm downloads in the thirty days ending August 23.
What shipped, and where it sits
The mechanism is a filter inside selectPaymentRequirements, the client routine that picks which of a server's payment offers to sign. The pipeline in x402Client now has six steps: filter by registered schemes, drop unrecognized paymentFlow values, enforce spend controls, apply user policies, prefer authorization-flow offers, then run the selector. Two placement details matter.
First, spend controls run before user-defined policies. A policy cannot see an offer the spend filter already discarded. Second, the filter is deliberately permissive with mixed offers: the code comment reads "Keeps any accept that fits so a mixed offer can still pay the affordable option." If a server offers the same resource at $5 and at $0.50, the client does not error — it silently signs the $0.50 option. We confirmed this in our drive: given both accepts, the SDK produced an EIP-3009 authorization for exactly 500000 atomic USDC units.
The default is a named constant, identical in the three codebases: DEFAULT_MAX_AMOUNT_PER_PAYMENT = "$1" in TypeScript (x402Client.ts), Python (client_base.py) and Go (types.go). Go's Newx402Client constructs with spendControlsEnabled: true; TypeScript and Python default the controls object to {}, which resolves to the same behavior. Nobody opts in. Everybody is in.
That was contentious in review. On PR #3124, reviewer CarsonRoscoe flagged that "upgrading silently caps every existing client at $1/payment and default-asset-only" — a behavior change riding a minor version bump. The author confirmed it was "the intended behaviour," and mitigated by adding explicit spendControls to the basic examples. The judgment call is defensible: a default that fails closed is the right polarity for autonomous buyers. But operators should be clear-eyed that 2.22.x to 2.23.0 is a semantic break for any client that was paying more than a dollar. The failure mode is an exception thrown from createPaymentPayload with a new error string, not a 402 retry.
What counts as a recognized stablecoin
The cap does not apply to all assets. It applies only to assets the SDK can identify as USD-pegged, via a new per-mechanism reverse lookup, findDefaultAsset(asset, network). Every mechanism package now ships a DEFAULT_ASSETS table keyed by CAIP-2 network. In TypeScript, all eleven chain families carry one: EVM, SVM, Aptos, Algorand, Concordium, Hedera, Keeta, NEAR, Stellar, TVM and XRPL. The EVM table lists 24 networks; Solana mainnet lists five entries (USDC, USDT, USDG, PYUSD, CASH — the latter three under Token-2022); XRPL ships RLUSD with the issuer pinned in the client scheme before signing; Concordium ships USDR. Python covers EVM, SVM and TVM; Go covers EVM and SVM — matching each SDK's mechanism coverage, with the 24-network EVM set identical across all three.
The table is live infrastructure, not documentation. The commit at HEAD when we cloned — merged the very morning of this audit — was #3227, adding Sei mainnet USDC (eip155:1329) and testnet to all three SDKs. Getting a token into DEFAULT_ASSETS is now the difference between "agents pay you by default" and "agents reject you by default." Expect that file to become one of the most politically interesting in the repository.
findDefaultAsset recognizes. Tokens the SDK knows nothing about are rejected outright by default, but once opted in via allowedAssets without a per-entry cap, they are uncapped. The better the SDK understands an asset, the tighter it holds the leash; the more exotic the asset, the less protection the numeric cap provides.
Driving the filter: eleven cases
Reading code tells you what should happen. We prefer to watch it happen. EIP-3009 signing is fully offline — the SDK signs a typed-data authorization without touching a chain — so a throwaway key exercises the entire path against the real published package. Eleven scenarios against @x402/core 2.23.0, Base mainnet requirements, exact scheme:
// @x402/core 2.23.0 + @x402/evm 2.23.0 from npm, 2026-08-24
A default / USDC $1.00 → ACCEPTED (signed, 1000000 atomic)
B default / USDC $1.000001 → REJECTED (spendControls.maxAmountPerPayment $1)
C default / DAI $0.01 → REJECTED (only default assets allowed)
D allowlist DAI, no cap / 1M DAI → ACCEPTED — uncapped
E allowedAssets: true / 1M DAI → ACCEPTED — uncapped
F allowedAssets: true / USDC $5 → REJECTED ($1 cap still binds defaults)
G per-asset cap "$2" → config error: must be atomic integer
H spendControls: false / $250 → ACCEPTED
I cap $0.05 / USDC $0.10 → REJECTED
J mixed offer $5 + $0.50 → ACCEPTED — signs the $0.50 option
K symbol "USDC", cap 2000000 / $1.50 → ACCEPTED (per-asset cap overrides $1)
Every case matched the source. The boundary is inclusive: exactly $1.00 signs, one millionth of a dollar more rejects. Case K shows the escape hatch done right — an allowedAssets entry can reference a default asset by symbol and raise its cap with an integer atomic amount, per token, without touching the global limit. Case G shows the SDK refusing a dollar-string per-asset cap as a configuration error rather than guessing decimals, which is correct: per-asset caps are denominated in atomic units precisely because the SDK may not know the token's decimals.
Cases D, E and F together are the finding. With allowedAssets: true — the one-liner every frustrated integrator will reach for — a payment of one million DAI sails through while a five-dollar USDC payment is still blocked. The asymmetry is principled (the SDK cannot convert an unknown token to USD, so it cannot cap it), but the operational result is that the loudest guardrail guards the safest assets. An agent whose operator "fixed" a rejection with allowedAssets: true is one prompt injection away from signing an arbitrary-value authorization in an arbitrary token, while its USDC spend stays politely capped at $1.
The fine print of the algorithm
Four details in the implementation deserve attention from anyone building against it.
Version 1 is covered. The filter reads amount on v2 requirements and maxAmountRequired on v1, and all three SDKs apply spend controls on both selection paths. Legacy facilitator flows do not bypass the cap.
Decimal amounts get a parallel path. Most x402 amounts are atomic-unit integer strings, but some ledgers quote decimals — an XRPL RLUSD offer can carry "0.01". The filter detects the non-integer form and compares it against the dollar cap at one-to-one parity, scaling both sides to eighteen decimals. That works only because DEFAULT_ASSETS enforces a USD-peg invariant — the repository's documentation is explicit that adding a EUR or JPY entry would require per-currency caps that do not exist yet. On the per-asset-cap path the same decimal form is simply dropped, since an integer atomic cap cannot be compared to a decimal ledger amount without decimals metadata; the changelog states it plainly: "a non-integer 402 amount on that path is dropped."
Custom schemes are guilty until integrated. findDefaultAsset is an optional method on the scheme-client interface. A community mechanism that predates 2.23.0 and never implemented it returns no default assets, which means every one of its offers is now "non-default" — rejected under default configuration until users allowlist its tokens or the mechanism ships a table. The same release also hardened the forward direction: a dollar-string settlement override now throws when getAssetDecimals does not know the asset, instead of guessing six decimals and silently mispricing an eighteen-decimal token by twelve orders of magnitude.
The errors teach the fix. Every rejection string enumerates its own escape hatches — "Raise maxAmountPerPayment, set it to false to disable, set allowedAssets[].maxAmountPerPayment for a per-asset atomic cap, or set spendControls: false to disable all spend controls." Developer-friendly, and worth a second thought: these strings surface in agent-facing exception traces, which means an LLM operating its own payment loop reads a menu of ways to remove its own guardrail. Whether the agent can act on that menu depends entirely on who controls the client configuration — which is exactly where operators should draw the line.
What spend controls are not
Three boundaries define this feature more than its code does.
It is not protocol. The x402 v2 specification mentions the entire concept once, in section 12.1, as "budget management and spending controls (implementation-specific)." Nothing on the wire changed: no headers, no fields, no facilitator involvement. A server cannot detect whether the buyer runs spend controls, and a facilitator cannot enforce them. This is a property of three particular SDKs, not of x402. The fourth SDK proves it: x402-rs, the Rust implementation we audited earlier this month, has no equivalent — its x402-reqwest client ships a FirstMatch selector and zero amount checks at HEAD e75adda. A Rust agent pays whatever the first matching offer demands.
It is not a budget. The cap is per-payment. There is no cumulative counter, no session limit, no daily ceiling anywhere in the implementation. An agent capped at $1 per payment can make ten thousand $1 payments. For metered work, the upto scheme bounds a whole flow with an on-chain escrowed maximum — a chain-enforced budget where spend controls are a process-local one. The two compose; neither replaces the other.
It is not enforcement. The check runs in the buyer's own process, on the buyer's side of the trust boundary. It protects an honest operator from overpriced servers, fat-fingered configs and manipulated agents. It protects no one from a modified client. That is the correct scope — the buyer's wallet key is the real authority, and anything the key holder runs can sign anything — but it means "x402 now has spend controls" should be read as "the official buyer stacks now have a seatbelt," not as a settlement-layer guarantee. The wallet-layer budgets emerging around the protocol — AWS AgentCore's infrastructure-level limits, Cloudflare's Virtual Wallet allowances — sit on the other side of that boundary, and remain necessary.
Friction in the wild
Eleven days after the TypeScript merge, the ecosystem is already teaching users to loosen the strap. Venice AI's agent-wallet guide states it plainly: "The SDK ships with max_amount_per_payment set to one dollar, and the Venice minimum top-up is five, so an unmodified client rejects every rail on offer." Their documented fix — SpendControls(max_amount_per_payment="$5", allowed_assets=True) — raises the cap and opens the asset filter in a single line, inheriting the case-E behavior above. This is the pattern to watch: defaults this strict generate copy-paste fixes, and the easiest fix is the widest one.
Inside the monorepo itself the exceptions are instructive. The browser paywall package constructs its client with spendControls: false — reasonable, because a human clicks approve, and the human is the spend control. The MCP client forwards spendControls through from_config, so tool-calling agents get the same defaults as HTTP agents. And one documentation drift worth reporting: the @x402/evm README references a class named ExactEvmClient fourteen times; the package exports ExactEvmScheme and no such class exists. The README published on npm is byte-identical to the repo's. Every code sample in the package's own front door fails to import.
What it means for LLM4Agents
LLM4Agents operates the buyer stack for fleets of agents paying per inference over an OpenAI-compatible gateway, so this change lands directly in our threat model — mostly as validation, partly as work.
Validation, because the defense-in-depth ordering we described in our billing internals assumed the SDK layer was the weakest link: platform-side reserve accounting, not client-side checks, is what actually bounds an agent's spend on our gateway. That remains true — spend controls do not add a cumulative budget, so the platform ledger is still the only place a fleet-wide ceiling exists. Work, because defaults propagate: any agent using the official SDKs against our x402-priced endpoints will now reject responses priced above $1 unless configured otherwise. Endpoints priced for long-context frontier calls can cross that line. We either keep per-request prices under the default cap, document the exact allowedAssets-with-atomic-cap configuration (never allowedAssets: true), or split expensive calls onto the upto scheme where the escrowed maximum makes the per-payment cap moot.
The DEFAULT_ASSETS table is also now a dependency. We settle in USDC on recognized networks, so we sit inside the allowlist today — but the table changes weekly (Sei landed the morning of this audit), Go and Python still lag TypeScript's family coverage, and any future settlement asset we add must be evaluated against it first. A token outside the table is a token the default agent cannot pay.
Staying on the frontier
Concrete steps, in order. First, pin and test: our SDK integrations move to 2.23.0-class versions behind our conformance suite, with explicit spendControls in every example we publish — the silent default should never be load-bearing in our docs. Second, price-audit the catalog: enumerate every x402-priced route we expose and flag any whose worst-case price exceeds $1, then either re-price, document the narrow opt-in, or migrate to upto. Third, ship the budget the SDK does not have: a per-agent cumulative spend ceiling enforced platform-side, exposed in the dashboard next to the per-payment cap the client already enforces, so operators see both numbers and understand they are different things. Fourth, publish a hardening guide that names the anti-pattern: allowedAssets: true and spendControls: false are debugging tools, not configuration. Fifth, watch the table: subscribe to DEFAULT_ASSETS changes in CI the way we already watch /supported drift on facilitators, because a new entry there changes what every default-configured agent on the internet will pay for — and a removed one changes what it refuses.
The deeper read is strategic. The payments layer keeps adding brakes — four of five changes we covered last week were restrictions — and each brake becomes a default that shapes agent behavior at population scale. A one-line constant in three SDKs now decides that the autonomous economy's unit of casual spending is one dollar. Whoever operates agents professionally needs to know exactly where that constant binds, where it does not, and what to build in the gap. That gap — cumulative budgets, asset-risk-aware caps, fleet-level policy — is where platforms earn their keep.
Run agents that pay within limits you set
LLM4Agents gives every agent a funded identity, per-use stablecoin billing, and platform-side spend ceilings the SDK cannot provide.
Register your agent