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
这篇文章我们来学习一下 Solidity 的内存布局。首先我们需要明白,我们这里谈到的内存是 Solidity 合约中的状态变量,而不是在函数方法中定义的临时变量。前者是存在于以太坊的存储结构中的,而后者只是运行时的临时内存变量。例如:
contract Storage {
uint256 public a;
bytes32 public b;
function foo() public {
uint256 c;
}
}
这段代码中,变量 a 和 b 是状态变量,属于我们讨论的范围,而 c 不属于,因为它是运行时临时变量。
Solidity 中的内存布局,有一个插槽(slot)的概念。每一个合约,都有 2 ^ 256 个内存插槽用于存储状态变量,但是这些插槽并不是实际存在的,也就是说,并没有实际占用了这么多空间,而是按需分配,用到时就会分配,不用时就不存在。插槽数量的上限是 2 ^ 256,每个插槽的大小是 32 个字节。图示如下:

Solidity 中有这么多的数据类型,它们都是怎么存储在这些插槽中的呢?我们来看看。
我们知道,Solidity 中的数据类型有很多,常见的有 uint,bytes(n), address,bool,string 等等。其中 uint 还有不同长度的,比如 uint8,uint256 等,bytes(n) 也包括 bytes2,bytes32 等。还有 mapping 以及 数组 类型等。前面提到过,一个插槽的大小是 32 个字节,那么像 uint256,bytes32 这些 32 字节大小的类型就可以刚好放在一个插槽中。
来看一个简单的例子:
contract Storage {
uint256 public a;
uint256 public b;
uint256 public c;
function foo() public {
a = 1;
b = 2;
c = 123;
}
}
上面的合约中,a,b,c 三个变量都是 uint256 类型的,恰好每个变量都占用了一个插槽,分别是插槽0,1,2。我们部署合约,调用 foo 函数,读取它们的值来确认一下:
const {ethers} = require("ethers");
const provider = new ethers.providers.JsonRpcProvider()
const main = async () => {
// 第一个参数是部署的合约地址
// 第二个参数是插槽的位置,这里注意,如果是十进制,就直接写数字
// 如果是十六进制,需要加上引号,例如 '0x0'
let a = await provider.getStorageAt(
"0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
0
)
console.log(a)
}
main()
这段代码使用了 ethersjs 库来读取合约插槽的数据,也可以使用其他的方法,例如 Python 可以使用 web3py 库。
我们分别读取0,1,2三个插槽的数据,分别为
0x0000000000000000000000000000000000000000000000000000000000000001
0x0000000000000000000000000000000000000000000000000000000000000002
0x000000000000000000000000000000000000000000000000000000000000007b
对应的 10 进制数为 1,2,123,验证正确。
我们再对上面的合约做一点小小的改动:
uint8 public a;
uint8 public b;
uint256 public c;
同样,我们部署并调用 foo 函数,再读取其插槽值,我们可以看到,插槽 0 的数据变成了:
0x0000000000000000000000000000000000000000000000000000000000000201
而插槽 1 的数据变成了:
0x000000000000000000000000000000000000000000000000000000000000007b
插槽 2 直接就没有数据了,这是为什么呢?因为一个插槽的大小是 32 字节,而 a 和 b 都只占用 1 个字节,Solidity 为了节省存储空间,会将它俩放在同一个插槽中,而下一个 c 变量,由于它占用了 32 字节,因此它要占用下一个插槽。
那么我们再做一点小改动,将 b 和 c 调换位置:
uint8 public a;
uint256 public c;
uint8 public b;
此时我们再去查看插槽数据,会发现,三个变量都各自占据了一个插槽,这是因为,虽然 a 只占据了插槽 0 中的 1 个字节,但是由于下一个变量 c 要占据一整个插槽,所以 c 只能去下一个插槽,那么 b 也就只能去第三个插槽了。
这里带给我们的思考就是,在开发合约时,内存的布局分配也是很重要的,合理地分配内存布局可以节省内存空间,也就节省了 gas 费用。
前面我们提到的 bytes(n) 类型,和 uint 类似,也是同样的道理。同时还有 bool 类型,它只占用 1 个字节。address 类型,占用 20 个字节。因此在开发过程中,可以将一些小字节类型放在一起,从而节省 gas 费用。
上面我们说到的,都是定长的数据类型。而像 string,bytes 这种非固定长度的类型,它们的存储规则是:
如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length * 2。
如果数据长度超出 31 字节,则在主插槽存储 length * 2 + 1, 数据照常存储在 keccak256(slot) 中。
来看一个实际的例子验证一下:
contract Storage {
string public a;
string public b;
function foo() public {
// a是31个字节,b是32个字节
a = 'abcabcabcabcabcabcabcabcabcabca';
b = 'abcabcabcabcabcabcabcabcabcabcab';
}
}
查看插槽 0 和 1 的值,分别为:
0x616263616263616263616263616263616263616263616263616263616263613e(最后一个字节存储长度 0x3e,即 62 = 31 * 2)
0x0000000000000000000000000000000000000000000000000000000000000041(最后一个字节存储长度 0x41,即 65 = 32 * 2 + 1)
我们再去看看 keccak256(slot) 中存储的值,通过
keccak256(abi.encode(1));
计算出哈希值,这也就是插槽的位置,再去读取其值:
// 第二个参数为插槽的位置,使用 ethersjs 库需要加引号,否则报错
let a = await provider.getStorageAt(
"0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
"0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6"
)
结果为:
0x6162636162636162636162636162636162636162636162636162636162636162
验证成功,注意我们这里的使用的是数据长度恰好为 32 字节,如果大于 32 字节,那么剩余的长度就会继续往下一个插槽【即 keccak256(abi.encode(1)) + 1 】延伸。
接下来我们看看 mapping 和 数组 类型是怎么存储的。
对于 mapping 类型,规则是:
所处的插槽,空置,不存储内容,
mapping 中的数据,存储在插槽 keccak256(key.slot) 中,也就是:
keccak256(abi.encode(key, slot))
来看一个例子:
contract Storage {
mapping(uint256 => uint256) public a;
function foo() public {
a[1] = 123;
a[2] = 345;
}
}
通过 keccak256(abi.encode(1, 0)) 和 keccak256(abi.encode(2, 0)) 分别计算出, a[1] 和 a[2] 所处的插槽位置为:
0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d
0xabbb5caa7dda850e60932de0934eb1f9d0f59695050f761dc64e443e5030a569
我们进行验证,插槽 0 的值为 0,上述这两个插槽的值分别为:
0x000000000000000000000000000000000000000000000000000000000000007b
0x0000000000000000000000000000000000000000000000000000000000000159
即分别为 123 和 345,验证成功。
再来看看数组类型,它所满足的规则是:
所处的插槽,存储数组的长度
数组内存储的元素,存储在以 keccak256(slot) 插槽开始的位置
同样来看一个例子:
contract Storage {
uint256[] public a;
function foo() public {
a.push(12);
a.push(34);
}
}
运行 foo 函数后,插槽 0 值就变成了 2,这里注意,如果运行了两次 foo,那么就变成了 4,因为数组的长度变成了 4。我们来计算 keccak256(abi.encode(0)) 的值为:
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
查询其插槽上的值为 12,再看看下一个插槽【即 keccak256(abi.encode(0)) + 1 】的值为 34,满足规则。
对于组合类型,例如 mapping(uint256 => uint256[]),那么就按照组合的规则,从外到里进行计算即可。
Solidity 中的内存布局,都严格遵守既定规则,并不是杂乱无章的。理解了内存布局,对于我们后面学习可升级合约,帮助很大。
欢迎和我交流
这篇文章我们来学习一下 Solidity 的内存布局。首先我们需要明白,我们这里谈到的内存是 Solidity 合约中的状态变量,而不是在函数方法中定义的临时变量。前者是存在于以太坊的存储结构中的,而后者只是运行时的临时内存变量。例如:
contract Storage {
uint256 public a;
bytes32 public b;
function foo() public {
uint256 c;
}
}
这段代码中,变量 a 和 b 是状态变量,属于我们讨论的范围,而 c 不属于,因为它是运行时临时变量。
Solidity 中的内存布局,有一个插槽(slot)的概念。每一个合约,都有 2 ^ 256 个内存插槽用于存储状态变量,但是这些插槽并不是实际存在的,也就是说,并没有实际占用了这么多空间,而是按需分配,用到时就会分配,不用时就不存在。插槽数量的上限是 2 ^ 256,每个插槽的大小是 32 个字节。图示如下:

