抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

代理合约

1. 代理模式

solidity合约部署到链上之后,代码是不可变的。

这一特性存在了一个严重的缺点:就算合约中存在bug,也不能修改或者升级,只能部署新合约。但是新合约的地址和旧合约的地址不一样,而且合约的数据也需要花费大量的gas进行迁移。

为了解决这一问题,从而引入了 代理模式这一概念。

image-20230719115132082

代理模式将合约数据和逻辑分开,分别保存在不同的合约中。以上图为例,数据(状态变量)存储在代理合约中,而逻辑(函数)保存在另一个逻辑合约中。逻辑合约(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合约保持一致,防止插槽冲突。
  • xuint变量,被设置为99
  • CallSuccess事件:在调用成功时释放。
  • increment()函数:会被Proxy合约调用,释放CallSuccess事件,并返回一个uint,它的selector0xd09de08a。即 abi.encodeWithSignature("increment()")= 0xd09de08a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* @dev 逻辑合约,执行被委托的调用
*/
contract Logic {
address public implementation; // 与Proxy保持一致,防止插槽冲突
uint public x = 99;
event CallSuccess(); // 调用成功事件

// 这个函数会释放CallSuccess事件并返回一个uint。
// 函数selector: 0xd09de08a
function increment() external returns(uint) {
emit CallSuccess();
return x + 1;
}
}

解读:

Logic函数是提供函数的,用于服务调用者,进行一些数据的修改之类的。而逻辑合约也是最容易出现bug或需要升级的合约。

调用者合约Caller

它有1个变量,2个函数:

  • proxy:状态变量,记录代理合约地址。
  • 构造函数:在部署合约时初始化proxy变量。
  • increase():利用call来调用代理合约的increment()函数,并返回一个uint。在调用时,我们利用abi.encodeWithSignature()获取了increment()函数的selector。在返回时,利用abi.decode()将返回值解码为uint类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @dev Caller合约,调用代理合约,并获取执行结果
*/
contract Caller{
address public proxy; // 代理合约地址

constructor(address proxy_){
proxy = proxy_;
}

// 通过代理合约调用increment()函数
function increment() external returns(uint) {
( , bytes memory data) = proxy.call(abi.encodeWithSignature("increment()"));
return abi.decode(data,(uint));
}
}

解读:

( , 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)), 提供gwei的以太坊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
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
32
33
34
35
36
37
38
39
40
41
42
contract Proxy {
address public implementation; // 逻辑合约地址。implementation合约同一个位置的状态变量类型必须和Proxy合约的相同,不然会报错。

/**
* @dev 初始化逻辑合约地址
*/
constructor(address implementation_){
implementation = implementation_;
}

/**
* @dev 回调函数,将本合约的调用委托给 `implementation` 合约
* 通过assembly,让回调函数也能有返回值
*/
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()函数

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

image-20230719182146867

当然 使用 call来调用也是同理

image-20230719182313952

1. 选择器冲突

智能合约中,函数选择器(selector)是函数签名的哈希的前4个字节。例如mint(address account)的选择器为bytes4(keccak256("mint(address)")),也就是0x6a627842。更多关于选择器的内容见WTF Solidity极简教程第29讲:函数选择器

由于函数选择器仅有4个字节,范围很小,因此两个不同的函数可能会有相同的选择器,例如下面两个函数:

1
2
3
4
5
// 选择器冲突的例子
contract Foo {
function burn(uint256) external {}
function collate_propagate_storage(bytes16) external {}
}

示例中,函数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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 透明可升级合约的教学代码,不要用于生产。
contract TransparentProxy {
address implementation; // logic合约地址
address admin; // 管理员
string public words; // 字符串,可以通过逻辑合约的函数改变

// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}

// fallback函数,将调用委托给逻辑合约
// 不能被admin调用,避免选择器冲突引发意外
fallback() external payable {
require(msg.sender != admin);
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}

// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
if (msg.sender != admin) revert();
implementation = newImplementation;
}
}

4. 逻辑合约

这里的新、旧逻辑合约与第47讲一样。逻辑合约包含3个状态变量,与保持代理合约一致,防止插槽冲突;包含一个函数foo(),旧逻辑合约会将words的值改为"old",新的会改为"new"

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
// 旧逻辑合约
contract Logic1 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变

// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "old";
}
}

// 新逻辑合约
contract Logic2 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变

// 改变proxy中状态变量,选择器:0xc2985578
function foo() public{
words = "new";
}
}

Remix实现

深度学习

link

参考博客

WTFproxyContract

评论



政策 · 统计 | 本站使用 Volantis 主题设计