以太坊账户抽象的安全骨架

以太坊执行层的量子升级(1/5)

  1. 以太坊账户抽象的安全骨架
  2. 以太坊原生账户抽象的交易解剖
  3. 一笔后量子交易的一生
  4. 一笔交易身边的世界
  5. 全网重放的机器
目录

以太坊的账户层正处在两场变革的交汇处。一场是账户抽象(Account Abstraction,下文简称 AA):把账户从「一把私钥定生死」的原始模式里解放出来,让批量操作、代付 gas、自定义验签规则全都成为可能。另一场是量子升级:在量子计算机攻破椭圆曲线签名之前,给这条链换上抗量子的密码学。两场变革在同一个地方汇合,因为 AA 提供的可插拔验签,正是抗量子签名在以太坊上最自然的着陆点。这个系列会沿着这条交汇线一路写下去,后面会讲到原生账户抽象(EIP-8141)等更新的提案;这第一篇,先把 AA 本身的地基和安全骨架一次讲透。

全文只有一条主线,先亮出来:

链上每一笔交易都要全网几千个节点跟着执行,烧的是真实算力,所以必须有人付钱;而「谁付钱、由谁保证他真的会付」,决定了整个交易系统的形状。

账户抽象九年来的所有设计、所有妥协、所有攻击面,都是这一个问题被反复回答的结果。记住这条主线,下面的一切都是推论。

EVM 是一台按指令收费的机器

要理解后面的一切,得先接受一个朴素的事实。EVM 是一台栈式虚拟机,程序由一串操作码(opcode)组成,而每一条 opcode 都被协议明码标价:

操作opcodegas 单价
加法ADD3
乘法MUL5
读一个存储槽(冷读)SLOAD2100
写一个存储槽SSTORE最高 22100
跨合约调用CALL起步 2600

一段代码的 gas 成本不是打包估算出来的,而是它实际执行路径上每条指令的单价逐条累加出来的。

gas 存在的根本目的,是给「占用全网算力」这件事定价。你的合约在链上跑一遍,全网每个节点都得原样跟着跑一遍来验证结果;不给这件事标价,一个死循环就能拖垮整个网络。所以有一个等式值得刻进直觉:算力就是 gas,gas 就是 ETH,就是真金白银。谁让节点干活,就必须有人为这些活买单。

顺带,这也解释了一个本系列后面会反复出现的扎眼数字。后量子签名的链上验证动辄几百万 gas,不是因为算法神秘,而是 Falcon、ML-DSA 的验证要跑大量模乘、NTT 变换和哈希展开,每一步都是明码标价的 opcode,条数乘以单价,数目就是这么大。作为参照,传统 ECDSA 验签走 ecrecover 预编译只要 3000 gas。一个贵上千倍的验签要塞进一个处处设防的系统,塞在哪、怎么塞,是整个量子升级绕不开的问题。

两种账户,同一个四元组

以太坊的全局状态是一张巨大的映射:地址指向账户。而每个账户,不分种类,都是同一个四元组:

(nonce, balance, storageRoot, codeHash)

nonce 是计数器(外部账户数它发过的交易,合约数它创建过的合约),balance 是以 wei 计的余额,storageRoot 指向账户私有存储树的根。真正区分两类账户的只有最后一个字段:合约账户的 codeHash 指向部署好的字节码;外部账户(EOA,就是私钥控制的普通账户)的 codeHash 历来是 keccak256(""),空字符串的哈希。注意措辞:不是缺这个字段,而是填了一个空值。这个空槽在后面讲 EIP-7702 时是主角。

从这个模型直接推出两条铁律,后面反复要用:

  1. EOA 的身份和密钥在协议层焊死。EOA 的地址是 keccak(公钥) 的后 20 字节,EOA 连换一把 ECDSA 私钥都做不到,更不要说把签名方案整个换成抗量子的。这就是 AA 要解决的问题的真正形状。
  2. 只有 EOA 能发起交易。合约没有私钥,永远是被动的,必须被一笔最终源头是某个 EOA 的交易调用才会动。这条铁律,就是后面「为什么 4337 必须有 bundler」的最终答案。

普通交易的一生:验证在 EVM 之外

一笔普通交易的信封上写着什么?nonce, gasLimit, maxFeePerGas, to, value, data,外加一个 secp256k1 ECDSA 签名 (v, r, s)。注意 value(转多少 ETH)和 to(调用谁)是明明白白印在信封上的,静态可读。这个细节在后面对比 4337 时至关重要。

