跳到主要内容

03 — exact 方案在 EVM 上的真身(EIP-3009)

前两章一直说"客户端签一笔授权、facilitator 上链结算"。本章把这句话落到真实代码:那笔授权到底签的是什么、为什么客户端不用付 gas、facilitator 凭什么相信它。

3.1 要解决的小问题

普通的链上转账有两个对"机器付款"很要命的痛点:

  1. 付款人得有 gas(链上手续费的原生币,如 ETH)。 一个只持有 USDC 的 agent 没法转账——它还得先有 ETH 付 gas。
  2. 付款人得自己发交易、等确认。 这要求它连 RPC、管 nonce、处理重试……全是链上脏活。

x402 的 exact 方案(精确额转账)用 EIP-3009 把这两个痛点一起解决。

3.2 思路:签授权,不发交易

EIP-3009(Transfer With Authorization) 是部分 ERC-20 代币(USDC 就支持)实现的一个标准。它让代币合约暴露一个 transferWithAuthorization 函数:任何人只要拿着"代币持有者签名的转账授权",就能替持有者发起这笔转账。

这就解开了上面两个结:

  • 客户端(付款人)只签名、不发交易——不需要 gas,不需要连 RPC。
  • facilitator 拿着这个签名去代付 gas、代发交易——它是"任何人"里的那个人。
传统转账: EIP-3009 (x402 exact):
付款人 ──签名+付gas+发交易──> 链 付款人 ──仅签名──> facilitator ──付gas+发交易──> 链
(需要 ETH、连 RPC) (零 gas、零 RPC) (承担 gas,可向商家收费)

签名内容是一份结构化授权,字段就是 §1 见过的那个 authorization 对象。它用 EIP-712(以太坊的结构化数据签名标准)签,类型定义是固定的(mechanisms/evm/src/constants.ts:3-9):

// mechanisms/evm/src/constants.ts — authorizationTypes(真实源码)
TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
]

3.3 客户端怎么签

造付款的真身在 createEIP3009Payloadmechanisms/evm/src/exact/client/eip3009.ts:16)。它做的事极简:填好授权对象、签名、打包。

// mechanisms/evm/src/exact/client/eip3009.ts:21-43(节选)
const nonce = createNonce(); // 随机 32 字节,防重放
const now = Math.floor(Date.now() / 1000);
const authorization = {
from: signer.address,
to: getAddress(paymentRequirements.payTo), // 收款地址来自服务器报价
value: paymentRequirements.amount, // 金额来自服务器报价
validAfter: "0", // 立即生效
validBefore: (now + paymentRequirements.maxTimeoutSeconds).toString(), // 报价里规定的有效期
nonce,
};
const signature = await signEIP3009Authorization(signer, authorization, paymentRequirements);
return { x402Version, payload: { authorization, signature } };

两个细节:

  • nonce 是随机 32 字节createNoncemechanisms/evm/src/utils.ts:42,底层 getRandomValues(new Uint8Array(32)))。EIP-3009 合约会记住用过的 nonce,同一个 nonce 不能用第二次——这是链上层面的防重放。
  • 签名域绑定了代币合约和链 IDsignEIP3009Authorizationeip3009.ts:54):domain = { name, version, chainId, verifyingContract: asset }name/version 从报价的 extra 里取(所以服务器报价必须带这俩,否则这里直接抛错,:61-65)。绑定 chainId + verifyingContract 意味着一个为 Base 上 USDC 签的授权,拿到别的链或别的代币上是无效的——天然防跨链/跨币重放。

3.4 facilitator 怎么验(6 道关卡 + 模拟)

verifyEIP3009mechanisms/evm/src/exact/facilitator/eip3009.ts:61)是"不花钱就能判断这笔付款会不会成功"的核心。它按顺序过一连串检查,任何一关挂了就返回 isValid:false + 对应错误码。主要关卡:

#关卡代码位置失败错误码
1scheme 是 exacteip3009.ts:75ErrInvalidScheme
2报价带 EIP-712 域(name/version):84ErrMissingEip712Domain
3network 与报价一致:96ErrNetworkMismatch
4签名有效(按地址类型选验证法):154-172ErrInvalidSignature
5收款人 to == 报价 payTo:177ErrRecipientMismatch
6时间窗有效(validBefore 在未来、validAfter 不在未来):185-202ErrValidBeforeExpired / ErrValidAfterInFuture
7金额精确相等:205ErrInvalidAuthorizationValue
8代币地址是已部署合约(非 EOA):216ErrAssetNotDeployedContract
9链上模拟交易会成功:222-242模拟诊断结果

