Post

账户抽象架构设计详解

本文将从零开始,逐步构建起 ERC-4337 中账户抽象的架构。本文主要面向有智能合约基础知识,但对账户抽象不甚了解的读者。文中描述的 API 和行为可能与最终版的 ERC-4337 存在部分差异。

自定义钱包

我们首先从一个简单的资产管理的场景出发:用户希望使用单个私钥签署大多数普通交易,但价格昂贵的 NFT 资产则需要同时用存放在银行保险库内的另一个私钥签署才能转移。

我们知道以太坊中的账户分为合约账户 (CA) 和外部账户 (EOA) 两类。EOA 由一个公私钥对控制,是“主动”的,可以发起交易并支付 EVM 执行的 gas 费,但只能执行基本操作(例如转账或与合约交互)。而 CA 则由智能合约的代码逻辑而非私钥控制,是“被动”的,只能响应来自 EOA 的交易,且不能支付 gas 费,但 CA 是可编程的,可以根据存储在该地址的代码执行任意逻辑。

那么问题来了:在我们的场景中,持有资产的账户应该是合约账户还是外部账户呢?

答案是:该账户必须是合约账户。因为如果是 EOA 的话,那么所有资产都可以被其私钥签署的交易随意转移,无法满足我们设定的安全需求。

因此,我们的链上身份将由一个智能合约代表,该合约被称为“智能合约钱包”。我们需要一种方式向这个钱包发出指令,使其能像 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

上述方案的缺点是用户需要运行一个单独的 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 费补偿。

由执行器而非用户的 EOA 调用智能合约钱包

这一方案的问题在于执行器需要确保钱包真的会支付 gas 费补偿。如果执行器调用 executeOp 但钱包并未退还 gas 费,执行器将不得不自己承担费用。

为了避免这种情况,执行器可以尝试在本地模拟 executeOp 操作,如使用 debug_traceCall,并检查是否会得到 gas 费补偿。在确认可以获得补偿后,执行器才会发送实际的交易。

但模拟并不总能完美预测真实的执行情况。完全有可能在模拟时钱包支付了 gas 费,但在实际将交易添加到区块时却支付失败。作恶钱包甚至会故意这样做,免费执行自己的操作并给执行器留下巨额的 gas 账单。

模拟与真实执行可能不一致的原因包括:

  • 操作可能会读取存储,而存储可能在模拟和真实执行之间发生变化。
  • 操作可能会使用像 TIMESTAMPBLOCKHASHBASEFEE 等操作码。这些操作码会从环境中读取信息,且这些信息在不同区块中是不可预测的。

执行器可以尝试限制操作的允许范围,例如拒绝所有使用“环境”操作码的操作,但这种限制过于严格。要注意,我们希望钱包能执行 EOA 能力范围内的所有操作,而禁用这些操作码会妨碍许多合理的使用场景。例如,钱包将无法与大量使用 TIMESTAMP 的 Uniswap 等 DApp 交互,这无疑与我们的初衷相悖。

由于钱包的 executeOp 可以包含任意代码,我们无法合理地对其进行限制来防止对模拟的欺骗,因此在当前接口下这个问题是无解的。executeOp 是一个黑箱,难以预测。

入口点合约

前述问题的核心在于我们要求执行器运行来自不可信合约的代码,而执行器希望在具有一定保障的环境中执行这些不可信操作——这不正是智能合约的目的吗?

因此我们将引入一个新的可信(经过审计和源代码验证)合约,称为入口点 (EntryPoint),并为其提供一个由执行器调用的方法:

1
2
3
contract EntryPoint {
    function handleOp(UserOperation op); // ...
}

handleOp 将执行以下操作:

  • 检查钱包是否有足够的资金支付可能使用的最大 gas 费(基于用户操作中的 gas 字段)。如果没有,拒绝执行。
  • 调用钱包的 executeOp 方法(使用适当的 gas 限额),并记录实际使用的 gas 量。
  • 从钱包中取出一些 ETH 支付给执行器,作为 gas 费补偿。

