账户抽象架构设计详解
本文详细介绍了以太坊 ERC-4337 中账户抽象的设计思路,主要面向具备智能合约基础知识,但对账户抽象不甚了解的读者。文中描述的 API 和行为可能与最终版的 ERC-4337 存在部分差异。
自定义钱包
我们从一个资产管理的具体场景出发:用户希望对普通交易使用单一私钥签名,但对于价格昂贵的 NFT 资产,则要求额外使用存放在银行保险库中的另一把私钥才能完成转移。
在以太坊中,账户分为两类:合约账户 (CA) 和外部账户 (EOA)。EOA 由公私钥对控制,属于“主动”类型,能够发起交易并支付 EVM 执行所需的 gas 费,但仅限执行基础操作,如转账或与合约交互。相比之下,CA 由智能合约的代码逻辑控制,是“被动”类型,只能响应 EOA 发起的交易,无法支付 gas 费,但具有可编程性,可根据存储的代码执行复杂逻辑。
那么在我们假设的场景中,资产持有账户应该选择合约账户还是外部账户?
答案是必须选择合约账户。若使用外部账户,所有资产都可能被私钥签名的交易任意转移,这显然无法满足我们既定的安全需求。
因此,我们的链上身份将由一个智能合约代表,这类合约被称为“智能合约钱包”。我们需要设计一种机制,能够向该钱包发出指令,使其像 EOA 一样执行我们期望的转账或合约调用操作。
这种资产管理方式要求每个用户都必须拥有独立的智能合约。不能使用单一大型合约来集中管理多个用户的资产,因为以太坊生态的基本假设是一个地址对应一个独立实体。共享合约钱包将导致用户无法被有效区分,有违区块链追求的身份透明性和独立性原则。
用户操作
我们部署一个用来持有资产的智能合约,并提供一个方法,通过该方法可以向合约传递用户希望执行的操作的信息,我们称其为用户操作 (User Operation)。
钱包合约如下:
1
2
3
contract Wallet {
function executeOp(UserOperation op);
}
用户操作中具体包含哪些信息呢?
首先是传给 eth_sendTransaction
的参数:
1
2
3
4
5
6
7
struct UserOperation {
address to;
bytes data;
uint256 value;
uint256 gas;
// ...
}
此外,还需要提供用来授权请求的数据,钱包会根据这段数据来决定是否执行操作。
在我们的场景中,对于大部分用户操作,只需要传递主密钥对其余操作数据的签名即可。但如果用户操作是转移 NFT,那么钱包将要求用户提供两个私钥分别对操作数据的签名。
最后,还要加入一个随机数 (nonce) 以防止重放攻击:
1
2
3
4
5
struct UserOperation {
// ...
bytes signature;
uint256 nonce;
}
至此,钱包合约达成了我们的目标:用户的 NFT 由该合约持有时,没有两个私钥签名就无法被转移。
合约钱包调用
还有一个问题是如何调用 executeOp(op)
。由于没有用户的私钥签名合约不会执行任何操作,因此所有人都可以尝试进行调用,不会有安全风险。但要想执行操作,就一定得有人实际调用该方法。
在以太坊上,只有 EOA 能发起交易,进行调用的 EOA 必须使用自己的 ETH 支付 gas 费。
我们可以单独设置一个 EOA,仅用于调用钱包合约。虽然这个 EOA 没有钱包合约那样的双重签名保护,但它只需要持有足够支付调用 gas 费的 ETH 即可,大部分资产仍由更安全的钱包合约持有。
由此,我们只用一个相当简单的合约就实现了大部分账户抽象的功能。
无需 EOA
上述方案的缺点是用户需要运行一个单独的 EOA 来调用钱包合约,增加了流程的复杂性。对于希望自行支付 gas 费但不想维护两个账户的用户,该如何解决?
前面提到钱包合约的 executeOp
方法可以被任何人调用,因此用户可以请求其他拥有 EOA 的人代为调用。我们暂时称这个 EOA 及其所有者为“执行器” (executor)。
由于执行器需要承担调用产生的 gas 费用,我们可以设计一种机制:让钱包合约预留部分 ETH,并在调用过程中向执行器转账,作为 gas 费的补偿。
“执行器”并不是 ERC-4337 中的术语,但很好地描述了这个参与者的作用。后文会将其替换为 ERC-4337 中实际使用的术语“打包器” (bundler),在尚未涉及打包操作的当前阶段,“执行器”这一表述更为恰当。值得注意的是,其他协议中也常用“中继器” (relayer) 来指代类似角色。
当前钱包的接口是:
1
2
3
contract Wallet {
function executeOp(UserOperation op);
}
我们的目标是调整 executeOp
的行为,使其能在完成操作后,根据实际消耗的 gas 量向执行器支付相应的 ETH 补偿。
这一方案面临的核心挑战是确保执行器能获得 gas 费补偿。如果执行器调用 executeOp
后钱包未退还费用,执行器将不得不自行承担开支。
为避免这种风险,执行器可以先通过 debug_traceCall
等方式在本地模拟 executeOp
操作,验证是否能获得 gas 费补偿。只有确认补偿可期,执行器才会提交实际交易。
然而,模拟无法保证完美预测。实际执行中完全可能出现模拟时钱包似乎支付了 gas 费,但在区块打包时却未能成功的情况。一些恶意钱包甚至可能故意为之,利用这一漏洞免费执行操作,同时将巨额 gas 费用转嫁给执行器。
模拟与真实执行不一致的根本原因主要有:
- 操作可能涉及存储读取,而存储状态在模拟与实际执行间可能发生变化。
- 操作可能调用依赖环境的操作码,如
TIMESTAMP
、BLOCKHASH
、BASEFEE
等,这些信息在不同区块间本质上是不可预测的。
执行器可以尝试通过限制操作范围来应对,比如拒绝使用“环境”操作码的操作。但这种做法过于武断。我们的初衷是使钱包具备与外部账户相当的操作能力,过度限制将阻碍诸如与 Uniswap 等 DApp 的正常交互,这显然背离了原始设计意图。
由于 executeOp
本质上是一个可以包含任意代码的黑箱,因此我们无法合理地防止模拟欺骗,这个问题在现有接口下是无解的。
入口点合约
前述问题的本质是在于执行不可信合约代码的安全性:执行器希望在一个可控、有保障的环境中处理这些具有潜在风险的操作——这不正是智能合约的目的吗?
为此,我们引入一个(经过严格审计和源代码验证的)可信合约,称为入口点 (EntryPoint),并为其设计了一个由执行器调用的方法:
1
2
3
contract EntryPoint {
function handleOp(UserOperation op); // ...
}
handleOp
方法将执行以下流程:
- 验证钱包是否具备足够资金支付潜在的最大 gas 费(基于用户操作中的 gas 字段)。若资金不足,直接拒绝执行。
- 以适当的 gas 限额调用钱包的
executeOp
方法,并精确记录实际消耗的 gas 量。 - 从钱包中提取一定量的 ETH 作为执行器的 gas 费补偿。
为确保第三点能够顺利实施,入口点需要直接持有用于支付 gas 费的 ETH。考虑到我们无法保证能够从钱包中可靠地提取资金,我们还需要额外的方法,使钱包能向入口点存入 gas 费资金,并在需要时将资金取出:
1
2
3
4
5
contract EntryPoint {
// ...
function deposit(address wallet) payable;
function withdrawTo(address payable destination);
}
通过这种设计,我们成功解决了执行器获取 gas 费补偿的问题。然而,随之而来的是钱包需要面对的新挑战。
拆分验证与执行
我们之前将钱包的接口定义为:
1
2
3
contract Wallet {
function executeOp(UserOperation op);
}
这个单一方法实际上同时承担了两个关键功能:验证用户操作的授权性,以及执行操作中指定的具体调用。在钱包所有者自行支付 gas 费时,这种设计没有问题;但随着引入执行器支付 gas 费的模式,区分验证和执行变得尤为重要。
当前实现下,钱包无条件地向执行器退还 gas 费。然而,在验证失败的情况下,钱包本不应承担任何费用。验证失败意味着未经授权的参与者试图强制钱包执行操作。在这种场景中,尽管 executeOp
会正确阻止操作,但根据现有实现,钱包仍需支付 gas 费。攻击者可以利用这一缺陷,发起大量未经授权的操作,逐步耗尽钱包的 gas 资金。
相反,如果验证成功但操作执行失败,钱包则应支付 gas 费。这类似于从外部账户发送一个最终回滚的交易:由于操作是经过所有者授权的,因此产生的 gas 费应由钱包承担。
鉴于当前 executeOp
接口无法有效区分验证失败和执行失败,我们需要对接口进行重构。
新钱包接口设计如下:
1
2
3
4
contract Wallet {
function validateOp(UserOperation op);
function executeOp(UserOperation op);
}
相应地,入口点的 handleOp
方法将实现更复杂的逻辑:
- 首先调用
validateOp
。如果验证未通过,立即终止执行。 - 从钱包的存款中预留 ETH,用于支付可能的最大 gas 费(基于操作的 gas 字段)。若钱包存款不足,拒绝执行。
- 调用
executeOp
并精确跟踪实际消耗的 gas 量。无论操作执行成功与否,都从预留资金中向执行器退还 gas 费,并将剩余资金退还至钱包存款。
这一设计对钱包而言颇为合理,确保仅对授权操作收取 gas 费。但对执行器来说,风险反而有所增加。
为防止未经授权的用户直接调用
executeOp
,钱包可通过限制该方法仅允许入口点合约调用来维护安全性。
一个潜在的风险是作恶钱包可能试图在
validateOp
中执行全部操作,以避免在执行失败时被收取 gas 费。后文将阐明validateOp
会受到严格限制,使其不适合执行实质性操作。
模拟机制
在当前机制下,当未授权用户提交操作时,validateOp
将失败,钱包无需支付任何费用。然而,执行器仍需为 validateOp
的链上执行支付 gas 费,且无法获得补偿。尽管作恶钱包无法再免费执行操作,但仍可令执行器为失败操作支付 gas 费并遭受损失。
先前,执行器通过本地模拟预测操作成功性,仅在模拟成功时提交链上交易调用 handleOp
。但执行器难以合理限制执行,防止模拟成功而实际交易失败的情况。
现在,通过解耦验证和执行,执行器只需模拟 validateOp
即可判断是否能获得补偿。与 executeOp
不同,对 validateOp
施加严格的限制不会阻碍钱包与区块链的交互。
具体而言,执行器仅在 validateOp
满足以下条件时才提交用户操作:
- 不使用受限操作码,如
TIMESTAMP
、BLOCKHASH
等。 - 仅访问钱包的关联存储,包括:
- 钱包自身存储
- 另一个合约中以钱包地址为键的映射存储位置
mapping(address => value)
- 另一个合约中与钱包地址对应的特定存储位置
这些规则旨在最大程度地减少模拟与实际执行不一致的风险。禁用特定操作码是直接的限制,而存储访问限制的核心思路是:任何存储访问都可能导致模拟失真。通过将存储访问限制在与钱包相关的位置,恶意者若要使模拟失真,需要更新特定的关联存储,其成本足以遏制恶意行为。
这种模拟机制下,钱包和执行器都是安全的。
对
validateOp
存储访问的限制还有一项额外好处:由于validateOp
仅能访问特定钱包的关联存储,不同钱包的存储位置相互独立,这意味着针对不同钱包的validateOp
调用不会相互干扰。这种互不影响的特性为后续讨论的“打包”操作提供了重要的并行处理基础。
钱包直接支付
目前,钱包通过将 ETH 存入入口点合约来支付 gas 费,但普通的外部账户是直接使用其 ETH 余额支付的。我们的合约钱包同样应该支持这种方式。
在拆分验证和执行后,入口点可以在验证阶段要求钱包向其发送 ETH,否则拒绝执行操作,从而实现钱包直接支付 gas 费。
我们需要更新钱包的 validateOp
方法,使入口点能够向其索要资金。如果 validateOp
未向入口点支付所需金额,入口点将拒绝执行操作:
1
2
3
4
contract Wallet {
function validateOp(UserOperation op, uint256 requiredPayment);
function executeOp(UserOperation op);
}
由于验证阶段无法精确预知执行期间的确切 gas 消耗,入口点将根据用户操作的 gas 字段,要求钱包支付可能使用的最大 gas 费。执行结束后,未使用的 gas 费将退还给钱包。
在智能合约开发中,直接向任意合约发送 ETH 存在风险,这会调用该合约的代码,可能触发不可预测的代码执行、gas 消耗,甚至引发重入攻击。因此,我们采用“拉取支付”模式:多余的 gas 费将保留在入口点,由钱包主动通过提款方法取出。
多余的 gas 费将通过 deposit
方法接收,钱包可使用 withdrawTo
方法提取。这意味着钱包可以从两个来源支付 gas 费:入口点合约中为其所持有的 ETH,以及钱包自身持有的 ETH,类似于支付宝余额与所绑定银行账户余额间的关系。
入口点将优先使用已存入的 ETH 支付 gas 费,若存款不足,则在调用钱包的 validateOp
时要求支付剩余部分。
执行器激励
迄今为止,执行器需要进行大量模拟,却没有任何收益,且在模拟失真时还要自行承担 gas 费。为此,我们允许钱包所有者在用户操作中附加小费,作为执行器的补偿。
在用户操作结构中新增字段:
struct UserOperation {
// ...
uint256 maxPriorityFeePerGas;
}
maxPriorityFeePerGas
代表发送方为获得操作处理优先权愿意支付的最高 gas 价格。执行器在提交调用入口点 handleOp
方法的交易时,可以使用较低的 maxPriorityFeePerGas
,将差价作为自身收益。
单例入口点
入口点的功能与特定钱包或执行器无关,因此可作为整个生态系统的单例存在。所有钱包和执行器都将与同一个入口点合约进行交互。
为此,我们需要调整用户操作的结构,使其能指定操作针对的钱包地址,便于入口点的 handleOp
方法确定需要验证和执行的钱包:
1
2
3
4
struct UserOperation {
// ...
address sender;
}
小结
我们的目标是创建一个能够自主支付 gas 费、无需钱包所有者管理独立外部账户的链上钱包。这一目标现已实现:
钱包接口设计如下:
1
2
3
4
contract Wallet {
function validateOp(UserOperation op, uint256 requiredPayment);
function executeOp(UserOperation op);
}
区块链通用的单例入口点接口如下:
1
2
3
4
5
contract EntryPoint {
function handleOp(UserOperation op);
function deposit(address wallet) payable;
function withdrawTo(address destination);
}
用户使用流程简述:
- 钱包所有者生成用户操作,并在链下寻求执行器处理。
- 执行器模拟钱包的
validateOp
方法,判断是否接受该操作。 - 若接受,执行器向入口点合约发送交易,调用
handleOp
。 - 入口点在链上处理验证和执行,并从钱包存款中向执行器退款。
打包操作
当前模式下,执行器每笔交易仅处理单一用户操作,效率低下。得益于入口点的通用性,我们可以聚合来自不同用户的多个操作,在单笔交易中一次性执行,从而节省 gas 费——这就是打包操作 (bundling)。
通过打包用户操作,可以避免重复支付固定的 21000 gas 交易发送费用,并降低冷存储访问成本(同一交易中多次访问相同存储空间的费用比首次访问便宜)。
实现改动极为简单,只需将:
1
2
3
4
5
contract EntryPoint {
function handleOp(UserOperation op);
// ...
}
替换为:
1
2
3
4
5
contract EntryPoint {
function handleOps(UserOperation[] ops);
// ...
}
handleOps
方法将执行以下逻辑:
- 对每个操作,在其发送者钱包上调用
validateOp
。未通过验证的操作将被丢弃。 - 对每个操作,调用发送者钱包的
executeOp
,跟踪 gas 消耗,并向执行器转账以支付 gas 费。
关键是入口点将先验证所有操作,随后再执行所有操作,而非逐一验证和执行。这一设计对模拟至关重要:若在验证下一个操作前已执行前一个操作,则前一操作的执行可能干扰后续操作验证所依赖的存储状态。
同样,也需要避免一个操作的验证干扰包中后续操作的验证。所幸,只要包中不包含同一钱包的多个操作,那么依靠前述存储限制,这一点很容易实现:两个操作的验证不涉及相同存储,就不会相互干扰。因此,执行器将确保每个钱包在包中最多只有一个操作。
在此机制下,执行器可以通过调整包中用户操作的顺序(可能插入自身操作)来获得最大可提取价值 (MEV)。
引入打包操作后,我们将使用 ERC-4337 中的术语“打包器” (bundler) 来指代控制外部账户的参与者,不再称其为“执行器”。
网络参与
在当前机制下,钱包所有者向打包器提交用户操作,并期望该操作能被纳入一个数据包中。这一机制与区块链上常规交易极其相似:账户所有者向区块构建者提交交易,并希望其被收录在特定区块中。我们可以从两者近似的网络结构中获得启发。
类比于节点将普通交易存储在内存池并广播给其他节点,打包器同样可以将经过验证的用户操作储存在内存池中并传播至其他打包器。在共享用户操作前进行验证,不仅可以减少重复工作,还能提高整体网络效率。
若打包器兼任区块构建者,则能精准选择操作被包含的区块,大幅降低甚至消除模拟成功后执行失败的可能性。更有趣的是,区块构建者和打包器能以相似方式通过 MEV 获利,随着技术演进,这两个角色很可能最终融为一体。
代付合约
目前,我们的钱包已全面实现 EOA 功能,并支持用户自定义验证逻辑。不过钱包仍需支付 gas 费,这意味着用户在进行链上操作前必须先准备一定数量的 ETH。
然而在特定场景中,我们可能希望绕开钱包所有者,由他人代为支付 gas 费,例如:
- 钱包所有者可能是区块链新用户,在进行链上操作之前获取 ETH 门槛较高。
- Dapp 可能愿意为其服务分担 gas 费用,降低用户进入的心理障碍。
- 赞助商或许会允许用户使用 USDC 等替代代币支付 gas 费。
- 在追求隐私的场景下,用户可能希望从混币器提取资产至新地址,并由无关账户代为支付 gas 费。
尽管 Dapp 可能有意为用户分摊成本,但显然无法为所有用户的每一笔操作买单。因此,我们需要在链上部署具有自定义逻辑的合约,用以审核用户操作并决定是否代为支付费用。我们将这类合约称为代付者(Paymaster)。
该合约提供了一个方法用于审查用户操作并判断是否代为承担费用:
1
2
3
contract Paymaster {
function validatePaymasterOp(UserOperation op);
}
当钱包提交操作时,需要明确指出期望由哪个代付者(如有)支付 gas 费。为此,我们在 UserOperation
中新增字段,标识代付者地址,并允许钱包向代付合约传递相关的数据,以说服其买单。
1
2
3
4
5
struct UserOperation {
// ...
address paymaster;
bytes paymasterData;
}
接下来,我们将调整入口点的 handleOps
方法,以无缝整合新的代付合约机制。其核心流程如下:
- 针对每笔操作:
- 在操作发送者的钱包上执行
validateOp
。 - 若操作指定了代付合约地址,则调用其
validatePaymasterOp
。 - 一旦以上任一验证未通过,立即丢弃该操作。
- 在操作发送者钱包上调用
executeOp
,精确追踪 gas 使用量,并向执行器转账 ETH 以结算 gas 费。若存在代付者,则使用代付者的 ETH;反之,则仍以钱包自有资金支付。
- 在操作发送者的钱包上执行
代付合约也需要像钱包一样先通过入口点的 deposit
方法存入 ETH,然后才能支付操作费用。
代付者质押
在之前的讨论中,我们提到打包器需要通过模拟来规避执行未通过验证的操作,因为这种情况下打包器不仅要为 gas 费买单,还无法获得钱包的补偿。
引入代付者后,情况类似:打包器同样需要避免提交未通过代付者验证的操作。
初看之下,我们或许可以对 validatePaymasterOp
采用与 validateOp
相同的限制(仅允许访问钱包及其关联存储,并禁止使用特定操作码),打包器可以在模拟钱包 validateOp
的同时,一并模拟用户操作的 validatePaymasterOp
。
然而,这里潜藏着一个微妙的问题:
先前对钱包存储的限制确保了不同钱包的操作验证相互隔离。但代付合约的存储却被同一数据包中所有使用该代付者的操作共享,这意味着一个 validatePaymasterOp
的行为可能导致包中其他使用相同代付合约的操作验证失败。更为严重的是,恶意代付者甚至可以利用这一特性发起 DoS 攻击。
为应对这一挑战,我们需要引入一个信誉系统:让打包器追踪每个代付者最近验证失败的频率,并通过限制或禁止使用该代付者的操作来惩罚频繁失败的代付者。然而,如果恶意代付者能轻易创建多个实例(即女巫攻击),这一信誉系统将形同虚设。因此,我们需要引入 ETH 质押机制。
在入口点中新增处理质押的方法:
1
2
3
4
5
6
contract EntryPoint {
// ...
function addStake() payable;
function unlockStake();
function withdrawStake(address payable destination);
}
质押存入后,只有在调用 unlockStake
并等待一定延迟后,才能提取。这些方法区别于先前讨论的 deposit
和 withdrawTo
,后者可随时取出。
此处的质押不会被罚没。其目的是迫使潜在攻击者锁定大量资金,从而提高发起大规模攻击的门槛。
postOp
方法
目前,代付合约仅在操作实际运行前的验证阶段被调用。
然而,代付者可能需要根据操作结果做出差异化处理。举例而言,一个支持使用 USDC 支付 gas 费的代付者需要准确了解操作实际消耗的 gas 量,以精确收取 USDC。
因此,我们为代付合约新增 postOp
方法,入口点将在操作完成后调用该方法,传递实际使用的 gas 量。同时,为了让代付者能在 postOp
中利用验证阶段的结果数据,我们允许验证返回任意 context 数据:
1
2
3
4
contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bytes context, uint256 actualGasCost);
}
以上述场景为例,代付合约在批准执行前会检查用户是否有足够 USDC 支付操作费用。然而,操作执行过程中完全有可能转走钱包的所有 USDC,导致代付者最终无法获得付款。
是否可以通过预先收取最大 USDC 金额,随后退还未使用部分来规避这一问题?理论上可行,但实践中颇为复杂,因为这需要两次
transfer
调用,不仅增加 gas 成本,还会产生两个不同的transfer
事件。
代付合约需要一种机制,既能使执行完成的操作失效,又确保代付者仍能获得应得的费用。我们的解决方案是允许入口点调用两次 postOp
。
入口点将 postOp
调用作为执行钱包 executeOp
的一部分,这意味着 postOp
的回滚也将导致 executeOp
的结果回滚。在这种情况下,入口点将再次调用 postOp
,此时 executeOp
尚未执行,代付者可以获取应得的费用。
为了提供更多上下文,我们为 postOp
添加 hasAlreadyReverted
标志参数,指示当前是否处于回滚后的第二次运行:
1
2
3
4
contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}
小结
为了支持 gas 费代付,我们引入了一种新的实体类型:代付者,即部署了具有以下接口的智能合约:
为了支持 gas 费代付机制,我们引入了一种全新的实体类型:代付者,本质上是部署了以下接口的智能合约:
1
2
3
4
contract Paymaster {
function validatePaymasterOp(UserOperation op) returns (bytes context);
function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}
用户操作新增两个关键字段,赋予钱包指定代付者的能力:
1
2
3
4
5
struct UserOperation {
// ...
address paymaster;
bytes paymasterData;
}
代付者可以通过与钱包相同的方式,将 ETH 存入入口点合约。入口点随之更新 handleOps
方法:除了通过钱包的 validateOp
进行验证外,还会调用代付合约的 validatePaymasterOp
对操作进行验证。验证通过后执行操作,最后调用代付合约的 postOp
。
为妥善解决代付者验证模拟中的潜在问题,我们引入了质押系统,用于锁定代付者的 ETH。新增的入口点合约方法如下:
1
2
3
4
5
6
contract EntryPoint {
// ...
function addStake() payable;
function unlockStake();
function withdrawStake(address payable destination);
}
引入代付者后,我们已经实现了账户抽象的绝大部分核心功能。
钱包创建
一个始终悬而未决的问题是:用户究竟如何创建其钱包合约?传统的合约部署需要使用 EOA 发送一笔没有接收者的交易,并附带合约部署代码。然而,我们的初衷恰恰是让用户无需 EOA 就能与区块链交互。如果用户必须先创建 EOA 才能部署钱包合约,那么这套设计就失去了其根本意义。
我们的理想场景是:尚未拥有钱包的用户能够创建链上钱包并支付 gas 费,可以选择自行使用 ETH 支付,或通过代付者代为支付。最关键的是,用户无需创建 EOA 就能完成这一过程。
另外,用户在创建普通 EOA 时只需在本地生成私钥并声明账户即可,无需发送任何交易。我们希望合约钱包能具备同样的灵活性:在实际部署前就能确定地址并接收资产。
确定性合约地址
简而言之,我们需要在实际部署合约前就确定其最终地址。
尚未部署但已预先确定的地址,被称为反事实地址 (counterfactual address)。
实现这一目标的关键在于 CREATE2
操作码。它能根据以下三个输入,在不实际部署合约的情况下确定合约地址:
- 调用
CREATE2
的合约地址 - 任意的 32 字节盐值
- 合约的 init code
init code 是一段特殊的 EVM 字节码,其执行会返回另一段将被部署的智能合约代码。值得注意的是,即便多次使用相同的 init code,也不保证最终部署的合约代码完全相同,因为 init code 可以读取存储或使用 TIMESTAMP
等操作码。
借助 CREATE2
,我们允许用户提供 init code,并由入口点在合约尚未存在时进行部署。为此,我们在用户操作中新增字段:
1
2
3
4
struct UserOperation {
// ...
bytes initCode;
}
随后更新入口点 handleOps
方法的验证逻辑:
- 若操作包含非空
initCode
,则使用CREATE2
部署对应合约。 - 继续执行标准验证流程:
- 调用新创建钱包的
validateOp
方法 - 若存在代付者,调用代付合约的
validatePaymasterOp
方法
- 调用新创建钱包的
这一方案实现了我们的核心目标:用户可以部署任意合约,并提前知晓部署地址。部署可由代付者赞助,或用户自行支付(将 ETH 存入待部署合约地址)。
然而,这种方案存在潜在风险:
- 代付者难以通过分析字节码判断是否代付
- 用户提交的部署代码难以全面验证,存在用户借助第三方工具部署合约时可能引入后门的风险
- init code 可能导致打包器模拟成功但实际执行失败
我们需要设计一种更安全的机制,让用户能在不提交任意字节码的情况下部署合约,为所有参与方提供更充分的保障。
工厂合约
我们不再直接使用任意字节码并调用 CREATE2
,而是引入了“工厂” (factory) 合约,允许用户选择专门用于创建不同类型钱包的合约。例如,某个工厂合约专门生成保护 NFT 的钱包,另一个则负责生成需要 3/5 多签才能操作的钱包。
工厂合约提供了创建合约的方法:
1
2
3
contract Factory {
function deployContract(bytes data) returns (address);
}
工厂返回新创建合约的地址,用户可以通过模拟该方法预测合约的部署位置,从而实现我们最初的目标。
在用户操作中增加字段,如果要部署钱包,则指定使用的工厂及要传递的相关数据:
1
2
3
4
5
struct UserOperation {
//...
address factory;
bytes factoryData;
}
这样解决了之前的两个问题:
- 代付者可以选择仅为来自特定工厂的部署付费。
- 使用经过审计的工厂合约,用户得到的一定是无后门、针对特定功能的合约钱包,无需逐一审查字节码。
最后一个问题是部署代码可能在模拟时成功但执行时失败。与之前代付合约 validatePaymasterOp
方法类似,解决方案是:打包器将限制工厂仅能访问其关联存储和正在部署的钱包存储,禁止调用特定方法,并要求工厂使用入口点的 addStake
方法质押 ETH。打包器可根据工厂合约模拟失败的频率来限制或禁用该工厂。
至此,我们的架构就实现了 ERC-4337 中的所有核心功能。
聚合签名
在当前实现中,打包中的每个用户操作都需要单独验证,这种低效的验证方式会产生不必要的 gas 消耗,导致签名验证成本居高不下。
为此,我们可以引入密码学中的聚合签名技术,通过单一签名同时验证多个操作,从而显著降低 gas 开支。聚合签名方案允许将多个使用不同密钥签名的消息合并为一个统一的聚合签名。若该聚合签名通过验证,则意味着所有原始签名均有效。
BLS (Boneh-Lynn-Shacham) 签名算法是支持签名聚合的典型方案。这种优化对于 Rollup 尤其有价值,因为数据压缩正是 Rollup 的核心目标,而签名聚合能够有效压缩签名部分,进一步提升数据压缩效率。
关于签名聚合带来的空间节省,可以参考 Vitalik 在相关推文中的阐述:
Some quick theorycrafting of how much data space we can save by improving compression (especially in rollups).
— vitalik.eth (@VitalikButerin) August 4, 2022
ERC4337 signature aggregation ( https://t.co/wFJI3BEq6g ) is essential to this. pic.twitter.com/nrlcOpVJKb
聚合器
并非包中的所有用户操作签名都能聚合。由于钱包可以采用任意逻辑验证签名,因此一个打包中可能包含多种不同的签名方案。这些异构签名无法直接聚合,导致最终的打包可能被划分为多个操作组,每组采用不同的聚合方案,甚至存在不聚合的情况。
为此,我们引入多个“聚合器” (aggregator) 合约,在链上表示不同的签名聚合方案。一个聚合方案由两部分组成:聚合(如何将多个签名合并)和验证(如何校验聚合签名)。因此,聚合器合约需要提供以下两个关键方法:
1
2
3
4
contract Aggregator {
function aggregateSignatures(UserOperation[] ops) returns (bytes aggregatedSignature);
function validateSignatures(UserOperation[] ops, bytes signature);
}
钱包可自主定义其签名方案,并决定兼容的聚合器。若钱包希望参与聚合,则需对外暴露选择聚合器的方法:
1
2
3
4
contract Wallet {
// ...
function getAggregator() returns (address);
}
通过 getAggregator
方法,打包器可将使用相同聚合器的操作归类,并调用对应聚合器的 aggregateSignatures
方法计算聚合签名。操作组的结构如下:
1
2
3
4
5
struct UserOpsPerAggregator {
UserOperation[] ops;
address aggregator;
bytes combinedSignature;
}
若打包器掌握特定聚合器的链下知识,还可通过硬编码本地签名聚合算法进行优化,避免执行 EVM 中的
aggregateSignatures
代码。
相应地,入口点合约新增 handleAggregatedOps
方法,其功能与 handleOps
基本一致,但接收按聚合器分组的操作,主要差异在于验证流程:
1
2
3
4
5
contract EntryPoint {
function handleOps(UserOperation[] ops);
function handleAggregatedOps(UserOpsPerAggregator[] ops);
// ...
}
handleOps
通过调用每个钱包的 validateOp
方法执行验证,而 handleAggregatedOps
则使用各组聚合器的 validateSignatures
方法验证聚合签名。
执行者用聚合器将用户操作分组,并发送至入口点,以同时进行验证
为防止模拟失真问题,我们同样对聚合器施加了必要限制:限制其可访问的存储和可用操作码,并要求在入口点合约中质押 ETH。
至此,我们已完整实现 ERC-4337 的架构,仅在方法名和参数等细节上存在微小差异。
总结
本文从用户需求和实际使用场景出发,逐步阐述了账户抽象的架构设计思路和演进过程,最终呈现了 ERC-4337 的全部核心功能。希望本文的解析能帮助读者更深入地理解账户抽象这一复杂概念,并从中获得启发。
附录:与 ERC-4337 的差异
1. 验证时间范围
钱包希望用户操作仅在特定时间段内有效,以防止恶意打包器囤积操作,并在对自身最为有利的时刻将其打包。由于我们在验证期间禁用了 TIMESTAMP
以避免模拟失真,钱包无法直接通过检查时间戳来限制操作有效期。
ERC-4337 给了 validateOp
返回值,钱包可以利用该值选择时间段:
1
2
3
4
contract Wallet {
function validateOp(UserOperation op, uint256 requiredPayment) returns (uint256 sigTimeRange);
// ...
}
这个返回值由两个连续的 8 字节整数组成,精确定义了操作的时间窗口。值得注意的是,在验证失败时,钱包应返回一个哨兵值而非直接回滚,这有助于更准确地估算 gas 消耗,因为 eth_estimateGas
无法准确反馈回滚交易的 gas 使用情况。
2. 任意调用数据
我们的钱包接口设计为:
1
2
3
4
contract Wallet {
function validateOp(UserOperation op, uint256 requiredPayment);
function executeOp(UserOperation op);
}
而在 ERC-4337 中,这一设计被更灵活的机制替代。用户操作引入了 callData
字段,作为传递给钱包的通用调用数据:
1
2
3
4
struct UserOperation {
// ...
bytes callData;
}
对于典型的智能合约调用,callData
的前 4 字节用作函数标识符,其余部分则为函数参数。这意味着除了必需的 validateOp
方法外,钱包可以完全自定义其接口,用户操作可以调用钱包中的任意方法。
同理,工厂合约也不再有固定的 deployContract
方法,而是通过操作的 initCode
字段接收任意调用数据。
3.压缩数据
原先的用户操作结构包含代付者地址和相关数据的独立字段:
1
2
3
4
5
struct UserOperation {
// ...
address paymaster;
bytes paymasterData;
}
ERC-4337 对此进行了优化,将这两个字段合并为单一的 paymasterAndData
:前 20 字节表示代付者地址,其余部分为相关数据:
1
2
3
4
struct UserOperation {
// ...
bytes paymasterAndData;
}
类似地,工厂合约的 factory
和 factoryData
也被整合为 initCode
,进一步简化了数据结构。
参考:
[1] ERC-4337: Account Abstraction Using Alt Mempool
[2] You Could Have Invented Account Abstraction
[3] ERC 4337: account abstraction without Ethereum protocol changes