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

Web3学习笔记:交易所钱包管理系统

交易所作为web3世界的核心枢纽,托管着大量加密资产,其钱包管理关系到大量用户的资产安全,因此很有必要对其钱包管理系统研究学习一番。这里我整理了一篇个人从产品设计者视角的学习笔记,希望能够帮助到大家学习。

交易所钱包系统组成

交易所钱包系统设计主要需要考虑到安全性便捷性。为了兼顾两者,交易所大部分资产用冷钱包管理,以保证资产安全;而少部分资产使用热钱包管理,以方便用户提现资产。

热钱包系统

热钱包系统由用户充值钱包、归集钱包、提现钱包、手续费钱包等组成

  • 用户充值钱包:用户需要向交易所进行代币充值 ,需要为每一名用户分配一个地址,用于充值代币,其私钥保存在服务端以供后续资金归集时签名。
  • 归集钱包:资产分散在各用户充值钱包中不方便管理,因此当充值钱包达到一定规模时需要进行资金归集,将资金统一归集至归集钱包。
    大量资金统一管理在一个钱包中是存在比较大的安全风险,所以需要对归集钱包中的资产进行分配并转移。
  • 提现钱包:为了方便用户提现和资金安全,一般会将20%资金转移到一个提现钱包(也可能是几个钱包),这个钱包专门用于客户提币使用。
  • 手续费钱包:归集资金、转移资金、用户提现这几个操作均需要交易费,当其他钱包ETH不足抵扣交易费时,由该钱包转入一定的ETH作为交易费。

冷钱包系统

冷钱包系统一般包含系统冷钱包和BOSS钱包组成。

  • 系统冷钱包:一般会将20%~30%的资金会转移至系统冷钱包,当提现钱包资金不够时才会在系统冷钱包进行划转。
  • BOSS钱包:一般50%以上资金会转移至boss钱包,由公司一个或多个老板控制。

钱包系统业务流程

交易所钱包系统主要涉及业务流程:

  • 注册— 生成充值地址
  • 用户充值处理
  • 资金归集
  • 资金分配转移
  • 提现转账

注册—— 生产充值地址

当用户注册账户,需要对应创建一个钱包,为用户分配一个单独的充值地址,私钥由保存在服务端以用于资金归集。

用户充值处理

我们需要持续监听充值钱包代币入账及区块确认数,一般12个区块确认后才认定为入账成功。入账成功后,需要判断平台是否支持该代币,若支持则增加账户对应代币的余额,至此完成用户充值处理。

资金归集

用户充值完成后,我们需要判断用户充值钱包该代币资产价值,一般当其价值达到1000U,我们需要将钱包中资金归集到归集钱包。

归集前需要判断预估gas是否过高(超过100 gas),若过高则等待降下来,再进行归集。
另外,需要判断钱包是否有足够ETH以抵扣交易费,若不足则需从手续费钱包转入0.01ETH再进行归集。

资金分配转移

归集钱包为热钱包,当大量资产都在一个热钱包是存在极大风险的,因此我们将资产转移。

筛选统计:在进行划转和分配之前,我们需要对归集钱包中资产进行筛选统计,筛选出平台支持且有余额的代币,然后统计筛出代币的余额。

资金分配:我们将80%的资产转移至冷钱包地址,20%继续转移至提现热钱包中。这样设计兼顾安全性和便捷性,冷钱包控制相对来说保证了安全性;而大部分玩家都是把大部分资金存放在交易所中,热钱包20%流动资金是足够支撑用户的提币需求,因此也一定保证了便捷性。

资金划转:我们一般为固定周期进行分配转移,一般设计为一周划转一次,具体周期和时间可以根据业务和公司情况而定。我们根据代币类型采用不同划转方式,ETH直接根据分配资金转账,ERC-20代币使用合约批量打包转账(节省交易费)。

提现转账

用户发起提现后,由于Nonce机制存在,并不直接将交易发送至网络,而使用本地队列排队,接收到上一条成功确认记录后,再获取最新Nonce,进行下一条转账发送。

