概述

深入解析Safe多签钱包智能合约:模块中分析FallbackManager模块时,限于篇幅限制且fallback合约自成一体,所以我们没有介绍具体的fallback模块。此篇文章的主要目的是完成这一缺陷,全面介绍fallback合约。

本文涉及的代码主要位于src/handler内,读者可自行查阅此仓库

合理性分析

此节主要关注于我们为什么需要Fallback合约这一主题,希望可以为读者在后文阅读源代码时起到提纲挈领的作用。

Fallback

在上文中,我们可以知道fallback函数的主体逻辑是进行了代理合约式的处理将逻辑代码交给此处的fallback合约执行。我们首先应当知道fallback函数的作用,此函数用于接受一切无法与其他函数名匹配到的调用都会被发送到fallback函数中,进一步这些调用被转发到fallback合约内。

我们可以认为fallback合约提供了对于GnosisSafe主合约的强大补充,避免了GnosisSafe因不具有某些函数而导致无法进行关键功能。在目前,fallback合约提供了以下功能:

  1. 向前兼容1.3.0之前的safe合约的功能
  2. 接受 ERC1155 代币的功能
  3. 接受 ERC721 NFT 的功能
  4. 基于ERC165实现的向外界暴露接口实现的功能

这些功能体现了Safe合约开发团队的一个基本目标,即尽可能保持GnosisSafe主合约的稳定性,将部分新的必要的功能放在可以通过简单的代理方式升级的fallback合约中。当然,也将部分兼容性功能放在了fallback合约内。

当然,这不意味着我们可以大肆修改fallback合约以增加功能。在代码设计上,复杂而非必要的功能应该以modules的形式开发。

在最后,我们希望可以获得任一Safe合约的fallback合约地址。为达成这一目的,我们需要查询fallback合约地址存储的变量。在src/base/FallbackManager.sol中,我们可以看到如下定义:

bytes32 internal constant FALLBACK_HANDLER_STORAGE_SLOT = 0x6c9a6c4a39284e37ed1cf53d337577d14212a4870fb976a4366c693b939918d5;

此变量属于internal,这意味着似乎无法从外界进行读取。实际上,所有的solidity变量均可以被读取,但前提是需要知道变量的具体位置。而上述FALLBACK_HANDLER_STORAGE_SLOT正好告诉了我们变量的存储位置,我们可以通过以下命令读取:

cast storage 0xDE06d17Db9295Fa8c4082D4f73Ff81592A3aC437 0x6c9a6c4a39284e37
ed1cf53d337577d14212a4870fb976a4366c693b939918d5 --rpc-url https://rpc.ankr.com/eth

其中,0xDE06d17Db9295Fa8c4082D4f73Ff81592A3aC437为我选择的Lido名下的一个safe多签钱包地址,读者可以自行替换为其他多签钱包地址。

代码运行结果如下:

0x000000000000000000000000f48f2b2d2a534e402487b3ee7c18c33aec0fe5e4

这正是在存储中的“裸”地址(没有被编码),其代表的真实地址为f48f2b2d2a534e402487b3ee7c18c33aec0fe5e4,其etherscan地址在

Receiver

我们在上文提到fallback合约提供了这两个功能:

  1. 接受 ERC1155 代币的功能
  2. 接受 ERC721 NFT 的功能

可能有读者比较困惑,我们直接进行转账等行为时似乎没有这些要求。这是因为我们一直使用的时非安全转账函数,如transferFrom。而ERC721ERC1155都提供了安全转账函数safeTransferFrom。此函数提供一个对代币接受者的校验,避免代币转移到无法接受代币的地址内。

为了方便读者理解,我们给出一个solmate中的NFT合约中的安全转账函数实现,代码如下:

function safeTransferFrom(
    address from,
    address to,
    uint256 id
) public virtual {
    transferFrom(from, to, id);

    require(
        to.code.length == 0 ||
            ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") ==
            ERC721TokenReceiver.onERC721Received.selector,
        "UNSAFE_RECIPIENT"
    );
}

我们可以看到此safe函数在transferFrom增加了to.code.length == 0 || ERC721TokenReceiver(to).onERC721Received(msg.sender, from, id, "") == ERC721TokenReceiver.onERC721Received.selector条件来校验接收地址to是否具有接受代币的资格。当地址满足以下两个条件之一即可接受代币:

  1. 接受地址为EOA(即使用私钥控制的地址),此条件提供to.code.length == 0判断地址是否存在代码来实现。当地址内没有代码时,我们可以认为此地址由用户私钥控制。

  2. 接受地址实现了onERC721Received接口并返回0x150b7a02

读者可能已经发现了此处要求接受合约满足实现接口的条件。而GnosisSafe开发者为了避免用户在使用safe系列转账函数时,合约无法接受代币,所以在Fallback合约内实现了这些要求的Receiver接口。

关于这些接口实现的细节,读者可以自行参考ERC721ERC1155中的详细规定。

