理解账户抽象#1 - 从头设计智能合约钱包

  • Tiny熊
  • 更新于 2023-03-06 09:37
  • 阅读 3971

本文提供一个新的视角来理解账户抽象(Account Abstraction)。通过从零开始设计智能合约钱包,你会明白几个简单选择之后,让 ERC-4337 如此复杂的原因。

理解账户抽象 - #1

通过从零开始设计智能合约钱包,你会明白几个简单选择之后,让 ERC-4337 如此复杂的原因。

账户抽象(Account Abstraction)即将完全改变我们与区块链的交互方式。但ERC-4337提出的账户抽象的版本艰难阅读,要理解为什么有这么多的参与者,为什么他们会以这样的方式交互,是一件很困难的事情。

会不会有更简单的东西呢?

在这篇文章中,我将介绍试图设计一个简单的账户抽象的过程,将看到随着我们增加更多的需求和解决出现的问题,我们最终会得到一个复杂度膨胀的东西,并且越来越接近于ERC-4337

这篇文章的目标受众是对智能合约有一定了解,但对账户抽象没有特别了解的人。

因为本文是在探索发明账户抽象的过程,所以会有很多时候,我描述的API或行为与ERC-4337的最终版本不一致。

例如,当我列出一个用户操作的字段时,请不要认为这些是实际的字段。这些字段代表了在最终版本之前对用户操作的第一次尝试性定义。

好了,准备好了吗?

我们开始吧。

目标:创建一个能保护我们资产的钱包

为了开始工作,让我们发明一种方法来保护我们最宝贵的资产。我们希望能够用一个单一的私钥来签署大多数交易(就像一个典型的账户),但我们无价的Carbonated Courage NFT 应该只有在我们用第二把私钥签署时才有可能转移,我们会把它锁在由”三头犬“看守的银行保险库里。

这里是第一个问题:

每个以太坊账户要么是智能合约,要么是外部拥有的账户(EOA),其中后者是由链外使用私钥控制的。持有这些资产的账户应该是智能合约还是EOA?

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

因此,与今天的大多数人不同,我们在链上的存在将由一个智能合约来代表,而不是一个EOA,我们将其称为智能合约钱包,或只是一个 "钱包"。

我们需要一种方法来向这个智能合约发布命令,以便它执行我们想要的行动。特别是,我们(从EOA 账户发起)需要能够命令智能合约进行任何形式的转账或调用。

每个希望自己的资产以这种方式得到保护的用户都需要自己的智能合约。不可能有一个大的合约持有多个人的资产,因为生态系统的其他应用假定一个地址代表一个实体。

例如,如果有人想向联合钱包合约中的某人发送NFT,NFT的转移API将只允许发送者指定联合钱包的地址,而不是其中的单个用户。

用户操作

我将部署一个钱包智能合约,它将持有我的资产,钱包需要有一个函数,以便我把我希望它进行的调用的信息传递给它。

这里把代表希望我的钱包执行的行动的数据称为用户操作(User Operation)。

因此,钱包的合约看起来像这样:

contract Wallet {
  function executeOp(UserOperation op);
}

‍用户操作的内容是什么?

首先,需要所有我们通常会传递给eth_sendTransaction的参数:

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

除此之外,我们还需要提供一些东西来授权请求 -- 也就是说,钱包将查看一块数据(签名)来决定是否要执行该操作。

对于我们的NFT保护的钱包,对于大多数用户的操作,我们会传递一个主密钥签名的操作部分的数据。

但是,如果用户操作是转移我们超级有价值的 Carbonated Courage NFT,那么钱包将需要我们传递由我们两个密钥各自签署的操作的签名。

我们还将需要有一个nonce,以防止重放攻击,即有人可以重新发送以前的用户操作来再次运行它。

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

这实际上已经达到了目的!

只要我的 Carbonated Courage NFT 被这个合约所持有,没有两个人的签名,它就不能被转让。

虽然钱包可以选择如何解释signaturenonce字段,但我希望几乎所有的钱包都使用signature字段来接收某种覆盖所有其他字段的签名,以防止未经授权的各方伪造或篡改操作。同样地,我希望几乎所有的钱包都能拒绝一个它已经使用过的nonce授权操作。

