账户抽象之路#1-从零设计

1. 用更简单的途径理解复杂的ERC4337

帐户抽象将彻底改变我们与区块链的交互方式。但ERC-4337标准中提出的帐户抽象版本晦涩难懂,很难理解为什么有这么多参与者,以及为什么他们以这种方式进行互动。

有更简单的方法吗?

有,在这篇文章中,我将带领大家一步一步设计一个及其简单版本的账户抽象,在这一过程中,我们将看到,随着我们添加更多的需求,最终会得到一个复杂并且接近ERC-4337的抽象账户。

本文的目标读者是对智能合约有一定了解,但对账户抽象不是特别了解的人。

因为这篇文章是带领大家重新创造一个账户抽象的过程,因此会有很多API和功能的案例与ERC-4337的最终版本有所不同。

比如,在我例举出用户操作(User Operation)的字段时,请不要认为这就是真实的字段,它代表了被加工成正式版本之前的第一次尝试。

好的,让我们开始:

2. 目标:创建一个可以保护你资产的钱包

开始之前请,让我们创建一个可以保护我们最重要资产的方法,我们希望能够使用单个私钥签署大多数的交易(就像经典的EOA账户一样),但是我最珍贵的Carbonated Courage NFT应该由第二把钥匙签名后才能转移,而这个钥匙锁在一个由三头犬看管的银行金库中。

第一个问题:

每个以太坊账户要么是智能合约,要么是外部拥有账户(EOA),后者是通过链下私钥进行控制的,那么持有这些资产的账户应该是用智能合约还是 EOA呢?

事实上,资产的拥有方必须是智能合约,如果是EOA,那么资产总是可以通过EOA的私钥签名的交易来转移,这绕过了我们想要的安全性。

因此,与今天的大多数人不同,未来我们在链上的账户将由智能合约代替EOA,我们将称之为智能合约钱包(Smart Contract Wallet),或者只叫“钱包”(Wallet)。

我们需要定义一种方法来向这个智能合约钱包发出指令,以便它执行我们想要的操作。特别是,我们需要能够指挥这个智能合约钱包,以便像之前从EOA那样进行转账或者调用某些其它合约。

每个希望以这种方式保护其资产的用户都需要自己的智能合约,不能用一个大的智能合约持有多人的资产,因为生态系统的其余部分已经假设一个地址代表一个实体,无法再细分其中的某个用户。例如,如果有人想在合并钱包合约中向某人发送NFT,NFT的transferAPI将只允许发件人指定合并钱包的地址,而不是其中的个人用户。

用户操作(User Operations)

首先我需要部署一个智能合约钱包,该合约将存放我的资产,并且有一种方法,我可以向它传递任何我想让它执行的消息。

让我们将其称为用户操作(user operation 或者 user op)。

那么智能合约钱包看起来将是这个样子:

contract Wallet {
  function executeOp(UserOperation op);
}

用户操作内部是什么样子?

首先我们需要之前EOA账户发送交易(eth_sendTransaction)所需要的所有参数。

struct UserOperation {
  address to;
  bytes data;
  uint256 value; // Amount of wei sent
  uint256 gas;
  // ...
}

除此之外,我们需要提供一些东西来授权这个请求——智能钱包将查看的一段数据,以决定是否要执行操作。

就我们的NFT保护钱包来说,对于大多数的用户操作,我们会传递由主密钥签名的操作的签名。(这里我们定义主密钥可以签名除了涉及Carbonated Courage NFT外的所有操作,副密钥用于签名涉及Carbonated Courage NFT操作)

但是,如果用户操作正在转移我们珍贵的Carbonated Courage NFT,那么智能钱包将需要我们传递主密钥签名和副密钥签名。

我们还将传递一个nonce,以防止重放攻击,就是防止有人可以重新发送以前的用户操作来再次运行它:

struct UserOperation {
  // ...
  bytes signature;
  uint256 nonce;
}

这实际上就实现了目标!

