您正在查看: Ethereum-优秀转载 分类下的文章

解构 Solidity 合约 #3:函数包装器

这是解构系列另一篇。如果你没有读过前面的文章,请先看一下。我们正在解构一个简单的Solidity合约EVM字节码

上一篇文章中,我们看到函数选择器在我们的BasicToken.sol合约中是如何充当枢纽作用。它位于合约的入口处,并将执行重定向到调用者想要运行的合约中的匹配函数。

图1. 来自函数选择器的重定向,查看解构图

如果被调用的是 totalSupply 函数,执行将被重定向到位置91,balanceOf 函数被重定向到 130,以此类推。

现在让我们像以前一样在Remix中启动一个新的调试会话,并再次调用totalSupply函数。请确保始终展开指令面板,这是Remix调试器的核心所在。正如我们之前看到的,Remix会把你放在指令246处,在那里函数的主体即将被执行。上一次,我们把交易滑块从这个位置拉回到指令0,因为我们想研究合约的入口点,以及它是如何从那里到达函数的入口点的。这一次,我们也要回去,但要回到指令91,而不是指令0,因为有这个东西,Solidity用来包裹一个函数的主体。别担心,我们会在下一篇文章中很快讲到函数的主体。我们就快到了,你的耐心会得到回报的。

所以,回到指令91,这是函数选择器指向的地方,因为函数ID与totalSupply(0x18160ddd)匹配。在这一点上,堆栈应该只包含函数的id。现在让我们从这里开始走一遍代码。

图2. non-payable 检查

如果交易中涉及到价值(即以太币),指令92到103 会被回退。同样,这也是Solidity编译器在一个函数不是 payable 时注入的一个非常常见的检查结构。我们看到这个完全相同的东西被用在构造函数中,在本系列的字节码一文,它也是一个不可支付的函数。这个 非payable 结构将检查CALLVALUE是否为零,如果是,将跳转到指令103(0x67),跳过指令102的REVERT操作码。

如果你在Remix上调用totalSupply而没有附加任何价值(ETH),将到达指令103。指令104清理了堆栈中剩余的一个零,然后112(十六进制0x0070)和245(十六进制0x00f5)被推到堆栈中。执行立即跳转到后一个位置:245号指令。注意,跳转发生在指令111,而之前推送的是112,所以想象一下代码跳去某个地方做什么,然后跳回来,也就是说,它将记住我们离开的地方(112),跳转,然后返回。

图3. 函数包装器跳入函数体(黄色虚线)。

让我们看看,通过跳转到那个神秘的245位置,是否真的会发生返回呢?

图4. totalSupply函数体。

如果你单步调试245到250,你会发现我们的分析确实是正确的。代码执行的这部分是实际的函数体,其内部工作原理现在对我们并不重要。对于本文的范围来说,重要的是代码是如何到达和离开这个 "主体" 的,也就是说,它是如何环绕它的。它跳进了的函数体,又跳出了的函数体。因此,我们看到250处的 "JUMP "将我们带回了112处,正如我们巧妙地预测的那样。

如果这是帝国时代,我们现在应该听到青铜时代的狂欢。没错,我们正在疯狂地使用JUMP和JUMPI在字节码中导航!这就是我们的目标。

但是!这次在堆栈中出现了新东西:数字10000(十六进制0x2710)。

我们刚刚执行的函数体很好心地为我们把它放在那里。记住,我们正在调用totalSupply函数。以某种方式,你需要把这个值从堆栈中拿到RETURN操作码中,这样它就可以被返回给用户。这正是代码在指令113到129之间所做的,在最后有一个实际的RETURN操作码。

图5. 一个uint256内存返回器结构

它首先会读取当前的空闲内存指针(指令113到116),然后将函数主体放在堆栈中的值复制到该空闲空间(指令117到119),最后在内存中存储数字10000(十六进制0x2710)。看到了吗,我们已经很擅长这个了! 如果你不相信我,就在Remix中浏览一下操作代码。听起来很复杂,其实不然。

最后,代码将计算出需要返回的数据的大小。我们接下来看一下:

图6. 内存指针的偏移?

它首先再次加载内存指针,并在指令120到124中使用减法与之前的内存指针进行比较,很可能是为了计算出需要返回的数据的大小。这个值似乎是在指令125中硬编码的,这似乎是多余的。这可能是优化器意识到返回数据的大小可以硬编码以节省一些Gas的结果,在应用优化后,一些残留的操作码被留下了。

这是一个奇怪的字节码的完美例子,它显然没有做任何相关的事情,或者看起来是多余的。忽略那些看起来没有任何作用的操作码是可以的,学会与它们共存(或者说 "通过它们")并简单地继续前进。随着你阅读越来越多的字节码,你会开始识别这些通用的、显然是空洞的结构的目的。

现在,这些深奥的废话已经足够了, 让我们回到地面上:

图7. 向用户返回数值

指令125将数字 32 (十六进制0x20)推到堆栈,并将其加上偏移量,将这些值交换,以符合RETURN消耗其值的顺序,用户有totalSupply值返回。

好的。所以,我们看到了代码是如何从函数选择器出发,进入这个包装结构,进入函数体,又从函数体出来,然后处理函数体产生的返回值,并打包这些数据返回给用户。那么,我们是否应该看看其他的函数,看看我们是否也能在其中观察到类似的模式?

如果你想先休息一下,这将是最佳时机。我们接下来要做的是简单地重复我们刚刚在其他两个函数中分析的结构,顺便在这里和那里加点眼色和一点魔法。

喝杯咖啡吧!

那么,这就是一个函数:第两个函数,让我们接下来看看balanceOf函数。

我们强烈建议你快速浏览一下解构图,以便直观地验证刚刚发生在totalSupply上的事情,并了解我们将在balanceOf上做什么。

函数选择器应该把我们带到指令130,也就是balanceOf的包装器,然后从那里把我们带入函数的主体,再从函数体出来,为用户打包返回值。然而,如果你注意到图中的情况,代码确实像预期的那样跳入了函数的主体,但是它返回到了totalSupply的包装器,而不是它自己的包装器。为什么?


图8. balanceOf的蓝色包装器跳回 totalSupply的黄色包装器。

一个诱人的原因是,由于totalSupply和balanceOf都返回一个uint256值,从堆栈中抓取一个uint256值并通过内存返回一个uint256的代码块是相同的,可以重复使用。Solidity编译器可能注意到为这两个包装器生成的部分代码是相同的,并决定重新使用这些代码以节省Gas。实际上,它就是这样做的,如果我们在编译合约时没有启用优化功能,我们就不会观察到这一点。让我们把这个被重用的结构称为 uint256内存返回器。一个很好的练习是在没有优化的情况下编译合约,并自己验证这一点。

Remix 时间, 让我们开始一个新的调试会话,使用我们部署合约的账户地址作为参数,调用balanceOf函数。它应该返回数字10000,因为代币的创建者最初持有所有的币。在Debug区域,退到指令142,也就是函数选择器离开的地方。


图9. balanceOf 函数包装器

在指令144,112(十六进制0x0070)被推入堆栈 -- 毫不奇怪,这就是我们刚才看到的uint256 内存返回器 所在的位置。代码即将跳转到balanceOf的主体,它正在记录主体执行后要跳回哪里。

然而,在指令175处跳入函数体的过程并没有立即发生。在它真正进行跳转之前,在指令147和172之间有一些事情正在发生:

图10. 解包 Calldata

在指令147中,一个带有40个f的十六进制数字(20个字节)被推到堆栈中,然后是推入 4。CALLDATALOAD被调用,以4为参数,其效果是在函数id之后从我们的calldata中读取第一个字(32字节)的数据。如果这听起来很奇怪,那么我建议你看一下系列函数选择器部分,在那里我们分析了calldata的工作原理。这个词是我们传入函数调用的参数,也就是我们在调用balanceOf时要检查其余额的地址。这个地址被大的0xffffffffffffffffffffffffff数字掩盖,用于类型检查/掩盖,然后在指令175中跳转到指令251(十六进制0x00fb)的目标函数体,从calldata读取的地址,舒服地放在堆栈中,准备供主体使用。

因此,我们可以看到,函数包装器的工作不仅是重定向到函数体,并为用户包装从函数体返回来的任何东西,而且还要包装供函数主体使用参数。这样,函数包装器的本质就完全展现在我们面前了!

函数包装器是一个中介,它为函数主体使用的calldata进行解包,将执行路由给它,然后为用户重新打包任何返回来的数据。这个包装器结构适用于所有属于 Solidity 合约公共接口的函数。

这种打包和解包是如何完成的,在以太坊的应用二进制接口规范中有细致的定义,它规定了函数调用中传入和传出的参数是如何编码的。

现在,让我们快速看看这3个函数包装器的整体情况:

图11. 在函数选择器之后的函数包装器。

很容易看到,在由Solidity编译的智能合约中,在函数选择器之后的一大块代码是函数包装器,一个接一个。是的,实际的函数体是在包装器之后的下一大块代码,在那之后有一个小的特别部分,叫做 "元数据哈希",我们在未来的文章中也会看到。

Solidity 编译器产生的 EVM 输出中看到一个宏大结构,在我们面前,它正慢慢变得不再神秘/混乱。当分析一个合约的字节码时,你将很快学会首先尝试看看你在这个宏大结构中的位置,然后再真正深入到字节码的步骤细节中。


图12. 大结构:函数选择器、包装器和函数体

正如我们在本系列的前几部分所做的那样,我们把对transfer函数的调用的调试工作留给你。你应该看到包装器这次是如何解压两个值的-- 接收者_to地址,以及转移的_value--将其发送给函数体,然后获取函数体的响应,再打包给用户。很有意义,对吗?

在本系列的下一部分中,我们将最终研究函数体。一旦我们做到了这一点,就没有什么其他的事情可做了......只有一些细节需要涵盖,我们就完成了。这种分而治之的策略真的开始让我们在本系列文章开始时要解决的问题,起初似乎不堪一击,但现在开始成为我们可以熟悉的模式。下次你看到一个操作码时,你将不会感到害怕。

转载:https://learnblockchain.cn/article/5212

解构 Solidity 合约 #2 - 函数选择器


这是解构系列另一篇。如果你没有读过前面的文章,请先看一下。我们正在解构一个简单的Solidity合约EVM字节码

