[KongchangAI]
· 3 min read· 1,565 words

Mandatory Caps for x402 Buyer Agents: Making Paying Clients Unable to Overspend

Mandatory Caps for x402 Buyer Agents: Making Paying Clients Unable to Overspend

AgentBadge makes paying agents carry mandatory caps, blocking overspending before signing.

In @agentbadge/circle-payments, AgentBadge treats an uncapped paying client as a bug: createPayingClient throws a TypeError without PaymentCaps. It mandates maxPaymentUsd and sessionBudgetUsd, uses BigInt in 6-decimal atomic units, and restricts chains via allowedNetworks. Accepts entries pass five checks; budgets are reserved before signing, triggering CapExceededError or BudgetExhaustedError while signTypedData is never called.

Title

x402 Buyer Agent Mandatory Caps: Making Paying Clients Unable to Overspend

Meta Description

AgentBadge introduces mandatory payment caps for x402 buyer agents. Per-call limits and session budgets are enforced before signing, rejecting unauthorized payments and preventing agent loops from overspending.

Article Content

When an AI agent calls a paid API in a loop, it's like a small program holding a credit card. If the API returns 402 on every retry, or the agent misreads a fluctuating price, the loop will keep signing payments until the wallet is emptied. The problem rarely lies in the payment channel itself—it's the absence of a cap. AgentBadge establishes a premise in the buyer-side design of @agentbadge/circle-payments: a paying client without caps is a bug, not a default behavior. There is no uncapped mode—createPayingClient throws a TypeError the moment you omit PaymentCaps, and this happens before any request leaves the process.

rss source: A Paying Client That Cannot Overspend: Mandatory Caps for x402 Buyer Agents

Why Are Spending Caps Mandatory Rather Than Optional?

Because a cap you can forget is a cap you will eventually forget. The paying client must provide {maxPaymentUsd, sessionBudgetUsd} at construction time—for example, {maxPaymentUsd: "0.50", sessionBudgetUsd: "5"}. Omitting them fails fast: TypeError: PaymentCaps required — refusing to create an uncapped paying client. Caps are USD decimal strings validated by regex; all amount calculations use BigInt based on atomic units with 6 decimal places, never floating point. The optional allowedNetworks array pins down which chains the signer can touch—defaulting to Arc mainnet (eip155:5042) and Arc testnet (eip155:5042002), enforced before anything else happens.

The x402 protocol is the underlying payment standard here: it reuses HTTP's 402 Payment Required status code to let servers declare directly in the response "how much this call costs, what asset to use, and which chain to settle on." The client interprets the 402 response body, follows its instructions to complete signing and retry, thereby turning "paying" into an ordinary HTTP round-trip. accepts[] is the list of acceptable payment methods provided by the server—each entry describes a combination of "scheme + network + asset + amount." Precisely because the 402 response is a server-controlled input, agents must rigorously validate it before signing: once the amount or chain ID within is blindly trusted, an attacker (or a buggy server) can induce the agent to overspend or pay incorrectly. AgentBadge front-loads caps and allowlists into the construction phase, essentially rewriting x402's "trust the server's quote" model into "self-verify first, then pay."

What Does Strict accepts Validation Reject?

Everything that doesn't conform to the contract—and it points out the specific rejected item. Every accepts[] entry must pass five checks: x402Version === 2; scheme is one of {exact, eip3009-client-broadcast, gateway-batch}; network is in your allowlist; asset equals the USDC address for that chain; amount is less than or equal to the per-call cap. Each rejection returns {ok:false, reason, field, value} so the agent can log which field caused the payment failure.

The strictest rule is extra.decimals. If a payment requirement declares a decimal count other than 6, the client rejects it outright rather than guessing—a mis-scaled amount (the difference between 6 and 18 decimals) is the classic pitfall in token payments. The safe answer is to reject, not infer.

These are not toy tests. Runs against production produced three live rejections, all occurring before any signing:

refused field=amount value="300000": amount 300000 exceeds per-call cap 10000 (atomic units)
refused field=network value="eip155:84532": network "eip155:84532" not in allowedNetworks
refused: Session budget exhausted: spent 0 + requested 300000 > budget 10000 (atomic units)

How Do Session Budgets Stop Runaway Loops?

The client maintains a cumulative SpendTracker for the session—and reservation happens before signing: validateAccepts → tracker.tryReserve(amount) → only then does the signer see the typed data payload. Exceeding the per-call cap triggers CapExceededError {cap, requested}; exceeding the budget triggers BudgetExhaustedError {spent, budget, requested}—and signTypedData is never called. Our cap-rejection test asserts precisely this: zero signing calls on the wallet mock.

Spend is recorded when funds are actually committed—at settlement for facilitator channels, at broadcast for self-settled ones—and the onSpend(entry) hook persists each event to whatever storage you own.

Two additional layers of protection stack on the same path: the loop guard throws PaymentNotAcceptedError when a retry carrying payment-signature returns 402 again—never paying twice for the same request; and paginateAll counts every page as a payment against the same budget.

