Smart Contract Developer
EIP-712 使用详解
之前的文章我们介绍过如何对数据进行签名,利用签名技术我们可以实现一些功能例如白名单校验等。但是这种签名技术的应用场景比较简单,一般就是给一串字符串,或者一串哈希签名,如果我们想为更复杂的数据签名就无法实现了。 EIP-712 的出现就是为了解决这个问题,利用 EIP-712,我们可以对更大的数据集,例如对结构体进行签名。那么这种签名格式有什么实际的应用场景呢。使用过 Uniswap,PancakeSwap 等 DEX 的朋友应该有印象,在移除 LP 流动性的时候,我们需要先签名,然后再发送一笔交易移除流动性。正常情况下,其实应该我们先调用 LP 代币的授权方法,授权 DEX 合约可以转移我们的 LP,然后再去移除流动性。而这种二合一的实现正是应用了 EIP-712。它帮助我们仅仅签名一次,就可以将两步交易合并为一步交易,从而节省 Gas 费用。这篇文章我们就来看看 EIP-712 到底是怎么使用的。基本结构EIP712Domain顾名思义,是一个与域相关的结构体,总共包含五个字段:name,合约或者协议的名称version,合约的版本chainId,合约部署的链 Id,一般使用 ...
流动性挖矿-合约原理详解
流动性挖矿应该是上个牛市最火热的内容,基本上整个 DeFi 都是在围绕着流动性挖矿展开的,今天我们就来看看它到底是什么以及合约代码层面是怎么实现的。流动性挖矿简介首先我们先从用户的角度来理解一下流动性挖矿是什么,实际上就是用户通过在合约中质押一个 token 从而赚取另一个 token 的过程。例如,SushiSwap 最初推出的 DEX 流动性挖矿,用户可以通过将 SushiSwap 的 LP token 质押到合约中赚取 Sushi token。那么这个奖励具体是怎么发放以及如何实现的呢?我们今天就来研究一下这部分内容。 先来看几个例子: 一:假设有一个流动性挖矿的合约,可以质押 A token 赚取 B token。它在 0 秒时开始活动,每秒奖励 R 个 B token。此时有用户 Alice 在第 3 秒时质押了 2 个 A token,并且之后没有其他人参与,在第 8 秒时取出 token,图示:那么他在此时获得的收益就是:5R = (2 / 2) * (8 - 3) * R 其中,第一个 2 是用户 A 质押的数量,第二个 2 是合约中质押的总量,(8-3)是用户 ...
CREATE2 操作码使用方法详解
CREATE2 是一个可以在合约中创建合约的操作码。我们先来举个例子看看它能干什么:这段代码是 Uniswap v2-core 里面的工厂合约代码,使用 create2 操作码创建了 pair 合约,返回值是 pair 的地址,这样就可以逻辑中直接使用其地址进行接下来的操作。 那么 create2 到底是怎么使用呢,根据官方 EIP 文档,create2 一共接收四个参数,分别是:endowment(创建合约时往合约中打的 ETH 数量)memory_start(代码在内存中的起始位置,一般固定为 add(bytecode, 0x20) )memory_length(代码长度,一般固定为 mload(bytecode) )salt(随机数盐)这里要注意的是第一个参数如果大于 0 的话,需要待部署合约的构造方法带有 payable。随机数盐是由用户自定,须为 bytes32 格式,例如在上面 Uniswap 的例子中,salt 为:bytes32 salt = keccak256(abi.encodePacked(token0, token1)); create2 还有一个优点,相...
EIP-712 使用详解
之前的文章我们介绍过如何对数据进行签名,利用签名技术我们可以实现一些功能例如白名单校验等。但是这种签名技术的应用场景比较简单,一般就是给一串字符串,或者一串哈希签名,如果我们想为更复杂的数据签名就无法实现了。 EIP-712 的出现就是为了解决这个问题,利用 EIP-712,我们可以对更大的数据集,例如对结构体进行签名。那么这种签名格式有什么实际的应用场景呢。使用过 Uniswap,PancakeSwap 等 DEX 的朋友应该有印象,在移除 LP 流动性的时候,我们需要先签名,然后再发送一笔交易移除流动性。正常情况下,其实应该我们先调用 LP 代币的授权方法,授权 DEX 合约可以转移我们的 LP,然后再去移除流动性。而这种二合一的实现正是应用了 EIP-712。它帮助我们仅仅签名一次,就可以将两步交易合并为一步交易,从而节省 Gas 费用。这篇文章我们就来看看 EIP-712 到底是怎么使用的。基本结构EIP712Domain顾名思义,是一个与域相关的结构体,总共包含五个字段:name,合约或者协议的名称version,合约的版本chainId,合约部署的链 Id,一般使用 ...
流动性挖矿-合约原理详解
流动性挖矿应该是上个牛市最火热的内容,基本上整个 DeFi 都是在围绕着流动性挖矿展开的,今天我们就来看看它到底是什么以及合约代码层面是怎么实现的。流动性挖矿简介首先我们先从用户的角度来理解一下流动性挖矿是什么,实际上就是用户通过在合约中质押一个 token 从而赚取另一个 token 的过程。例如,SushiSwap 最初推出的 DEX 流动性挖矿,用户可以通过将 SushiSwap 的 LP token 质押到合约中赚取 Sushi token。那么这个奖励具体是怎么发放以及如何实现的呢?我们今天就来研究一下这部分内容。 先来看几个例子: 一:假设有一个流动性挖矿的合约,可以质押 A token 赚取 B token。它在 0 秒时开始活动,每秒奖励 R 个 B token。此时有用户 Alice 在第 3 秒时质押了 2 个 A token,并且之后没有其他人参与,在第 8 秒时取出 token,图示:那么他在此时获得的收益就是:5R = (2 / 2) * (8 - 3) * R 其中,第一个 2 是用户 A 质押的数量,第二个 2 是合约中质押的总量,(8-3)是用户 ...
CREATE2 操作码使用方法详解
CREATE2 是一个可以在合约中创建合约的操作码。我们先来举个例子看看它能干什么:这段代码是 Uniswap v2-core 里面的工厂合约代码,使用 create2 操作码创建了 pair 合约,返回值是 pair 的地址,这样就可以逻辑中直接使用其地址进行接下来的操作。 那么 create2 到底是怎么使用呢,根据官方 EIP 文档,create2 一共接收四个参数,分别是:endowment(创建合约时往合约中打的 ETH 数量)memory_start(代码在内存中的起始位置,一般固定为 add(bytecode, 0x20) )memory_length(代码长度,一般固定为 mload(bytecode) )salt(随机数盐)这里要注意的是第一个参数如果大于 0 的话,需要待部署合约的构造方法带有 payable。随机数盐是由用户自定,须为 bytes32 格式,例如在上面 Uniswap 的例子中,salt 为:bytes32 salt = keccak256(abi.encodePacked(token0, token1)); create2 还有一个优点,相...
Smart Contract Developer