谁调用智能合约钱包?

这里有一个未回答的问题是executeOp(op)如何被调用。如果没有我的私钥的签名,它不会做任何事情,我们可以让任何人尝试调用它,不会有任何安全风险。但我们确实需要来实际调用,以使操作得以发生。

在以太坊上,所有的交易都必须来自一个EOA,而调用的EOA必须用自己的ETH来支付Gas。

我可以做的是有一个单独的EOA账户,其唯一目的是调用我的钱包合约。虽然这个EOA不会像钱包合约那样有双签名保护,但它只需要持有足够的ETH来支付我的钱包运行所需的Gas,而更安全的钱包合约可以持有我所有的宝贵财产。

因此,我们实际上只用一个相当简单的合约就得到了大部分账户抽象的功能!

img

用户使用独立EOA调用智能合约的钱包。

不错吧!

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

目标:没有单独的EOA

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

我们说过,钱包合约的executionOp方法可以被任何人调用,所以我们可以直接让其他拥有EOA的人帮我们调用它。我将把这个EOA和运行它的人称为 "执行者"。

由于执行者是支付Gas费的人,没有多少人愿意免费做这件事。所以新的计划是,钱包合约将持有一些ETH,作为执行者调用的一部分,钱包将转账一些ETH给执行者,以补偿执行者使用的任何Gas。

"执行者(Executor)" 不是ERC-4337的术语,但它很好地描述了这个参与者的工作。稍后,我们将用ERC-4337使用的实际术语 "捆绑者(bundler)"来代替它,但现在这样做还没有意义,因为我们目前还没有做任何捆绑工作。其他协议也可能把这个参与者称为 "中继者(relayer)"。

第一次尝试钱包在最后向执行者付款

让我们试着保持简单。钱包的接口是:

contract Wallet {
  function executeOp(UserOperation op);
}

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

img

执行者调用智能合约的钱包,而不是用户自己的EOA。

第一次模拟

如果我的钱包是值得信赖的,那么这就很好用了!但执行者需要确定钱包真的会支付还款。如果执行者调用executionOp,但钱包并没有真正退还Gas,那么执行者就会承担Gas费的责任。

为了避免这种情况,执行者可以尝试在本地模拟executeOp操作,可能使用debug_traceCall,看看它是否真的得到了Gas的补偿。只有这样,它才会发送实际的交易。

这里的一个问题是,模拟并不能完美地预测未来。钱包在模拟过程中支付Gas,而在交易实际被添加到区块中时却没有这样做,也是完全有可能的。一个不诚实的钱包可以故意这样做,让它的操作免费执行,并为执行者带来巨大的Gas费。

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

  • 操作可以从存储空间读取,而存储空间在模拟和执行的时候可能会发生变化。
  • 该操作可以使用TIMESTAMP、BLOCKHASH、BASEFEE等操作码。这些操作码从环境中读取信息,并在不同的区块之间发生不可预知的变化。

执行者可以尝试的一件事是限制被允许操作什么,比如拒绝任何使用任何 "环境" 操作码的操作。但这将是一个非常苛刻的限制。

请记住,我们希望钱包能够做任何EOA能做的事情,所以禁止这些操作码会阻止太多的合法使用。例如,它将阻止钱包与Uniswap交互,后者广泛地使用TIMESTAMP。

由于钱包的executeOp可以包含任意的代码,而且我们不能合理地限制它以阻止它欺骗模拟,这个问题在目前的接口上是无法解决的。executeOp 是一个复杂的黑盒。

更好的尝试:引入入口点合约

这里的问题是,我们要求执行者从一个不受信任的合约中运行代码。执行者想要的是在一个给予某些保证的背景下运行这些不受信任的操作。这是智能合约的全部目的,所以我们将引入一个新的受信任的(即经过审计、源代码验证的)合约,称为入口(EntryPoint),并给它一个方法,由执行者来代替调用。

contract EntryPoint {
  function handleOp(UserOperation op);

  // ...
}

