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
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
今天我们来聊聊 EVM,那么什么是 EVM?EVM 其实就是执行 bytecode(字节码)的机器,它的全称是 Ethereum Virtual Machine(以太坊虚拟机),和 Java 的 JVM 很类似。我们平时写合约都是用 Solidity (或者 Vyper)编写的,但是这种语言机器是没有办法理解的,我们需要先使用编译器进行编译,编译后的结果是一串二进制码,EVM 可以理解这些二进制的东西,因此它就可以执行这些代码,从而完成一笔交易。
我们用一个简单的图示来解释这个过程:

我们从一个很简单的例子开始(文件命名为 Demo.sol):
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
contract Demo {
uint256 a;
constructor() {
a = 1;
}
}
我们平时开发项目用的都是 hardhat,forge 这种框架,他们的底层都是通过 solc 来进行编译。这次我们就直接使用编译工具 solc 来进行编译,可以参考这里进行安装。这里列出 Mac 的安装方法:
brew update
brew upgrade
brew tap ethereum/ethereum
brew install solidity
安装完成后,我们来编译试试,使用下面的命令:
solc Demo.sol --bin
输出以下内容:
======= Demo.sol:Demo ======= Binary: 6080604052348015600f57600080fd5b506001600081905550603f8060256000396000f3fe6080604052600080fdfea26469706673582212204ca38d4a605f03f1487b9cb337c0853cca3c62a6c42f942ecb021fb7357002b564736f6c634300080f0033
我们看到在结果中输出了一串 16 进制字符,这就是上面的合约经过编译过后的字节码。我们前面说过,字节码是一串二进制的字符,这里显示为 16 进制方便阅读。注意到我们在命令中指定了 --bin 参数,因此输出的是 16 进制。
第一眼看见这串字符是不是已经懵了,别慌,我们慢慢来研究。首先,我们需要知道,EVM 的核心实际上是一个 stack machine(栈机器),它会接受操作符和操作数,学过数据结构的朋友应该都了解栈的原理。其次,上面这些字符都是由操作符和操作数组成的。例如开头的 60 代表的是 PUSH1,也就是将后面的一个字节(这里是 80)压入栈中。后面又是一个 60,接着是 40,即代表将 40 压入栈中。后面是 52,代表 MSTORE,它需要消耗两个操作数,需要从栈中获取。也就是说,上面的 6080604052,就代表着:
MSTORE 的两个操作数分别为 0x40、0x80,即 MSTORE(0x40, 0x80),也就是在内存中地址 0x40 处存储了数据 0x80。(这一句不明白没关系,我们先往下看)
现在我们已经明白了字节码的基本逻辑,它就是由操作符和操作数组成的。其中两个字符代表一个字节,操作符都是一个字节,但是操作数可能有多个字节。像我们前面看到的 PUSH1,是将后面的一个字节压入栈中,如果是 PUSH4,就是将后面的四个字节压入栈中。一般将操作符称为 Opcodes,这里可以看到所有的 Opcodes。需要注意的是操作符和操作数有可能重复,例如判断 60 是操作符还是操作数,取决于它在字节码中的位置,并不是绝对的。例如 6060,前面的 60 是操作符,后面的就是操作数,代表 PUSH1 60。
接下来我们看看字节码的构造,我们在上面的字节码中搜索 fe,可以看到其中有两个 fe,同时查询 Opcodes 对照表,可知 fe 是无效操作符(INVALID)。它的作用其实是分隔符,它将字节码分成了三部分:
init bytecode(初始化字节码)
runtime bytecode (运行时字节码)
metadata hash(合约的一些 meta 信息哈希)

那么我们前面编译的字节码就被分成了三部分:
6080604052348015600f57600080fd5b506001600081905550603f8060256000396000f3(init bytecode)
6080604052600080fd(runtime bytecode)
a26469706673582212204ca38d4a605f03f1487b9cb337c0853cca3c62a6c42f942ecb021fb7357002b564736f6c634300080f0033(metadata hash)
我们先来看看最后这里的 metadata hash,它默认是合约 metadata 文件的 IPFS 哈希值,我们可以使用:
solc Demo.sol --metadata
来获取到其 metadata:
{
"compiler": {
"version": "0.8.15+commit.e14f2714"
},
"language": "Solidity",
"output": {
"abi": [
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
}
],
"devdoc": {
"kind": "dev",
"methods": {},
"version": 1
},
"userdoc": {
"kind": "user",
"methods": {},
"version": 1
}
},
"settings": {
"compilationTarget": {
"Demo.sol": "Demo"
},
"evmVersion": "london",
"libraries": {},
"metadata": {
"bytecodeHash": "ipfs"
},
"optimizer": {
"enabled": false,
"runs": 200
},
"remappings": []
},
"sources": {
"Demo.sol": {
"keccak256": "0xf6e99f20fac61b16466088a9996227f35c4ca82119a846ba19a83698e8e126b1",
"license": "UNLICENSED",
"urls": [
"bzz-raw://3fda90691cfa365f68a59d5b6bb76f8a0189153cebf93143879521ea309751b8",
"dweb:/ipfs/QmP2dVkfPWy3tAriX17tar9phGzC8gqYzutmhEur6dwdiw"
]
}
},
"version": 1
}
可以看到其中主要包含了编译器版本,ABI,IPFS 等信息。这部分了解即可,我们平时也用不到这些,详细内容可以查看文档。
我们前面提到了一些操作数,例如 0x40、0x80,这些数字是什么意思呢。要了解这些,我们就得先明白 EVM 中的内存布局。前面的文章中,我们讲解过内存布局,但是当时讲的实际上是 Storage Layout,也就是状态变量的布局结构。而我们现在要讲的是 Memory Layout。区别在于 Storage 的数据是永久存在于区块链上的,类似于计算机的硬盘数据。而 Memory 的数据只有在发起交易的时候才有,交易完毕,数据全部消失,类似于计算机的内存数据。
Memory 的数据结构就是一个简单的字节数组,数据可以以 1 字节(8 位)或者 32 字节(256 位)为单位进行存储,读取时只能以 32 字节为单位读取,但是读取时可以从任意字节处开始读取,不限定于 32 的倍数字节。图示:

用于操作内存的一共有 3 个操作符:
MSTORE (x, y) - 在内存 x 处开始存储 32 字节的数据 y
MLOAD (x) - 将内存 x 处开始的 32 字节数据加载到栈中
MSTORE8 (x, y) - 在内存 x 处存储 1 字节数据 y(32字节栈值中的最低有效字节)
Solidity 中预留了 4 个 32 字节的插槽(slot),分别是:
0x00 - 0x3f (64 字节): 哈希方法的暂存空间
0x40 - 0x5f (32 字节): 当前已分配内存大小 (也称为空闲内存指针)
0x60 - 0x7f (32 字节): 零槽,用作动态内存数组的初始值,永远不能写入值

这里面最重要的就是中间这一项,也就是空闲指针。它会指向空闲空间的开始位置,也就是说,要将一个新变量写入内存,给它分配的位置就是空闲指针所指向的位置。需要注意的是,Solidity 中的内存是不会被释放(free)的。
对于空闲指针,它的更新遵守了很简单的原则:
新的空闲指针位置 = 旧的空闲指针位置 + 分配的数据大小
上图中我们看到,Solidity 的预留空间已经占据了 128 个字节,因此空闲指针的起始位置就只能从 0x80(128字节) 开始。空闲指针本身是存在于 0x40 位置的。由于我们在函数中的操作均需要在内存中进行,因此首要任务就是要通过空闲指针分配内存,所以我们前面才需要使用 6080604052,也就是 MSTORE(0x40, 0x80),来加载空闲指针。此时是不是已经有些明白为什么所有的合约都是以 6080604052 开头了(有些老版本合约以 6060604052 开头)。
这篇文章我们就先介绍到这里,我们学习了合约的编译过程,字节码的构造,以及合约的内存分布。可以多看几遍消化消化。下篇文章我们将介绍合约的部署,也就是 init bytecode 部分,了解 EVM 在合约部署时的运行逻辑。
欢迎和我交流
今天我们来聊聊 EVM,那么什么是 EVM?EVM 其实就是执行 bytecode(字节码)的机器,它的全称是 Ethereum Virtual Machine(以太坊虚拟机),和 Java 的 JVM 很类似。我们平时写合约都是用 Solidity (或者 Vyper)编写的,但是这种语言机器是没有办法理解的,我们需要先使用编译器进行编译,编译后的结果是一串二进制码,EVM 可以理解这些二进制的东西,因此它就可以执行这些代码,从而完成一笔交易。
我们用一个简单的图示来解释这个过程:

我们从一个很简单的例子开始(文件命名为 Demo.sol):
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
contract Demo {
uint256 a;
constructor() {
a = 1;
}
}
我们平时开发项目用的都是 hardhat,forge 这种框架,他们的底层都是通过 solc 来进行编译。这次我们就直接使用编译工具 solc 来进行编译,可以参考这里进行安装。这里列出 Mac 的安装方法:
brew update
brew upgrade
brew tap ethereum/ethereum
brew install solidity
安装完成后,我们来编译试试,使用下面的命令:
solc Demo.sol --bin
输出以下内容:
======= Demo.sol:Demo ======= Binary: 6080604052348015600f57600080fd5b506001600081905550603f8060256000396000f3fe6080604052600080fdfea26469706673582212204ca38d4a605f03f1487b9cb337c0853cca3c62a6c42f942ecb021fb7357002b564736f6c634300080f0033
我们看到在结果中输出了一串 16 进制字符,这就是上面的合约经过编译过后的字节码。我们前面说过,字节码是一串二进制的字符,这里显示为 16 进制方便阅读。注意到我们在命令中指定了 --bin 参数,因此输出的是 16 进制。
第一眼看见这串字符是不是已经懵了,别慌,我们慢慢来研究。首先,我们需要知道,EVM 的核心实际上是一个 stack machine(栈机器),它会接受操作符和操作数,学过数据结构的朋友应该都了解栈的原理。其次,上面这些字符都是由操作符和操作数组成的。例如开头的 60 代表的是 PUSH1,也就是将后面的一个字节(这里是 80)压入栈中。后面又是一个 60,接着是 40,即代表将 40 压入栈中。后面是 52,代表 MSTORE,它需要消耗两个操作数,需要从栈中获取。也就是说,上面的 6080604052,就代表着:
MSTORE 的两个操作数分别为 0x40、0x80,即 MSTORE(0x40, 0x80),也就是在内存中地址 0x40 处存储了数据 0x80。(这一句不明白没关系,我们先往下看)
现在我们已经明白了字节码的基本逻辑,它就是由操作符和操作数组成的。其中两个字符代表一个字节,操作符都是一个字节,但是操作数可能有多个字节。像我们前面看到的 PUSH1,是将后面的一个字节压入栈中,如果是 PUSH4,就是将后面的四个字节压入栈中。一般将操作符称为 Opcodes,这里可以看到所有的 Opcodes。需要注意的是操作符和操作数有可能重复,例如判断 60 是操作符还是操作数,取决于它在字节码中的位置,并不是绝对的。例如 6060,前面的 60 是操作符,后面的就是操作数,代表 PUSH1 60。
接下来我们看看字节码的构造,我们在上面的字节码中搜索 fe,可以看到其中有两个 fe,同时查询 Opcodes 对照表,可知 fe 是无效操作符(INVALID)。它的作用其实是分隔符,它将字节码分成了三部分:
init bytecode(初始化字节码)
runtime bytecode (运行时字节码)
metadata hash(合约的一些 meta 信息哈希)

那么我们前面编译的字节码就被分成了三部分:
6080604052348015600f57600080fd5b506001600081905550603f8060256000396000f3(init bytecode)
6080604052600080fd(runtime bytecode)
a26469706673582212204ca38d4a605f03f1487b9cb337c0853cca3c62a6c42f942ecb021fb7357002b564736f6c634300080f0033(metadata hash)
我们先来看看最后这里的 metadata hash,它默认是合约 metadata 文件的 IPFS 哈希值,我们可以使用:
solc Demo.sol --metadata
来获取到其 metadata:
{
"compiler": {
"version": "0.8.15+commit.e14f2714"
},
"language": "Solidity",
"output": {
"abi": [
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
}
],
"devdoc": {
"kind": "dev",
"methods": {},
"version": 1
},
"userdoc": {
"kind": "user",
"methods": {},
"version": 1
}
},
"settings": {
"compilationTarget": {
"Demo.sol": "Demo"
},
"evmVersion": "london",
"libraries": {},
"metadata": {
"bytecodeHash": "ipfs"
},
"optimizer": {
"enabled": false,
"runs": 200
},
"remappings": []
},
"sources": {
"Demo.sol": {
"keccak256": "0xf6e99f20fac61b16466088a9996227f35c4ca82119a846ba19a83698e8e126b1",
"license": "UNLICENSED",
"urls": [
"bzz-raw://3fda90691cfa365f68a59d5b6bb76f8a0189153cebf93143879521ea309751b8",
"dweb:/ipfs/QmP2dVkfPWy3tAriX17tar9phGzC8gqYzutmhEur6dwdiw"
]
}
},
"version": 1
}
可以看到其中主要包含了编译器版本,ABI,IPFS 等信息。这部分了解即可,我们平时也用不到这些,详细内容可以查看文档。
我们前面提到了一些操作数,例如 0x40、0x80,这些数字是什么意思呢。要了解这些,我们就得先明白 EVM 中的内存布局。前面的文章中,我们讲解过内存布局,但是当时讲的实际上是 Storage Layout,也就是状态变量的布局结构。而我们现在要讲的是 Memory Layout。区别在于 Storage 的数据是永久存在于区块链上的,类似于计算机的硬盘数据。而 Memory 的数据只有在发起交易的时候才有,交易完毕,数据全部消失,类似于计算机的内存数据。
Memory 的数据结构就是一个简单的字节数组,数据可以以 1 字节(8 位)或者 32 字节(256 位)为单位进行存储,读取时只能以 32 字节为单位读取,但是读取时可以从任意字节处开始读取,不限定于 32 的倍数字节。图示:

用于操作内存的一共有 3 个操作符:
MSTORE (x, y) - 在内存 x 处开始存储 32 字节的数据 y
MLOAD (x) - 将内存 x 处开始的 32 字节数据加载到栈中
MSTORE8 (x, y) - 在内存 x 处存储 1 字节数据 y(32字节栈值中的最低有效字节)
Solidity 中预留了 4 个 32 字节的插槽(slot),分别是:
0x00 - 0x3f (64 字节): 哈希方法的暂存空间
0x40 - 0x5f (32 字节): 当前已分配内存大小 (也称为空闲内存指针)
0x60 - 0x7f (32 字节): 零槽,用作动态内存数组的初始值,永远不能写入值

这里面最重要的就是中间这一项,也就是空闲指针。它会指向空闲空间的开始位置,也就是说,要将一个新变量写入内存,给它分配的位置就是空闲指针所指向的位置。需要注意的是,Solidity 中的内存是不会被释放(free)的。
对于空闲指针,它的更新遵守了很简单的原则:
新的空闲指针位置 = 旧的空闲指针位置 + 分配的数据大小
上图中我们看到,Solidity 的预留空间已经占据了 128 个字节,因此空闲指针的起始位置就只能从 0x80(128字节) 开始。空闲指针本身是存在于 0x40 位置的。由于我们在函数中的操作均需要在内存中进行,因此首要任务就是要通过空闲指针分配内存,所以我们前面才需要使用 6080604052,也就是 MSTORE(0x40, 0x80),来加载空闲指针。此时是不是已经有些明白为什么所有的合约都是以 6080604052 开头了(有些老版本合约以 6060604052 开头)。
这篇文章我们就先介绍到这里,我们学习了合约的编译过程,字节码的构造,以及合约的内存分布。可以多看几遍消化消化。下篇文章我们将介绍合约的部署,也就是 init bytecode 部分,了解 EVM 在合约部署时的运行逻辑。
欢迎和我交流
No activity yet