为自动付费 Agent 加上签名前预算检查
教程为 x402 API 客户端加入单次金额上限、网络与代币限制、收款方白名单、来源检查及每日预算,并接入官方 fetch 客户端。示例在本地测试服务器运行,允许支付的路径也在签名前以试运行方式停止。
AI 深度解读
让 Agent 自动调用付费 API,难点不在于付钱,而在于签名前能否回答:这笔钱该不该付、最多付多少、收款人是谁。原文用 x402 搭了一套 Node 防护,把单次限额、收款白名单和日预算放进支付流程。
结论很明确:付款权限应该由签名代码执行,不能只靠模型遵守提示词。不过,作者验证的是本地测试与签名前拦截,没有完成真实支付;示例也还没解决多进程共享预算的问题。
收到 402,先看清它让你付什么
x402 允许 HTTP API 返回 402 Payment Required,附上机器可读的付款条件,客户端付款后自动重试。方便之处和风险来自同一个地方:Agent 一旦持有有余额的钱包,访问的服务就能向它报价。
作者称,所有代码于 2026 年 10 月 26 日运行,环境为 Node 20,@x402/ 系列包版本为 2.28.0,包括 @x402/fetch、@x402/core、@x402/evm、@x402/express。文章由 AI Agent 撰写,作者同时披露他们团队在开发相关工具。
本地 Express 服务的一次报价是:Base Sepolia 上支付 0.002 USDC。响应体只有 {},付款条件藏在 PAYMENT-REQUIRED 请求头中,是经过 base64 编码的 JSON;decodePaymentRequired 将其解码后,可以看到 x402Version: 2、资源描述 resource 和付款选项 accepts。
真正需要检查的是每个付款选项:
amount: "2000"使用代币最小单位。USDC 有 6 位小数,因此对应 0.002 USDC,比较时要用BigInt。network: "eip155:84532"是 Base Sepolia;eip155:8453则是 Base 主网。网络标识遵循 CAIP-2。asset是代币合约。示例测试网 USDC 地址为0x036CbD53842c5426634e7929541eC2318f3dCF7e,主网示例地址为0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913。payTo是收款地址,示例使用销毁地址0x000000000000000000000000000000000000dEaD。maxTimeoutSeconds: 300表示签名授权可使用的时间长度。
extra.name: "USDC" 和 extra.version: "2" 只是附加信息,不能拿代币名称代替合约校验。服务端写着 USDC,不代表它给出的合约就是你准备支付的资产。
预算要变成代码规则,拒付也要说清原因
作者把策略写成普通数据:单次最多 "5000",即 0.005 USDC;每日预算 "1000000",即 1 USDC;授权时长最多 600 秒;网络、代币和收款人分别限制。
下面这段检查函数解决的是「报价进入签名流程前,哪些条件必须同时成立」:
export function checkOffer(offer, policy, { requestUrl, resourceUrl, spentToday }) {
const no = [];
if (offer.scheme !== 'exact') no.push(`scheme "${offer.scheme}" not allowed`);
const token = policy.tokens[offer.network];
if (!token) no.push(`network ${offer.network} not allowed`);
else if (offer.asset.toLowerCase() !== token.toLowerCase()) no.push(`asset ${offer.asset} is not the allowed token on ${offer.network}`);
const amount = BigInt(offer.amount);
if (amount > BigInt(policy.maxPerCall)) no.push(`amount ${amount} > per-call cap ${policy.maxPerCall}`);
if (BigInt(spentToday) + amount > BigInt(policy.dailyBudget)) no.push(`daily budget: ${spentToday} spent + ${amount} > ${policy.dailyBudget}`);
if (policy.payees && !policy.payees.some((p) => p.toLowerCase() === offer.payTo.toLowerCase())) no.push(`payTo ${offer.payTo} not on the allowlist`);
if (offer.maxTimeoutSeconds > policy.maxTimeoutSeconds) no.push(`maxTimeoutSeconds ${offer.maxTimeoutSeconds} > ${policy.maxTimeoutSeconds}`);
if (resourceUrl && new URL(resourceUrl).origin !== new URL(requestUrl).origin) no.push(`resource ${new URL(resourceUrl).origin} differs from the URL we called`);
return no;
}
重点看最后两项:授权不能长期有效,报价指向的资源也要与请求 URL 同源。这里的同源检查只比较 origin,没有核对完整路径和查询参数。
返回值采用拒绝原因列表,空数组才代表允许。这样 Agent 可以解释「收款人不在白名单」,而不只是收到一个 false 后继续重试。
作者用同一个报价跑了五种策略:基准策略放行;单次限额降到 "1000" 时拒绝;替换收款白名单时拒绝;只允许主网时拒绝测试网报价;日预算设为 "10000"、已用 "9000" 时,新增 "2000" 也会被拒绝。
这些结果验证了几个判断分支,没有验证资金结算。输出中的 PAY 只是策略允许付款。
guardedQuote 还会把报价按金额排序,选择最便宜的合规项。这个做法适合同一种计价资产;若不同网络允许不同代币,直接比较最小单位整数,不能证明谁更便宜。
官方客户端已有三道接入位置
不用自己重写付款和重试流程。原文使用的官方客户端提供了三处控制点。
第一处是默认开启的 spendControls:只允许其识别的美元稳定币,默认每次最多 1 美元。对于通常只收几分之一美分的接口,这个默认值仍可能过高,需要显式设置。
第二处是 registerPolicy(fn),在客户端挑选付款选项前过滤报价。第三处是 onBeforePaymentCreation(hook),在签名前执行,可以返回 { abort: true, reason } 阻止继续。
下面保留客户端的关键配置片段;它依赖前面准备好的 signer、policy、ledger 和 URL_:
const client = new x402Client()
.register('eip155:84532', new ExactEvmScheme(signer))
.setSpendControls({ maxAmountPerPayment: '$0.01' })
.registerPolicy((_v, reqs) => reqs.filter((r) => checkOffer(r, policy, { requestUrl: URL_, spentToday: ledger.spent() }).length === 0))
.onBeforePaymentCreation(async ({ selectedRequirements: r }) => {
console.log(`would sign: ${r.amount} of ${r.asset.slice(0, 6)}… on ${r.network} to ${r.payTo.slice(0, 6)}…`);
if (process.env.DRY_RUN) return { abort: true, reason: 'dry run, nothing signed' };
})
.onAfterPaymentCreation(async ({ selectedRequirements: r }) => ledger.record(r.amount));
要盯住最后一行:这里在支付载荷创建后记账,还没有等待结算成功。失败付款也可能占用预算,属于偏保守的计算;需要准确账目时,作者建议改到 onPaymentResponse 处理。
原文通过 wrapFetchWithPayment(fetch, client) 包装请求,并运行 DRY_RUN=1 node client.mjs。基准案例到达签名前的钩子后退出;DRY_RUN=1 CAP=1000 node client.mjs 和替换 PAYEE 的案例则在策略过滤阶段退出。密钥由 generatePrivateKey() 临时生成且没有余额,没有任何付款或签名发生。
将 SDK 上限改为 maxAmountPerPayment: '$0.001',也会拒绝 0.002 USDC 的报价。SDK 还支持通过 allowedAssets[].maxAmountPerPayment 设置按资产最小单位计的上限;maxAmountPerPayment: false 可关闭该限额,spendControls: false 可关闭全部消费控制。
另一个实际问题是日志:策略过滤失败只会报告选项被过滤。若需要具体拒付原因,要在 registerPolicy 内自行记录。
我的判断
最值得采用的是让签名入口强制检查支付策略。 相比在提示词里要求模型节省费用,这条边界更可核查。前提是模型拿不到私钥,也不能直接调用另一条签名工具绕过检查。
示例距离生产实现还有几处缺口。Ledger 按 UTC 日期把金额写入 ledger.json,无法保证多个进程同时读取、检查和写入时的预算正确性。多 Agent 共用钱包,需要原子更新的共享预算存储,或分别使用小余额钱包。
官方客户端示例也没有把 resourceUrl 传给 checkOffer,因此没有执行前面展示的资源同源检查;签名前钩子只做日志和 dry-run,没有再次检查或预占预算。不能因为代码里出现了这些接口,就认定所有防护已经接好。
收款白名单适合已知服务,开放式浏览则很难提前列全收款人。作者提出用低单次限额和日预算兜底,这是合理的工程取舍,但没有对照数据证明哪种组合更有效。价格合规也不保证内容有用,返回质量仍需单独判断。
能拿来做什么
- 在 Node Agent 的付费请求层统一设置网络、代币合约、收款人和单次上限,并让所有签名请求经过同一套检查。
- 先按原文的
DRY_RUN=1方式验证正常报价、超额报价和陌生收款人,检查日志能否说明拒付原因,再接入有余额的钱包。 - 多个任务共享钱包时,把预算检查与额度预占做成原子操作,同时区分已预占、已创建支付载荷和已结算金额。
- 给付费摘要等服务增加输出质量检查;即使价格和收款地址都合规,持续返回无用结果的服务也应停止调用。