发送前,判断余额是否充足,若不足则需申请从系统钱包手动转入足够的代币后再进行转账。

私钥管理方案

多签

提示:以太坊本身不支持多签,需要使用智能合约实现,合约本身又存在安全风险,因此交易所的以太坊私钥一般采用该方案保存

普通用户可以将私钥/助记词保存在软件、纸上或者通过大脑记忆保存。但对于拥有巨额资产的交易所而言,普通用户的私钥管理方式根本无法适用,否则将存在单点保存的风险。

为了避免一个私钥的丢失导致地址的资金丢失,达到风险分散的目的,一般会采用多重签名技术。所谓多重签名技术,就是把一个钱包地址的控制权交给多个密钥,这些密钥保存在不同的地点,并分别生成签名。举个简单的例子:

2-3多重签名,表示3个人拥有签名权,而两个人签名就可以支配这个账户里的资金;
1-2多重签名,表示2个人可以签名,两个人拥有私钥,谁都可以来支配这笔资金。

在热钱包方面,一般采用2-3多重签名,需要三个私钥持有人中的两个分别进行授权,才能完成签名。
在冷钱包方面,一般采用的是2-2多重签名,即每个私钥的使用需要两个人双重授权才能进行提币,以保证冷钱包安全。

备份

在采用多重签名的同时,为了进一步降低私钥丢失或损坏的风险,还对私钥进行了备份处理:
对于热钱包,备份私钥存储办公室附近的银行保险柜。
对于冷钱包,一般备份两份,一份存在办公室附近的银行保险柜,一份存在异地某一家银行的保险柜;同时,异地银行保险柜必须由两个不同的人掌握,掌握银行保险柜的两个人不得乘坐同一辆交通工具。

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

EIP 1014: CREATE2 指令

EIP 1014: CREATE2 指令

作者 类型 分类 状态 创建时间
Vitalik Buterin Standards Track Core Final 2018-04-20

#规范

添加了一个新的0xf5 操作码指令CREATE2 ,它使用4个栈参数:endowment, memory_start, memory_length, salt 。 0xf5 指令的行为和CREATE相同,只不过它使用keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:] 而不是 发送者+nonce hash来计算出合约地址。

CREATE2CREATEgas 模式一样,但是有一个额外的 GSHA3WORD * ceil(len(init_code) / 32) hash 计算消耗(hashcost),在计算出地址和执行 init_code代码之前,hashcost 与内存扩展 及 CreateGas 一起被扣除。

  • 0xff 为一个字节
  • address20 字节
  • salt32 直接 (占用一个栈).

因此,最后一次哈希keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:] 的内容始终正好是85字节长, 在2018-08-10进行的核心开发者会议上决定使用上述公式。

#动机

允许使用链上尚不存在但可以依赖的地址进行交互,对于像 counterfactually 类似的状态通道中非常有用。地址由特定初始化代码及可控的salt控制。

#原理阐述

#Address 计算公式

  • 确保使用此方案创建的地址不会与使用传统的 keccak256(rlp([sender, nonce])) 公式创建的地址冲突,而0xff 只可能是很长字节的 RLP编码前缀。
  • 确保hash 的转载内容是 固定字节大小

#Gas 消耗

由于地址计算依赖于对 init_code 的hash ,因此如果反复对大块 init_code 执行 hash,可能使网络客户端容易受到DoS攻击,因为内存扩展仅支付一次。 该EIP使用与 SHA3 操作码相同的每字成本。

#澄清

init_code 是执行后会生成运行时字节码(存入链状态)的代码,通常由高级语言用来实现构造函数。合约

此EIP可能产生冲突。 冲突行为EIP 684有指出:

如果使用创建交易或CREATE(或将来的CREATE2)操作码去尝试创建合约,并且目标地址已经具有非零 nonce 或非空代码,则该创建将立即抛出,如果初始化代码中的第一个字节是无效的操作码,则同样会抛出此错误。 适用与从创世纪块开始追溯。

