0%

StudyRecord-USDCV2_Upgrading_a_multi-billion_dollar_ERC-20token

Pre:

USDC v2: Upgrading a multi-billion dollar ERC-20 token 学习记录大部分机翻+自己进行demo测试

usdc升级前的状况:

  • 市值很大

  • 是defi中被广泛使用的稳定币

  • erc20代币最初在2018年部署在eth链上,运行了两年

  • 有个问题:为了使用usdc,你还需要 ETH 来支付交易费用

综上,usdc需要安全地进行升级

可升级的智能合约:

从技术上讲,部署在以太坊上的智能合约是不可变的。虽然这个属性对于完全去信任的应用程序是必要的,但需要注意的是,一旦代码提交到区块链上,代码中的错误或安全缺陷就无法被纠正。

在以太坊上,代理模式可以用作此限制的解决方法。

代理合约模式的总体思路是让用户与代理合约交互,代理合约将所有函数调用转发到包含实际逻辑的执行合约。

执行合约可以被替换,这使得合约“可升级”。代理合约可以通过使用称为 DELEGATECALL 的特殊以太坊操作码来构建。此操作码允许合约从另一个合约借用和执行代码,同时保留调用合约的上下文,例如存储和调用者 ( msg.sender )。使用这个操作码和一个回退函数来捕获任意函数调用,代理合约可以将合约状态保存在其存储中,并且单独的执行合约可以包含逻辑。

20220629203321

代理合约包含一个存储执行合约地址的变量,回退函数将函数调用中继到该执行合约去。要升级合约,合约的管理员可以简单地部署一个新的执行合约并更新代理合约中的执行合约地址,使其指向新部署的合约。

20220629203508

乍一看,上述升级过程可能显得微不足道。但是,如果在设计替换执行合约时不特别注意,可能会发生严重的数据丢失和意外行为。有两个重要因素需要考虑:

  1. 状态变量在合约存储中的布局方式

  2. 合约状态变量存储在代理合约里而不是在执行合约里。

存储槽Storage Slots:

在以太坊中,智能合约的状态变量从位置零开始,按顺序排列在存储槽中。有一些复杂的规则可以确定不同类型和大小的状态值的存储槽位置,但一般来说,这些存储槽是按照代码中声明变量的顺序分配的

让我们考虑以下示例:

  • 合约Foo有两个状态变量,称为alphabravo

  • 合约Bar有一个状态变量charlie

  • 合约Baz有两个状态变量deltaecho

  • Baz是要部署在区块链上的合约。

20220629203902

由于Baz按顺序从FooBar继承,因此Baz最终有 5 个状态变量,按以下顺序声明:alphabravocharliedeltaecho。然后,五个变量会被分配到位置 0 到 4 的存储槽。

现在,如果我要更新代码并向合约Bar添加一个名为foxtrot的新状态变量,变量的顺序和相应的存储槽位置将会改变。

20220629204157

如上图所示,此更改会导致存储插槽错位。如果我用这个新合约替换执行合约,那么状态变量foxtrot将位于位置 3,delta位于位置 4,echo位于位置 5。这会导致新变量foxtrot在升级后,delta 具有echo的值,它甚至不是相同的数据类型,并且echo失去它的值。

20220630171029

在上面的示例中,新变量不是修改现有合约Bar ,而是作为Baz继承的名为Qux合约的新父代的一部分引入。不幸的是,这会导致存储插槽位置出现相同的错位。

此处避免存储槽错位的正确方法是在echo之后在Baz中引入新的状态变量,或者在继承自Baz的新合约中引入。

意思就是要按顺序,只能在Baz之后,不能在它之前

除了仔细枚举所有声明的状态变量并确保顺序不会改变之外,还有其他方法可以避免这个问题。一种简单的方法是专用一个合约来保存所有状态变量,并让所有其他合约继承它。另一种方法是使用映射来包装状态变量,以便字段的名称在存储槽的派生中发挥作用。最后,还可以使用 EVM 操作码SLOADSSTORE直接指定存储槽。

