充分利用 CREATE2

在本文中,我们将深入探讨 CREATE2 操作码及其在反事实实例化(counterfactual instantiation)以及用户引导中的应用。我们将了解如何将 CREATE2 与初始化程序、代理以及元交易等不同的技术相结合并投入应用。这些技术为创建用户身份开辟了新的方法,甚至能让我们在创建身份之前快速迭代并修复漏洞。

科普时间

如果我们利用外部账户(EOA)(译者注:即由用户直接控制的账户)或者使用原生 CREATE 操作的合约账户创建一个合约,很容易就能确定被创建合约的地址。每个账户都有一个与之关联的 nonce (译者注:可理解为交易流水号):对外部账户而言,每发送一个交易,nonce 就会随之 +1 ;对合约账户而言,每创建一个合约,nonce 就会随之 +1。新合约的地址由创建合约交易的发送者账户地址及其 nonce 值计算得到:

合约地址 = hash (发送者地址, nonce )

虽然这种模式可以让人们很容易提前算出自己的哪个 nonce 会把合约创建到哪个地址上,但你很难真正 “占住” 地址。你必须确保自己没有拿那个 nonce 来干别的,这样,等到你想要部署自己的合约(到那个地址上)时,才能真正部署上去(译者注:不然就永远错过了,因为 nonce 只增不减)。你肯定要问,为什么要 “占住” 一个地址呢?这有什么用?

请注意:此处 “占住” 的意思是为合约预留一个地址,与占住域名(domain parking)类似,唯一的差别是我们不能选择具体的地址。实际上,我们想要的是确定未来部署合约的时候它会部署到哪个地址上,希望这个地址不要因为我们中间发了多少比交易而变动。

反事实

反事实实例化 是在广义状态通道的背景下逐渐流行起来的概念。它指的是创建一个还未部署上链,但满足有可能部署上链这一事实条件的合约。正如《反事实的广义状态通道白皮书》中所定义的那样:

我们将以下情况称为反事实 X :

  1. X 可能在链上发生,但是没有发生
  2. 任何参与者都可以单方面通过执行机制促使 X 发生
  3. 参与者可以表现得好像 X 已经发生了

这意味着,你需要能够引用那些随时都有可能创建,但目前还未部署上链的合约。从这一点来看,为合约保留地址的能力很有帮助,因为在合约被部署上链之前,我们就可以(通过为合约保留的地址)引用该合约。正如 Vitalik 在 CREATE2 提议中所说的:

对于那些不存在于链上,但有可能包含由某一段 init code 创建的代码的地址来说,彼此之间能够进行实际的交互,或是通过状态通道进行反事实的交互。这对那些与合约进行反事实交互的状态通道用例而言十分重要。

在状态通道中,这意味着如果所有参与者是诚实的,我们甚至根本不需要花费 gas 来部署合约。另一方面,如果有一名参与者行为不端,另一名参与者可以将链下操作结果上链,同时将合约部署到其预期的地址。

虽然状态通道中的反事实实例化是 CREATE2 最初想要达成的目标,但是这个新的操作码还有另一个更加常见的用例。

用户引导

保留地址已经成了新用户引导流程的关键一环。用户身份合约(也称智能账户)是一种非常重要的技术。通过该技术,我们可以在某个合约中定义用户的账户,这个合约可由多个外部账户进行管理,其中每个外部账户都对应用户的一个设备。这样一来,无需像过去那样依赖于中心化的可信第三方,针对外部账户也可实现双因素认证以及账户找回技术。同时,该技术还支持元交易(也被称作保镖代理)。

然而,要实现这种身份合约技术的用户引导是很困难的。以太坊当前的用户引导已经足够复杂了:用户要先在交易所注册一个账户,随后使用法币或者其他数字货币购买 ETH;安装浏览器拓展程序或者专门的浏览器,创建一个以太坊账户,记下由 12 个单词组成的助记词,输入一串密码,之后再从交易所将 ETH 提到以太坊账户内,等等。如果在此基础上还要部署一个身份合约并将所有 ETH 都转移到该合约内,不就变得更加复杂了吗?

所幸是,事实证明,为身份合约 “保留” 地址可以解决很多此类问题。我们可以直接在浏览器中创建一次性的外部账户,这样就可以为用户免去安装 MetaMask 的麻烦。之后,我们为身份合约保留一个地址,让用户从交易所直接提款到该地址,甚至都不用等到该身份合约实际创建完成。之后,Gas station 中继者可以代表用户部署合约,并注册那个一次性的外部账户来管理该合约。部署合约的奖励可以直接从被转移的资产中支出,甚至可以让应用程序来支付(就算是它的获客成本了)。随后,用户在与该应用程序交互的过程中可以逐渐将更强健的密钥添加到他们自己的身份合约上。