几个值得细看的设计:

"精确"二字落在第 7 关。 BigInt(authorization.value) !== BigInt(requirements.amount) 就拒——付款额必须一分不差等于报价额,这就是 exact 方案的定义。(对比:upto 方案允许 ≤ 上限,见第 5 章。)

第 6 关给了 6 秒缓冲。 validBefore < now + 6 就算过期(:187)——预留出块时间,免得一笔刚好卡点的授权在打包瞬间失效。

第 8 关防的是"静默空转"坑。 注释解释得很到位(:215):如果 asset 填的是个普通地址(EOA)而非合约,eth_call 不会 revert、只会返回空 data,于是模拟会"假装成功"但实际不会发生任何转账。所以这里专门查 getCode 确认它有字节码。这是"读测试/读注释才知道的坑"的典型。

第 9 关是"预演"。 真正发交易前先 eth_call 模拟一遍 transferWithAuthorizationsimulateEip3009TransferResult),失败了还会跑 diagnoseEip3009SimulationFailure 给出具体原因(余额不足等)。verify 必须能预测 settle 的结果——这个原则在代码里反复出现。

ERC-6492 反事实钱包(智能合约钱包未部署)的处理是真考究。 classifyErc6492Payer:128)会判断付款人是不是"还没上链的智能钱包"。如果是,verify 不做本地验签、而是把验证推迟到链上模拟(因为得先部署钱包合约才能验它的签名);同时严格要求部署用的工厂合约在 facilitator 的白名单里(eip6492AllowedFactories:138-152),否则拒——防止有人用攻击者控制的工厂注入任意交易。

3.5 facilitator 怎么结算

settleEIP3009eip3009.ts:262)是唯一真正花钱的地方。流程:

  1. 再验一遍。 进来先调 verifyEIP3009:273)——settle 不信任"verify 早就过了",重新验。默认 settle 时不再跑链上模拟simulateInSettle 缺省 false,:46),因为下一步真交易本身就是最终裁决。
  2. 必要时先部署智能钱包。 若是 ERC-6492 反事实钱包,先发工厂交易部署它,等回执确认成功(:327-344,部署失败直接返回 ErrSmartWalletDeploymentFailed)。
  3. 发交易。 executeTransferWithAuthorization:371)把签名 + 授权丢给代币合约的 transferWithAuthorizationfacilitator 自己付 gas
  4. 等回执、判成败。 receipt.status !== "success" → 返回 ErrTransactionFailed:381)。
// mechanisms/evm/src/exact/facilitator/eip3009.ts:371-396(节选)
const tx = await executeTransferWithAuthorization(signer, getAddress(requirements.asset), settlePayload, dataSuffix);
const receipt = await signer.waitForTransactionReceipt({ hash: tx });
if (receipt.status !== "success") {
return { success: false, errorReason: Errors.ErrTransactionFailed, transaction: tx, ... };
}
return { success: true, transaction: tx, network: payload.accepted.network, payer };

一个贯穿始终的诚实细节: 出错时它同时返回映射后的错误码errorReason,给程序判断)和原始 revert 文本errorMessage,给人排查)。注释明说原因(:397-400)——错误码会把很多种不同的链上 revert 都collapse 成同一个 ErrInvalidSignature,丢了原文就没法定位真凶。

3.6 一张图收束 exact-EVM

客户端 facilitator 链上
────── ─────────── ────
signer.signTypedData( verifyEIP3009():
EIP-712 授权) ──签名──> ① scheme/网络/域 检查
② verifyTypedDataSignature
③ to==payTo, 时间窗, 金额精确
④ getCode(asset) 是合约?
⑤ eth_call 模拟 ───模拟──────> (不花钱)
settleEIP3009():
⑥ 再 verify 一遍
⑦ transferWithAuthorization ──发交易+付gas──> 转账发生
⑧ 等回执 → txHash

第 4 章回到 HTTP 层,看这套 verify/settle 是怎么被中间件"夹在业务逻辑前后"调用的——以及那个"handler 没成功就不结算"的关键时序。