本文作者:bixia1994 - 互联网小工

上篇文章 [1] 简单分析 Gnosis Safe 中的部分业务逻辑,主要是链下签名与链上验证的逻辑,关于方法执行,Gas 费用 [2] 扣减等并未涉及到。因为主要是目前也暂时用不到那一块。这一篇文章主要是分析下 GnosisSafe 的合约结构。代码以最新 release 的 v1.3.0 为准,地址为 https://rinkeby.etherscan.io/address/0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552#code

GnosisSafe 是一个多签 [3] 钱包,在项目中一大优点就是它的合约架构设计,实现可插拔,可配置的功能。 GnosisSafe - 合约结构分析

可插拔模块设计

可插拔模块,即每个模块都可以为合约增加新的功能。在这种模式中,主合约提供了一套核心的不可变的功能,并允许新的模块被注册。这些模块增加了新的功能,可以调用核心合约。这种模式在钱包中最常见,如 GnosisSafe InstaDapp 。用户可以选择在自己的钱包中添加新的模块,然后每次调用钱包合约都要求执行特定模块的特定功能。

请记住,这种模式要求核心合约是没有错误的。任何关于模块管理本身的错误都不能通过在这种方案中添加新模块来修补。另外,根据实施情况,新模块可能有权通过使用 DELEGATECALLs 代表核心合约运行任何代码,所以也应该仔细审查。

ModuleManager 合约里比较有意思的一点是它使用了一个 map(address=>address) 形成的链表将所有的 Modules 串起来,并设置了一个哨兵模块 address(1) 作为链表头部。

在网站上添加 Module 的步骤可以参考如下链接: https://help.gnosis-safe.io/en/articles/4934427-add-a-module

模块管理合约- ModuleManager

从合约代码中,我们可以看到 ModuleManager 主要提供了如下几种功能:

    哨兵:      address internal constant SENTINEL_MODULES = address(0x01);  

setupModules -通过 delegateCall 的方式初始化模块

    function setupModules(address to, bytes memory data) internal {       // 确认哨兵在链表尾部,即该链表为空链表          require(modules[SENTINEL_MODULES] == address(0));          // 把哨兵指向自己          modules[SENTINEL_MODULES] = SENTINEL_MODULES;          // 对 to 地址的模块进行初始化          bool success;          assembly {              success := delegatecall(gas(),to,add(data,0x20),mload(data),0,0)          }          require(success);      }  

enableModule - 使能该模块 , 实际上是添加该模块到链表里

    //sentinel -> A <-> A      function enableModule(address module) public authorized {          // 要求该 module 不能重复添加。如果该 module 在链表尾部,则该 module 应该指向自己          require(modules[module] == address(0));          // 将该 module-> A <-> A          modules[module] = modules[SENTINEL_MODULES];          // 将哨兵重新指向 module:sentinel -> module -> A <-> A          modules[SENTINEL_MODULES] = module;      }  

disableModule - 废弃该模块 , 将该 module 移除链表

    //sentinel -> prevModule -> module -> B <-> B      function disableModule(address prevModule, address module) public authorized {          // 要求要废除的 module 和该 module 前的 preModule 都在链表中 , 且 prevModule->module          require(modules[module] != address(0) && modules[prevModule] != address[0] && modules[prevModule] = module);          //sentinel -> prevModule -> B <-> B          modules[prevModule] = modules[module];          //module -> address(0)          modules[module] = address(0);      }  

getModulesPaginated - 拿到所有的模块列表

    function getModulesPaginated(address start, uint256 pageSize) external view returns (address[] memory array, address next) {          array = new address[](pageSize "" "" "" "");          // 遍历链表,不包括哨兵          address currentModule = modules[SENTINEL_MODULES];          uint moduleCount = 0;          while (currentModule != address(0) && currentModule != SENTINEL_MODULES && moduleCount < pageSize) {              array[moduleCount] = currentModule;              moduleCount += 1;              currentModule = modules[currentModule];                }          next = currentModule;          // 设置正确的 array 大小          assembly{              mstore(array, moduleCount)          }      }  

execTransactionFromModuleReturnData - 通过模块执行方法

    function execTransactionFromModuleReturnData(              address to,              uint256 value,              bytes memory data,              Enum.Operation operation          ) public returns (bool success, bytes memory returnData) {          // 这里不能直接把数据写到 reutrnData 这一个内存数组里面,还是要从 freepointer 处开始       if (operation == Enum.Operation.Call) {              assembly{                  success := call(gas(),to,value,add(data,0x20),mload(data),0,0)                  let free_ptr := mload(0x40)                  let returndatasize_:= returndatasize()                  // 更新 0x40 的值                  mstore(0x40, add(free_ptr,add(0x20,returndatasize_)))                  mstore(free_ptr, returndatasize_)                  returndatacopy(add(free_ptr,0x20),0,returndatasize_)                  returnData = free_ptr              }          } else if (operation == Enum.Operation.DelegateCall) {              assembly{                  success := delegatecall(gas(),to,add(data,0x20),mload(data),0,0)                  let free_ptr := mload(0x40)                  let returndatasize_:= returndatasize()                  // 更新 0x40 的值                  mstore(0x40, add(free_ptr,add(0x20,returndatasize_)))                  mstore(free_ptr, returndatasize_)                     returndatacopy(add(free_ptr,0x20),0,returndatasize_)                  returnData = free_ptr              }          }      }  

思考 1:多签钱包的主合约应该如何调用模块方法?

我们注意到主合约: GnosisSafe 直接继承了 ModuleManager ,而 ModuleManager 中列出的方法都是 public/external ,说明用户可以直接访问模块中的方法,不需要多签?

思考 2:模块方法中是否要进行权限认证?只允许多签钱包的主合约直接调用

即是否需要在模块合约中,都保存主合约的地址,并在公开的方法中,添加一个 modifier:

    modifier GnosisSafeOnly() {          //proxy ->delegatecall-> GnosisSafe ->call-> module => msg.sender == address(proxy)          //proxy ->delegatecall-> GnosisSafe ->delegatecall-> module => msg.sender == address(proxy) // 对吗?          require(               msg.sender == address(GnosisSafeProxy)              );          _;      }  
    modifier authorized() {          require(msg.sender == address(manager), "Method can only be called from manager");         _;      }  

思考 3:代理合约通过 delegatecall 来访问主合约,而调用主合约中的执行模块方法时,可以选择用 call 来执行传入的 to 地址上的方法,那么 delegatecall 的上下文环境里,再使用 call,最后它的状态变化发生在哪里?是代理合约里呢还是 call 中的 to 地址上?

问题实质是 msg.sender 分别是谁:

    //proxy ->delegatecall-> GnosisSafe ->call-> module => msg.sender == address(proxy)      //proxy ->delegatecall-> GnosisSafe ->delegatecall-> module => msg.sender == address(proxy) // 对吗?  

工厂代理合约

工厂合约地址:

https://rinkeby.etherscan.io/address/0xa6b71e26c5e0845f74c812102ca7114b6a896ab2#code

部署后得到的代理合约地址:

https://rinkeby.etherscan.io/address/0xee52992d1ccc6338f1b83880da210a0b9fe7463f#code

创建代理合约的交易哈希

https://rinkeby.etherscan.io/tx/0x37b0091794de7862e5d0b9d470a4c454c74f8954e964280d7d2ad0d71dd45f71 GnosisSafe - 合约结构分析

分析工厂代理合约前的创建交易,可以从交易侧了解到合约的一个创建过程。

    Function: createProxyWithNonce(address_singleton, bytes initializer, uint256 saltNonce)      MethodID: 0x1688f0b9        //_singleton       //offset 初始化数据       //saltNonce       //length       //keccak256("setup(address[],uint256,address,bytes,address,address,uint256,address)") 0xb63e800d             0000000000000000000000000000000000000000000000000000000000000100  //_owners offset        //_threshold=2        //to = address(0)        //data offset        //fallbackHandler        //paymentToken        //payment        //paymentReceiver        //owners len        //owner_1        //owner_2        //owner_3        //len data      //keccak256("setup(address[],uint256,address,bytes,address,address,uint256,address)") 0xb63e800d  
    function createProxyWithNonce(address_singleton,bytes memory initializer,uint256 saltNonce) {       // 使用 create2 创建一个代理合约          bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));          //GnosisSafe.contructor(address_singleton) 编码时初始化时,需要将初始化的参数编码到 creationCode 后面          bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));          address proxy;          assembly{              proxy := create2(0,add(deploymentData,0x20),mload(deploymentData),salt)          }           // 使用 call 调用 setup 进行初始化 , call 成功会返回 1,call 失败会返回 0          assembly{              let success := call(gas(),proxy,0,add(initializer,0x20),mload(initializer),0,0)              if (eq(success,0x0)) {                  let ptr := mload(0x40)                  returndatacopy(ptr,0,returndatasize())                  revert(ptr,returndatasize())              }          }      }  

简单来讲, GnosisSafeProxyFactory 合约作为一个工厂合约,为每一个多签钱包创建一个 GnosisSafeProxy 的代理合约,所有的数据都储存在代理合约上。然后代理合约 GnosisSafeProxy 将所有的函数调用都通过 delegatecall 的方式远程调用 GnosisSafe 合约。

注意点 1:代理合约的构造函数有参数,工厂合约如何创建

在代理合约 GnosisSafeProxy 里面,其构造函数如下:

    constructor(address_singleton) {          require(_singleton != address(0), "Invalid singleton address provided");          singleton =_singleton;      }  

可以看到在构造函数里有一个参数 address_singleton ,作为工厂合约,最简单的生产一个 Proxy 的方法如下:

    function createProxy(address singleton, bytes memory data) public returns (GnosisSafeProxy proxy) {       proxy = new GnosisSafeProxy(singleton);          // 初始化       (bool success, bytes memory res) = address(proxy).call(data);       require(success, "GnosisSafeProxyFactory/createProxy init fail");      }  

在上面的创建 Proxy 合约的过程中,其实质是调用了 create 这一 opcode。又因为 create 这一个 opcode 的创建合约的地址仅与 Factory 合约的地址和 nonce 有关,故导致钱包地址可被人手动推算出来。导致任何通过 Factory 合约这一方法创建钱包的人的钱包地址都可以被推断,出现安全隐患。

地址的推算方法如下:

    // 首先拿到工厂合约的地址:      address factory = 0xa6b71e26c5e0845f74c812102ca7114b6a896ab2;      假设 nonce = 1, 则 RLP((s,n)) 为:      ethers.utils.RLP.encode(["0xa6b71e26c5e0845f74c812102ca7114b6a896ab2","0x01"])      =>      RLP((factory,nonce)) = 0xd694a6b71e26c5e0845f74c812102ca7114b6a896ab201      Keccak256(RLP((factory,nonce))) =  0x4c2134364fb2823682748fe543e77ba9f5e59cefb97d55cf58641ebb7beb22c4      address = 0x43e77ba9f5e59cefb97d55cf58641ebb7beb22c4  

使用 create2 这一 OPCODE 就没有这个问题,但使用 create2 时,需要理解构造函数中的参数应该怎么传入进去:

Arguments for the constructor of a contract are directly appended at the end of the contract’s code, also in ABI encoding. The constructor will access them through a hard-coded offset, and not by using the codesize opcode, since this of course changes when appending data to the code.

即将 contructor 里的参数直接以 ABI 编码后贴在 contract.creationCode 里。

    bytes memory data = abi.encode(type(GnosisSafeProxy).creationCode, uint256(uint160(singleton)))  

注意点 2:代理合约与实现合约的 Storage 插槽排布是否一致

由于代理合约 GnosisSafeProxy 与实现合约 GnosisSafe 是通过 delegatecall 来调用,故需要仔细检查两边的插槽排布,需让其保持一致。

首先是 GnosisSafeProxy 代理合约:

    slot_00 => singleton  

然后是 GnosisSafe 实现合约

    contract GnosisSafe is          EtherPaymentFallback,          Singleton,          ModuleManager,          OwnerManager,          SignatureDecoder,          SecuredTokenTransfer,          ISignatureValidatorConstants,          FallbackManager,          StorageAccessible,          GuardManager      EtherPaymentFallback => 无全局变量      Singleton => 有全局变量 slot_00 => singleton      ModuleManager is SelfAuthorized, Executor      SelfAuthorized => 无全局变量      Executor => 无全局变量      ModuleManager => 有全局变量 mapping(address => address) internal modules      OwnerManager is SelfAuthorized      SelfAuthorized => 无全局变量      OwnerManager => 有全局变量 mapping(address => address) internal owners;                              uint256 internal ownerCount;                                uint256 internal threshold;      SignatureDecoder => 无全局变量      SecuredTokenTransfer => 无全局变量      ISignatureValidatorConstants => 无全局变量      FallbackManager is SelfAuthorized      SelfAuthorized => 无全局变量      FallbackManager => 有全局变量 keccak256("fallback_manager.handler.address") => fallback_handler      StorageAccessible => 无全局变量      GuardManager is SelfAuthorized      SelfAuthorized => 无全局变量      GuardManager => 有全局变量 keccak256("guard_manager.guard.address") => set_guard      GnosisSafe => 有全局变量 uint256 public nonce;                              bytes32 private_deprecatedDomainSeparator;                              mapping(bytes32 => uint256) public signedMessages;                              mapping(address => mapping(bytes32 => uint256)) public approvedHashes;  

将上面的 GnosisSafe 实现合约的插槽整理如下:

    slot_00 => singleton      slot_01 => mapping(address => address) internal modules      slot_02 => mapping(address => address) internal owners;      slot_03 => uint256 internal ownerCount;      slot_04 => uint256 internal threshold;      slot_05 => uint256 public nonce;      slot_06 => bytes32 private_deprecatedDomainSeparator;      slot_07 => mapping(bytes32 => uint256) public signedMessages;      slot_08 => mapping(address => mapping(bytes32 => uint256)) public approvedHashes;      keccak256("fallback_manager.handler.address") => fallback_handler      keccak256("guard_manager.guard.address") => set_guard  

可以看到代理合约 Proxy 和实现合约 GnosisSafe 的插槽并不完全一致,但是在代理合约 Proxy 的插槽排布中,slot_00 位置处的值都是 singleton ,并未出现碰撞。可能是 Gnosis 想让 proxy 合约尽可能小,所以这样设计。

注意点 3:与 compound 的 Unitroller 部分对比

Compound 中的 Unitroller 是一个可升级合约架构,即其对应的实现 comptrollerImplementation 合约地址可以通过 Unitorller 中的方法去更改,从而实现合约升级。而 GnosisSafeProxy 并不是一个可升级合约架构,它对应的实现 singleton 是在初始化时就写死的,没有办法去更改实现。

作为一个代理合约,其实现地址通常需要在创建时就传入进去,然后再调用 init 方法来进行初始化。

参考资料

[1]

上篇文章 : https://learnblockchain.cn/article/2980

[2]

Gas 费用 : https://learnblockchain.cn/2019/06/11/gas-mean

[3]

多签 : https://learnblockchain.cn/article/1127

GnosisSafe - 合约结构分析