只要我的NFT由这个合约持有,在没有两个密钥签名的情况下,它就不能转移。

虽然钱包可以选择如何选择signature和nonce字段,但我希望几乎所有的钱包都使用signature字段,以防止未经授权的各方伪造或篡改操作,同样地,我希望几乎所有的钱包都能拒绝一个它已经见过的nonce操作。

谁来调用智能合约钱包?

这里一个未回答的问题是如何调用executeOp(op),由于没有我私钥的签名,它不会做任何事情,我们可以让任何人尝试调用它,并且不会有任何安全风险,但我们确实需要有人真正发起调用,这个操作才能执行。

在以太坊上,所有交易都必须由EOA发起,EOA必须用自己的ETH支付gas费,才能发起调用。

我能做的是持有一个单独的EOA帐户,其唯一目的是调用我的钱包合约。虽然这个EOA不会有与钱包合约一样的双签名保护机制,但它只需要持有足够的ETH来支付我执行钱包合约的gas费即可,而更安全的钱包合约可以持有我所有有价值的资产。

因此,我们实际上只用一个非常简单的合约就实现了很大一部分帐户抽象的功能!

用户单独的EOA账户调用智能合约钱包
用户单独的EOA账户调用智能合约钱包

我所说的“钱包合约”在ERC-4337中被称为“帐户”。我觉得这很令人困惑,因为我认为每个地址都是一个帐户。我总是把这位参与者称为“钱包合约”或只是“钱包”。

3. 目标:没有单独的EOA账户

上述解决方案的一个缺点是,我需要运行一个单独的EOA帐户来调用我的钱包。如果我不想那样做呢?目前,我仍然愿意用ETH支付自己的gas费,我只是不想有两个单独的帐户。

我们说过,钱包合约的executeOp方法可以被任何人调用,所以我们可以让其他有EOA的人为我们调用它。我将把这个EOA和运行它的人称为“执行者”(executor)。

由于执行者是支付gas费的人,所以没有多少人愿意免费这样做。因此,新计划是,钱包合约将持有一些ETH,作为执行人调用的一部分,钱包将向执行人转移一些ETH,以补偿执行人使用的gas费。

“执行人Executor”不是ERC-4337术语,但它很好地描述了这位参与者的所作所为。稍后,我们将用ERC-4337使用的实际术语“打包者Bundler”替换它,但现在这样做还没有意义,因为我们目前没有进行任何打包。其他协议也可能称这个参与者为“中继者relayer”。

第一次尝试:智能钱包在执行结束退还Gas费

让我们尽量保持简单些,智能钱包的接口如下:

contract Wallet {
  function executeOp(UserOperation op);
}

我们会尝试修改executeOp的行为,以便在最后查看它使用了多少 gas,并将适当数量的 ETH 发送给执行者以支付gas费用。

执行者调用智能合约钱包,而非用户的EOA账户
执行者调用智能合约钱包,而非用户的EOA账户

模拟执行

如果我的钱包值得信赖,那么这很好用!但执行者需要确保智能钱包真的会支付退款。如果执行者调用executeOp,但钱包实际上没有退还gas费,那么执行者将承担gas费。

为了避免这种情况,执行者可以尝试在本地环境模拟executeOp操作,比如用debug_traceCall,并查看它是否真的被退还了gas费。只有这样,它才会发送实际交易。

这里的一个问题是,本地模拟并不能完美地预测未来。钱包完全有可能在模拟阶段顺利支付gas费,但在实际上链时却拒绝支付gas费。一个不诚实的钱包可以故意这样做,免费执行其操作,并让执行者支付大量的gas费。

由于以下几个原因,模拟可能与实际执行不同:

  • 操作可以从区块链存储中读取数据,存储可能在模拟时与真正执行时会有不同。

  • 该操作可以使用TIMESTAMPBLOCKHASHBASEFEE等opcode。这些opcode会从链上环境中读取信息,并且每个block都不相同,且不可预测。