要实现第三点,我们需要让入口点来持有用于支付 gas 费的 ETH。因为正如前文所述,我们无法确保能从钱包中取出 ETH。因此,还需要一个方法,让钱包向入口点存入支付 gas 费的 ETH,并提供另一个方法让钱包在需要时能将 ETH 取出:

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 费。这表示钱包所有者授权执行的操作最终没有成功,就像从 EOA 发送一个回滚的交易一样,由于用户进行了授权,因此应该为 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,防止钱包在未经验证的情况下执行操作。钱包可以通过强制 executeOp 只能由入口点合约调用来防止这种情况。

为什么作恶钱包不能在 validateOp 中执行所有操作?这样如果执行失败就不会被收取 gas 费。后文将说明 validateOp 会受到严格的限制,使其不适合执行真正的操作。

模拟机制

当前实现下,当未经授权的用户为钱包提交操作时,validateOp 会失败,钱包不必支付任何费用。但执行器仍需为 validateOp 的链上执行支付 gas 费,且不会获得补偿。

作恶钱包无法再免费执行操作,但仍能令执行器为失败的操作支付 gas 费并损失资金。

在之前的模拟中,执行器会先在本地模拟操作,只有在模拟成功时才会提交链上交易调用 handleOp。但执行器无法合理地限制执行来防止模拟成功但真实交易失败的情况发生。

但在我们拆分了操作的验证和执行后,执行器只需要模拟 validateOp,就能知道是否会获得补偿。与 executeOp 不同,对 validateOp 施加更严格的限制不会影响钱包与区块链的自由交互。

具体而言,只有当 validateOp 满足以下限制时,执行器才会向链上提交用户操作:

  1. 不使用禁用列表中的操作码,包括 TIMESTAMPBLOCKHASH 等。
  2. 只访问钱包的关联存储,定义为以下任一情况:
    • 钱包自身的存储
    • 另一个合约在 mapping(address => value) 中与钱包地址对应的存储位置
    • 另一个合约中与钱包地址相同的存储位置(非常规做法)

这些规则旨在最小化模拟与实际执行不一致的风险。禁用操作码很好理解,存储限制的思路是:任何存储访问都会增加模拟失真的风险,因为该存储位置可能在模拟和执行期间发生变化。但如果将存储限制为与此钱包相关的位置,那么恶意者就需要更新与钱包相关的特定存储才能使模拟失真,其成本足以阻止恶意者。

在这种模拟机制下,钱包和执行器都是安全的。

validateOp 存储访问的限制还有另一个好处:由于 validateOp 只能访问与该钱包关联的特定存储位置,而不同钱包的关联存储位置是相互独立的,因此针对不同钱包的 validateOp 调用不会相互影响。这种互不干扰的特性对于并行处理多个钱包的操作是非常有利的,这一点对后文将讨论的“打包”操作非常重要。

钱包直接支付

目前,钱包支付 gas 费的方式是先将 ETH 存入入口点合约,然后再发送用户操作。但普通的 EOA 是直接用其 ETH 余额支付 gas 费的,我们的合约钱包也应该支持这点。

在拆分验证和执行后,入口点可以在验证步骤中要求钱包向其发送 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 费退还给钱包,而是应保留这部分 ETH,并允许钱包调用提款方法将其取出,也就是所谓的“拉取支付”模式。

多余的 gas 费会被发送到 deposit 方法接收 ETH 的位置,钱包可以使用 withdrawTo 方法将其取出。这也就意味着钱包可以从两个位置支付 gas 费:入口点合约中为其所持有的 ETH,以及钱包自身持有的 ETH。这其实类似于支付宝余额和所绑定银行账户余额间的关系。

入口点会先尝试使用存入的 ETH 支付 gas 费,如果存款不足,则在调用钱包的 validateOp 时要求支付剩余部分。

执行器激励

到目前为止,执行器需要运行大量模拟,却没有任何利润,模拟失真时还要自费支付 gas 费。因此作为补偿,我们允许钱包所有者在用户操作中附加小费,这部分费用将归执行器所有。