handleOp 将做以下工作:

  • 检查钱包是否有足够的资金来支付它可能使用的最大数量的Gas(基于用户操作中的Gas字段)。如果没有足够资金,拒绝执行。
  • 调用钱包的executeOp方法(使用适当的Gas),跟踪它实际使用的Gas数量。
  • 将钱包中的一些ETH发送给执行者,以支付Gas费用。

为了使第三个要点发挥作用,我们实际上需要EntryPoint持有支付Gas的ETH,而不是钱包本身,因为正如我们在上一节看到的,我们不能确定我们能够从钱包中获得ETH。因此,入口点还需要一个方法,让钱包(或代表钱包的人)将ETH放入EntryPoint,以支付其Gas,我们将有另一个方法,钱包就可以在想要的时候将其ETH 取回来。

contract EntryPoint {
  // ...

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

有了这个实现,无论如何,执行者都会得到Gas的退款。

img

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

这对执行者来说是件好事!但对于钱包来说,这实际上是一个相当大的问题......

钱包难道不应该用自己的ETH而不是存入EntryPoint的ETH来支付Gas吗?是的,它应该! 我们会解决这个问题的,但是在我们有了下一节的变化之前,我们不能这样做,即使这样,仍然需要存款/提款系统。另外,我们以后还需要存款/取款系统来支持付款人。

分离验证和执行

我们之前将钱包的接口定义为:

contract Wallet {
  function executeOp(UserOperation op);
}

这个方法实际上做了两件事:它验证了用户操作(op)是被授权的,然后它实际执行了op所指定的调用。当钱包的主人用自己的账户支付Gas时,这种区别并不重要,但现在我们要求执行者来做这件事,这就很重要了。

我们目前的实现让钱包无论如何都要把Gas费退给执行者。但实际上,如果验证失败,我们不希望钱包付款。

如果验证失败,这意味着没有权力控制钱包的人要求钱包做一些事情。

在此案例中,钱包的executionOp将正确地阻止该操作,但在目前的实现下,钱包仍然要支付Gas。

这是一个问题,因为与钱包没有关系的人可以向该钱包请求进行一堆操作,并耗尽该钱包的所有Gas钱。

相比之下,如果验证成功,但之后的操作失败,那么该钱包应该被收取Gas费。这代表钱包所有者授权了一个行动,但结果却没有成功,就像从EOA中发送一个revert的交易一样,由于他们授权了,所以他们应该负责缴费。

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

我们的新钱包接口将是:

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

入口点的handleOp的新实现将是:

  • 调用validateOp如果失败,就此停止。
  • 从钱包的存款中留出ETH,用于支付它可能使用的最大数量的Gas(基于op的Gas字段)。如果钱包没有足够的钱,则拒绝。
  • 调用executionOp并跟踪它使用了多少Gas。无论这个调用是成功还是失败,都从我们预留的资金中退还执行者的Gas,并将其余的资金返还给钱包存款。

现在对钱包来说,事情看起来很好! 除了它授权的操作,它不会被收取Gas费。

img

将验证和执行分开,以区分验证失败和执行失败。

但对于执行者来说,事情看起来又很棘手了......

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

另一个问题:为什么一个不诚实的钱包不在validateOp函数中做所有的执行,这样如果执行失败,它就不会被收取Gas?我们马上就会看到,validateOp 将有很大的限制,使其不适合 "真正的" 操作。

再次模拟

现在,当未经授权的用户给钱包提交操作时,该操作将在validateOp中失败,钱包无需支付。但是执行者仍然要为validateOp的链上执行支付Gas,而且不会得到补偿。

不诚实的钱包不能再让他们的操作免费运行,但攻击者仍然可以在任何时候让执行者为失败的操作损失Gas钱。

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

我们遇到了问题,因为执行者无法合理地限制执行,以防止它在模拟过程中成功,但在实际交易中失败。

但这次有些不同。

执行者不需要模拟整个执行过程,现在由validateOpexecuteOp组成。它只需要模拟第一部分,validateOp,以知道它是否会得到报酬。而且,与executeOp不同的是,executeOp需要能够执行任意的操作,以便钱包能够自由地与区块链交互,我们可以对validateOp施加更严格的限制。

具体来说,除非validateOp满足以下限制,否则执行者将拒绝用户的操作,而不把它发送到链上:

  1. 它从不使用某个禁止列表中的操作码,其中包括TIMESTAMP、BLOCKHASH等代码。

  2. 它访问的唯一存储是钱包的相关存储,定义为以下任何一种:

  • 钱包自己的存储。
  • 对应于钱包的mapping(adress => value)中的插槽另一个合约的存储。
  • 另一个合约在与钱包地址相等的存储槽中的存储(这是一个不寻常的存储方案,在 Solidity 中并不自然出现)。

这些规则的目的是尽量减少验证Op在模拟中成功但在实际执行中失败的情况。

被禁止的操作码是不言而喻的,但这些存储限制可能看起来有点奇怪。

我们的想法是,任何存储访问都代表着虚假模拟的危险,因为存储槽在模拟和执行之间可以改变,但如果我们将存储限制在与这个钱包相关的位置,那么攻击者就需要更新这个钱包的特定存储来伪造模拟结果。我们希望更新这个存储空间的成本足以阻止破坏者的行为。

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

这种存储限制还有另一个好处,那就是我们知道对不同钱包的操作调用validateOp不太可能相互干扰,因为它们都能访问的存储是有限的。当我们谈论捆绑时,这将是很重要的。

改进直接从钱包中支付Gas费用

目前,钱包提供ETH购买Gas的方式是先将其存入入口点(EntryPoint),然后才发送用户操作。但一个普通的EOA从自己的ETH储备中支付Gas。我们的钱包能否也这样做?

现在可以做到这一点,因为我们已经将验证和执行分开,因为入口点可以要求钱包向入口点发送ETH作为验证步骤的一部分,否则操作会被拒绝。

我们将更新钱包的validateOp方法,这样入口点就可以要求它提供资金,如果validateOp没有向入口点支付要求的金额,入口点就会拒绝该操作。

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

由于在验证时我们不知道执行过程中会使用多少Gas,入口点根据操作的Gas字段要求执行可能使用的最大数量。然后在执行结束时,我们要将未使用的Gas钱返回到钱包中。

但这里我们遇到了一个问题。

在编写智能合约时,向任意合约发送ETH是不可靠的,因为这样做会调用该合约上的任意代码,可能会失败,使用不可预测的Gas量,甚至试图对我们进行可重入攻击。所以我们不会直接把多余的Gas钱送回钱包。

相反,我们会扣留它,并允许钱包在以后通过调用提取它来获得它。这就是拉取-支付模式

因此,我们实际上要做的是让多余的Gas钱进入与入金时发送的ETH相同的地方,而钱包可以在以后通过 withdrawTo 将其取出。

事实证明,我们毕竟需要存款/取款系统(或者至少是取款部分)。

这意味着一个钱包的Gas支付实际上可以来自两个不同的地方:EntryPoint持有的ETH,以及钱包自己持有的ETH。

EntryPoint将首先尝试使用存入的ETH来支付Gas,然后如果没有足够的存款,它将在调用钱包的验证Op时要求获得剩余部分。

执行者激励机制

目前,作为一个执行者是一项不容易的任务。他们需要运行大量的模拟,没有任何利润,有时当他们的模拟被伪造时,还被迫自掏腰包买Gas。

为了补偿执行者,我们将允许钱包所有者在他们的用户操作中提交一个小费,这个小费将交给执行者。

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

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

与普通交易中类似的字段一样,maxPriorityFeePerGas表示发送方愿意支付的费用,以使其操作得到优先处理。

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

入口点作为单例

我们谈到了入口点应该是一个可信的合约,以及它的作用。你可能会注意到,关于入口点没有任何东西是特定于钱包或执行者的。因此,入口点可以是整个生态系统中的一个单例。所有的钱包和所有的执行者都将与同一个入口点合约交互。

这确实意味着我们需要调整用户的操作,以便他们也指定他们是为哪个钱包服务的,这样当操作被传递给入口点的handleOp时,入口点将知道要求哪个钱包进行验证和执行。

让我们更新一下:

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

不在需要单独的EOA

我们的目标是创建一个链上钱包,支付自己的Gas,而其所有者不需要管理一个单独的EOA,现在我们已经实现了这个目标!

我们所拥有的是一个具有以下接口的钱包:

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);
}