执行者可以尝试的一件事是限制允许执行的操作,例如拒绝执行所有依赖于“环境”opcode的操作,但这将是一个过于严格的限制。

请记住,我们希望智能钱包能够做EOA可以做的任何事情,所以禁止这些操作码将影响太多的正常使用。例如,它会影响智能钱包与uniswap的交互,因为uniswap大量使用了TIMESTAMP

由于钱包的executeOp可以包含任何代码,我们无法合理地通过限制它,来阻止它欺骗模拟器,因此这个问题在当前状态下是无法避免的,executeOp是一个彻头彻尾的黑盒。

更好的尝试:新增入口点Entry Point

这里的问题是我们要求执行者去执行不受信任的合约代码,执行者更希望的是在授予某些保证的上下文中运行这些不受信任的操作。这就是这个智能合约的全部目的,所以我们将引入一个新的可信(即经过审计、源代码有验证)的合约,称为入口点(Entry Point),并为其提供一个执行者可调用的方法:

contract EntryPoint {
  function handleOp(UserOperation op);

  // ...
}

handleOp 会做以下事情:

  • 检查智能钱包是否有足够的资金来支付它可能使用的最大 gas 费(基于用户 op 中的 gas 字段);如果不是,则拒绝。

  • 调用钱包的 executeOp 方法(使用适当的gas),并跟踪它实际使用了多少gas。

  • 将智能钱包的一些 ETH 发送给执行者以支付 gas。

为了使第三个要点起作用,我们实际上需要入口点合约持有一定的 ETH来支付gas ,而不是钱包本身,因为正如我们在上一节中看到的那样,我们无法确定是否能够取出智能合约钱包里的ETH,来支付执行者的gas费。 因此,入口点合约还需要智能钱包(或代表钱包的人)将 ETH 放入入口点以支付其 gas 的接口,我们还需要另一个接口,以便钱包可以在以它想要的时候取回其 ETH :

contract EntryPoint {
  // ...

  function deposit(address wallet) payable;
  function withdrawTo(address payable destination);
}

通过这个实现方案,执行者无论如何都会得到gas费的退款。

引入经过审计、源代码验证的入口点合约,以确保执行者得到补偿。
引入经过审计、源代码验证的入口点合约,以确保执行者得到补偿。

这对执行者来说很棒!但这对钱包来说实际上是一个很大的问题...…

智能钱包不应该能够使用自己的 ETH, 而不是存在入口点的 ETH 来支付 gas 费用吗?是的,应该是这样!我们会谈到这一点,但在我们从下一节进行更改之前我们不能这样做,即使那样我们仍然需要存款/取款接口。另外,我们稍后需要存款/取款系统来支撑代理者的一些操作。

拆分验证和执行环节

我们之前定义的智能钱包接口是这样:

contract Wallet {
  function executeOp(UserOperation op);
}

这个接口实际上会做两件事:首先验证用户操作是否已授权,然后执行用户指定的op操作。当钱包的所有者用自己的帐户支付gas费时,这种区别并不重要,但现在我们要求执行者去付gas费,这种区分就很重要了。

我们目前的方案无论在何种情况下,钱包都会将gas费退还给执行者,但我们实际上不希望智能钱包在验证失败时付款。

如果验证失败,则意味着提交这个op的人,没有权限要求该智能钱包做任何事情。

在这种情况下,智能钱包的executeOp将正确地阻止操作,但在目前的实现方式下,智能钱包仍然需要支付 gas。

这是一个问题,因为与智能钱包无关的人可以从该钱包请求一系列操作,并用完钱包的所有gas费用。

相比之下,如果验证成功,但之后操作失败,则钱包应该被收取 gas 费用。 这表示钱包所有者授权了一项执行失败的操作,就像从 EOA 发送被拒绝的交易一样,并且因为他们授权了它,所以他们应该对产生的gas费负责。

目前这个方法的钱包接口没有提供区分验证失败和执行失败的接口,所以我们需要把它分成两部分。

我们新的智能钱包接口是:

contract Wallet {
  function validateOp(UserOperation op);
  function executeOp(UserOperation op);
}

新的实现下的入口点合约操作将是:

  • 调用validateOp ,如果失败,则停止;

  • 从智能钱包在入口点合约的存款中预留出一部分ETH ,以支付op可能使用的最大 gas 费(基于 opgas的字段),如果钱包不够,则拒绝。

  • 调用 executeOp 并跟踪它使用了多少 gas,无论这次调用成功还是失败,从我们预留的ETH资金中退还执行者的 gas,并将其余资金退还给钱包的存款处。

现在智能钱包看起来很棒!除授权的操作外,它不会收取gas费用。

拆分验证和执行操作,以区分验证失败和执行失败。
拆分验证和执行操作,以区分验证失败和执行失败。

但对于执行者来说,事情看起来又变得有风险。。。

我们应该确保未经授权的用户不能直接调用钱包上的 executeOp,防止它在未经验证的情况下采取行动。钱包可以通过强制只能由入口点调用 executeOp 来防止这种情况。

为什么不诚实的智能钱包不直接在 validateOp 中执行所有操作,这样如果执行失败,它就不会被收取 gas 费用。稍后我们将看到 validateOp 将具有很大的限制,使其不适合“真实”执行阶段调用。

重新模拟

现在,当未授权用户提交对钱包的操作时,validateOp 操作将失败,钱包无需支付gas费,但是executor还是会为validateOp的链上执行支付gas费用,不会得到相应的gas补偿。

不诚实的钱包无法再让他们的操作免费运行,但恶意的人仍然可以随时通过让执行者因失败的操作,而在 gas 上损失钱。

在前面的模拟部分,执行者先在本地尝试模拟操作,看看是否能通过,然后才提交交易,调用链上的handleOp

我们之前遇到问题是,执行者无法合理地限制用户的执行,以防止它在模拟时成功,但在真实交易中却失败。

但这次却有些不同。

现在执行者不需要模拟整个执行过程,执行的步骤被拆分成validateOpexecuteOp,执行者只需要模拟第一部分validateOp,就可以知道他是否能拿回执行的gas费,并且不像 executeOp 需要能够执行任何操作,以便钱包可以自由地与区块链交互,我们可以对 validateOp 施加更严格的限制。

具体来说,除非validateOp满足一下限制,否则执行者有权利拒绝用户提交的操作,不将其提交到链上:

  1. 它不能使用黑名单中的操作码,比如TIMESTAMPBLOCKHASH 等。

  2. 它只能获取和这个智能钱包相关的存储:

    • 智能钱包自己的存储

    • 另一个合约在映射中对应于钱包的插槽中的存储(地址=>值)

    • 另一个合约在与钱包地址相同的存储槽中的存储(这是一种不寻常的存储方案,在 Solidity 中通常不会出现)

这些规则都是为了最小化validateOp 在模拟时成功,但在实际执行时却失败的情况。

被禁止的操作码是理所当然的,但这些存储限制看起来可能有点奇怪。

这个想法是任何存储访问都代表了错误模拟的危险,因为存储槽可能在模拟和真正执行时发生改变,但是如果我们将存储限制在与这个钱包相关联的位置,那么恶意攻击者将需要更新攻击的钱包,更新此存储的成本足以阻止攻击者。

有了这个模拟,钱包和执行者都是安全的。

这种存储限制还有另一个好处,那就是我们知道调用validateOp在不同钱包上进行操作不太可能相互干扰,因为它们都可以访问的存储空间是有限的,当我们谈论打包时,这更加重要。

改进:从智能钱包付gas费

之前的策略是,智能钱包通过向入口点合约存入ETH作为gas费用,然后才能发送用户操作,但是,原来的EOA账户是通过自己的钱包支付gas费,我们的智能钱包不应该也是如此么?

我们现在可以通过拆分验证和执行环节这样做了,因为入口点可以要求钱包将 ETH 作为验证步骤的一部分发送到入口点合约,否则操作会被拒绝。

