Pre:
USDC v2: Upgrading a multi-billion dollar ERC-20 token 学习记录大部分机翻+自己进行demo测试
usdc升级前的状况:
综上,usdc需要安全地进行升级
可升级的智能合约:
从技术上讲,部署在以太坊上的智能合约是不可变的。虽然这个属性对于完全去信任的应用程序是必要的,但需要注意的是,一旦代码提交到区块链上,代码中的错误或安全缺陷就无法被纠正。
在以太坊上,代理模式可以用作此限制的解决方法。
代理合约模式的总体思路是让用户与代理合约交互,代理合约将所有函数调用转发到包含实际逻辑的执行合约。
执行合约可以被替换,这使得合约“可升级”。代理合约可以通过使用称为 DELEGATECALL
的特殊以太坊操作码来构建。此操作码允许合约从另一个合约借用和执行代码,同时保留调用合约的上下文,例如存储和调用者 ( msg.sender
)。使用这个操作码和一个回退函数
来捕获任意函数调用,代理合约可以将合约状态保存在其存储中,并且单独的执行合约可以包含逻辑。
代理合约包含一个存储执行合约地址的变量,回退函数将函数调用中继到该执行合约去。要升级合约,合约的管理员可以简单地部署一个新的执行合约并更新代理合约中的执行合约地址,使其指向新部署的合约。
乍一看,上述升级过程可能显得微不足道。但是,如果在设计替换执行合约时不特别注意,可能会发生严重的数据丢失和意外行为。有两个重要因素需要考虑:
状态变量在合约存储中的布局方式
合约状态变量存储在代理合约里而不是在执行合约里。
存储槽Storage Slots:
在以太坊中,智能合约的状态变量从位置零开始,按顺序排列在存储槽中。有一些复杂的规则 可以确定不同类型和大小的状态值的存储槽位置,但一般来说,这些存储槽是按照代码中声明变量的顺序分配的 。
让我们考虑以下示例:
由于Baz
按顺序从Foo
和Bar
继承,因此Baz
最终有 5 个状态变量,按以下顺序声明:alpha
、bravo
、charlie
、delta
和echo
。然后,五个变量会被分配到位置 0 到 4 的存储槽。
现在,如果我要更新代码并向合约Bar
添加一个名为foxtrot
的新状态变量,变量的顺序和相应的存储槽位置将会改变。
如上图所示,此更改会导致存储插槽错位。如果我用这个新合约替换执行合约,那么状态变量foxtrot
将位于位置 3,delta
位于位置 4,echo
位于位置 5。这会导致新变量foxtrot
在升级后,delta
具有echo
的值,它甚至不是相同的数据类型,并且echo
失去它的值。
在上面的示例中,新变量不是修改现有合约Bar
,而是作为Baz
继承的名为Qux
合约的新父代的一部分引入。不幸的是,这会导致存储插槽位置出现相同的错位。
此处避免存储槽错位的正确方法是在echo
之后在Baz
中引入新的状态变量,或者在继承自Baz
的新合约中引入。
意思就是要按顺序,只能在Baz
之后,不能在它之前
除了仔细枚举所有声明的状态变量并确保顺序不会改变之外,还有其他方法可以避免这个问题。一种简单的方法是专用一个合约来保存所有状态变量,并让所有其他合约继承它。另一种方法是使用映射来包装状态变量,以便字段的名称在存储槽的派生中发挥作用 。最后,还可以使用 EVM 操作码SLOAD
和SSTORE
直接指定存储槽。
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 升级的第一个任务是创建一个单元测试 ,以验证是否保留了原始存储插槽。
上表描述了USDC v1
存储中各种状态变量的布局方式。要读取特定插槽位置的存储,您可以使用web3.eth.getStorageAt (web3.js)
或provider.getStorageAt (ethers)
,它以十六进制格式返回存储插槽的内容,去掉前面的零。
测试单个存储槽:
testing-storage-slot-0.js
1 2 3 4 5 6 7 8 9 const data = await web3.eth .getStorageAt (contract.address , 0 );const ownerData = data.slice (-40 ).padStart (40 , "0" ); expect (ownerData).to .equal ( ownerAddress.slice (2 ).toLowerCase () );
测试共享存储槽:
小于 32 字节的多个相邻状态变量可以共享一个存储槽 ,从低位字节开始(右对齐)。例如,USDC 中的存储槽 1 和 8 包含地址和布尔值。
1 2 3 4 5 6 7 8 const data = await web3.eth .getStorageAt (contract.address , 1 );const pauserData = data.slice (-40 ); const pausedData = data.slice (-42 , -40 ); expect (pauserData).to .equal (pauserAddress.slice (2 ).toLowerCase ());expect (!!parseInt (pausedData, 16 )).to .equal (paused);
测试字符串:
最多 31 个字节长的字符串在存储槽中编码,文本存储在高位字节(左对齐)中,其长度 × 2 存储在低位字节中。
1 2 3 4 5 6 7 const data = await web3.eth .getStorageAt (contract.address , 4 );const len2 = parseInt (data.slice (-2 ), 16 ); 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) )。主存储插槽留空,不保存任何数据。
1 2 3 4 5 6 7 8 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 合约,对其进行初始化,运行各种测试以确保一切按预期工作,并在一切正常后自毁。这一切都在一个原子事务 中完成,如果检测到问题,它会回滚整个升级过程,就好像什么都没发生一样。换句话说,无论升级结果如何,停机时间为零。
交易确认:
传奇的结局有些虎头蛇尾:在交易提交后的几秒钟内,Etherscan中出现了一个绿色的复选标记,USDC 智能合约稳定运行着。升级完成,资金安全。
主要收获是,一小群工程师可以安全地升级价值 10 亿美元的全球金融服务,且停机时间为零。这在传统金融系统中是前所未有的,它是这项新技术真正强大的一个完美例子。
存储槽Demo测试:
自己demo测试 一下怎么列出和读取存储槽的内容
列出存储槽:
可使用插件hardhat-storage-layout
,具体使用方法见其文档。
测试单个存储槽:
测试totalSupply,在存储槽的位置2
测试脚本:
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 (); const totalSupplyDataBytes32 = await ethers.provider .getStorageAt (Token .address , 2 ); console .log ('totalSupplyDataBytes32: ' + totalSupplyDataBytes32); let totalSupplyDecode = await Token .interface .decodeFunctionResult ("totalSupply" , totalSupplyDataBytes32); console .log (totalSupplyDecode) const totalSupplyString = totalSupplyDecode[0 ].toString () console .log ('totalSupplyString: ' + totalSupplyString); const queryTotalSupply = await Token .totalSupply (); console .log ('queryTotalSupply: ' + queryTotalSupply); expect (queryTotalSupply).to .equal (totalSupplyString); });
测试结果:
1 2 3 4 5 6 totalSupplyDataBytes32: 0x00000000000000000000000000000000000000000000003635c9adc5dea00000 totalSupplyString: 1000000000000000000000 queryTotalSupply: 1000000000000000000000
测试一个共享存储槽:
编号5存储槽中存储了相邻状态变量:
1 2 3 4 address public ownerAddress; uint8 private _decimals = 18 ; bool public pauseable = false ; bool public isAdmin = true ;
小于 32 字节的多个相邻状态变量可以共享一个存储槽,从低位字节开始(右对齐)。
测试脚本:
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 (); const slotNum = 5 ; const storageData = await ethers.provider .getStorageAt (Token .address , slotNum); console .log ('slotNum: ' + slotNum + 'storageData: ' + storageData); const storageOwnerAddress = storageData.slice (-40 ); console .log ("storageOwnerAddress: " + storageOwnerAddress); const decimalsData = storageData.slice (-42 , -40 ); console .log ("decimalsData: " + decimalsData); const decimalsInt = parseInt (decimalsData, 16 ); console .log ("decimalsInt: " + decimalsInt); const pauseableData = storageData.slice (-44 , -42 ); console .log ("pauseableData: " + pauseableData); const pauseableBoolean = Boolean (parseInt (pauseableData, 16 )); console .log ("pauseableBoolean: " + pauseableBoolean); const isAdminData = storageData.slice (-46 , -44 ); 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); 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 ; 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 (); 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 ); console .log ('len2: ' + len2); const nameText = Buffer .from (storageData.slice (2 , 2 + len2), "hex" ).toString ("utf8" ); console .log ('nameText: ' + nameText); 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) )。主存储插槽留空,不保存任何数据。
举例示例图:
测试脚本:
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 (); 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); const tokenOwnerBalanceAtStorageFormat = '0x' + tokenOwnerBalanceAtStorage.slice (2 ).replace (/\b(0+)/gi , "" ) console .log (tokenOwnerBalanceAtStorageFormat) const tokenOwnerBalanceAtStorageInt = parseInt (tokenOwnerBalanceAtStorageFormat, 16 ); console .log ('tokenOwnerBalanceAtStorageInt: ' + tokenOwnerBalanceAtStorageInt); const tokenOwnerBalanceAtStorageSring = BigInt (tokenOwnerBalanceAtStorageInt).toString (); console .log ('tokenOwnerBalanceAtStorageSring: ' + tokenOwnerBalanceAtStorageSring); const ownerBalance = await Token .balanceOf (tokenOwner.address ); console .log ('ownerBalance: ' + ownerBalance); const ownerBalanceNumber = ownerBalance.toString (); console .log ('ownerBalanceNumber: ' + ownerBalanceNumber); 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. 合约状态变量存储在代理合约里而不是在执行合约里。
为了保证存储槽里的顺序不变,有几种方法:
在usdcV2升级过程中,为了验证是否保留了原始存储槽的顺序,创建了单元测试脚本:去读取、验证各类型的状态变量在存储槽中的值除了单元测试以外,还fork了主网在本地进行更多的手工测试。在真正升级时,准备了完备的升级脚本:保证升级操作在在一个原子事务中完成,如果检测到问题,它会回滚整个升级过程。最后,成功升级
Refs: