Solidity:使用 Ethers.js 的 Solidity 存储变量

语言: CN / TW / HK

以太坊和智能合约状态

以太坊虚拟机(EVM)上的数据使用Modified Merkle Patricia Trie数据结构进行组织。区块链上的每个区块引用4个树:[全局]状态树、存储树、交易树和接收树。状态树包含EOA(外部拥有的帐户)数据作为地址到ETH余额的映射,

而智能合约数据存储在指向状态树的存储树中。

存储树中的智能合约数据表示合约的持久状态,可以通过更新全局状态的交易进行更改。在一个Solidity的智能合约中,动态变量被存在持久化的存储中。内存中初始化的任何变量都是临时的,将在执行下一次外部函数调用之前被删除。此外,无法修改的常量变量不使用存储空间,因此,使用更少的gas。

智能合约存储布局

以太坊虚拟机(EVM)中的智能合约都有自己的永久存储空间,该存储空间在键-值对的映射中包含32字节的插槽(键和值都是32字节)。

32 字节的固定大小变量

固定大小的32字节变量,如字符串、uint256和int256,会按照它们在智能合约中列出的顺序分配一个单独的存储槽。在StorageLayoutOne合约中,常量变量hello没有存储槽,因为它不能被修改。变量numOne、goodbye和num分别使用存储槽0x0、0x1和0x2。

contract StorageLayoutOne {  string constant hello = "hello world"; // no storage
uint256 numOne = 1; // slot 0x0
string goodbye = "goodbye world"; // slot 0x1


int256 num; // slot 0x2}

固定大小变量< 32字节

如果可能,小于32字节的固定大小的变量将被字节打包到单个存储槽中。在StorageLayoutTwo合约中,变量lock、byteX、bytesY和bytesZ都将被打包到slot 0x0(1+1+4+16 = 22字节)中。下一个变量bytesA存储在0x1槽中,因为它不能放入前一个变量中。最后,变量bytesB和bytesC被打包到槽0x2中。

contract StorageLayoutTwo {  bool lock; // slot 0x0
byte byteX; // slot 0x0
bytes4 bytesY; // slot 0x0
bytes16 bytesZ; // slot 0x0 bytes28 bytesA; // slot 0x1 bytes16 bytesB; // slot 0x2


bytes16 bytesC; // slot 0x2}

需要注意的是,EVM 在 32 字节上运行,因此使用小于 32 字节的变量可能会由于额外的转换操作而导致更高的 gas 成本。但是,字节打包允许EVM编译器在同一个存储槽内组合对变量的多个读和写操作,来抵消这一点。因此,以最有效的方式将小于32字节的变量组合在一起,以降低总体gas成本是很重要的。

动态大小的变量

使用 keccak-256 散列算法将可能超过 32 字节的动态大小的变量(例如动态数组和映射)散列到抗碰撞存储位置,该算法伪随机地选择 2²⁵⁶ 存储槽范围内的位置。如果你想知道,2²⁵⁶ =

115792089237316195423570985008687907853269984665640564039457584007913129639936

由于这种膨胀的存储空间(可用的槽比已知宇宙中的恒星更多),EVM可以在不分配存储的情况下分配存储位置,因为每个键分配与任何其他键之间的距离都是光年之远。EVM不跟踪未分配的插槽,查询1只会返回0。

动态数组从由槽的散列确定的存储位置开始。后续数组项位于前一项的相邻位置。当数组项小于等于16字节时,应用字节打包规则。

与数组相反,映射通过首先将映射键与存储槽连接,然后将其散列到惟一的槽来分散数据。

在StorageLayoutThree合约中,arrayOfNums, 槽0x0(填充到32字节)被散列以查找第一项arrayOfNums[0]的存储位置。对于userBalances,用户的地址与槽0x1连接,并散列以提供值的位置。

contract StorageLayoutThree {   uint[] public arrayOfNums; // slot 0x0 => keccak256(0x0)  mapping(address => uint256) public userBalances;


// slot 0x1 => keccak256(key + 0x1)}

此外,映射通常嵌套在其他映射中,并可能包含结构。同样,数组可以嵌套在其他数组中,也可以在数组中嵌套映射。在处理嵌套数据结构时,可以使用嵌套的keccak-256哈希查找数据位置。本文后面的编码教程提供了这样的示例。

非存储变量

常量、枚举、结构定义、事件和用户定义的错误不使用存储空间。

Ether.js 代码教程

Ethers-JavaScript 库提供了许多有用的工具来与以太坊区块链上的智能合约进行交互,包括直接访问存储变量的实用工具,下面将通过代码示例进行解释。完整的代码可以在这个GitHub存储库中找到,其中包括一个智能合约示例和单元测试文件。

要安装Ether.js,在项目根目录的终端输入以下命令:

npm install ethers

定义可重用的 Ethers.js 常量

下面是添加到JavaScript文件顶部的一些常量定义,它们将有助于编写更清晰的代码:

const { ethers } = require('hardhat');
require('dotenv').config();


// ethers methods
const utils = ethers.utils;
const BigNumber = ethers.BigNumber;
const MaxUint256 = ethers.constants.MaxUint256

字符串

一个32字节的存储槽最多可以容纳32个字符的字符串,因此,如果你访问的字符串超过32个字符,就需要从多个连续的槽读取数据。对于小于32个字符的字符串,使用getShortStr函数,大于32个字符的字符串,使用getLongStr函数。

async function getShortStr(slot, contractAddress) {
const paddedSlot = utils.hexZeroPad(slot, 32);
const storageLocation = await ethers.provider.getStorageAt(contractAddress, paddedSlot);
const storageValue = BigNumber.from(storageLocation);


const stringData = utils.toUtf8String(
storageValue.and(MaxUint256.sub(255)).toHexString()
);
return stringData.replace(/\x00/g, '');
}
async function getLongStr(slot, contractAddress) {
const paddedSlot = utils.hexZeroPad(slot, 32);
const storageReference = await ethers.provider.getStorageAt(contractAddress, paddedSlot);


const baseSlot = utils.keccak256(paddedSlot);
const sLength = BigNumber.from(storageReference).shr(1).toNumber();
const totalSlots = Math.ceil(sLength / 32);


let storageLocation = BigNumber.from(baseSlot).toHexString();
let str = "";


for (let i=1; i <= totalSlots; i++) {
const stringDataPerSlot = await ethers.provider.getStorageAt(contractAddress, storageLocation);
str = str.concat(utils.toUtf8String(stringDataPerSlot));
storageLocation = BigNumber.from(baseSlot).add(i).toHexString();
}
return str.replace(/\x00/g, '');
}

数字

256位数字占用了整个存储槽,因此它们不需要任何位移,并且应该用作默认整数类型,除非字节打包是有利的。

async function getUint256(slot, contractAddress) {
const paddedSlot = utils.hexZeroPad(slot, 32);
const storageLocation = await ethers.provider.getStorageAt(contractAddress, paddedSlot);
const storageValue = BigNumber.from(storageLocation);
return storageValue;
}

getUint256函数将在下面的映射函数中使用。

映射

与只需要存储槽和合约地址参数的字符串和数字不同,映射需要一个附加键参数。对键和存储槽进行散列运算,以查找对应于该键的值的位置。

例如,[EOA] 地址到 [uint256] 余额的映射将采用以下参数:存储槽、合约地址和 EOA 地址。插槽被 2 分片以删除“0x”,因为键已经包含表示十六进制数的“0x”。

async function getMappingItem(slot, contractAddress, key) {
const paddedSlot = utils.hexZeroPad(slot, 32);
const paddedKey = utils.hexZeroPad(key, 32);
const itemSlot = utils.keccak256(paddedKey + paddedSlot.slice(2));
return await getUint256(itemSlot, contractAddress);
}

映射到结构

初始化的结构存储数据类似于数组存储数据:适用时具有传染性和字节打包。因此,在找到slot + key的哈希值之后,必须确定类型,因为struct通常保存各种类型的数据。对于这个函数,我将type限制为字符串、字节或数字。最后,我们需要在struct中选择一个属性。项目编号将对应于结构属性的顺序。

注意,此函数不处理字节打包结构,但假定结构属性占用完整的存储槽。

async function getMappingStruct(slot, contractAddress, key, item, type) {
const paddedSlot = utils.hexZeroPad(slot, 32);
const paddedKey = utils.hexZeroPad(key, 32);
const itemSlot1 = utils.keccak256(paddedKey + paddedSlot.slice(2));
const itemSlot = BigNumber.from(itemSlot1).add(item).toHexString();


switch (type) {
case "string":
return await getShortStr(itemSlot, contractAddress);
case "bytes":
return getBytePackedVar(itemSlot, contractAddress, 0, 32);
case "number":
return getUint256(itemSlot, contractAddress);
}
}

映射到结构中的嵌套映射

最后一个映射函数处理映射中的结构内部的映射。与前面的示例比较,“type”已被替换为“nestedKey”。我们不需要类型,因为映射将一种类型映射到另一种类型,这意味着最终的值将是一种类型。在这种情况下,最终值被假设为Uint256。

新参数nestedKey引用另一个映射中的结构中映射的键。

async function getNestedMappingStruct(slot, contractAddress, key, item, nestedKey) {
const paddedSlot = utils.hexZeroPad(slot, 32);
const paddedKey = utils.hexZeroPad(key, 32);
const itemSlot1 = utils.keccak256(paddedKey + paddedSlot.slice(2));
const itemSlot = BigNumber.from(itemSlot1).add(item).toHexString();
const paddednestedKey = utils.hexZeroPad(nestedKey, 32);
const itemNestedSlot = utils.keccak256(paddednestedKey + itemSlot.slice(2));


return getUint256(itemNestedSlot, contractAddress);
}

字节打包槽

当将小于32字节的连续变量打包到单个插槽中时,就会发生字节打包。最好的做法是在智能合约的顶部以最有效的字节打包方式定义所有小于32字节的变量,以最大化存储空间并节省gas。

在编写Solidity智能合约时,只要你能做基本的数学运算,就很容易使用字节包。但是,访问打包字节的变量有点棘手,因为它需要在存储槽中进行位移动来找到特定变量。

JavaScript很难处理较大的数字,这就是为什么当字节大小超过6字节时,会递归调用此函数,并返回完整变量的连接结果。

async function getBytePackedVar(slot, contractAddress, byteShift, byteSize) {
const paddedSlot = utils.hexZeroPad(slot, 32);
const storageLocation = await ethers.provider.getStorageAt(contractAddress, paddedSlot);
let result = "";
let altByteSize = 0;
let altByteShift = 0;
let check = false;


if (byteSize <= 6) {
return BigNumber.from(storageLocation).shr(byteShift * 4).mask(byteSize * 4 * 2).toNumber().toString(16);
} else {
altByteSize = byteSize - 6;
altByteShift = byteShift + 12;
check = true;
result += await getBytePackedVar(slot, contractAddress, altByteShift, altByteSize);
}


if (check) {
result += await getBytePackedVar(slot, contractAddress, byteShift, 6);
}
return result;
}

getBytePackedVar函数将在下面的动态数组函数中使用。

动态数组

数组被散列到所有项目都位于连续位置的存储位置。一旦定位到数组的头部,就可以通过槽位或位移位找到后续项。如果项目是 16 字节或更少,存储槽将被字节打包。

async function getArrayItem(slot, contractAddress, item, byteSize) {
const hashedSlot = utils.keccak256(utils.hexZeroPad(slot, 32));
const itemsPerSlot = 32 / byteSize;
let itemPos = item;


for (let s=1; s<item; s++) {
if (item >= itemsPerSlot) {
itemPos - itemsPerSlot;
}
}

let byteShift = (itemPos / itemsPerSlot) * 64;
while (byteShift >= 64) {
byteShift -= 64;
}
const hashedSlotByItem = BigNumber.from(hashedSlot).add(Math.floor(item / itemsPerSlot)).toHexString();


return getBytePackedVar(hashedSlotByItem, contractAddress, byteShift, byteSize);
}

结论

了解 Solidity 智能合约存储对于编写高效、安全和数据优化的代码非常重要。Ethers.js 提供了许多有用的方法,可用于访问智能合约持久状态中的存储变量。在我们自己部署的智能合约上使用或修改上面提供的代码示例将帮助我们能够更好地理解EVM的存储级别。

Source:https://betterprogramming.pub/solidity-storage-variables-with-ethers-js-ca3c7e2c2a64

关于

ChinaDeFi   - ChinaDeFi.com 是一个研究驱动的DeFi创新组织,同时我们也是区块链开发团队。每天从全球超过500个优质信息源的近900篇内容中,寻找思考更具深度、梳理更为系统的内容,以最快的速度同步到中国市场提供决策辅助材料。

Layer 2道友   - 欢迎对Layer 2感兴趣的区块链技术爱好者、研究分析人与Gavin(微信: chinadefi)联系,共同探讨Layer 2带来的落地机遇。敬请关注我们的微信公众号   “去中心化金融社区”