您正在查看: Ethereum 分类下的文章

名词解释:Web3 账户相关概念大梳理

刚刚结束的 Devcon 上,账户抽象算是是最热的几个话题之一,最近可以经常看到 AA / EOA / SCW / 4337 等缩写和代号在各种 talk、panel 和信息流里出现。再加上叙事开始往「Onboarding next billion users」的方向发展,一些新的形容词也开始出现在产品之前,比如 seedless / gasless / social recovery / non-custodial。相信看完这两句的你已经开始脑壳疼了,那么接下来就让我尽自己所能来帮大家梳理一下这些名词概念到底代表什么。

阅前提示:本文不是严肃的技术文档,可能会用不精确但容易理解的语言进行阐述或比喻,欢迎大家以此为起点深入探索这些技术的细节。

EOA - Externally Owned Accounts

EOA 中文叫做 外部账户,我们最熟悉的 MetaMask 生成的地址就是 EOA。它的特点是原理简单,比如生成规则是:

私钥 → 公钥 → Keccak256 哈希 → 最后 20 Bytes → 十六进制字符串(EOA 地址)

可以看出这个规则非常直接,全是由数学变换计算出来的,生成的地址内部没有任何结构和逻辑。节点验证一笔交易是否被地址 owner 授权的时候也是固定的规则:

交易签名 → ec_recover → 公钥 → (用上面的规则生成)地址 → 对比要操作的地址

对比结果一致那么验签通过,进行后续流程;不通过则直接打回,不会进一步广播交易。

EOA 的另一个设定是作为交易的发起方并支付 gas,相对应的 CA(合约账户) 只能被其他 CA 或者 EOA 调用。也就是说,EOA 是交易的触发器,一笔交易无论后面有多少合约调用,一开始都必须由一个 EOA 发起并且支付足够的 gas 才可以进行。

需要指出的是,EOA 是以太坊以及其他 EVM 兼容链(或类 EVM 链)才有的概念,严格来说包括 BTC 在内的主流非 EVM 链都没有这个设定。

CA - Contract Accounts

CA 中文叫做 合约账户(也曾被称为内部账户),我们常见的 ERC-20 代币合约、DeFi 业务合约等都有一个跟 EOA 长得很像的地址,这就是 CA。

在设定上,CA 是以太坊世界的原住民,EOA 和 ETH 是为 CA 的业务逻辑准备的触发器和燃料;实际使用下来,以太坊上除 ETH 之外的所有资产都是由 CA 承载,DeFi 等业务逻辑就更是全都由 CA 来实现。然而 CA 无法主动进行操作和支付 gas 的设定也限制了它的能力,早在 2016 年就有提案希望能让 CA 自己支付 gas。

简单来说,CA 是具备内部逻辑的以太坊账户,里面既可以是业务逻辑(Token 合约用来记账,质押合约用来放贷和清算),也可以是账户逻辑(比如 gnosis safe 的多签逻辑),而后者就是我们即将提到的「SCW - 智能合约钱包」概念。

CA 的地址规则是通过计算生成的,有 CREATE 和 CREATE2 两种方式,这里不再展开。大家只需要记住 CA 和公钥没有必然对应关系即可,比如 gnosis safe 创建的 CA 里可以设定任意多把公钥来解锁它的地址对应的资产;当然 CA 也可以不设定任何密钥,而是由其他 CA 的逻辑决定是否可以解锁,比如 DeFi 的借贷合约,只要还了钱就能取回质押的资产。

SCW/A - Smart Contract Wallet/Account

智能合约钱包 应该是字面意思最好理解的了,也就是用 CA 作为地址的钱包方案,而我们常用的 EOA 钱包方案是用前述的公钥变换结果作为地址。由于具备内部逻辑,智能合约钱包可以实现很多 EOA 无法实现的功能,比如 gas 代付,批量交易,权限管理,离线授权,社交恢复等等。

这里举几个例子来展示一下智能合约钱包的扩展潜力:

  • Gnosis safe 利用智能合约钱包架构实现多签逻辑;
  • 用户可以在一笔上链交易中同时给多个地址发送不同的 token,也可以在用 uniswap 时让 approve 和 swap 在一笔交易里完成,从而做到需要多少授权多少,避免因为过度授权造成安全隐患。
  • 用户可以给不同资产设定不同的操作权限,比如给 PFP 设定比普通 ERC-20 token 更高的操作门槛(例如需要一把由硬件钱包管理的 admin key 才能转移),这样即便日常使用的环境发生密钥泄露,黑客也无法将高价值资产转走,在安全和便利中间取得平衡。
  • 用户可以签署一个离线授权「谁能给我 100 ETH,就可以转走我的某个 BAYC」,这样不需要授权给第三方合约,用户就可以跟其他人 P2P 地完成原子交易。

AA - Account Abstraction

账户抽象 其实不是一个新概念了,最早可以追溯到 2015 年的一些讨论,当时 Vitalik 认为至少要让以太坊用来验证交易的密码学算法做到可替换,比如换成性能更优的 ed25519(详见这里),可以说 7 年来 Vitalik 和 EF 都没有停止对账户抽象方案的讨论和探索,这里有个整理好的 link tree 可以帮大家回顾一下历史。
那么账户抽象怎么理解呢?这里我引用一下 ERC-4337 里对其目标的描述

Achieve the key goal of account abstraction: allow users to use smart contract wallets containing arbitrary verification logic instead of EOAs as their primary account. Completely remove any need at all for users to also have EOAs (as status quo SC wallets and EIP-3074 both require)

可以看出以太坊对于账户抽象的期望是改变目前大多数人都在使用 EOA 的现状,希望用户转向 SCW,并且把生态对 EOA 的依赖完全去除。除了里面提到的 EIP-3074 之外,还有一个更为激进和远期的 EIP-5003,这里同样引述几段原文(有省略):

EOAs … are limited by the protocol in a variety of critical ways. These accounts do not support rotating keys for security, batching to save gas, or sponsored transactions to reduce the need to hold ether yourself. There are countless other benefits that come from having a contract account or account abstraction, like choosing one’s own authentication algorithm, setting spending limits, enabling social recovery, allowing key rotation, arbitrarily and transitively delegating capabilities, and just about anything else we can imagine.

…This EIP provides a path not to enshrine EOAs, but to provide a migration path off of them, once and for all.

不难看出,EIP-5003 的目标是一次性将 EOA 转换为 CA,让所有用户用上 SCW,彻底解决向前兼容的问题。(经过上面的名词解释,看这些缩写是不是顺畅了些?)

到这里大家对 AA 的来龙去脉和未来目标应该有所了解了。但需要指出的是,AA 这个概念不是以太坊和 EVM 专属的,很多链原生已经具备了不同程度的 AA 特性。比如 EOS / Polkadot / Near / Solona / Flow / Aptos … 甚至 BTC(单签 / 多签 / Taproot),这些链在设计时就已经将账户做成了有内部结构甚至具备权限管理能力的状态,还有 StarkNet / CKB 等具备更完善的账户抽象能力。说到这里大家不难发现,以太坊的 AA 是在解决 EOA 意外地流行带来的历史遗留问题,从而在账户层面上变得更加先进和灵活。

4337 - ERC 4337

从上面对 AA 的讨论里不难看出,ERC-4337 只是这个方向众多提案中的一个,但是为什么大家一提到 AA 或者 SCW 就会说到它呢?我们来看这个文档的副标题:

An account abstraction proposal which completely avoids consensus-layer protocol changes, instead relying on higher-layer infrastructure.

也就是说,ERC-4337 是 AA 的路线第一次从「暴力革命」转向「和平演变」,不再追求利用共识层的改变实现 AA,而是转而使用 SCW 这种用户层的方案。并且为了实现更好的互操作性,ERC-4337 定义了一些 SCW 应该实现的接口,以及元交易打包、gas 代付等基础设施的框架。它的出现让目前差异极大的各种 SCW 方案能够拥有统一的用户交互界面以及共用一些生态层面搭建的开放基础设施,有助于各种场景快速实现自己需要的 SCW 方案。另一方面,ERC-4337 的推动有助于促进生态其他参与方提升对 SCW 的兼容性,比如验签需要的 EIP-1271 和有些 DeFi 协议里定义的禁止 CA 交互的一些规则。

Seedless

这里的 seed 指的是 seed phrase,就是我们创建钱包的时候经常被要求备份的助记词。那么 seedless 的意思就是「无助记词的」,或者也可以说成「无私钥的」。注意这个「无」并不是实际意义上的没有密钥,而是指不需要用户备份助记词 / 私钥或者感知到它们的存在。

一个常见的问题是,如果用户不备份助记词,用户是不是就没有账户的控制权了?一旦用户切换新设备环境,账户不就无法访问了吗?没错,只是把用户备份助记词的功能砍掉的话只能算是产品设计失误,而 seedless 追求的是用户「不需要」知道助记词的存在,同时依然拥有账户的完全控制权。也就是说,用户(且只有用户自己)拥有在新设备自主恢复账户控制的能力,只是不再依赖助记词这种 UX 很差、过于 geek 的方式,比如下面要讲到的社交恢复就是非常好的一种。

Gasless

这里的 gas 指的是 gas fee,所以 gasless 的意思是「免 gas fee 的」。同样的,gasless 也不是真的不需要支付 gas fee,而是指用户不需要被迫去了解 gas 概念,更不用提前购买各种原生代币来支付 gas。

那么 gas 谁来付?分两种情况:

一种是用户账户里已经有 crypto asset 的时候,比如 play to earn 得到 token,或者领到的空投,亦或是别人的转账,只要这些 token 有一定的价值和流动性,就会有 relayer 愿意接受它们并帮用户支付 gas,以此赚取收益。

另一种是用户账户里没有有价 token,比如刚刚创建的账户。如果此时需要链上交互,应用方可以选择资助用户一些「定向」用途的 gas 来帮他们 bootstrap,从而降低用户流失,这时即便算上 gas 补贴的消耗,整体的用户获取成本反而可能会更低;或者可以通过让用户观看广告等方式来换取一些 gas。这两种策略在 gas 成本较低的 L2 上都非常有效。

Social Recovery

社交恢复 是指利用社交关系帮助用户在丢失密钥的情况下重新获得账户访问权的机制。如果你用微信登录过新设备,应该有过「让你的两个朋友发送 xxx 给你的账号以登录」的体验——这就是社交恢复想达到的效果,只不过验证方从微信变成了智能合约。

一种常见的误区是把利用社交账号来创建 / 登录钱包的方案称为社交恢复,这是错把「社交关系」与「社交平台账号」划了等号。老牌智能合约钱包 Argent 就内置了社交恢复能力,它要求你的 guardian 提供一个以太坊地址,从而在你需要登陆新设备时提供签名来进行授权,然而这一方案的潜在设定就是:你的 guardian 一定比你在管理以太坊账户上更专业,否则当你需要他们签名的时候,如果他们自己的账户已经无法访问,你的账户也会连带遭殃。所以一种更加可行的办法是利用 email 的密码学证明(DKIM Signature)或者电子护照等生活中常见的密码学工具来增强社交恢复方案的实用性。