上一篇文章中,我们发现有必要将合约的字节码分为创建时和运行时代码。在对创建部分进行了深入研究之后,现在是时候开始对运行时部分的探索了。

运行时代码

如果你看一下解构图,我们将从第二个大的部分开始,对应结构图标题为BasicToken.evm(runtime)的部分。

在一开始可能看起来有点吓人,因为运行时的代码看起来至少是创建代码的四倍!但不要担心,技能的学习是很重要的。在前面的文章中我们对EVM代码已经有所理解,结合无懈可击的分而治之的策略,使用系统化方式解决这个挑战,会把它变得容易。简单的开始查看代码,识别独立的结构,并继续分割,直到没有其他东西可以分割。

所以,为了开始,让我们回到Remix,用运行时字节码启动一个调试会话。我们怎么做呢?上一次,我们部署了合约并调试了部署交易。这一次,我们将与已部署的合约的接口交互,与它的一个函数交互,并对该交易进行调试。回顾一下我们的合约:
https://gist.github.com/ajsantander/dce951a95e7608bc29d7f5deeb6e2ecf#file-basictoken-sol

在Remix中使用Javascript虚拟机、启用了优化, 编译器0.4.24版以及10000作为初始发行量来部署它。一旦合约部署完毕,你应该看到它被列在Remix的DEPLOY & RUN面板的Deployed Contracts部分。点击它,展开合约的界面。就像专栏这篇文章那样进入调试。

展开合约的界面列出了合约的所有方法,这些方法要么是公共的(public),要么是外部的(external),也就是说,任何以太坊账户或合约都可以与之交互。私有的和内部的方法不会显示在这里,事实上,从 "外部世界 "是无法到达的。如何与合约的运行时代码的特定部分交互将是本文的重点。

入口检查

我们要不要试一下?点击Remix的Run面板上的totalSupply按钮。你应该马上看到按钮下面有一个响应。0: uint256: 10000,这是我们所期望的,因为我们以10000作为初始代币供应来部署合约。现在,在控制台面板上,点击调试(Debug)按钮,开始对这个特定的交易进行调试。注意,在Console面板上会有多个Debug按钮,请确保你使用的是最新的一个。

在这个例子中,我们不是在调试0x0地址的交易,它创建了一个合约, 正如我们在前面的文章中看到的。现在,我们要调试的是对合约本身的交易--也就是对其运行时代码的交易。

如果你打开调试面板,你应该能够验证Remix列出的指令与解构图BasicToken.evm(运行时)部分的指令是一致的。如果它们不匹配,就说明出了问题。试着重新开始,并确保你使用了上述的正确设置。

一切顺利吗?你可能注意到的第一件事是,调试器把你放在指令246处,交易滑块被定位在字节码的大约60%处。为什么呢?因为Remix是一个非常好的、慷慨的程序,它直接把你带到EVM刚要执行totalSupply函数的部分。然而,在这之前发生了很多事情,这些都是我们在这里要注意的。事实上,在这篇文章中,我们甚至不会去研究函数主体的执行。我们唯一关心的是Solidity生成的EVM代码如何引导进入的交易,我们将理解为合约的 "函数选择器 "的工作。

所以,抓住那个滑块,把它一直向左拖,这样我们就可以从指令0开始。正如我们之前所看到的,EVM总是从指令0开始执行代码,没有例外。让我们逐个操作码走过这个执行过程。

第一个出现的结构是我们以前见过的(实际上我们会看到很多)。

图1. 空闲内存指针。

这是Solidity生成的EVM代码, 在调用中总是在其他事情之前做的事情:在内存中保存一个位置以便以后使用。

让我们看看接下来会发生什么:

图2. Calldata长度检查。

如果你在Debug标签中打开Remix的Stack面板,走过指令5到7,你会看到堆栈现在包含数字4两次。如果你在阅读这些超长的数字时遇到困难,请注意调整Remix的Debug面板的宽度,使这些数字很好地融入单行。第一个数字来自普通的推送,但第二个数字是执行操作码CALLDATASIZE的结果,如黄皮书所述,它不需要参数,并返回 当前交易上下文环境中的输入数据 的大小,或者我们通常所说的calldata。

什么是calldata? 正如Solidity的文档ABI规范中所解释的那样,calldata是一个十六进制数字的编码块,它包含了关于我们想要调用合约的哪个函数的信息,以及它的参数或数据。简单地说,它由一个 "函数ID "组成,它是由函数的签名哈希值产生(截断到前四个字节)和打包的参数数据。如果你想的话,你可以详细研究一下文档链接,文档里有最详细的解释,也许一下子难以掌握,但先不要担心没法理解这种打包的工作方式,用实际的例子来理解要容易得多。

让我们看看这个calldata是什么。在Remix的调试器中打开Call Data面板,可以看到:0x18160ddd。这是四个字节,正是通过对字符串 totalSupply() 的函数签名应用 keccak256 算法,并进行前四个字节截断而产生的。由于这个特殊的函数不需要参数,它只是:一个四字节的函数ID。当CALLDATASIZE被调用时,它只是把第二个4推到堆栈上。

然后指令8使用LT来验证calldata的大小是否小于4。如果是,下面的两条指令表示跳转(JUMPI)到指令86(0x0056)。所以在此案例中,将不会有跳转,执行流程将继续到指令13。但在这之前,让我们想象一下,我们用空的calldata调用我们的合约--也就是用0x0而不是0x18160ddd。在Remix中你不能这样做,但如果你手动构建交易,你可以这样做。

在此案例中,我们会在86号指令中结束,它基本上是把几个0推到堆栈中,并把它们送入REVERT操作码。为什么呢?嗯,因为这个合约没有回退函数(fallback)。如果字节码不能识别传入的数据,它就会把数据流转到回退函数,如果没有回退函数 接住 这个调用,那么就会无情地终止执行。如果没有什么可以回退的,那么就没有什么可以做的,调用就会被完全退回(revert)。

现在,让我们做一些更有趣的事情。回到Remix的Run标签,复制Account地址,用它作为参数调用balanceOf而不是totalSupply,并调试该交易。这是一个全新的调试环节;现在我们先忘记totalSupply。导航到指令8,CALLDATASIZE现在将推送36(0x24)到堆栈。如果你看一下calldata,它现在是0x70a08231000000000000000000ca35b7d915458ef540ade6068dfe2f44e8fa733c。

这个新的calldata实际上是非常容易分解的:前四个字节70a08231是函数balanceOf(address) 签名的哈希值,而后面的32个字节包含我们作为参数传递的地址。好奇的读者可能会问,如果以太坊地址只有20个字节,为什么是32个字节?ABI总是使用32字节的 字 或 槽 来保存函数调用中使用的参数。

继续我们的balanceOf调用,让我们从第13条指令开始,这时堆栈中没有任何东西。第13条指令将0xffffffff推入堆栈,下一条指令将一个29字节长的0x000000001000...000数字推入堆栈。我们稍后会看到原因。现在,只需注意一个包含四个字节的f,另一个包含四个字节的0。

接下来CALLDATALOAD接收一个参数(第48条指令中推到堆栈的参数)并从该位置的Calldata中读取32字节的大块数据,在本例中Yul将是:

calldataload(0)

基本上是把我们的整个calldata推到堆栈中。现在是有趣的部分。DIV从堆栈中消耗了两个参数,把calldata除以那个奇怪的0x000000001000...000数字,有效地过滤了calldata中除了函数签名以外的所有东西,并把它单独留在堆栈中:0x000...000070a08231。下一条指令使用AND,它也消耗了堆栈中的两个元素:我们的函数ID和带有f的四个字节的数字。这是为了确保签名哈希值正好是8个字节的长度,掩盖了其他的东西(如果有任何东西存在的话)。我想这是Solidity使用的安全措施。

长话短说,我们已经简单地检查了calldata是否太短,如果是的话,就退回,然后把东西洗了一下,这样我们的函数ID就在堆栈里了:70a08231。

接下来的部分真的很容易理解:

图3. 函数选择器。

在指令53,代码将18160ddd(totalSuppy的函数ID)推入堆栈,然后使用DUP2来复制我们传入的calldata 70a08231值,目前在堆栈的第二个位置。为什么是DUP?因为指令59的EQ操作码将消耗堆栈中的两个值,我们想保留70a08231的值,因为我们已经费尽心思从calldata中提取它。

现在代码将尝试将calldata中的函数ID与一个已知的函数ID相匹配。由于堆中是 70a08231 ,它将不会与 18160ddd 匹配,跳过指令63的 JUMPI。但在接下来的检查中,它将与之匹配,并跳入指令74的JUMPI。

让我们花点时间观察一下,合约的每个公共或外部函数都有一个这样的匹配检查(EQ)。这是函数选择器的核心:作为某种开关语句,简单地将执行路由到代码的正确部分,它是我们的 "hub(枢纽)"。

因此,由于上一个案例是匹配的,执行流将我们带到130位置(0x82)的JUMPDEST,我们将在本系列下一部分看到它,它是balanceOf函数的ABI Wrapper(包装器)。这个包装器将负责对交易的数据进行解包,供函数主体使用。

继续,这次尝试调试transfer函数。函数选择器其实并不神秘。它是一个简单而有效的结构,位于每一个合约(至少是所有从 Solidity 编译的合约)的大门口,并将执行重定向到代码中的适当位置。它是Solidity赋予合约的字节码模拟多个入口点的能力的方式,因此也是一个接口。

看一下解构图,这就是我们刚刚解构的内容:

图4. 函数选择器和合约的运行时代码主入口点。
下一篇,我们继续解构 函数包装器。
转载:https://learnblockchain.cn/article/5197

解构Solidity合约 #1 - 字节码

解构Solidity合约 #1 - 字节码

专栏 理解 EVM - 探究Solidity 背后的秘密 继续加入OpenZepplin 解构合约系列文章。

你在路上,开着你那辆罕见的、完全修复的1969年野马马赫1快速行驶。阳光照射在所有原始的、华丽的镀金轮辋上,闪闪发光。只有你,道路,沙漠,以及对地平线的无尽追逐。完美无缺!

眨眼间,你的335马力的野兽被白烟吞没,仿佛变成了一个蒸汽机车,你被迫停在路边。带着决心,你打开引擎盖,才意识到你完全不知道自己在看什么。这台该死的机器是如何工作的?你抓住你的手机,发现你没有信号。