节点收到交易后,在碰 EVM 之前,先由客户端程序(geth 之类,Go 或 Rust 写的)做三件事:

  • 从签名恢复出公钥、算出地址,确认它就是发送者;
  • 查交易的 nonce 是否正好等于账户当前值;
  • 查余额是否付得起 value + gasLimit × maxFeePerGas

三件事全是对状态数据库的静态读取,微秒级,结论确定。全部通过,交易才进 mempool(待打包交易池)、才可能被选进区块;进了区块,EVM 才第一次启动,按信封上的 to 和 data 执行调用,逐条指令累加 gas。执行可以失败,失败了状态改动回滚,但已经烧掉的 gas 照扣,因为算力已经实实在在被占用了。

普通 EOA 交易的生命周期:协议层在 EVM 之外完成验证,进区块后 EVM 才启动

有一点值得加重语气:协议层的验证写死在客户端代码里,发生在 EVM 之外,没有任何合约参与,链上没有一行字节码被执行。容易与它混淆的是 ecrecover 预编译(地址 0x01)和 EIP-1271 的 isValidSignature:它们的数学和协议层验签完全相同,但那是应用层的工具,给合约在执行期内部验某个签名用的(permit、多签、元交易),和「这笔交易能否上链」的协议层判定隔着一层楼。同一把锤子,一把在协议手里,一把在合约手里。

这里值得提前埋一个伏笔:「由一个公共合约统一调度验签」的世界确实存在,只是它不是今天以太坊的原生机制,而是 4337 建成之后的世界,EntryPoint 就是那个公共验签调度合约。这个念头先放着,后面自然会接上。

这一节还有一个必须单独记住的性质。因为付款人被协议写死为签名者本人,节点不用执行任何代码,三次查表就能百分之百确定「这笔交易的 gas 有人付、付得起」。判断廉价、静态、结论可靠。请记住这个性质,它就是接下来要被打破的东西。

账户抽象,一句话

铺垫收拢,AA 的定义只需要一句话:

账户抽象,就是把「什么算一笔有效交易」的判定权,从协议硬编码(客户端代码)搬进账户自己的合约代码。

你的账户是一个合约,里面有一个 validateUserOp 函数,你想让什么算有效,就在里面写什么:换成 P-256 让手机 passkey 直接签,换成 Falcon 或 ML-DSA 抗量子,加每日限额,加多签,全都只是这一句话的推论。批量操作、代付 gas、更换签名方案,同样全是推论。

三个容易带偏直觉的地方,值得在定义旁边立三块牌子:

  1. 4337 的原生形态里没有 EOA 什么事。账户就是一个用 CREATE2 部署的合约,地址不从任何私钥推导;「AA 是把 EOA 和合约绑定起来」的直觉来自 EIP-7702,那是给存量 EOA 修的桥,不是 AA 的本体。
  2. bundler 从不验签。它在链下模拟执行你账户的验证代码,那是它决定接不接单的经济自保;权威判定发生在链上,由 EntryPoint 调用同一段代码。同一段代码跑两遍,目的不同。
  3. AA 本身不抗量子。它提供的是密码敏捷性(crypto-agility):验签逻辑变成了可插拔的代码,抗量子来自你插进去的方案。这个区分极其关键,后面讲 7702 时会看到它在哪里失效。

msg.sender 与批量的死结

为什么非要把验证变成代码?先看被卡死的到底是什么。

EVM 执行期有个基本量 msg.sender:当前这一层调用的直接发起者。EOA 直接调合约 X,X 里的 msg.sender 就是这个 EOA;X 再去调 Y,Y 里的 msg.sender 就变成了 X。每跨一层调用换一次。顶层的 msg.sender 由交易签名决定,没有私钥就伪造不出来,所以合约里写 require(msg.sender == owner) 是安全的。这是「身份」在协议层的加密根。

但同一个机制把批量卡死了。一笔交易只有一个顶层调用(一个 to,一份 calldata),而大量操作(转你的代币、approve、投票、任何 owner-only 函数)都要求 msg.sender 是你本人。想一笔干多件事,只能调一个批量合约让它替你连环调用,可那样每个内部调用的 msg.sender 都变成了批量合约,不是你。经典例子:经 multicall 去调 approve,授权授给了错误的主人。这正是 approve 加 swap 永远要签两笔的原因。

