GnosisSafeProxy 学习
GnosisSafe是以太坊区块链上最流行的多签钱包!它的最初版本叫
MultiSigWallet
,现在新的钱包叫Gnosis Safe
,意味着它不仅仅是钱包了。它自己的介绍为:以太坊上的最可信的数字资产管理平台(The most trusted platform to manage digital assets on Ethereum)。
Gnosis Safe Contracts
的核心合约采用了代理/实现这种模式,并且为了方便大家创建,使用了ProxyFractory合约来进行代理合约的创建(当然创建代理合约之前必须创建实现合约)。
这里什么是代理/实现模式就不再讲了,不清楚的读者可以自行阅读相关文章。
1.1 GnosisSafeProxy.sol 合约源码
既然是代理/实现合约,那么我们平常交互的对象就是代理合约了,虽然逻辑在实现合约里面。相对其它而言,代理合约是非常简单的,和openzeppelin
的代理合约也很相似,我们先看本合约源码。
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;
/// @title IProxy - Helper interface to access masterCopy of the Proxy on-chain
/// @author Richard Meissner - <richard@gnosis.io>
interface IProxy {
function masterCopy() external view returns (address);
}
/// @title GnosisSafeProxy - Generic proxy contract allows to execute all transactions applying the code of a master contract.
/// @author Stefan George - <stefan@gnosis.io>
/// @author Richard Meissner - <richard@gnosis.io>
contract GnosisSafeProxy {
// singleton always needs to be first declared variable, to ensure that it is at the same location in the contracts to which calls are delegated.
// To reduce deployment costs this variable is internal and needs to be retrieved via `getStorageAt`
address internal singleton;
/// @dev Constructor function sets address of singleton contract.
/// @param _singleton Singleton address.
constructor(address _singleton) {
require(_singleton != address(0), "Invalid singleton address provided");
singleton = _singleton;
}
/// @dev Fallback function forwards all transactions and returns all received return data.
fallback() external payable {
// solhint-disable-next-line no-inline-assembly
assembly {
let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
// 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
mstore(0, _singleton)
return(0, 0x20)
}
calldatacopy(0, 0, calldatasize())
let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
if eq(success, 0) {
revert(0, returndatasize())
}
return(0, returndatasize())
}
}
}
1.2 源码学习
注意:阅读注释很重要,魔鬼细节全在注释里。
我们现在开始学习,直接跳过版权声明和pragma
声明部分。
-
IProxy
定义了一个代理合约需要实现的接口,它仅有一个函数masterCopy()
,功能为返回其实现合约地址。 -
contract GnosisSafeProxy
代理合约定义。注意注释中提到,它会根据master
合约中的代码来执行所有交易(其实这里有一个例外,就是masterCopy
函数本身。注意,合约定义并没有is IProxy
,也就是不需要显式实现masterCopy
函数。这是因为为了节省gas
,该函数统一通过fallback
函数来实现,所以不需要显式定义合约必须实现IProxy
接口。 -
singleton
字面意思类似Java中单例,也就是唯一实现master
。注意,它是合约中的第一个状态变量,所以存储在插槽0。实现合约中的相同的状态变量必须和代理合约中保持插槽顺序一致(否则会引起插槽冲突),也就是说实现合约的第一个状态变量必须也是singleton
。这个我们以后学习到实现合约时再做验证。 -
注释中提到它是内部可见性,是为了节省gas。它可以通过
getStorageAt
也就是直接读取插槽位置获取,当然了,本合约中可以通过IProxy
定义的接口函数masterCopy
获取,当然,它内部也是通过读取插槽0实现的。 -
构造器参数是实现合约地址,验证了它不能为0地址,这个很简单,当然我们可以进一步验证其它必须为合约地址。
-
fallback
函数。我们知道,调用一个合约时,如果合约匹配不到相应的函数,则会调用fallback
函数(如果有定义)。代理/实现模式利用了这一特点,在fallback
函数里将所有的调用转为调用实现合约中相应的逻辑,再返回相应结果。因为本合约未定义receive
函数,所以接收ETH也是执行的本函数。 -
本列中的
fallback
函数和openzeppelin
合约中的略有不同,首先,它判断了调用是否为masterCopy
函数,如果是的话,直接返回singleton
地址,因此变相实现了IProxy
。如果不是调用的masterCopy
函数,则委托调用实现合约的相关逻辑。我们来简单学习一下它的代码。需要注意的是,在内嵌汇编中,所有的
EVM dialect
涉及的数据类型都是uint256类型,没有其它类型。接下来的文档中如果没有特殊说明,所有的word
均指32字节(256位)。EVM中的操作一般是以一个word为单位的。-
let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
这行代码先读取插槽0的数据(32字节,256位),然后和40个F
按位与操作,重置前面未使用的数据位为0。这是一个良好的习惯,我们不能假定前面未使用的数据位一定为0,虽然本例中的确为0。最后的结果得到singleton
地址,注意前面提到过,其不是地址类型,而是uint256
。 -
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) { mstore(0, _singleton) return(0, 0x20) }
判断调用是否为
masterCopy
。注意,虽然我们平常调用合约时,类似masterCopy
这样的没有参数的函数调用它的数据只有8位0xa619486e
(函数选择器),但是calldataload
读取的是calldata中的0
地址开始的一个word内容,它是256位的,不足的话会被右边补0。所以if
语句中相比较的是补0后的函数选择器,那么补了多少个0呢?由于uint256是64个16进制长度,函数选择器的长度是8,所以补了64 - 8 = 56
个0.如果比较相等,则把
singleton
地址保存到内存中0地址开始的字节中去,然后返回该地址。注意return(0, 0x20)
返回内存中0地址开始的一个word,第一个参数0代表开始地址,第二个参数0x20
代表返回内容的长度(字节数)。0x20 = 32
也就是一个word(32字节),刚好是上一步压入内存的地址。 -
如果不是
masterCopy
函数,则执行逻辑和openzeppelin
中相关函数一致,我们来看代码:calldatacopy(0, 0, calldatasize()) let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0) returndatacopy(0, 0, returndatasize()) if eq(success, 0) { revert(0, returndatasize()) } return(0, returndatasize())
第一行将所有的
calldata
数据复制到内存中(从calldata的0地址开始,复制到内存中的0地址开始位置)。第二行进行委托调用,对应的参数按顺序分别为剩余的
gas
,实现合约地址,内存中开始地址,数据大小,output开始位置 ,output大小(最后两项一般为0)。 因为上一步复制了calldata
到内存0位置,所以这里我们是从0地址开始的,大小刚好就是calldatasize
。第三行将返回值复制到了内存中从0地址开始的位置(多次利用了零地址开头的内存)。
4-6行判断如果返回值是0(代表delegatecall失败),则将返回值
revert
(这里一般是出错原因)。第一个参数0代表内存开始位置 ,第二个参数代表数据大小–字节数。第7行如果调用成功,则将返回值
return
。(第一个参数0代表内存开始位置 ,第二个参数代表数据大小–字节数) -
我们可以对比一下
openzeppelin
中相关代码_delegate
函数,基本是类似的:function _delegate(address implementation) internal virtual { assembly { // Copy msg.data. We take full control of memory in this inline assembly // block because it will not return to Solidity code. We overwrite the // Solidity scratch pad at memory position 0. calldatacopy(0, 0, calldatasize()) // Call the implementation. // out and outsize are 0 because we don't know the size yet. let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) // Copy the returned data. returndatacopy(0, 0, returndatasize()) switch result // delegatecall returns 0 on error. case 0 { revert(0, returndatasize()) } default { return(0, returndatasize()) } } }
Gnosis的代码和这个相比,仅是多了一个
masterCopy
的调用判断及返回。 -
知识拓展。我们知道,在Solidity中,有自由内存指针,并且还有
scratch
。我们平常并不是从内存中零地址开始操作的,通常是从自由内存指针指向的地址开始操作的,一般为0x80
(前四个word已经被占用)。但是这里openzeppelin
的注释解释的很清楚,它并没有采用Solidity的内存控制,而是自己完全控制,因为它不涉及到Solidity代码(内嵌汇编是Yul
代码),因此是不冲突的。同时它还解释了我们将delegatecall
最后两个参数设置为0的原因是我们无法知道返回值大小。
好了,
GnosisSafeProxy.sol
就算学习结束了,它只是一个简单的代理合约。和标准的代理合约相比,它多了一个masterCopy
函数的调用判断。为什么没有把它单独列为一个函数呢?根据注释猜想应该是为了节省
gas
。相对而言,
openzeppelin
模板中的TransparentUpgradeableProxy
合约专门提供了一个函数implementation
用来返回实现合约的地址。 另外,TransparentUpgradeableProxy
中的实现合约一般不是插槽位置0的状态变量,例如实现了eip-1967
的
ERC1967Upgrade
合约,它的实现插槽是根据"eip1967.proxy.implementation"
计算的哈希值减去1 得到的,虽然这样会存在哈希碰撞的可能,但仅存于理论上。采用相同插槽位置(从0开始)来保存相同状态变量的代理/实现模式还有CompoundV2版本的合约,大家有兴趣的可以自己去看一下相关源码。
拓展一点:
openzeppelin
在它自己的访问提到了为什么会有TransparentUpgradeableProxy
.是因为本合约这种最简单的代理实现模式可能存在函数选择器冲突。如果实现合约恰好有一个函数的选择器和masterCopy
相同(利用编程语言可以构造一个),那么在调用这个函数时其实是会调用masterCopy
,从而得到的一个错误的结果。但是我们这里的实现合约是固定的,所以不会存在这个问题。大家有兴趣的可以参考:
https://docs.openzeppelin.com/contracts/4.x/api/proxy#TransparentUpgradeableProxy -