如果这听起来很熟悉,那么不用担心!本系列文章的目的是为了让你了解你的合约。我们将解构一个简单的 Solidity 合约,查看它的字节码,并将其分解为可识别的结构,直至最低层。我们将揭开 Solidity 的盖子。在本系列结束时,你在查看或调试EVM字节码时应该感到熟悉。这个系列的全部意义在于揭开由 Solidity 编译器产生的 EVM 字节码的神秘面纱。而且它真的比看起来要简单得多。

注意:这个系列的目的是针对那些已经熟悉 Solidity 合约开发有经验的开发者,但想了解事情是如何在一个稍深/较底层的工作 - 即 Solidity 编译器如何将 Solidity 翻译成 EVM 的字节码,以及 EVM 如何执行这种字节码。如果你还没到那一步,我推荐你阅读Solidity 合约开发专栏

下面是我们要解构的合约:

pragma solidity ^0.4.24;


contract BasicToken {

  uint256 totalSupply_;
  mapping(address => uint256) balances;

  constructor(uint256 _initialSupply) public {
    totalSupply_ = _initialSupply;
    balances[msg.sender] = _initialSupply;
  }


  function totalSupply() public view returns (uint256) {
    return totalSupply_;
  }


  function transfer(address _to, uint256 _value) public returns (bool) {
    require(_to != address(0));
    require(_value <= balances[msg.sender]);
    balances[msg.sender] = balances[msg.sender] - _value;
    balances[_to] = balances[_to] + _value;
    return true;
  }


  function balanceOf(address _owner) public view returns (uint256) {
    return balances[_owner];
  }
}

注意:这个合约很容易受到溢出攻击的影响,但我们在这里只是为了手头的工作而保持简单。

编译合约

为了编译合约,我们将使用Remix。点击文件浏览区上方左上角的+按钮,创建一个新的合约。将文件名设为BasicToken.sol。现在,将上述代码粘贴到编辑器部分。

在左侧编译 Tab部分,进入编译设置,确保选择了启用优化。另外,确认所选的Solidity编译器的版本是: "0.4.24+commit.e67f0147"。这两个细节非常重要,否则你将会看到与这里讨论的略有不同的字节码。

如果你进入Compile标签并点击Bytecode按钮,会复制Solidity编译器生成的所有东西,其中之一是一个名为BYTECODE的JSON对象,它有一个 "object "属性,是合约的编译代码。它看起来像这样:

608060405234801561001057600080fd5b5060405160208061021783398101604090815290516000818155338152600160205291909120556101d1806100466000396000f3006080604052600436106100565763ffffffff7c010000000000000000000000000000000000000000000000000000000060003504166318160ddd811461005b57806370a0823114610082578063a9059cbb146100b0575b600080fd5b34801561006757600080fd5b506100706100f5565b60408051918252519081900360200190f35b34801561008e57600080fd5b5061007073ffffffffffffffffffffffffffffffffffffffff600435166100fb565b3480156100bc57600080fd5b506100e173ffffffffffffffffffffffffffffffffffffffff60043516602435610123565b604080519115158252519081900360200190f35b60005490565b73ffffffffffffffffffffffffffffffffffffffff1660009081526001602052604090205490565b600073ffffffffffffffffffffffffffffffffffffffff8316151561014757600080fd5b3360009081526001602052604090205482111561016357600080fd5b503360009081526001602081905260408083208054859003905573ffffffffffffffffffffffffffffffffffffffff85168352909120805483019055929150505600a165627a7a72305820a5d999f4459642872a29be93a490575d345e40fc91a7cccb2cf29c88bcdaf3be0029

是的,这是完全不可读的(至少对一个正常人来说)。

部署合约

接下来,进入Remix中的DEPLOY & RUN部分。在顶部,确保你使用的是Remix VM。这基本上是一个嵌入式的Javascript EVM+ 网络,是我们理想的以太坊游乐场。确保BasicToken被选中,并在Deploy输入框中输入数字10000。接下来,点击部署按钮。这将部署一个BasicToken合约的实例,初始发行量为10000个代币,由顶部当前选择的账户拥有,它将持有我们的代币发行总量。

在部署的合约部分,你应该看到已部署的合约,并有字段与它的三个功能交互:transfer、balanceOf 和 totalSupply。在这里,我们将能够与我们刚刚部署的合约的实例进行交互。

但在此之前,让我们看看 "部署 "合约到底意味着什么。在控制台区域,你应该看到日志 "create of BasicToken pending...",然后是一个带有各种字段的交易条目:from、to、value、data、logs和 hash。点击这个条目来扩展交易的信息。即使是缩写,你也应该看到该交易的 input 数据是我们上面介绍的相同的字节码(加上参数)。这个交易被发送到0x0地址,结果是,一个新的合约实例被创建,有它自己的地址和代码。我们将在下一篇文章中详细研究这个过程.

拆解字节码

在交易数据的右边,仍然在控制台中,点击Debug(调试)按钮。这将激活Remix右侧区域的调试器标签。让我们来看看指令部分。如果你向下滚动,你应该看到以下内容。

000 PUSH1 80
002 PUSH1 40
004 MSTORE
005 CALLVALUE
006 DUP1
007 ISZERO
008 PUSH2 0010
011 JUMPI
012 PUSH1 00
014 DUP1
015 REVERT
016 JUMPDEST
017 POP
018 PUSH1 40
020 MLOAD
021 PUSH1 20
023 DUP1
024 PUSH2 0217
027 DUP4
028 CODECOPY
029 DUP2
030 ADD
031 PUSH1 40
033 SWAP1
034 DUP2
035 MSTORE
036 SWAP1
037 MLOAD
038 PUSH1 00
040 DUP2
041 DUP2
042 SSTORE
043 CALLER
044 DUP2
045 MSTORE
046 PUSH1 01
048 PUSH1 20
050 MSTORE
051 SWAP2
052 SWAP1
053 SWAP2
054 SHA3
055 SSTORE
056 PUSH2 01d1
059 DUP1
060 PUSH2 0046
063 PUSH1 00
065 CODECOPY
066 PUSH1 00
068 RETURN
069 STOP
070 PUSH1 80
072 PUSH1 40
074 MSTORE
075 PUSH1 04
077 CALLDATASIZE
078 LT
079 PUSH2 0056
082 JUMPI
083 PUSH4 ffffffff
088 PUSH29 0100000000000000000000000000000000000000000000000000000000
118 PUSH1 00
120 CALLDATALOAD
121 DIV
122 AND
123 PUSH4 18160ddd
128 DUP2
129 EQ
130 PUSH2 005b
133 JUMPI
134 DUP1
135 PUSH4 70a08231
140 EQ
141 PUSH2 0082
144 JUMPI
145 DUP1
146 PUSH4 a9059cbb
151 EQ
152 PUSH2 00b0
155 JUMPI
156 JUMPDEST
157 PUSH1 00
159 DUP1
160 REVERT
161 JUMPDEST
162 CALLVALUE
163 DUP1
164 ISZERO
165 PUSH2 0067
168 JUMPI
169 PUSH1 00
171 DUP1
172 REVERT
173 JUMPDEST
174 POP
175 PUSH2 0070
178 PUSH2 00f5
181 JUMP
182 JUMPDEST
183 PUSH1 40
185 DUP1
186 MLOAD
187 SWAP2
188 DUP3
189 MSTORE
190 MLOAD
191 SWAP1
192 DUP2
193 SWAP1
194 SUB
195 PUSH1 20
197 ADD
198 SWAP1
199 RETURN
200 JUMPDEST
201 CALLVALUE
202 DUP1
203 ISZERO
204 PUSH2 008e
207 JUMPI
208 PUSH1 00
210 DUP1
211 REVERT
212 JUMPDEST
213 POP
214 PUSH2 0070
217 PUSH20 ffffffffffffffffffffffffffffffffffffffff
238 PUSH1 04
240 CALLDATALOAD
241 AND
242 PUSH2 00fb
245 JUMP
246 JUMPDEST
247 CALLVALUE
248 DUP1
249 ISZERO
250 PUSH2 00bc
253 JUMPI
254 PUSH1 00
256 DUP1
257 REVERT
258 JUMPDEST
259 POP
260 PUSH2 00e1
263 PUSH20 ffffffffffffffffffffffffffffffffffffffff
284 PUSH1 04
286 CALLDATALOAD
287 AND
288 PUSH1 24
290 CALLDATALOAD
291 PUSH2 0123
294 JUMP
295 JUMPDEST
296 PUSH1 40
298 DUP1
299 MLOAD
300 SWAP2
301 ISZERO
302 ISZERO
303 DUP3
304 MSTORE
305 MLOAD
306 SWAP1
307 DUP2
308 SWAP1
309 SUB
310 PUSH1 20
312 ADD
313 SWAP1
314 RETURN
315 JUMPDEST
316 PUSH1 00
318 SLOAD
319 SWAP1
320 JUMP
321 JUMPDEST
322 PUSH20 ffffffffffffffffffffffffffffffffffffffff
343 AND
344 PUSH1 00
346 SWAP1
347 DUP2
348 MSTORE
349 PUSH1 01
351 PUSH1 20
353 MSTORE
354 PUSH1 40
356 SWAP1
357 SHA3
358 SLOAD
359 SWAP1
360 JUMP
361 JUMPDEST
362 PUSH1 00
364 PUSH20 ffffffffffffffffffffffffffffffffffffffff
385 DUP4
386 AND
387 ISZERO
388 ISZERO
389 PUSH2 0147
392 JUMPI
393 PUSH1 00
395 DUP1
396 REVERT
397 JUMPDEST
398 CALLER
399 PUSH1 00
401 SWAP1
402 DUP2
403 MSTORE
404 PUSH1 01
406 PUSH1 20
408 MSTORE
409 PUSH1 40
411 SWAP1
412 SHA3
413 SLOAD
414 DUP3
415 GT
416 ISZERO
417 PUSH2 0163
420 JUMPI
421 PUSH1 00
423 DUP1
424 REVERT
425 JUMPDEST
426 POP
427 CALLER
428 PUSH1 00
430 SWAP1
431 DUP2
432 MSTORE
433 PUSH1 01
435 PUSH1 20
437 DUP2
438 SWAP1
439 MSTORE
440 PUSH1 40
442 DUP1
443 DUP4
444 SHA3
445 DUP1
446 SLOAD
447 DUP6
448 SWAP1
449 SUB
450 SWAP1
451 SSTORE
452 PUSH20 ffffffffffffffffffffffffffffffffffffffff
473 DUP6
474 AND
475 DUP4
476 MSTORE
477 SWAP1
478 SWAP2
479 SHA3
480 DUP1
481 SLOAD
482 DUP4
483 ADD
484 SWAP1
485 SSTORE
486 SWAP3
487 SWAP2
488 POP
489 POP
490 JUMP
491 STOP
492 LOG1
493 PUSH6 627a7a723058
500 SHA3
501 INVALID
502 INVALID
503 SWAP10
504 DELEGATECALL
505 GASLIMIT
506 SWAP7
507 TIMESTAMP
508 DUP8
509 INVALID
510 INVALID
511 INVALID
512 SWAP4
513 LOG4
514 SWAP1
515 JUMPI
516 INVALID
517 CALLVALUE
518 INVALID
519 BLOCKHASH
520 INVALID
521 SWAP2
522 INVALID
523 INVALID
524 INVALID
525 INVALID
526 CALLCODE
527 SWAP13
528 DUP9
529 INVALID
530 INVALID
531 RETURN
532 INVALID
533 STOP
534 INVALID
535 STOP
536 STOP
537 STOP
538 STOP
539 STOP
540 STOP
541 STOP
542 STOP
543 STOP
544 STOP
545 STOP
546 STOP
547 STOP
548 STOP
549 STOP
550 STOP
551 STOP
552 STOP
553 STOP
554 STOP
555 STOP
556 STOP
557 STOP
558 STOP
559 STOP
560 STOP
561 STOP
562 STOP
563 STOP
564 STOP
565 INVALID
566 LT

为了确保你所遵循的是本系列中描述的同一套操作代码,请将你在Remix中看到的内容与本gist中的字节码进行比较。

这是合约的反汇编字节码。反汇编听起来挺吓人的,但其实很简单。如果你按字节扫描原始字节码(一次两个字符),EVM会识别出特定的操作码,并将其与特定的动作联系起来。比如说。

0x60 => PUSH
0x01 => ADD
0x02 => MUL
0x00 => STOP
...

拆解后的代码仍然非常低级,难以阅读,但正如你将看到的,我们可以开始对它进行理解。

操作码

在我们开始彻底解构字节码的雄心壮志之前,你将需要一套基本的工具来理解各个操作码,如PUSH、ADD、SWAP、DUP等。一个操作码,能进行的操作是从EVM的堆栈、内存或属于合约的存储中推入或消耗项目。

要查看EVM可以处理的所有可用的操作码,请查看EVM Codes。要了解每个操作码的作用和工作原理,Solidity的汇编文档是一个很好的参考。尽管它与原始操作码不是一对一的关系,但它非常接近(它实际上是Yul,Solidity和EVM字节码之间的一种中间语言)。最后,如果你讲究科学,总是有以太坊 Yellow Paper可以依靠。

现在从头到尾阅读这些资料是没有意义的;只是把它们放在身边作为参考。我们将在前进的过程中使用它们。

指令

上面拆解的代码中的每一行都是EVM要执行的指令。每条指令都包含一个操作码。例如,让我们来看看其中的一条指令,88号指令,它将数字4推到堆栈中。这个特殊的反汇编程序对指令的解释如下:

88 PUSH1 0x04
|  |     |     
|  |     要推入的值(16 进制)
|  指令
Instruction number.

尽管反汇编后的代码使我们更接近于理解正在发生的事情,但它仍然相当令人生畏。我们需要一个策略来解构整个代码,它有596条指令!我们需要一个策略来解构整个代码。

策略

那些一开始就显得不堪一击的问题通常会屈服于全能的 "分而治之 "策略,这个问题也不例外。我们将在拆解后的代码中找出分割点,然后一点一点地减少,直到最后得到可消化的小块代码,我们将在Remix的调试器中一步一步地完成。在下图中,我们可以看到我们可以对反汇编的代码进行第一次分割,我们将在下一篇文章中对其进行全面分析。

你可以在解构图中找到整个解构的最终结果。如果你一开始不理解这张图,不要担心。你不应该这样做。这个系列会一步一步地看完它。把它放在身边,这样你就可以在我们进行的过程中保持对大局的跟踪。

Creation vs. Runtime

让我们开始用分而治之的光剑来攻击我们合约。正如我们在前面介绍所看到的,这些反汇编的代码是非常低级的,但与原始字节码相比是可读性好了很多。请确保你已经跟随前面的内容,并且你已经在Remix中编译和部署了BasicToken合约。调试创建交易,打开指令面板。另外,在我们进行的过程中,请准备好解构图

现在,让我们专注于 "JUMP"、"JUMPI"、"JUMPDEST"、"RETURN "和 "STOP" 操作码,而忽略所有其他操作码。每当我们发现一个操作码不属于这些操作码时,我们将忽略它,并跳到下一条指令,假装没有任何干预。

当EVM执行代码时,它是自上而下执行的,没有任何例外--也就是说,代码没有其他入口点。它总是从顶部开始。它可以跳来跳去,是的,这正是JUMP和JUMPI的作用。JUMP从堆栈中获取最顶端的值,并将执行转移到该位置。但目标位置必须包含JUMPDEST操作码,否则执行会失败。这就是JUMPDEST的唯一目的:将一个位置标记为有效的跳转目标。JUMPI是完全相同的,但是在堆栈的第二个位置不能是 0,否则就不会有跳转。所以这是一个有条件的跳转。STOP完全停止了合约的执行,RETURN也停止了执行,但从EVM的一部分内存中返回数据,这很方便。

所以,让我们开始解释代码,记住所有这些。在Remix的调试器中,将交易滑块向左移动,查看指令部分。你可以用Step Into按钮(看起来像一个向下的小箭头的那个)来浏览这些指令。第一条指令可以忽略不计,但在第11条指令中我们发现了第一个 JUMPI。如果它不跳转,它将继续执行第12至15条指令,并以 REVERT 结束,这将停止执行。但是如果它跳转了,它将跳过这些指令到达16号位置(十六进制0x0010,在指令8中被推入堆栈)。指令16是一个JUMPDEST。到目前为止还不错。

继续浏览操作码,直到交易滑块一直向右移动。刚才发生了很多废话,但只有在第68个位置我们发现了一个 "RETURN" 操作码(还有一个 "STOP "操作码在指令69中,以备不时之需)。这是很奇怪的。如果你仔细想想,这个合约的控制流总是在指令15或68结束。我们刚刚浏览了一遍,确定没有其他可能的流程,那么剩下的指令是干什么用的呢?(如果你在指令面板下滑,你会看到代码在566位置结束)。

我们刚刚穿越的这组指令(0到69)就是所谓的合约的 "创建代码(creation code)"。它永远不会成为合约代码本身的一部分,而只是在创建合约的交易中由EVM执行一次。我们很快就会发现,这段代码负责设置创建的合约的初始状态,以及返回其运行时代码的副本。剩下的497条指令(70到566),正如我们所看到的,永远不会被执行流到达,正是将成为部署的合约的一部分的代码。

如果你打开解构图,你应该看到我们刚刚是如何进行第一次拆分:我们已经区分了创建时与运行时代码。

现在我们将深入研究代码的创建部分:

对BasicToken.sol的创建时EVM字节码的解构。

这是本文中需要理解的最重要的概念。创建代码在一个交易中被执行,它返回一个运行时代码的副本,也就是合约的实际代码。正如我们将看到的,构造函数是创建代码的一部分,而不是运行时代码的一部分。合约的构造函数是创建代码的一部分;一旦合约被部署,它就不会出现在合约的代码中。

这个魔术是如何发生的呢?这就是我们现在要一步步分析的:

好了。所以,现在我们的问题被简化为理解与创建时间代码相对应的这~70条指令。

让我们重新采取自上而下的方法,这次我们要边走边理解所有的指令,不要跳过任何一条。首先,让我们把重点放在指令0到2上,这些指令使用了PUSH1和MSTORE操作码:

空闲内存指针的EVM字节码结构

PUSH1简单地将一个字节推入堆栈顶部,MSTORE从堆栈中抓取最后两个项目并将其中一个存储到内存中:

mstore(0x40, 0x80)
   |     |
   |     What to store.
   Where to store.
(in memory)

注意:上面的代码片段是Yul-风格代码。注意它是如何从堆栈中从左到右消耗元素的,总是先消耗堆栈顶部的东西。

这基本上是将数字0x80(十进制128)存储到内存的位置0x40(十进制64)。为了什么? 肯定是有原因的(实际上我们以后会看到)。现在,在Remix的调试器标签中打开_Stack和Memory面板,以便在你来回踩踏这些指令时直观地看到这些值。

你可能想知道:指令1和3是什么?PUSH指令是唯一的EVM指令,实际上是由两个或多个字节组成。所以,PUSH 80实际上是两条指令。那么谜底就揭晓了:指令1是0x80,指令3是0x40。

接下来是指令5到15:

非payable的 EVM字节码结构。

这里我们有一堆新的操作码:CALLVALUE,DUP1,ISZERO,PUSH2,和REVERT。CALLVALUE推送了创建交易中涉及的Wei的数量,DUP1复制了堆栈中的第一个元素,ISZERO在堆栈的最上面的值为0时推送了一个1,PUSH2就像PUSH1,但它可以推送两个字节到堆栈,而不是只有一个,REVERT则停止执行。

那么这里发生了什么?在Solidity中,我们可以这样写这块汇编:

if(msg.value != 0) revert();

这段代码实际上不是我们原始 Solidity 源的一部分,而是由编译器注入的,因为我们没有声明构造函数是payable的。在Solidity的最新版本中,没有明确声明自己是可支付的函数不能接收以太币。回到汇编代码,第11条指令的JUMPI将跳过第12至15条指令,如果不涉及以太币,则跳到第16条。否则,REVERT将在两个参数都为0的情况下执行(意味着将不会返回任何有用的数据)。

好了! 咖啡时间。接下来的部分会有点棘手,所以休息几分钟可能是个好主意。来吧,给自己准备一杯好咖啡,同时唤起你的注意力。确保你理解我们到目前为止所看到的,因为接下来的部分有点复杂。

如果你想用另一种方式来可视化我们刚刚做的事情,可以试试我建立的这个简单的工具: solmap。它允许你实时编译Solidity代码,然后点击EVM操作码来突出相关的Solidity代码。反汇编与Remix的有点不同,但通过比较,你应该能理解它。
准备好继续前进了吗?很好! 接下来是指令16到37。继续用Remix的调试器进行跟踪。(记住,Remix是你的朋友^^)


EVM的字节码结构,用于从合约的字节码末尾附加的代码中检索构造函数参数。

前四条指令(17到20)读取内存中0x40位置的任何东西,并将其推到堆栈。如果你还记得刚才的情况,那应该是数字0x80。接下来的指令将0x20(十进制32)推入堆栈(指令21),复制该值(指令23),推入0x0217(十进制535)(指令24),最后复制第四个值(指令27),这应该是0x80。吁! 我在写这句话的时候差点没喘过气来。在看这样的EVM指令时,暂时不明白发生了什么也没关系。别担心,它会突然在你的脑海中闪现。

在第28条指令中,CODECOPY被执行,它需要三个参数:要复制代码的目标内存位置,要复制的指令号,以及要复制的代码字节数。因此,在此案例中,它的目标内存位置是0x80,从代码中的字节位置535开始,代码长度为32字节。为什么?

如果你看一下整个反汇编的代码,有566条指令。那么,为什么这段代码试图复制最后32个字节的代码?实际上,在部署一个构造函数包含参数的合约时,参数会作为原始十六进制数据附加到代码的末尾。向下滚动指令面板可以看到这一点。在此案例中,构造函数需要一个uint256参数,所以这段代码所做的就是把参数从附加在代码末尾的值复制到内存。这32条指令在分解后的代码中没有意义,但在原始十六进制中却有意义:0x000000000000000000000...00000000000000000002710。当然,这就是我们在部署合约时传递给构造函数的十进制值10000!

明白为什么你需要那杯咖啡了吗?再一次,请在Remix中一步一步地重复这一部分,确保你明白刚才发生了什么。最终的结果应该是,你看到数字0x00...002710存储在内存的0x80位置。

好的。对于下一部分,你可能想给自己准备两杯威士忌。这将变得很奇怪。

只是开玩笑。没有更多的魔法,我保证。从这里开始都是轻松的下坡路。

下一组指令(29到35)将存储在内存0x40位置的数值从0x80更新到0xa0:也就是说,它们将数值偏移了0x20(32)字节。现在我们可以开始理解指令0到2 Solidity记录了一个叫做 空闲内存指针 的东西:也就是说,在内存中我们可以用来存储东西,并保证没有人会覆盖它(当然,如果我们犯了一个错误,除了我们使用内联汇编或Yul)。因此,由于我们在旧的空闲内存位置存储了数字10000,我们通过向前移动32字节来更新空闲内存指针。

即使是有经验的Solidity开发者,当他们看到 空闲内存指针 的表述或代码mload(0x40, 0x80)时也会感到困惑。这些只是在说,我们将从这一点开始向内存写入,每次我们写入一个新的条目,并保留一个偏移量的记录。 Solidity中的每一个函数,当编译为EVM字节码时,将初始化这个指针。

在0x00到0x40之间的内存中有什么,你可能会问。没有什么。它只是Solidity为计算哈希值而保留的一大块内存,正如我们即将看到的,哈希值对于映射和其他类型的动态数据是必要的。

现在,在第37条指令中,MLOAD从内存的0x40位置读取,基本上是将我们的10000值从内存中下载到堆栈中,在那里它将是新鲜的,准备在下一组指令中使用。

这是由Solidity生成的EVM字节码的一个常见模式:在执行一个函数体之前,函数的参数被加载到堆栈中(只要有可能),这样接下来的代码就可以消耗它们--而这正是接下来要发生的。

让我们继续学习指令38至55:

这些指令不外乎是构造函数的主体:也就是 Solidity 代码:

totalSupply_ = _initialSupply;
balances[msg.sender] =  _initialSupply;

前四条指令是非常不言自明的(38到42)。首先,0被推到堆栈中,然后堆栈中的第二项被复制(这就是我们的10000号),然后数字0被复制并推到堆栈中,这就是totalSupply_的存储的位置槽。现在,SSTORE可以消耗这些值,并且仍然保留10000,以供将来使用。

sstore(0x00, 0x2710) 
   |     |
   |     What to store.
   Where to store.
(in storage)

Voila! 我们在变量 "totalSupply_"中存储了数字10000。这不是很令人吃惊吗?

请确保在Remix的调试器标签中也能看到这个值。你会在 加载完成的状态 面板中找到它。

下一组指令(43至54)有点令人不安,但基本上将处理在msg.sender的键的balances映射中存储10000。在继续前进之前,请确保你理解Solidity文档的这一部分,它解释了映射是如何保存在存储器中的。长话短说,它将把映射值的槽(在本例中是数字1,因为它是合约中声明的第二个变量)和使用的键(在本例中是msg.sender,用操作码CALLER获得)连接起来,然后用SHA3操作码哈希,把它作为存储的目标位置。最后,存储只是一个简单的字典或哈希表。

继续执行指令43到45,msg.sender地址被存储在内存中(这次是在0x00位置),然后在指令46到50,值1(映射的槽)被存储在内存0x20位置。最后,SHA3 操作码计算内存中从0x00位置到0x40位置的任何东西的Keccak256哈希值--即映射的槽/位置与所使用的key的连接。这正是10000值在我们的映射中的存储位置。

sstore(hash..., 0x2710)
   |        |
   |        What to store.
   Where to store.

就这样了。在这一点上,构造函数的主体已经被完全执行。

所有这些一开始可能有点令人不知所措,但这是 Solidity 中存储工作的一个基本部分。如果你不太明白,我建议你跟着Remix的调试器多看几遍,把Stack和Memory面板放在眼前。这种模式在Solidity生成的EVM字节码中随处可见,你将很快学会毫不费力地识别它。归根结底,它只不过是在存储中计算某个映射的某个键的值应该保存在哪里。

好了,我们在这里差不多完成了。如果你走到这一步,接下来的部分将是小菜一碟。

运行时的代码复制

在指令56到65中,我们再次进行代码复制。只是这一次,我们不是把代码的最后32个字节复制到内存中;我们要把0x01d1(十进制465)字节从0x0046(十进制70)位置开始复制到内存的0位置。

如果你再把交易滑块一直滑到右边,你会发现位置70就在我们的创建时EVM代码之后,执行停止了。运行时的字节码就包含在这465个字节中。这是代码的一部分,将被保存在区块链中作为合约的运行时代码,这将是每次与合约交互时执行的代码。(我们将在本系列的未来部分介绍运行时代码)。

而这正是指令66至69的作用:我们复制到内存中的代码被返回:

运行时代码返回EVM字节码结构。

RETURN抓取复制到内存中的代码并将其交给EVM。如果这个创建代码在交易的上下文中被执行到0x0地址,EVM将执行该代码并将返回值存储为创建的合约的运行时代码。

这就是了! 现在,我们的BasicToken合约实例将被创建和部署,其初始状态和运行时代码已准备好使用。如果你退一步看一下空闲指针图:

你会发现我们分析的所有EVM字节码结构都是通用的,除了紫色高亮显示的那个:也就是说,它们将在Solidity编译器生成的创建时字节码中。各个构造函数的不同之处仅仅在于紫色部分--构造函数的实际主体。嵌入在字节码末尾的获取参数的结构,以及复制运行时代码并返回的结构,可以被认为是模板代码和通用的EVM操作码结构。你现在应该可以看任何一个构造函数了,在逐条研究它的指令之前,你应该对构成它的组件有一个大致的概念。

在下一篇文章中,我们将研究实际的运行时代码,首先是你如何在不同的入口点与合约的EVM代码进行交互。现在,给自己一个当之无愧的掌声,因为你刚刚消化了本系列中最困难的部分。你也应该培养了很强的能力来阅读和调试EVM字节码,理解泛型结构,最重要的是,理解创建时和运行时EVM字节码之间的区别。这就是Solidity中合约的构造函数的特殊之处。

转载:https://learnblockchain.cn/article/5190

Gas 技巧:Solidity 中利用位图大幅节省Gas费

有过合约开发经验的同学都可能知道的,以太坊中最昂贵的操作是存储数据(SSTORE)。所以大家也应该一直寻找方法来减少存储需求。让我们来探讨一个特别有用的方法:位图
注:在 Uniswap 的代码中,有很多使用位图来优化 gas 的技巧。

如何实现一个简单的位图

假设我们想存储10个布尔值。通常,我们会用一个简单的布尔数组来实现这一点,例如:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract BitmapTest {
    bool[10] implementationWithBool;

    function setDataWithBoolArray(bool[10] memory data) external {
        implementationWithBool = data;
    }

    function readWithBoolArray(uint256 index) external returns (bool) {
        return implementationWithBool[index];
    }
}

而使用 Bitmap 位图,可以用一个 uint10代替bool数组来实现。uint10将在存储中用10 位(bits 比特位)表示。
例如,这里有一些用比特(bit)表示的十进制数字:

  • 0: 0000000000
  • 1: 0000000001
  • 512: 0100000000
  • 729: 1011011001
  • 1023: 1111111111

我们可以用一些额外的数学方法来利用这种位表示法。为了得到这个整数的第n位,我们可以使用位运算

让我们来看看729这个数字,在常规方式下,用一个bool数组来读取第4个bool值,它只是一个array[4]。对于位图,我们可以通过使用左移运算符<<将1向左移,来代替创建第二个数字。

1 << 4 = 0000000001 << 4 = 0000010000

现在使用位和运算符&,我们可以得到第n位的值(从0开始计算)。

729 & (1 << 4) = 1011011001 & 0000010000

其结果是

  • 1011011001 &
  • 0000010000 =
  • 0000010000

只要这个结果 大于0 ,原数的第n位就是1,所以现在我们可以实现位图:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract BitmapTest {
    uint256 implementationWithBitmap;

    function setDataWithBitmap(uint256 data) external {
        implementationWithBitmap = data;
    }

    function readWithBitmap(uint256 indexFromRight) external returns (bool) {
        uint256 bitAtIndex = implementationWithBitmap & (1 << indexFromRight);
        return bitAtIndex > 0;
    }
}

选择位图大小