「那先把额度授权给合约不就行了?」行,而且这就是现状:先 approve 给 Uniswap Router,由它 transferFrom 替你操作,DeFi 的 router 模式。但把这套模式的代价拆开细算,有三层:

  1. 常驻授权等于空白支票。allowance 没有过期、没有单笔限额、没有条件,无限授权的行业习惯让授权钓鱼和 router 漏洞盗刷成了链上最大损失类目之一;Permit2 专门给授权补上过期和额度,这个补丁的存在本身,就是底层授权模型太糙的供词。
  2. 你签的是支票,不是操作。签名背书的是「额度」,实际执行路径不在签名覆盖范围内。
  3. 最根本的一层:allowance 只能委托「可转让的权利」,不能委托「身份」。ERC-20 的拉取是协议专门开的口子,而投票、领绑定地址的空投、owner-only 参数这类检查 msg.sender 的操作,没有任何 approve 机制可以交出去。

智能账户把三层代价一起消掉。账户合约自己就是那个身份,它的 execute 循环里每个内部调用的 msg.sender 就是账户地址,而代币和授权全都住在这个地址上;签名盖在整个 UserOp 的哈希上,callData 里每个调用都在背书范围内,所签即所执行;甚至可以原子地完成 approve、使用、清零三步,不留任何常驻授权。一句话总结:multicall 加预授权解决的是「批量转移代币」这个子集,代价是空白支票;AA 解决的是「批量行使身份」这个全集,而且不留残余。

绕协议的原罪:GSN 把加密根换成了信任假设

在 AA 成形之前,还有一条更早的绕路值得单独记下来,因为它第一次暴露了「绕协议」路线的原罪。

元交易(meta-transaction)要解决的是「新用户没有 ETH,付不起第一笔 gas」。方案是用户离线签名(不花钱),由 relayer 用自己的 ETH 替他把交易发上链。但这样一来,交易的顶层发起者是 relayer,合约看到的 msg.sender 是 relayer,真实用户的身份丢了。ERC-2771 的补救是:用户把自己的真实地址一起签进去,relayer 发交易时把它拼在 calldata 的末尾,目标合约不再直接用 msg.sender,改调一个 _msgSender(),它的逻辑是「如果这笔调用来自我信任的转发器,就无视 msg.sender,去 calldata 尾部读那个真实地址」。

看清楚这里发生了什么。身份的来源,从「密码学保证的顶层 msg.sender」换成了「calldata 里的一段明文,加上相信转发器不作恶」。裂缝随之而来:信任校验写错一点,任何人都能直接调用合约、在 calldata 尾部拼上你的地址,以你的身份操作;受信转发器本身被攻破,等于攻击者能冒充所有人。

这个模式后面会一再重演。每次绕过协议的原生保证,都得用一层应用层假设去顶替,而假设是会破的。GSN 的 _msgSender() 伪造面是第一次,4337 对 bundler 诚实模拟的信任是第二次,7702 的 ECDSA 后门是第三次。追踪「身份和授权的信任根从哪滑到哪」,是看懂整部 AA 安全史的主线,这条线到文章结尾会收成一张完整的图。

转折点:可编程验证打开 DoS 之门

现在到了整个故事的枢纽。

前面说过,固定规则下 mempool 是安全的,因为「这单会不会付钱」是三次静态查表,廉价、确定、可靠。把验证变成账户自己的代码之后,这个判断变成了什么?变成了「运行一段图灵完备的程序,并预测它的结果」。那段验证代码可以写「区块号是偶数才付钱」,可以写「预言机报价高于 X 才付钱」,也可以单纯写得极其昂贵。而要知道任意代码的输出,除了执行它,没有第二种办法。这就是「执行才知道」的精确含义,也是一切麻烦的源头。

DoS 攻击从这个结构里自动浮现,两个变种。

变种一是基本型。往 mempool 提交一个操作是免费的(它还没上链,没有任何人为它付过 gas),而节点要判断接不接这一单,必须先把它的验证代码跑一遍,烧的是节点自己的 CPU。攻击者批量构造验证注定失败的操作(签名故意写错,或者干脆必然 revert),免费灌进来,每一个都强迫全网节点白跑一遍才能发现「这单不会付钱,扔掉」。攻击者零成本,节点烧真金白银。