Subscribe to xyyme.eth

Subscribe to xyyme.eth
Share Dialog
Share Dialog
<100 subscribers
<100 subscribers
今天我们来聊聊调用合约方法在字节码层面是怎么实现的。同样地,我们以一个简单的合约作为例子:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
contract Demo {
constructor() {}
function func1() public {}
function func2() public {}
}
该合约中一共有两个方法,分别是 func1 与 func2。我们这里着重于理解方法调用的过程,因此简单起见就将方法内容置为空。
使用 solc 进行编译:
solc Demo.sol --bin
得到的字节码为:
利用 0xfe 操作符分割后,得到的各部分分别为:
6080604052348015600f57600080fd5b5060818061001e6000396000f3(init bytecode)
6080604052348015600f57600080fd5b506004361060325760003560e01c806374135154146037578063b1ade4db14603f575b600080fd5b603d6047565b005b60456049565b005b565b56(runtime bytecode)
a264697066735822122069532a763e3f61abcfbb422ae7dc4587126c8f8b2264e73bb837a5924ebb1d4964736f6c634300080f0033(metadata hash)
init bytecode 是合约部署部分,我们在上篇文章中已经介绍过了。调用合约方法是与 runtime bytecode 这部分进行交互,我们主要来研究这一块。
我们知道,调用合约方法交易的 data 域就是合约签名与参数的拼接内容。具体来说,就是合约签名的 keccak256 哈希值的前 4 个字节再加上参数内容就构成了 data 部分。
在上面例子中,func1 与 func2 的哈希值前 4 个字节分别是 0x74135154 与 0xb1ade4db,同时由于这两个方法都没有参数,因此不用在后面拼接参数。如果 fun1 的方法签名为:
function func1(uint _a) public {}
那么就需要添加参数,并且补齐 32 字节:
其中 0x254e43db 是 func1(uint256) 的哈希值前 4 个字节。
接下来我们就来看看在调用合约方法的过程中,在 EVM 字节码层面会发生什么。我们以调用 func1 方法为例,那么此时交易的 data 域内容就为 0x74135154。
我们先将 runtime bytecode 部分的字节码与其 opcodes 一一对应起来:
0 -> 60 PUSH1
1 -> 80 0x80
2 -> 60 PUSH1
3 -> 40 0x40
4 -> 52 MSTORE
5 -> 34 CALLVALUE
6 -> 80 DUP1
7 -> 15 ISZERO
8 -> 60 PUSH1
9 -> 0f 0xF
10 -> 57 JUMPI
11 -> 60 PUSH1
12 -> 00 0x0
13 -> 80 DUP1
14 -> fd REVERT
15 -> 5b JUMPDEST
16 -> 50 POP
17 -> 60 PUSH1
18 -> 04 0x4
19 -> 36 CALLDATASIZE
20 -> 10 LT
21 -> 60 PUSH1
22 -> 32 0x32
23 -> 57 JUMPI
24 -> 60 PUSH1
25 -> 00 0x0
26 -> 35 CALLDATALOAD
27 -> 60 PUSH1
28 -> e0 0xE0
29 -> 1c SHR
30 -> 80 DUP1
31 -> 63 PUSH4
(32 ~ 35) -> 74135154 func1 方法签名哈希前四字节
36 -> 14 EQ
37 -> 60 PUSH1
38 -> 37 0x37
39 -> 57 JUMPI
40 -> 80 DUP1
41 -> 63 PUSH4
(42 ~ 45) -> b1ade4db func2 方法签名哈希前四字节
46 -> 14 EQ
47 -> 60 PUSH1
48 -> 3f 0x3F
49 -> 57 JUMPI
50 -> 5b JUMPDEST
51 -> 60 PUSH1
52 -> 00 0x0
53 -> 80 DUP1
54 -> fd REVERT
55 -> 5b JUMPDEST
56 -> 60 PUSH1
57 -> 3d 0x3D
58 -> 60 PUSH1
59 -> 47 0x47
60 -> 56 JUMP
61 -> 5b JUMPDEST
62 -> 00 STOP
63 -> 5b JUMPDEST
64 -> 60 PUSH1
65 -> 45 0x45
66 -> 60 PUSH1
67 -> 49 0x49
68 -> 56 JUMP
69 -> 5b JUMPDEST
70 -> 00 STOP
71 -> 5b JUMPDEST
72 -> 56 JUMP
73 -> 5b JUMPDEST
74 -> 56 JUMP
来看看这部分字节码都干了些什么。
0 - 4 行我们已经非常熟悉了,加载空闲内存指针。
5 - 14 行是校验 msg.value 必须为零,我们在上篇文章已经看到过这部分了。由于合约中没有 payable 方法,因此要求所有的合约调用的 callvalue 都要为零,否则会在 14 行REVERT 失败。
如果传入的 value 为零,则进入正常流程 15 行,在 16 行将栈中无用数据 pop 出。
17 - 18 行将 0x4 放入栈中,它代表正常的方法调用签名长度。此时栈为:
| 4
19 行获取到 data 域的长度并放入栈中,0x74135154 的长度为 4 个字节。此时栈为:
| 4 | 4
20 行从栈中获取两个数据,并判断第一个数字是否小于第二个数字,小于返回 1,否则返回 0。这里由于 4 = 4,因此返回 0。这部分是什么意思呢?上面我们说到第一次放入栈中的 4 代表正常的方法调用签名长度,也就是方法签名前四个字节的意思。无论我们调用了哪个方法,有没有参数,data 域的长度都至少应该是 4。如果某个交易的 data 域的长度小于 4,说明它并不是正常的方法调用,那么在接下来的流程中肯定就走不到正常的方法名匹配部分,要么交易失败,要么进入到 fallback 的调用流程中。我们这个例子中没有声明 fallback 方法,因此如果这时 LT 操作符返回 1(即 TRUE),交易就会失败。
在这里正常情况下,此时栈中为:
| 0
21 - 22 行将 0x32 放入栈中:
| 0 | 0x32
23 行 JUMPI(0x32, 0) 操作符根据栈中数据判断是否跳转。由于第二个参数是 0,因此不跳转,继续向下执行。这里假设第二个参数是 1,也就是上一步中的 data 域长度小于 4 字节,流程会跳转到 0x32,也就是 50 行,并最终在 54 行 REVERT 交易失败,验证了我们上面的说法。
24 - 25 行将 0x0 放入栈中:
| 0
26 行加载 32 字节长度的 data 域放入栈中,开始位置从栈中获取,这里即为 calldata[0],当前的 data 域内容为 0x74135154,不够 32 字节因此需要用 0 补齐。此时栈中为:
| 7413515400000000000000000000000000000000000000000000000000000000
27 - 28 行将 0xE0,即 224 放入栈中:
| 0x7413515400000000000000000000000000000000000000000000000000000000 | 0xE0
29 行 SHR 取出栈中数据,将 7413515400000000000000000000000000000000000000000000000000000000 右移 224 位,前者的长度是 256(即 64 * 4) 位,右移之后变成 0x74135154,刚好是四个字节,这四个字节恰好是方法签名哈希值的前四个字节,是用来匹配合约中的方法的。此时栈为:
| 0x74135154
30 行复制一份栈顶数据:
| 0x74135154 | 0x74135154
31 - 35 将 func1 的方法签名哈希前四个字节放入栈中:
| 0x74135154 | 0x74135154 | 0x74135154
36 行 EQ 判断栈顶两个元素是否相等,这里相等,因此返回 1:
| 0x74135154 | 1
37 - 38 将 0x37 放入栈中:
| 0x74135154 | 1 | 0x37
39 行 JUMPI(0x37, 1) 判断是否跳转,此时会跳转到 0x37,即 55 行。栈为:
| 0x74135154
这里如果第二个参数,也就是 EQ 的结果为 0,说明调用的方法与当前的方法不匹配,则不跳转,继续向下运行,寻找下一个方法签名进行匹配。
此时,我们已经找到了相匹配的合约方法,也就是说已经完成了匹配方法名的过程。接下来就是执行方法体内容了。
跳转到 55 行,56 - 59 将 0x3D 与 0x47 放入栈中:
| 0x74135154 | 0x3D | 0x47
其中,0x47,即 71 是 func1 方法体所在的字节码开始位置。
60 行 JUMP 跳转到 71 行执行 func1 方法体,此时栈为:
| 0x74135154 | 0x3D
由于 func1 内容为空,因此不需要执行什么操作。
72 行获取栈顶元素 0x3D,即 61,并跳转。
61 - 62 行这里会进行方法调用的结尾工作,通过 STOP 结束。
这篇文章我们学习了合约方法调用在字节码层面的实现。我们通过一个简单的合约,了解了合约方法调用过程中,用户的请求是如何匹配到具体的方法的。其实内容也比较简单,就是在栈中与所有的方法签名哈希值一个一个进行比对,如果相同,则跳转到相应的部分执行对应方法内容。这里还是建议大家都亲手去 evm.codes 这个网站去实际操作一下,感受整个流程中内存与栈内容的变化,会加深对整个流程的理解。
欢迎和我交流
今天我们来聊聊调用合约方法在字节码层面是怎么实现的。同样地,我们以一个简单的合约作为例子:
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
contract Demo {
constructor() {}
function func1() public {}
function func2() public {}
}
该合约中一共有两个方法,分别是 func1 与 func2。我们这里着重于理解方法调用的过程,因此简单起见就将方法内容置为空。
使用 solc 进行编译:
solc Demo.sol --bin
得到的字节码为:
利用 0xfe 操作符分割后,得到的各部分分别为:
6080604052348015600f57600080fd5b5060818061001e6000396000f3(init bytecode)
6080604052348015600f57600080fd5b506004361060325760003560e01c806374135154146037578063b1ade4db14603f575b600080fd5b603d6047565b005b60456049565b005b565b56(runtime bytecode)
a264697066735822122069532a763e3f61abcfbb422ae7dc4587126c8f8b2264e73bb837a5924ebb1d4964736f6c634300080f0033(metadata hash)
init bytecode 是合约部署部分,我们在上篇文章中已经介绍过了。调用合约方法是与 runtime bytecode 这部分进行交互,我们主要来研究这一块。
我们知道,调用合约方法交易的 data 域就是合约签名与参数的拼接内容。具体来说,就是合约签名的 keccak256 哈希值的前 4 个字节再加上参数内容就构成了 data 部分。
在上面例子中,func1 与 func2 的哈希值前 4 个字节分别是 0x74135154 与 0xb1ade4db,同时由于这两个方法都没有参数,因此不用在后面拼接参数。如果 fun1 的方法签名为:
function func1(uint _a) public {}
那么就需要添加参数,并且补齐 32 字节:
其中 0x254e43db 是 func1(uint256) 的哈希值前 4 个字节。
接下来我们就来看看在调用合约方法的过程中,在 EVM 字节码层面会发生什么。我们以调用 func1 方法为例,那么此时交易的 data 域内容就为 0x74135154。
我们先将 runtime bytecode 部分的字节码与其 opcodes 一一对应起来:
0 -> 60 PUSH1
1 -> 80 0x80
2 -> 60 PUSH1
3 -> 40 0x40
4 -> 52 MSTORE
5 -> 34 CALLVALUE
6 -> 80 DUP1
7 -> 15 ISZERO
8 -> 60 PUSH1
9 -> 0f 0xF
10 -> 57 JUMPI
11 -> 60 PUSH1
12 -> 00 0x0
13 -> 80 DUP1
14 -> fd REVERT
15 -> 5b JUMPDEST
16 -> 50 POP
17 -> 60 PUSH1
18 -> 04 0x4
19 -> 36 CALLDATASIZE
20 -> 10 LT
21 -> 60 PUSH1
22 -> 32 0x32
23 -> 57 JUMPI
24 -> 60 PUSH1
25 -> 00 0x0
26 -> 35 CALLDATALOAD
27 -> 60 PUSH1
28 -> e0 0xE0
29 -> 1c SHR
30 -> 80 DUP1
31 -> 63 PUSH4
(32 ~ 35) -> 74135154 func1 方法签名哈希前四字节
36 -> 14 EQ
37 -> 60 PUSH1
38 -> 37 0x37
39 -> 57 JUMPI
40 -> 80 DUP1
41 -> 63 PUSH4
(42 ~ 45) -> b1ade4db func2 方法签名哈希前四字节
46 -> 14 EQ
47 -> 60 PUSH1
48 -> 3f 0x3F
49 -> 57 JUMPI
50 -> 5b JUMPDEST
51 -> 60 PUSH1
52 -> 00 0x0
53 -> 80 DUP1
54 -> fd REVERT
55 -> 5b JUMPDEST
56 -> 60 PUSH1
57 -> 3d 0x3D
58 -> 60 PUSH1
59 -> 47 0x47
60 -> 56 JUMP
61 -> 5b JUMPDEST
62 -> 00 STOP
63 -> 5b JUMPDEST
64 -> 60 PUSH1
65 -> 45 0x45
66 -> 60 PUSH1
67 -> 49 0x49
68 -> 56 JUMP
69 -> 5b JUMPDEST
70 -> 00 STOP
71 -> 5b JUMPDEST
72 -> 56 JUMP
73 -> 5b JUMPDEST
74 -> 56 JUMP
来看看这部分字节码都干了些什么。
0 - 4 行我们已经非常熟悉了,加载空闲内存指针。
5 - 14 行是校验 msg.value 必须为零,我们在上篇文章已经看到过这部分了。由于合约中没有 payable 方法,因此要求所有的合约调用的 callvalue 都要为零,否则会在 14 行REVERT 失败。
如果传入的 value 为零,则进入正常流程 15 行,在 16 行将栈中无用数据 pop 出。
17 - 18 行将 0x4 放入栈中,它代表正常的方法调用签名长度。此时栈为:
| 4
19 行获取到 data 域的长度并放入栈中,0x74135154 的长度为 4 个字节。此时栈为:
| 4 | 4
20 行从栈中获取两个数据,并判断第一个数字是否小于第二个数字,小于返回 1,否则返回 0。这里由于 4 = 4,因此返回 0。这部分是什么意思呢?上面我们说到第一次放入栈中的 4 代表正常的方法调用签名长度,也就是方法签名前四个字节的意思。无论我们调用了哪个方法,有没有参数,data 域的长度都至少应该是 4。如果某个交易的 data 域的长度小于 4,说明它并不是正常的方法调用,那么在接下来的流程中肯定就走不到正常的方法名匹配部分,要么交易失败,要么进入到 fallback 的调用流程中。我们这个例子中没有声明 fallback 方法,因此如果这时 LT 操作符返回 1(即 TRUE),交易就会失败。
在这里正常情况下,此时栈中为:
| 0
21 - 22 行将 0x32 放入栈中:
| 0 | 0x32
23 行 JUMPI(0x32, 0) 操作符根据栈中数据判断是否跳转。由于第二个参数是 0,因此不跳转,继续向下执行。这里假设第二个参数是 1,也就是上一步中的 data 域长度小于 4 字节,流程会跳转到 0x32,也就是 50 行,并最终在 54 行 REVERT 交易失败,验证了我们上面的说法。
24 - 25 行将 0x0 放入栈中:
| 0
26 行加载 32 字节长度的 data 域放入栈中,开始位置从栈中获取,这里即为 calldata[0],当前的 data 域内容为 0x74135154,不够 32 字节因此需要用 0 补齐。此时栈中为:
| 7413515400000000000000000000000000000000000000000000000000000000
27 - 28 行将 0xE0,即 224 放入栈中:
| 0x7413515400000000000000000000000000000000000000000000000000000000 | 0xE0
29 行 SHR 取出栈中数据,将 7413515400000000000000000000000000000000000000000000000000000000 右移 224 位,前者的长度是 256(即 64 * 4) 位,右移之后变成 0x74135154,刚好是四个字节,这四个字节恰好是方法签名哈希值的前四个字节,是用来匹配合约中的方法的。此时栈为:
| 0x74135154
30 行复制一份栈顶数据:
| 0x74135154 | 0x74135154
31 - 35 将 func1 的方法签名哈希前四个字节放入栈中:
| 0x74135154 | 0x74135154 | 0x74135154
36 行 EQ 判断栈顶两个元素是否相等,这里相等,因此返回 1:
| 0x74135154 | 1
37 - 38 将 0x37 放入栈中:
| 0x74135154 | 1 | 0x37
39 行 JUMPI(0x37, 1) 判断是否跳转,此时会跳转到 0x37,即 55 行。栈为:
| 0x74135154
这里如果第二个参数,也就是 EQ 的结果为 0,说明调用的方法与当前的方法不匹配,则不跳转,继续向下运行,寻找下一个方法签名进行匹配。
此时,我们已经找到了相匹配的合约方法,也就是说已经完成了匹配方法名的过程。接下来就是执行方法体内容了。
跳转到 55 行,56 - 59 将 0x3D 与 0x47 放入栈中:
| 0x74135154 | 0x3D | 0x47
其中,0x47,即 71 是 func1 方法体所在的字节码开始位置。
60 行 JUMP 跳转到 71 行执行 func1 方法体,此时栈为:
| 0x74135154 | 0x3D
由于 func1 内容为空,因此不需要执行什么操作。
72 行获取栈顶元素 0x3D,即 61,并跳转。
61 - 62 行这里会进行方法调用的结尾工作,通过 STOP 结束。
这篇文章我们学习了合约方法调用在字节码层面的实现。我们通过一个简单的合约,了解了合约方法调用过程中,用户的请求是如何匹配到具体的方法的。其实内容也比较简单,就是在栈中与所有的方法签名哈希值一个一个进行比对,如果相同,则跳转到相应的部分执行对应方法内容。这里还是建议大家都亲手去 evm.codes 这个网站去实际操作一下,感受整个流程中内存与栈内容的变化,会加深对整个流程的理解。
欢迎和我交流
No activity yet