你可能已经注意到,我们在上面的实现中选择了uint256。虽然uint10在技术上是足够的,但这实际上会导致比使用uint256更高的Gas成本。这是因为EVM在32个字节的寄存器(256位)上操作,任何低于这个数字的都需要额外的转换。

所以你应该总是选择 uint256 吗?

也不是,这取决于你的使用情况。用一个uint256,你可以表示256位。那么你想存储的数据是否适合一个256位的布尔数组?如果是,那么就继续使用单个uint256。

如果不能,例如布尔数组可以任意增长,那么就把位图本身打包成一个数组。我将在最后用一个例子来探讨这两种选择。

比较Gas成本

让我们先来看看10位例子中的Gas成本差异。用原来的布尔数组,交易的执行成本是:

  • setDataWithBoolArray: 140,583 gas
  • ReadWithBoolArray: 1,281 gas

现在有了位图,我们可以大大改善这个情况:

  • setDataWithBitmap: 78,043 gas
  • readWithBitmap: 1,129 gas

使用场景1:设置布尔开关

现在来看看第一个使用场景: 布尔开关通常被用来激活系统中的某些选项。
比方说,你建立了一个像Uniswap一样的DEX,你可以自动触发的交易。你可以根据交易的来源来激活某些设置。例如,你可能有如下开关

  • NO_FEES (无交易费)
  • ...
  • SENDING_FEES_TO_GOVERNANCE (发送费用到治理)
  • DELAY_TRADE_EXECUTION (延迟交易执行)

这些选项可能不会超过256个,所以你可以很容易地将这些选项存储在一个uint256中。

使用场景2:参与者的名单

你可能想向任何参与过你的合约的人支付奖励。这可能是一个任意的大列表。你可以在一个映射中保存每个参与者,或者用一个uint256数组来代替位图。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

contract ParticipatedWithBitmap {
    uint256[] public participantsBitmap;

    function setParticipants(uint256[] memory participantsBitmap_) external onlyOwner {
        participantsBitmap = participantsBitmap_;
    }

    function hasParticipated(uint256 bitmapIndex, uint256 indexFromRight) external view returns (bool) {
        uint256 bitAtIndex = participantsBitmap[bitmapIndex] & (1 << indexFromRight);
        return bitAtIndex > 0;
    }
}

欢迎订阅专栏 学习更多 Solidity 高阶优化技巧。

转载:https://learnblockchain.cn/article/5287

一文读懂以太坊签名:ECDSA、RLP、EIP155、EIP191、EIP712

思维导图

我把以太坊签名分为对消息签名与对交易签名,这两种签名都是基于ECDSA算法与流程,本章就让我们来搞清楚两种签名具体的内容。

签名概述

签名的作用或目的?

  • 身份认证:证明你拥有地址的私钥;
  • 不可否认:确认你的确发布过该消息;
  • 完整性:确保信息没有被篡改;

具体见维基百科:wikipedia签名的性质

什么是签名?

当我刚开始接触签名这个名词时,我也很困惑,此处的签名和现实世界中合同的签名有什么不同?当我签署一份租房合同,当我们租期到时,如果对屋内的物品所有损坏,房东可凭借这份合同上的内容对我进行索赔(扣押金),如果我进行抵赖,说我没签署过这份合同,那么房东可去司法机构进行签名笔迹认证。以太坊中的签名也是如此,在租房合同中签名是笔迹,在以太坊中的签名就是一段数据,这段数据的作用和我签署租房合同的签名笔迹没有任何不同,节点们(矿工们、验证者们)可以凭借这段数据进行身份认证,即证明这些消息就是我签署的(因为只有我拥有私钥),同时,我想抵赖也是不可能的,因为这段数据具备不可否认性,第三方也不可能对消息进行篡改,因为这段数据具备完整性。如果对上面阐述的内容暂时不理解也没关系,继续往下看,多看一些资料很快就会理解的。此时我们只需记住,以太坊或者计算机中这个"签名"与现实世界中的向"合同上签名"是一个意义。下面先概括一下以太坊的签名与验证过程:

  • 签名过程:ECDSA_正向算法(消息 + 私钥 + 随机数)= 签名
  • 验证过程:ECDSA_反向算法(消息 + 签名)= 公钥

在以太坊、比特币中这个算法是经过二开的ECDSA(原始的ECDSA只有r、s组成,以太坊、比特币的ECDSA由r、s、v组成)。

使用ECDSA签名并验证

什么是ECDSA

ECDSA可理解为以太坊、比特币对消息、交易进行签名与验证的算法与流程。在智能合约层面,我们不必多关注其算法的细节,只需理解其流程,看得懂已有项目代码,可以在项目写出对应功能代码即可。

流程

  • 签名即正向算法(消息 + 私钥 + 随机数)= 签名,其中消息是公开的,私钥是隐私的,经过ECDSA正向算法可得到签名,即r、s、v(不用纠结与r、s、v到底什么,只需要知道这就是签名即可)。
  • 验证即反向算法(消息 + 签名)= 公钥,其中消息是公开的,签名是公开的,经过ECDSA反向算法可得到公钥,然后对比已公开的公钥。

签名交易

关键词

  • RLP:一种序列化的方式,其与网络传输中json的序列化/反序列化有一些不同,RLP不仅兼顾网络传输,其编码特性更确保了编码后的一致性,因为每笔交易过程中要进行Keccak256,如果不能保证编码后的一致性,会导致其Hash值不同,那么验证者就无法验证交易是否由同一个人发出。
    若对上面的阐述不理解,继续看下面的内容。
    编码方式详情见详解以太坊RLP编码(不用过度研究)。
  • Keccak256 :以太坊的Hash算法,生成32个字节Hash值。

签名交易流程

1. 构建原始交易对象

  • nonce: 记录发起交易的账户已执行交易总数。Nonce的值随着每个新交易的执行不断增加,这能让网络了解执行交易需要遵循的顺序,并且作为交易的重放保护。
  • gasPrice:该交易每单位gas的价格,Gas价格目前以Gwei为单位(即10^9wei),其范围是大于0.1Gwei,可进行灵活设置。
  • gasLimit:该交易支付的最高gas上限。该上限能确保在出现交易执行问题(比如陷入无限循环)之时,交易账户不会耗尽所有资金。一旦交易执行完毕,剩余所有gas会返还至交易账户。
  • to:该交易被送往的地址(调用的合约地址或转账对方的账户地址)。
  • value:交易发送的以太币总量。
  • data:
    • 若该交易是以太币交易,则data为空;
    • 若是部署合约,则data为合约的bytecode;
    • 若是合约调用,则需要从合约ABI中获取函数签名,并取函数签名hash值前4字节与所有参数的编码方式值进行拼接而成,具体参见文章Ethereum的合约ABI拓展
  • chainId:防止跨链重放攻击。 ->EIP155

2. 签署交易

签署交易可使用MetaMask和ethers库。

  • MetaMask
    前端:使用MetaMask进行签名为前端技术栈,目前比较流行为nextjs+ethers,我对前端不太了解,这里不做展开。
  • ethers库
    后端:使用ethers库可以进行交易的签名,详情见如下代码:
const ethers = require("ethers")
require("dotenv").config()

async function main() {
    // 将RPC与私钥存储在环境变量中
    // RPC节点连接,直接用alchemy即可
    let provider = new ethers.providers.JsonRpcProvider(process.env.RPC_URL)
    // 新建钱包对象
    let wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider)
    // 返回这个地址已经发送过多少次交易
    const nonce = await wallet.getTransactionCount()
    // 构造raw TX
    tx = {
      nonce: nonce,
      gasPrice: 100000000000,
      gasLimit: 1000000,
      to: null,
      value: 0,
      data: "",
      chainId: 1, //也可以自动获取chainId = provider.getNetwork()
    }
    // 签名,其中过程见下面详述
    let resp = await wallet.signTransaction(tx)
    console.log(resp)
    // 发送交易
    const sentTxResponse = await wallet.sendTransaction(tx);
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

wallet.signTransaction中发生了什么?

  1. 对(nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0)进行RLP编码;
  2. 对上面的RLP编码值进行Keccak256 ;
  3. 对上面的Keccak256值进行ECDSA私钥签名(即正向算法);
  4. 对上面的ECDSA私钥签名(v、r、s)结果与交易消息再次进行RPL编码,即RLP(nonce, gasPrice, gasLimit, to, value, data, v, r, s),可得到如下编码;
    0xf8511485174876e800830f42408080802da077cdb733c55b4b9b421c9e0e54264c6ccf097e5352ab117e346b69ac09755606a03ef912e80a022713cf5ccc3856f2d4159bf47de7c14090b66ef155b10d073776

    细心的同学可能会问,为什么步骤1中包含chainId字段,而步骤4中再次编码时没有chainId字段?原始消息内容都不一样,怎么可能会验证通过?先别急,这是因为chainId 是被编码到签名的 v参数中的,因此我们不会将chainId本身包含在最终的签名交易数据中(下面一小节也有阐述)。当然,我们也不会提供任何发送方地址,因为地址可以通过签名恢复。这就是以太坊网络内部用来验证交易的方式。

验证过程

交易签名发送后,以太坊节点如何进行身份认证、不可否认、完整性?

  1. 对上面最终的RPL解码,可得到(nonce, gasPrice, gasLimit, to, value, data, v, r, s);
  2. 对(nonce, gasPrice, gasLimit, to, value, data)和(v,r,s)ECDSA验证(即反向算法),得到签名者的address,细心的同学可以看到第一个括号少了chainId,这是因为chainId在ECDSA私钥签名(即正向算法) 时被编码到了v,所以由v可以直接解码出chainId(所以在对上面的RLP编码值进行Keccak256;这一步,肯定是把chainId复制了一下,给对上面的Keccak256值进行ECDSA私钥签名(即正向算法);这一步用);
  3. 对上面得到的签名者的address与签名者公钥推导的address进行比对,相等即完成身份认证、不可否认性、完整性

我们可以去MyCrypto - Ethereum Wallet Manager,将wallet.signTransaction生成的编码复制进去,对上述验证步骤有一个直观的感受,比对一下ECDSA反向算法得出的"from"是不是自己的地址?

安全问题

