众所周知,智能合约一旦在以太坊网络部署后就无法修改代码内容。如果我们的代码写错或者需要修改,增加合约功能将会变得举步维艰。
于是保证合约的可升级性在部署合约之初就显得非常重要。
(文章内容仅供学习,有安全性风险,不可用于实际生产)
1.基本实现思想
合约毕竟是铁定不能修改的,但是我们可以将数据的存储和业务逻辑的执行通过两份合约分开来。接受用户指令并存储数据的我们叫代理合约,而写好业务逻辑的合约我们叫逻辑合约。这种分离保证了用户call的地址可以一直保持一个,同时我们也可以通过替换逻辑合约来做到合约升级
比如:一旦逻辑合约的逻辑有问题,我们只需要写过一份逻辑合约再部署上链,同时代理合约改一下指向的逻辑合约地址到新合约,合约的升级就迎刃而解了。
那代理合约是咋做到的呢?可以看看以下一个相对简单的代理合约代码
///这是代理合约
contract CounterProxy {
address public impl; // impl + 2
uint public counter;
//1.设置逻辑合约地址
constructor(address _impl) {
impl = _impl;
}
/2.更换逻辑合约的函数
function upgradeTo(address _impl) public {
impl = _impl;
}
/3.通过delegatecall调用逻辑合约的函数,在代理合约上进行状态的储存
function add(uint256 n) external {
bytes memory callData = abi.encodeWithSignature("add(uint256)", n);
(bool ok,) = address(impl).delegatecall(callData);
if(!ok) revert("Delegate call failed");
}
function get() external returns(uint256) {
bytes memory callData = abi.encodeWithSignature("get()");
(bool ok, bytes memory retVal) = address(impl).delegatecall(callData);
if(!ok) revert("Delegate call failed");
return abi.decode(retVal, (uint256));
}
}
//这是逻辑合约
contract Counter {
// address public impl;
uint public counter;
function add(uint256 i) public {
counter += 1;
}
function get() public view returns(uint) {
return counter;
}
}
1和2部分确定了逻辑合约的指向地址和更改逻辑合约的方法。逻辑合约中函数有bug我们就能通过更改impl来指向一个新合约。
而3的部分调用逻辑合约使用了delegatecall方法,此方法会使用逻辑合约的函数,并在调用者合约(代理合约)的储存中执行,调用者合约的存储状态会改变。
记得区分call方法,call方法调用目标合约的函数,并在目标合约的上下文中执行,但调用者合约的存储状态不会改变 |
abi.encodeWithSignature("add(uint256)", n) 则是在将函数签名和参数经过abi编码为字节码来作为调用的calldata传入给delegatecall。(ABI编码:以太坊链上交易与互动-CSDN博客)
2.事情没你想得这么简单
2.1 插槽冲突
把上面的代码编译部署后,直接测试代理合约get功能时你就会发现一个很神奇的现象,本来按逻辑合约get函数的话是获取counter的默认值,也就是0,但是你会发现最后出来的却是一大串uint256 (比如我这里uint256: 1172712777314684138911760979886105193870493588985)
为什么会这样?
记得我们用的delegatecall是具体干了什么吗?
delegatecall: 此方法会使用逻辑合约的函数,并在调用者合约(代理合约)的储存中执行,调用者合约的存储状态会改变 |
简而言之,你虽然拿了别人的函数,但是毕竟是在你自己的储存空间上运行的,逻辑合约的函数执行时,会去寻找逻辑合约上储存数据的插槽位置来运行函数,也就是get在逻辑合约找的是逻辑合约上 uint public counter的插槽(第一个插槽)作为参数。函数本身不会变化,它在代理合约上运行的时候也会寻第一个插槽位置的数据作为参数传出,也就是address public impl。
所以这个输出这么长就算插槽位置冲突问题导致的(插槽长啥样可以去evm.storage看看)
而这种插槽冲突就会引起一个非常恶性的bug。就是如果写入的指向合约地址address impl的槽位和逻辑合约调用的参数槽位一样,而且函数会改变这个参数的值,那么一旦用户通过这个函数修改过了address,那这个代理合约就会直接停摆。
所以EIP - 1967规定要将代理合约地址指向的化合约的插槽固定在一个非常远的地方,代码如下
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1);
2.2 代理合约写死了函数,那我要增加功能怎么办?
上面写法除了会出现插槽冲突的问题,更致命的是,代理合约写死了,如果逻辑合约有要加新函数,我怎么调的到?
所以上面直接定义函数的方法通常不会被用上,我们真正做合约会使用fallback()回调函数,当收到信息的合约中没有对应的函数时,也能通过调起逻辑合约中的函数完成任务。
而在实际运用中这种统一委托的方法也更加神奇,因为我们甚至连相关变量都可以不用在代理合约中定义。
library StorageSlot {
struct AddressSlot {
address value;
}
function getAddressSlot(
bytes32 slot
) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
contract CounterProxy {
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1);
constructor() {
}
function _delegate(address _implementation) internal virtual {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
// 代理到 Counter
function _fallback() private {
_delegate(_getImplementation());
}
fallback() external payable {
_fallback();
}
receive() external payable {
_fallback();
}
function _getImplementation() private view returns (address) {
return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
}
function _setImplementation(address _implementation) private {
require(_implementation.code.length > 0, "implementation is not contract");
StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
}
function upgradeTo(address _implementation) external {
_setImplementation(_implementation);
}
}
我们可以试着部署上面的更加强大的代理合约,这个合约中除了必要的合约插槽位置之外没有任何的变量定义。
部署后之后产生一个upgradeTo函数,这个函数就是我们指向逻辑合约的函数。
因为没有定义其他任何函数,所以在remix上默认的getter函数对应的按键也不再有,既然没有对应的函数,那我们怎么和代理合约交互呢?
如果仔细看,你会看见low level interactions的calldata那栏,没错这就是我们和在remix上合约交互的方式。那么这东西填什么呢?
这个其实就是我们在ABI中所学到的交易中传输的data,也就是经过ABI编译的4字节的函数选择器和对应的参数的拼接。它可能长以下的样子:
0x60fe47b10000000000000000000000000000000000000000000000000000000000000064
看到这里也就明白,其实交互方式就是通过发送交易并附上逻辑合约中函数对应abi编码的信息给代理合约调用fallback()就可以,和我们之前在以太坊交互中一节中讲的一样。
2.2.1 正经人谁用ABI编码啊
就算知道了是拿calldata交互,但是正经人谁用手写代码翻译成ABI编码数据测试啊,太麻烦了。
Remix其实也有办法提供解决这种问题。
按上面流程将逻辑合约和代理合约都部署后,我们再进行一次逻辑合约的部署,只不过,这次我们需要将At Address填入我们的代理合约的地址,之后再选则逻辑合约再进行一次部署,而新生成的部署合约将与之前部署的逻辑合约地址相同,但是它getter指向的合约将会是代理合约。
而此时如果使用新部署的合约操作加法,最后的储存都会在代理合约上进行,你可以加完数后,试着在这个合约上调用get函数,并复制操作get函数时remix报出的abi编码,输入进代理合约的low level interactions的calldata来验证这代理合约是否真的被改写了。
2.3 事情还没完--函数碰撞
上面我们写的代理合约有几个问题,首先就是upgradeTo这个函数所有权的问题,我们似乎可以定义一个owner来解决这个问题,但是一旦代理合约中出现了变量,就会面对之前的插槽问题,这样你就得这个owner放在另外一个很远的位置。
第二,我们代理合约中还是有一个函数upgradeTo()的,而我们知道,当我们调用这个函数时是发送函数选择器+参数的abi编码的,而函数选择器是4字节的。
问题就来了,如果你在写逻辑合约时,有一个逻辑合约中的函数的函数选择器编码结果和upgradeTo()恰好一样怎么办?那这可意味着你写的这个函数将永远不会调用,因为它会被识别为已有的upgradeTo(),而不会落到fallback()里去。
为了解决这个问题,目前通用的升级方案有两个
- 透明代理(Transparent Proxy)- ERC1967Proxy
- UUPS(universal upgradeable proxy standard)- ERC-1822
2.3.1 Transparent Proxy
为了解决上述问题,透明代理提供了一个思路,首先将owner的插槽和之前一样通过bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1);放在一个很远的位置,同时我们将upgradeTo包装在fallback中,只有识别了是owner的地址时,才会有改合约地址的操作。
pragma solidity ^0.8.0;
library StorageSlot {
struct AddressSlot {
address value;
}
function getAddressSlot(
bytes32 slot
) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
contract TransparentProxy {
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1);
bytes32 private constant ADMIN_SLOT =
bytes32(uint(keccak256("eip1967.proxy.admin")) - 1);
constructor() {
}
function _delegate(address _implementation) internal virtual {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
// revert(p, s) - end execution, revert state changes, return data mem[p…(p+s))
revert(0, returndatasize())
}
default {
// return(p, s) - end execution, return data mem[p…(p+s))
return(0, returndatasize())
}
}
}
// 代理到 Counter
function _fallback() private {
_delegate(_getImplementation());
}
/识别是否是管理员,不是的全部fallback///
fallback() external payable {
if(msg.sender != _getAdmin()) {
_fallback();
} else {
(address newImplementation, bytes memory data) = abi.decode(msg.data[4:], (address, bytes));
_setImplementation(newImplementation);
// if (data.length > 0) {
// newImplementation.delegatecall(data);
// }
}
}
receive() external payable {
_fallback();
}
function _getImplementation() private view returns (address) {
return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
}
function _getAdmin() private view returns (address) {
return StorageSlot.getAddressSlot(ADMIN_SLOT).value;
}
function _setImplementation(address _implementation) private {
require(_implementation.code.length > 0, "implementation is not contract");
StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
}
}
2.3.2 UUPS
上面的透明代理也有个缺点,那就是所有人都要经过一层判断,这会造成少量的gas费用的消耗,而且非常奢侈,因为管理员只有一个,而你要为一个人消耗这么多人的gas。
UUPS则提供了另外一个思路,其核心思想就是将代理合约的判断和合约升级拿掉,所有人都做fallback的操作,而upgrade的操作放在逻辑合约中去,在最初做一个owner来管理合约就好了。
pragma solidity ^0.8.0;
library StorageSlot {
struct AddressSlot {
address value;
}
function getAddressSlot(
bytes32 slot
) internal pure returns (AddressSlot storage r) {
assembly {
r.slot := slot
}
}
}
contract Counter {
uint private counter;
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1);
constructor(uint x) {
counter = x;
}
function init(uint x) public {
counter = x;
}
function add(uint256 i) public {
counter += 1;
}
function get() public view returns(uint) {
return counter;
}
function _setImplementation(address _implementation) private {
require(_implementation.code.length > 0, "implementation is not contract");
StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
}
// 我们在这里做函数来改,逻辑合约的owner将调用合约来更改Impl
function upgradeTo(address _implementation) external {
// if (msg.sender != admin) revert();
_setImplementation(_implementation);
}
}
contract UUPSProxy {
bytes32 private constant IMPLEMENTATION_SLOT =
bytes32(uint(keccak256("eip1967.proxy.implementation")) - 1);
constructor(address impl) {
_setImplementation(impl);
}
function _delegate(address _implementation) internal virtual {
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
// delegatecall returns 0 on error.
case 0 {
// revert(p, s) - end execution, revert state changes, return data mem[p…(p+s))
revert(0, returndatasize())
}
default {
// return(p, s) - end execution, return data mem[p…(p+s))
return(0, returndatasize())
}
}
}
// 代理到 Counter
function _fallback() private {
_delegate(_getImplementation());
}
fallback() external payable {
_fallback();
}
receive() external payable {
_fallback();
}
function _getImplementation() private view returns (address) {
return StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value;
}
function _setImplementation(address _implementation) private {
require(_implementation.code.length > 0, "implementation is not contract");
StorageSlot.getAddressSlot(IMPLEMENTATION_SLOT).value = _implementation;
}
}
Further Reading
用 OpenZeppelin 和 Foundry 创建和部署可升级的 ERC20 代币 | 登链社区 | 区块链技术社区
https://www.wtf.academy/docs/solidity-103/ProxyContract/
一文讲透可升级合约,并通过hardhat+openzeppelin开发生产环境可升级合约 | 登链社区 | 区块链技术社区