具体来说,如果 noncecode 非零,则创建操作将失败。

以及 EIP 161

在执行初始化代码之前,帐户创建交易和CREATE操作应随机数增加1。

这意味着,如果在交易中创建合约,则 nonce 立即为非零,其副作用是,同一交易中的冲突将始终失败-即使是通过 init_code 本身执行 。

还应注意,SELFDESTRUCTnoncecode 没有立即生效,因此不能在一次交易中销毁和重新创建合约。

#示例

示例 0

  • address 0x0000000000000000000000000000000000000000
  • salt 0x0000000000000000000000000000000000000000000000000000000000000000
  • init_code 0x00
  • gas (assuming no mem expansion): 32006
  • result: 0x4D1A2e2bB4F88F0250f26Ffff098B0b30B26BF38

示例 1

  • address 0xdeadbeef00000000000000000000000000000000
  • salt 0x0000000000000000000000000000000000000000000000000000000000000000
  • init_code 0x00
  • gas (assuming no mem expansion): 32006
  • result: 0xB928f69Bb1D91Cd65274e3c79d8986362984fDA3

示例 2

  • address 0xdeadbeef00000000000000000000000000000000
  • salt 0x000000000000000000000000feed000000000000000000000000000000000000
  • init_code 0x00
  • gas (assuming no mem expansion): 32006
  • result: 0xD04116cDd17beBE565EB2422F2497E06cC1C9833

示例 3

  • address 0x0000000000000000000000000000000000000000
  • salt 0x0000000000000000000000000000000000000000000000000000000000000000
  • init_code 0xdeadbeef
  • gas (assuming no mem expansion): 32006
  • result: 0x70f2b2914A2a4b783FaEFb75f459A580616Fcb5e

示例 4

  • address 0x00000000000000000000000000000000deadbeef
  • salt 0x00000000000000000000000000000000000000000000000000000000cafebabe
  • init_code 0xdeadbeef
  • gas (assuming no mem expansion): 32006
  • result: 0x60f3f640a8508fC6a86d45DF051962668E1e8AC7

示例 5

  • address 0x00000000000000000000000000000000deadbeef
  • salt 0x00000000000000000000000000000000000000000000000000000000cafebabe
  • init_code 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
  • gas (assuming no mem expansion): 32012
  • result: 0x1d8bfDC5D46DC4f61D6b6115972536eBE6A8854C

示例 6

  • address 0x0000000000000000000000000000000000000000
  • salt 0x0000000000000000000000000000000000000000000000000000000000000000
  • init_code 0x
  • gas (assuming no mem expansion): 32000
  • result: 0xE33C0C7F7df4809055C3ebA6c09CFe4BaF1BD9e0

本翻译采用BY-NC-ND许可协议,译者:深入浅出区块链 Tiny熊。

转载自:https://learnblockchain.cn/docs/eips/eip-1014.html

解构 Solidity 合约 #4: 函数体

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

我们已经走过了很长的路,不是吗?首先,我们理解了合约的创建时间和运行时字节码之间的区别;接下来,我们理解了来自任何调用或交易的执行入口是如何通过函数选择器被路由到特定的函数的;最后,我们看到了传入的交易数据是如何被解包给函数使用的,以及函数产生的数据是如何通过函数包装器为用户重新打包的。在这一节中,我们将(最后)看看函数的实际执行情况,或者我们通常称为 "函数体" 的部分。

函数体正是函数包装器在解开传入的calldata后所跳入的部分。当一个函数体被执行时,函数的参数应该安然无恙的在堆栈中(如果数据是动态的,则在内存中),等待被使用。让我们看看balanceOf(address)函数的实际应用。这个函数应该接收一个 address,并返回这个地址相应的 uint256 的余额。

让我们回到Remix,像以前一样编译和部署合约,然后调用balanceOf函数,把部署合约时用的地址作为参数。这应该返回数字10000,因为它是最初赋值给构造函数代码中部署合约的地址的,我们在部署合约时使用了这个地址。

好了,现在让我们来调试一下这个交易。

