Web实习计划(二)

1.16

把这周的笔记汇总整理了一下。然后做钓鱼攻防任务ing。

1.17

数据存储

Solidity合约数据存储采用的为合约的每项数据指定一个可计算的可存储位置,数据存储在一个容量为2^256^超大数组中,其中数组的每个元素的初始值都为0。而2^256^是一个超级大的数字,足够容量合约需要任意大小的存储。Solidity是插槽式数据存储,每个插槽可存储32字节的数据,当某个数据超过了32字节,则需要占用多个存储插槽(占用插槽数量由数据长度决定,data.length/32)。

对于数据长度是否已知,solidity有不同的存储方式:

  • 当数据长度已知时,则存储位置将在编译时指定存储位置;

    • 定长数据存储:Solidity编译器在编译合约时,当数据类型是值类型(固定大小的值)时,编译时将严格根据字段排序顺序,给每个要存储的值类型数据预分配存储位置。 相当于已提前指定了固定不变的数据指针。

    • 紧凑存储:一大部分值类型实际上不需要用到 32 字节,如booluint1uint256。 为了节约存储量,编译器在发现所用存储不超过 32 字节时,将会将其和后面字段尽可能的存储在一个存储中。

      • 即使是bool类型,虽然取值只有true/false(1/0),但是仍然占据1字节。

      • 出于节省gas的目的,在编写合约代码时,即使声明的状态变量的数量及其类型发生不变,而变量的顺序发生变化有时也会有gas不同的差别。

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        pragma solidity >0.8.0;
        contract Storage1 {
        uint256 a = 11; // slot0
        uint8 b = 12; // slot1,1 字节
        uint128 c = 13; // slot1,16 字节
        bool d = true; // slot1,1 字节
        uint128 e = 14;// slot2
        }

        contract Storage2 {
        uint256 a = 11; // slot0
        uint8 b = 12; // slot1,1 字节
        uint128 c = 13; // slot1,16 字节
        uint128 e = 14;// slot2
        bool d = true; // slot3,1 字节
        }

        如上述合约所示,只将变量e和d的顺序调换,占用插槽的数量也会不一样。鉴于这种紧凑存储原则,有效降低了存储占用。而以太坊存储是昂贵的,因此为了降低存储占用, 在编写合约时,记得注意状态变量的声明顺序。

  • 而对于未知长度的数据时(比如动态数组,映射等)则按照一定的规则计算存储位置。

    • stringbytes 实际是一个特殊的 array ,编译器对这类数据有进行优化。如果 stringbytes 的数据很短。那么它们的长度也会和数据一起存储到同一个插槽。 具体为:
      • 如果数据长度小于等于 31 字节, 则它存储在高位字节(左对齐),最低位字节存储 length * 2
      • 如果数据长度超出 31 字节,则在主插槽存储 length * 2 + 1, 数据照常存储在 keccak256(slot) 中。
    • 动态数组 T[] 由两部分组成,数组长度和元素值。在 Solidity 中定义动态数组后,将在定义的插槽位置存储数组元素数量, 元素数据存储的起始位置是:keccak256(slot)+偏移量,每个元素需要根据下标和元素大小来读取数据。
    • 映射的存储布局是直接存储 Key 对应的 value,每个 Key 对应一份存储。一个 Key 的对应存储位置是 keccak256(abi.encode(key, slot)) , 可直接获得 value 的存储。

1.18

ABI编解码函数:

  • abi.decode(bytes memory encodedData, (...)) returns (...)
    • 根据传入的数据类型解码数据(encodeencodePacked均可解码)
    • 数据类型通过在括号中作为第二个参数给出。
    • example:(uint a, uint[2] memory b, bytes memory c) = abi.decode(data, (uint, uint[2], bytes))
  • abi.encode(...) returns (bytes memory)
    • 对给定的数据进行 abi 编码
  • abi.encodePacked(...) returns (bytes memory)
    • 对给定的数据进行紧密编码(此编码可能会不明确)
  • abi.encodeWithSelector(bytes4 selector, ...) returns (bytes memory)
    • 从第二个数据开始,对给定的参数进行 ABI 编码,并在前面添加给定的 4 bytes 的函数选择器
    • 通过该内置函数,我们可以构造我们的 calldata
  • abi.encodeCall(function functionPointer, (...) ) returns (bytes memory)
    • 使用元组中找到的参数对 functionPointer的调用进行 ABI 编码。
    • 执行完整的类型检查,确保类型与函数签名匹配。
    • 结果等于 abi.encodeWithSelector(functionPointer.selector, ...)
  • abi.encodeWithSignature(string memory signature, ...) returns (bytes memory)
    • 相当于 abi.encodeWithSelector(bytes4(keccak256(bytes(signature))), ...)