在用户操作结构中添加一个字段来表示这一点:

1
2
3
4
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);
}

当前用户的使用流程如下:

  1. 当钱包所有者想要执行某个操作时,会生成一个用户操作,并在链下请求一个执行器来处理。
  2. 执行器模拟钱包的 validateOp 方法,以决定是否接受该用户操作。
  3. 如果接受,执行器会发送一笔交易给入口点合约,调用 handleOp
  4. 入口点会在链上处理验证和执行操作,并从钱包的存款中退款给执行器。

打包操作

目前,执行器发送一笔交易只能执行一个用户操作,效率较低。由于入口点不与特定钱包绑定,我们完全可以收集来自不同用户的一批操作,然后在单个交易中一次性执行以节省 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,然后将 ETH 转账执行器,用于支付 gas 费。

这里需要注意的一点是,在处理操作时,入口点会先验证全部操作,然后再执行全部操作,而不是对每个操作分别进行验证和执行。这点对于模拟非常重要,如果 handleOps 时在验证下一个操作之前就执行了前一个操作,那么前一个操作的执行就有可能干扰第二个操作的验证所依赖的存储。

同样,也应当避免一个操作的验证干扰包中后续操作的验证。不过,只要包中不包含同一个钱包的多个操作,那么由于前述的存储限制,这点是很容易做到的:两个操作的验证不涉及相同的存储,就不会相互干扰。因此,执行器会确保包中每个钱包最多只包含一个操作。

在此机制下,执行器可以通过安排包中的用户操作顺序(可能插入自己的操作)来获得最大可提取价值 (MEV)。

引入打包操作后,我们将以 ERC-4337 中的术语“打包器” (bundler) 来称呼控制 EOA 的参与者,不再称其为“执行器”。

网络参与

在当前机制下,钱包所有者向打包器提交用户操作,并希望操作能被包含在一个包中。这与区块链上普通交易的机制非常相似,即账户所有者向区块构建者提交交易,希望交易能被包含在一个区块中。我们可以从两者相似的网络结构中受益。

就像节点将普通交易存储在内存池中并广播给其他节点一样,打包器可以将验证后的用户操作存储在内存池中并广播给其他打包器。打包器可以在分享用户操作之前先对其进行验证,从而节省彼此验证操作的工作量。

打包器如果同时也是区块构建者,就可以选择包会被包含在哪个区块中,从而减少甚至消除在模拟成功后执行失败的可能性。此外,区块构建者和打包器能以类似的方式通过 MEV 获利。随着时间的推移,打包器和区块构建者的角色可能会逐渐融合。

代付合约

目前,我们的钱包已经完全实现了 EOA 的功能,且允许用户自定义验证逻辑。但钱包仍然需要支付 gas 费,因此用户在进行链上操作之前需要先获取一些 ETH。

如果我们希望由其他人而非钱包所有者来支付 gas 费呢?例如下列情况:

  • 钱包所有者可能是区块链新手,在进行链上操作之前获取 ETH 有一定门槛。
  • Dapp 可能愿意为其方法支付 gas 费,以免 gas 费用吓退潜在用户。
  • 赞助商可能允许钱包使用其他代币支付 gas 费,例如USDC。
  • 出于隐私原因,用户可能希望从混币器中提取资产到一个新地址,由与其无关的账户支付 gas 费。