你会注意到的第一件事是,调试器把我们放在了指令252处。如果你看一下解构图,在包装器的蓝色部分,你应该看到balanceOf函数包装器将指令175处重定向到251的JUMPDEST指令。正如我们之前多次看到的,Remix将我们精确地放在了函数主体即将被执行的位置。

图1. 函数包装器将执行重定向到函数体(指令175的蓝色虚线)


图2. 函数体的执行,来自于函数包装器(指令251处的蓝色虚线)。

现在,如果你看一下堆栈,你会发现它最上面的值是我们调用balanceOf的地址。包装器已经完成了正确解包calldata的工作。所以我们准备通过指令251到290,即balanceOf函数体。

指令252推送了一个20字节的0xffffffffffff值,并使用AND操作码将32字节的地址 mask(掩码)为正确的类型(记住,以太坊的地址是20字节的,而堆栈的操作是32字节的字)。

在指令274至278中:

字节码将把地址从堆栈上传到内存。它需要这个地址用于即将到来的 SHA3 操作码。如果你看一下黄皮书,SHA3操作码有两个参数:计算哈希值的内存位置和哈希值的字节数。

但是,为什么代码会使用SHA3操作码?这个函数想从balances映射中读取。更确切地说,它想读取映射到的地址的值。如果你了解映射在存储中的布局,变量槽 (在这里是1)的哈希值,因为balances被定义为第二个变量(totalSupply_是第一个变量,在槽0),实际的键本身是地址,SHA3需要这两个值寻找的值在存储中的位置。

其槽位置计算方式大概可以表述为:

keccak(
  leftPadTo32Bytes(addr) ++ leftPadTo32Bytes(1)
) 
用 Solidity 代码表示:
bytes32 aliceBalanceSlot = keccak256(
    abi.encodePacked(uint256(uint160(address)), uint256(1))
);

所以,我们已经得到了内存中的地址,但现在我们需要内存中的插槽。这就是指令279和283之间接下来发生的事情:

数字0x01被存储在内存位置0x20。现在内存保存着第一个字的地址,即内存位置0x00,和第二个字的槽,即内存位置0x20。耶! 我们准备调用SHA3。

于是在指令284和287之间调用了它。

当287号指令调用SHA3时,堆栈包含0x00(SHA3的起始位置)和0x40(SHA3的长度),这基本上是告诉EVM在前两个32字节的字中对内存中的任何内容进行哈希。32个字节的十六进制是0x20,所以0x20+0x20等于0x40。

现在,SHA3在堆栈中留下了32字节的哈希值,这是一个非常长的十六进制数字,比以太坊地址长很多。这个哈希值是合约存储中的位置,传递给balanceOf的地址的余额就存储在这里。你可以使用Remix调试器中的Storage completely loaded面板来直观地看到这一点。你应该在第二个存储对象中找到一个匹配的位置。

在这个位置上存储了什么?数字10000,或者十六进制的0x2710。在第288条指令中,SLOAD接收了从存储位置(我们的哈希值)读取的参数,并将0x2710推到堆栈。

最后,在第289条指令中,SWAP1重新显示了函数包装器的JUMPDEST位置(0x70,或112),第290条指令中的JUMP将我们带回函数包装器的输出部分,它将重新包装0x2710以返回给用户。

我强烈建议你回顾一下我们刚才对balanceOf的调试过程,再对totalSupply和transfer函数的进行调试。前者非常简单,而后者要复杂得多,但基本上是由相同的结构块组成的。秘密在于理解如何从映射中读取数值和写入映射。真的没有什么更多的东西了。

现在让我们回到大解构图:

图3. 函数包装器之后的函数体。

正如我们之前所讨论的,函数体都集中在函数封装器之后。执行流从包装器中跳到它们,并在执行完每个函数的指令后返回到包装器。
如果你仔细看这张图,在函数体之后有一大块代码,叫做 "元数据哈希"。这是一个非常简单的结构,在下一篇文章我们将解析一下这个部分。

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

解构 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