变种二更阴险,叫失效攻击(invalidation)。攻击者先提交 1000 个此刻验证都能通过的操作,节点验完欣然收进 mempool,前期已经为它们花了 CPU;然后攻击者发一笔廉价交易,改掉这些操作的验证共同依赖的某个状态(转走验证要读的余额,或者翻转某个共享存储位),1000 个操作同时作废,节点前期的验证工作全部白干,还得把它们清理出去。一笔交易的成本,撬动一千次验证的浪费,杠杆放大了几个数量级。

这个问题是结构性的,躲不开。有一句总结值得单独写下:

此后所有 AA 方案的验证段设计,本质都是在给这个被打开的口子打补丁。

EIP-2938 给验证段戴上镣铐,强行让它重新变得廉价可预测;ERC-4337 不改协议,把风险外包给自愿承担的 bundler;EIP-8141(原生 AA)再把这套风险接回协议自己的 mempool。九年前杀死 EIP-86(协议内 AA 的第一次尝试)的正是这个问题,今天的设计仍然围着它转。

EIP-2938:PAYGAS 与三道镣铐

2938 是第一个系统性的答案。它最终死在了合并(The Merge)前后的排期政治上,但它的设计几乎原样活在今天的 4337 里,值得完整拆开。

先钉死一个关键的模型问题。「先验证,验证过了 EVM 才启动执行」这个先后两段的图景,只对普通 EOA 交易成立,因为那里验证真的在客户端代码里、EVM 之外。而 2938 和 4337 的全部创新,恰恰是把验证搬进了合约代码,合约代码只能由 EVM 执行。所以在这两个世界里,验证段不是「EVM 之前」的东西,验证段就是 EVM 正在跑的一段代码。从头到尾都是同一趟 EVM 执行,只是被一条特殊指令从中间劈成了两截。

那条指令叫 PAYGAS。它不是「验证之后进入 EVM 的门」,而是同一趟执行里前半截和后半截之间的分界桩。看伪代码最清楚:

// ---- 验证段 ----
sig_ok = 验证签名(tx.signature) // 一串验签 opcode,就在 EVM 里跑
if (!sig_ok) { REVERT }
if (nonce != expected) { REVERT }
// ...其他授权检查...
PAYGAS(gas_price, gas_limit) // 能执行到这一行,说明上面全过了
// ---- 执行段 ----
真正干活...

PAYGAS 是一条真实的指令。EVM 执行到它的瞬间,账户余额里的 gas 钱被锁定划走,付款从这一刻起板上钉钉;此后哪怕执行段整个失败,这块 gas 照扣。它在代码里的位置本身编码了一个契约:能执行到我,当且仅当我上面的所有检查都通过了。任何一个检查失败,REVERT 当场终止整趟执行、回滚状态,代码根本走不到 PAYGAS,付款从未发生。而节点为这次失败模拟烧掉的 CPU,自己白白承担。这正是上一节 DoS 的核心:付款落定之前,验证失败的代价落在别人头上。

付款分界线:同一趟 EVM 执行被 PAYGAS 或 prefund 劈成验证段和执行段

所以 2938 给验证段戴上三道镣铐,每一道都精确对着一个威胁:

  1. 禁用环境类操作码(TIMESTAMP、NUMBER、COINBASE 这些)。如果验证结果依赖「现在几点、第几个区块」,那「此刻能过」不等于「下个区块能过」,环境一变就翻脸;禁掉之后,验过就基本能信。
  2. 验证段不得读外部状态,只能读账户自己的存储。如果我的验证依赖你的状态,你一改我就失效,失效攻击的杠杆正长在这里;锁死在自己的存储里,外人就没有任何办法废掉我的验证。
  3. 验证段 gas 设上限。验证是节点免费替你跑的,不封顶的话,攻击者写一个烧几千万 gas 的验证段就能拖垮全网。

协议镣铐之外还有一条 mempool 规则补刀:每个账户同时只许一笔待处理操作,想再发,先等前一笔上链,堵死「同一账户塞一百个互相冲突的操作让节点白验九十九个」的路径。

这三道镣铐每一道都值得记住出处,因为它们后来全部转生:

2938 的设计转生为
镣铐一:禁用环境操作码ERC-7562 的禁用操作码清单
镣铐二:只读自己的存储ERC-7562 的存储白名单
mempool 单笔规则bundler 的准入策略

读 7562 的每一条,都能在 2938 找到出生证明。而第三道,验证段 gas 上限,是量子升级绕不开的一堵墙:几百万 gas 的后量子验签,顶到的正是这块天花板。「验证段 gas 上限对撞重型签名」是一个结构性的矛盾,本系列后面还会撞上它。