Non-custodial

非托管 可以说是 crypto 行业最政治正确、也是被滥用最多的概念之一了,因为很多时候各家都会有自己的定义。这里我也分享一下我们对非托管的定义,主要有两方面:

  • 钱包开发商无法擅自操作用户的账户
  • 钱包开发商无法阻止用户操作自己的账户

如果你也认同这两点,那么判断一个钱包是托管、半托管还是非托管就可以直接拿这两个规则去检验了:

不满足 1 → 托管;满足 1 不满足 2 → 半托管;1、2 都满足 → 非托管。

那么知道了是哪种托管程度有什么用吗,用户可能并不 care 背后的原理,只要好用就行了呗!没错,其实我也部分认同这种观点,至少在现在的阶段,行业还没有发展到发生用户认知范式转移的程度。其实我认为三种类型的方案分别适用于不同的场景:

  1. 托管方案 - 适用于交易所、大机构金服、强合规等场景,比如 coinbase 提供的一些服务。特点是用户量少,不需要应对高频交互,而且客单价高,能支撑服务商花费大成本来维护一系列高防系统。
  2. 半托管方案 - 适用于相对高端的个人用户群体。他们明白服务方可以审查自己的交易,并且有能力提前准备备份方案(比如导出私钥),在服务方主动或被动拒绝服务时可以不影响自己的资产安全。这样日常使用时可以享受安全和便利,极端情况下可以保全资产。注意这种方案对服务商的运维能力要求也非常高,毕竟个人用户量大,日常跟各种应用的交互需求也更高,再就是对数据可用性要求高,毕竟一旦丢失服务端保存的数据有可能导致所有没备份的用户永远无法访问账户。
  3. 非托管方案 - 适用于面向 mass adoption 的场景。初听上去可能是反直觉的,但是从成本上讲,非托管方案是唯一能够在低客单价的场景里保证足够的安全性和可用性的方案。如果一个面向大规模用户场景的应用方打算选择上面两种方案,就一定要考虑对方能否为自己的用户群提供足够安全可用的服务,否则一旦内部人员作恶、黑客入侵或不可抗力导致服务停摆,自己的所有用户都会受到牵连,自己的业务也可能因此一蹶不振。历史上的无数次案例都在讲述一个故事,安全无小事,为用户负责就是为自己负责。

MPC - Multi-Party Computation

多方安全计算 跟 零知识证明(ZKP)可以并称当下 Web3 两大「魔法」,一旦跟它们沾边,似乎原来做不到的事情 somehow 就能做了。实际上有些情况是这样的,尤其是 ZKP,可以利用概率换可行性;MPC 则是通过分散控制权来达成风控或者灾备能力。

MPC 其实是一种范式,包含很多技术方案,在目前 Web3 的语境下大都指的是 tss。

TSS - Threshold Signature Scheme

门限签名 是一种分布式多方签名协议,包含分布式密钥生成、签名,以及在不改变公钥的情况下更换私钥碎片的 re-sharing 等算法。

一个 m-n 的 tss 指的是一个公钥对应了 n 个私钥碎片,其中 m 个碎片的联合签名可以被公钥验签成功。不难发现这个逻辑类似于多签(multi-sig),他们的区别主要在公钥的数量上。

举例来说,2-2 的多签是一个门上挂了 2 把锁,必须用两个钥匙把它们都打开才能开门;2-2 的 tss 是一个门上挂了 1 把锁,但是钥匙有两片,合起来用才能打开门。这里为了好理解,描述并不严谨,两把钥匙合成一把其实更符合 Shamir Secret Sharing 算法的情况;tss 算法下的密钥碎片是不会相遇的,而是它们分别签名之后,通过特定算法可以用对应的公钥验签通过。

那么 tss 是不是一定是托管或者非托管的?其实没有必然联系,主要看最终的方案如何设计和取舍。非托管方案要求用户拥有独立操作账户的能力,所以用户必须掌握不少于门限数量的密钥碎片,例如 2-3 的话用户需要掌握 2 片,而 2-2 的方案无法达成非托管,最多可以做到半托管(比如 ZenGo);但是如果用户管理最多的私钥碎片,那么势必会提高对用户能力的要求,很难做到 mass adoption。

写到这里应该把常见的 Web3 账户相关的名词都覆盖到了,数了一下字数也有差不多 5k 了。这么多的内容难免有错误和疏漏的地方,还请大家不吝拍砖,发现问题或者有不同观点直接来 Twitter 找我提就好(@frank_lay2),后面有内容增改或更新我也会在 Twitter 上及时跟大家同步。

原文链接:https://mirror.xyz/zhixian.eth/dACTTYPzEfRcF6jSE_iwJsnbNmN2Ier_NA_TzkZaOeM
转载自:https://learnblockchain.cn/article/4917

Polygon Hermez

在传统货币理论中存在“不可能三角”,即一国无法同时实现货币政策的独立性、汇率稳定与资本自由流动,最多只能同时满足两个目标,而放弃另外一个目标。

相类似,当前的区块链技术也存在“不可能三角”,即无法同时达到可扩展(Scalability)、去中心化(Decentralization)、安全(Security),三者只能得其二。

  • 可扩展性:每秒可以处理大量交易。
  • 去中心化:拥有大量参与区块生产和验证交易的节点。
  • 安全性:获得网络的多数控制权需要非常高昂的成本。

目前很多区块链会在三者中有所权衡,比如以太坊和比特币比较关心的就是去中心化和安全性。而有一些新公链更注重的是可扩展性和安全性。

从比特币创世开始,一直到以太坊网络中Crypto Kitties游戏的出现。主流公链项目最被人诟病的地方就是低下的TPS,以太坊15左右的TPS完全无法给大多数应用提供实时稳定的支持,这与当前互联网行业动辄上万TPS的业务形成了鲜明的对比。
扩展性也许是排在第一位的问题。扩展性问题已经成为很多系统的坟墓。这是一个重大而艰巨的挑战。-Vitalik Buterin

zk-Rollup

对于以太坊而言,过去几年内关于以太坊扩容的方案不断出现。其主流的方案如下所示:

链上扩容

分片(Sharding)技术

Sharding一词本来源于数据库的术语,表示将大型数据库分割为很多更小的、更易管理的部分,从而能够实现更加高效的交互。区块链分片是指对区块链网络进行分片,从而增加其扩展性。根据最新的以太坊2.0规范,以太坊区块链会被分为1024个分片链,这也意味着以太坊的TPS将提高1000倍以上。但目前Sharding方案仍然在跨分片通信、欺诈识别、随机分配与选举安全性等方面存在不足。

链下扩容

状态通道(State Channel)

指代用于执行交易和其他状态更新的“链下”技术。但是,一个状态通道内发生的事务仍保持了很高的安全性和不可更改性。如果出现任何问题,我们仍然可以回溯到链上交易中确定的稳定版本。

侧链(Sidechain)技术

侧链是平行于主链的一条链,由侧链上的验证者把一条链的最新状态提交给主链上的智能合约,这样持续推进的一类系统。侧链通常使用PoA(Proof-of-Authority)、PoS(Proof of Stake)等高效的共识算法。它的优势在于代码和数据与主链独立,不会增加主链的负担,缺陷在于它的安全性弱、不够中心化,无法提供审查抗性、终局性和资金所有权保证。

Rollup技术

顾名思义,就是把一堆交易卷(Rollup)起来汇总成一个交易,所有接收到这个交易的节点只去验证执行结果,而不会验证逻辑。因此Rollup交易所需Gas费会远小于交易Gas费总和,TPS也增加了。主流的Rollup技术可以分为两类:

zk-Rollup

基于零知识证明的Layer2扩容方案,采用有效性验证方法(VP),默认所有交易都是不诚实的,只有通过有效性验证才会被接受。ZkRollup在链下进行复杂的计算和证明的生成,链上进行证明的校验并存储部分数据保证数据可用性。

Optimistic Rollup

乐观的Rollup协议,采用欺诈证明方法,即对链上发布的所有Rollup区块都保持乐观态度并假设其有效,它仅在欺诈发生的情况下提供证据。乐观Rollup的优势在于能使得原生Layer1上的solidity合约可以无缝移植到Layer2,从而最大程度提升了技术人员的研发体验,目前主流方案包括Optimism和Arbitrum。

Plasma方案

通过智能合约和Merkle树建立子链,每个子链都是一个可定制的智能合约,子链共存并独立运行,从而大幅降低主链的TPS压力。

从中长期来看,随着 ZK-SNARK 技术的改进,ZK rollups 将在所有用例中胜出。— Vitalik Buterin

zkEVM

ZK-Rollup早期为人诟病的地方是不能兼容 EVM,不能支持智能合约功能,例如 Gitcoin 捐赠主要支付途径的 zkSync 1.0 仅能支持转账等基本功能。同时,由于不同 ZK 应用有各种专用电路,无法相互调用,可组合性差。因此市场急需能够支持以太坊智能合约的ZK-Rollup,而其中关键门槛就是能够支持零知识证明的虚拟机。随着引入 EVM 兼容的 zkVM,zk-rollups 才开始支持以太坊 dApps。

Comparison

由于 ZK-EVM 并没有统一的设计标准,所以每个项目方基于不同角度在兼容 EVM 和支持 ZK 之间权衡设计出各自方案,目前基本分为两种思路:

  1. 编程语言层面支持,自定义 EVM 操作码,把 ZK-friendly 的操作抽出来重新设计新的、架构不同的虚拟机,通过编译器将 Soilidity 编译成新的虚拟机操作码
  2. 字节码层面支持,支持原生 EVM 操作码

对于第一种策略,由于不受原有 EVM 指令集的约束,可以更灵活的将代码编译成对零知识证明更友好的指令集,同时也摆脱了兼容所有 EVM 原有指令集所需要的艰巨而繁重的工作。

对于第二种策略,由于完全支持了 EVM 现有的指令集,其使用的是和 EVM 一样的编译器,因此天然就对现有的生态系统和开发工具完全兼容,同时还更好的继承了以太坊的安全模型。

第一种思路更灵活,工作量更小,但需要花费额外精力在适配上;第二种思路工作量相对来说会大一些,但是兼容性更好,安全性更高。

Starkware zkEVM

Starkware 的 ZK-Rollup 通用解决方案 StarkNet 可以运行任意的以太坊 dApp。开发者可以通过编译器将 Solidity 编译成 StarkNet 的智能合约语言 Cairo,再部署到其 ZK-friendly 的 VM。

zkSync zkEVM

类似 Starkware,zkSync 2.0 通过开发编译器前端 Yul 和 Zinc 来实现 ZK-EVM 功能。Yul 是一种中间 Solidity 表示,可以编译为不同后端的字节码。Zinc 是用于智能合约和通用零知识证明电路的基于 Rust 的语言。它们都是基于开源框架 LLVM,能够实现最高效的 ZK-EVM 字节码。

https://miro.medium.com/max/1400/0*S3TKmlfGRTx5MNkE
与 StarkNet 一样,zkSync zkEVM 在语言层面实现了 EVM 的兼容性,而不是在字节码层面。

Polygon zkEVM

Polygon Hermez 是一个具有 zkVM 的 Polygon zk-rollup,旨在支持 EVM 的兼容性。为此,EVM 字节码被编译成 「微操作码(micro opcodes)」 并在 uVM 中执行,uVM 使用 SNARK 证明和 STARK 证明来验证程序执行的正确性。

Scroll zkEVM

Scroll 是一个EVM等效的zk-Rollup,可以实现与以太坊字节码级别的兼容性,也就是说,所有的EVM操作码和基础层完全相同。Scroll 团队计划为每个 EVM 操作码设计零知识电路。

https://github.com/privacy-scaling-explorations/zkevm-circuits

"Scroll design, architecture, and challenges" (Ye Zhang, Scroll)

Which is better?

https://vitalik.ca/general/2022/08/04/zkevm.html
在 Vitalik 的博文里,他将 ZK-EVM 分为几种类型。其中,类型 1 是直接在以太坊上面直接开发 ZK-EVM,这个开发过于复杂而且目前效率太低,以太坊基金正在研究中。类型 2、类型 2.5 和类型 3 是 EVM 等效的 ZK-EVM,Scroll 和 Polygon Hermez 目前处于类型 3 这个阶段,朝着类型 2.5 乃至类型 2 努力。类型 4 是高级语言兼容的 ZK-EVM,包括 Starkware 和 zkSync。这些类型并无好坏之分,而且 ZK-EVM 也没有统一的标准。

从理论上讲,以太坊不需要为 L1 使用单一的 ZK-EVM 实现进行标准化;不同的客户可以使用不同的证明,因此我们继续从代码冗余中受益。— Vitalik Buterin

Overview

Polygon zkEVM主要包含以下组件:

Proof of Efficiency (PoE) Consensus Mechanism

  • zkNode
    • Synchronizer
    • Sequencers & Aggregators
    • RPC
  • zkProver
  • Bridge

PoE

https://wiki.polygon.technology/ko/assets/images/fig2-simple-poe-7cb9c3761a3d3c6482eefba525598cd2.png
Proof-of-Efficiency(PoE)共识算法分2步实现,由不同参与者完成:

1)第一步的参与者称为Sequencer。sequencer负责将L2的交易打包为batches并添加到L1的PoE智能合约中。任何运行zkEVM-Node的参与者,均可成为Sequencer。每个Sequencer都必须以$Matic token来作为抵押物,以此来获得创建和提交batches的权利。Sequencer赚L2的交易手续费,但是只有相应的证明提交后,Sequencer才能获得其所提交的batch内的L2交易手续费。
2)第二步的参与者称为Aggregator。Aggregator负责检查batches的有效性,并提供相应的证明。作为Aggregator,需要运行zkEVM-Node和zkProver来创建相应的零知识证明,赚取Sequencer为batches支付的Matic费用。

PoE智能合约有2个基本的函数:
1)sendBatch:用于接收Sequencer提交的batches。
2)validateBatch:用于接收Aggregator生成的proof,并进行验证。

zkEVM-Node

Basic concept

https://wiki.polygon.technology/ko/assets/images/fig3-zkNode-arch-aa4d18996fba1849291ea18e3f11d955.png

  • Batch: 为一组使用zkProver来执行或证明的交易,会将batch发送到L1,也会从L1同步batch。
  • L2 Block: 目前,所有的L2 block被设置成只包含一个交易,从来能够保证即时的确认。
  • RPC:为用户(如metamask、etherscan等)与节点交互的接口。与以太坊RPC完全兼容,并附加了一些额外的端口。如与state交互可获得数据的接口;处理交易的接口;与pool交互存储交易的接口。
  • Pool:通过RPC来存储交易的DB,pool中所存储的交易后续可由sequencer来选中或丢弃。
  • Sequencer:
    • Sequencer 从用户那里接收 L2 交易,将它们预处理为新的 L2 batch,然后将该 batch 作为有效的 L2 交易提交给 PoE 合约。 Sequencer 接收来自用户的交易,并将收取所有已发布交易的交易费。 因此,Sequencer 在经济上受到激励来发布有效交易,以便从中获得最大利润。
    • Trusted sequencer: 具有特殊权限的Sequencer。这样做是为了实现快速最终确定并降低与使用网络相关的成本(较低的gas)。
    • Permissionless sequencer:任何人都可以参与,但是会带来较慢的最终确认等问题,相应的,抗审查性和去中心化会好一些。目前尚未实现。
  • Aggregator:通过生成零知识证明来验证之前提交的batches。它会通过向state发送请求来获得prover所需输入数据。一旦proof生成,即可发送到L1中的智能合约进行验证。
  • Synchronizer:负责从以太坊区块链读取事件,通过etherman从以太坊获取数据更新state。Synchronizer从智能合约中获取的数据包括Sequencer发布的数据(交易)和Aggregator发布的数据(有效性证明)。所有这些数据都存储在一个巨大的数据库中,并通过JSON-RPC服务提供给第三方。
  • Prover:生成ZK proofs的服务。注意Prover并未在zkEVM-Node中实现,而是从节点的角度将其当成是“黑盒”。当前Prover有2版实现:
    • JS参考版本实现——zkevm-proverjs库
    • C++生产版本实现——zkevm-prover库
  • Etherman:对需与以太坊网络和相关合约交互的方法的抽象。
  • State:负责管理存储在StateDB的状态数据(batches、blocks、transactions等),同时State还会处理与executor和Merkletree服务的集成。
  • StateDB:为状态数据的持久层。
  • Merkletree:该服务中存储Merkle tree,包含了所有的账号信息(如balances、nonces、smart contract code 和 smart contract storage)。Merkletree模块也并未在zkEVM-Node中实现,而是作为节点的一个外部服务实现在zkevm-prover中。

Source Code

./zkevm-node run --genesis ../config/environments/local/local.genesis.config.json --cfg ../config/environments/local/local.node.config.toml --components synchronizer

Sequencer

每个Sequencer都有一个配置,池子,状态,交易管理者,etherman,gpe等

type Sequencer struct {
   cfg Config

   pool      txPool
   state     stateInterface
   txManager txManager
   etherman  etherman
   checker   *profitabilitychecker.Checker
   gpe       gasPriceEstimator

   address common.Address

   sequenceInProgress types.Sequence
}

start

  1. 循环调用isSynced,判断synchronizer是否同步完成
    1. 查询数据库state.virtual_batch执行sql获取lastSyncedBatchNum
    2. 调用接口从PoE合约中获取lastEthBatchNum
    3. 如果lastSyncedBatchNum<lastEthBatchNum,说明还没同步完成
  2. 同步完成后,初始化sequence,获取最新的batchNum
    1. 如果batchNum为0,创建创世区块
    2. 否则执行loadSequenceFromState,确定Sequencer中的sequenceInProgress,即确定一个序列
  3. 启动一个协程,执行trackOldTxs,将pool中已处理过的交易在数据库中删除
  4. 启动一个协程,执行tryToProcessTx来对交易进行处理
  5. 启动一个协程,执行tryToSendSequence,将sequence发送到L1

loadSequenceFromState

  1. 循环检查是否同步完成
  2. 执行MarkReorgedTxsAsPending,将重组的交易的状态从selected更新为pending
  3. 获取最新信息lastBatch和这个块是否被关闭了,如果关闭了:
    1. 开始一个状态交易
    2. 获取最新的global exit root,并构造一个进行时的上下文
    3. 调用OpenBatch将新的batch加入state中,batchnumber+1
    4. 更新Sequencer中的sequenceInProgress
  4. 否则,如果没有关闭
    1. 根据batchNumber获取所有交易
    2. 新建一个Sequence并设为当前Sequencer处理中的Sequence

trackOldTxs

  1. 获取即将要从pool中删除的交易的txHashes
  2. 根据txHashes中的hash,直接在数据库中删除

tryToProcessTx

  1. 检查同步
  2. 检查当前sequence是否应该被关闭,即batch中的交易数量是否超过了最大限制
  3. 备份当前sequence
  4. 处理sequenceInProgress中的txs
  5. 更新状态

tryToSendSequence

  1. 检查同步
  2. 获取需要被发送给L1的sequence数组
  3. 调用SequenceBatches,重试发送sequences给eth

Aggregator

type Aggregator struct {
    cfg Config

    State                stateInterface
    EthTxManager         ethTxManager
    Ethman               etherman
    ProverClients        []proverClientInterface
    ProfitabilityChecker aggregatorTxProfitabilityChecker
}

start

  1. aggregator中会建立很多对Prover的RPC连接,对aggregator中的每一个proverClient,都启动一个协程,调用tryVerifyBatch
  2. 启动一个协程,循环调用tryToSendVerifiedBatch

tryVerifyBatch

  1. 检查网络是否同步
  2. 调用getBatchToVerify获取需要被verify的state.Batch
  3. 根据获取的batch调用buildInputProver构建一个inputProver
  4. 在proverClients中找一个闲置的prover
  5. 调用GetGenProofID,这个函数会根据上面的inputProver生成一个genProofRequest,并调用prover的genProof生成一个proof,返回一个proofID
  6. 执行sql语句,将proof插入到state里面
  7. 调用getAndStoreProof,获取proof.Proof并更新

tryToSendVerifiedBatch

  1. 检查是否同步
  2. 调用GetLastVerifiedBatch获取最新的确认过的lastVerifiedBatch
  3. 调用GetGeneratedProofByBatchNumber获取proof
  4. 如果proof不为空,调用VerifyBatch,将proof发送给智能合约
  5. 成功后再删除proof

VerifyBatch

最后会调用POE的VerifyBatch进行验证

Synchronizer

Sync

  1. 获取dbTx
  2. 获取最新的区块lastEthBlockSynced,如果获取不到,用genesis区块
  3. commit dbTx
  4. 循环进行同步
    1. 调用syncBlocks从特定的块同步到最新块
    2. 从合约获取最新batch number:latestSequencedBatchNumber
    3. 从state获取latestSyncedBatch
    4. 如果latestSyncedBatch >= latestSequencedBatchNumber说明,合约的状态全部同步完成,就调用syncTrustedState

RPC

NewServer

在调用NewServer的时候,会调用registerService注册serviceName和service,保存在serviceMap中,serviceMap中包含service和funcMap
具体的:例如eth服务ethEndpoints,会遍历ethEndpoints的所有方法,并保存在funcMap中。
目前的serviceName:

const (
   // APIEth represents the eth API prefix.
   APIEth = "eth"
   // APINet represents the net API prefix.
   APINet = "net"
   // APIDebug represents the debug API prefix.
   APIDebug = "debug"
   // APIZKEVM represents the zkevm API prefix.
   APIZKEVM = "zkevm"
   // APITxPool represents the txpool API prefix.
   APITxPool = "txpool"
   // APIWeb3 represents the web3 API prefix.
   APIWeb3 = "web3"
)

start

  1. 初始化json rpc server,包括host和port
  2. 调用handle处理请求,从request中解析出serviceName和funcName,再从serviceMap和funcMap中获取service和funcData并调用相关函数进行处理,获取结果

zkProver

Polygon zkEVM 中交易的证明全部由zkProver来处理。通过电路来保证交易执行的有效性。zkProver 以多项式和汇编语言的形式执行复杂的数学计算,随后在智能合约上进行验证。

Interation with Node and Database

如上面的流程图所示,整个交互分为4步:

  1. 节点将 Merkle 树的内容发送到数据库以存储在那里
  2. 节点然后将输入交易发送到 zkProver
  3. zkProver 访问数据库并获取生成证明所需的信息。 这些信息包括Merkle树根、相关siblings的键和hash等
  4. zkProver 然后生成交易证明,并将这些证明发送回节点

Components


而zkProver的内部可以看作由以下四个部分组成:

Exector

Executor其实就是Main State Machine Executor。它将交易、新旧状态根等作为输入。
同时Exector还使用:

  1. PIL(Polynomial Identity Language)和一些寄存器
  2. ROM:存储与执行相关的指令列表。

有了这些,Executor会生成承诺多项式和一些公共数据,这些公共数据构成了 zk-SNARK 验证器输入的一部分。

STARK Recursion

Main State Machine Executor将交易和相关数据转换为承诺多项式之后会作为STARK 递归组件的输入:

  • 承诺多项式
  • 常数多项式
  • 脚本,它是指令列表,为了生成 zk-STARK 证明

为了促进快速 zk-STARK 证明,STARK 递归组件使用Fast Reed-Solomon Interactive Oracle Proofs of Proximity (RS-IOPP),也称为 FRI,用于每个 zk-proof。

Circom Library

最初的Circom论文将其描述为定义算术电路的电路编程语言和编译器。

  1. 包含一组关联的 Rank-1 约束系统 (R1CS) 约束的文件
  2. 一个程序(用 C++ 或 WebAssembly 编写),用于有效地计算对算术电路所有连线的valid assignment。

STARK 递归组件生成的单个 zk-STARK 证明会作为 Circom 组件的输入。
Circom是 zkProver 中使用的电路库,用于为 STARK 递归组件生成的 zk-STARK 证明生成witness。

zk-SNARK Prover

最后一个组件是 zk-SNARK Prover,或者说Rapid SNARK。

Rapid SNARK是一个 zk-SNARK 证明生成器,用 C++ 和英特尔汇编语言编写,可以非常快速地生成Circom输出的证明。目前支持PLONK/Groth16.

之所以采用两套证明系统是因为STARK 证明的生成速度更快,但是证明的规模却很大,在链上验证的时候开销也很大,SNARK 由于更小的证明规模和更快的验证速度,所以在以太坊上验证会更便宜。

https://github.com/matter-labs/awesome-zero-knowledge-proofs
Polygon Hermez zkEVM 使用一个 STARK 证明电路来生成状态转换的有效性证明,用 SNARK 证明验证 STARK 证明的正确性(可以认为是生成「证明的证明」),并将 SNARK 证明提交到以太坊进行验证。

State Machines

zkProver 遵循模块化设计,包含14个状态机,分别对应不同的一些操作:

  • The Main State Machine
  • Secondary state machines:The Binary SM, The Storage SM, The Memory SM, The Arithmetic SM, The Keccak Function SM, The PoseidonG SM
  • Auxiliary state machines:The Padding-PG SM, The Padding-KK SM, The Nine2One SM, The Memory Align SM, The Norm Gate SM, The Byte4 SM, The ROM SM

How to run
Requirement
zkEVM-Prover: 128vCPU, 1T RAM Recommended by Hermez Team

Config
zkEVM-Node config: https://github.com/0xPolygonHermez/zkevm-node/blob/develop/config/environments/public/public.node.config.toml

zkEVM-Prover config:https://github.com/0xPolygonHermez/zkevm-prover/blob/main/config/config_prover.json

Notes

  • prover如果配置了statedb为local的话会把数据暂时存在内存中,没有做持久化,一旦重启prover会丢失数据。
  • 在zkevm-contrant下deployment/deployment_v2-0/deploy_parameters.json 中配置好trustedSequencerAddress,保证zkevm-node/config/environments中的node.config.toml中etherman中的PrivateKeyPath配置项的keystore文件对应的是该地址的私钥,否则Sequencer提交交易会报错。
  • trustedSequencerAddress不要用hermez官方仓库的私钥对应的地址。
  • 如果修改了l2的genesis相关配置需要保证合约目录下的deployment/deployment_v2-0/genesis.json和node目录下的config/envirment/中的genesis对应起来。

Resources
Github:https://github.com/0xPolygonHermez

Docs:
https://wiki.polygon.technology/ko/docs/zkEVM/introduction
https://docs.hermez.io/zkEVM/Overview/Overview/

转载自:https://learnblockchain.cn/article/5336

uniswap基本概念

什么是Uniswap?

uniswap是一个进行自动化做市商的项目,该项目的特点是公平,去中心,抗审查,安全。并且uniswap并不会存在特殊群体,参与项目的每个人都是平等的不论你是LP还是Trader。

V1的特点:

  • 支持不同的ERC20token进行交换
  • 可以加入流动矿池成为LP并获取奖励费用
  • 利用公式进行自动定价,每次交易过后都会进行计算定价
  • 支持私人定制的交换

每个LP按照一定比例输入ERC20-ERC20的数量之后,会获得一定数量LPtoken,用来表示贡献度,可以根据贡献度来领取池中的奖励(奖励的来源为每次交易收取的费用)。如果想退出的话,可以将LPtoken进行销毁,销毁后会按照LPtoken的比例将两种资金进行返回。

同时可以设置限价单和限时单。

Uniswap如何进行计算

无交易费推导

在整个计算过程中uniswap使用的是x-y-k的模型,即为无论怎样进行交易,保持交易后x * y = k,两种代币的乘积不变。根据这个思想,进行推导

设要用Δx数量的x代币交换Δy代币,则有

(x + Δx) * (y - Δy) = k = x * y
设 α = Δx / x, β = Δy / y,将其代入上式可得
∴ (x + α * x) * (y - β * y) = x * y
∴ (1 + α) * (1 - β) = 1
∴ α = β / (1 - β), β = α / (1 + α)
∴ Δy = α / (1 + α) * y = Δx / (x + Δx) * y
  Δx = β / (1 - β) * x = Δy / (y - Δy) * x

带手续费交易

在实际情况中,每一次交易都会收取一定的手续费用来交易LP,通常手续费为交易量的0.3%,这就表明你输入的Δx并不是全用于计算,实际的计算值为Δx * 0.97%,剩下的作为手续费放入交易池中奖励LP,下面是实际的推导过程:
设要用Δx数量的x代币交换Δy代币,则有

设手续费的比例为ρ, 1 - ρ为γ
(x + Δx * γ) * (y - Δy) = k = x * y
设 α = Δx / x, β = Δy / y,将其代入上式可得
∴ (x + α * x * γ) * (y - β * y) = x * y
∴ (1 + α * γ) * (1 - β) = 1
∴ α = β / ((1 - β) * γ), β = (α * γ) / (1 + α * γ)
∴ Δy = (α * γ / (1 + α * γ)) * y = ((Δx * γ) / (x + Δx * γ)) * y
  Δx = β / (1 - β) * x = Δy / (y - Δy) * (1 / γ) * x
//我们假设ρ = 0.3%,所以γ = 997 / 1000
∴ 上述结果可以表示为:
  Δy = (997 * Δx * y) / (1000 * x + 997 * Δx)
  Δx = (1000 * Δy * x) / ((y - Δy) * 997))

通过上面推导可以看出当ρ为0时就成为了无手续费模式,并且可以发现一个问题,代入手续费之后整个池子的k只会略微变大,这是因为会有部分的费用作为手续费进入池子并不会进入交易当中。也可以理解为,你输入的Δx = 手续费 + 实际的交易的数量

在实际的交易中,会出现以下两种情况:

  • 给出交易的x代币数量,计算出y代币的数量
    在这种情况下,输入的x代币会有一部分作为手续费放入池中,其余部分才会被用来做交换。计算时可参考上面手续费交易的结果。
  • 给出想要的y代币数量,计算出所需要的x代币数量
    给出想要获取的y代币,计算所需的x代币数量,同时x代币中包含了手续费和实际交换数量。计算时可参考上面手续费交易的结果。

流动性计算

用户不仅可以进行代币的交换,同时还可以成为LP(Liquidity provider),获取LPtoken用来获取池子中的利息。流动性计算的推导如下

设l = x * y表示两种代币的数量,则有
//提供流动性推导
设α = Δx / x,则有
∴ Δy = Δx / x * y + 1
  Δl = Δx / x * y
∴ x' = x + Δx
  y' = y + Δx * t / x + 1 //这是考虑到solidity语法在计算时的小数会进行向下取整
  l' = l + Δx * l / x

//取消流动性
设β = Δl / l
∴ Δx = Δl * x / l
  Δy = Δl * y / l
∴ x' = x - Δx
  y' = y - Δy
  l' = l - Δl

由于存在向下取整的计算方式,我们将提供和取消两种结合起来看之后会发现,在取消之后剩余的x,y的数量大于提供流动性之前,这是为了保证避免投资者通过这种方式进行获利。如果不使用向下取整的计算方式的话,其实提供和取消之后x,y的数量不会发生变化。同时在提供流动性之后,LP会获取LPtoken,LPtoken数量等于两种代币之积(代币数量更新之后)再开根号。

滑点问题

由于Uniswap是在区块链上的操作,所以可能会导致你看到的价格和实际的价格会有所不同,这是由于交易的确认需要时间,并且交易的顺序不清楚。这样就会导致产生交易滑点,通常为0.5%的滑点保护。而对于滑点的计算我们通常使用(实际成交价格 - 交易时输入的价格) / 交易时输入的价格。当计算出的值满足设定的比例时,即可完成交易。

手续费问题

