在Solidity中,通过代理模式来升级智能合约是一种常见且有效的做法,它允许在不中断现有合约功能的情况下进行更新。这种模式的基本思路是将合约的状态和主要逻辑分离,使得可以在一个新的合约中部署更新的逻辑,然后通过一个代理合约来调用新的逻辑,从而达到升级的目的。
1. 初版
首先,假设有一个初始版本的智能合约(称为实现合约),包含状态变量和主要的业务逻辑。
代码语言:txt复制// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.2 <0.9.0;
// 初始版本的合约
contract MyContract {
uint public data;
function setData(uint _data) public {
data = _data;
}
function getData() public view returns (uint) {
return data;
}
}
2. 升级版本
然后,创建一个新的版本的合约,它包含新的逻辑或修复。
代码语言:txt复制// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.2 <0.9.0;
// 升级后的合约版本
contract MyContractV2 {
uint public data;
mapping(address => bool) public accessAllowed;
function setData(uint _data) public {
require(accessAllowed[msg.sender], "Access not allowed");
data = _data;
}
function getData() public view returns (uint) {
return data;
}
function grantAccess(address _addr) public {
accessAllowed[_addr] = true;
}
function revokeAccess(address _addr) public {
accessAllowed[_addr] = false;
}
}
3. 代理合约
创建一个代理合约,用于转发调用到实际的合约实现。代理合约通常保持与初始版本相同的接口,并持有一个指向当前实现版本的地址。
代码语言:txt复制// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.2 <0.9.0;
// 代理合约
contract MyContractProxy {
address public currentVersion;
address public owner;
constructor(address _currentVersion) {
currentVersion = _currentVersion;
owner = msg.sender;
}
// 转发所有调用到当前版本的合约
fallback() external payable {
address implementation = currentVersion;
require(implementation != address(0), "Contract implementation not set");
assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize())
let result := delegatecall(gas(), implementation, ptr, calldatasize(), 0, 0)
returndatacopy(ptr, 0, returndatasize())
switch result
case 0 { revert(ptr, returndatasize()) }
default { return(ptr, returndatasize()) }
}
}
// 更新合约实现版本
function upgrade(address newVersion) public {
require(msg.sender == owner, "Only the owner can upgrade");
currentVersion = newVersion;
}
}
在上面的合约中,我们在 fallback
函数中实现了代理合约的核心逻辑。它首先将传入的调用数据复制到内存中,然后使用 delegatecall
将调用转发到逻辑合约,并在当前合约的上下文中执行其代码。最后,根据 delegatecall
的结果,决定是回滚交易并返回错误数据,还是返回成功的数据。
let ptr := mload(0x40)
mload(0x40)
读取内存位置0x40
上的值,该位置通常被称为 "free memory pointer"(空闲内存指针),它指向当前空闲内存的开始位置。let ptr := mload(0x40)
将这个空闲内存地址存储在变量ptr
中,以供后续使用。
calldatacopy(ptr, 0, calldatasize())
calldatacopy
将调用数据(包括函数选择器和参数)从消息的输入数据复制到内存中。ptr
是内存的起始位置。0
是调用数据的起始位置。calldatasize()
返回调用数据的大小。- 这行指令的作用是将所有传入的调用数据复制到内存中,从
ptr
开始存储。
let result := delegatecall(gas(), implementation, ptr, calldatasize(), 0, 0)
delegatecall
是一个 EVM 操作码,用于在另一个合约的上下文中执行代码,同时保留当前合约的存储、msg.sender 和 msg.value。gas()
返回当前可用的剩余 gas。implementation
是逻辑合约的地址。ptr
是内存中存储调用数据的起始位置。calldatasize()
是调用数据的大小。0
是返回数据的存储位置(初始设置为 0)。0
是返回数据的大小(初始设置为 0)。- 这行指令的作用是执行逻辑合约的代码,并将执行结果存储在
result
中。
returndatacopy(ptr, 0, returndatasize())
returndatacopy
将返回数据从调用返回位置复制到内存中。ptr
是内存的起始位置。0
是返回数据的起始位置。returndatasize()
返回上一个调用(即delegatecall
)返回的数据大小。- 这行指令的作用是将
delegatecall
的返回数据复制到内存中,从ptr
开始存储。
switch result
switch
语句基于result
的值进行分支处理。result
是delegatecall
的返回值,如果调用成功则为 1,失败则为 0。
case 0 { revert(ptr, returndatasize()) }
- 如果
result
为 0,表示delegatecall
调用失败。 revert(ptr, returndatasize())
会回滚交易,并返回错误数据。ptr
是内存中错误数据的起始位置。returndatasize()
是错误数据的大小。
- 如果
default { return(ptr, returndatasize()) }
- 如果
result
为非 0,表示delegatecall
调用成功。 return(ptr, returndatasize())
会返回成功的数据。ptr
是内存中返回数据的起始位置。returndatasize()
是返回数据的大小。
- 如果
简单来说,这段汇编代码在代理合约的 fallback
函数中执行以下操作:
- 将传入的调用数据复制到内存。
- 使用
delegatecall
将调用转发到逻辑合约,并在当前合约的上下文中执行其代码。 - 根据
delegatecall
的结果,决定是回滚交易并返回错误数据,还是返回成功的数据。
这种模式确保了代理合约可以灵活地转发调用,并根据逻辑合约的实现来执行具体的业务逻辑。
4. 升级过程
- 首先部署初始版本的合约(
MyContract
)和代理合约(MyContractProxy
),将代理合约初始化为指向初始版本。 - 当需要升级时,部署新版本的合约(
MyContractV2
)。 - 调用代理合约的
upgrade
函数,将当前版本更新为新版本的合约地址。
5. 优势和注意事项
- 无中断更新: 使用代理模式,更新过程不会中断已有合约的使用。
- 灵活性: 可以在需要时随时更新合约逻辑,而无需改变合约地址。
- 安全性: 更新前的合约状态和余额不会丢失或重置。
- 成本: 代理模式可以降低升级过程的成本,避免重新部署合约带来的高昂费用。
需要注意的是,代理模式需要谨慎设计,确保新版本的合约与旧版本保持兼容性,以及更新过程的安全性和透明性。