The separation of reservation (reserve) and settlement (settle) is the core mechanism of this design. A "reservation" first counts the current amount into the total spent in the in-memory SpendTracker, with no cryptographic action yet; if signing or broadcasting subsequently fails, the reservation must be released or rolled back accordingly. The benefit of this is that budget decisions happen before the most expensive and irreversible steps (signing, broadcasting), so even if multiple concurrent requests arrive simultaneously, none will individually think "the budget is still sufficient." CapExceededError corresponds to the per-call cap, BudgetExhaustedError to the session total, and their carried fields {cap, requested} and {spent, budget, requested} let upstream precisely record which layer blocked it. The fact that signTypedData is never called is the key assertion for determining whether the interception is truly "side-effect free"—it means the private key was not involved and wallet state was not changed.

What Does the Agent Sign—and When Does Money Move?

Signing varies by scheme—and signing does not equal spending. exact → EIP-3009 transferWithAuthorization signature; the facilitator settles later, so the signature alone does not move funds. eip3009-client-broadcast (Arc self-settlement) → the client broadcasts the USDC transfer itself; when extra.payerBinding is declared, the retry carries X-Wallet/X-Sig/X-Timestamp (EIP-191, agentbadge-pay:v1). gateway-batch → BatchEvmScheme payload, with optional auto-deposit to the Gateway wallet at settlement via gateway.autoDepositUsd, rather than failing outright.

Real runs gave numbers for the gap between signing and spending: three paid attempts triggered a server 500 after the payment-signature was accepted, the facilitator never broadcast, and the payer balance only changed on another successful settlement. Three signatures, zero on-chain settlements on failed attempts—even when the server had issues, the caps held.

EIP-3009 transferWithAuthorization is the "authorized transfer" standard designed on Ethereum for tokens like USDC: the payer signs a piece of structured data offline (containing from, to, value, validAfter, validBefore, nonce), and any third party holding this signature can submit it on-chain within the validity period to complete the transfer. Its value lies in splitting "signing" and "broadcasting" into two steps—the payer doesn't need to send the transaction themselves or hold native tokens to pay gas, and the facilitator can settle on their behalf and batch submit. This is precisely the origin of "signing does not equal spending" for the exact scheme in this article: a signature is only a redeemable voucher, and funds move only when the facilitator actually broadcasts and confirms on-chain. Understanding this is necessary to see why the client can calmly perform cap validation before signing, and why the payer balance does not change when a server 500 causes the facilitator to never broadcast.

How Do You Run It—and Verify Before Signing?

const client = createPayingClient({
  caps: { maxPaymentUsd: "0.50", sessionBudgetUsd: "5" },
  signer: viemSigner(walletClient),
});
await client.get("https://agentbadge.xyz/api/total-scan");
// 402 → validateAccepts → cap-check → sign → retry → spend recorded

From the shell, agentbadge pay enforces the same cap discipline. Caps come from flags (--max-payment, --budget) or environment variables (AGENTBADGE_MAX_PAYMENT, AGENTBADGE_BUDGET); secrets can only come from environment variables (AGENTBADGE_PAYER_KEY)—never flags, since argv is visible in ps. Exit codes: 0 success · 2 rejected/cap/budget · 3 transport error.

--print-only is the buyer's smoke test—it decodes the 402, validates accepts, prints payment details, then exits without touching the signer at all. Real output against production:

agentbadge pay https://agentbadge.xyz/api/total-scan \
  --max-payment 0.50 --budget 5 --network eip155:84532 --print-only
# {"scheme":"exact","network":"eip155:84532","asset":"0x036CbD53…",
#  "amountAtomic":"300000","priceUsd":"0.3","payTo":"0x9a66…95A5"}

Can the Client Verify the Server's Contract Before Paying?

Yes—the client-validated 402 response body is part of the machine-readable API contract. The server-committed openapi.yaml includes x402 components (X402PaymentRequired, X402PaymentRequirement, X402HonestRefusal) and pins x402Version: 2, the scheme enum, extra.decimals: 6, and charged: false as constants; if the spec drifts from live routes, the CI drift check fails. The same contract is validated on both ends of the communication.

Eating our own dog food also caught our own issues. In the same run, our directory declared eip155:5042002 (Arc testnet), while the live 402 only offered Base Sepolia (eip155:84532)—the default Arc-only allowlist rejected the quote until we explicitly passed --network. Our own validator flagged our own directory drift, which is exactly the kind of failure you want your validator to cause.

This article is C18 in the Arc Campaign series.

One-line Summary

AgentBadge makes paying agents carry mandatory caps, blocking overspending before signing.

Paragraph Summary

In the buyer-side design of @agentbadge/circle-payments, AgentBadge defines "an uncapped paying client" as a bug: createPayingClient throws a TypeError before any request leaves the process if PaymentCaps are missing. The client mandates maxPaymentUsd and sessionBudgetUsd, computes amounts as BigInt in 6-decimal atomic units, and restricts touchable chains via allowedNetworks. All accepts[] entries must pass five checks, with rejections returning the specific field and value; a non-6 extra.decimals is rejected outright rather than guessed. Session budgets are reserved before signing, with overages triggering CapExceededError or BudgetExhaustedError, and signTypedData is never called. Signing and on-chain settlement are separated, so caps hold even during server failures. --print-only lets you verify the 402 and payment details before any real signing."

#x402#buyer agents#payment caps#AgentBadge#session budget#EIP-3009#AI agent payments
Share:

Related articles