智能合约中,ABI 编码是合约直之间数据交互的桥梁,承载了数据以及接口。而函数选择器则为调用的函数的标识,因其只有 4 个字节,故容易出现函数选择器的冲突。在合约升级中,我们有三种方式去实现升级的逻逻辑,并解决函数选择器冲突:https://learnblockchain.cn/shawn_shaw
和许多编程语言不同的是,传统的编程语言数据传输依赖序列化、反序列化,而以太坊智能合约的交互方式为 ABI
编码。
abi
编码方式,固定类似会被按每个 32
字节的方式进行二进制编码,但打印出来通常是 16
进制的。(动态类型借助偏移量、长度)。故编码后的内容会有很多 0
。适用于合约之间传递参数、构造 calldata
。abi.encode("hello", uint256(123))
// => 32字节长度 + 32字节数据 + padding ...
abi.encode()
的压缩,当想要节省空间,不与合约之间进行交互的时候可以使用。会将很多 0
忽略。有可能出现 hash
冲突的情况。abi.encodePacked("hello", uint256(123))
// => 没有 padding,字符串直接拼 uint256 二进制
encode
类似,但第一个参数固定为函数的签名,其余参数为函数的参数。编码后的结果相当于在 encode
的基础上,在前面加上了 4
字节的函数选择器。可以使用编码后的数据调用其他合约。abi.encodeWithSignature("transfer(address,uint256)", 0xabc..., 100)
abi.encodeWithSignature
功能类似,只不过第一个参数为函数选择器
,为函数签名
Keccak
哈希的前 4
个字节。bytes4 selector = bytes4(keccak256("transfer(address,uint256)"));
abi.encodeWithSelector(selector, 0xabc..., 100)
calldata
可以直接进行函数调用,结构如下:[ 4 bytes 函数选择器 ][ 编码后的参数(ABI编码) ]
一般是使用abi.encodeWithSignature("transfer(address,uint256)", to, 100)
进行编码后的数据。
keccak
hash
后的前 4
个字节。一般填充在 calldata
数据中的前 4
个字节。bytes4(keccak256("mint(address)"));
需要注意的是,对于 contract
、enum
、struct
分别对应的转化类型为 address
、uint8
、tuple
。
ETH
转到指定地址。ETH
到指定地址。(必须创建和自毁在同一笔交易中,也就是我们必须要用到另一个合约来进行控制这两步操作)ETH
转出去到指定地址,合约本身并不会被删除,仍能调用。selfdestruct(_addr);
在前面,我们讲过代理合约的原理,其最本质的区别就是使用了 delegate
委托调用的方式。使得合约的数据部分和逻辑部分分离。调用了逻辑部分的函数作用的改变会发生在数据部分(代理合约)上。
contract Proxy {
// 存储结构必须和 Logic 完全一致
uint public num;
function delegateSetNum(address logic, uint _num) public {
(bool success, ) = logic.delegatecall(
abi.encodeWithSignature("setNum(uint256)", _num)
);
require(success, "delegatecall failed");
}
}
contract Logic {
uint public num;
function setNum(uint _num) public {
num = _num;
}
}
上述例子为不可升级的代理模式,写法固定,无法进行合约的升级更新。我们可以基于上述的样例,改进一下,添加可升级的逻辑。
contract Proxy {
address public implementation; // 逻辑合约地址
address public admin; // admin地址
// 存储结构必须和 Logic 完全一致
uint public num;
// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// 利用 fallback() 委托调用
// 不存在的函数都会打到这里,然后 delegate 去调用逻辑合约
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
// 逻辑合约B
contract LogicB {
// 状态变量和 Proxy 合约一致,防止插槽冲突
address public implementation;
address public admin;
uint public num; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量
function foo() public{
num = 1;
}
// 获取 proxy 中的 num
function getFoo() public view returns(uint){
return num;
}
}
逻辑合约 C
// 逻辑合约C
contract LogicC {
// 状态变量和 Proxy 合约一致,防止插槽冲突
address public implementation;
address public admin;
uint public num; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,
function foo() public{
num = 2;
}
// 获取 proxy 中的 num
function getFoo() public view returns(uint){
return num;
}
}
foundry 测试
contract SimpleUpgrateTest is Test {
LogicB logicB;
LogicC logicC;
Proxy proxy;
function setUp() public {
logicB = new LogicB();
logicC = new LogicC();
proxy = new Proxy(address(logicB));
}
function testSimpleUpgrade() public {
address(proxy).call(abi.encodeWithSignature("foo()"));
// data 为 0x,因为 fallback() 无法返回data,但可以采用内联汇编魔法返回,这里暂且不谈
(bool success, bytes memory data) = address(proxy).call(abi.encodeWithSignature("getFoo()"));
assertEq(success,true,"success should eq true");
proxy.upgrade(address(logicC));
address(proxy).call(abi.encodeWithSignature("foo()"));
(bool success1, bytes memory data1) = address(proxy).call(abi.encodeWithSignature("getFoo()"));
assertEq(success1,true,"success should eq true");
}
}
原始代理合约存在的问题:
智能合约中,函数选择器(selector
)是函数签名的哈希的前4个字节。例如mint(address account)
的选择器为bytes4(keccak256("mint(address)"))
,也就是0x6a627842
。由于函数选择器仅有4个字节,范围很小,因此两个不同的函数可能会有相同的选择器。这种情况被称为“选择器冲突”。在这种情况下,EVM
无法通过函数选择器分辨用户调用哪个函数,因此该合约无法通过编译。
由于代理合约和逻辑合约是两个合约,就算他们之间存在“选择器冲突”也可以正常编译,这可能会导致很严重的安全事故。举个例子,如果逻辑合约的a
函数和代理合约的升级函数的选择器相同,那么管理人就会在调用a
函数的时候,将代理合约升级成一个黑洞合约,后果不堪设想。
目前,有两个可升级合约标准解决了这一问题:透明代理Transparent Proxy
和通用可升级代理UUPS
。这两种代理模式我们将在下面介绍。
透明代理的逻辑非常简单:管理员可能会因为“函数选择器冲突”,在调用逻辑合约的函数时,误调用代理合约的可升级函数。那么限制管理员的权限,不让他调用任何逻辑合约的函数,就能解决冲突:
管理员变为工具人,仅能调用代理合约的可升级函数对合约升级,不能通过回调函数调用逻辑合约。
其它用户不能调用可升级函数,但是可以调用逻辑合约的函数。
透明代理的实现非常简单,只需在 fallback()
函数上加入限制,不允许管理员调用即可。
以上面代理模式的代码为例,我们稍做改造:
Proxy 合约改造(基于上面原始合约升级):
// 利用 fallback() 委托调用
// 不存在的函数都会打到这里,然后 delegate 去调用逻辑合约
fallback() external payable {
require(msg.sender != admin); // 禁止管理员调用,防止管理员调用普通函数和升级函数出现选择器冲突,导致升级成黑洞
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
核心要点就是 fallback()
函数中的 require(msg.sender != admin)
; 这条语句,限制了管理员去调用普通函数。
contract TransparentUpgradeScript is Script {
function run() external {
// 读取两个私钥
uint256 admin = vm.envUint("PRIVATE_KEY_1");
uint256 EOA = vm.envUint("PRIVATE_KEY_2");
vm.startBroadcast(admin);
LogicB logicB = new LogicB();
LogicC logicC = new LogicC();
Proxy proxy = new Proxy(address(logicB));
vm.stopBroadcast();
vm.startBroadcast(EOA);
address(proxy).call(abi.encodeWithSignature("foo()"));
address(proxy).call(abi.encodeWithSignature("getFoo()"));
vm.stopBroadcast();
vm.startBroadcast(admin);
proxy.upgrade(address(logicC));
vm.stopBroadcast();
vm.startBroadcast(EOA);
address(proxy).call(abi.encodeWithSignature("foo()"));
address(proxy).call(abi.encodeWithSignature("getFoo()"));
vm.stopBroadcast();
}
}
注意:这里必须分开 admin
和 EOA
账号,admin
用来部署发起合约升级,EOA
账号用于调用普通函数。若采用 admin
账号来调用普通函数,会失败,达到了限制 admin
调用普通函数的目的。
admin
和 EOA
账号分开升级和普通函数调用。
admin
不允许调用普通函数UUPS
也是一种解决函数选择器冲突的方案。由于透明代理的方式,需要在 fallback()
函数上加上对管理员的权限校验,因此,透明代理的解决方案在调用上会额外耗费 gas
。而UUPS
也是一种解决函数选择器冲突的合约升级方案。
UUPS
的核心思想是:将升级函数放在逻辑合约中。这样,因为升级函数和普通函数都在一个合约中,哪怕出现函数选择器冲突,代码的编译器也能给我们检测到从而阻止我们进行编译。
我们依旧使用原始的合约升级代码举例:
Proxy 合约:删除 upgrade()
函数
contract Proxy {
address public implementation; // 逻辑合约地址
address public admin; // admin地址
// 存储结构必须和 Logic 完全一致
uint public num;
// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}
// 利用 fallback() 委托调用
// 不存在的函数都会打到这里,然后 delegate 去调用逻辑合约
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}
}
upgrade()
函数// 逻辑合约B
contract LogicB {
// 状态变量和 Proxy 合约一致,防止插槽冲突
address public implementation;
address public admin;
uint public num; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量
function foo() public{
num = 1;
}
// 获取 proxy 中的 num
function getFoo() public view returns(uint){
return num;
}
// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
LogicC 合约:添加 upgrade()
函数
// 逻辑合约C
contract LogicC {
// 状态变量和 Proxy 合约一致,防止插槽冲突
address public implementation;
address public admin;
uint public num; // 字符串,可以通过逻辑合约的函数改变
// 改变proxy中状态变量,
function foo() public{
num = 2;
}
// 获取 proxy 中的 num
function getFoo() public view returns(uint){
return num;
}
// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}
contract UUPSUpgrade is Script {
function run() external {
// 私钥
uint256 admin = vm.envUint("PRIVATE_KEY_1");
vm.startBroadcast(admin);
LogicB logicB = new LogicB();
LogicC logicC = new LogicC();
Proxy proxy = new Proxy(address(logicB));
address(proxy).call(abi.encodeWithSignature("foo()"));
address(proxy).call(abi.encodeWithSignature("getFoo()"));
bytes memory callBytes = abi.encodeWithSignature("upgrade(address)",address(logicC));
address(proxy).call(callBytes);
address(proxy).call(abi.encodeWithSignature("foo()"));
address(proxy).call(abi.encodeWithSignature("getFoo()"));
vm.stopBroadcast();
}
}
观察到值从 1
变成了 2
,升级合约成功。
fallback
中每次调用都要鉴权,gas
消耗较高。UUPS
实现稍微麻烦些。UUPS
虽然节省 gas
,但也存在一个问题:如果升级的时候,在逻辑合约中忘记写 upgrade()
函数,那这个合约后续会变成不可升级合约。如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!