Solidity 中有这么多的数据类型,它们都是怎么存储在这些插槽中的呢?我们来看看。
我们知道,Solidity 中的数据类型有很多,常见的有 uint,bytes(n), address,bool,string 等等。其中 uint 还有不同长度的,比如 uint8,uint256 等,bytes(n) 也包括 bytes2,bytes32 等。还有 mapping 以及 数组 类型等。前面提到过,一个插槽的大小是 32 个字节,那么像 uint256,bytes32 这些 32 字节大小的类型就可以刚好放在一个插槽中。
来看一个简单的例子:
contract Storage {
uint256 public a;
uint256 public b;
uint256 public c;
function foo() public {
a = 1;
b = 2;
c = 123;
}
}
上面的合约中,a,b,c 三个变量都是 uint256 类型的,恰好每个变量都占用了一个插槽,分别是插槽0,1,2。我们部署合约,调用 foo 函数,读取它们的值来确认一下:
const {ethers} = require("ethers");
const provider = new ethers.providers.JsonRpcProvider()
const main = async () => {
// 第一个参数是部署的合约地址
// 第二个参数是插槽的位置,这里注意,如果是十进制,就直接写数字
// 如果是十六进制,需要加上引号,例如 '0x0'
let a = await provider.getStorageAt(
"0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
0
)
console.log(a)
}
main()
这段代码使用了 ethersjs 库来读取合约插槽的数据,也可以使用其他的方法,例如 Python 可以使用 web3py 库。
我们分别读取0,1,2三个插槽的数据,分别为
0x0000000000000000000000000000000000000000000000000000000000000001
0x0000000000000000000000000000000000000000000000000000000000000002
0x000000000000000000000000000000000000000000000000000000000000007b
对应的 10 进制数为 1,2,123,验证正确。
我们再对上面的合约做一点小小的改动:
uint8 public a;
uint8 public b;
uint256 public c;
同样,我们部署并调用 foo 函数,再读取其插槽值,我们可以看到,插槽 0 的数据变成了:
0x0000000000000000000000000000000000000000000000000000000000000201
而插槽 1 的数据变成了:
0x000000000000000000000000000000000000000000000000000000000000007b
插槽 2 直接就没有数据了,这是为什么呢?因为一个插槽的大小是 32 字节,而 a 和 b 都只占用 1 个字节,Solidity 为了节省存储空间,会将它俩放在同一个插槽中,而下一个 c 变量,由于它占用了 32 字节,因此它要占用下一个插槽。
那么我们再做一点小改动,将 b 和 c 调换位置:
uint8 public a;
uint256 public c;
uint8 public b;
此时我们再去查看插槽数据,会发现,三个变量都各自占据了一个插槽,这是因为,虽然 a 只占据了插槽 0 中的 1 个字节,但是由于下一个变量 c 要占据一整个插槽,所以 c 只能去下一个插槽,那么 b 也就只能去第三个插槽了。
这里带给我们的思考就是,在开发合约时,内存的布局分配也是很重要的,合理地分配内存布局可以节省内存空间,也就节省了 gas 费用。
前面我们提到的 bytes(n) 类型,和 uint 类似,也是同样的道理。同时还有 bool 类型,它只占用 1 个字节。address 类型,占用 20 个字节。因此在开发过程中,可以将一些小字节类型放在一起,从而节省 gas 费用。
上面我们说到的,都是定长的数据类型。而像 string,bytes 这种非固定长度的类型,它们的存储规则是:
如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length * 2。
如果数据长度超出 31 字节,则在主插槽存储 length * 2 + 1, 数据照常存储在 keccak256(slot) 中。
来看一个实际的例子验证一下:
contract Storage {
string public a;
string public b;
function foo() public {
// a是31个字节,b是32个字节
a = 'abcabcabcabcabcabcabcabcabcabca';
b = 'abcabcabcabcabcabcabcabcabcabcab';
}
}
查看插槽 0 和 1 的值,分别为:
0x616263616263616263616263616263616263616263616263616263616263613e(最后一个字节存储长度 0x3e,即 62 = 31 * 2)
0x0000000000000000000000000000000000000000000000000000000000000041(最后一个字节存储长度 0x41,即 65 = 32 * 2 + 1)
我们再去看看 keccak256(slot) 中存储的值,通过
keccak256(abi.encode(1));
计算出哈希值,这也就是插槽的位置,再去读取其值:
// 第二个参数为插槽的位置,使用 ethersjs 库需要加引号,否则报错
let a = await provider.getStorageAt(
"0x4ed7c70F96B99c776995fB64377f0d4aB3B0e1C1",
"0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6"
)
结果为:
0x6162636162636162636162636162636162636162636162636162636162636162
验证成功,注意我们这里的使用的是数据长度恰好为 32 字节,如果大于 32 字节,那么剩余的长度就会继续往下一个插槽【即 keccak256(abi.encode(1)) + 1 】延伸。
接下来我们看看 mapping 和 数组 类型是怎么存储的。
对于 mapping 类型,规则是:
所处的插槽,空置,不存储内容,
mapping 中的数据,存储在插槽 keccak256(key.slot) 中,也就是:
keccak256(abi.encode(key, slot))
来看一个例子:
contract Storage {
mapping(uint256 => uint256) public a;
function foo() public {
a[1] = 123;
a[2] = 345;
}
}
通过 keccak256(abi.encode(1, 0)) 和 keccak256(abi.encode(2, 0)) 分别计算出, a[1] 和 a[2] 所处的插槽位置为:
0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d
0xabbb5caa7dda850e60932de0934eb1f9d0f59695050f761dc64e443e5030a569
我们进行验证,插槽 0 的值为 0,上述这两个插槽的值分别为:
0x000000000000000000000000000000000000000000000000000000000000007b
0x0000000000000000000000000000000000000000000000000000000000000159
即分别为 123 和 345,验证成功。
再来看看数组类型,它所满足的规则是:
所处的插槽,存储数组的长度
数组内存储的元素,存储在以 keccak256(slot) 插槽开始的位置
同样来看一个例子:
contract Storage {
uint256[] public a;
function foo() public {
a.push(12);
a.push(34);
}
}
运行 foo 函数后,插槽 0 值就变成了 2,这里注意,如果运行了两次 foo,那么就变成了 4,因为数组的长度变成了 4。我们来计算 keccak256(abi.encode(0)) 的值为:
0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563
查询其插槽上的值为 12,再看看下一个插槽【即 keccak256(abi.encode(0)) + 1 】的值为 34,满足规则。
对于组合类型,例如 mapping(uint256 => uint256[]),那么就按照组合的规则,从外到里进行计算即可。
Solidity 中的内存布局,都严格遵守既定规则,并不是杂乱无章的。理解了内存布局,对于我们后面学习可升级合约,帮助很大。
欢迎和我交流
No activity yet