由于在每笔交易时都会收取一定的手续费,这些手续费会按照LP的持有的token的比例进行分发。这是为了激励用户成为LP并且投入更多的资金,创造更多的流动性。从理论上来说当每一一笔交易发生的时候要将手续费分发给LP,在进行分发的时候可能会使用一个大循环进行分配。但是在实际中这样对用户消耗的gas是很多的,所以这样的方法是不可行的。所以在代码中将手续费的分配放在LP提供流动性和移除流动性的部分,并且维持手续费公平分配的与那里很简单。每次用户进行交易的时候交易的手续费会将每单位的LPtoken的代币的价值提高,而LP在提供流动性的时候会按照当前每单位的LPtoken的代币的价值进行买入LPtoken(变相地理解),举例说明:用户A提供流动性的时候获取了100LPtoken,池子中有1000tokenA,1000tokenB,这时候1LPtoken的价值分别是10tokenA和10tokenB,在经过多次买卖后池子里面有1100tokenA和1500tokenB,这时候1LPtoken对应11tokenA和15tokenB,多出来的这部分即为手续费收益。当用户B想要增加流动性的时候,会按照1LPtoken对应11tokenA和15tokenB的比例进行生成LPtoken,然后按照上面所说的再进行手续费的收益。

质押性挖矿

质押挖矿,在项目中LP可以通过质押自己的LPtoken通过质押一定的时间可以获取质押合约中的奖励代币。再uniswap中LP可以质押自己的LPtoken去获取UNI代币,获取的UNI代币可以去交易所兑换其他的代币,也可以用于参与uniswap的治理,可以通过持有的UNI数量进行投票。

质押挖矿的算法推导

在质押挖矿合约中会进行规定每经过一段时间就会生成一定数量的奖励代币,并且将这些奖励代币按照LP质押的token的数量进行分配给LP。正常思路下我们在分配的时候,会采用循环将质押的用户进行遍历分配。但是在智能合约中使用循环便利的话会消耗大量的gas,这样是不明智的。所以我们要换一种思路来进行奖励的分配。

我们假设质押合约中每秒的奖励为R,合约中质押的代币总数为T,用户A的质押代币数量为a,T包含a
∴ 每秒每一代币的奖励数量为R / T, 用户A每秒获取的代币数量为 a * R / T
我们假设用户A在6秒后取出质押的代币
∴ A所获得的奖励代币数量为:a * R / T * 6

我们再假设B用户在A用户质押4秒后质押了b数量的代币
∴ A所获得的奖励代币数量为:
  a * R / T * 4 + a * R / (T + b) * 2 = a * (R / T + R / T + R / T + R / T + R / (T + b) + R / (T + b))

按照这种思路,我们再进行假设
假设质押的总时长为6秒,A用户在第2秒质押a数量代币,B用户在第4秒质押b数量代币,A用户质押前的代币总数为T'
∴ A所获得的奖励代币数量为:
  a * R / T * 2 + a * R / (T + b) * 2 = a * (R / T + R / T + R / (T + b) + R / (T + b)) = a * (R / T' + R / T' + R / T + R / T + R / (T + b) + R / (T + b)) - a  * (R / T' + R / T')

通过最终的推导式,我们可以得到用户A获得的奖励代币数量等于结算时的累计每份质押代币对应的奖励代币数量之和减去加入时累计每份质押代币对应的奖励代币数量之和再乘以A代币的数量。这样计算的前提是用户A的质押代币的数量不会进行更改,那么问题来了,如果用户对质押代币的数量进行更改了,如何进行计算呢?
其实解决方法很简单,在每次更改数量的时候,对先前数量的质押代币获取的奖励进行结算,然后将变换后的质押代币数量重新进行上述的数量不变的推导。这就是uniswap质押合约代码的逻辑结构,详细的代码可以参考https://github.com/Uniswap/liquidity-staker

问题

  1. 质押挖矿的算法推导是不存在问题的,但是在使用时要结合实际情况来进行考虑。由于solidity中对于除法计算使用的是向下取整,所以在上述推导公式中对于每份质押代币对应的奖励代币数量的计算可能会出现结果为0的情况,虽然uniswap的代码中将每秒奖励的数量扩大10**18倍,但是还要考虑扩大之后如果还是小于总的质押代币数量,这个时候可能会出现用户在这段时间的收益为0的情况。这是由于语言特性产生的bug问题。
  2. 也要考虑额质押代币数量为0的情况,此时计算收益会出现问题。

转载自:https://learnblockchain.cn/article/5346

默克尔树(Merkle Patricia Tree)详解

一、概念

本文是阅读《深入以太坊智能合约开发》的附录A Merkle Patricia Tree后的知识归纳以及扩展。
默克尔树(Merkle Patricia Tree)在以太坊中是一种通用的,用来存储键值对的数据结构,可以简称为“MPT”,是字典树Redix tree的变种,也是以太坊的核心算法之一。
MPT对于树中节点的插入、查找、删除操作,这种结构可以提供对数级别的复杂度O(log(N)),所以它是一种相对高效的存储结构。

二、如何根据键值对构造默克尔树

2.1 节点类型

树类型的数据结构,都会有节点的概念,MPT也不例外。
MPT中有三种节点类型:branch、leaf、extension:

  1. branch(分支)
    1. 由17个元素组成的元组,格式为:(v0,……,v15,vt)。
    2. 其中,v0~v15的元素是以其索引值(0x0~0xf)为路径的子节点数据的keccak256哈希值,如果没有子节点数据则元素为空。
    3. vt为根节点到当前节点的父节点所经过的路径对应的value值,也就是根节点到父节点所经过的路径组成了一个键key,这个key对应的value存在vt里面,如果这个key没有对应的value,那么vt为空。
  2. leaf(叶)
    1. 两个元素组成的元组,格式为:(encodePath,value)
    2. encodedPath为当前节点路径的十六进制前缀编码
    3. value是从根节点到当前节点路径组成的键对应的值
  3. extension(扩展)
    1. 两个元素组成的元组,格式为:(encodePath,key)
    2. encodedPath为当前节点路径的十六进制前缀编码
    3. key为当前节点子节点数据的keccak256哈希值

总结一下上述的那一堆概念:

  • 键值对
  • extension节点记录着:路径、子节点的哈希值。
  • leaf节点记录着:路径、vlaue。
  • branch记录着:以索引值为路径的子节点的哈希值、从根节点到branch的父节点路径组成的键对应的value。
  • value值在leaf和branch节点中存放,key被拆解开,最终由extension、leaf的encodePath以及branch的索引值组成。

2.2 十六进制前缀编码

branch和extension元组的第一个元素encodePath就是当前节点路径的十六进制前缀编码(Hex-Pretix Encoding,HP编码)。使用HP编码能够区分节点是扩展结点还是叶子节点。
而HP编码,和当前节点类型还有当前路径半字节长度的奇偶有关。

半字节是4位二进制,即1位16进制。
0xf是半字节,0xff是1字节。
1111是半字节,11111111是1字节。

共有四种前缀:

所以extension节点有两种前缀:0x00、0x1;leaf有两种前缀:0x20、0x3。
可以看到最终前缀在偶数个半字节0x0、0x2后补了一个0,变成了0x00,0x20,目的是为了凑成整字节,避免出现半字节导致长度不便于合并。
HP前缀需要放在原始路径前面去组成HP编码,实例:

2.3 构造一颗默克尔树

上面的概念不容易理解,现在我们以下面的例子,一步步来进行树的构造,帮助我们更好的理解:
我们假设有一组(4个)键值对数据需要用树来存储:

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

为方便解释说明以及阅读,我们把键值对数据的“键”表示为十六进制字符串,“值”则保留为原始字符串。在实际使用时,它们都需要经过特定的编码变换。

1.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

每棵树都有根节点,默克尔树的根节点会保存当前路径和子节点哈希,所以很明显,根节点会是一个extension节点。
上面节点类型介绍了extension格式为:(encodePath,key),encodePath是十六进制的HP编码。分析给出的4个键我们可以得出都是以6开头,后面分为4、8两条路。所以根节点存储的共同路径值为0x6。
由于0x6只有一位,所以路径长度是奇数,节点又是extension类型,所以HP前缀是0x1,组合出来的HP编码:0x16。
所以当前默克尔树如下图:

HashA代表着子节点的哈希值。

2.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

根节点已经找到,但在根节点后出现了两条路,这个时候需要使用branch来处理这种多条路径的情况。

上文说到,branch由17个元素组成的元组,格式为:(v0,……,v15,vt)。其中,v0~v15是以其索引值(0x0~0xf)为路径的子节点数据的keccak256哈希值,如果没有子节点数据则为空。
这里4和8就是索引值,4、8对应元素是其字节点的哈希值。
所以当前默克尔树如下图:

3.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

我们可以观察到,在0x68后只有唯一路径了,即0x6f727356,而value为“stallion”,所以不再分叉的情况下,就不是branch或者extension了,而应该是一个叶节点。

上文提到,leaf节点是两个元素组成的元组,格式为:(encodePath,value),encodedPath为当前节点路径的十六进制前缀编码,value是从根节点到当前节点路径组成的键,所对应的值。

当前节点的路径是0x6f727356,长度是偶数,节点类型是leaf,所以可以得出HP前缀是0x20,HP编码是0x206f727356。所以可得该leaf节点:(0x206f727356,"stallion")。

所以当前默克尔树如下图:

4.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

说完了8,我们再说说4这部分,路径4后面有共同路径6f,6f后才产生null和6两条分叉。

共同路径6f是一个extension节点,extension节点格式不再介绍,开始计算HP编码,6f长度是偶数,又是extension类型,所以HP前缀为0x00,HP编码为0x006f。

所以当前默克尔树如下图:

5.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

6f后分出了null和6,是多条路径,所以HashD的节点是一个branch节点,6是索引值,索引为6的元素存储着子节点hash;而null是没有的,上文提到:vt为根节点到当前节点的父节点所经过的路径组成的键对应的value。

则代表当前HashD节点该存储从根节点到父节点0x646f组成的键对应的值:'verb'。那么该由HashD的vt保存'verb'。

所以当前默克尔树如下图:

6.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

接下来是共同路径7,一个extension节点,开始计算HP编码,7长度是奇数,又是extension类型,所以HP前缀为0x1,HP编码为0x17。

所以当前默克尔树如下图:

7.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

7后分出了null和6,是多条路径,与第五步相同,HashF是一个branch节点,索引为6的元素存储子节点哈希,vt存储'puppy'的值

所以当前默克尔树如下图:

8.

<64 6f> : 'verb'
<64 6f 67> : 'puppy'
<64 6f 67 65> : 'coin'
<68 6f 72 73 65> : 'stallion'

好了,现在只剩下一条路径了,表示这最后一个是一个leaf叶子节点,路径为5,路径长度为奇数,索引HP前缀为0x3,HP编码为0x35

所以当前也是最终的默克尔树如下图:

以上就是这棵默克尔树构造的全部过程了,写得很详细,是希望能够为在阅读书籍时没有理顺思路的小伙伴们提供一些帮助。

三、总结

从构造过程中我们可以看出,MPT中节点之间,是通过哈希值来确定的。由于哈希值的特性,只要数据有了微小改动,就会导致根节点改变,所以我们可以用树的根节点来代表整个树中数据的状态,这样就不用保存整个树的数据。

在以太坊中,默克尔树有着大量的应用,比如保存和验证系统中的所有账户状态、所有合约的存储状态、区块中的所有交易和所有收据数据的状态等。

参考

【深度知识】以太坊区块数据结构及以太坊的4棵数

《深入以太坊智能合约开发》

转载自:https://learnblockchain.cn/article/5321

从EVM 角度看合约创建与部署

技术背景

本文我们探讨智能合约是如何创建和部署的。

当谈论以太坊上的合约构建时,我们必须区分在EVM层面上发生的事情和作为Solidity开发者,在编写我们想要部署的合约时看到的事情之间的区别。

在这份文件中,我们将探讨智能合约是如何在链上部署的,以及与EVM执行合约创建代码有关的微妙之处。

前备知识点

  1. 构造函数用于初始化状态变量,与普通函数不同,它在合约部署后是不可访问的。
  2. 以太坊的合约部署是独特的,因为这个动作本身就是运行EVM字节码的副产品。
  3. 部署者附加构造函数参数,这使得初始化的合约状态可以改变而不必重新编译。
  4. 尽管常量(constants)的使用成本较低,但不可变的变量(immutable)有更大的灵活性,因为它们可以在构造函数中初始化,也可以运行时复制值。
  5. 当运行一个合约的构造函数时,新创建的合约存在,但只是部分存在,这可能会引起不可预测的操作码执行结果。

Solidity

当Solidity开发者编写合约时,可以定义一个特殊的函数,称为constructor(),其作用很像其他面向对象编程中的构造函数。就好像Solidity团队想让开发者有宾至如归的感觉,让他们觉得他们只是在定义一个类(合约)和它的构造函数。

constructor()函数通常用于初始化状态变量:

pragma solidity ^0.8.13;

contract MyCoin {
  uint public constant totalSupply = 1000000000000000000000000000;
  mapping(address => uint256) balances;

  constructor() {
    balances[msg.sender] = totalSupply;
  }
}

上面的例子是一个简单的构造函数,它将部署器的余额设置为totalSupply值,在我们的例子中这是一个常数。

尽管构造函数在Solidity中是明确定义的,但它与普通函数的不同之处在于,它在合约部署后是不可访问的。我们将使用 solc的- abi输出标志来观察合约的可访问函数列表:

[{
  "inputs": [],
  "stateMutability": "nonpayable",
  "type": "constructor"
}, {
  "inputs": [],
  "name": "totalSupply",
  "outputs": [{
    "internalType": "uint256",
    "name": "",
    "type": "uint256"
  }],
  "stateMutability": "view",
  "type": "function"
}]

编译器为totalSupply状态变量创建了一个公共函数,因为我们把这个变量声明为public。

我们的 构造函数 在合约的ABI中具有一个特殊的 类型,以表明它不是一个普通的函数。它没有name 字段,与 真正的 函数不同,暗示它是不可访问的。

部署

让我们看看当我们部署合约时将会运行的实际代码:

solc --bin ~/Desktop/MyCoin.sol

======= Desktop/MyCoin.sol:MyCoin =======

Binary:
608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220edc6183cb296d3c2809859d4531deef0fb83c0ad90b772697ffaa375befe9c7664736f6c634300080d0033

这是 init code,而不仅仅是将被部署到链上的代码。对于后者,我们使用solc的-- bin-runtime 选项获得:

solc --bin-runtime ~/Desktop/MyCoin.sol

======= Desktop/MyCoin.sol:MyCoin =======

Binary of the runtime part:
6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220edc6183cb296d3c2809859d4531deef0fb83c0ad90b772697ffaa375befe9c7664736f6c634300080d0033

-- bin输出包含-- bin-runtime输出,这不是巧合。-- bin输出包含部署动作和要部署的代码,而-- bin-runtime输出只显示要部署的代码:

让我们仔细看看init code 是做什么的,看一下我们例子合约的反汇编:

label_0000:
    0000 60 PUSH1 0x80
    0002 60 PUSH1 0x40
    0004 52 MSTORE
    0005 34 CALLVALUE
    0006 80 DUP1
    0007 15 ISZERO
    0008 61 PUSH2 0x0010
    000B 57 JUMPI
label_000C:
    000C 60 PUSH1 0x00
    000E 80 DUP1
    000F FD REVERT
label_0010:
    0010 5B JUMPDEST
    0011 50 POP
    0012 6B PUSH12 0x033b2e3c9fd0803ce8000000
    001F 60 PUSH1 0x00
    0021 80 DUP1
    0022 33 CALLER
    0023 73 PUSH20 0xffffffffffffffffffffffffffffffffffffffff
    0038 16 AND
    0039 73 PUSH20 0xffffffffffffffffffffffffffffffffffffffff
    004E 16 AND
    004F 81 DUP2
    0050 52 MSTORE
    0051 60 PUSH1 0x20
    0053 01 ADD
    0054 90 SWAP1
    0055 81 DUP2
    0056 52 MSTORE
    0057 60 PUSH1 0x20
    0059 01 ADD
    005A 60 PUSH1 0x00
    005C 20 SHA3
    005D 81 DUP2
    005E 90 SWAP1
    005F 55 SSTORE
    0060 50 POP
    0061 60 PUSH1 0xbd
    0063 80 DUP1
    0064 61 PUSH2 0x006e
    0067 60 PUSH1 0x00
    0069 39 CODECOPY
    006A 60 PUSH1 0x00
    006C F3 RETURN
    006D FE ASSERT

为了完全理解这个例子的要点,建议读者尝试用调试器自己执行这个例子,比如使用evm.codes

这里发生了很多事情,但我们可以把它分解成2个基本部分:

  1. 运行构造函数(设置代码)
    这一部分来自0000-005F
  2. 返回将被部署在链上的运行时字节码
    这一部分来自于0060-006C

对于构造函数部分,事情是非常简单的。请记住,我们将Solidity构造函数设置为 nonpayable. 事实上,我们不需要做任何事情就可以使构造函数成为 nonpayable,这是一个 "solc "默认值,除非指定 payable。

因此,如果任何 value 与运行我们代码的交易一起被发送,0005-000F 行将简单地回退。
在第0012-005F行,可以看到Solidity构造函数的反汇编,它包含了一行代码:

pragma solidity ^0.8.13;
...
balances[msg.sender] = totalSupply;

由于我们用一个常数来表示 totalSupply 值,编译器将该值植入EVM字节码。这可以在第0012行看到,十六进制值0x033b2e3c9fd0803ce8000000被推送到堆栈。这是1000000000000000000000的十进制,也就是我们给totalSupply赋值。

CALLER操作码,在第0022行检索Solidity的msg.sender。

随后,在005F行,我们可以看到SSTORE,它实际上是在映射中存储常量。

在第0060-006C行,CODECOPY将当前代码从偏移量0x006E复制到偏移量0x00的内存中,该偏移量刚好在init code的最后一行。RETURN被执行。

这实际上是将 runtime code 完整地返回给EVM,以便它将其作为代码存储在账户的状态中。这种行为,类似于一个安装者(init code),想要部署它所持有的一段代码(runtime code)。

由于在以太坊中存储数据的成本很高,最好只存储我们真正打算多次使用的代码,即运行时代码(runtime code)。这就是为什么初始化合约状态的一次性设置代码,即 constructor,不是部署代码的一部分。进行一个疯狂类比,设置代码可以被看作是火箭飞船的助推器,在使用一次后就被处理掉。然而,当一次性助推器永远消失在海洋中时,设置代码作为合约部署交易的一部分永远记录在链上。保留部分对于分析新部署的合约往往是有用的。

我们可以用一幅漂亮的ASCII图来总结本节,它显示了 "初始代码(init code)" 是如何组成的。

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                           setup_code                          |
+                           +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           |                                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+                                   +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                          runtime_code                         |
+                     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                     |
+-+-+-+-+-+-+-+-+-+-+-+

我们有110字节的 "设置代码(setup code)",用于初始化合约的状态,附加189字节的 "运行时代码(runtime code)",将被部署到链上。

构造函数参数

如果我们想在构造函数的参数中指定总的发行量呢?这在同一合约将以不同的初始化值被多次部署的情况下最为有用:

pragma solidity ^0.8.13;

contract MyCoin2 {
  mapping(address => uint256) balances;

  constructor(uint256 _totalSupply) {
    balances[msg.sender] = _totalSupply;
  }
}

为构造函数提供参数的能力允许 Solidity 开发人员改变合约的初始化状态,而不必重新编译合约。这与我们观察到的在字节码中把初始化参数设置成常量的以往方法不同。

对源码模式的小改变对 init code 有实质性的影响:

solc --bin ~/Desktop/MyCoin2.sol

======= Desktop/MyCoin2.sol:MyCoin2 =======

Binary: 6080604052348015600f57600080fd5b506040516101223803806101228339818101604052810190602f919060ad565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168152602001908152602001600020819055505060d5565b600080fd5b6000819050919050565b608d81607c565b8114609757600080fd5b50565b60008151905060a7816086565b92915050565b60006020828403121560c05760bf6077565b5b600060cc84828501609a565b91505092915050565b603f806100e36000396000f3fe6080604052600080fdfea2646970667358221220c96818e63eea5c37b6a86bd71c0e718fc8f036db10e8d071ade062040534d7a564736f6c634300080d0033

让我们检查一下代码的大小:

In [3]: hex(len(binascii.unhexlify('6080604052348015600f57600080fd5b506040516101223803806101228339818101604052810190602f919060ad565b806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffff
ffffffffffffff168152602001908152602001600020819055505060d5565b600080fd5b6000819050919050565b608d81607c565b8114609757600080fd5b50565b60008151905060a7816086565b92915050565b60006020828403121560c05760bf6077565b
5b600060cc84828501609a565b91505092915050565b603f806100e36000396000f3fe6080604052600080fdfea2646970667358221220c96818e63eea5c37b6a86bd71c0e718fc8f036db10e8d071ade062040534d7a564736f6c634300080d0033')))

Out[3]: '0x122'

这个链接 是反汇编的init code 及 除去runtime 后的代码 。
Solidity代码中的一个小变化引起了EVM字节码的巨大变化。当我们处理EVM字节码时,提供构造函数参数是相当复杂的。
我们建议读者用evm.codes 来执行每一步代码。
虽然也可能通过反汇编整个代码来了解发生了什么,但让我们查看反编译版本中的main()函数:

 pragma solidity ^ 0.8 .13;
 ...

 var temp0 = memory[0x40: 0x60];
 var temp1 = code.length - 0x0122;
 memory[temp0: temp0 + temp1] = code[0x0122: 0x0122 + temp1];
 memory[0x40: 0x60] = temp1 + temp0;
 var0 = 0x2f;
 var var2 = temp0;
 var var1 = var2 + temp1;
 var0 = func_00AD(var1, var2);

main()函数抓取CODESIZE操作码的结果,用于计算当前运行代码的大小,并从中减去0x0122。记住,init code是0x0122字节。init code 在意它的大小,并期望在执行前有东西附加到它上面!

反编译中的其他函数用来验证附加在init code上的东西不大于0x20(32)字节,并获取它。
然后我们看到这个值在同一设置代码中使用,因为它被SSTORE存储在合约的状态中。
总之,当EVM执行init code时,构造函数参数会被附加到init code上。负责正确追加参数的是部署者,而不是编译器。再次使用一个图表示:

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                                                               |
+                                                               +
|                           setup_code                          |
+     +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     |                                                         |
+-+-+-+                                                         +
|                          runtime_code                         |
+   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   |                   constructor_arguments                   |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|   |
+-+-+

我们可以看到这次的setup_code非常笨重,因为它包含了辅助函数来检索附加在runtime_code末尾的constructor_arguments,而这次的runtime_code出乎意料的小!

为什么这次的 runtime code 只有63字节?(提示:我们是否在合约中省略了任何可公开访问的函数?)

常量(Constant) 与不可变量(Immutable)

我们已经看 到Solidity编译器是如何将定义为常量的值植入EVM字节码的。这并不是在Solidity中可以声明不可修改的变量的唯一形式, 我们也可以将状态变量声明为 immutable。
与 constant 变量不同,immutable变量可以在构造函数中被初始化:

pragma solidity ^ 0.8 .13;

contract MyCoin {
  uint256 public immutable totalSupply;
  mapping(address => uint256) balances;

  constructor(uint256 _totalSupply) {
    totalSupply = _totalSupply;
    balances[msg.sender] = _totalSupply;
  }
}

通过这样的设置,会在运行时字节码中看到一大块零:

solc --bin ~/Desktop/MyCoin3.sol

======= Desktop/MyCoin3.sol:MyCoin =======

Binary: 60a060405234801561001057600080fd5b506040516101d43803806101d4833981810160405281019061003291906100be565b8060808181525050806000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16815260200190815260200160002081905550506100eb565b600080fd5b6000819050919050565b61009b81610088565b81146100a657600080fd5b50565b6000815190506100b881610092565b92915050565b6000602082840312156100d4576100d3610083565b5b60006100e2848285016100a9565b91505092915050565b60805160d161010360003960006049015260d16000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e91906082565b60405180910390f35b7f000000000000000000000000000000000000000000000000000000000000000081565b6000819050919050565b607c81606b565b82525050565b6000602082019050609560008301846075565b9291505056fea26469706673582212205d7bfe172b611c06fba2648d535d5437f63e0f25b22919f9c17fb605c7ad916f64736f6c634300080d0033

运行时字节码的偏移量73:105(0x49:0x69)包含了一个神秘的32字节的零块。这个块只不过是一个占位,由编译器放置在那里,当immutable在构造过程中被赋值时,它是一个占位符。有趣的是,无论我们为immutable 变量声明什么类型,编译器都会使用32字节的 0 作为它的占位符。

在我们检查init code之前,我们鼓励读者自己使用evm.codes 运行。
让我们重新审视一下 init code的反编译情况:

contract Contract {
  function main() {
    memory[0x40: 0x60] = 0xa0;
    var var0 = msg.value;

    if (var0) {
      revert(memory[0x00: 0x00]);
    }

    var temp0 = memory[0x40: 0x60];
    var temp1 = code.length - 0x01d4;
    memory[temp0: temp0 + temp1] = code[0x01d4: 0x01d4 + temp1];
    memory[0x40: 0x60] = temp1 + temp0;
    var0 = 0x0032;
    var var2 = temp0;
    var var1 = var2 + temp1;
    var0 = func_00BE(var1, var2);
    var temp2 = var0;
    memory[0x80: 0xa0] = temp2;
    memory[0x00: 0x20] = msg.sender;
    memory[0x20: 0x40] = 0x00;
    storage[keccak256(memory[0x00: 0x40])] = temp2;
    var temp3 = memory[0x80: 0xa0];
    memory[0x00: 0xd1] = code[0x0103: 0x01d4];
    memory[0x49: 0x69] = temp3;
    return memory[0x00: 0xd1];
  }

  function func_0088(var arg0) returns(var r0) {
    return arg0;
  }

  function func_0092(var arg0) {
    var var0 = 0x009b;
    var var1 = arg0;
    var0 = func_0088(var1);

    if (arg0 == var0) {
      return;
    } else {
      revert(memory[0x00: 0x00]);
    }
  }

  function func_00A9(var arg0, var arg1) returns(var r0) {
    var var0 = memory[arg1: arg1 + 0x20];
    var var1 = 0x00b8;
    var var2 = var0;
    func_0092(var2);
    return var0;
  }

  function func_00BE(var arg0, var arg1) returns(var r0) {
    var var0 = 0x00;

    if (arg0 - arg1 i >= 0x20) {
      var var1 = 0x00;
      var var2 = 0x00e2;
      var var3 = arg0;
      var var4 = arg1 + var1;
      return func_00A9(var3, var4);
    } else {
      var1 = 0x00d3;
      revert(memory[0x00: 0x00]);
    }
  }
}

在这里看到的与我们在上一节中看到的相似。
让我们关注main()函数,它检索构造函数参数并将其存储在内存偏移量[0x80:0xa0]中:

pragma solidity ^0.8.13;
...

var0 = func_00BE(var1, var2);
var temp2 = var0;
memory[0x80: 0xa0] = temp2;

然后,它把runtime_code全部复制到内存偏移量[0x00:0xd1]中。它还取了构造函数参数的值,并在内存中修补了偏移量[0x49:0x69]。当然,这些是零块在字节码中的准确偏移量。

pragma solidity ^ 0.8 .13;
...

var temp3 = memory[0x80: 0xa0];
memory[0x00: 0xd1] = code[0x0103: 0x01d4];
memory[0x49: 0x69] = temp3;
return memory[0x00: 0xd1];

关于使用 constants和 immutable,有几件事需要注意:因为 immutable 总是使用32字节,而不管其类型的实际大小,有时使用常量会更便宜。但是常量的灵活性较差:因为它们的值是由编译器复制到字节码中的,它们的值在编译时必须是已知和固定的。与immutables不同,它们的值不能依赖于以太坊状态或环境变量,如msg.value。

你可能也注意到在这两个例子中,构造函数参数的值没有用SSTORE放入合约存储。

执行环境

当 init code 被EVM执行时,新创建的合约存在,但只是部分存在:它有一个地址,但还没有代码。
在这个部分存在的过程中,init code可以改变合约的状态,自由地执行存储操作。因为在这个阶段(部分存在期间)没有与合约相关的代码,所以一些操作码可能会产生意想不到的结果。
例如,诸如 EXTCODESIZE 这样的操作码在针对正在初始化的合约地址运行时应该返回0。在使用EXTCODEHASH时也同样应该如此。

部署方法

我们已经讨论了在合约部署过程中如何执行init code,但如何触发执行流?为了让事情在链上发生,它必须由一个外部拥有的账户(EOA)用私钥发起的交易启动。

在本节中,我们将介绍两种在链上执行 init code的方法:

合约创建交易

以太坊有两种类型的交易,一种是消息调用(对合约的调用)的交易,另一种是创建带有相关代码的新账户。
这两种类型可以通过查看它们各自的 to 字段来加以区分:

  • 合约创建交易:to 字段为空。
  • 信息调用:to 字段是一个地址。
    让我们来看看在这篇文章中编写的第一个合约的合约创建交易:
{
  "data": "0x608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220a7134163728f1a3b08ff82e20f2b89e7223e63617ff221cc42e2f9a1a4c94e9664736f6c63430008110033",
  "gasLimit": 281474976710655,
  "gasPrice": 10,
  "nonce": 2,
  "to": ,
  "value": 0
}

注意该交易的data字段包含合约的 init code,其to字段为空。
在这个交易的value字段中发送的任何数值都将被用作新创建合约的初始余额。
也请注意:由于发送了一个合约创建交易,已经从发起者的余额中扣除了32000 gas 的费用。
在合约主体从 init code返回后,我们知道它必须作为新创建账户的代码存储。但新创建账户的地址是什么?
当发送合约创建交易时,地址是RLP编码结构的keccak-256散列的最低阶(最右边)160位,它由发起者的地址和发起者的nonce组成。
让我们来演示一下地址是如何计算的,示例交易部署合约在: 0xb66a603f4cfe17e3d27b87a8bfcad319856518b8 。
让我们首先定义一个RLP对象来表示需要根据协议进行编码的结构:

import binascii
from rlp
import Serializable, encode
from rlp.sedes
import big_endian_int, Binary

address = Binary.fixed_length(20, allow_empty = True)

class Struct(Serializable):
  fields = [
    ('sender', address),
    ('nonce', big_endian_int)
  ]

现在我们可以创建该结构,并将其字段赋值为示例交易中的值:

  • sender: 0x3482549fca7511267c9ef7089507c0f16ea1dcc1.
  • 发起者的nonce: 135

所得的字节:

sender = binascii.unhexlify('3482549fca7511267c9ef7089507c0f16ea1dcc1')

# create a new struct with the sender 's address and the nonce  
s = Struct(sender, 135)

# this is what the RLP encoded struct looks like
binascii.hexlify(encode(s))
b 'd7943482549fca7511267c9ef7089507c0f16ea1dcc18187'

然后我们需要使用keccak-256对结果进行Hash,并从结果字节中提取最低位的160位:

from Crypto.Hash
import keccak

# this
function does the keccak - 256 hashing
def do_sha3(to_hash: bytes) - > str:
  k = keccak.new(digest_bits = 256)
k.update(to_hash)
return k.hexdigest()

# keccak - 256 hash the RLP encoded struct
do_sha3(encode(s))
'441f3147356e22bb52c90d65b66a603f4cfe17e3d27b87a8bfcad319856518b8'

# extract only the lowest order 160 - bits
_[-40: ]
'b66a603f4cfe17e3d27b87a8bfcad319856518b8'

这就给了我们在Etherscan中相同的地址。太好了

CREATE/2

到目前为止,我们已经看到了EOA账户是如何启动一个交易,从而部署一个新合约。但是通过合约来部署合约的情况又是怎样的呢?

这种操作对很多项目都很有用。例如,当你调用它的 创建交易池 功能时,你最喜欢的DEX的工厂合约可以按需为你部署合约。这都要归功于CREATE操作码的魔力。

为了更好地理解这个操作码,我们可以使用Solidity的new关键字,它允许合约创建其他合约,条件是新合约的代码被编译器知道(当创建合约被编译时)。

让我们来看看这个操作的一个例子:

// SPDX-License-Identifier: BUSL-1.1  
pragma solidity ^ 0.8 .13;

contract MyCoin {
  uint public constant totalSupply = 1000000000000000000000000000;
  mapping(address => uint256) balances;

  constructor() {
    balances[msg.sender] = totalSupply;
  }
}

contract MyCoinDeployer {
  MyCoin c = new MyCoin();
}

看看 MyCoinDeployer 合约。这个合约的(隐式定义的)构造函数只创建了一个MyCoin合约,并将新创建的合约的地址存储在storage(存储)中。

让我们来看看MyCoinDeployer的init code:

======= Desktop/MyCoin.sol:MyCoinDeployer =======

Binary:
60806040526040516100109061007e565b604051809103906000f08015801561002c573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555034801561007857600080fd5b5061008b565b61012b806100d883390190565b603f806100996000396000f3fe6080604052600080fdfea26469706673582212209983b99bc11f2e9d7ae4320e06581f610c06531ff26a7e6872ffe9f9b27cdeb564736f6c634300080d0033608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220fa3ea064ac1eacf0fcb8a977e85160987245ae4739fac9b2d1f6718fcf987a4564736f6c634300080d0033

乍一看,编译这个单行合约使用相当多的init code。仔细观察可以发现,这段相当长的代码是原始的 MyCoin 合约的 init code,嵌入到 MyCoinDeployer 合约的 init code 中:

my_coin_deployer = binascii.unhexlify('60806040526040516100109061007e565b604051809103906000f080158015
61002c573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffff
ffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055503480156100785760
0080fd5b5061008b565b61012b806100d883390190565b603f806100996000396000f3fe60806040526000
80fdfea26469706673582212209983b99bc11f2e9d7ae4320e06581f610c06531ff26a7e6872ffe9f9b27c
deb564736f6c634300080d0033608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000
006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffff
ffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe608060405234
8015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b603360
47565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b60008190
50919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056
fea2646970667358221220fa3ea064ac1eacf0fcb8a977e85160987245ae4739fac9b2d1f6718fcf987a45
64736f6c634300080d0033')

my_coin = binascii.unhexlify('608060405234801561001057600080fd5b506b033b2e3c9fd0803ce8000000
6000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffff
ffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe60806040523480
15600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047
565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050
919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fe
a2646970667358221220fa3ea064ac1eacf0fcb8a977e85160987245ae4739fac9b2d1f6718fcf987a4564
736f6c634300080d0033')

# verify that my_coin's init code is in the init code of the deployer
my_coin in my_coin_deployer
True

# find the index (offset) of my_coin's init code in the deployer's init code
my_coin_deployer.index(my_coin)
216

# hex representation of the offset
hex(_)
'0xd8'

在 MyCoinDeployer 的 init code 中的偏移量0xd8,我们可以找到 MyCoin 的全部代码。

让我们进一步探索当我们执行 "MyCoinDeployer "的 init code时,会发生什么,可以反编译它查看这里。鼓励读者通过这个evm.codes链接 运行一下。

在反汇编的第0008行,我们看到偏移量0x0010被推到了堆栈。这将在以后被使用, 在000C-000F行,我们看到一个JUMP到0x007e,看起来像:

label_007E:
  007E 5B JUMPDEST
  007F 61 PUSH2 0x012b
  0082 80 DUP1
  0083 61 PUSH2 0x00d8
  0086 83 DUP4
  0087 39 CODECOPY
  0088 01 ADD
  0089 90 SWAP1
  008A 56 JUMP

啊哈,0x00d8! 从偏移量0xd8到偏移量0xd8+0x12b的这段代码将被复制,并通过DUP4存储在偏移量0x80的内存中。

然后堆栈中的0x10被带到前面,我们JUMP到它:

label_0010:
  0010 5B JUMPDEST
  0011 60 PUSH1 0x40
  0013 51 MLOAD
  0014 80 DUP1
  0015 91 SWAP2
  0016 03 SUB
  0017 90 SWAP1
  0018 60 PUSH1 0x00
  001A F0 CREATE
  001B 80 DUP1
  001C 15 ISZERO
  001D 80 DUP1
  001E 15 ISZERO
  001F 61 PUSH2 0x002c
  0022 57 JUMPI

现在我们终于可以看到CREATE操作码了! 如果你在执行CREATE操作码之前在你的 evm.codes环境中设置一个断点,堆栈应该看起来像这样:

我们将使用evm.codes的操作码表来进一步解释CREATE 操作码

在这里,我们案例中的堆栈输入是:

  • value: 0x0
    新创建合约的初始余额。这个值将从当前执行账户的余额中扣除。
  • offset: 0x80
    内存中的偏移量,我们从这里开始复制新合约的初始化代码。
  • size: 0x12b
    我们从内存中复制初始化代码片断的大小。

如果部署成功,这个操作的预期输出将是新部署的合约的地址,如果部署失败,则是0。

使用 evm.codes调试器,我们可以看到CREATE操作码部署成功了,输出了新部署的合约的地址:0x43a61f3f4c73ea0d444c5c1c1a8544067a86219b。

就像合约创建交易一样,这个地址是用发起者的地址和nonce计算出来的。然而,我们不能可靠地预测发起者的nonce。这种不可预测性限制了我们在合约部署前试图与之交互的可能。

例如,我们想给一个账户提供资金,并允许另一个地址在账户部署后索取这些资金。当需要提前确定一个部署合约的地址时,这种限制可能会很麻烦。

CREATE2解决了这种情况下的问题。在 EIP-1014中引入,这个操作码允许创建可预先确定地址的合约。我们现在来分析一下CREATE2是如何工作的。首先,让我们看一下下面的Solidity代码,它隐含地使用了CREATE2:

// SPDX-License-Identifier: BUSL-1.1  
pragma solidity ^ 0.8 .13;

contract MyCoin {
  uint public constant totalSupply = 1000000000000000000000000000;
  mapping(address => uint256) balances;

  constructor() {
    balances[msg.sender] = totalSupply;
  }
}

contract MyCoinDeployer {
  MyCoin c = new MyCoin {
    salt: bytes32(0)
  }();
}

我们看到与先前的 CREATE 示例相同的MyCoinDeployer。这里唯一的变化是我们使用了一个新的参数,叫做salt,传递给CREATE2操作码。

同样,CREATE2的使用在纯Solidity中是不明显的,尽管它可以用Yul来显式使用,但它是由编译器隐式使用。

所以让我们观察一下新编译的代码:

======= Desktop/MyCoin.sol:MyCoinDeployer =======

Binary:
60806040526000801b60405161001490610086565b8190604051809103906000f5905080158015610034573d6000803e3d6000fd5b506000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555034801561008057600080fd5b50610093565b61012b806100e083390190565b603f806100a16000396000f3fe6080604052600080fdfea2646970667358221220fbe71703e26e2f04db54fc3812dd16a3cca9b7e4f7f07e461c0c556d1641c90d64736f6c634300080d0033608060405234801561001057600080fd5b506b033b2e3c9fd0803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160ddd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b6000602082019050608160008301846061565b9291505056fea2646970667358221220e98c52b4cebc217f7cd697fac8a76189401a6a7bc860e0c9405eefa8e819634864736f6c634300080d0033

反编译代码这这里 ,我们可以用evm.codes调试这段代码。

接下来,我们看到反汇编中的主要变化在第0x14行:

label_0014:
    0014 5B JUMPDEST
    0015 81 DUP2
    0016 90 SWAP1
    0017 60 PUSH1 0x40
    0019 51 MLOAD
    001A 80 DUP1
    001B 91 SWAP2
    001C 03 SUB
    001D 90 SWAP1
    001E 60 PUSH1 0x00
    0020 F5 CREATE2
    0021 90 SWAP1
    0022 50 POP
    0023 80 DUP1
    0024 15 ISZERO
    0025 80 DUP1
    0026 15 ISZERO
    0027 61 PUSH2 0x0034
    002A 57 *JUMPI

这里,CREATE操作码被替换成了CREATE2。当我们在它被执行之前设置断点,堆栈看起来是这样的:

现在栈上有更多的项目了。让我们回顾一下CREATE2的操作码表参考,并将这些项目映射出来:

  • value: 0x0
  • offset: 0x80
  • size: 0x12b
  • salt: 0x0
    这是从堆栈中使用的新值。

这个salt值是什么,它是如何使地址具有确定性的?正如EIP-1014中所描述的,计算地址的新方法与CREATE/合约创建交易的方法有很大不同。我们传递给keccak-256哈希函数的值现在必须由以下部分组成。0xff与发起者的地址相连接,一个32字节长的salt和新部署合约的init code的keccak-256哈希值。不再有nonce!

在evm.codes playground 环境中给定以下参数:

  • sender: 0x9bbfed6889322e016e0a02ee459d306fc19545d8。
    注意:这里的发起者是部署者合约的地址(不要与部署者合约的 playground 账户混淆!)。
  • salt: 0x0
  • init code: 我们已经知道它,它是 MyCoin 合约的初始代码。

我们可以写一些代码来计算CREATE2的确定性地址,并验证它与我们在CREATE2返回0xfce80a394f575e00cb59c2f84155292f855d75b6后在堆栈中看到的地址相匹配。

my_coin = binascii.unhexlify('608060405234801561001057600080fd5b506b033b2e3c9fd0
803ce80000006000803373ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffff
ffffffffffffffffffffffffff1681526020019081526020016000208190555060bd8061006e6000
396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806318160d
dd14602d575b600080fd5b60336047565b604051603e9190606e565b60405180910390f35b6b033b
2e3c9fd0803ce800000081565b6000819050919050565b6068816057565b82525050565b60006020
82019050608160008301846061565b9291505056fea2646970667358221220e98c52b4cebc217f7c
d697fac8a76189401a6a7bc860e0c9405eefa8e819634864736f6c634300080d0033')

# '318dd75a494f082e9adf7f0cf71cdbeb96bc5e2601b0a076e6f26e61643cf6ae'
keccak_my_coin = do_sha3(my_coin)

# the sender is the deployer contract's address
sender = binascii.unhexlify('9bbfed6889322e016e0a02ee459d306fc19545d8')

# keccak-256(b'ff' + sender + salt + keccak-256(init_code))
do_sha3(b'\xff' + sender + int(0).to_bytes(length=32, byteorder='big', signed=False) + binascii.unhexlify(keccak_my_coin))

'237cc2ba643fff86788e2c0bfce80a394f575e00cb59c2f84155292f855d75b6'

# extract only the lowest order 160-bits
_[-40:]
'fce80a394f575e00cb59c2f84155292f855d75b6'

很好,我们得到了相同的地址!
CREATE/2对发起者征收的费用是32000 gas,与发送合约创建交易的费用相同。
本文结束, 结合 通过逆向和调试深入EVM 系列文章 一起学习,可以有更多的收获。

转载自:https://learnblockchain.cn/article/5148