GnosisSafe.sol 学习

Posted MateZero

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了GnosisSafe.sol 学习相关的知识,希望对你有一定的参考价值。

GnosisSafe是以太坊区块链上最流行的多签钱包!它的最初版本叫 MultiSigWallet,现在新的钱包叫Gnosis Safe,意味着它不仅仅是钱包了。它自己的介绍为:以太坊上的最可信的数字资产管理平台(The most trusted platform to manage digital assets on Ethereum)。

上次我们学习到了OwnerManager.sol,这次我们按着导入的顺序接着学习。

1. SignatureDecoder.sol

首先看源码:

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;

/// @title SignatureDecoder - Decodes signatures that a encoded as bytes
/// @author Richard Meissner - <richard@gnosis.pm>
contract SignatureDecoder 
    /// @dev divides bytes signature into `uint8 v, bytes32 r, bytes32 s`.
    /// @notice Make sure to peform a bounds check for @param pos, to avoid out of bounds access on @param signatures
    /// @param pos which signature to read. A prior bounds check of this parameter should be performed, to avoid out of bounds access
    /// @param signatures concatenated rsv signatures
    function signatureSplit(bytes memory signatures, uint256 pos)
        internal
        pure
        returns (
            uint8 v,
            bytes32 r,
            bytes32 s
        )
    
        // The signature format is a compact form of:
        //   bytes32 rbytes32 suint8 v
        // Compact means, uint8 is not padded to 32 bytes.
        // solhint-disable-next-line no-inline-assembly
        assembly 
            let signaturePos := mul(0x41, pos)
            r := mload(add(signatures, add(signaturePos, 0x20)))
            s := mload(add(signatures, add(signaturePos, 0x40)))
            // Here we are loading the last 32 bytes, including 31 bytes
            // of 's'. There is no 'mload8' to do this.
            //
            // 'byte' is not working due to the Solidity parser, so lets
            // use the second best option, 'and'
            v := and(mload(add(signatures, add(signaturePos, 0x41))), 0xff)
        
    

SignatureDecoder 顾名思义,用来解码签名,它只有一个函数signatureSplit,从它的注释和参数中我们可以猜出signatures是一个包含了多个签名的bytes变量(肯定和多签相关了),pos用来指明解码哪个签名,相当于数组的索引。 返回的结果就是解码后的v,r,s了。

注释中也提到,签名是紧密排列的,其格式为bytes32 rbytes32 suint8 v,因此其大小为0x41字节(两个 0x20 和一个0x1)。

注意中提到,在调用之前要进行边界检查,函数里是没有进行检查的。

我们重点看一下如下四行内嵌汇编:

  let signaturePos := mul(0x41, pos)
  r := mload(add(signatures, add(signaturePos, 0x20)))
  s := mload(add(signatures, add(signaturePos, 0x40)))
  v := and(mload(add(signatures, add(signaturePos, 0x41))), 0xff)

第一行,计算某个签名的偏移量(起始位置),我们提到了一个签名的大小是0x41(65)个字节,因此相应pos的领衔量为 0x41 * pos.

第二行,分离出r的值。这里很简单,就是读取r开始地址的一个word就行了。注意到这里又add了一个0x20,是为了略过长度前缀。

第三行,同样的方法分离s的值,这里add 0x40是因为长度前缀一个word,r一个word,所以我们得跳过两个word。

第四行,分离出 v值的办法稍微有一些技巧,按道理这里应该读取add 0x60(长度前缀 + 前面的r,s),但是这样读取后的32字节只有第一个字节是我们需要的数据,后来都是无用的冗余数据。而处理32字节头部远比处理32字节尾部麻烦,因此,从后往前推一个字节读取,把欲分离的变量读取的word的最后,就是另一种思路。所以代码里它直接从add 0x41开始读,这样,v 就包含在读取的32字节的最后,我们只要把它和0xff按位与,就能把前面的冗余数据清零,得到最终的结果。

这里注释'byte' is not working due to the Solidity parser的意思是 字节(uint8)无法直接用于Solidity解析,所以借助了工具and

分离出v类似的技巧还可以应用于分离地址类型的值,因为地址类型其实是uint160,不完全占用32字节,当然其它非32字节的固定大小类型也可以分离。

笔者注:其实最后可以不使用and操作,因为将结果赋值给了uint8会直接截断得到最后的一个字节。类似的示例见UniswapV3中BytesLib.sol中的toUint24函数。代码如下:

function toUint24(bytes memory _bytes, uint256 _start) internal pure returns (uint24) 
    require(_start + 3 >= _start, 'toUint24_overflow');
    require(_bytes.length >= _start + 3, 'toUint24_outOfBounds');
    uint24 tempUint;

    assembly 
        tempUint := mload(add(add(_bytes, 0x3), _start))
    

    return tempUint;

不过,bool类型赋值时不是只看最后一位,只要整个word不为0就是true。

2. SecuredTokenTransfer.sol

这个和UniswapV2的_safeTransfer及Openzeppelin的SafeERC20类似,都是使用底层调用来转移ERC20代币。为什么要使用底层调用呢?因为部分早期的代币不是标准的ERC20代币(例如USDT)其transfer函数没有返回值,使用contract.transfer方式会由于函数返回值检查通不过而失败。使用底层调用能绕过这个返回值检查。

本合约源码如下:

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;

/// @title SecuredTokenTransfer - Secure token transfer
/// @author Richard Meissner - <richard@gnosis.pm>
contract SecuredTokenTransfer 
    /// @dev Transfers a token and returns if it was a success
    /// @param token Token that should be transferred
    /// @param receiver Receiver to whom the token should be transferred
    /// @param amount The amount of tokens that should be transferred
    function transferToken(
        address token,
        address receiver,
        uint256 amount
    ) internal returns (bool transferred) 
        // 0xa9059cbb - keccack("transfer(address,uint256)")
        bytes memory data = abi.encodeWithSelector(0xa9059cbb, receiver, amount);
        // solhint-disable-next-line no-inline-assembly
        assembly 
            // We write the return value to scratch space.
            // See https://docs.soliditylang.org/en/v0.7.6/internals/layout_in_memory.html#layout-in-memory
            let success := call(sub(gas(), 10000), token, 0, add(data, 0x20), mload(data), 0, 0x20)
            switch returndatasize()
                case 0 
                    transferred := success
                
                case 0x20 
                    transferred := iszero(or(iszero(success), iszero(mload(0))))
                
                default 
                    transferred := 0
                
        
    

这里的代码完全是内嵌汇编,和以前的稍有不同。一是call调用时预留了1000gas,另一个是将返回值写在了第内存的第一个字节。

接下来的switch case倒是简单,如果没有返回值,则返回是否成功(这时1代表成功,0代表失败)。如果有返回值(只有一个word),那么根据是否调用成功和返回值是否为0来得到最后的transferred。如果返回值不只一个word,则表示不能处理的返回值,直接算失败。

注意,这里的代码并没有重置交易,只是返回了一个代表是否转移成功的布尔值。在实际使用中一定要根据不同的返回值进行处理。

如果想失败时直接重置交易,直接使用_safeTransfer倒是个不错的选择,相关代码如下:

bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));
function _safeTransfer(address token, address to, uint value) private 
    (bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value));
    require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED');

3. ISignatureValidator.sol

这个合约很简单,源码如下:

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;

contract ISignatureValidatorConstants 
    // bytes4(keccak256("isValidSignature(bytes,bytes)")
    bytes4 internal constant EIP1271_MAGIC_VALUE = 0x20c13b0b;


abstract contract ISignatureValidator is ISignatureValidatorConstants 
    /**
     * @dev Should return whether the signature provided is valid for the provided data
     * @param _data Arbitrary length data signed on the behalf of address(this)
     * @param _signature Signature byte array associated with _data
     *
     * MUST return the bytes4 magic value 0x20c13b0b when function passes.
     * MUST NOT modify state (using STATICCALL for solc < 0.5, view modifier for solc > 0.5)
     * MUST allow external calls
     */
    function isValidSignature(bytes memory _data, bytes memory _signature) public view virtual returns (bytes4);

这个类似ERC721中的onERC721Received,也就是给别人一个执行完成后的回调,返回0x20c13b0b这个常量。

4. FallbackManager.sol

这个从名字可以看出来,是回调管理,到底是什么回调呢?其实是Solidity合约的默认回调函数fallback()

先上源码:

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;

import "../common/SelfAuthorized.sol";

/// @title Fallback Manager - A contract that manages fallback calls made to this contract
/// @author Richard Meissner - <richard@gnosis.pm>
contract FallbackManager is SelfAuthorized 
    event ChangedFallbackHandler(address handler);

    // keccak256("fallback_manager.handler.address")
    bytes32 internal constant FALLBACK_HANDLER_STORAGE_SLOT = 0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5;

    function internalSetFallbackHandler(address handler) internal 
        bytes32 slot = FALLBACK_HANDLER_STORAGE_SLOT;
        // solhint-disable-next-line no-inline-assembly
        assembly 
            sstore(slot, handler)
        
    

    /// @dev Allows to add a contract to handle fallback calls.
    ///      Only fallback calls without value and with data will be forwarded.
    ///      This can only be done via a Safe transaction.
    /// @param handler contract to handle fallbacks calls.
    function setFallbackHandler(address handler) public authorized 
        internalSetFallbackHandler(handler);
        emit ChangedFallbackHandler(handler);
    

    // solhint-disable-next-line payable-fallback,no-complex-fallback
    fallback() external 
        bytes32 slot = FALLBACK_HANDLER_STORAGE_SLOT;
        // solhint-disable-next-line no-inline-assembly
        assembly 
            let handler := sload(slot)
            if iszero(handler) 
                return(0, 0)
            
            calldatacopy(0, 0, calldatasize())
            // The msg.sender address is shifted to the left by 12 bytes to remove the padding
            // Then the address without padding is stored right after the calldata
            mstore(calldatasize(), shl(96, caller()))
            // Add 20 bytes for the address appended add the end
            let success := call(gas(), handler, 0, 0, add(calldatasize(), 20), 0, 0)
            returndatacopy(0, 0, returndatasize())
            if iszero(success) 
                revert(0, returndatasize())
            
            return(0, returndatasize())
        
    

我们知道,GnosisSafe采用的是一个代理/实现模式。我们实际调用的合约是GnosisSafeProxy,它会将所有调用通过fallback全部委托调用GnosisSafe执行,

也就是这里已经回调过一次了,那么在实现合约中能否再实现一个fallback再次进行调用/委托调用呢?答案是肯定的。本合约指定了一个特定插槽,将插槽的内容不为空地址时,则将消息调用转发到插槽对应的地址执行(注意是call调用),同时,会在原payload上添加调用者的地址。

至于为什么要这样做,暂时还未清楚,等后面学习后也许就可以解惑了。

4.1 internalSetFallbackHandler 函数

内部调用,就是设置插槽的值,很简单

4.2 setFallbackHandler 函数

外部调用,注意限定了自调用。就是设置插槽的值再加触发一个事件。注意,这里是可以设置零地址的,这样就不用再次调用其它合约了。

4.3 fallback 函数

这个也比较简单,首先读取插槽值,如果为零地址,则直接退出执行。否则的话将calldata数据(payload)全部复制到内存中0地址开始的地方,注意这里并未使用变量,因此是没有长度前缀的,内存中零地址开始到calldatasize的内容就是payload的值。

mstore(calldatasize(), shl(96, caller())) 是在后面添加了一个msg.sender地址,为了节省空间,将caller(msg.sender)向左移动了12个字节(地址是20字节40位,一个word是32字节,所以左移12个字节相当于把地址放在一个word的开头位置)。最后使用mstore添加在payload后。这样0 ~ calldatasize就是原payload,calldatasize ~ calldatasize + 32就是原msg.sender。有人说,你这并未节省啊,因为mstore还是操作的word,你还是占32字节,请往下看。

let success := call(gas(), handler, 0, 0, add(calldatasize(), 20), 0, 0)

我们再次回顾一个call函数的简单定义:call(g, a, v, in, insize, out, outsize)

对比一下,我们可以看到以前使用时insize的值为calldatasize,这次的值为calldatasize + 20,这样我们就把内存中复制的旧payload后附加的地址一并作为新的payload发送了出去。这是是add 20不是add 0x20,所以call左移12字节后最右边的12字节的0并没有作为新的payload发送出去。

后面的执行同原来的示例类似,注意 calldelegatecall返回0代表失败,1代表成功。

这里有一个风险点:当回调地址插槽FALLBACK_HANDLER_STORAGE_SLOT的内容为任一EOA账号(Externally Owned Account)时,由于EOA账号没有代码,此时call执行会成功(这是由于EVM的特性造成的,其它项目有过类似的安全事故)。所以在执行前必须小心设定该插槽的内容。

5. StorageAccessible.sol

本合约只有两个函数,一个函数getStorageAt是获取连续的多个内存插槽的内容,另一个simulateAndRevert是模拟执行并返回结果。

我们贴了源码后分别学习。

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;

/// @title StorageAccessible - generic base contract that allows callers to access all internal storage.
/// @notice See https://github.com/gnosis/util-contracts/blob/bb5fe5fb5df6d8400998094fb1b32a178a47c3a1/contracts/StorageAccessible.sol
contract StorageAccessible 
    /**
     * @dev Reads `length` bytes of storage in the currents contract
     * @param offset - the offset in the current contract's storage in words to start reading from
     * @param length - the number of words (32 bytes) of data to read
     * @return the bytes that were read.
     */
    function getStorageAt(uint256 offset, uint256 length) public view returns (bytes memory) 
        bytes memory result = new bytes(length * 32);
        for (uint256 index = 0; index < length; index++) 
            // solhint-disable-next-line no-inline-assembly
            assembly 
                let word := sload(add(offset, index))
                mstore(add(add(result, 0x20), mul(index, 0x20)), word)
            
        
        return result;
    

    /**
     * @dev Performs a delegetecall on a targetContract in the context of self.
     * Internally reverts execution to avoid side effects (making it static).
     *
     * This method reverts with data equal to `abi.encode(bool(success), bytes(response))`.
     * Specifically, the `returndata` after a call to this method will be:
     * `success:bool || response.length:uint256 || response:bytes`.
     *
     * @param targetContract Address of the contract containing the code to execute.
     * @param calldataPayload Calldata that should be sent to the target contract (encoded method name and arguments).
     */
    function simulateAndRevert(address targetContract, bytes memory calldataPayload) external 
        // solhint-disable-next-line no-inline-assembly
        assembly 
            let success := delegatecall(gas(), targetContract, add(calldataPayload, 0x20), mload(calldataPayload), 0, 0)

            mstore(0x00, success)
            mstore(0x20, returndatasize())
            returndatacopy(0x40, 0, returndatasize())
            revert(0, add(returndatasize(), 0x40))
        
    