ERC-4337:不改协议,雇一个翻译

2938 死后,社区换了思路:协议一个字不改,把整套系统搬到用户态。这就是 4337。先回答它最常被问的问题:为什么必须有 bundler 这个角色?

因为协议只认一种交易:EOA 发起、ECDSA 签名、发送者自己用 ETH 付 gas。而 UserOperation(4337 世界里的「交易」,下称 UserOp)三条全违反:签名算法任意,发起者是合约账户,gas 可以由别人代付。在协议眼里 UserOp 是个非法物,必须有人把它翻译成合法交易。bundler 干的就是这件事:从专用的 alt-mempool 收集一批 UserOp,用自己的 EOA 签一笔真交易打包提交,垫付全部 ETH,事后从托管机制里拿回报销。它同时是翻译者、垫资人和风险承担者。一句话:bundler 是「不硬分叉就要 AA」的代价。

链上的调度中枢是一个公共合约 EntryPoint。bundler 的打包交易调用 EntryPoint.handleOps(ops),EntryPoint 再去逐个调用每个账户的 validateUserOp。4337 造不出新的 opcode,那 PAYGAS 去哪了?由一笔普通的合约内转账顶替:

function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256) {
// 1. 验证段:对应 PAYGAS 之前
require(_checkSignature(userOp.signature, userOpHash)); // 不过就 revert
// 2. 付款落定:对应执行到 PAYGAS 的那一刻
if (missingAccountFunds > 0) {
(bool ok, ) = msg.sender.call{value: missingAccountFunds}("");
// msg.sender 是 EntryPoint;这笔钱只是 gas 押金,不是业务金额
}
}

(简化版,省略了 nonce 处理和返回值细节。)方向千万别搞反:不是 PAYGAS 触发 validateUserOp,而是 EntryPoint 调用 validateUserOp,后者在验签通过之后,主动把 missingAccountFunds(这一单最坏情况的 gas 钱)转进 EntryPoint 托管。这笔转账就是 PAYGAS 的转世:转账发生,当且仅当上面的 require 全部通过,位置编码的那个契约原封不动。失败的后果也一一对应:2938 里验证 revert,全网节点白烧 CPU,所以协议用镣铐硬限;4337 里验证 revert,bundler 白烧 CPU,所以它靠链下模拟加 7562 沙箱自保。

1 ETH 转 100 ETH:合法性拆成四个问题

4337 的验证哲学,用一个案例就能整个照亮。智能账户里只有 1 ETH,主人签了一笔转 100 ETH 的 UserOp。谁来拦下这笔注定失败的交易?答案是:没有任何人去验「够不够 100 ETH」,而且是故意的。

传统世界拦得住,是因为那 100 ETH 写在信封的 value 字段上,协议静态一查余额就判无效,连区块都进不了。而 4337 里,「转 100 ETH」埋在 callData 深处,是账户 execute 函数的参数,对协议、对 bundler、对 EntryPoint 都是不透明的字节,EntryPoint 从不解析 callData 的语义。于是「这笔交易合法吗」被拆成了四个问题,各归各家:

  1. 信封交易合法吗?协议层验的是 bundler 那笔打包交易的签名、nonce 和余额,100 ETH 根本不可见。通过。
  2. gas 有人付吗?validateUserOp 里账户已经预付了 missingAccountFunds,那只是 gas 钱(大约是 gas 上限乘以 maxFeePerGas),1 ETH 绰绰有余。通过。
  3. 操作被授权了吗?账户的验签代码查的是「主人签没签过这个 UserOp」,不是「它能不能成功」。签名千真万确。通过。这里有一句值得单独裱起来的话:注定失败的转账,可以是完美授权的。
  4. 你真的有 100 ETH 吗?没有任何一层把它当作准入条件。它在执行阶段才第一次暴露:内部 CALL 带着 100 ETH 的 value,余额不足,执行 revert。但验证阶段锁进托管的 gas 钱不回滚,照扣,事件日志里记一个 success=false,bundler 分文不亏,买单的只有签名者自己。

为什么故意不设第四道闸?两个理由:

  • 做不到。从模拟到上链之间,状态随时会变,「执行必定成功」这种话没有人能承诺;强迫 bundler 担保执行结果,等于把无界的风险压回它身上,而 validate-then-execute 这个拆分要消灭的正是这种风险。
  • 不需要。托管机制已经把执行失败变成了对系统无害、只对签名者收费的普通事件,这和传统世界里「调用一个必然 revert 的合约,照样是合法交易、照样烧 gas」是同一个哲学。