返回的是一个 bytes 类型的动态数组,它本质上就是一个 byte 数组,即 bytes memory,你当然可以通过索引(data[i])访问和修改它的每一个字节。这会生成一个完整的 calldata:

  • 前 4 字节:函数选择器(registerTreasury() 的 keccak256 hash 前四位)
  • 后 32 字节:参数部分,但 ABI 会扩展成一个完整的 uint256 占满 32 字节(高位为 0)

所以总共 4 + 32 = 36 字节的数组。你能通过索引 data[0] ... data[35] 访问和修改任何一位。

拓展:abi.encode(), string.concat(), 和 abi.encodePacked() 这三者在 Solidity 中的主要作用是数据编码和字符串拼接,但它们的行为和适用场景有所不同:
总结
方法 作用 返回类型 适用场景 是否适用于哈希计算 是否适用于字符串拼接
abi.encode() 标准 ABI 编码(32 字节对齐) bytes 存储、参数传递、哈希安全 是(无哈希冲突)
string.concat() 字符串拼接 string 字符串操作
abi.encodePacked() 紧凑编码(可能导致哈希冲突) bytes 哈希计算(但可能冲突) 可能冲突 但需转换成 string

什么时候用?
  • 拼接字符串string.concat()
  • 计算哈希abi.encodePacked()(注意哈希冲突)
  • 存储数据、避免哈希冲突abi.encode()

1.19

今天把Crowdfunding挑战完成,也算是第一次利用hardhat框架去完成项目了。

1.20

完成Dapp开发任务,然后又复现了Ethernaut的几个关卡。算是重温之前学的知识了。

然后又回顾了点UniswapV2的知识。

1.21

今天完成挑战 Challenge - Tokenization,然后回顾了gas优化和合约安全方面的知识。

1.22

被反应式智能合约Uniswap V2 止损订单折磨麻了,环境变量给的不明确,只能去尝试。到现在也没有完成demo

1.23

今天在搭SpoonOS ing,闹麻了。明天继续配

1.24

又配失败了遂放弃。学习了一点gas优化

gas 优化技巧

注意事项

  • gas 优化技巧并不总是有效

    • 例如在if else判断时,和条件取正相比,条件取反会消耗额外的操作码。令人意外的是,有很多情况下,这种优化实际上会增加交易的成本。Solidity编译器有时是不可预测的。
    • 因此,在选择特定算法之前,你应该实际测量替代方案的效果。考虑这些技巧可以认识到编译器一些可能会让人惊讶的地方。Gas 优化技巧有时取决于编译器在本地的操作。通常应同时测试代码的最优版本和非最优版本,以查看是否真正获得了改进。我们将记录一些令人惊讶的情况,即本应导致优化的情况实际上导致了更高的成本。
  • 注意复杂性和可读性

    • gas优化通常会使代码变得更难度和更复杂,我们需要在主观上去进行权衡这部分的gas优化是否是值得的。

1. 最重要的是:尽可能避免零到一的存储写入

初始化存储变量是合约可以执行的最昂贵的操作之一。

当存储变量从零变为非零时,用户必须支付总共22,100 gas(20,000 gas 用于从零到非零的写入,2,100 gas 用于冷存储访问)。

这就是为什么 Openzeppelin 的重入保护使用1和2来注册函数的活动状态,而不是0和1。将存储变量从非零更改为非零只需花费5,000 gas。

2. 缓存存储变量:仅写入和读取存储变量一次

在高效的 Solidity 代码中,你经常会看到以下模式。从存储变量读取至少需要2,100 gas,写入 20,000gas(新写) / 2,900(改为非零) / 100(改为零),因为 Solidity 不会缓存存储读取。写入要昂贵得多。因此,你应该手动缓存变量,以便仅进行一次存储读取和一次存储写入。比如下面的这两个合约,前者的函数读取了两次计数器,而后者只读取了一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Counter1 {
uint256 public number;

function increment() public {
require(number < 10);
number = number + 1;
}
}
// 区别: 一个是直接读取全局变量对全局变量进行赋值,一个是通过定义一个局部变量对全局变量进行赋值。后者调用完函数即释放内存(节省gas)
contract Counter2 {
uint256 public number;

function increment() public {
uint256 _number = number;
require(_number < 10);
number = _number + 1;
}
}

3. 打包相关变量

将相关变量打包到同一个槽位中可以通过最小化昂贵的存储相关操作来减少 gas 成本。

手动打包是最高效的

我们通过位移操作将两个 uint80 值存储在一个变量(uint160)中。这样只使用一个存储槽位,在单个事务中存储或读取各个值时更便宜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract GasSavingExample {
uint160 public packedVariables;

function packVariables(uint80 x, uint80 y) external {
packedVariables = uint160(x) << 80 | uint160(y);
}

function unpackVariables() external view returns (uint80, uint80) {
uint80 x = uint80(packedVariables >> 80);
uint80 y = uint80(packedVariables);
return (x, y);
}
}
EVM 打包略微低效

这个示例与上面的示例一样使用了一个槽位,但在单个事务中存储或读取值时可能稍微昂贵。 这是因为 EVM 会自行进行位移操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
contract GasSavingExample2 {
uint80 public var1;
uint80 public var2;

function updateVars(uint80 x, uint80 y) external {
var1 = x;
var2 = y;
}

function loadVars() external view returns (uint80, uint80) {
return (var1, var2);
}
}
不打包是最低效的

这种方式没有使用任何优化,在存储或读取值时更昂贵。

与其它示例不同,这里使用了两个存储槽位来存储变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
contract NonGasSavingExample {
uint256 public var1;
uint256 public var2;

function updateVars(uint256 x, uint256 y) external {
var1 = x;
var2 = y;
}

function loadVars() external view returns (uint256, uint256) {
return (var1, var2);
}
}

4. 打包结构体

像打包相关状态变量一样,打包结构体成员可以帮助节省 gas 。(需要注意的是,在 Solidity 中,结构体成员按顺序存储在合约的存储中,从它们初始化的槽位位置开始)。

考虑以下示例:

未打包的结构体

未打包的结构体 unpackedStruct 有三个成员,它们将存储在三个单独的槽位中。然而,如果这些成员被打包,只会使用两个槽位,这将使读取和写入结构体成员更便宜。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;

contract Unpacked_Struct {
struct unpackedStruct {
uint64 time; // Takes one slot - although it only uses 64 bits (8 bytes) out of 256 bits (32 bytes).
uint256 money; // This will take a new slot because it is a complete 256 bits (32 bytes) value and thus cannot be packed with the previous value.
address person; // An address occupies only 160 bits (20 bytes).
}

// Starts at slot 0
unpackedStruct details = unpackedStruct(53_000, 21_000, address(0xdeadbeef));

function unpack() external view returns (unpackedStruct memory) {
return details;
}
}
打包的结构体

我们可以通过打包结构体成员来减少上面示例的 gas 消耗,如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract Packed_Struct {
struct packedStruct {
uint64 time; // In this case, both `time` (64 bits) and `person` (160 bits) are packed in the same slot since they can both fit into 256 bits (32 bytes)
address person; // Same slot as `time`. Together they occupy 224 bits (28 bytes) out of 256 bits (32 bytes).
uint256 money; // This will take a new slot because it is a complete 256 bits (32 bytes) value and thus cannot be packed with the previous value.
}

// Starts at slot 0
packedStruct details = packedStruct(53_000, address(0xdeadbeef), 21_000);

function unpack() external view returns (packedStruct memory) {
return details;
}
}

5. 保持字符串长度小于32字节

在 Solidity 中,字符串是可变长度的动态数据类型,意味着它们的长度可以根据需要进行更改和增长。

如果长度为32字节或更长,它们定义的槽位中存储的是字符串长度 * 2 + 1,而实际数据存储在其它位置(该槽位的 keccak 哈希值)。

然而,如果字符串长度小于32字节,长度 * 2 存储在其存储槽位的最低有效字节中,并且字符串的实际数据从定义它的槽位的最高有效字节开始存储。

6. 从不更新的变量应为不可变的或常量

在 Solidity 中,不打算更新的变量应该是常量或不可变的。

这是因为常量和不可变值直接嵌入到它们所定义的合约的字节码中,不使用存储空间。

这样可以节省大量的 gas,因为我们不进行任何昂贵的存储读取操作。

7. 使用映射而不是数组以避免长度检查

当存储你希望按特定顺序组织并使用固定键/索引检索的项目列表或组时,通常使用数组数据结构是常见的做法。这种方法很有效,但你知道可以实现一个技巧,每次读取时可以节省2000多个 gas 吗?

请参考下面的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// get(0) gas cost: 4860 
contract Array {
uint256[] a;

constructor() {
a.push() = 1;
a.push() = 2;
a.push() = 3;
}

function get(uint256 index) external view returns(uint256) {
return a[index];
}
}

/// get(0) gas cost: 2758
contract Mapping {
mapping(uint256 => uint256) a;

constructor() {
a[0] = 1;
a[1] = 2;
a[2] = 3;
}

function get(uint256 index) external view returns(uint256) {
return a[index];
}
}

仅仅通过使用映射,我们就可以节省2102个 gas。为什么?在底层,当你读取数组的索引值时,Solidity 会添加字节码来检查你是否正在读取有效的索引(即索引严格小于数组的长度),否则会回滚并显示恐慌错误(具体为 Panic(0x32))。这样可以防止读取未分配或更糟糕的已分配存储/内存位置。

由于映射的方式是(简单的键=>值对),不需要进行这样的检查,我们可以直接从存储槽中读取。重要的是要注意,当以这种方式使用映射时,你的代码应确保不要读取超出规范数组索引的位置。

8. 使用 unsafeAccess 在数组上避免冗余的长度检查

使用映射来避免 Solidity 在读取数组时进行的长度检查(同时仍然使用数组)的另一种方法是使用 Openzeppelin 的 Arrays.sol库中的 unsafeAccess 函数。这使开发人员可以直接访问数组中任意给定索引的值,同时跳过长度溢出检查。但是,仅在确保传递给函数的索引不会超过传递的数组的长度时才使用此方法。

9. 在使用大量布尔值时,使用位图而不是布尔值

一个常见的模式,特别是在空投中,是在领取空投或 NFT 时将地址标记为“已使用”。

然而,由于只需要一个位来存储这些信息,而每个存储槽是 256 位,这意味着可以使用一个存储槽存储 256 个标志/布尔值。

Solidity 支持紧凑存储(Storage Packing),但只有当多个变量可以打包到同一个存储槽时才有效。即使 bool 变量可以紧凑存储,使用位图仍然可以提供更大的优化空间,特别是在 大量布尔值 的情况下。Solidity 在 structmapping按 32 字节(256 位)对齐存储变量。如果多个 booluint8 变量在同一个 struct 里,编译器会自动打包到一个存储槽中,从而提高存储效率。

示例:bool 变量的紧凑存储

1
2
3
4
5
6
7
8
9
10
11
12
solidity复制编辑pragma solidity ^0.8.0;

contract PackedStorage {
struct UserData {
bool isRegistered; // 1 bit
bool hasClaimed; // 1 bit
uint8 age; // 8 bits
uint16 score; // 16 bits
}

mapping(address => UserData) public users;
}

在这个 UserData 结构体中:

  • isRegistered 占 1 bit
  • hasClaimed 占 1 bit
  • age 占 8 bits
  • score 占 16 bits
  • 这些字段会被自动打包到一个 32 字节的存储槽,而不会浪费 32 字节存储槽。

紧凑存储的限制

  1. 不同类型的变量不能自动打包
    • 例如 booluint256 不能共享同一个存储槽,uint256 仍然会占用完整的 32 字节。
  2. 单个 bool 仍然浪费 32 字节
    • 如果 mapping(address => bool), 每个 bool 仍然占 一个完整的存储槽(256 bits)。

为什么仍然推荐位图?

即使 Solidity 支持紧凑存储,位图(BitMap)仍然更高效,因为:

  • 紧凑存储仍然无法避免 boolmapping 里的高开销
  • 位图可以存 256 个布尔值在一个 uint256 变量里,更节省存储。
  • Gas 成本更低,减少 SSTORE 操作次数(每次 SSTORE 操作都很昂贵)。

位图 vs 紧凑存储

方案 存储槽利用率 读取 Gas 写入 Gas 适用场景
mapping(address => bool) 1/256 小量布尔值
struct + 紧凑存储 struct 内小量布尔值
mapping(address => uint256) (位图) 100% 大量布尔值

位图的实际应用

对于像 空投领取、DAO 投票、NFT 领取 这样的情况,位图比 bool 更节省 Gas

示例:使用 mapping(address => uint256) 作为位图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
solidity复制编辑pragma solidity ^0.8.0;

contract AirdropBitmap {
mapping(address => uint256) private claimedBitMap;

function isClaimed(address user, uint8 index) public view returns (bool) {
return (claimedBitMap[user] & (1 << index)) != 0;
}

function claim(address user, uint8 index) external {
require(index < 256, "Index out of bounds");
require(!isClaimed(user, index), "Already claimed");

claimedBitMap[user] |= (1 << index); // 设置索引位置为 1
}
}

结论

  • 少量布尔值(<8 个):紧凑存储(struct)是合理的。
  • 大量布尔值(>256 个):位图 显著节省存储和 Gas
  • 如果 SimpleDEX 使用 mapping(address => bool) 来存储状态,可以用位图优化 Gas 成本!