Solidity状态变量存储布局与地址冲突

这是”可升级智能合约:存储要点与挑战”系列的第二篇文章。 这一次我们将仔细研究Solidity合约的状态变量的存储步距以及使用delegatecall时可能 发生的地址/槽位冲突问题,并分析一个存在地址冲突问题的合约的示例,最终给出相应的 解决方案。

用自己熟悉的语言学习 以太坊开发Java | Php | Python | .Net / C# | Golang | Node.JS | Flutter / Dart

1、Solidity合约状态变量的存储布局

让我们回忆下 EVM 存储模型是什么样,以及Solidity如何将其用于存储基本类型变量、数组和映射。

以太坊智能合约的存储(Storage)是一个uint256到uint256的映射。Uint256为32 字节:此固定大小值 在以太坊上下文中称为插槽(Slot)。这个存储模型类似于计算机的虚拟内存(RAM),但地址宽度为 256 位(与标准 32 和 64 位不同),每个值大小为 32 字节而不是 1 个字节。256 位宽地址足以容纳 众所周知的Solidity技巧:任何 256 位的哈希都可以用作地址,我们稍后会重温这一点。这种数据存储 方法相当奢侈,与适用于 WebAssembly 的方法不同,但其有效性不属于本文的范围。

在标准计算机程序执行过程中,应控制内存的分配,以便不同的变量和数据结构不会冲突并损坏彼此 的数据。通常所谓的内存分配器可以处理该任务。分配器提供有API(例如malloc、free、new、delete等)。 此外,记录通常”紧凑地”存储,不会在地址空间中随意安排数据,这些也是分配者的责任。Solidity不具有 存储控制分配器,任务的处理方式也不同。智能合约将状态变量的值存储在插槽中,从插槽 0 开始递增。基本固定大小值 类型占用一个插槽。此外,多个变量有时可以打包到一个插槽中,在使用时拆包。

当存储 数组(Array) 时,Solidity会将数组元素记录到一个插槽中(让我们将其称为”槽首”),成员本身则位于头槽号的keccak256哈希值这个地址。 这与C++和Java中使用的动态数组存储机制类似,这时数组数据结构位于主结构所指的单独内存位置。 唯一的区别是,Solidity不会在任何地方保留这个指针。这是可行的,因为我们可以写入任何存储位置而 不需要提前分配内存 - 它完全属于我们,默认情况下以零值进行初始化。

例如,在以下代码中调用allocate()函数后:

1
2
3
4
5
6
7
8
9
10
11
uint256 foo;
uint256 bar;
uint256[] items;

function allocate() public {
require(0 == items.length);

items.length = 2;
items[0] = 12;
items[1] = 42;
}

存储看起来是这样:

solidity slot conflict

我们可以通过执行如下的js代码查看素组元素的地址:

1
2
3
4
web3.sha3(
'0x00000000000000000000000000000000000000000000000000000000000000002',
{encoding: 'hex'}
)

映射(mapping) 具有类似的机制,只有每个值是单独计算地址的,因为地址的哈希计算还涉及相应的键。 你可能会想到潜在的地址冲突,不过这种冲突的可能性极小因此可以忽视。合约继承不能与当前情况迭加。 对于使用继承的合约,状态变量的顺序由 合约的C3 线性顺序决定,从最基本的合约开始。上述规则 称为”存储中状态变量布局”(下面简称为”存储布局”),详细信息可以在这里查阅。 修改这些规则将破坏与之前版本合约的兼容性,因此这一规则在未来也不太可能发生变化。

现在,我们了解了代理合约的操作以及合约的存储布局,让我们看看可能会出什么问题。

2、Solidity合约状态变量的潜在地址冲突问题

在代理合约的存储中记录数据的特定代码版本具有自己的变量和存储布局,继承的合约也具有其自己的存储布局, 并且必须能够处理根据以前的存储布局形成的数据。这是麻烦的一部分。不要忘记还有代理合约本身代码,也有一个 存储布局与当前的智能合约版本同时存在。因此,代理代码的存储布局和当前智能合约版本不应交互,即它们 不能将相同的插槽用于不同的数据。

一种解决方案是存储代理合约的数据时避开通常的Solidity存储布局机制,使用 EVM的sstoresload指令来读取 或写入数据到伪随机插槽中,例如,使用由keccak256(my.proxy.version)返回的值作为插槽号。 这样我们就可以避免槽位的冲突。

另一种方法是使用相同的存储布局和高级别的数据争议解决,就像这个github仓库中的实现。

让我们看看如果错过了两个”竞争的”存储布局会发生什么。

3、实例:AkropolisToken合约

看看AkropolisToken合约3ad8eaa6f2849dceb125c8c614d5d61e90d465a2提交代码。我们负责该公司的token销售合约的安全审计。

我们注意到,TokenProxy和 当前的AkropolisToken合约都有各自的状态变量,AkropolisToken的变量在基础合约中定义。 我们预计TokenProxy和AkropolisToken会发生地址冲突问题,因此未来会有大麻烦。然而,一个快速测试让我们困惑。

如果我们在调用pause函数后更改paused变量(在从Pausable合约继承的AkropolisToken合约中),则TokenProxy状态变量不会改变。 TokenProxy函数调用执行正确 -TokenProxy合约的调用获取功能在决定代理合合约中定义的token后发生。由于 代理没有pause功能,因此通过UpgradeabilityProxy合约的默认函数进行调用,该合约又在AkropolisToken中 执行的delegatecall包含了pause功能。为什么还没有冲突?

我们必须集中注意力,并根据前面指出的状态可变位置规则,绘制TokenProxy和AkropolisToken插槽的详细图示。 我们必须找出基本合约的正确顺序,并记住有可能几个状态变量会封装在一个插槽中。你可以在Remix 中测试一下:提交一些交易,调试它并跟踪存储的更改。

solidity slot conflict

注意插槽3和4。与TokenProxy合约中一样,插槽3用于存储pendingOwner变量。但是,pendingOwner属于地址类型, 只有 20 字节宽,即它不会占用整个插槽。因此,只需要1位的paused和locked布尔标志也可以打包到插槽中, 这反过来又解释了paused和name没有冲突的原因。由于插槽 4 是whitelist映射的槽首并没有使用,因此没有 发生name和whitelist的冲突。

两个合约几乎避免了地址冲突, 但我们仍然可以在第 5槽跟踪到冲突。为了说明这一观点,我们写了 一个测试,这个测试在42行就会失败 - decimals的值不再等于18,虽然按照TokenProxy的合约代码,这个值应该是不变的。

最简单的办法就是禁用在此提交中已执行的插槽 5 。

4、结论

使用delegatecall等底层指令需要深入了解Solidity存储布局。我们简单地回顾了这个问题,提供了一个可能存在 问题的例子,并提出了若干解决办法。


原文链接:Collisions of Solidity Storage Layouts

汇智网翻译整理,转载请标明出处