总而言之,这就是对用户身份进行反事实实例化的过程,而且只有在转账后才能真正创建用户身份。

这与那些采用无障碍引导流程的项目——如 Burner Wallet 以及 Universal Logins 使用的方法是相似的。James Young 及 Chris Whinfrey 正在研究如何通过 EVM package 技术来实现这一流程,用他们的话来说就是:

CREATE2 为用户引导以及钱包管理开辟了巨大的设计空间。根据 2019 年的 Dapp 调查报告显示,超过四分之三的开发者提到用户引导是 Dapp 普及的主要障碍。Burner Wallet 等近期项目设计了一些备选方案,利用 Zooko 三角中的权衡关系来减轻生成密钥的负担(项目叫做 “burner(销毁器)” 是有原因的)。基于 CREATE2 的钱包在用户引导方面有着相似的用户体验目标。然而,假设资产是由合约而非私钥持有的,那么不仅用户拥有对资产的可编程访问权限,而且私钥也可被视为一次性的。生成合约地址与生成私钥一样简单可靠。要解决合约部署成本的悖论,一种方法是等用户资产被转移到合约地址上,再部署一个合约来持有该资产。这篇文章进一步讨论了这种技术及其原理。

其设想是,不将完全的去中心化强加给新用户,而是认为新用户更有可能倾向于灵活且对用户友好的系统——即我们所说的 “渐进式去中心化”。这类系统优先考虑用户心理,并为各种专业技术、目标以及用例提供支持。它们与用户一起成长,并向着用户激励的方向不断完善。

现在我们已经阐明了保留地址的概念,接下来一起看看如何实现吧!

走入 CREATE2 的世界

CREATE2 是在君士坦丁堡硬分叉过程中引入的新操作码,用来替代原来的 CREATE 操作码。两种操作码的主要区别在于合约地址的计算方法。新的操作码不再依赖于账户的 nonce ,而是对以下参数进行哈希计算,得出新的地址:

  • 合约创建者的地址
  • 作为参数的混淆值(salt)
  • 合约创建代码

这些参数都不依赖于合约创建者的状态。这意味着你可以尽情创建合约而无需考虑 nonce,同时还可以在有需要的时候将这些合约部署到被保留的地址。

一个重要的细节是,计算合约地址所需的最后一个参数并非合约本身的代码,而是其创建代码。该代码是用来创建合约的,合约创建完成后将返回运行时字节码(runtime bytecode)。在大多数情况下,该代码包括合约的构造函数及其参数,以及合约代码本身。这意味着,一旦确定了想要部署的合约及其构造函数的参数,你就可以选择一个混淆值,然后确定,如果要部署到链上,这个合约的地址就是那一个。这种设计可以实现很多有意思的场景。

由 CREATE2 驱动的 factory 合约

有了使用 CREATE2 操作码的公共工厂(factory)合约,所有用户都可以共享一个合约(例如身份合约)、这个合约的构造函数参数以及盐值,而且 任何人 都可以在预定义地址上部署该合约。

contract Factory {
  function deploy(bytes memory code, bytes32 salt) public returns (address addr) {
    assembly {
      addr := create2(0, add(code, 0x20), mload(code), salt)
      if iszero(extcodesize(addr)) { revert(0, 0) }
    }
  }
}

从用户到部署者都不需要任何访问控制,因为部署者要想让部署地址匹配的话,只能运行用户提供的合约创建代码。另外请注意,用户或者部署者的地址不影响合约部署地址的计算,因为计算所用的 发送者 地址是工厂合约地址,而非发起交易的用户地址。

另外,此时你可能已经注意到了,有了可再生的部署地址,你可以等到旧的合约自毁之后,在这个地址上部署一个新合约。你可以部署一个合约,等到该合约自毁之后,再在同一个地址上再次部署合约。这一特性产生出了另一种合约升级模式,在这篇文章中有深入讨论过。

虽然这种流程能派上许多用场,但是对于一些别的情形来说,必须确定合约及其构造函数参数这一点造成了极大的局限性。让我们想想有没有解决这个问题的办法。

构造函数 vs 初始化函数

正如我们之前提到的,Solidity 中的构造函数(以及它们的参数)是合约创建代码的一部分。这意味着它们将直接影响到 CREATE2 的部署地址。

另一方面,初始化函数是标准的 Solidity 函数,具备与构造函数相同的作用。初始化函数旨在初始化合约状态,并设有一个手动防护装置,以保证初始化函数不会被再次调用。

