构建可升级的合约:透明代理 VS UUPS代理

  • Louis
  • 更新于 2024-07-30 15:56
  • 阅读 1617

智能合约从技术角度实现了"codeislaw",在智能合约的世界里,代码本身就是法律规则的体现。这一理念的核心是,智能合约是自执行的协议,由编写好的代码直接控制,无需中介或第三方干预。

code is law:

大家都说,智能合约从技术角度实现了"code is law",在智能合约的世界里,代码本身就是法律规则的体现。这一理念的核心是,智能合约是自执行的协议,由编写好的代码直接控制,无需中介或第三方干预。一旦智能合约被部署并启动,它就会按照代码中的逻辑自动运行,任何参与者都必须遵循其中的规则。

是的,智能合约是不可改变的,一旦一个合约被部署,意味着你不能在这个地址调整合约的任何功能,你只能与它进行交互。为什么要这么做?试想一下,如果说有一天某个控制者突然对合约中的规则做出了有利于他们的规定,那么这个合约就不值得信赖了。这与传统的系统有着鲜明的区别。

为什么需要代理,代理可以做什么?

一句话总结:代理让合约升级成为事实上的标准,但是上面我们明明已经说了合约是不可以改变的,这不是前后矛盾吗?

其实,合约不可改变这个点是仍然正确的,但是代理可以解决这个问题。

尽管区块链的不可变性有很多好处,但在多个版本中推送bug修复和补丁是不能忽视的,而且非常需要修补bug和安全漏洞。代理模式解决了这个问题。让我们来看看代理如何工作的。

代理是如何工作的

在深入讨论之前,我们先说明一些背景术语:

  • 代理合约:一个作为代理的合约,将所有调用委托给它所代理的合约。在这里,它也会被称为存储层。
  • 实现合约:你想升级或修补的合约。这是代理合约被代理的合约。在这种情况下,它也是逻辑层

代理合约将实现合约地址存储为一个状态变量。与普通合约不同的是,用户实际上并不直接向实现合约发送调用。相反,所有的调用都要经过代理合约,这个代理合约将调用委托给实现合约,并把从实现合约收到的任何数据返回给调用者,或者对错误进行回退。

delegatecall
User ---------->  Proxy  -----------> Implementation
             (storage layer)          (logic layer)

这里需要注意的关键是,代理合约通过delegatecall函数调用实现合约。因此,实际上是由代理合约来存储状态变量,即它是存储层。这就像你只是从实现合约借用逻辑,并在代理合约的上下文中执行,并影响代理合约在存储中的状态变量。

举个例子,考虑一个简单的Box(实现)合约,以及BoxProxy(代理)合约。

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.13;

contract Box {
  uint256 private _value;

  function store(uint256 value) public {
    _value = value;
  }

  function retrieve() public view returns (uint256) {
    return _value;
  }
}

contract BoxProxy {
  function _delegate(address implementation) internal virtual {
    // delegating logic call to boxImpl...
  }

  function getImplementationAddress() public view returns (address) {
    // Returns the address of the implementation contract
  }

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

尽管Box定义了一个uint256状态变量_value,但实际上是BoxProxy合约存储了与_value相关的值。

委托相关的逻辑通常被放在代理合约的fallback函数中。

因此升级机制可以理解为:通过授权改变代理合约存储实现合约地址变量的,以指向新部署的、升级的实现合约。这样升级就完成了。代理合约现在将调用委托给这个新的实现合约。虽然那个旧的合约会永远存在。

upgrade call
Admin -----------> Proxy --x--> Implementation_v1
                     |
                      --------> Implementation_v2

很简单吧?但是有一些问题,比如代理和实现合约之间潜在的、由delegatecall引起Collisions of Solidity Storage Layouts

代理合约和实现合约之间的存储碰撞

我们不能简单地在代理合约中声明 address implementation,因为这会引起与实现合约的存储发生冲突,即实现合约中的多个变量在存储槽中有重叠。

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

实现合约中的var1的任何写入,实际上都会写入Proxy合约中的implementation(存储层)!

解决方案是选择一个伪随机槽,并将 implementation的地址写入该槽中。这个槽的位置应该有足够的随机性,这样,在实现合约里有一个变量在同一个槽里就可以忽略不计了。

|Proxy                   |Implementation |
|------------------------|---------------|
|    ..                  |address var1   |
|    ..                  |mapping var2   |
|    ..                  |uint256 var3   |
|    ..                  |    ..         |
|    ..                  |    ..         |
|address implementation  |    ..         | <-  随机槽

根据EIP-1967,一个这样的槽可以被计算为:

bytes32 private constant implementationPosition = bytes32(uint256(
  keccak256('eip1967.proxy.implementation')) - 1
));