当谈,几遍 Dapp 愿意为其用户支付 gas 费,也不可能替所有人的所有操作付费,因而需要在链上部署自定义逻辑的合约,来查看用户操作并决定是否为该操作支付 gas 费。我们将这样的合约称为代付者 (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,否则仍使用钱包持有的 ETH。

执行器同时调用代付合约和用户的合约钱包来确定交易是否被赞助

与钱包一样,代付合约也需要先通过入口点的 deposit 方法存入 ETH,然后才能支付操作费用。

代付者质押

前文提到,打包器需要用模拟来避免执行未通过验证的操作,因为这种情况下打包器要为 gas 费买单,且不会得到钱包的补偿。

而引入代付者后,情况类似:打包器同样需要避免提交未通过代付者验证的操作。

初看之下,我们似乎可以对 validatePaymasterOp 使用与 validateOp 相同的限制(即只能访问钱包及其关联存储,且不能使用被禁止的操作码),打包器可以在模拟钱包 validateOp 的同时模拟用户操作的 validatePaymasterOp

但这里有一个问题:

由于存储限制规定钱包的 validateOp 只能访问该钱包相关的存储,因此只要操作来自不同的钱包,那么包中多个操作的验证就不会相互干扰,因为其共享的存储很少。但是代付合约的存储是被整个包中所有使用该代付者的操作共享的,这就意味着一个 validatePaymasterOp 的行为可能会导致包中许多使用该代付合约的其他操作验证失败。恶意代付者甚至可以利用这一点进行 DoS 攻击。

为了防止这种情况,我们需要引入一个信誉系统:让打包器跟踪每个代付者最近验证失败的频率,并通过限制或禁止使用该代付者的操作来惩罚经常失败的代付者。但如果恶意代付者可以轻松创建多个实例(女巫攻击),信誉系统就会失效。因此需要让代付者质押 ETH。

在入口点添加新方法来处理质押:

1
2
3
4
5
6
contract EntryPoint {
    // ...
    function addStake() payable;
    function unlockStake(); 
    function withdrawStake(address payable destination);
}

一旦质押被存入,只有调用 unlockStake 并经过一段延迟后才能取出。这些方法与之前讨论过的 depositwithdrawTo 是不同的,后者可随时取出。

此处的质押不会被罚没。其存在的目的是令潜在攻击者必须锁定大量资金才能实施大规模攻击。

postOp 方法

目前,代付合约只在操作实际运行之前的验证步骤中被调用。

但代付者可能还需要根据操作的结果做出不同的处理。例如,一个支持使用 USDC 支付 gas 费的代付者需要知道操作实际使用了多少 gas,以确定应该收取多少 USDC。

因此,为代付合约添加一个新方法 postOp,入口点将在操作完成后调用该方法,传递实际使用的 gas 量。我们还希望代付者能将验证过程中的结果数据放到 postOp 中计算,因此允许验证返回任意的 context 数据,这些数据会被传递给 postOp

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 尚未被执行,由于刚检查过 validatePaymasterOp,因此代付者能获取应得的费用。

postOp 添加旗标参数 hasAlreadyReverted 以提供更多上下文,该参数指示当前是否处于回滚过后的第二次运行中:

1
2
3
4
contract Paymaster {
    function validatePaymasterOp(UserOperation op) returns (bytes context);
    function postOp(bool hasAlreadyReverted, bytes context, uint256 actualGasCost);
}

小结

为了支持 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 字节码,指定了一个函数,当执行时会返回另一个 EVM 字节码,这个返回的字节码就是即将部署的智能合约代码。有趣的是:部署合约时,你提交的代码并不是最终合约中的代码,多次使用相同的 init code 并不保证部署的合约将具有相同的代码,因为 init code 可以读取存储或使用 TIMESTAMP 等操作码。

利用 CREATE2,我们可以让用户提供 init code,如果合约尚未存在,则由入口点部署该合约。在用户操作中添加一个新字段:

1
2
3
4
struct UserOperation {
    // ...
    bytes initCode;
}

并更新入口点 handleOps 方法中的验证部分,执行以下操作:

  • 在验证用户操作时,如果操作有非空的 initCode,则使用 CREATE2 部署一个具有该 init code 的合约。

然后像正常情况一样继续进行其余的验证:

  • 调用新创建的钱包的 validateOp 方法。
  • 如果操作有代付者,则调用代付合约的 validatePaymasterOp 方法。

这一方案实现了上面讨论的目标:用户可以部署任意合约,并且可以提前知道合约将被部署的地址。部署可以由代付者赞助,或由用户自己支付(将 ETH 存入合约将被部署的地址)。

但该方案仍存在一些缺陷,主要围绕在让用户提交的任意字节码并由入口点对其进行验证这点:

  • 当代付者查看用户操作时,无法通过分析字节码来决定是否为其付费。
  • 当用户提交字节码来部署合约时,难以验证所提交的字节码是否如其所愿。如果用户使用第三方工具部署合约,则要面临该工具作恶或被入侵的风险,如提交的 init code 中可能藏有后门,这类行为难以检测。
  • 由于 init code 可以是任意代码,很容易在打包器模拟时成功而在实际执行时失败。

我们需要一种方式让用户能在不提交任意字节码的情况下部署合约,为其他参与者提供更多保障。

工厂合约

我们不再让入口点直接使用任意字节码并调用 CREATE2,而是允许用户选择一个调用 CREATE2 的合约。这些合约被称为“工厂” (factory),专门用于创建不同类型的钱包合约。

例如,某个工厂合约专门生成保护 NFT 的钱包,另一个工厂合约则负责生成需要$\frac{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 就是一个支持聚合的常见签名方案。

这种优化对于实现 Rollup 尤其有用,因为 Rollup 的主要目标就是数据压缩,而签名聚合令签名部分可被压缩。

关于签名聚合带来的空间节省可参见 Vitalik 的推文:

聚合器

包中并非所有用户操作的签名都可以聚合。由于钱包可以使用任意逻辑来验证给定的签名,因此一个包中可能存在各种签名方案。

不同方案的签名无法聚合,因此最终包中会得到几组操作,每组使用不同的聚合方案,甚至也可能不进行聚合。我们使用多个名为聚合器 (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;
}

如果打包器有特定聚合器链下知识,则可通过硬编码签名聚合算法的本地版本来进行优化,无需运行 aggregateSignatures 的 EVM 代码。

更新入口点合约,提供新方法 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 来避免这种情况,因为我们为防止模拟失真在验证期间禁用了 TIMESTAMP。因此我们需要另外的方式来指示操作有效的时间段。

ERC-4337 给了 validateOp 返回值,钱包可以利用该值选择时间段:

1
2
3
4
contract Wallet {
   function validateOp(UserOperation op, uint256 requiredPayment) returns (uint256 sigTimeRange);
   // ...
}

此返回值表示操作有效的时间段,由两个连续的 8 字节整数表示。

ERC-4337 中还有一点说明:在验证失败的情况下,钱包应该从 validateOp 返回一个哨兵值,而不是回滚,这有助于估算 gas,因为 eth_estimateGas 无法告诉你回滚的交易使用了多少 gas。

2. 任意调用数据

我们的钱包接口是:

1
2
3
4
contract Wallet {
   function validateOp(UserOperation op, uint256 requiredPayment); 
   function executeOp(UserOperation op);
}

在 ERC-4337 中,钱包没有名为 executeOp 的方法。与之相对,用户操作中有一个 callData 字段,这被作为调用数据传递给钱包:

1
2
3
4
struct UserOperation {
   // ...
   bytes callData;
}

对于一般的智能合约,此数据的前 4 个字节被解释为函数标识符,其余部分为函数参数。

这意味着除了必需的 validateOp 方法之外,钱包可以自定义接口,用户操作可用于调用钱包中的任意方法。

同样,在 ERC-4337 中,工厂合约也没有 deployContract 方法,而是接收来自操作的 initCode 字段的任意调用数据。

3.压缩数据

用户操作包含指定代付者的字段以及要传递的数据:

1
2
3
4
5
struct UserOperation {
   // ...
   address paymaster;
   bytes paymasterData;
}

在 ERC-4337 中,二者被优化为一个字段,该字段的前 20 字节是代付者地址,其余部分是数据:

1
2
3
4
struct UserOperation {
   // ...
   bytes paymasterAndData;
}  

工厂合约也是如此:ERC-4337 将 factoryfactoryData 两个字段合并为了 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

This post is licensed under CC BY 4.0 by the author.