contract Multisig {
  address[] owners;
  uint256 required;
  function initialize(address[] memory _owners, uint256 _required) public {
    require(required == 0, "Contract has already been initialized");
    require(_required > 0, "At least one owner is required");
    owners = _owners;
    required = _required;
  }
}

初始化函数是常规函数,因此在合约创建后可以随时调用它们。但是,在合约创建之后,应在同一个交易里立即调用初始化函数,从而确保没有人抢先运行初始化函数并更改实例中的初始值。

初始化函数是常规函数,因此不会在合约创建代码中执行。这意味着我们可以将构造函数参数(在这里是初始化函数参数)从部署地址的计算中移除。换言之,我们可以等到部署时再选择用来初始化合约的参数。

该技术要求你使用初始化函数而非构造函数编写合约。不过,如果你使用的是 ZeppelinOS,那么告诉你一个好消息,这个操作系统用的正是初始化函数。

contract Factory {
  function deploy(bytes memory code, bytes32 salt, bytes memory initdata) public returns (address addr) {
    assembly {
      addr := create2(0, add(code, 0x20), mload(code), salt)
      if iszero(extcodesize(addr)) { revert(0, 0) }
    }
    (bool success,) = addr.call(initdata);
    require(success);
  }
}

现在,我们已经推迟了初始化参数的选择,接下来看看能否进一步改进。我们可以试一试推迟对合约本身的选择。

依旧是代理

如果你经常阅读我们博客上文章,那么相信你已经对代理非常熟悉了。代理是与用户进行交互,并保存所有应用状态的小合约,它将所有调用委托给保存着业务逻辑的 逻辑合约 执行。ZeppelinOS 的核心部分使用代理来驱动平滑升级:代理保存对逻辑合约的引用,不过随时都可以对代理进行修改,代之以另一个实现的执行代码。

充分利用 CREATE2插图

使用代理会带来一个有趣结果,即,无论代理索引的逻辑合约是什么,代理的合约代码总是相同的。在基于代理的系统中,部署 ERC20 代币、多签名或者是 Fomo3D 游戏都是一样的:你仅需要部署一个 代理 来指向相应实现。

这意味着,因为我们创建合约时用的是同样的代码(代理合约代码),所以无论我们想要创建的是什么样的(逻辑)合约,都将通过 CREATE2 获得相同的合约部署地址。我们在保持同一个初始地址不变的同时,已经将在特定地址创建什么样的合约推迟到部署时再决定了。不仅如此,我们还允许用户随时升级其身份合约。同时,我们为每一位用户部署瘦代理而非完整的身份合约,从而成功降低了部署成本。

我们的工厂合约变得稍微复杂了一点。首先,需要使用 CREATE2 创建一个新的代理,然后使用逻辑合约地址初始化该代理,接着通过该代理调用逻辑合约的初始化函数。但是,这种改变给我们带来了我们想要的灵活性。

contract Factory {
  function deploy(address logic, bytes32 salt, bytes memory initdata) public returns (address addr) {
    bytes memory code = type(Proxy).creationCode;
    assembly {
      addr := create2(0, add(code, 0x20), mload(code), salt)
      if iszero(extcodesize(addr)) { revert(0, 0) }
    }
    Proxy(addr).initialize(logic);
    (bool success,) = addr.call(initdata);
    require(success);
  }
}

虽然这种灵活性很棒,但是在不经意间将攻击向量引入了工厂合约。如果攻击者知道了用户为 CREATE2 预选的盐值,就可以在目的地址抢先调用 deploy 函数,传入不同的逻辑合约或者不同的初始化数据,让这一套合约的功能与预想的完全不同,。接下来,让我们在盐值中添加一些额外的成分,来解决这一问题。

交易发送者地址参上

如前文所述, CREATE2 使用发送者地址(在本例中指的是工厂合约地址)来计算合约创建地址。但是,我们可以通过将交易发送者的地址并入盐值,让交易发送者地址也能在合约地址计算中发挥一定作用。

我们可以先将预先选定的盐值以及部署函数的调用者地址一起进行哈希计算,并将结果(作为盐值)传入 CREATE2 ,而不是直接将预先选定的盐值传入 CREATE2。这意味着,只有 原始用户 才能调用该函数并将合约部署到他们之前保留的地址上。任何别的消息发送者都将生成不同的加盐值,从而得到不同的部署地址。

contract Factory {
  function deploy(
    address logic, bytes32 salt, bytes memory initdata
  ) public returns (address addr) {
    bytes32 newsalt = keccak256(abi.encodePacked(salt, msg.sender)); 
    bytes memory code = type(Proxy).creationCode;
    assembly {
      addr := create2(0, add(code, 0x20), mload(code), newsalt)
      if iszero(extcodesize(addr)) { revert(0, 0) }
    }
    Proxy(addr).initialize(logic);
    (bool success,) = addr.call(initdata);
    require(success);
  }
}

这是 2.3.1 版本中发布的新的 ZeppelinOS 代理工厂合约的实现方式之一。你也可以使用命令行界面的 zos create2 命令行轻松使用这一功能。

$ zos create2 --salt 42 --query
> Instance using salt 42 will be deployed at 0x123456
...
$ zos create2 MyContract --salt 42 --init initialize
> Instance of MyContract deployed at 0x123456

然而,增加了部署地址必须基于发送者地址计算得出的限制之后,我们原本想要实现的元交易这一用例就泡汤了。元交易模式是,用户广播他们想要执行的交易,中继者选择该交易并将其上链。这意味着发送者地址与用户地址是不同的,因此不会生成预期的部署地址。但是幸运的是,这个问题也是可以解决的。

可以不用发送者地址,用签名者地址嘛

我们之所以将合约部署地址与交易发送者地址绑定,只是为了验证合约创建是否遵循原始用户的规范——并验证是否有人抢先运行这些规范并提交具有相同加盐值、不同参数的合约创建交易。然而,要验证者一点,并不是非得要用户来发送交易,还可以让用户通过签名来表示同意该合约的部署。

考虑到这一点,我们可以请求对部署参数进行 签名 。请求创建合约的用户只需要在 ta 愿意部署时对参数进行签名。然后,工厂合约可以用签名者地址替代发送者地址,来计算合约的部署地址。

contract Factory {
  function deploy(
    address logic, bytes32 salt, bytes memory initdata, bytes memory signature
  ) public returns (address addr) {
    address signer = keccak256(
      abi.encodePacked(logic, salt, initdata, address(this))
    ).toEthSignedMessageHash().recover(signature);
    bytes32 newsalt = keccak256(abi.encodePacked(salt, signer)); 
    bytes memory code = type(Proxy).creationCode;
    assembly {
      addr := create2(0, add(code, 0x20), mload(code), newsalt)
      if iszero(extcodesize(addr)) { revert(0, 0) }
    }

该流程也被编码到了 ZeppelinOS 代理工厂合约中。正如我们的示例项目所示,与前文给出的例子一样,可以添加一个签名项,然后使用 zos cretate2 命令行调用该合约。

$ zos create2 --salt 43 --from 0x44
> Instance using salt 43 will be deployed at 0x654321
...
$ zos create2 MyContract --salt 43 --signature 0xabcdef --init initialize --from 0x88
> Instance of MyContract deployed at 0x654321

总而言之,这意味着任何用户都可以选择一个随机的盐值,并拥有一个唯一确定的地址可用来随时部署他们想要的合约。不仅如此,用户只需要对部署参数进行 签名 就可以直接通过他们的地址或者中继者地址执行部署程序了。

总结

感谢 CREATE2 让我们可以为用户创造零阻力的引导流程。该操作码可以让我们对用户的身份合约进行反事实实例化,并仅在必要时部署这些合约。

此外,在该操作码中添加代理可以降低我们的部署成本,同时还能将对身份合约所用的逻辑合约的选择推迟到实际需要之时。这可以让我们更加灵活地对身份合约的实现进行快速迭代,并确保用户无论在何时保留的身份合约地址,都会被直接引导至最新版本。

如果我们在身份合约实现中发现了一个漏洞,可以使用本文提到的技术来保证所有反事实创建的身份合约都是通过固定版本在链上实现的。由于我们在保留地址之时不再受具体实现的约束,就可以根据需要切换到另一个实现上。

我们将在另一篇文章中深入讲解如果构建该解决方案。与此同时,你今天就可以开始尝试用 CREATE2 进行实验。还请在论坛上与社区其他成员分享你用这个新操作码进行的尝试。

祝编程愉快!

你会发现实际的项目代码与本文所展示的代码略有不同。特别要注意的是,ZeppelinOS 的代理会将初始化数据作为其构造函数/初始化函数的一部分自动转发出去。但是为了更加清晰易懂,本文的示例略去了设置代理管理员权限的部分。

原文链接: https://blog.openzeppelin.com/getting-the-most-out-of-create2/
作者: SANTIAGO PALLADINO
翻译&校对: Aisling & 闵敏

你可能还会喜欢:

智能合约灵活升级
Argent:为什么智能合约钱包才是未来
Counterfactual 项目:广义的以太坊状态通道

评论