前言
在【关于可变合约的二三事】相关的文章中,我们讨论了代理合约的使用方式,但文中的最后,也提到了,给出的代理合约例子是有问题的,不能用在真实项目中,哪真实项目中是怎么使用代理合约的呢?本文就以最近比较火热的项目TwitterScan作为分析对象,讨论其使用代理合约的方式。
在讨论前,有必要提一下,网上很多代理合约的文章都脱离了真实项目,即你了解完后,依旧看不懂真实项目的合约,比较难受。最好的学习方式还是专业机构的文档+真实在跑的项目,这样才能验证你自己的理解是否正确。
TwitterScan简介
因为有些朋友可能不知道TwitterScan是什么项目,这里简单介绍一下。
TwitterScan官网:https://twitterscan.com/
一个Web3数据分析工具,可以追踪链上数据(即各种Token和链的交易数据)还可以追踪Twitter Web3圈KOL社交发言等链下数据。
嗯,这些都不是重点,重点是,这个项目在短短几个月内,获利536.68个ETH(即获利近500w RMB),而且,技术难度也不会太高。
这里,插一下我目前的一个观点:web3工具类的项目,开发难度不会特别大,但起来的项目收益都很高,所以开发者同行们,可以多多关注一下这个领域的工具。
简单代理合约的问题
基于【关于可变合约的二三事】一文,我们知道代理合约会通过delegatecall函数来实现委托调用,其目的是将项目数据放在代理合约中,将项目玩法逻辑放在逻辑合约中,当项目需要更新时,直接替换代理合约关联的逻辑合约,便可以无痛切换成新的玩法且数据不会丢失。当然,逻辑合约出现了bug也可以利用这种方式修改一下。
那代理合约出现bug了呢?抱歉,救不了,要救只能通过社交更新法,即告诉大家,旧的合约有问题,一起来玩新的合约吧,我们官方不承认旧合约里的数据了,嗯,这就是所谓的社交更新,你要让大家配合你玩。
为了稳妥起见,多数项目都不会自己去开发代理合约,而是直接使用专业机构提供的代理合约模型,比如openzeppelin提供了不同模式的代理合约玩法,TwitterScan项目便使用openzeppelin提供的透明代理合约。
在看TwitterScan的合约前,有必要讨论一下简单代理合约的问题。
slot clash
solidity语言有个比较隐晦的特性,那便是slot的分布,很多人看solidity的语法与JavaScript类似,就模仿JavaScript的形式写代码,从而在这里踩坑。
为了方便你理解,这里写给出一个简单的Proxy合约和逻辑合约:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract Proxy {
address public implementation;
address public admin;
uint256 public x = 0;
constructor() {
admin = msg.sender;
}
modifier onlyAdmin {
require(msg.sender == admin);
_;
}
function setImplementation(address _implementation) external onlyAdmin{
implementation = _implementation;
}
fallback() external payable {
_delegate();
}
receive() external payable {
_delegate();
}
function _delegate() internal {
assembly {
let _implementation := sload(0)
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), _implementation, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
}
contract LogicsContract {
// 与代理合约中变量的顺序要一致
address public implementation;
address public admin;
uint256 public x = 0;
function add_x(uint256 _x) external returns(uint256) {
x += _x;
return x;
}
}
上面的代码是从【关于可变合约的二三事】中直接复制过来的,看到LogicsContract中的注释,solidity要求你,逻辑合约要与代理合约的变量顺序是一直的,究其原因,便是slot。
在solidity中,每个合约中的变量都是按顺序放置的,比如LogicsContract合约中的implementation变量在第0个slot,admin变量则在第1个slot,它刚好与proxy合约中的implementation变量对应上,如果你将顺序调整一下,比如调整成如下形式:
contract LogicsContract {
// x => 0 slot
uint256 public x = 0;
// implementation => 1 slot
address public implementation;
// admin => 2 slot
address public admin;
function add_x(uint256 _x) external returns(uint256) {
x += _x;
return x;
}
}
如果调整成上面的顺序,那么x变量就在第0个slot了,而proxy中,第0个slot是implementation变量,这样当我们调用add_x函数时,在LogicsContract中,处理的是第0个slot,即x变量,但映射到Proxy中,则变成改变的是implementation变量,这种情况便是slot clash(插槽冲突)。
当我在阅读slot clash相关的文档时,有个容易遗漏的细节。
如果光看教程文档,会天真的以为proxy合约中变量的顺序与逻辑合约中变量的顺序一致就好了,但当你去看TwitterScan项目的合约代码时,发现proxy相关的合约中没有相应的变量,以上面的例子为例便是:LogicsContract合约中有x变量,Proxy合约中没有定义x变量,那当我们通过delegatecall委托调用add_x时,Proxy合约将数据存在哪里?都没有定义x变量,不会报错吗?
Proxy合约会将数据存在相应的slot位置中,比如LogicsContract合约中x变量在第0个slot,就算Proxy合约中没有定义x变量,当我们委托调用,操作x变量时,也可以正常操作,其中第0个slot便是其x变量,只是没有显示的声明出来。
我画个图简单总结一下:
当我们通过delegatecall调用chnage_a函数时,change_a函数会去修改LogicsContract合约中a变量,而a变量在第0个slot,因为delegatecall函数调用后,数据会在ProxyContract合约中保存,所以change_a函数的效果便是覆盖了ProxyContract合约的owner变量,如果owner变量记录着发布当前ProxyContract合约的用户address,而转账函数又限制成只有owner可以调用,那么此时ProxyContract合约就变成了黑洞合约(无法将里面的Token转出)。
当我们通过delegatecall调用change_c函数时,change_c函数会去修改LogicsContract合约中c变量,c变量的slot为2,ProxyContract合约中虽然不存在c变量,但依旧可以将值存下来。
怎么验证上面我说的是对的?当然是做实验!
我们按上图的形式,写下如下代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract ProxyContract{
address public owner;
constructor(address _owner) {
owner = _owner;
}
function delegatecallChangeA(address LogicsContractAddr, uint256 _a) public returns(uint256) {
(, bytes memory data) = LogicsContractAddr.delegatecall(
abi.encodeWithSignature("change_a(uint256)", _a)
);
return abi.decode(data, (uint));
}
function delegatecallChangeC(address LogicsContractAddr, uint256 _c) public returns(uint256) {
(, bytes memory data) = LogicsContractAddr.delegatecall(
abi.encodeWithSignature("change_c(uint256)", _c)
);
return abi.decode(data, (uint));
}
}
contract LogicsContract {
uint256 public a;
uint256 public b;
uint256 public c;
function change_a(uint256 _a) public returns(uint256) {
a = _a;
return a;
}
function change_c(uint256 _c) public returns(uint256) {
c += _c;
return c;
}
}
然后将LogicsContract合约与ProxyContract合约都部署上。
ProxyContract合约中有owner变量,用于记录部署当前合约的账户地址,如下图所示:
当我们调用delegatecallChangeA函数后,owner变量就变成了6,这是因为ProxyContract合约的owner变量与LogicsContract合约的a变量在同一slot位置,所以改变a变量其实会影响到代理合约的owner变量,从而导致get_name函数再也无法被任何人调用,如果get_name函数是其他转账相关的函数,这个合约就不可用了。
随后,我们调用两次delegatecallChangeC函数时,发现decode ouput会是12,即Proxy中,位置为2的slot其实存了c变量的值。
聊了这么多slot clash会出现的问题,那有什么解决方法呢?
有,那便是EIP1967提出的解决方案,为Proxy合约中一些必要的变量指定slot的位置,因为是我们自定义的slot位置,某些变量不再按从0到N的slot顺序排布,openzeppelin将这种slot存储模式的代理称为:非结构化存储代理(Unstrctured Storage Proxies)。
我们假设在Proxy合约中,定义Implementation变量来存储逻辑合约的address,定义admin变量来存储部署Proxy合约的账户地址,为了避免slot clash,EIP1967会根据如下方式来定义这两个变量的slot:
// 计算Implementation变量的slot位置
// IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
bytes32 private constant IMPLEMENTATION_SLOT = bytes32(uint256(
keccak256('eip1967.proxy.implementation')) - 1
));
// 将Implementation变量的slot位置指定成计算出的IMPLEMENTATION_SLOT
function _implementation() virtual override internal view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
impl := sload(slot)
}
}
// admin变量也一样
// ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
bytes32 private constant ADMIN_SLOT = bytes32(uint256(
keccak256('eip1967.proxy.admin')) - 1
));
function _admin() internal view returns (address adm) {
bytes32 slot = ADMIN_SLOT;
assembly {
adm := sload(slot)
}
}
自定义的slot位置被LogicsContract合约中变量覆盖的概率微乎其微。
keccak256('eip1967.proxy.variable_name')) - 1 这种方式是EIP1967约定俗成的方式,你当然可以自定义成其他的slot位置,让其概率足够小则可,但如无必要,按约定走吧。
关于EIP 1967的更多细节,可以阅读其文档:https://eips.ethereum.org/EIPS/eip-1967
这里有个误区需要注意,EIP 1967主要解决的是Proxy合约中变量slot clash的问题,但无法解决逻辑合约冲突的问题,如下:
|Implementation_v0 |Implementation_v1 |
|--------------------|-------------------------|
|address _owner |address _lastContributor | <=== Storage collision!
|mapping _balances |address _owner |
|uint256 _supply |mapping _balances |
|... |uint256 _supply |
| |... |
上面这种形式是Implementation_v0逻辑合约升级成Implementation_v1逻辑合约,但原本slot为0位置的变量被新的_lastContributor变量覆盖,这就会出问题了,所以在升级逻辑合约时,如果要添加新的变量,按顺序添加在后面,如下:
|Implementation_v0 |Implementation_v1 |
|--------------------|-------------------------|
|address _owner |address _owner |
|mapping _balances |mapping _balances |
|uint256 _supply |uint256 _supply |
|... |address _lastContributor | <=== Storage extension.
| |... |
function clash
除了slot clash外,代理合约模式还可能会遇到function clash。我们知道,solidity中有函数选择器的概念,当用户调用某个函数时,函数选择器会选择出用户要调用的函数。
函数选择器在匹配函数时,使用函数签名hash后的前4个字节,而不是通过函数名,这就会出现两个函数名完全不同的函数会被同时匹配上,如下图:
如果你在同一个合约中,solidity会检测出function slot,但代理合约模式下,函数会分布到多个合约,此时就无法检测function clash了。
**假设Proxy合约与Logics合约上的函数出现了function clash,就会出现调用该函数时,没有调用到Logics合约上,而使用了Proxy合约中相应的函数,导致出现逻辑bug。**区块链世界中其实出现过相关的安全问题,详情可看:https://medium.com/nomic-foundation-blog/malicious-backdoors-in-ethereum-proxies-62629adf3357
为了解决这个问题,openzeppelin给出了透明代理合约的模式(Transparent Proxy),这种模式很好理解,在Proxy合约中定义一个admin变量,用于记录可以操作Proxy合约中函数的地址,后续用户使用Proxy合约时,不论是否出现functino clash,只要调用方的address与admin变量记录的一致,则只能调用Proxy合约中的函数,不通过delegatecall进行委托调用,若Proxy合约中不存在相关的函数,则直接报错。如果调用方的address与admin变量记录的不一致,则所有的调用都会被委托调用到逻辑合约中,对于调用方而言,不需要考虑function clash的情况。
具体的实现方式很简单,定义一个modifier判断当前调用者是否为admin则可,例子如下:
modifier ifAdmin() {
if (msg.sender == _admin()) {
// 如果admin,则直接调用Proxy合约中的函数
_;
} else {
// 如果不是admin,则触发委托调用的逻辑
_fallback();
}
}
// 代理合约中用于更新逻辑合约的方法,只有admin address才能调用
function upgradeTo(address newImplementation) external ifAdmin {
_upgradeTo(newImplementation);
}
function _fallback() internal {
// 如果admin调用的函数,在proxy不存在时,会触发fallback
// 在fallback中,通过_willFallback函数判断当前调用者是否为admin,如果是,则返回报错,
// 不再执行委托调用的逻辑
_willFallback();
_delegate(_implementation());
}
function _willFallback() virtual override internal {
require(msg.sender != _admin(), "Cannot call fallback function from the proxy admin");
}
但这也存在一个问题,如果admin中记录着当前部署Proxy合约的用户address,那么这个用户在使用时,便只能使用代理合约中的函数,即部署Proxy合约的用户无法成为一个正常账户。
举一个具体的例子,假设Proxy合约中有owner()和upgradeTo()函数,逻辑合约中有owner()和Transfer()函数,当调用者是owner(Proxy合约部署者)和other address(其他普通用户)时,会有如下情况:
msg.sender | owner() | upgradeto() | transfer() |
---|---|---|---|
Owner | returns proxy.owner() | returns proxy.upgradeTo() | fails |
Other | returns erc20.owner() | fails | returns erc20.transfer() |
因为Owner代理合约部署者,所以无法成功调用逻辑合约中的transfe()r函数,而Other是普通用户,无法成功调用代理合约中的upgradeto()函数,Other账户是正常现象,但Owner用户连转账都不能操作,不能作为正常用户来使用了,你可以将Owner账户就专门用于管理Proxy合约,再创建其他账户进行正常交互,嗯,就麻烦了些。
为了解决这个问题,openzeppelin在透明代理合约的模式中添加了ProxyAdmin合约,这个合约负责管理代理合约的所有函数,比如修改代理合约关联的逻辑合约,从而实现玩法更新等,大体关系图如下:
1.Admin User创建了ProxyAdmin合约、Proxy合约和Logics合约,是项目方的账户。
2.Proxy合约的admin变量设置成ProxyAdmin合约,后续Proxy合约中的函数只允许ProxyAdmin合约调用。
3.Other User是普通用户,他们可以正常使用Proxy合约,Admin User地址因为与admin变量中记录的地址不同,所以也可以正常使用Proxy合约。
这便是openzeppelin提供的透明代理合约的实现方式,TwitterScan项目便使用了这种模型。
构造函数不被调用
当我们在看真实项目时,还会发现一个现象,被代理的逻辑合约中,通常没有构造函数,为何?
这是因为,代理合约无法使用逻辑合约的构造函数。如果逻辑合约中有构造函数,在逻辑合约部署时,构造函数会自动调用,数据会留存在逻辑合约中,此时再与代理合约关联上时,逻辑合约中构建函数的逻辑与数据没有任何意义。
但我们很多业务都需要构造函数来实现,比如初始化NFT的名称、数量等。
openzeppelin提供了Initializable合约来解决这个问题,这个合约中,提供名为Initializiable的modifier,这个modifier可以确保函数只能被调用一次,其实现如下:
uint8 private _initialized;
bool private _initializing;
modifier initializer() {
bool isTopLevelCall = !_initializing;
// 被调用过的函数,如果通过该require
require(
(isTopLevelCall && _initialized < 1) || (!AddressUpgradeable.isContract(address(this)) && _initialized == 1),
"Initializable: contract is already initialized"
);
_initialized = 1;
if (isTopLevelCall) {
// 被调用后,记录一下
_initializing = true;
}
_;
if (isTopLevelCall) {
_initializing = false;
emit Initialized(1);
}
}
阅读initializer的源码,可以发现,initializer实现确保某函数单次执行的效果其实就是基于一个变量来记录函数是否被调用过,仅此而已。
通过initializer,我们将原本是需要通过构造函数中逻辑放在某个函数中,当代理合约初始化时,调用这个函数,因为有initializer的存在,可以确保改函数只被调用一次,从而实现逻辑合约中构造函数的效果。
TwitterScan合约代码阅读
前面我们了解了相应的基础后,便可以阅读TwitterScan项目的合约代码了。
其代理合约的地址为:https://etherscan.io/address/0xd9372167ef419cfbbcd6483603ad15976364e557#writeProxyContract
我个人习惯使用deth.net来阅读合约代码,使用方式如下:
deth.net工具会帮你打开当前的合约代码。如果当前合约是Proxy合约,它还会帮你找到逻辑合约,然后一同展示出来,效果如下:
其中__AdminUpgradeabilityProxy__是代理合约,TwitterscanPass是逻辑合约,代理合约记录数据,逻辑合约实现项目的玩法。
本文主要关注Twitterscan项目使用代理合约的形式,所以不去过多关注其玩法代码。
我个人习惯是,通过deth.net简单浏览合约的代码,但真正要深入理解时,光静态分析是不够的,我还会将合约代码扒下来,在remix上运行起来,看具体的效果,来验证自己的理解是否正确。
怎么扒呢?因为项目引用了openzeppelin中较多的代码,一个个复制粘贴,不太优雅,我个人会用:https://smart-contract-downloader.vercel.app/工具来下载。
下面我们来看其具体的实现逻辑。
首先,看到__AdminUpgradeabilityProxy__合约,它继承了2个合约,并在构造方法中,调用了UpgradeabilityProxy合约的构造函数。
其实这些合约都会继承基本的Proxy合约。
基本的Proxy合约就是最简单的代理合约,通过_delegate函数与fallback函数来实现代理功能。
BaseUpgradeabilityProxy合约继承了Proxy,然后基于EIP1967的方式,指定了代理合约中变量的slot位置,避免slot clash。
此外,BaseUpgradeabilityProxy合约还实现了ProxyAdmin的功能,对于代理合约,只有Admin才能调用。
然后我们看回UpgradeabilityProxy合约的构造函数,__AdminUpgradeabilityProxy__合约的构造函数中调用了UpgradeabilityProxy合约的构造函数,其代码如下
我们看到逻辑合约,即TwitterscanPass.sol代码,看到它的initialize函数,该函数只能被调用一次。
很明显,UpgradeabilityProxy合约的构造函数会调用逻辑合约TwitterscanPass中的initialize函数,实现数据的初始化。
然后因为slot的原因,代理合约中,关键的变量,都采用了自定义slot的形式,逻辑合约中的这些变量会正常的映射到代理。
为了验证上面的分析,我们在TwitterscanPass逻辑合约中,加上ayu_number这个uint256类型的变量,然后定义一个函数来获取这个值,代码如下:
然后我们部署一下__AdminUpgradeabilityProxy__合约与TwitterscanPass合约。
因为TwitterscanPass合约中的逻辑比较长,编译时会出现下面问题:
此时我们开启Remix IDE的编译优化,便可以解决这个问题:
因为部署__AdminUpgradeabilityProxy__合约时,需要逻辑合约address、Admin address和调用TwitterscanPass合约initialize函数的encode编码。
为了方便,Admin address我直接使用了当前的账户address,而encode编码,通过下面代码,可以获得。
contract GetInitFunc{
function get_func_hash() public pure returns(bytes memory) {
// 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 为 部署 __AdminUpgradeabilityProxy__ 合约的address
return abi.encodeWithSignature("initialize(address)", 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4);
}
}
部署__AdminUpgradeabilityProxy__合约
部署后,如果理解成功,TwitterscanPass合约的initialize函数会被调用,而initialize函数中设置的ayu_number=666便会生效。
为了判断ayu_number是否生效了,我写了如下代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract Caller {
address public proxy;
constructor(address _proxy) {
proxy = _proxy;
}
function get_ayuname_proxy() external returns(uint256) {
// 调用 get_ayu_number函数,查询 __AdminUpgradeabilityProxy__ 合约中的ayu_number变量
(, bytes memory data) = proxy.call(abi.encodeWithSignature("get_ayu_number(uint256)", 1));
return abi.decode(data, (uint256));
}
}
部署上面的合约,将__AdminUpgradeabilityProxy__合约地址设置为上述合约的proxy变量,然后委托调用get_ayu_number函数,因为数据是存在__AdminUpgradeabilityProxy__合约中,所以会获得ayu_number的值,效果如下:
结合Twitterscan项目的合约代码和实验结果,说明我们对透明代理的理解到位了,真实项目中,确实是如我们理解那般使用的。
结尾
区块链世界很有趣,我目前在开发NFT相关的工具,欢迎这个圈子的朋友找我玩。
我是二两,我们下篇文章见。