sload-sstore-example.sol

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
contract Foo {
bytes32 private constant NUM_SLOT = keccak256("Foo.num");

function num() public view returns (uint256 val) {
bytes32 slot = NUM_SLOT;
assembly {
val := sload(slot) // 读取
}
}

function setNum(uint256 val) public {
bytes32 slot = NUM_SLOT;
assembly {
sstore(slot, val) // 修改
}
}
}

没有单一的最佳解决方案,因为每种方法都有缺点,例如合约规模增加、gas 成本增加或代码更复杂。

如果您正在开始一个新项目,我建议您查看较新的设计模式,例如EIP-2535 Diamond 标准,这些模式在创建时考虑了可升级性和可组合性

测试存储槽:

在 USDC v2 的情况下,存储槽的意外更改可能导致超过 10 亿美元的资金损失。这无疑会对我们的用户对协议的信任造成无法弥补的损害。因此,开发 v2 升级的第一个任务是创建一个单元测试,以验证是否保留了原始存储插槽。

20220629204827

上表描述了USDC v1存储中各种状态变量的布局方式。要读取特定插槽位置的存储,您可以使用web3.eth.getStorageAt (web3.js)provider.getStorageAt (ethers),它以十六进制格式返回存储插槽的内容,去掉前面的零。

测试单个存储槽:

testing-storage-slot-0.js

1
2
3
4
5
6
7
8
9
// 这段测试用例,试着从存储槽中读取第一个变量owner address
// Storage slot 0 contains "owner" (address)
const data = await web3.eth.getStorageAt(contract.address, 0);
// Take the last 20 bytes, left-pad with zeros if needed
const ownerData = data.slice(-40).padStart(40, "0");

expect(ownerData).to.equal(
ownerAddress.slice(2).toLowerCase() // Remove "0x"
);

测试共享存储槽:

小于 32 字节的多个相邻状态变量可以共享一个存储槽,从低位字节开始(右对齐)。例如,USDC 中的存储槽 1 和 8 包含地址和布尔值。

1
2
3
4
5
6
7
8
// 这段测试用例,试着从共享一个存储槽中的两个布尔值pauser和paused
// Storage slot 1 contains "pauser" (bool) and "paused" (address)
const data = await web3.eth.getStorageAt(contract.address, 1);
const pauserData = data.slice(-40); // Take the last 20 bytes
const pausedData = data.slice(-42, -40); // Take 1 byte before that

expect(pauserData).to.equal(pauserAddress.slice(2).toLowerCase());
expect(!!parseInt(pausedData, 16)).to.equal(paused); // Convert to boolean

测试字符串:

最多 31 个字节长的字符串在存储槽中编码,文本存储在高位字节(左对齐)中,其长度 × 2 存储在低位字节中。

1
2
3
4
5
6
7
// Storage slot 4 contains "name" (string)
const data = await web3.eth.getStorageAt(contract.address, 4);
const len2 = parseInt(data.slice(-2), 16); // Last 1 byte = length * 2
// Read the text (skip "0x")
const text = Buffer.from(data.slice(2, 2 + len2), "hex").toString("utf8");

expect(text).to.equal(name);

测试mapping:

从合约存储中读取映射有点棘手。由于映射没有预定义的大小,因此映射中每个值的槽位置通过执行键 ( k ) 的 Keccak-256 散列与映射的主存储槽位置 ( p ) ( keccak256( k . p) )。主存储插槽留空,不保存任何数据。

20220630130540

1
2
3
4
5
6
7
8
// Storage slot 9 contains "balances" (mapping(address => uint256))
// Derive slot position for the key k
const k = holderAddr.slice(2).toLowerCase().padStart(64, "0");
const p = "9".padStart(64, "0");
const valueSlotPos = web3.utils.keccak256("0x" + k + p);
const data = await web3.eth.getStorageAt(contract.address, valueSlotPos);

expect(new BN(data.slice(2), 16).eq(expectedBalance)).to.be.true;

强烈建议所有可升级的智能合约项目都包括一个存储槽测试,因为它让开发人员有信心对代码库进行大量更改,而不会造成意外数据丢失的风险。

在“产品”中进行测试:

单元测试有助于捕捉代码中的潜在错误,USDC v2 拥有 100% 的测试覆盖率。但是,单元测试并不能完全复制生产环境,手动测试仍然很有价值。

