x402 买家代理强制限额:让付费客户端无法超支

AgentBadge 让付费代理必须携带强制限额,在签名前拦住超额支付。
AgentBadge 在 `@agentbadge/circle-payments` 的买家侧设计中,把「无上限的付费客户端」定义为 bug:`createPayingClient` 若缺少 `PaymentCaps`,会在任何请求离开进程前抛出 `TypeError`。客户端强制要求 `maxPaymentUsd` 与 `sessionBudgetUsd`,金额以 6 位小数原子单位的 BigInt 计算,并通过 `allowedNetworks` 限定可触碰的链。所有 `accepts[]` 条目要过五项校验,拒绝时返回具体字段与值,`extra.decimals` 非 6 会直接拒绝而非猜测。会话预算在签名前预留,超额触发 `CapExceededError` 或 `BudgetExhaustedError`,`signTypedData` 从不被调用。签名与链上结算分离,限额在服务器故障时依然守住。`--print-only` 则允许在真实签名前验证 402 与支付详情。
AI 代理在循环中调用付费 API 时,就像一个手持信用卡的小程序。如果 API 在每次重试时都返回 402,或者代理误读了一个变动的价格,循环就会不断签名支付,直到钱包被清空。问题很少出在支付通道本身,而是缺少一个上限。AgentBadge 在 @agentbadge/circle-payments 的买家侧设计中确立了一个前提:没有限额的付费客户端是一个 bug,而不是默认行为。没有无上限模式——createPayingClient 在你省略 PaymentCaps 的那一刻就会抛出 TypeError,这一切发生在任何请求离开进程之前。

