为付费 API Agent 强制设置支出上限
AgentBadge 的支付客户端强制要求提供支付限制,缺失配置即抛错。示例展示了单次支付上限、网络白名单和会话预算如何在签名前拦截支付,避免重试循环持续消耗余额。
AgentBadge 的支付客户端强制要求提供支付限制,缺失配置即抛错。示例展示了单次支付上限、网络白名单和会话预算如何在签名前拦截支付,避免重试循环持续消耗余额。
本文最初发表于 AgentBadge 博客。
2026 年 10 月 10 日,我们用自家的 agentbadge pay CLI 请求自家的生产 API,结果它拒绝向我们付款。一共三次,原因各不相同:一次触发单次调用限额,一次触发网络白名单限制,一次触发会话预算限制。每次拒绝都明确指出了具体字段及其值,每次都发生在生成签名之前。钱包余额一分钱都没动。这不是 bug 报告,而是功能正常工作的证明。本文就来讲讲它是怎么做到的。
一个在循环中调用付费 API 的 Agent,就像一个拿着信用卡的小程序。如果 API 每次重试都返回 402,或者 Agent 误读了发生变化的价格,循环就会不断签署付款,直到钱包被掏空。问题通常不在支付通道,而在于没有设置支出上限。因此,我们在构建 @agentbadge/circle-payments 的买方功能时,确立了一个前提:没有限额的付款客户端是 bug,不该成为默认行为。这里没有不限额模式:只要省略 PaymentCaps,createPayingClient 就会立即抛出 TypeError,此时进程还没有发出任何请求。

因为只要限额可以忘记设置,你就总会忘记。付款客户端在构造时要求提供 {maxPaymentUsd, sessionBudgetUsd},例如 {maxPaymentUsd: "0.50", sessionBudgetUsd: "5"}。省略它们就会立即失败:TypeError: PaymentCaps required — refusing to create an uncapped paying client。限额使用表示美元金额的十进制字符串,通过正则表达式校验;所有金额计算都使用 BigInt,以 6 位小数对应的最小单位进行,绝不使用浮点数。可选的 allowedNetworks 数组用于限定签名方可以操作哪些链,默认允许 Arc mainnet(eip155:5042)和 Arc testnet(eip155:5042002),这项限制会在其他任何操作之前执行。

凡是不符合契约的内容,都会拒绝,而且会明确指出拒绝了什么。每个 accepts[] 条目都必须通过五项检查:x402Version === 2;scheme ∈ {exact, eip3009-client-broadcast, gateway-batch};network ∈ 你的白名单;asset === 对应链的 USDC 地址;amount ≤ 单次调用限额。每次拒绝都会返回 {ok:false, reason, field, value},让 Agent 可以记录究竟是哪个字段导致付款被拒绝。
其中最严格的一条规则针对 extra.decimals。如果付款要求声明的小数位数不是 6,客户端会直接拒绝,而不是猜测。金额精度弄错,比如把 6 位小数当成 18 位,是代币支付中一个典型的坑。安全的处理方式是拒绝,而不是推断。
这些不是演示性质的测试。10 月 10 日对生产环境的实测产生了三次真实拒绝,每次都发生在签名之前:
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)

客户端通过 SpendTracker 累计跟踪当前会话的支出,并且会在签名之前预留预算:validateAccepts → tracker.tryReserve(amount) → 只有完成这两步,签名方才会收到 typed-data payload。超过单次限额,就抛出 CapExceededError {cap, requested};超过会话预算,就抛出 BudgetExhaustedError {spent, budget, requested}。这两种情况下都不会调用 signTypedData。我们的限额拒付测试断言的正是这一点:钱包 mock 的签名调用次数为零。
支出会在资金实际提交时记录:通过 facilitator 的支付通道在 settle 时记录,自行结算则在 broadcast 时记录。onSpend(entry) hook 会将每个事件持久化到你自己的存储中。
同一条执行路径上还有两道叠加的保护:防循环机制会在携带 payment-signature 的重试请求再次收到 402 时抛出 PaymentNotAcceptedError,确保同一个请求绝不付款两次;paginateAll 则将每一页都视为一次付款,计入同一份预算。
签名方式取决于 scheme,而且签名不等于支出。exact → EIP-3009 transferWithAuthorization 签名;facilitator 稍后才会结算,因此仅有签名不会转移任何资金。eip3009-client-broadcast(Arc 自行结算)→ 客户端自行广播 USDC 转账;如果声明了 extra.payerBinding,重试请求会携带 X-Wallet/X-Sig/X-Timestamp(EIP-191,agentbadge-pay:v1)。gateway-batch → BatchEvmScheme payload,可选择启用 gateway.autoDepositUsd,为 Gateway 钱包补充余额,避免在 settle 时因余额不足而失败。
10 月 10 日的实测用数字展示了签名与支出的区别:三次付费尝试都在 payment-signature 被接受后遇到了服务器 500 错误,facilitator 始终没有广播交易,付款方余额只在另外一次成功结算时发生了变化。失败的尝试产生了三次签名,链上结算次数却是零。即使服务器出了问题,支出上限依然有效。
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
在 shell 中,agentbadge pay 同样强制执行限额规则。限额可以通过参数(--max-payment、--budget)或环境变量(AGENTBADGE_MAX_PAYMENT、AGENTBADGE_BUDGET)设置;私钥只能通过环境变量(AGENTBADGE_PAYER_KEY)提供,绝不能通过命令行参数传入,因为 ps 可以看到 argv。退出码:0 表示成功,2 表示拒付、触发限额或预算限制,3 表示传输错误。
--print-only 是买方的冒烟测试:它会解码 402、校验 accepts、打印付款详情,然后退出,全程不会接触签名方。以下是 10 月 10 日请求生产环境时的真实输出:
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"}

可以。客户端校验的 402 响应体,本身就是机器可读 API 契约的一部分。服务器提交到代码仓库中的 openapi.yaml 包含 x402 组件(X402PaymentRequired、X402PaymentRequirement、X402HonestRefusal),其中将 x402Version: 2、scheme 枚举、extra.decimals: 6 和 charged: false 固定为契约中的常量约束;如果规范与实际路由出现偏差,CI 的 drift-check 就会失败。通信双方验证的是同一份契约。
我们自己使用这套工具时,也被它抓到了问题。在同一次 10 月 10 日的实测中,我们的服务目录声明的是 eip155:5042002(Arc testnet),但实际的 402 响应只提供 Base Sepolia(eip155:84532)。默认只允许 Arc 的白名单拒绝了这项付款选项,直到我们显式传入 --network。我们自己的校验器发现了自家服务目录的偏差,这正是我们希望校验器触发的那类失败。
本文是 Arc Campaign 系列的 C18。上一篇:《拒绝就绝不收费——可由机器检查的拒付契约》。
相关链接:服务目录 · openapi.yaml · llms.txt · @agentbadge/circle-payments · AgentBadge
别只做认证,要实际测量。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。