介绍如何使用Ganache启动以太坊主网的本地分叉进行测试,也就是本地模拟了真实环境,还能继续使用uniswap等应用程序的前端进行测试。

升级合约:

如果升级后发现问题,技术上可以通过将执行合约设置回原始地址来回滚

但是,升级失败导致的任何停机都可能对用户造成严重的经济损失,并且能够恢复还取决于失败的升级没有破坏原始合约。

解决我们担忧的方法当然是更多的代码:升级合约

升级 USDC 合约,对其进行初始化,运行各种测试以确保一切按预期工作,并在一切正常后自毁。这一切都在一个原子事务中完成,如果检测到问题,它会回滚整个升级过程,就好像什么都没发生一样。换句话说,无论升级结果如何,停机时间为零。

20220630132957

交易确认:

传奇的结局有些虎头蛇尾:在交易提交后的几秒钟内,Etherscan中出现了一个绿色的复选标记,USDC 智能合约稳定运行着。升级完成,资金安全。

主要收获是,一小群工程师可以安全地升级价值 10 亿美元的全球金融服务,且停机时间为零。这在传统金融系统中是前所未有的,它是这项新技术真正强大的一个完美例子。

存储槽Demo测试:

自己demo测试一下怎么列出和读取存储槽的内容

列出存储槽:

可使用插件hardhat-storage-layout
,具体使用方法见其文档。
20220630095108

测试单个存储槽:

测试totalSupply,在存储槽的位置2

20220630172149

测试脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
it("test single storage slot", async function () {
const {Token, users, tokenOwner} = await setup();

// Returns the Bytes32 value of the position pos at address addr
// 1. 从存储槽中取值
const totalSupplyDataBytes32 = await ethers.provider.getStorageAt(Token.address, 2);
console.log('totalSupplyDataBytes32: ' + totalSupplyDataBytes32);
// 2. decode存储槽中的值
let totalSupplyDecode = await Token.interface.decodeFunctionResult("totalSupply", totalSupplyDataBytes32);
console.log(totalSupplyDecode)
// [ BigNumber { _hex: '0x3635c9adc5dea00000', _isBigNumber: true } ]
const totalSupplyString = totalSupplyDecode[0].toString()
console.log('totalSupplyString: ' + totalSupplyString);

const queryTotalSupply = await Token.totalSupply();
console.log('queryTotalSupply: ' + queryTotalSupply);
// 3. 与从合约查询出来的值进行比较
expect(queryTotalSupply).to.equal(totalSupplyString);

});

测试结果:

1
2
3
4
5
6
# 从存储槽中取值
totalSupplyDataBytes32: 0x00000000000000000000000000000000000000000000003635c9adc5dea00000
# decode存储槽中的值
totalSupplyString: 1000000000000000000000
# 查询合约totalSupply
queryTotalSupply: 1000000000000000000000

测试一个共享存储槽:

20220630104910

编号5存储槽中存储了相邻状态变量:

1
2
3
4
address public ownerAddress;
uint8 private _decimals = 18;
bool public pauseable = false;
bool public isAdmin = true;

小于 32 字节的多个相邻状态变量可以共享一个存储槽,从低位字节开始(右对齐)。

20220630113434