5.1 getStorageAt 函数

我们知道,在Solidity中,内存是使用字节为单位的,比如地址0x20,则是从0x20字节开始。但是存储却是以插槽为单位的,理论上插槽是从1到2**256-1个。

所以getStorageAt的两个参数分别为:offset,起始插槽,length欲获取内容的插槽数量。这两个弄明白了后面就很好解决了。

因为插槽内容也是32字节(key-value都是一个word,32字节),所以bytes memory result = new bytes(length * 32); 先分配了一块内存区域来保存返回结果。

接下来一个For循环遍历读取每个欲获取的插槽内容,并且写入到result中对应的位置去。这里为什么会有add(result, 0x20)呢?记住result是个bytes,动态类型,它有个长度前缀,所以得跳过这个长度前缀word。

5.2 simulateAndRevert 函数

这个函数逻辑倒是不难,难点在应用上。我们直接学习汇编代码。

首先,进行了一个委托调用,这里delegatgecall我们已经学习很多次了,所以可以直接看明白。

接下来,将是否调用成功存在了内存第一个32字节,将返回的数据大小存在了内存的第二个32字节。将返回的数据存在了内存第三个32字节开始的地方。

接下来重置交易,因为是模拟执行,所以不能改变状态,然后将内存中returndatasize + 0x40大小的内容(其实就是包含了返回数据和内存中前两个word)作为重置原因返回。

可以看到,该函数为非view函数,因此就算是调用失败了,也仍然会记录在链上,仍然要花费gas。所以这里免费调用可能需要一定的技巧,例如在ethers.js中使用provider.call来调用(不绑定钱包,这样就不花费了),然后catch返回的结果就可以了(暂未测试验证,待有空验证)。

6. GuardManager.sol

先上源码:

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;

import "../common/Enum.sol";
import "../common/SelfAuthorized.sol";

interface Guard 
    function checkTransaction(
        address to,
        uint256 value,
        bytes memory data,
        Enum.Operation operation,
        uint256 safeTxGas,
        uint256 baseGas,
        uint256 gasPrice,
        address gasToken,
        address payable refundReceiver,
        bytes memory signatures,
        address msgSender
    ) external;

    function checkAfterExecution(bytes32 txHash, bool success) external;


/// @title Fallback Manager - A contract that manages fallback calls made to this contract
/// @author Richard Meissner - <richard@gnosis.pm>
contract GuardManager is SelfAuthorized 
    event ChangedGuard(address guard);
    // keccak256("guard_manager.guard.address")
    bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8;

    /// @dev Set a guard that checks transactions before execution
    /// @param guard The address of the guard to be used or the 0 address to disable the guard
    function setGuard(address guard) external authorized 
        bytes32 slot = GUARD_STORAGE_SLOT;
        // solhint-disable-next-line no-inline-assembly
        assembly 
            sstore(slot, guard)
        
        emit ChangedGuard(guard);
    

    function getGuard() internal view returns (address guard) 
        bytes32 slot = GUARD_STORAGE_SLOT;
        // solhint-disable-next-line no-inline-assembly
        assembly 
            guard := sload(slot)
        
    

可以看到,本合约源文件包含一个接口定义和一个简单的检查合约。

接口 Guard定义了一个函数用来检查交易,还定义了一个函数用来要执行后检查(具体逻辑未知)。

合约GuardManager定义了一个插槽GUARD_STORAGE_SLOT用来保存实现Guard接口的合约的地址,并分别提供了getset函数。注意set函数是限定自调用的。

好了,到此我们GnosisSafe合约所有的导入合约都学习完毕,从学习中我们可以看出,它采用模块化设计的,导入的合约虽然多,但是每个都是独立实现了一个小功能,方便复用。

有了这些导入合约学习作基础,我们接下来便可学习GnosisSafe合约的正文了。

以上是关于GnosisSafe.sol 学习的主要内容,如果未能解决你的问题,请参考以下文章

GnosisSafe.sol 学习

GnosisSafe.sol 学习

GnosisSafe.sol 学习

GnosisSafe.sol 学习

GnosisSafe.sol 学习

GnosisSafe.sol 学习