实践中钱包在 eth_estimateUserOperationGas 时会连执行一起模拟,提醒你这笔会失败,但那是礼貌性的用户体验,不是有效性闸门;bundler 完全可以把一个注定执行失败的 UserOp 打包上链,反正它拿得到钱。

ERC-7562 沙箱:堵住付款之前的空窗

现在聚焦真正危险的地带。bundler 决定接不接单,必须先跑一遍 validateUserOp,而这发生在任何人付钱之前。如果验证 revert,烧掉的模拟 CPU 找谁要?发起者是一个合约账户,没签过 EOA 交易,没上过链,链上无痕,抓不到人。验证失败等于纯亏,而且无处追偿。这段「付款落定之前的免费空窗」是整个系统的阿喀琉斯之踵,ERC-7562 沙箱就是为堵它而生的,分两路,对应两种攻击者。

第一路:让免费的失败变得可预测

它针对的是失效攻击在 4337 里的复刻:恶意账户把验证写成「外部存储位 X 等于 1 才通过」,提交时 X 是 1,bundler 模拟通过、收进 mempool;攻击者再发一笔廉价交易把 X 改成 0,这个操作连同所有依赖 X 的操作集体作废,bundler 的前期工作全废。

对策就是 2938 镣铐的用户态复刻:验证期间基本只准读写自己地址下的存储槽(存储白名单),于是一个操作只依赖它自己的存储,除了它自己没有人能改状态让它失效,攻击者的杠杆消失;同时禁用 TIMESTAMP、NUMBER、COINBASE、BLOCKHASH 这些环境操作码,保证这一秒的模拟结果到下个区块不会翻脸。两条合起来买到一个性质:

bundler 的一次链下模拟,能够可靠预测这个操作上链时的验证结果。

别小看这句话,整个 4337 的经济可行性就架在它上面。没有「模拟可预测上链」,bundler 什么都不敢打包,怕的就是模拟通过、上链 revert、垫款血本无归。

第二路:让抓不到的人变得抓得到

白名单管得住普通账户,但共享基础设施天然要越界。paymaster(替用户代付 gas 的合约)在验证期得读自己的存储,查「这个用户的赞助额度还剩多少」;factory(负责部署新账户的工厂合约)也一样。一刀切禁掉,这些角色就没法存在;放开,攻击者就能写一个恶意 paymaster,靠改动自己的全局存储,批量废掉所有挂靠它的操作,失效攻击借道复活。

7562 的解法:想在验证期访问更宽的存储,可以,先在 EntryPoint 质押一笔 ETH。质押不是买通行证,是押上可以被惩罚的抵押品:反复造成失效就会被 bundler 拉黑限流,而质押的 ETH 有解绑延迟,跑不掉。它把匿名的、抓不到的攻击者,变成了具名的、有把柄的实体。

付款分界线:执行段为什么无法无天

对照之下有个乍看很奇怪的事实:执行段,也就是 callData 真正干活的那一段,完全不受 7562 约束。随便读外部状态,随便用 TIMESTAMP,随便 revert,想怎么折腾怎么折腾。为什么验证段被锁成那样,执行段却彻底放养?

因为钱已经在 prefund 那一刻锁进托管了。执行段无论出什么乱子,哪怕整个 revert,gas 都从已锁定的押金里扣,bundler 稳拿报销,分文不亏。执行失败对系统无害,自然不需要任何规则。危险的从来不是「执行」,而是「付款之前那段免费空窗」。这就是贯穿 4337 全部设计的总纲:

整个 ERC-4337 的安全设计,围绕一条付款分界线展开。线之前是失败无人买单的空窗,被沙箱严防死守;线之后是失败只烧签名者钱的安全区,几乎放任自流。

理解了这条线在哪、为什么在那,4337 的一切机制都变成推论。

这条线在链上的物理位置也清清楚楚。EntryPoint 的 handleOps 内部是两个独立的循环。第一循环对批内每个 UserOp 依次做两件事:调 validateUserOp 验签(后量子验签就住在这一步,受沙箱约束),然后收取 prefund 锁进托管。全部通过后,所有人的 gas 钱都已在托管里。第二循环才逐个执行 callData,「1 ETH 转 100 ETH」的余额不足在这里才第一次暴露,失败只回滚业务,gas 照扣。最后是结算:按实际用量多退少补,再从托管里补偿 bundler 的垫款加小费。付款分界线不多不少,正好落在两个循环之间:第一循环全在线前,第二循环全在线后。

