ABI 编码
和许多编程语言不同的是,传统的编程语言数据传输依赖序列化、反序列化,而以太坊智能合约的交互方式为 ABI
编码。
- abi.encode()
标准 abi
编码方式,固定类似会被按每个 32
字节的方式进行二进制编码,但打印出来通常是 16
进制的。(动态类型借助偏移量、长度)。故编码后的内容会有很多 0
。适用于合约之间传递参数、构造 calldata
。
abi.encode("hello", uint256(123))
// => 32字节长度 + 32字节数据 + padding ...
- abi.encodePacked()
属于
abi.encode()
的压缩,当想要节省空间,不与合约之间进行交互的时候可以使用。会将很多0
忽略。有可能出现hash
冲突的情况。
abi.encodePacked("hello", uint256(123))
// => 没有 padding,字符串直接拼 uint256 二进制
- abi.encodeWithSignature()
和
encode
类似,但第一个参数固定为函数的签名,其余参数为函数的参数。编码后的结果相当于在encode
的基础上,在前面加上了4
字节的函数选择器。可以使用编码后的数据调用其他合约。
abi.encodeWithSignature("transfer(address,uint256)", 0xabc..., 100)
- abi.encodeWithSelector()
与
abi.encodeWithSignature
功能类似,只不过第一个参数为函数选择器
,为函数签名
Keccak
哈希的前4
个字节。
bytes4 selector = bytes4(keccak256("transfer(address,uint256)"));
abi.encodeWithSelector(selector, 0xabc..., 100)
函数选择器
- calldata
calldata
可以直接进行函数调用,结构如下:
[ 4 bytes 函数选择器 ][ 编码后的参数(ABI编码) ]
一般是使用abi.encodeWithSignature("transfer(address,uint256)", to, 100)
进行编码后的数据。
- method id 、函数选择器、函数签名
这三个实为一个概念,即为函数签名经过 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;
}
}
1. 最原始的合约升级
上述例子为不可升级的代理模式,写法固定,无法进行合约的升级更新。我们可以基于上述的样例,改进一下,添加可升级的逻辑。
- 代理合约 A
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
// 逻辑合约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
。这两种代理模式我们将在下面介绍。
- 智能合约中,函数选择器(
2. 透明代理
透明代理的逻辑非常简单:管理员可能会因为“函数选择器冲突”,在调用逻辑合约的函数时,误调用代理合约的可升级函数。那么限制管理员的权限,不让他调用任何逻辑合约的函数,就能解决冲突:
- 管理员变为工具人,仅能调用代理合约的可升级函数对合约升级,不能通过回调函数调用逻辑合约。
-
其它用户不能调用可升级函数,但是可以调用逻辑合约的函数。
-
透明代理的实现非常简单,只需在
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)
; 这条语句,限制了管理员去调用普通函数。
- foundry 测试
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 调用:失败,因为透明代理中
admin
不允许调用普通函数
3. 通用可升级合约(UUPS)
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);
}
}
- LogicB 合约:添加
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;
}
}
- foundry 测试:
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
,升级合约成功。
- 和透明代理的区别:
- gas 费用:透明代理在
fallback
中每次调用都要鉴权,gas
消耗较高。 - 复杂度:透明代理实现简单,
UUPS
实现稍微麻烦些。 - 风险:
UUPS
虽然节省gas
,但也存在一个问题:如果升级的时候,在逻辑合约中忘记写upgrade()
函数,那这个合约后续会变成不可升级合约。
- gas 费用:透明代理在