我们将更新钱包的validateOp接口,以便入口点合约可以向它请求资金,如果 validateOp 没有向入口点合约支付它请求的金额时,则入口点拒绝该操作:

contract Wallet {
  function validateOp(UserOperation op, uint256 requiredPayment);
  function executeOp(UserOperation op);
}

由于在验证时我们不知道执行期间将使用的确切gas费用,因此入口点会根据操作Op的gas字段费用,获取执行的最大gas费用,然后在执行结束的时候,入口点合约要把没用完的gas钱还给智能钱包。

但这里我们遇到了问题

在编写智能合约时,很难将 ETH 发送到任意合约,因为这样做会调用该合约上的任意代码,这可能会失败,使用不可预估的gas费,甚至尝试对我们进行重入攻击,所以我们不会直接把多余的 gas 钱打回钱包。

相反,入口点合约会保留这部分gas费用,并允许智能钱包之后通过点用一定的方法提取这部分资金。这就是拉动支付模式pull-payment pattern)。

所以我们实际上要做的是,通过deposit 方法将足额的 gas 费用存储到同一个地方,钱包可以稍后使用withdrawTo将其取出。

事实证明我们确实需要存款/取款接口系统(或者至少它的取款部分)。

这意味着钱包的 gas 支付实际上可以来自两个不同的地方:入口点合约持有的 ETH,以及智能钱包本身持有的 ETH。

入口点合约将首先尝试使用存入的 ETH 支付 gas,然后如果存入的 ETH 不够,它将在调用智能钱包的 validateOp 时索取剩余部分。

执行者激励

目前,作为执行者是一项吃力不讨好的工作。他们需要运行大量仿真模拟,却没有任何利润,有时当他们碰到用户操作Op被伪造时,他们被迫自掏腰包支付gas费。

为了补偿执行者,我们将允许智能钱包所有者提交带有用户操作时带上小费,该小费将发送给执行者。

我们将在用户操作中添加一个字段来实现这一点:

struct UserOperation {
  // ...
  uint256 maxPriorityFeePerGas;
}

与常规交易中字段一样,maxPriorityFeePerGas表示发送方愿意支付的小费额度,以便执行者优先处理他们的操作。

执行者在发送其交易,调用入口点的handleOp时,可以选择较高的maxPriorityFeePerGas,并将差额收入囊中。

入口点合约作为唯一入口

我们讨论了入口点如何成为受信任的合约以及它的作用,您可能会注意到,关于入口点的任何内容都不是限定于智能钱包或执行者的,因此,入口点可以是整个生态系统中的唯一入口。所有钱包和所有执行者都将与同一个入口点合约进行交互。

这意味着我们需要调整用户操作,以便其他人可以知道来自哪个用户,这样当操作被传递到入口点的 handleOp 时,入口点就可以知道要请求验证和执行的是哪个智能钱包。

让我们更新一下:

struct UserOperation {
  // ...
  address sender;
}

4. 回顾无EOA的流程

我们的目标是创建一个链上钱包,无需其所有者管理单独的 EOA ,也可以支付自己的 gas,现在我们已经实现了!

我们将拥有一个带有如下接口的智能钱包:

contract Wallet {
  function validateOp(UserOperation op, uint256 requiredPayment);
  function executeOp(UserOperation op);
}

我们还有一个带有如下接口的全区块链唯一入口:

contract EntryPoint {
  function handleOp(UserOperation op);
  function deposit(address wallet) payable;
  function withdrawTo(address destination);
}

当智能钱包所有者想要执行某项操作时,他们会制定一个特定的用户操作,并在链下请求执行者为他们处理。

执行者在这个用户操作上模拟钱包的validateOp方法来决定是否接受它。

如果接受,则执行程序将事务发送到入口点以调用handleOp

然后入口点处理链上操作的验证和执行,然后从该智能钱包存入的资金中,将 ETH 退还给执行者。

哇!

内容很多,但我们做到了!