一笔 UserOp 的完整一生:链下模拟与链上 handleOps 的两个循环

顺手把链下半场也记全,凑成一笔 UserOp 的完整一生。用户先用一个与真实签名同长同形的 dummy 签名去模拟估 gas(Falcon 的签名是变长的,「最坏情况的 dummy 怎么构造」本身就是一个工程难点),然后对整个 UserOp 的哈希做真签名,提交进 alt-mempool。bundler 用 debug_traceCall 逐操作码核查 7562 规则,违规直接丢弃;再做一次执行模拟,排除必然失败的单,但这一步纯属礼貌,不是准入;然后按 maxBundleGas 装箱,用自己的 EOA 签名、垫付 ETH,调用 handleOps。链下全程没有任何人付过一分钱,所有风险都压在 bundler 一个人身上。这是它必须谨慎模拟的原因,也是它定价 preVerificationGas 的底气。

EIP-7702:给存量 EOA 修的桥,和焊死的后门

4337 的账户是新部署的合约,那几亿个存量 EOA 怎么办?这就轮到 EIP-7702 出场了。

回想账户四元组:EOA 的 code 字段填的是空值,而不是不存在。7702 干的事,机械上就是往这个空槽里写 23 个字节。用户用 ECDSA 私钥签一个授权元组(chain_id、委托目标地址、nonce),装进 type-4 交易上链;协议验签通过后,把 0xef0100 ‖ 目标地址 写进该 EOA 的 code 字段。0xef 这个前缀是精心挑过的:EIP-3541 之后,任何正常部署的合约代码都不允许以 0xef 开头,所以这 23 个字节永远不会和真实字节码混淆,它是协议认识的指针标记,不是可执行代码。

此后任何调用打到这个 EOA,EVM 沿指针加载目标合约的代码来执行,而执行语境是 EOA 自己:address(this)、存储、余额,全是这个 EOA 的。等价于在协议层装了一个 delegatecall 代理,EOA 是 proxy,委托目标是 implementation。

几个审计口径的细节:

  • 已委托 EOA 的 EXTCODESIZE 返回 23;
  • EXTCODECOPY 拷出来的是这 23 字节本身,不是目标代码,但调用会沿指针执行;
  • 委托随时可以用新的授权改指向,或者清零退回普通 EOA。

EIP-7702 委托机制:code 字段里的 23 字节指针与焊死的 ECDSA admin 钥匙

接下来是整篇文章安全含量最高的一段。这个 proxy 的 admin 权限,也就是改指向的权力,永远属于那把原始的 ECDSA 私钥。你可以把 implementation 换成世界上最好的后量子验证器,让日常操作全走抗量子签名;但量子攻击者一旦从链上暴露的公钥恢复出私钥,签一个新的授权,把委托改指到恶意合约,你的抗量子外壳瞬间归零。这个后门在应用层无法修复,因为它长在协议层的授权机制里;修复只能靠协议出手,也就是 ECDSA 失活类提案(限制或迁移存量 ECDSA 的授权权力,细节还在讨论)。前面说「AA 提供密码敏捷性」,在 7702 账户上要打一个精确的补丁:验签逻辑确实敏捷了,但委托权力还焊死在 ECDSA 上。

聚合器:最后的逃生口,最后一次滑动

回到 handleOps 的第一循环,那里还藏着一个和后量子直接相关的乘法。第一循环对每个 UserOp 各跑一遍验签。ECDSA 时代一次 ecrecover 才 3000 gas,无所谓;换成后量子签名,每单几百万 gas,一批 N 单就是几百万乘 N,第一循环直接被撑爆。这是 PQ 上 4337 除了单价之外的第二重困难。

4337 预留的逃生口叫聚合器(aggregator)。某些签名方案(BLS 天生支持,一部分格方案也在探索)允许把 N 个签名压成一个聚合签名,验证这一个,等价于验证了全部 N 个。协议接口是 IAggregator:标记了聚合器的 UserOp,EntryPoint 不再逐个调 validateUserOp 里的验签,而是对整组只调一次聚合器的 validateSignatures。整个系统里最贵的那一项,从乘 N 变成了除 N。

但信任根又滑了一格。逐个验签的时候,每个操作的验证是自包含的:账户验自己的签名,谁也不依赖谁。引入聚合器之后,验证变成了一个外部实体替整组做出的集体判断,你信任的对象从「我自己的验签代码」换成了「聚合器诚实聚合,而且它的 validateSignatures 没有漏洞」。聚合器作恶或者有 bug,没签过名的操作就可能混在聚合里蒙混过关,这在逐个验签的模型里根本不可能发生。协议按住它的办法毫无新意,而这正是重点:还是质押。聚合器和 paymaster、factory 是同一类实体(被多方依赖的共享基础设施),就用同一件武器。

这条路线的尽头,还留着一个至今没人填的空白:聚合的真实经济学从未被系统测算过。EntryPoint 对 validateSignatures 的调用不单独计量,没有干净的机制把集体验证的成本分摊回每个操作,bundler 只能靠抬高每单的 preVerificationGas 变相收回来。N 要多大才划算,摊薄之后每单实际省多少,bundler 的收费够不够覆盖成本,全部是空白。

总骨架:信任根的滑动与两件武器

整篇文章沉淀到最后,剩下一张图和两件武器。先看图,身份与付款的信任根九年来的完整滑动轨迹:

信任根的滑动轨迹:每次换来一种能力,就诞生一类攻击面;协议用镣铐和抵押品两件武器补救

每一次滑动都是一笔交易:交出一部分协议原生的密码学保证,换回一种新能力,同时诞生一类新攻击面。而协议的应对,来来回回只有两件武器:

  1. 给验证段戴镣铐。2938 的三道镣铐转生为 7562 沙箱,存储白名单加禁用环境操作码,买到「模拟可预测上链」这个性质,拆掉失效攻击的杠杆。
  2. 给越界实体套上抵押品。paymaster、factory、聚合器,谁想在付款前的空窗里享受特权,谁就先质押,让匿名攻击者变成有把柄的具名实体。

一句话总纲:

账户抽象的安全史,就是信任根为换取能力而不断滑动、每次滑动诞生一类新攻击面、协议每次用镣铐或抵押品去补救的历史。

这一切与后量子签名的交点

最后回到这个系列的主题。花一整篇讲账户抽象,和量子升级到底什么关系?关系在于,这张地图精确标出了痛处的位置。

后量子验签住在验证段,而验证段是全系统约束最密的窄门:gas 上限最紧,存储访问被白名单锁死,环境操作码被禁用,批量场景下每单还要在第一循环里各验一遍。几百万 gas 的 PQ 验证要闯的正是这道门。这不是巧合,是结构性的:系统对失败的全部恐惧都堆在付款分界线之前,而验签恰好住在线前。

从这张图上还能直接读出几个悬而未决的开放问题,每一个都长在本文讲过的某个结构上:

  • Falcon 变长签名的最坏情况 dummy 怎么构造,估 gas 才不会被低估;
  • 7702 的 ECDSA 后门只能协议层修,ECDSA 失活提案族的设计空间长什么样;
  • 聚合路线的经济学从未被测过,N 多大才回本没人知道;
  • EIP-8141 把 bundler 扛的模拟 DoS 接回协议自己的 mempool,情况是变好了还是更难了。

这些话题会在本系列后面的文章里各自展开,这里先把它们钉在地图上。

总结

把整篇压回一段话。以太坊的交易系统围着一个问题转:谁付 gas,由谁保证他真的会付。旧世界用最笨也最稳的答案:付款人写死为签名者,三次静态查表,执行之前就板上钉钉。账户抽象把「什么算有效交易」变成账户自己的代码,能力全部解锁:批量、代付、换签名方案,想要什么写什么。但代价是「会不会付钱」从查表变成了「执行一段任意代码才知道」,免费空窗被打开,DoS 从结构里自己长出来。此后的一切都是修补。PAYGAS 和它的转世 prefund 转账,在同一趟 EVM 执行里钉下付款分界线;线前的验证段被镣铐和沙箱锁死,买到「模拟可预测上链」;必须越界的共享设施用质押换特权;线后的执行段因为钱已锁定而彻底自由。7702 把几亿存量 EOA 接进这个世界,代价是一把永远焊在 ECDSA 上的 admin 钥匙;聚合器把最贵的验签从乘 N 变成除 N,代价是信任根再滑一格。而后量子签名的全部困难,归根结底源于一件事:它恰好要住进这张图上防守最严的那间房。

下一篇会顺着这张地图继续往前走,看原生账户抽象(EIP-8141)怎样把 bundler 溶解进协议本身,又把哪些老问题一并带了回去。