当钱包所有者想要执行一个动作时,他们制作一个用户操作(UserOperation),并在链外要求一个执行者为他们处理。

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

如果它接受,执行者将发送一个交易到入口点,调用handleOp

然后入口点处理验证和执行链上的操作,之后从钱包的存款中向执行者退还ETH。

耶!

这是一个很大的问题,但我们做到了!

插曲:捆绑

在我们进入下一个大功能之前,让我们花点时间做一个令人惊讶的简单优化。

到目前为止,我们已经实现了,执行者发送一个交易来执行一个用户操作。但现在我们有了一个不只绑定在一个钱包上的入口合约,我们可以通过从不同的人那里收集一堆用户操作,然后在一个交易中全部执行,从而节省一些Gas。

这种对用户操作的捆绑,将通过不重复支付固定的21,000 的交易费用,以及降低执行冷存储访问的费用(在一个交易中多次访问同一存储,在第一次之后会更便宜)来节省Gas。

需要的修改少得令人耳目一新。

我们将取代:

contract EntryPoint {
  function handleOp(UserOperation op);

  // ...
}

用这个:

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

  // ...
}

基本上就是这样了:

img

在一个交易中捆绑并执行一堆用户操作。

新的handleOps方法或多或少做了你所期望的事情。

  • 对于每个操作,在操作的发送者钱包上调用验证Op。任何验证失败的操作都会被丢弃。
  • 对于每个操作,在操作的发送者钱包上调用executeOp,跟踪我们使用了多少Gas,然后将ETH转给执行者以支付这些Gas。

这里需要注意的是,我们首先执行所有的验证,然后才执行所有的执行,而不是验证和执行每一个操作后再进入下一个操作。

这对于保护模拟是很重要的。

如果在handleOps过程中,我们在验证下一个操作之前先执行一个操作,那么第一个操作的执行将能够自由地扰乱第二个操作的验证所依赖的存储,并导致其失败,即使第二个操作在模拟时通过验证。

按照类似的思路,我们希望避免出现一个操作的验证会扰乱捆绑中的后一个操作的验证的情况。

只要这个捆绑中不包括同一个钱包的多个操作,我们实际上就可以容易实现这个,因为上面讨论的存储限制:如果两个操作的验证不接触相同的存储,它们就不会相互干扰。为了利用这一点,执行者将确保一个捆绑包最多包含一个来自任何特定钱包的操作。

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

执行者有机会通过在捆绑物中安排用户操作(也可能插入他们自己的操作),以一种有利可图的方式获得一些最大可提取价值(MEV)

现在我们有了捆绑,我们可以不再把这些参与者称为 "执行者",而开始用他们真正的名字,捆绑者来称呼他们。

为了与ERC-4337的术语保持一致,我将在本系列的其余几篇文章称他们为捆绑者,但实际上我发现 "执行者"在我的头脑中是一个很好的方式,因为它强调他们的工作是通过从EOA发送交易来实际启动链上执行。

捆绑者作为网络参与者

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

就像节点在mempool中存储普通交易并将其广播给其他节点一样,捆绑者可以在mempool中存储经过验证的用户操作,并将其广播给其他捆绑者。捆绑者可以在与其他捆绑者共享之前验证用户操作,从而节省了彼此验证每个操作的工作。

捆绑者也可以通过成为区块构建者而受益,因为如果他们可以选择他们的捆绑物所包含的区块,他们可以减少甚至消除操作在模拟成功后执行过程中失败的可能性。此外,区块构建者和捆绑者可以通过了解如何提取MEV来获得类似的好处。

随着时间的推移,我们可能会期望捆绑者和区块构建者合并成同一个角色。

耶! 已经探索很多了

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

继续阅读

接下来,我们会继续探索 sponsored 交易Create2 钱包创建聚合签名

点赞 4
收藏 8
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

4 条评论

请先 登录 后评论
Tiny熊
Tiny熊
0x3aA0...D0A7
登链社区发起人 通过区块链技术让世界变得更好而尽一份力。