5. 插曲: 打包

在介绍下一个重要功能之前,让我们花点时间进行一个非常简单的优化。

到目前为止,我们已经实现了执行者发送一个事务来执行一个用户的操作,但是现在我们有了一个不只绑定到一个智能钱包的入口点合约,我们可以通过收集来自不同人的一堆用户操作,以此来节省一些 gas费,然后在一个事务中执行它们!

这种通过打包用户操作的方式,将通过不再重复支付固定的 21,000 gas 费用来发送交易,达到降低执行冷存储访问的费用来节省 gas(在第一次交易中多次访问相同的存储更便宜)。

这又需要一些更新的改动。

我们将替换:

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

成下面的:

contract EntryPoint {
  function handleOps(UserOperation[] ops);

  // ...
}

基本上就是这样:

在单个事务中打包和执行一堆用户操作。
在单个事务中打包和执行一堆用户操作。

新的handleOps方法或多或少实现了你所期望的:

  • 对于每个操作,在操作的发送者钱包上调用validateOp 方法,丢弃任何未通过验证的操作。

  • 对于每个 op,在 op 的发送者钱包上调用executeOp 方法,跟踪操作使用了多少 gas,然后将这部分 ETH 转移给执行者以支付该 gas。

这里需要注意的一件事是,我们首先启动所有验证op,然后才启动所有执行op,而不是在继续执行下一个操作之前,去验证和执行每个操作。

这对于仿真模拟很重要。

如果在handleOps期间我们在验证下一个操作之前执行了一个操作,那么第一个op的执行会扰乱第二个op验证所依赖的存储,并导致它失败,即使第二个操作在我们模拟时通过了。

沿着类似的思路,我们希望避免一个op的验证与后面一个op的验证混淆的情况。

只要打包中不包含同一钱包的多个操作,我们实际上是免费获得的,因为上面讨论的存储限制:如果两个操作的验证不涉及相同的存储,它们就不会干扰彼此。为了利用这一点,执行者将确保一个包最多含有给定钱包的一个操作。

对执行者来说,一件好事是他们有了新的收入来源!

执行者有机会以有利于他们的方式,在打包中安排用户操作(并可能插入他们自己的操作)来获得一些最大可提取价值 (MEV) 。

现在我们有了打包,我们可以不再称这些参与者为“执行者”,而是开始用他们的真名称呼他们:打包者

为了与 ERC-4337 术语一致,我将在接下来的文章中称它们为打包者,但实际上在我的脑海中,我发现“执行者”是更好的表述方式,因为它强调了执行模式是通过从 EOA 发送交易来真正开始执行链上的操作。

打包者作为矿工

我们有一个设置,钱包所有者将用户操作提交给打包者,希望将这些操作包含在捆绑的包中。这与普通交易的设置非常相似,账户所有者将交易提交给区块构建者(矿工),希望将这些交易包含在一个区块中,因此我们可以从一些相同的网络架构中受益。

正如节点将普通交易存储在内存池(memory pool)中并将它们广播到其他节点一样,打包者也可以将经过验证的用户操作存储在内存池(mempool)中【注:这两个内存池并不相同】,并将它们广播到其他打包者。打包者可以与其他打包者共享验证后用户操作,从而节省彼此验证每个操作的工作量。

打包者也可以通过成为矿工而受益,因为如果他们可以选择他们的打包所在的区块,他们可以减少甚至消除成功模拟后在执行过程中操作失败的可能性。此外,区块的构建者(矿工)和打包者可以通过类似 MEV 方式受益。

现在,我们可以将打包者和矿工合并为同一角色了。

哇!那又将是更多的内容了。

到目前为止,我们已经弄清楚了如何创建一个智能合约钱包来保护我们最有价值的资产,以及如何依靠执行者或打包者者,来代我们调用这个智能合约钱包。

继续阅读:

接下来,你将了解到代理支付、钱包创建和聚合签名!

原文地址:

Subscribe to 0xBitFly
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.