moon

moon

Build for builders on blockchain
github
twitter

編寫可升級的智能合約

智能合約一旦部署,就是不可改變的。所以一旦你的合約代碼出現了bug 或者去添加一些新功能時,也不能去做出修改,轉而只能去:

  • 重新部署一個新的合約
  • 手動將數據從舊合約遷移到新合約
  • 更新所有與舊合約互動的合約,包括使用新合約的地址等
  • 通知並說服社區使用新合約地址

既然升級合約如此麻煩,那麼就需要一個統一的解決方案。可以讓我們去改變合約代碼,同時保留數據。

代理模式#

代理模式將合約數據和邏輯分開,分別保存在不同合約中:

  • 代理合約:與用戶進行互動的智能合約,保存著數據。它是一個EIP1967 標準代理合約。
  • 實現合約:代理合約所代理的合約,提供功能和邏輯。

代理合約將實現合約的地址存儲為一個狀態變量。用戶並不直接向實現合約發送調用。相反,所有的調用都要經過代理合約,經過代理將調用委託給實現合約,並把從實現合約收到的任何數據返回給調用者,或者對錯誤進行回退。

proxy-upgrade.png

代理合約通過 delegatecall 函數去調用實現合約。基於delegatecall 的特性,實現合約的狀態變量是由代理合約進行存儲的。

如下所示:

contract Robo {
  uint public count;

  function setCount(uint _count) public {
    count = _count;
  }
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {
    // 代理方法: delegatecall 執行實現合約方法
  }

  function _implementation() internal view virtual returns (address) {
    // 返回實現合約 Robo 的地址
  }

  fallback() external {
    _delegate(_implementation());
  }
}

當需要調用 Robo 合約的 setCount 方法時,代理模式中我們並不與該合約互動,而是通過代理合約進行調用。即調用 RoboProxysetCount 方法,但 RoboProxy 並沒有 setCount 方法。轉而會去執行其 fallback 函數 (調用不存在的合約方法時會觸發 fallback 函數)。fallback 函數內部會去執行 _delegate 方法。

_delegate 方法也就是真正的代理方法,其內部會通過 delegatecall 執行 Robo 合約的 setCount 方法。

基於 delegatecall 的特性,即使 Robo 合約定義了一個變量 count,其值也不會由 Robo合約存儲。而是由 RoboProxy 合約存儲該值。

到這裡你可能會困惑,RoboProxy 並沒有定義變量 count,它是如何存儲的呢。這實際上涉及到了 EVM 的存儲佈局。

其實每個在 EVM 中運行的智能合約的狀態變量的值都在鏈上永久地存儲著,這些值就存儲在存儲槽中。每一個合約,都有 22562^{256} 個存儲槽,每個存儲槽的大小是 32 個字節。但這麼大的存儲槽並不是全部存在的,而是用到時才會真正的佔據空間。

----------------------------------
|               0                |     # slot 0
----------------------------------
|               1                |     # slot 1
----------------------------------
|               2                |     # slot 2
----------------------------------
|              ...               |     # ...
----------------------------------
|              ...               |     # 每個插槽 32 字節
----------------------------------
|              ...               |     # ...
----------------------------------
|            2^256-1             |     # slot 2^256-1

所以當執行代理方法的時候,就會將count變量的值存儲的代理合約的存儲槽中。

正是由於這種特殊的存儲結構,所以很容易出現一些潛在的存儲碰撞問題。

存儲碰撞#

代理合約和實現合約之間的存儲碰撞#

|Proxy                   |Implementation |
|------------------------|---------------|
|address implementation  |address var1   | <- 存儲碰撞
|                        |mapping var2   |
|                        |uint256 var3   |
|                        |...            |

由於變量 var1implementation 都是合約的第一個變量,也就意味著值都存儲在第一個存儲槽 slot 0中。而且具有相同的類型,意味著佔用相同的存儲空間。當通過代理方法 delegatecallvar1 賦值時,實際上會將值寫入到代理合約的存儲空間上,寫入的位置正好是 implementation 變量所佔據的空間。所以 implementation 變量的值就是所設置的 var1的值。

為了解決代理合約和實現合約之間的存儲碰撞問題,使用隨機存儲槽,也就是在巨大的存儲空間中,隨機選擇一個存儲槽用來存儲 implementation 變量。

|Proxy                   |Implementation |
|------------------------|---------------|
|    ..                  |address var1   |
|    ..                  |mapping var2   |
|    ..                  |uint256 var3   |
|    ..                  |    ..         |
|    ..                  |    ..         |
|address implementation  |    ..         | <-  隨機槽

根據EIP-1967,存儲實現合約地址的存儲槽的位置在:

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));
// 值為 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

每次實現地址需要被訪問或修改時,都会讀寫這個存儲槽。

不同版本的實現合約之間的存儲碰撞#

當升級到一個新的實現合約時,如果一個新的狀態變量被添加到實現合約中,應該被追加到存儲佈局中。而不是隨意的插入到原先的存儲佈局中。

|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo      |address baz     | <- 存儲碰撞
|mapping bar      |address foo     | 
|                 |mapping bar     |
|                 |...             |

由於baz的插入,導致了原先 foo 的值錯亂。

正確做法是不改變原先的變量位置而是去追加變量。

|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo      |address foo     | 
|mapping bar      |mapping bar     | 
|                 |address baz     | <- 追加
|                 |...             |

合約的初始化#

由於代理合約存儲著所有的數據,所以數據初始化的代碼也需要被執行。但初始化代碼往往是寫在實現合約的構造函數裡的,並且構造函數只在部署的時候執行一次,這也就導致代理合約沒法執行初始化的代碼,數據沒有得到初始化。

OpenZeppelin 的解決辦法是將初始化的代碼由實現合約的構造函數轉移到 initialize 函數中

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract RoboToken is Initializable {
    // `initializer` modifier 確保該方法中只執行一次
    function initialize(
        address arg1,
        uint256 arg2,
        bytes memory arg3
    ) public payable initializer {
        // 初始化代碼...
    }
}

之後由代理合約執行 initialize 函數

代理方法實現#

代理合約中執行實現合約中的方法是通過 delegatecall 執行的。

_implementation.delegatecall(abi.encodeWithSignature("setValue()", 10));

或者

_implementation.delegatecall(abi.encodeWithSelector(Robo.setValue.selector, 10));

然而這兩種方式都需要知道實現合約具體的方法名,沒法做到通用。

所以就有了內聯匯編這種調用方式,實現如下

function _delegate(address implementation) internal virtual {
  assembly {
    // 將 calldata 拷貝到內存裡 
    // calldata的前4個字節是函數selector, 緊接著的是函數參數。通過函數selector 就能匹配到要調用的函數
    calldatacopy(0, 0, calldatasize())

    // delegatecall 調用 implementation 的方法
    let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0)

    // 拷貝返回數據
    returndatacopy(0, 0, returndatasize())
    
    // 返回 0 表示出錯
    switch result
    case 0 {
      revert(0, returndatasize())
    }
    default {
      return(0, returndatasize())
    }
  }
}

代理合約的實現用到了一些內聯匯編函數

  • calldatacopy(t, f, s): 將 msg.data 從位置 f 複製 calldatasize() 長度的數據到內存位置 t
  • delegatecall(g, a, in ,insize, out, outsize):調用地址 a,輸入數據為內存位置[in...in+insize]間的數據,並將結果輸出到內存 [out...out+outsize] 位置上
  • returndatacopy(t, f, s):將輸出數據從位置 f 複製 returndatasize() 長度的到內存位置 t
  • revert(p, s):終止執行並回滾,返回內存位置 [p...p+s] 間的數據
  • return(p, s):正常返回結果,返回內存位置 [p...p+s] 間的數據

透明代理和 UUPS 代理#

透明代理 ( Transparent) 和 UUPS 代理只是代理模式的不同實現方式,本質上並沒有很大的區別,它們使用相同的升級接口,同樣是委託給實現合約。

透明代理#

在透明代理模式中(EIP-1967),升級是由代理合約處理的。本質就是將代理合約中的實現合約地址變量變更為新的實現合約地址。例如調用upgradeTo(address newImpl)這樣的函數來升級到一個新的實現合約。然而,由於這個邏輯放在代理合約裡,部署這類代理的成本很高。

contract Robo {
  uint public value;
  function setValue(uint _value) public {}
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {}

  function getImplementationAddress() public view returns (address) {}
  fallback() external {}

  upgradeTo(address newImpl) external {
     // 獲取 implementation 所在的存儲槽並賦值
  }
}

UUPS 代理#

UUPS 模式首次在EIP1822提出。與透明模式不同的是,在 UUPS 中,升級邏輯是由實現合約本身處理的。實現合約包括升級邏輯的方法,以及通常的業務邏輯。你可以通過讓它繼承一個包括升級邏輯的通用標準接口來使任何實現合約符合 UUPS 標準,比如繼承 OpenZeppelin 的UUPSUpgradeable接口:

contract Robo is UUPSUpgradeable {
  uint private value;

  function setValue(uint _value) public {}

  upgradeTo(address newImpl) external {
  	// 代理合約中通過 delegatecall 調用 upgradeTo 方法, 並將 newImpl 的值保存到代理合約中
  }
}

contract RoboProxy {
  function _delegate(address implementation) internal virtual {  }
  function getImplementationAddress() public view returns (address) {  }
  fallback() external {  }
}

使用 OpenZeppelin 編寫可升級的智能合約#

當在 hardhat 中使用 OpenZeppelin 去編寫可升級的智能合約時,可以節省很大的工作量。我們不需要關心代理合約如何去實現,不需要關心如何去執行初始化方法等,所要做的就是專注寫好我們的實現合約的邏輯。

初始化#

使用 hardhat 初始化項目之後安裝 openzeppelin 相關包

$ yarn add @openzeppelin/contracts @openzeppelin/contracts-upgradeable @openzeppelin/hardhat-upgrades

配置 hardhat 配置文件

import { HardhatUserConfig } from 'hardhat/config'
import '@nomicfoundation/hardhat-toolbox'
import '@openzeppelin/hardhat-upgrades'

const config: HardhatUserConfig = {
  solidity: '0.8.15'
}

export default config

編寫合約#

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract Robo is Initializable {
  uint public value;
  
  // 初始化方法
  function initialize(uint _value) public initializer {
    value = _value;
  }

  function increaseValue() external {
    ++value;
  }
}

部署腳本#

使用 @openzeppelin/hardhat-upgrades 庫去部署合約。

import { ethers, upgrades } from 'hardhat'

// yarn hardhat run scripts/deploy_robo.ts --network localhost
async function main () {
  const Robo = await ethers.getContractFactory('Robo')
  
  // 部署合約, 並調用初始化方法
  const robo = await upgrades.deployProxy(Robo, [10], {
    initializer: 'initialize'
  })
  
  // 代理合約地址
  const proxyAddress = robo.address
  // 實現合約地址
  const implementationAddress = await upgrades.erc1967.getImplementationAddress(robo.address)
  // proxyAdmin 合約地址
  const adminAddress = await upgrades.erc1967.getAdminAddress(robo.address)

  console.log(`proxyAddress: ${proxyAddress}`)
  console.log(`implementationAddress: ${implementationAddress}`)
  console.log(`adminAddress: ${adminAddress}`)
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})

本地終端運行

$ yarn hardhat node

再另一終端運行下面命令,用於在本地網絡部署合約

$ yarn hardhat run scripts/deploy.ts --network localhost

實際上部署的合約有三個

  • 代理合約
  • 實現合約
  • ProxyAdmin 合約

ProxyAdmin 合約是用來管理代理合約的,包括了升級合約,轉移合約所有權。

升級合約的步驟就是

  • 部署一個新的實現合約,
  • 調用 ProxyAdmin 合約中升級相關的方法,設置新的實現合約地址。

以上通過 upgrades.deployProxy 部署的合約,默認情況下是使用的透明代理模式。如果你要想使用 UUPS 代理模式,需要顯示的指定。

// 部署合約, 並調用初始化方法
const robo = await upgrades.deployProxy(Robo, [10], {
  initializer: 'initialize',
  kind: 'uups'
})

同時合約也要去繼承 UUPSUpgradeable

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract Robo is Initializable, UUPSUpgradeable {
  uint public value;

  function initialize(uint _value) public initializer {
    value = _value;
  }

  function increaseValue() external {
    ++value;
  }
  
  function _authorizeUpgrade(address) internal override {}
}

編譯並部署 UUPS 代理模式的合約時,實際只會部署兩個合約

  • 代理合約
  • 實現合約

此時的升級合約的步驟就是

  • 部署一個新的實現合約,
  • 調用代理合約中的 upgradeTo 方法,設置新的實現合約地址。

升級合約#

當實現好新版本的合約時,並不需要像第一次那樣調用 deployProxy 去部署,只需要調用 upgradeProxy

async function main () {
  // 第一次部署時 代理合約的地址
  const proxyAddress = '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0'

  const RoboV2 = await ethers.getContractFactory('RoboV2')
  // upgradeProxy 升級合約
  await upgrades.upgradeProxy(proxyAddress, RoboV2)
}

main().catch((error) => {
  console.error(error)
  process.exitCode = 1
})
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。