除此之外,我们还在fallback合约中实现了另一个较为常用的EIP-165标准用来辅助实现Receiver接口的实现。EIP165的功能是方便其他合约可以通过supportsInterface()函数判断合约是否实现了某一个接口,代码如下:

function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) {
    return
        interfaceId == type(ERC1155TokenReceiver).interfaceId ||
        interfaceId == type(ERC721TokenReceiver).interfaceId ||
        interfaceId == type(IERC165).interfaceId;
}

代码较为简单,其中interfaceId对于每一个接口都有唯一的值与之对应。其他合约可以提供一个特定接口的interfaceId调用supportsInterface函数,如果对方合约实现了此接口就会返回True,否则就返回Fasle

代码分析

相信读者通过上一节已经对fallback合约基本功能有了一定的认识,此节我们会按照上文给出的fallback合约的功能逐一介绍代码实现。

兼容代码分析

在此部分,我们会看到一些在 1.2.0 版本GnosisSafe主合约中实现的函数,此部分函数很多都已被废弃,我们不建议使用此部分函数用于实际操作。

isValidSignature 1

此函数我们在GnosisSafe中介绍合约签名曾使用此函数。当合约实现此函数时,就意味着合约可以实现合约签名的特性。该特性由EIP1271规定,具体可以参考基于链下链上双视角深入解析以太坊签名与验证文章。

此函数需要以下参数:

  • _data 需要验证签名的原数据
  • _signature 签名数据,用户可以通过getMessageHash获得代签数据的哈希值后使用自己的私钥进行签名

具体代码如下:

function isValidSignature(bytes calldata _data, bytes calldata _signature) public view override returns (bytes4) {
    // Caller should be a Safe
    GnosisSafe safe = GnosisSafe(payable(msg.sender));
    bytes32 messageHash = getMessageHashForSafe(safe, _data);
    if (_signature.length == 0) {
        require(safe.signedMessages(messageHash) != 0, "Hash not approved");
    } else {
        safe.checkSignatures(messageHash, _data, _signature);
    }
    return EIP1271_MAGIC_VALUE;
}

我们首先将msg.sender初始化为safe合约变量,这使我们可以使用safe合约内的变量和函数。

我们在FallbackManager模块中使用了call进行调用代理合约,所以此处msg.sender正是发起请求的safe合约地址。

然后,我们通过getMessageHashForSafe函数获得_data对于的签名字段。对于getMessageHashForSafe函数,我们会在下文进行详细介绍。

获得_data的签名字段后,我们进入分支判断,当满足以下任一条件后我们会返回EIP1271_MAGIC_VALUE,即0x20c13b0b:

  1. _signature签名为空,但我们可以在GnosisSafe主合约内查询到该用户在之前对于此messageHash进行过授权
  2. _signature不为空,且经过调用GnosisSafe主合约的checkSignatures函数发现签名正确

getMessageHash

此函数需要以下参数:

  • message 需要验证签名的数据

此函数会返回用于签名的代签数据。由于此函数过于简单,我们在此处也给出其核心逻辑的实现函数getMessageHashForSafe,具体代码如下:

function getMessageHashForSafe(GnosisSafe safe, bytes memory message) public view returns (bytes32) {
    bytes32 safeMessageHash = keccak256(abi.encode(SAFE_MSG_TYPEHASH, keccak256(message)));
    return keccak256(abi.encodePacked(bytes1(0x19), bytes1(0x01), safe.domainSeparator(), safeMessageHash));
}

在此处,我们首先将待签数据与SAFE_MSG_TYPEHASH拼接在一起,然后将以下数据进行拼接:

0x19 || 0x01 || domainSeparator || safeMessageHash

此种hash值获得的方式类似EIP712结构化哈希,具体可以参考这篇博客

isValidSignature 2

此函数用于协调GnosisSafe合约对EIP1271的特殊改造版本与标准版本。简单来说,GnosisSafe规定EIP1271_MAGIC_VALUE,即验证签名成功后返回的签名为0x20c13b0b,这与EIP1271标准规定的0x1626ba7e是不符的。为了协调两者,使GnosisSafe也具有通用的合约签名能力,开发者设计了此函数。

其代码实现如下:

function isValidSignature(bytes32 _dataHash, bytes calldata _signature) external view returns (bytes4) {
    ISignatureValidator validator = ISignatureValidator(msg.sender);
    bytes4 value = validator.isValidSignature(abi.encode(_dataHash), _signature);
    return (value == EIP1271_MAGIC_VALUE) ? UPDATED_MAGIC_VALUE : bytes4(0);
}

较为简单,核心在于使用接口实现将msg.sender(即GnosisSafe主合约)初始化为ISignatureValidator对象,使用isValidSignature函数判断去签名是否正确,并在最后使用三目表达式返回标准版本的EIP1271_MAGIC_VALUE

此处的函数名isValidSignature与我们在此节介绍的第一个函数名是相同的,但两者所需要参数类型不同,所以abi编码后的结果也不同,读者不必担心混淆问题。