我们注意到原始交易对象里由Nonce和ChainID两个字段,这是为了防范双花攻击\重放攻击(双花与重放是相对的,本质都是重复使用一个签名,用自己的签名做对自己有利的重复叫双花,用别人的签名做对自己有利的重复叫重放):

  • Nonce:账户交易计数,以太坊的账户模型中记录着每个账户Nonce,即此账户发起过多少次交易。
  • ChainId:分叉链区分,比如我在以太坊链上给evil进行一笔转账交易,evil又可以去以太坊经典链上重放这笔交易,这时如果我在以太坊经典上也有资产,那么会遭受损失。所以EIP155提议加入ChainId,以防止跨链重放。以太坊ChainId为1,以太坊经典ChainId为61。

节点验证之后的动作

这里和签名的关系不大,便不再详述,大体可以看看下面这个帖子:
https://blog.csdn.net/LJFPHP/article/details/81261050

签名消息( =presigned message = 预签名)

大家看完上面的签名交易后,再看本章的签名消息,可能会有有些懵的感觉,会将其混为一谈,误以为这两个东西是平行的,各自发给节点的。这是对以太坊交易流程不清晰导致的,只需记住一点,发给节点的只能是交易签名+相应参数,其大概可分为三种情况:

  • 单纯的转账交易:就是上一章节的内容,由(nonce, gasPrice, gasLimit, to, value, data:空,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to, value, data:空, v, r, s)发往节点。注意,这里data是空的。
  • 部署合约交易:由(nonce, gasPrice, gasLimit, to:空, value, data:合约创建字节码,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to:空, value, data:合约创建字节码, v, r, s)发往节点。注意,to是空的, data是合约创建字节码,节点看到to是空的就知道这是部署合约交易。不明白什么是合约创建字节码,可以看看OpenZeppelin这个系列文章,我对这系类文章有相应笔记,待后续整理发出。。
  • 调用合约函数交易:由(nonce, gasPrice, gasLimit, to, value, data:selector+函数参数,chainId)经私钥通过ECDSA正向算法得到(v,r,s),将(nonce, gasPrice, gasLimit, to, value, data:selector+函数参数, v, r, s)发往节点。注意, data是selector+函数参数,例如bytes4(keccak256(bytes("foo(uint256,address,string,uint256[2])"))), x, addr, name, array。对于这个不明白的同样看上面OpenZeppelin文章即可。

综上,无论是部署合约交易还是调用合约函数交易都是改变data的值。 我们本章所讲的消息签名就是调用合约函数交易,调用对应合约函数(一般为验证verfiy函数),消息签名作为参数。

针对上面调用合约函数交易+消息签名的内容,我们配合流程图与层叠图加深理解:
流程图:

层叠图:

通用消息签名方法