测试脚本:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
it("test shared storage slot", async function () {
const {Token, users, tokenOwner} = await setup();

// Returns the Bytes32 value of the position pos at address addr
// 1. 从存储槽中查询
const slotNum = 5;
const storageData = await ethers.provider.getStorageAt(Token.address, slotNum);
console.log('slotNum: ' + slotNum + 'storageData: ' + storageData);
// 2.按偏移量取值并格式化
// 右对齐,从右边开始往左取值
const storageOwnerAddress = storageData.slice(-40); // Take the last 20 bytes
console.log("storageOwnerAddress: " + storageOwnerAddress);

const decimalsData = storageData.slice(-42, -40); // Take 1 byte before that
console.log("decimalsData: " + decimalsData);
const decimalsInt = parseInt(decimalsData, 16);
console.log("decimalsInt: " + decimalsInt);

const pauseableData = storageData.slice(-44, -42); // Take 1 byte before that
console.log("pauseableData: " + pauseableData);
const pauseableBoolean = Boolean(parseInt(pauseableData, 16));
console.log("pauseableBoolean: " + pauseableBoolean);

const isAdminData = storageData.slice(-46, -44); // Take 1 byte before that
console.log("isAdminData: " + isAdminData);
const isAdminDataBoolean = Boolean(parseInt(isAdminData, 16));
console.log("isAdminDataBoolean: " + isAdminDataBoolean);

const ownerAddress = await Token.ownerAddress();
console.log("Query Token address: " + ownerAddress);

const queryDecimal = await Token.decimals();
console.log("queryDecimal: " + queryDecimal);

const queryPauseable = await Token.pauseable();
console.log("queryPauseable: " + queryPauseable);

const queryIsAdmin = await Token.isAdmin();
console.log("queryIsAdmin: " + queryIsAdmin);
// 3. 与从合约查询出来的值进行比较
expect(storageOwnerAddress).to.equal(ownerAddress.slice(2).toLowerCase());
expect(queryDecimal).to.equal(decimalsInt);
expect(queryPauseable).to.equal(pauseableBoolean);
expect(queryIsAdmin).to.equal(isAdminDataBoolean);
});

测试结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
slotNum: 5 storageData: 0x000000000000000000010012f39fd6e51aad88f6f4ce6ab8827279cfffb92266
storageOwnerAddress: f39fd6e51aad88f6f4ce6ab8827279cfffb92266
decimalsData: 12
decimalsInt: 18

pauseableData: 00
pauseableBoolean: false

isAdminData: 01
isAdminDataBoolean: true

Query Token address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
queryDecimal: 18
queryPauseable: false
queryIsAdmin: true
test shared storage slot (1357ms)

为了测试方便才把两个布尔值可进行设置为public

1
2
bool public pauseable = false;
bool public isAdmin = true;

如果可见性改成状态变量的默认可见性:internal

1
2
bool pauseable = false; // 状态变量的默认可见性:internal
bool isAdmin = true;

这样就无法从外部获取到这两个状态变量,但仍然可以从存储槽中读取到这两个状态变量的数据。

测试字符串:

最多 31 个字节长的字符串在存储槽中编码,文本存储在高位字节(左对齐)中,其长度 × 2 存储在低位字节中。

测试脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it("test string storage slot", async function () {
const {Token, users, tokenOwner} = await setup();

// 1. 从存储槽中位置3读取_name
const slotNum = 3;
const storageData = await ethers.provider.getStorageAt(Token.address, slotNum);
console.log('slotNum: ' + slotNum + ' storageData: ' + storageData);

const len2 = parseInt(storageData.slice(-2), 16); // Last 1 byte = length * 2
console.log('len2: ' + len2);

// 2.按偏移量取值并格式化
// Read the text (skip "0x")
const nameText = Buffer.from(storageData.slice(2, 2 + len2), "hex").toString("utf8");
console.log('nameText: ' + nameText);

// 3. 与从合约查询出来的值进行比较
const queryName = await Token.name();
console.log("queryName: " + queryName);
expect(nameText).to.equal(queryName);
});

测试结果:

1
2
3
4
5
6
7
8
slotNum: 3 storageData: 0x4a546f6b656e000000000000000000000000000000000000000000000000000c
len2: 12
nameText: JToken
queryName: JToken
test string storage slot (1284ms)
1 passing (1s)

✨ Done in 5.39s.

测试mapping:

由于映射没有预定义的大小,因此映射中每个值的槽位置通过执行键 ( k ) 的 Keccak-256 散列与映射的主存储槽位置 ( p ) ( keccak256( k . p) )。主存储插槽留空,不保存任何数据。

举例示例图:
20220630130540