getModules

为兼容老版本函数名所设计的函数,在当前版本种可使用getModulesPaginated函数代替。其实现就是对getModulesPaginated的简单封装,不进行解释。

simulate

用于模拟代码在目标合约内运行,此函数需要以下参数:

  • targetContract 需要运行代码的目标合约
  • calldataPayload 需要目标合约运行的字节码Bytecode

此函数一个较为现代的实现为src/common/StorageAccessible.sol合约中的simulateAndRevert函数。此函数本质上就是对simulateAndRevert的包装。

在目前以太坊中,如果读者想测试合约运行字节码的情况,建议使用eth_call接口,或者使用foundry封装好的cast call命令。eth_call会使代码在合约内运行但不会消耗gas和改变区块链状态。

此函数的代码较为复杂,但所幸开发者为我们留下了大量注释。在函数体的开始,开发者使用了以下代码:

targetContract;
calldataPayload;

根据注释,我们可以知道把两个变量直接放在函数体开始仅是为了避免编译器报错。核心代码位于assembly内。

在汇编代码块内,如往常一样,通过mload操作码在0x40地址内读取指向空闲内存的指针。接下来使用mstore(internalCalldata, "\xb4\xfa\xba\x09")向指针对于的内存中写入0x64faba09,即simulateAndRevert(address,bytes)(我们在上文提及的StorageAccessible.sol中的simulateAndRevert)函数的签名。

使用\xb4\xfa\xba\x09不足够直观,读者可使用hex"64faba09"代替,后者也可以直接将 16 进制字符转为bytes

接下来我们构建一个用于请求simulateAndRevert完整的calldata。这意味着我们需要把以下calldata:

sig(simulate) || args

转换为:

sig(simulateAndRevert) || args

上述calldata中的sig()指获得函数签名的方法,即对函数名及参数进行keccak256哈希计算,可以使用cast sig进行直接调用,详情可参考此篇博客

上述args指用户输入的参数,由于simulatesimulateAndRevert使用参数相同,所以不必继续修改。||表示拼接。

简单来说,我们只需要将calldata中的前 4 字节进行替换。我们使用calldatacopycalldata进行复制。calldatacopy需要以下参数:

  • destOffset 复制到内存中的起始位置
  • offset 需要复制的数据在calldata中的起始位置
  • size 复制的calldata长度

根据上文结论,我们只需要将calldata中的自第 4 bytes 开始的数据复制到内存中的internalCalldata的第 4 bytes 后,翻译成代码为:

calldatacopy(add(internalCalldata, 0x04), 0x04, sub(calldatasize(), 0x04))

此代码正好可以完成对internalCalldata的构建。

接下来,我们进行call操作,但在进行call操作前,我们使用了pop在内联汇编内返回数据。

我们在之前解释的含有内联汇编的函数均没有返回值,此函数作为含有返回值的函数较为特殊

pop内部,使用了一个较为传统的call函数,因为我们在此前已多次解释过此函数,所以在此处不进行详细解释,具体可参考文档。一个很特殊的对方是此处直接选择将callsuccess返回值(即call返回值的前 32 bytes ,用来标志call请求是否成功)写入内存,而没有选择传统的先写入return data区域再复制的方法。原因在于call返回的success变量长度固定为 32 bytes ,直接写入内存是可行的。

不建议将call返回的其他数据写入内存,原因在于长度未知可能导致内存覆写

此处选择将success写入内存的0x00区域,此处属于scratch space for hashing methods,写入此区域具有安全性,且不用考虑0x40指针移动问题。

在处理完success部分后,我们需要考虑call返回值中的实际数据部分。在此处,我们先计算返回值长度let responseSize := sub(returndatasize(), 0x20),只是在returndatasize基础上减去success的长度0x20

接下来,我们避免内存覆写问题,我们需要手动调整0x40指向的区域,即原有指针增加responseSize。具体使用的代码如下:

response := mload(0x40)
mstore(0x40, add(response, responseSize))

调整完指针后,我们在将returndata中的非success部分进行复制,使用returndatacopy(response, 0x20, responseSize)。最终,我们判断success是否为0,如果为0,则证明调用失败,应中止调用。在上文中,我们把success变量存储到了0x00位置,此处只需要使用mload提取即可,代码如下:

if iszero(mload(0x00)) {
    revert(add(response, 0x20), mload(response))
}

当然,在revert时我们也返回一段内存信息。

接受代码分析

此部分主要介绍用于适配safe系列转账函数的代码,较为简单,大多只需要返回一个Magic value即可。此部分的代码位于src/handler/DefaultCallbackHandler.sol内、。

较为简单,不再进行解析。

总结

本文介绍了GnosisSafe模块中较为简单的最后一部分fallback。涉及以下内容:

  1. EIP-165返回支持的接口ID
  2. EIP-721、EIP-1155 的safe系列交易