为什么消费限额是强制而非可选的?
因为一个你能忘记的限额,就是一个你终将忘记的限额。付费客户端在构造时就必须提供 {maxPaymentUsd, sessionBudgetUsd}——例如 {maxPaymentUsd: "0.50", sessionBudgetUsd: "5"}。省略它们会快速失败:TypeError: PaymentCaps required — refusing to create an uncapped paying client。限额是经过正则验证的 USD 十进制字符串;所有金额计算都基于 6 位小数的原子单位使用 BigInt,绝不使用浮点数。可选的 allowedNetworks 数组固定签名者可以触碰哪些链——默认是 Arc 主网(eip155:5042)和 Arc 测试网(eip155:5042002),并在其他任何事情发生之前强制执行。
x402 协议是这里的底层支付标准:它复用 HTTP 的 402 Payment Required 状态码,让服务器在响应中直接声明「这次调用需要支付多少钱、用什么资产、结算到哪条链」。客户端解读 402 响应体,按其指示完成签名与重试,从而把「付费」变成一次普通的 HTTP 往返。accepts[] 就是服务器给出的可接受支付方式列表——一条条目描述一种「方案 + 网络 + 资产 + 金额」的组合。正因为 402 响应是服务器可控的输入,代理必须在签名前对它做严格校验:一旦盲信其中的金额或链 ID,攻击者(或一个有 bug 的服务器)就能诱导代理超额或错误地付款。AgentBadge 把上限与允许列表前置到构造阶段,本质上是把 x402 的「信任服务器报价」模型改写为「先自证,再付款」。
严格的 accepts 验证会拒绝什么?
所有不符合合约的内容——并且它会指出被拒绝的具体项。每个 accepts[] 条目必须通过五项检查:x402Version === 2;scheme 属于 {exact, eip3009-client-broadcast, gateway-batch};network 在你的允许列表中;asset 等于该链的 USDC 地址;amount 小于等于单次调用上限。每次拒绝都返回 {ok:false, reason, field, value},这样代理就能记录是哪个字段导致了支付失败。
最严格的规则是 extra.decimals。如果一个支付要求声明的小数位数不是 6,客户端会直接拒绝而不是猜测——一个缩放错误的金额(6 位与 18 位小数之差)是代币支付中的经典陷阱。安全的答案是拒绝,而不是推断。
这些不是玩具测试。针对生产环境的运行产生了三次实时拒绝,且都发生在任何签名之前:
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) → 然后签名者才会看到类型化数据负载。超过单次上限会触发 CapExceededError {cap, requested};超过预算会触发 BudgetExhaustedError {spent, budget, requested}——并且 signTypedData 永远不会被调用。我们的上限拒绝测试正是这样断言的:钱包模拟上零次签名调用。
支出在资金实际提交时记录——对于 facilitator 通道是在结算时,对于自行结算是在广播时——并且 onSpend(entry) 钩子会将每个事件持久化到你拥有的任何存储中。
同一条路径上还叠加了两层保护:防循环守卫会在携带 payment-signature 的重试请求再次返回 402 时抛出 PaymentNotAcceptedError——绝不为同一个请求支付两次;而 paginateAll 将每一页都视为一次支付,计入同一预算。
预留(reserve)与结算(settle)分离是这段设计的核心机制。所谓「预留」,是在内存中的 SpendTracker 里先把本次金额计入已花费总额,此时还没有任何密码学动作;如果后续签名或广播失败,预留需要被相应地释放或回滚。这样做的好处是:预算判断发生在最昂贵、最不可逆的步骤(签名、广播)之前,即使并发多个请求同时到达,也不会各自都认为「预算还够」。CapExceededError 对应单次上限,BudgetExhaustedError 对应会话总额,两者携带的 {cap, requested} 与 {spent, budget, requested} 字段让上游能精确记录是被哪一层拦下的。signTypedData 从未被调用,是判断拦截是否真正「无副作用」的关键断言——它意味着私钥没有参与,钱包状态没有改变。
代理签什么——钱什么时候动?
签名因方案而异——而且签名不等于花费。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 负载,并可通过 gateway.autoDepositUsd 选择在结算时自动充值 Gateway 钱包,而不是直接失败。
真实运行给出了签名与花费之间差距的数字:三次付费尝试在 payment-signature 被接受后触发了服务器 500,facilitator 从未广播,付款方余额仅在另一次成功结算时发生变化。三次签名,失败尝试上零链上结算——即使服务器出了问题,上限依然守住了。
EIP-3009 transferWithAuthorization 是以太坊上为 USDC 这类代币设计的「授权转账」标准:付款方离线签署一段结构化数据(包含 from、to、value、validAfter、validBefore、nonce),任何持有这段签名的第三方都可以在有效期内把它提交到链上完成转账。它的价值在于把「签名」和「广播」拆成两步——付款方不需要自己发送交易、不需要持有原生代币付 gas,而 facilitator 可以代为结算并批量提交。这正是本文里 exact 方案「签名不等于花钱」的由来:签名只是一份可被兑换的凭证,只有当 facilitator 真正广播并上链时,资金才移动。理解这一点,才能明白为什么客户端可以在签名前从容地做上限校验,以及为什么服务器 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)——绝不用标志,因为 argv 在 ps 中可见。退出码:0 成功 · 2 拒绝/限额/预算 · 3 传输错误。
--print-only 是买方的冒烟测试——它解码 402、验证 accepts、打印支付详情,然后退出,完全不触碰签名者。针对生产环境的真实输出:
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、方案枚举、extra.decimals: 6 和 charged: false 固定为常量;如果规范与实时路由发生漂移,CI 漂移检查就会失败。同一份合约在通信的两端都得到验证。
吃自己的狗粮也抓到了我们自己的问题。在同一次运行中,我们的目录声明了 eip155:5042002(Arc 测试网),而实时 402 只提供了 Base Sepolia(eip155:84532)——默认仅限 Arc 的允许列表拒绝了该报价,直到我们显式传入 --network。我们自己的验证器标记了我们自己的目录漂移,这正是你希望验证器引发的失败。
本文是 Arc Campaign 系列的第 C18 篇。
相关推荐

Workbuddy零基础入门:桌面AI智能体如何落地工作流
Workbuddy桌面AI智能体零基础教程解读:从安装、积分领取到文件管理与自动化工作流搭建,对比CodeX的适用场景与成本差异,帮助职场人完成从AI提问者到AI管理者的思维转变。

OpenAI智能体新栈:计算机操作、决策API与超速工作流
OpenAI Dev Day发布智能体新栈:Computer Use操作速度超越普通人,Decisions API基于Luna实现极低延迟分类,Agents API开放同源计算机操作能力,GPT-6.1 Sol成本仅为Astra七分之一。本文梳理核心更新、实测数据与开发者落地要点。

梁文峰开源推理框架DeepInfo,硬刚美国禁令
DeepSeek在开源模型后再开源推理框架DeepInfo,包含GPU/CPU交叉流水线、小模型预判大模型复核、华为昇腾与英伟达芯片全面适配等技术,直面美国联邦禁令。本文解析其对AI产业落地的意义与开源策略。