每次实现合约的地址需要被访问或者修改时,都会读或者写这个槽。

不同实现合约之间的存储碰撞

务必需要注意,代理合约是存储层。正因为如此,当升级到一个新的实现合约时,如果一个新的状态变量被添加到实现合约中,它必须被附加到存储布局中。新的合约必须扩展存储布局而不是修改它。否则,可能会发生碰撞。

错误!

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

正确!✅

|ImplementationV1 |ImplementationV2|
|-----------------|----------------|
|address foo      |address foo     | 
|mapping bar      |mapping bar     | 
|                 |address baz     | <- 扩展
|                 |...             |

初始化构造函数的代码

同样,由于代理合约是存储层,任何初始化逻辑都应该在代理内部运行——比如给状态变量设置一些初始值。但是,你不能代理调用实现合约构造函数。因为构造函数代码只在部署期间运行一次,它不是运行时字节码的一部分。所以,代理合约没有办法直接访问构造函数字节码并在其上下文中执行它。

解决这个问题的方法(根据OpenZeppelin)是将构造函数代码转移到实现合约中的 initializer(初始化)函数。这就像一个普通的函数,但必须确保它只被调用一次。

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

contract MyContract is Initializable {
    // `initializer` modifier makes sure it runs only once
    function initialize(
        address arg1,
        uint256 arg2,
        bytes memory arg3
    ) public payable initializer {
        // "constructor" code...
    }
}

代理合约和实现合约之间的函数冲突

由于代理合约确实存在,也需要有自己的函数。比如说一个upgradeTo(address impl)函数。它应该用来决定是否修改实现合约的地址。如果实现合约有一个相同名称的函数,即upgradeTo(address someAddr),怎么办?

必须有一种机制来决定是否将调用委托给实现合约。其中一种方式(OpenZeppelin方式)是通过代理合约的管理员或所有者地址判断。如果管理员(即msg.sender==admin)正在对Proxy进行调用,它将不会委托调用,而是在Proxy本身执行该函数。因此,只有管理员地址可以调用代理合约的upgradeTo(address impl)来升级到新版本的实现合约。

考虑一个 Ownable的ERC20和Ownable的代理合约的例子(所有者是管理员),调用将是这样的:

msg.sender -> | proxy `owner` 调用   | 其他人调用
----------------|------------------------------------
`owner()`       | proxy.owner()       | erc20.owner()
`upgradeTo(..)` | proxy.upgradeTo(..) | reverts
`transfer(..)`  | reverts             | erc20.transfer(..)

所有在 其他栏中的调用都被委托给了实现合约。

透明代理 vs UUPS 代理

Transparent(透明代理)和UUPS 代理只是实现代理模式的不同方式,以支持实现合约的升级机制。实际上,这两种不同的模式之间并没有很大的区别,在这个意义上,它们使用相同的升级接口,同样是委托给实现合约。

区别在于,升级逻辑究竟在哪里, 是在代理合约还是在实现合约

透明代理:

在透明代理模式中(EIP-1967),升级逻辑驻留在代理合约中——意味着升级是由代理处理的。必须调用upgradeTo(address newImpl)这样的函数来升级到一个新的实现合约。然而,由于这个逻辑放在代理合约里,部署这类代理的成本很高。

透明代理还需要管理机制来决定是委托调用实现合约中的功能还是执行代理合约本身的功能,以Box为例:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.13;

contract Box {
  uint256 private _value;

  function store(uint256 value) public { /*..*/ }

  function retrieve() public view returns (uint256) { /*..*/ }
}

contract BoxProxy {
  function _delegate(address implementation) internal virtual { /*..*/ }

  function getImplementationAddress() public view returns (address) { /*..*/ }

  fallback() external { /*..*/ }

  // Upgrade logic in Proxy contract
  function upgradeTo(address newImpl) external {
    // Changes stored address of implementation of contract
    // at its slot in storage
  }
}

UUPS Proxy

UUPS模式首次在EIP1822提出。与透明模式不同,在UUPS中,升级逻辑是由实现合约本身处理的。实现合约包括升级逻辑的方法,以及通常的业务逻辑。你可以通过让它继承一个包括升级逻辑的通用标准接口来使任何实现合约符合UUPS标准,比如继承OpenZeppelin的UUPSUpgradeable接口:

强烈建议继承这个接口来实现合约。因为如果不能在新版本的实现中包含升级逻辑(非UUPS兼容),就升级到它了,将永远锁定升级机制!因此建议你使用防止这种情况发生的措施的库(如UUPSUpgradeable

参考资源

点赞 1
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
Louis
Louis
web3 developer,技术交流或者有工作机会可加VX: magicalLouis