代理合约
1. 代理模式
solidity合约部署到链上之后,代码是不可变的。
这一特性存在了一个严重的缺点:就算合约中存在bug,也不能修改或者升级,只能部署新合约。但是新合约的地址和旧合约的地址不一样,而且合约的数据也需要花费大量的gas进行迁移。
为了解决这一问题,从而引入了
代理模式
这一概念。
代理模式将合约数据和逻辑分开,分别保存在不同的合约中。以上图为例,数据(状态变量)存储在代理合约中,而逻辑(函数)保存在另一个逻辑合约中。逻辑合约(Proxy)通过delegatecall
,将函数调用全权委托给逻辑合约(Implementation)执行,再把最终的结果返回给调用者(Caller)。
2. 代理合约
它由OpenZeppelin的Proxy合约简化而来。它有三个部分:代理合约Proxy
,逻辑合约Logic
,和一个调用示例Caller
。它的逻辑并不复杂:
- 首先部署逻辑合约
Logic
。 - 创建代理合约
Proxy
,状态变量implementation
记录Logic
合约地址。 Proxy
合约利用回调函数fallback
,将所有调用委托给Logic
合约- 最后部署调用示例
Caller
合约,调用Proxy
合约。 - 注意:
Logic
合约和Proxy
合约的状态变量存储结构相同,不然delegatecall
会产生意想不到的行为,有安全隐患
逻辑合约Logic
implementation
:占位变量,与Proxy
合约保持一致,防止插槽冲突。x
:uint
变量,被设置为99
。CallSuccess
事件:在调用成功时释放。increment()
函数:会被Proxy
合约调用,释放CallSuccess
事件,并返回一个uint
,它的selector
为0xd09de08a
。即abi.encodeWithSignature("increment()")
=0xd09de08a
。
1 | /** |
解读:
Logic函数是提供函数的,用于服务调用者,进行一些数据的修改之类的。而逻辑合约也是最容易出现bug或需要升级的合约。
调用者合约Caller
它有1
个变量,2
个函数:
proxy
:状态变量,记录代理合约地址。- 构造函数:在部署合约时初始化
proxy
变量。 increase()
:利用call
来调用代理合约的increment()
函数,并返回一个uint
。在调用时,我们利用abi.encodeWithSignature()
获取了increment()
函数的selector
。在返回时,利用abi.decode()
将返回值解码为uint
类型。
1 | /** |
解读:
( , bytes memory data) = proxy.call(abi.encodeWithSignature("increment()"));
表示通过proxy调用 Proxy中的
increment()
函数,此时对于Proxy合约来说,msg.data 是abi.encodeWithSignature("increment()")
代理合约Proxy
用到了内联汇编,因此比较难理解。它只有一个状态变量,一个构造函数,和一个回调函数。状态变量implementation
,在构造函数中初始化,用于保存Logic
合约地址。
Proxy
的回调函数将外部对本合约的调用委托给 Logic
合约。这个回调函数很别致,它利用内联汇编(inline assembly),让本来不能有返回值的回调函数有了返回值。其中用到的内联汇编操作码:
calldatacopy(t, f, s)
:将calldata(输入数据)从位置f
开始复制s
字节到mem(内存)的位置t
。delegatecall(g, a, in, insize, out, outsize)
:调用地址a
的合约,输入为mem[in..(in+insize))
,输出为mem[out..(out+outsize))
, 提供g
wei的以太坊gas。这个操作码在错误时返回0
,在成功时返回1
。returndatacopy(t, f, s)
:将returndata(输出数据)从位置f
开始复制s
字节到mem(内存)的位置t
。switch
:基础版if/else
,不同的情况case
返回不同值。可以有一个默认的default
情况。return(p, s)
:终止函数执行, 返回数据mem[p..(p+s))
。revert(p, s)
:终止函数执行, 回滚状态,返回数据mem[p..(p+s))
。
1 | contract Proxy { |
解读:
分析
fallback()
函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27 fallback() external payable {
address _implementation = implementation;
assembly {
// 将msg.data拷贝到内存里
// calldatacopy操作码的参数: 内存起始位置,calldata起始位置,calldata长度
calldatacopy(0, 0, calldatasize())
// 利用delegatecall调用implementation合约
// delegatecall操作码的参数:gas, 目标合约地址,input mem起始位置,input mem长度,output area mem起始位置,output area mem长度
// output area起始位置和长度位置,所以设为0
// delegatecall成功返回1,失败返回0
let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
// 将return data拷贝到内存
// returndata操作码的参数:内存起始位置,returndata起始位置,returndata长度
returndatacopy(0, 0, returndatasize())
switch result
// 如果delegate call失败,revert
case 0 {
revert(0, returndatasize())
}
// 如果delegate call成功,返回mem起始位置为0,长度为returndatasize()的数据(格式为bytes)
default {
return(0, returndatasize())
}
}fallback函数用于处理调用者在调用该合约中不具备的函数时被触发。其中的代码逻辑是:内联汇编处理调用者的
msg.data
,通过解析msg.data
调用其中包含的函数,比如此例中,msg.data
便是 函数increment()
的选择器,它是用来唯一识别合约中的函数,合约调用哪个函数由调用者决定,即使该合约被部署到链上,但是仍然可以通过传入不同的代理逻辑合约的地址,实现不同的功能。从而实现合约的升级。又由于采用的是delegatecall
的调用方式,使得将作用结果呈现给调用者。这个代理合约Proxy真的很牛,特别是这个内联汇编的使用,极大的提高了合约的兼容性!!!
Remix演示
透明代理
知识点:
如果在代理合约和逻辑合约中有相同的函数(选择器相同),且代理合约通过 delegatecall 去调用逻辑合约中的与代理合约相同的函数时,代理合约会优先调用本合约中的函数。
验证如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 contract Proxy {
uint public result;
address logic;
constructor(address _logic) {
logic = _logic;
}
function delegatecall() public {
result = 999;
}
fallback() external {
(bool success, bytes memory data) = logic.delegatecall(msg.data);
}
}
contract Logic {
uint public result;
address proxy;
// 0xbc957fda
function delegatecall() public {
result = 666;
}
function calSelector(string memory _funName) external pure returns(bytes4) {
return bytes4(abi.encodeWithSignature(_funName));
}
}Proxy 和 Logic 中都有 delegatecall 函数,通过Proxy去调用Logic中的 delegatecall
当然 使用 call来调用也是同理
1. 选择器冲突
智能合约中,函数选择器(selector)是函数签名的哈希的前4个字节。例如mint(address account)
的选择器为bytes4(keccak256("mint(address)"))
,也就是0x6a627842
。更多关于选择器的内容见WTF Solidity极简教程第29讲:函数选择器
由于函数选择器仅有4个字节,范围很小,因此两个不同的函数可能会有相同的选择器,例如下面两个函数:
1 | // 选择器冲突的例子 |
示例中,函数burn()
和collate_propagate_storage()
的选择器都为0x42966c68
,是一样的,这种情况被称为“选择器冲突”。在这种情况下,EVM
无法通过函数选择器分辨用户调用哪个函数,因此该合约无法通过编译。
由于代理合约和逻辑合约是两个合约,就算他们之间存在“选择器冲突”也可以正常编译,这可能会导致很严重的安全事故。举个例子,如果逻辑合约的a
函数和代理合约的升级函数的选择器相同,那么管理人就会在调用a
函数的时候,将代理合约升级成一个黑洞合约,后果不堪设想。
如何理解呢,假设在某种情况下,Prxoy中的
upgrade
函数的 选择器abi.encodeWithSignature(upgrade(address))
的值,和msg.data
的值相同的话,函数则会选择调用upgrade
函数,而不会执行msg.data
中的函数。如若在msg.data
中包含了参数,且该参数是地址类型,执行(bool success, bytes memory data) = implementation.delegatecall(msg.data);
则会将合约中的逻辑合约的地址给修改,从而导致合约损坏。
目前,有两个可升级合约标准解决了这一问题:透明代理Transparent Proxy
和通用可升级代理UUPS
2. 透明代理概念
透明代理的逻辑非常简单:管理员可能会因为“函数选择器冲突”,在调用逻辑合约的函数时,误调用代理合约的可升级函数。那么限制管理员的权限,不让他调用任何逻辑合约的函数,就能解决冲突:
- 管理员变为工具人,仅能调用代理合约的可升级函数对合约升级,不能通过回调函数调用逻辑合约。
- 其它用户不能调用可升级函数,但是可以调用逻辑合约的函数。
3. 代理合约
这里的代理合约和第47讲的非常相近,只是fallback()
函数限制了管理员地址的调用。
它包含3
个变量:
implementation
:逻辑合约地址。admin
:admin地址。words
:字符串,可以通过逻辑合约的函数改变。
它包含3
个函数
- 构造函数:初始化admin和逻辑合约地址。
fallback()
:回调函数,将调用委托给逻辑合约,不能由admin
调用。upgrade()
:升级函数,改变逻辑合约地址,只能由admin
调用。
1 | // 透明可升级合约的教学代码,不要用于生产。 |
4. 逻辑合约
这里的新、旧逻辑合约与第47讲一样。逻辑合约包含3
个状态变量,与保持代理合约一致,防止插槽冲突;包含一个函数foo()
,旧逻辑合约会将words
的值改为"old"
,新的会改为"new"
。
1 | // 旧逻辑合约 |