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
})
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。