当我在看EIP-191这个提案时(https://eips.ethereum.org/EIPS/eip-191),有些困惑,其标准格式到底是第一个红框还是第二个红框?经过看网上资料和琢磨一定时间后,其中的逻辑应该是这样的,我们可以把签名方法划分为三种:

  • 通用消息签名方法;
  • EIP-191标准签名方法;
  • EIP-712标准签名方法;

我们不用把他们想的太复杂,简单点来说通用签名方法就是添加了"\x19Ethereum Signed Message:\n"这个字符串的签名,如metamask的personal_sign方法和ECDSA库的toEthSignedMessageHash方法,添加这个字符串只是单纯的为了表明这是以太坊的签名,让我们看看通用签名方法的例子--NFT白名单。

metamask的personal_sign方法:

ECDSA库的toEthSignedMessageHash方法:

NFT白名单签名举例

让我们来写一个NFT白名单合约,想想如何实现白名单这个功能?

  • 把白名单地址存入数组,当目标地址调用mint函数时进行判断。 ->因为storage会耗费大量gas,项目方还没开始赚钱在deploy合约时就先破产了。后续白名单用户在Mint对白名单数组的读opcode又会耗费大量gas,白名单用户本来就想占点便宜,结果还花费了大量gas。所以这个方法几乎没有人用。
  • 用Merkle Tree来校验白名单,具体方法看0xAA师傅的这篇文章https://github.com/AmazingAng/WTF-Solidity/blob/main/36_MerkleTree/readme.md,这个方法的只需要storage一个root值,但相较于上面的方法已经非常节省gas。
  • 对白名单进行链下签名,这个方法是当下通用的,以我们目前NFT这个例子来说,也会需要storage一个owner,但相较于Merkle Tree在白名单用户mint的时候会少传一个数组,少很多opcode,也就为用户节省了gas。下面让我们来实操一下。

前提

技术栈:hardhat(社区hardhat-deploy包)+ethers.js,以下我只针对于白名单这个知识点进行讲解,不涉及技术栈的知识。
我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址。

合约

  • constructor,确定NFT名称、符号与signer,即签名者地址;
  • recoverSigner,对签名进行解析,返回地址。这里是白名单签名逻辑的核心,我们传入签名与签名时的参数,用ECDSA反向运算解析出签名者地址;
  • verify,确定signer就是签名者;
  • mintNft,调用verify,判断通过进行_safeMint;
    
    // SPDX-License-Identifier: UNLICENSED
    pragma solidity ^0.8.7;

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

error SignatureNFT__VerifyFailed();

contract SignatureNFT is ERC721 {
// signer address
address public immutable i_signer;

constructor(address signer) ERC721("Test Signature NFT", "TSN") {
    // set signer address
    i_signer = signer;
}

// mint NFT
function mintNft(
    address account,
    uint256 tokenId,
    bytes memory signature
) public {
    if (!verify(account, tokenId, signature)) {
        revert SignatureNFT__VerifyFailed();
    }
    _safeMint(account, tokenId);
}

// Check if signer and i_signer are equal
function verify(
    address account,
    uint256 tokenId,
    bytes memory signature
) public view returns (bool) {
    address signer = recoverSigner(account, tokenId, signature);
    return signer == i_signer;
}

// Return signer address
function recoverSigner(
    address account,
    uint256 tokenId,
    bytes memory signature
) public pure returns (address) {
    // 后招
    bytes32 msgHash = keccak256(abi.encodePacked(account, tokenId));
    bytes32 msgEthHash = ECDSA.toEthSignedMessageHash(msgHash);
    address signer = ECDSA.recover(msgEthHash, signature);
    return signer;
}

}


#### 签名脚本
- 逻辑:hash(签名参数) -> signMessage(hash(签名参数));
- 生产环境中,我们将signers[index].address换为白名单地址,将signers[0]换位signer即可;

```javascript
const { ethers } = require("hardhat")

// 对要签名的参数进行编码
function getMessageBytes(account, tokenId) {
    // 对应solidity的Keccak256
    const messageHash = ethers.utils.solidityKeccak256(["address", "uint256"], [account, tokenId])
    console.log("Message Hash: ", messageHash)
    // 由于 ethers 库的要求,需要先对哈希值数组化
    const messageBytes = ethers.utils.arrayify(messageHash)
    console.log("messageBytes: ", messageBytes)
    // 返回数组化的hash
    return messageBytes
}

// 返回签名
async function getSignature(signer, account, tokenId) {
    const messageBytes = getMessageBytes(account, tokenId)
    // 对数组化hash进行签名,自动添加"\x19Ethereum Signed Message:\n32"并进行签名
    const signature = await signer.signMessage(messageBytes)
    console.log("Signature: ", signature)
}

async function main() {
    signers = await ethers.getSigners()
    // 我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址
    for (let index = 1; index < 4; index++) {
        await getSignature(signers[0], signers[index].address, index)
    }
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

部署脚本

在部署脚本中,我们直接用deployer作为constructor参数传入,即用deployer当作signer;

const { network, ethers } = require("hardhat")

module.exports = async ({ getNamedAccounts, deployments }) => {
    const { deployer } = await getNamedAccounts()
    console.log(deployer)
    const { deploy, log } = deployments
    const chainId = network.config.chainId

    const args = [deployer]

    await deploy("SignatureNFT", {
        contract: "SignatureNFT",
        from: deployer,
        log: true,
        args: args,
        waitConfirmations: network.config.waitConfirmations || 1,
    })
    log("[==>]------Deployed!------------------------------------------")
}

module.exports.tags = ["SignatureNFT"]

我们就选取最后一个Signature,即项目方给accounts[3]的签名。

测试脚本

分别对每个函数进行测试,其中mintNft为模拟交易
注意:
当我对mintNft函数进行错误性测试时(即第二个it,"incorrect mintNft shoule trigger error of SignatureNFTVerifyFailed"),我开始写的是.to.be.revertedWith("SignatureNFTVerifyFailed"),但出现了如下报错,于是我肯定ECDSA.recover这个函数中有先行报错,但我把其中四个revert报错都试过来了,还是会有but it reverted with a custom error这个提示,大家有兴趣可以找找这个先行报错在哪里,我就不找了,直接用.to.be.revertedWithCustomError()吧...


const { getNamedAccounts, ethers, network } = require("hardhat")
const { assert, expect } = require("chai")

describe("SignatureNFT", function () {
    let signatureNFT, deployer

    beforeEach(async function () {
        // 我们将accounts[0]作为deployer和signer,account[1]、account[2]、account[3]作为白名单地址
        accounts = await ethers.getSigners()
        console.log(`accounts[0]:${accounts[0].address}`)
        console.log(`accounts[1]:${accounts[1].address}`)
        // 通过hardhat.config.js配置deployer就是accounts[0]
        // 当然直接用accounts[0]也行,这里显得直观些
        deployer = (await getNamedAccounts()).deployer
        console.log(`deployer:${deployer}`)
        // 部署合约
        await deployments.fixture(["SignatureNFT"])
        // 获得合约对象,若getContract没有account传入,则为deployer连接
        signatureNFT = await ethers.getContract("SignatureNFT")
    })

    // test constructor
    describe("constructor", function () {
        it("i_signer is deploy_address when deploy constract", async function () {
            const signer = await signatureNFT.i_signer()
            assert.equal(signer, deployer)
        })
    })

    //test recoverSigner
    describe("recoverSigner", function () {
        it("recoverSigner could return address of account[0](deployer) when contract deploy in default chain", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const signer = await signatureNFT.recoverSigner(account.address, tokenId, signature)
            assert.equal(signer, deployer)
        })
    })

    //test varify
    describe("varify", function () {
        it("function varify should return ture", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const verify = await signatureNFT.verify(account.address, tokenId, signature)
            assert.equal(verify, true)
        })
    })

    // correct mintNft
    describe("mintNft", function () {
        it("the third tokenId should belong to account[3] when mint the third signature", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            const tokenId = 3
            const account = accounts[3]
            const verify = await signatureNFT.mintNft(account.address, tokenId, signature)
            const owner = await signatureNFT.ownerOf(3)
            assert.equal(owner, account.address)
        })

        // incorrect mintNft shoule trigger error of SignatureNFT__VerifyFailed
        it("incorrect mintNft shoule trigger error of SignatureNFT__VerifyFailed", async function () {
            const signature =
                "0x46bd542e1c97a9fd5541efbfa649dd8cecc0c7bb00c79bcac48f7986f45174893ce2063168c862996ebfa272cbc245cab3b93d0b49c8a5c5f3eec2d51ad5c6941c"
            // 我们规定accounts[3]只能Mint tokenId_3,他想Mint tokenId_2,那肯定不可以。
            const tokenId = 2
            const account = accounts[3]
            // 这里有个注意点
            await expect(
                signatureNFT.mintNft(account.address, tokenId, signature)
            ).to.be.revertedWithCustomError(signatureNFT, "SignatureNFT__VerifyFailed")
        })
    })
})

综上

通用签名方法实现逻辑为:

  • 签名逻辑:parameter... -> keccak256得到"消息Hash" -> 以太坊签名消息Hash -> 加入私钥进行运算得到"签名”。
    • 消息HASH -> keccak256(abi.encodePacked(parameter...) 。
    • 以太坊签名消息 -> keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)) 。
  • 合约验证逻辑:ecrecover(_msgHash, v, r, s) 可以"消息"和"签名"中的vrs推出地址,然后我们在我们需要的程序中对其进行对比即可。

EIP-191

我们再一次看看这张图,第一个红框就是EIP-191的实现格式,以0x1900,0x1945开头的数据段可视为EIP191,它有什么作用?在https://eips.ethereum.org/EIPS/eip-191的Motivation中可以看出,EIP-191提出了在签名中加入合约自身的address参数,以防止重放攻击的手法。
我们这里不多阐述EIP-191,因为在生产中,人们似乎用更高端的EIP-712更多,EIP-712相当于继承了EIP-191,而且签名时还能小狐狸上显示签名内容,既高端又安全。那么这里我们就略过EIP-191,直接进行EIP-712,对EIP-191有兴趣的同学可以去看看https://eips.ethereum.org/EIPS/eip-191 最下方的例子。

把Version byte写为0x01就是EIP-712啦。

关于重放攻击

例子Motivation中用多签钱包举例,实际上任何两个实现逻辑相同的合约,都会存在重放攻击的风险,如我们举例NFT白名单,试想项目方想布置另一套NFT,只是修改了URI就进行部署了,那么毫无疑问上一个项目的白名单撸穿。
这是一个油管视频截图(https://www.youtube.com/watch?v=jq1b-ZDRVDc, 再看看这个https://solidity-by-example.org/hacks/signature-replay/) ,很好的解释了如何防范重放攻击:

  • 没有任何防护的多签钱包,Eve重放Signature就会再次取出eth
    • 在encodePacked的时候加入nonce参数,这就相当于每个Signature具备唯一性;
    • 在合约逻辑上加入一个mapping,当一个Signature用过后就将其设置为true,表示已用过;
  • 如果这个多签钱包的代码被被另一个项目方复用,正巧我在Eve和Alice也想在此放置资产,怎么防止重放?
    • 在encodePacked的时候加入合约的address参数,这就相当于每个Signature对每个地址具备唯一性;
  • 一个合约工厂用create2在可预测地址创建多签钱包,并且带有selfdestruct(),管理员没事还运行selfdestruct(),如何防范重放?(哪个奇葩会写出这样的合约)
    • 防不了了,Alice在A地址的多签钱包存了10ETH,然后给Eve一个Signature,同意其取5个ETH,Eve正常取了5个之后想要重放攻击,因为有nonce并且Signature被记录,所以重放不了,但合约的owner在某种情况下selfdestruct()了合约,这是nonce恢复初始值,但Alice没注意到selfdestruct()事件,仍然向A地址的多签钱包存钱,这时Eve就可以重放Signature了,再拿走5ETH。

这里我再加一个:

  • 加入chainId,防范在不同链的、逻辑相同的合约的重放攻击

EIP-712

在学习EIP-712时,不妨让我们先看一下其具体实现。以代码下是UniswapV2ERC20合约的实现,在20行bytes32 digest由三部分组成:

  • '\x19\x01' ,固定字符串;
  • DOMAIN_SEPARATOR,由constructor中定义;
  • keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)),其中PERMIT_TYPEHASH是constant变量

而EIP-712就是由以上三部分组成。

string public constant name = 'Uniswap V2';
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
constructor() public {
    uint chainId;
    assembly {
        chainId := chainid
    }
    DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
            keccak256(bytes(name)),
            keccak256(bytes('1')),
            chainId,
            address(this)
        )
    );
}
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
    require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');
    bytes32 digest = keccak256(
        abi.encodePacked(
            '\x19\x01',
            DOMAIN_SEPARATOR,
            keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
        )
    );
    address recoveredAddress = ecrecover(digest, v, r, s);
    require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
    _approve(owner, spender, value);
}

"面子"上的区别

在https://eips.ethereum.org/EIPS/eip-712 中,我们可以看出,EIP-712与通用签名消息方法、EIP-191的"面子"上的区别为在钱包签名时显示的不同,遵循EIP-712方法可使签名内容可视化,这会提高我们签名时的安全性,试想,你的主机被黑客控制,准备签名的Message被替代,凭借肉眼,你肯定看不出签名的内容有何不同。

"里子"上的区别

EIP-712 结构式

encode(domainSeparator,message)=x19x01∣∣domainSeparator∣∣hashStruct(message)

EIP-712 结构解析

对比我们上述"通用消息签名方法"中只是对要签名的参数进行序列化、keccak256、添加"\x19Ethereum Signed Message:\n32"后再次序列化与keccak256、签名相比,EIP-712是有着结构化上的要求的,我这里针对其结构式进行一个思维导图上的解析,大家如果想看文字上的描述,可以研读如下资料:

下面让我们对照UniswapV2ERC20和思维导图看看domainSeparator和hashStruct(meaasge)具体实现。

签名域-domainSeparator

domainSeparator由两部分组成,第一部分为对结构体的keccak256(encodeType),第二部分为结构体的具体实现(encodeData);
domainSeparator结构体如下所示,一般来说salt(随机数)会省略;

struct EIP712Domain{
    string name, //用户可读的域名,如DAPP的名字
    string version, // 目前签名的域的版本号
    uint256 chainId, // EIP-155中定义的chain ID, 如以太坊主网为1
    address verifyingContract, // 用于验证签名的合约地址
    bytes32 salt // 随机数
}
  • 再让我们对照UniswapV2ERC20来记几个结论:
    • encodeType与encodeData都要按照如上的结构体顺序来实现,其中字段可以省略,但不可以颠倒顺序,如UniswapV2ERC20所示,省略了salt字段,但是按照name、version、chainId、verifyingContract来排序的;
    • 针对string 或者 bytes 等动态类型,即长度不定的类型,其取值为 keccak256(string) 、 keccak256(bytes) 即内容的hash值;
    • 我们可以看到这里用的是abi.encode而非abi.encodePacked,这是因为domainSeparator结构体要求每个字段占据256位,以便于前端分割。
      DOMAIN_SEPARATOR = keccak256(
        abi.encode(
            // encodeType
            keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
            // encodeData
            keccak256(bytes(name)),
            keccak256(bytes('1')),
            chainId,
            address(this)
        )
      );

签名对象-hashStruct(message)

  • 一般来讲,hashStruct(message)与domainSeparator格式相同,也是由由两部分组成,第一部分为对自定义结构体的keccak256(encodeType),第二部分为自定义结构体的具体实现(encodeData);
  • 由注释可知,PERMIT_TYPEHASH就是Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)的hash,注意自定义对象名要首字母大写;
  • encodeData与encodeType顺序要相同;
    // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
    bytes32 public constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
    bytes32 digest = keccak256(
          abi.encodePacked(
              '\x19\x01',
              DOMAIN_SEPARATOR,
              keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline))
          )
      );

    然后就是熟悉的套路,ecrecover出address,进行比较,最后授权。

    
    address recoveredAddress = ecrecover(digest, v, r, s);
      require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');
      _approve(owner, spender, value);
### 签名脚本
用uniswapV2结构体进行举例,注意我们要先确定verifyingContract,即pair的地址。
```javascript
const { ethers } = require("hardhat")

async function signTypedData(signer) {
    const domain = {
        name: "Uniswap V2",
        version: "1",
        chainId: 1,
        verifyingContract: "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC",
    }

    // The named list of all type definitions
    const types = {
        Permit: [
            { name: "owner", type: "address" },
            { name: "spender", type: "address" },
            { name: "value", type: "uint256" },
            { name: "nonce", type: "uint256" },
            { name: "deadline", type: "uint256" },
        ],
    }

    // The data to sign
    const value = {
        owner: xx,
        spender: xx,
        value: xx,
        nonce: xx,
        deadline: xx,
    }

    const signature = await signer._signTypedData(domain, types, value)
    return signature
}

async function main() {
    signers = await ethers.getSigners()
    signature = await signTypedData(signers[0])
    console.log(signature)
}

main()
    .then(() => process.exit(0))
    .catch((error) => {
        console.error(error)
        process.exit(1)
    })

uniswap为什么要应用EIP712?

实质上还是和NFT白名单的目的一样——为了节省GAS,uniswapV2分为pair合约UniswapV2Pair.sol和路由合约UniswapV2Router02.sol。当我们要移除流动性时需要通过路由合约来操作pair合约,试想如果没有应用消息签名,其操作路径为先在pair合约进行授权,然后在路由合约中移除流动性,也就是两笔交易。而应用消息签名只需要一笔交易即可完成。可想而知,面对Uniswap这种海量交易的情况下,也节省海量的GAS,也节省了海量的链上资源。

后记:

  • 以上为签名相关的知识点讲解,内容肯定有疏忽或者理解不到位的情况,欢迎各位大佬交流指正。
  • 封面出自Jose Aguinaga文章,如涉及侵权,请站内私信,我会及时整改。
  • 如需转载本文,请注明出处。
  • ethsamgs 2022.11.8

参考&致谢

转载:https://learnblockchain.cn/article/5012