电脑基础 · 2023年3月23日

GnosisSafeProxy合约学习

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为单位的。

    1. let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff) 这行代码先读取插槽0的数据(32字节,256位),然后和40个F按位与操作,重置前面未使用的数据位为0。这是一个良好的习惯,我们不能假定前面未使用的数据位一定为0,虽然本例中的确为0。最后的结果得到 singleton地址,注意前面提到过,其不是地址类型,而是uint256

    2. 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字节),刚好是上一步压入内存的地址。

    3. 如果不是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代表内存开始位置 ,第二个参数代表数据大小–字节数)

    4. 我们可以对比一下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的调用判断及返回。

    5. 知识拓展。我们知道,在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