测试脚本:

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
30
31
32
33
34
35
36
37
38
39
40
41
42
it.only("test mapping storage slot", async function () {
const {Token, users, tokenOwner} = await setup();

// Storage slot 0 contains "balances" (mapping(address => uint256))
// Derive slot position for the key k
// 1.从存储槽直接读取用户tokenOwner的余额
const k = tokenOwner.address.slice(2).toLowerCase().padStart(64, "0");
console.log('k: ' + k);
const p = "0".padStart(64, "0");
console.log('p: ' + p);
const valueSlotPos = ethers.utils.keccak256("0x" + k + p);
console.log('valueSlotPos: ' + valueSlotPos);

const tokenOwnerBalanceAtStorage = await ethers.provider.getStorageAt(Token.address, valueSlotPos);
console.log('tokenOwnerBalanceAtStorage: ' + tokenOwnerBalanceAtStorage);

// 2.对取出的存储槽的值进行类型转换
// 0x00000000000000000000000000000000000000000000003635c9adc5dea00000
// 去掉0x 去掉前面的0然后 加上0x
const tokenOwnerBalanceAtStorageFormat = '0x' + tokenOwnerBalanceAtStorage.slice(2).replace(/\b(0+)/gi, "")
console.log(tokenOwnerBalanceAtStorageFormat)

const tokenOwnerBalanceAtStorageInt = parseInt(tokenOwnerBalanceAtStorageFormat, 16);
console.log('tokenOwnerBalanceAtStorageInt: ' + tokenOwnerBalanceAtStorageInt);
// console.log(typeof tokenOwnerBalanceAtStorageInt); // number

const tokenOwnerBalanceAtStorageSring = BigInt(tokenOwnerBalanceAtStorageInt).toString();
console.log('tokenOwnerBalanceAtStorageSring: ' + tokenOwnerBalanceAtStorageSring);

// 3.通过合约查询tokenOwner余额
const ownerBalance = await Token.balanceOf(tokenOwner.address);
console.log('ownerBalance: ' + ownerBalance);
// console.log(typeof ownerBalance);
// const check = await ethers.BigNumber.isBigNumber(ownerBalance);
// console.log(check); // is bignumber
const ownerBalanceNumber = ownerBalance.toString();
console.log('ownerBalanceNumber: ' + ownerBalanceNumber);
// console.log("ownerBalance Readable: " + ethers.utils.formatUnits(ownerBalance, 18));

// 4.比对
expect(ownerBalanceNumber).to.equal(tokenOwnerBalanceAtStorageSring);
});

测试结果:

1
2
3
4
5
6
7
8
9
k: 000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266
p: 0000000000000000000000000000000000000000000000000000000000000000
valueSlotPos: 0x723077b8a1b173adc35e5f0e7e3662fd1208212cb629f9c128551ea7168da722
tokenOwnerBalanceAtStorage: 0x00000000000000000000000000000000000000000000003635c9adc5dea00000
0x3635c9adc5dea00000
tokenOwnerBalanceAtStorageInt: 1e+21
tokenOwnerBalanceAtStorageSring: 1000000000000000000000
ownerBalance: 1000000000000000000000
ownerBalanceNumber: 1000000000000000000000

Summary:

介绍了usdcV2的现况,需要安全地进行升级。虽然一般情况下智能合约一旦发布就不可修改,但可以通过代理合约的方式来进行升级。代理合约升级的原理:代理合约使用fallback()函数来捕获用户任意的函数调用,通过delegatecall传递函数调用给执行合约

在升级过程中,为了防止数据丢失,要注意的点:

1. 状态变量在合约存储中的布局方式 :从位置零开始,按顺序排列在存储槽中
2. 合约状态变量存储在代理合约里而不是在执行合约里。

为了保证存储槽里的顺序不变,有几种方法:

  • 仔细枚举所有声明的状态变量并确保顺序不会改变

  • 专用一个合约来保存所有状态变量

  • 使用映射来包装状态变量

  • 使用 EVM 操作码SLOADSSTORE直接指定存储槽。

  • 可以考虑较新的设计模式,例如EIP-2535 Diamond 标准

在usdcV2升级过程中,为了验证是否保留了原始存储槽的顺序,创建了单元测试脚本:去读取、验证各类型的状态变量在存储槽中的值除了单元测试以外,还fork了主网在本地进行更多的手工测试。在真正升级时,准备了完备的升级脚本:保证升级操作在在一个原子事务中完成,如果检测到问题,它会回滚整个升级过程。最后,成功升级

Refs: