概述

Safe(或称Gnosis Safe)是目前在以太坊中使用最为广泛的多签钱包。本文主要解析此钱包的逻辑设计和代码编写。

读者可以前往Safe Contracts获得源代码。

预备知识

Safe优势

作为智能合约钱包,Safe支持多签名批准交易。这带来了以下优势:

  1. 更高的安全性。将资产放置在多签钱包内可以有效避免因为个人单一私钥的泄露而导致的资产丢失。用户可以将多签设置为2-of-3形式,个人保存两个私钥并将第三个私钥作为备份。当遭受黑客攻击时,泄露1个私钥对资产安全性没有影响。

  2. 更加高级的交易设置。相对于以太坊用户,智能合约具有可编程性,这意味着用户可以自行编辑一些交易逻辑,比如将多个交易聚合起来一起执行(batched transactions)。此部分由Safe Library contracts提供。

  3. 更加灵活的访问管理。用户可以在钱包内加入具有特定功能的模块,比如限制单一用户每日最大可批准金额。这对于DAO是十分有用的。此部分由`Safe Modules提供。

上述仅仅是对Safe优势的简单介绍。如果读者想了解更多关于此方面的介绍,请参考Gnosis Safe 官网

以太坊账户

在以太坊网络中,具有地址的账户被分为以下两类:

  • EOA(externally owned accounts) 我们平常使用的使用账户均属于这一类型。这一类型的账户具有公钥和私钥。

  • Contract accounts 合约账户。我们创建的合约也均有对应的区块地址,但没有私钥可以用于签名等操作,这一类型的账户被称为合约账户。与EOA相比,合约账户内存在代码逻辑,可以进行编写一些复杂操作。

值得注意的是,在以太坊中,EOA与合约账户是被同等对待的。合约账户可以发送交易,也可以接受ETH。

多签钱包

多签钱包是指需要使用多个私钥进行签名完成交易的钱包。它们的形式一般被标记为m-of-n,即需要n个签名人中的m个签名人进行签名确认。在实际形式上,存在一些加密算法可以实现签名聚合等操作,比如schnorrBLS等算法都可以实现原生上的多签。

但上述方法一般依赖于一些特定的密码学算法,构建基于这些算法的钱包具有一定的复杂性而且要求设计者具有较高的密码学造诣。而使用智能合约实现多签钱包较为简单,因为智能合约具有数据存储和处理功能,这大大降低了多签钱包智能合约的设计难度。

我们会在后文向读者介绍Gnosis Safe的多签钱包的构造逻辑和代码。

Multisig Wallet

中继商

在以太坊生态内,用户只能使用ETH作为Gas支付的货币。随着ERC20代币的日益繁荣,很多用户有了使用ERC20代币支付Gas的需求,在此需求刺激下,以太坊生态环境内出现了一种特殊的实体——中继商。它们运行用户向其支付ERC20代币,然后由中继商代替用户进行交互。

值得注意的是中继商进行上述操作需要合约支持,比较著名的有EIP2771 MetaTranscation标准,具体可以参考EIP712的扩展使用。当然,Gnosis Safe合约对于中继商进行交易进行了很好的支持,我们会在下文逐渐介绍。

代码准备

由于Github仓库也用于Gnosis Safe团队日常开发,在完成阶段性开发后进行审计,所以直接clone仓库会获得未经审计的代码。一种更好的方法是前往Github Release下载源代码。

当我们下载并解压代码后,我们在项目目录中输入forge init foundry-safe,然后我们将下载的代码中的contracts文件夹中的合约文件转移到foundry-safe项目中的src目录中。

在后文中,我们将按照合约的生命周期逐渐分析源代码。

在此处,我们给出在Etherscan网站中的各个合约地址:

  1. Proxy Factory
  2. GnosisSafeProxy
  3. Gnosis Safe: Relay service - Transactions

代理工厂合约

当我们获取代码后,我们先研究合约的部署过程。参见下图:

Proxy Deploy

这一部分的代码主要参考src/proxies/GnosisSafeProxyFactory.sol合约。为了方便研究合约,我们也给出此合约在以太坊主网中的地址

此流程的主要目的是使用工厂函数createProxy创建逻辑合约的代理合约。使用代理合约的模式的目的是为了节省gas fee

最简核心实现

我们首先分析最简单的createProxy函数。读者可以前往此网页查看一个真实的createProxy交易。

createProxy函数代码如下:

function createProxy(address singleton, bytes memory data) public returns (GnosisSafeProxy proxy) {
    proxy = new GnosisSafeProxy(singleton);
    if (data.length > 0)
        // solhint-disable-next-line no-inline-assembly
        assembly {
            if eq(call(gas(), proxy, 0, add(data, 0x20), mload(data), 0, 0), 0) {
                revert(0, 0)
            }
        }
    emit ProxyCreation(proxy, singleton);
}

通过natspec注释,我们可以得到各个参数的含义:

  • singleton 为逻辑合约的地址,在以太坊主网上地址为0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
  • data 为调用逻辑合约(GnosisSafe.sol)的初始化calldata,我们会在后文介绍。

我们首先使用proxy = new GnosisSafeProxy(singleton);创造了代理合约。此流程背后其实调用了create函数。

此处较难理解的是以下代码:

call(gas(), proxy, 0, add(data, 0x20), mload(data), 0, 0)

关于call的参数可以参考此网页。此函数的形式为call(gas,addr,value,argsOffset,argsLength,retOffset,retLength),各参数含义如下:

  • gas 进行call所需要的gas
  • addr 目标合约地址
  • value 进行call操作转移的ETH
  • argsOffset 进行call操作发送的calldata在内存中的开始位置
  • argsLength 进行call操作发送的calldata的长度
  • retOffset 返回值写入内存的开始位置
  • retLength 返回值的长度

在此处,我们使用add(data, 0x20)获得calldata在内存中的起始位置。其原理为在内存中存储的data属于array类型,此数据类型在第一个内存槽内存储有长度,其余地址槽内存储有真实的数据。我们通过add(data, 0x20)获得真实数据的起始位置,然后通过mload(data)获得data的前 32 byte 中存储的长度。

上述内容可以参考Memory Management文档

完成上述操作,我们使用了if判断call的是否正确执行,call正确执行会返回True,在数值上等同于1

有了以上知识,我们可以分析此交易Input Data,我们点击Decode Input Data以更加友好的方式分析变量,我们可以看到singleton变量为0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552data为一个复杂用于合约初始化的bytes,由于此初始化涉及到GnosisSafe的核心实现,我们会在后文进行分析。

最后此代码释放ProxyCreation事件,此事件的第一个参数为代理合约地址,第二个参数为复制的逻辑合约地址

读者可能会感觉上述流程极其奇怪,一是没有使用require进行错误断言,二是在call流程中没有使用solidity抽象出的call函数。出现上述的原因在于此部分代码是5年前写的,使用了solidity的远古版本,因为一直可以正常运行,所以没有更新。

复杂核心实现

本小节介绍其他的合约部署实现。

我们首先研究deployProxyWithNonce函数,此函数的作用是使用create2部署合约,但不会调用代理合约初始化初始化函数(即没有进行上文给出的call流程)。

此函数的核心使用了create2函数,该函数所需要的参数如下:

  • value 转移给代理合约的ETH
  • offset 合约初始化代码在内存中的偏移量
  • size 初始化代码的长度
  • salt 用于计算部署合约地址的参数

结合以上参数,我们可以获得确定的合约地址,计算方法如下:

keccak256(
    0xff + sender_address + salt + keccak256(initialisation_code)
)[12:]

此函数源代码如下:

function deployProxyWithNonce(
    address _singleton,
    bytes memory initializer,
    uint256 saltNonce
) internal returns (GnosisSafeProxy proxy) {
    // If the initializer changes the proxy address should change too. Hashing the initializer data is cheaper than just concatinating it
    bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
    bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));
    // solhint-disable-next-line no-inline-assembly
    assembly {
        proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
    }
    require(address(proxy) != address(0), "Create2 call failed");
}

首先,我们应该构建出可以部署的字节码。我们可以通过type(GnosisSafeProxy).creationCode获得需要部署合约的创建字节码。

注意上述表述为创建代码而不是运行代码,具体请参考此文章

但我们发现一个问题,我们部署的合约包含一个构造器(src/proxies/GnosisSafeProxy.sol),代码如下:

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

上述代码说明我们在构造对应字节码的过程中需要填入对应的参数。深入研究创建代码(可以通过proxyCreationCode()获得),我们发现此代码总是在字节码的最后按照内存长度逐一读取参数。也就是说,我们需要在creationCode后增加EVM标准内存长度( 32 byte )的代理合约的地址。在此处,我们使用了uint256(uint160(_singleton))进行了转换,将合约地址转换为uint256数据类型,此数据类型恰好占用 32 byte 。

在获得创建代码和构造器参数后,我们使用了abi.encodePacked对参数进行合并,此过程的目的是生成符合EVM标准的字节码。此函数的作用是将各参数进行编码并非标准的合并,详细可以参考文档

如果读者想观察最后生成的代码,请前往此网站观察和运行代码。

获得关键的deploymentData参数后,我们可以非常简单的实现create2。此处基本与上一节给出的call类似,我们在此处不再赘述。对于最后结果使用了require进行断言。

在目前,我们不建议仍使用此方法进行create2。我个人更建议大家使用由solidity抽象的create语法。即下述语法:

proxy = new GnosisSafeProxy{salt: salt}(_singleton)

显然,只是用solidity抽象语法更加简洁易懂。

上述函数仅仅作为合约构建过程中的中间函数,此函数是为了createProxyWithNonce使用的。此函数较为简单,相当于在deployProxyWithNonce基础上,增加了call流程实现初始化。具体的call流程与createProxy类似,我们在此处不再赘述。

createProxyWithNonce是目前使用最为广泛的创建代理合约的函数。读者可前往此网页查看。

More createProxyWithNonce

createProxyWithCallback是在createProxyWithNonce基础上实现的一个极其不常见的函数。简单来说,此函数的作用是在创建完成代理合约后会向指定的合约地址进行proxyCreated请求。

其核心代码如下:

if (address(callback) != address(0)) callback.proxyCreated(proxy, _singleton, initializer, saltNonce);

此处要求callback实现以下接口:

interface IProxyCreationCallback {
    function proxyCreated(
        GnosisSafeProxy proxy,
        address _singleton,
        bytes calldata initializer,
        uint256 saltNonce
    ) external;
}

使用此函数创建代理合约的交易极为罕见。

辅助函数

本节主要介绍GnosisSafeProxyFactory中的三个辅助函数。这些函数也使用的比较少,不属于核心实现。

最为简单是以下两个辅助函数:

  • proxyRuntimeCode() 获得Runtime代码
  • proxyCreationCode() 获得create代码

如果读者无法理解两者的区别,请参考此文章

还有一个极其鸡肋的函数:

  • calculateCreateProxyWithNonceAddress 此函数用于计算待部署的代理合约的地址

此合约通过revert中断合约创建流程,并返回代理合约地址等信息。但使用此函数,需要提交一个fromGnosisSafeProxyFactory地址的交易,对于一般用户而言不是很好构建,而且上述计算过程也会消耗较多gas,我建议读者通过相关公式在链下进行计算。

代理合约

此部分是工厂合约部署出的合约,与工厂合约相比,代理合约较为简单。此节介绍的代码位于src/proxies/GnosisSafeProxy.sol

此部分可以参考我之前写的使用多种方式编写可升级的智能合约(上)

此处使用let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)获取逻辑合约地址。此流程使用and操作使地址满足EVM要求。

另一段复杂的代码如下:

// 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
    mstore(0, _singleton)
    return(0, 0x20)
}

此处代码其实没有非常大的用途,相当于对IProxy的实现。IProxy定义如下:

interface IProxy {
    function masterCopy() external view returns (address);
}

此处实现了对代理合约调用masterCopy()指令会返回逻辑合约的地址。当判断出来调用了函数masterCopy()函数选择器时,我们使用mstore将地址释放到内存中的第一个内存槽,然后使用return返回前 32 byte ,即返回逻辑合约地址。

其余的代码主要实现了代理相关的逻辑,读者可自行参考我之前写的文章。

核心代码

我们在此节会进入核心代码GnosisSafe.sol。此代码串联了各个模块,结构具有一定的复杂性。

Gnosis Safe Inherit

由于此处涉及到大量外部模块,我们在此处不会详细介绍模块的实现仅会提及模块的功能,具体实现会在后文提及。

Setup

我们跳过了没有任何参数的构造器函数,直接讨论setUp函数。在此处设计setUp函数的原因在于此合约通过代理合约的形式部署,而代理合约部署时不会也无法调用构造器函数。此处给出的构造器函数几乎没有作用。

此处,我们也就使用之前的交易作为示例。我们可以在Input Data获得输入的data。如果读者还记得我们上文的讨论,就会知道此data的作用正是初始化代理合约。

我们对此data使用cast --calldata-decode进行解析:

cast --calldata-decode "setup(address[],uint256,address,bytes,address,address,uint256,address)" 0xb63e800d000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000d85bf7de2a15fb2cf44f5beec271f804a0e6c881000000000000000000000000ab6647ad2a897d814d4c111a36d9fba6ed8ec28a00000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000000

运行截图如下: Cast Calldata decode

此交易进行了最简单的初始化,仅初始化了_owners_threshold。这两个参数的含义如下:

  • _owners 规定多签钱包的拥有者列表
  • _threshold 规定单一交易需要签名的数量

如上述交易规定[0xd85bf7de2a15fb2cf44f5beec271f804a0e6c881, 0xab6647ad2a897d814d4c111a36d9fba6ed8ec28a]为多签钱包拥有者,且单一交易需要两人均签名同意。

如果读者设计的多签钱包仅用于安全的存储资金,那么仅需要初始化这两个参数。

接下来我们介绍其他参数的作用:

  • to 用于初始化模块的地址
  • data 用于初始化模块的calldata
  • fallbackHandler 应对fallback情况的合约地址,可以设置为此地址
  • paymentpaymentReceiver 此参数是为中继器等机构设计的参数

我们接下来逐行介绍setUp函数代码及功能:

setupOwners(_owners, _threshold);设置钱包拥有者和单一交易所需要签名的数量。我们会在后文介绍OwnerManager时进行更加详细的介绍。

if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);设置fallback函数以处理特殊情况。我们会在介绍FallbackManager时进行介绍。

setupModules(to, data);进行模块初始化的操作,我们会在介绍ModuleManager时进行分析相关代码。

以下代码较难理解:

if (payment > 0) {
    handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
}

由于此处涉及到handlePayment函数,我们在此处一并给出代码:

function handlePayment(
    uint256 gasUsed,
    uint256 baseGas,
    uint256 gasPrice,
    address gasToken,
    address payable refundReceiver
) private returns (uint256 payment) {
    // solhint-disable-next-line avoid-tx-origin
    address payable receiver = refundReceiver == address(0) ? payable(tx.origin) : refundReceiver;
    if (gasToken == address(0)) {
        // For ETH we will only adjust the gas price to not be higher than the actual used gas price
        payment = gasUsed.add(baseGas).mul(gasPrice < tx.gasprice ? gasPrice : tx.gasprice);
        require(receiver.send(payment), "GS011");
    } else {
        payment = gasUsed.add(baseGas).mul(gasPrice);
        require(transferToken(gasToken, receiver, payment), "GS012");
    }
}

简单阅读就可以发现此函数的作用为向refundReceiver返还gas费用。当然,我们可以选择使用任意的代币进行返还。

在了解handlePayment函数的作用后,我们就可以理解初始化过程的代码。此代码的作用是为中继商设置的,实现用户可以委托中继商进行合约初始化的功能。当用户使用GnosisSafeProxyFactory创建GnosisSafe合约后,用户可以首先向未初始化的合约内转入资产,然后由中继商代为初始化。中继商在初始化过程中,通过设置paymentReceiver等参数转移合约内的资产以覆盖自己的gas成本。为了方便各位理解,我们进行合约调用测试。

我们首先需要进行一些步骤以保证foundry可以成功编译和测试合约。首先删除src/test,此文件夹内包含GnosisSafe编写的辅助测试合约,这些合约对于我们进行测试是不需要的。然后修改src/interfaces/ISignatureValidator.sol中的function isValidSignature(bytes memory _data, bytes memory _signature) public view virtual returns (bytes4);修改为function isValidSignature(bytes calldata _data, bytes calldata _signature) public view virtual returns (bytes4);。如果使用原代码会出现接口与实现不对应的情况。

我们需要在test/utils/MockERC20.sol创建一个类似ERC20的合约。代码如下:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;


contract MockERC20 {
    uint256 public transferAmount;
    address public receiver;
    
    function transfer(address refundReceiver, uint256 amount) public {
        transferAmount = amount;
        receiver = refundReceiver;
    }

    receive() external payable {}

}

此合约只需要实现transfer函数以保证handlePayment可以正常运行。接下来,我们需要编写测试合约,我们在此处仅给出setUp部分,完整的代码请参考Github仓库

代码如下:

function setUp() public {
    Token = new MockERC20();
    SingletonTest = new GnosisSafe();
    Safe = new GnosisSafeProxy(address(SingletonTest));
    
    address[] memory ownerAddress = new address[](2);
    uint256 threshold = 2;

    ownerAddress[0] = address(0xd85bF7de2a15FB2Cf44f5beEc271F804A0E6C881);
    ownerAddress[1] = address(0xaB6647aD2A897D814D4c111A36d9fba6ED8ec28A);

    IGnosis(address(Safe)).setup(            
        ownerAddress,
        threshold,
        address(0),
        "",
        address(0),
        address(Token),
        10000,
        payable(address(1))
    );
}

初始化过程中的参数来自此交易,但在此处我们将paymentToken等参数进行了设置,最后我们编写以下函数测试payment是否成功:

function testTransfer() public {
    assertEq(Token.receiver(), address(1));
    assertEq(Token.transferAmount(), 10000);
}

在测试中,我们发现receiver获得了转移的代币。

当然,对于一般的用户而言,此功能并不是非常重要,主要实现了使用ERC20代币支付初始化Gas功能,即用户使用GnosisSafeProxyFactory创建合约后向合约转移ERC20代币,与中继商协商价格后由中继商进行初始化,同时中继商使用handlePayment函数在合约内收回等价值的代币。

execTransaction

根据natspec提供的信息,我们可以得到execTransaction所需要的参数:

  • to 支付目标地址
  • value 支付数量
  • data 交易所携带的信息,即调用目标合约的calldata
  • operation 交易的类型,包括CallDelegateCall方式
  • safeTxGas 设置交易的gas费用
  • baseGas 与交易执行无关的gas费用,主要为中继商设置
  • gasPrice 用于付款计算的gas价格,主要为中继商设置
  • refundReceiver 提取资金的中继商地址
  • signature 交易签名

在分析代码之前,我们首先给出一个在以太坊中的真实交易。此交易实现了提取多签钱包内的资金进行转账的功能,向目标用户转移了 8000 ETH。读者可以自行使用cast --calldata-decode自行解码calldata进行分析。

我们可以看到在函数体内大量使用了{}花括号进行分割代码,这是为了避免Stack too deep错误,具体可以参考这篇文章

我们首先分析第一代码块中的代码,如下:

bytes32 txHash;
{
    bytes memory txHashData =
        encodeTransactionData(
            // Transaction info
            to,
            value,
            data,
            operation,
            safeTxGas,
            // Payment info
            baseGas,
            gasPrice,
            gasToken,
            refundReceiver,
            // Signature info
            nonce
        );
    // Increase nonce and execute transaction.
    nonce++;
    txHash = keccak256(txHashData);
    checkSignatures(txHash, txHashData, signatures);
}

此代码的作用主要为验证EIP712签名。在此处使用了encodeTransactionData将交易数据编码为EIP712规定的结构化数据形式。我们会在后文对此函数进行介绍。然后,我们通过keccak256计算哈希,完成EIP712的结构化数据哈希流程。

读者可自行阅读我之前写的这两篇文章以理解上述流程:

在获得相关数据后,我们使用checkSignatures(txHash, txHashData, signatures);进行验证签名是否正确。此函数我们会在下文进行介绍。

接下来,我们分析第二代码块:

address guard = getGuard();
{
    if (guard != address(0)) {
        Guard(guard).checkTransaction(
            // Transaction info
            to,
            value,
            data,
            operation,
            safeTxGas,
            // Payment info
            baseGas,
            gasPrice,
            gasToken,
            refundReceiver,
            // Signature info
            signatures,
            msg.sender
        );
    }
}

此代码主要涉及GuardManager模块,在此处我们不详细分析其具体实现。它的功能是检测交易是否符合合约部署者所设置的其他条件.当然,这些条件需要用户自行编写合约并进行部署,然后调用setGuard(address guard)进行设置。

在后面介绍GuardManager模块时,我们会再次进行说明。

接下来介绍用于gas相关设置的代码块,代码如下:

require(gasleft() >= ((safeTxGas * 64) / 63).max(safeTxGas + 2500) + 500, "GS010");
{
    uint256 gasUsed = gasleft();
    // If the gasPrice is 0 we assume that nearly all available gas can be used (it is always more than safeTxGas)
    // We only substract 2500 (compared to the 3000 before) to ensure that the amount passed is still higher than safeTxGas
    success = execute(to, value, data, operation, gasPrice == 0 ? (gasleft() - 2500) : safeTxGas);
    gasUsed = gasUsed.sub(gasleft());
    // If no safeTxGas and no gasPrice was set (e.g. both are 0), then the internal tx is required to be successful
    // This makes it possible to use `estimateGas` without issues, as it searches for the minimum gas where the tx doesn't revert
    require(success || safeTxGas != 0 || gasPrice != 0, "GS013");
    // We transfer the calculated tx costs to the tx.origin to avoid sending it to intermediate contracts that have made calls
    uint256 payment = 0;
    if (gasPrice > 0) {
        payment = handlePayment(gasUsed, baseGas, gasPrice, gasToken, refundReceiver);
    }
    if (success) emit ExecutionSuccess(txHash, payment);
    else emit ExecutionFailure(txHash, payment);
}

在进行具体交易代码前,我们可以看到合约使用require(gasleft() >= ((safeTxGas * 64) / 63).max(safeTxGas + 2500) + 500, "GS010");检查了gasleft的数值。

我们首先分析约束gasleft大于(safeTxGas * 64) / 63的原因,这一要求是基于EIP150EIP150规定:

63 / 64 * gas available = Call/DelegateCall gas

call操作传递的gas应为当前可用gas63 / 64。在此处,Call/DelegateCall gas即我们为交易设置的safeTxGas,而gas available(可用gas)即合约的gasleft。我们可用简单的计算得到gas left = (safeTxGas * 64) / 63。当然,此时显示了gasleft的可在交易中传递safeTxGas的最小情况。如果gasleft < (safeTxGas * 64) / 63情况出现,我们就无法满足向交易传递safeTxGas的要求。

除了验证符合EIP150的条件,我们还需要保证gas费用足够合约抛出events。此部分的成本为2500。所以有了另一个限制条件gasleft > safeTxGas + 2500

最后,我们还需要在满足上述两个限制的基础上保留另一部分gas保证代码运行,此部分数值为500

对上述条件进行组合,我们可以得到最终条件。

在完成gasleft的校验后,我们进入了交易执行的核心模块。首先声明gasUsed变量。然后,我们进入了交易执行的核心代码,如下:

success = execute(to, value, data, operation, gasPrice == 0 ? (gasleft() - 2500) : safeTxGas);

此处使用的execute位于Executor模块,主要用于进行交易,核心就是封装了CallDelegateCall函数。我们会在后文为大家介绍此模块的实现。

在此处,我们给出execute的函数的定义,如下:

function execute(
    address to,
    uint256 value,
    bytes memory data,
    Enum.Operation operation,
    uint256 txGas
) internal returns (bool success)

我们关注个参数的含义:

  • to 目标地址
  • value 交易转移的ETH数量
  • data 交易包含的calldata
  • operation 决定交易为calldelegatecall
  • txGas 交易消耗的gas

在此处,较为复杂的为txGas参数为gasPrice == 0 ? (gasleft() - 2500) : safeTxGas)。这显然是一个三目表达式,当设置gasPrice为0时,交易发送的gasgasleft() - 2500。而如果设置了gasPrice,则交易发送safeTxGas数量的gas。进行如此设置的合理性在于,正如上文所述,gasPrice参数一般由交易中继商设置,中继商会设置gasPrice等变量,这些设置最终关乎中继商可在交易内获得的回报。所以当设置gasPrice时,严格限制发送的gas数量为safeTxGas是有必要的。当然,对于普通用户而言,我们只需要交易可以正常进行而不关注交易过程的具体gas消耗,所以如果用户没有设置gasPrice参数,则会对交易设置gasleft() - 2500gas。此处预留2500是为了保证events可以顺利抛出。

值得注意的是,虽然我们对交易设置了较高的gas,但并不意味着相较于safeTxGas的消耗的gas多,原因在于,交易执行方会将多余的gas进行返还操作。

在进行交易执行后,我们通过gasUsed = gasUsed.sub(gasleft());计算上述交易步骤消耗的具体gas数量。着主要方便中继商在合约内提取手续费。

完成上述核心步骤后,我们接下来主要处理中继商提取手续费和抛出events的过程。

首先,我们可用看到在此处进行了一系列条件检测,如下:

require(success || safeTxGas != 0 || gasPrice != 0, "GS013");

一旦不满足以下三个条件,合约会停止运行并抛出异常:

  1. 交易未成功
  2. 未设置safeTxGas
  3. 未设置gasPrice

交易未成功抛出异常可能对于大家而言比较好理解,但为什么同时要求满足未设置safeTxGasgasPrice的条件呢?因为此参数主要由中继商设置,我们知道即使交易失败也会消耗gas,所以我们需要在交易失败的条件下继续运行后面的中继商提取交易手续费的逻辑代码,避免中继商在失败交易中蒙受损失。

最后我们观察中继商提取手续费的代码块:

uint256 payment = 0;
if (gasPrice > 0) {
    payment = handlePayment(gasUsed, baseGas, gasPrice, gasToken, refundReceiver);
}

当交易设置的gasPrice大于0时,我们通过handlePayment函数计算中继商手续费数量并将其返回给中继商。我们会在后文详细介绍handlePayment函数。

手续费提取的具体交易可以参考这个交易,如下图:

Relay Tx

在交易的最后代码,我们进行了抛出事件和调用Guard合约中的checkAfterTransaction进行监控。

handlePayment

在上文中,我们在setupexecTransaction中都使用了这一重要的参数。我们会在此处详细介绍此函数的参数和代码逻辑。由于此处使用到了gas相关的基础知识,特别是EIP1559相关的gas机制,建议读者先行阅读此文

我们首先从参数分析,此函数需要以下参数:

  • gasUsed 用于计算手续费的gas数值
  • baseGas 类似EIP1559中的Base Fee,具体可以参考此文
  • gasPrice gas的价格
  • gasToken 用于支付gas的代币,即中继商提取手续费时提取的代币种类
  • refundReceiver 提取手续费资金的获得者,一般为中继商自身钱包地址

在此函数体中的第一行代码通过三目表达式定义了receiver变量。当用户设置refundReceiver参数时,即采用用户的设定参数; 否则使用tx.origin作为提取手续费的接收者。

接下来我们进入一个分支结构,根据代币类型进行分支判断。我们首先分析gasToken == address(0)的情况,即选择gasToken为ETH的情况,在此处,我们使用以下公式计算中继商在合约内提取的手续费:

Gas Fee = (gasUsed + baseGas) * gasPrice

当然,此处的gasPrice也通过一个三目表达式进行选择,要求gasPrice为用户设置的gasPrice和交易内含的tx.gasprice中的较大值。当计算完成后,我们便将Gas Fee数量的ETH通过send发送给接收者。

用户可以在solidity 官方文档中查询到所有的tx结构体中的参数。

在其他代币分支,我们使用了类似公式进行计算。但由于以太坊交易tx的数据结构中不包含以其他代币计费的情况,所以在此处我们无法使用tx.gasprice。在此处,我们只能接受函数设置的gasPrice变量。然后,我们通过位于src/common/SecuredTokenTransfer.soltransferToken进行代币转移。

通过此函数,用户可以通过中继商使用任何代币支付gas费用。

checkSignatures

我们在execTransaction通过此函数检测交易多签是否符合签名者的数量限制。此函数需要以下参数:

  • dataHash 交易参数的EIP712哈希值,具体可以参考此文章
  • data 需要进行签名的数据
  • signatures 需要进行检查的签名数据

此部分的核心代码为checkNSignatures(dataHash, data, signatures, _threshold);,对于此函数我们会在下文进行介绍。

checkNSignatures

由于此函数所需要的参数与checkSignatures有大量重叠,所以在此处我们不再进行相关的参数介绍。

在函数的起始位置,合约首先检查了signatures.length >= requiredSignatures.mul(65)。这是为了保证signatures聚合签名的长度符合预期。

此处的常数为65的原因是在最小签名(即仅包含vrs)的长度为65 bytes。具体可以参考此文章

值得注意的是,GnosisSafe为了满足多种签名方式并存的情况,修改了部分签名的定义。读者可以阅读相关文档进行学习。当然,我们也会在后文尽可能解释Gnosis的签名格式。

我们使用for循环和signatureSplit函数进行签名分割。signatureSplit被定义在src/common/SignatureDecoder.sol合约中,我们会在后文进行分析。

GnosisSafe支持多种签名方式,通过不同的v值进行判断,包括以下几种类型:

v值签名类型
0合约签名(EIP1271)
1预签名签名
v > 30eth_sign签名
31 > v > 26ECSDA签名

Gnosis的签名规定中,签名包含两部分,分别是静态部分和动态部分。所有的签名类型都具有静态部分,只有合约签名具有动态部分。顾名思义,静态部分的程度都是已知的65 bytes,且由v r s三部分构成; 而动态部分的长度不固定,作为合约签名的附属部分存在。在多个签名最后聚合时,我们必须保证静态部分在前而动态部分在后,同时保证静态部分按升序排列。

合约签名(Contract Signature)

读者在阅读此部分时需要对EIP1271标准有一定理解,如果读者对此没有了解,请先阅读此文章。简单来说,合约将签名权授予某拥有私钥的用户,由此用户进行签名。接受合约签名的合约使用合约签名对签名验证合约调用isValidSignature函数,获得此签名是否是正确的合约签名。

我们首先给出合约签名的静态格式:

{ 32-bytes 签名验证合约 r }{ 32-bytes 签名数据位置 s }{ 0 v }

在这里比较神奇的一点是由于签名有65 bytes的长度限制,我们在此处无法完整将完整合约签名的编码,所以在此处我们设置了签名数据位置参数,即合约签名数据在组合后的多签名中的位置。

注意合约签名数据的位置必须位于常规签名(即包含v r s字段的签名)的后面,否则会影响函数读取签名。上述表达都较为抽象,我们十分建议读者阅读文档中的示例以更好地理解上述表述。

合约签名的动态部分,即签名数据部分格式如下:

{32-bytes signature length}{bytes signature data}

在此处我们注意到signature data没有具体长度,此签名的长度其实取决于签名验证合约中的isValidSignature的代码逻辑。

根据EIP1271的相关流程,我们需要首先获得签名验证合约的地址。根据上文给出的合约签名的格式,我们通过对r值的转换获得对应的地址,使用代码如下:

currentOwner = address(uint160(uint256(r)));

由上文我们给出的“静态部分在前,动态部分在后”的规则,我们需要校验指向合约动态部分的s值是否在静态部分之外,使用require(uint256(s) >= requiredSignatures.mul(65), "GS021");代码进行判断。

此处我们使用了每个签名的静态部分长度固定为65 bytes进行判断

接下来,我们检查签名数据是否位于多签名内。通过动态部分的格式,我们知道s + 32即签名数据的起始位置,我们使用require(uint256(s).add(32) <= signatures.length, "GS022");检查签名数据的起始位置是否位于多签名内。

上文给出的条件检查并不能完全保证合约签名中的s指向的数据位于多签数据内,因为可能多签名由多于requiredSignatures的签名组成,这导致使用require(uint256(s) >= requiredSignatures.mul(65), "GS021");不能正确判断s指向的数据是否在多签数据内。

我们需要通过一些更加复杂的手段判断s指向的数据是否在多签数据内。如果需要更加精确的判断,我们需要获得多签数据的具体长度。具体来说,我们需要获取动态数据的长度。其核心函数为:

contractSignatureLen := mload(add(add(signatures, s), 0x20))

要理解此代码,读者需要对于EVM底层数据存储有所了解。signatures属于bytes,本质上属于动态类型,变量相当于指向底层数据在内存的指针。而我们需要先通过s获得动态数据的起始位置。由于signatures只是指向内存的指针,我们在此指针后增加s就可以获得动态数据的起始内存地址。但需要注意signatures属于动态类型,根据solidity的规范,此数据的头部32 bytes为数据长度,所以我们需要在signatures + s的基础上增加0x20实现跳过signatures的长度数据的作用获取的真正的动态数据起始位置。

建议阅读Layout of State Variables in Storage以进一步了解上述步骤。

当我们获得到动态数据在内存中的起始位置后,我们可以通过mload(offset)直接获取到动态数据的前 32 bytes ,即动态数据长度。

接下来我们需要具体计算判断s指向的数据是否在多签数据内,通过计算签名长度确定,代码如下:

require(uint256(s).add(32).add(contractSignatureLen) <= signatures.length, "GS023");

在经过一系列检查后,我们终于进行提取签名数据的流程,代码如下:

contractSignature := add(add(signatures, s), 0x20)

非常简单粗暴的将动态数据整体提取出来。为什么需要将长度和签名数据同时提取?正如上文所述,前32 bytes作为长度,后面数据作为数据是solidity中动态数据类型的基本形式。为了保证与solidity规定相符,我们在此处也使用了此种数据格式进行数据提取。

在获取到完整的签名数据后,我们只需要对签名验证合约(r)发送isValidSignature请求即可,代码如下:

require(ISignatureValidator(currentOwner).isValidSignature(data, contractSignature) == EIP1271_MAGIC_VALUE, "GS024");

使用接口进行函数调用,较为简单。具体的接口实现由签名验证合约决定。如果验证正确,合约会返回0x20c13b0b已证明签名正确。

预认证签名(Pre-Validated Signatures)

预认证签名的具体形式如下:

{32-bytes hash validator}{32-bytes ignored}{1}

从前之后依次由rsv变量表示。

此函数依赖于合约中的映射:

mapping(address => mapping(bytes32 => uint256)) public approvedHashes;

此映射反映了某用户是否对特定的信息进行了预签名。如果进行了预签名,则uint256会被置为1。此过程通过approveHash实现,此函数代码如下:

function approveHash(bytes32 hashToApprove) external {
    require(owners[msg.sender] != address(0), "GS030");
    approvedHashes[msg.sender][hashToApprove] = 1;
    emit ApproveHash(hashToApprove, msg.sender);
}

此函数较为简单,我们在后文不再进行介绍。

而对于此签名的检查是极其简单的,代码如下:

currentOwner = address(uint160(uint256(r)));
require(msg.sender == currentOwner || approvedHashes[currentOwner][dataHash] != 0, "GS025");

符合条件的交易需要满足以下条件:

  1. 发送者为签名中的r,此时发送的任何签名都被认可
  2. 发送者对于dataHash已进行过授权

上述条件为的关系,满足任一一点即证明签名有效。

Eth_sign签名

签名的具体形式如下:

{32-bytes r}{32-bytes s}{1-byte v}

这与传统的ECSDA签名基本一致,但此处为了使用v实现签名类型区分的作用,所以相比于正常的v(即27或28),此处的v值进行了+ 4操作,即取值可以为3132

由于此处使用了传统签名方法,所以检验签名的方式极其简单,使用ecrecover预编译函数,代码如下:

currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v - 4, r, s);

此处需要补充的一点是eth_sign接口会在签名信息前增加\x19Ethereum Signed Message:\n32,这是为了防止签名被滥用,具体请参考Github

ECSDA签名

与其他签名验证相比,此签名验证较为简单,我们在此处不进行解释。

上述内容主要介绍了各种类的签名,我们处理验证签名外,我们还需要验证签名人是否符合要求。在介绍具体的判断代码前,我们需要简单了解一下用于存储签名人的映射,变量名为owners。在此处。我们不加解释给出owners中的映射情况(下图假设abc均为设置的签名人):

0x1 => a
a => b
c => 0x1

关于此映射情况的来源,我们会在介绍setupOwners函数中进行解释。

所以在验证签名人身份时,我们可以使用owners[currentOwner] != address(0) && currentOwner != SENTINEL_OWNERS进行判断。

currentOwner > lastOwner是为了保证签名人的地址按升序排列。

综合以上,我们使用下列代码进行判断:

require(currentOwner > lastOwner && owners[currentOwner] != address(0) && currentOwner != SENTINEL_OWNERS, "GS026");

requiredTxGas

此函数用于估计交易的gas耗费,此函数使用的参数较为简单且多次使用过,所以我们不再具体介绍参数含义。

函数代码如下:

uint256 startGas = gasleft();
// We don't provide an error message here, as we use it to return the estimate
require(execute(to, value, data, operation, gasleft()));
uint256 requiredGas = startGas - gasleft();
// Convert response to string and return via error message
revert(string(abi.encodePacked(requiredGas)));

代码较为简单,值得注意的是为了避免我们在估计gas消耗时完成不必要的交易,我们在代码的最后使用revert函数直接抛出异常,达到交易中止的目的。当然,我们在revert返回的错误信息中编码了requiredGas,使用户可以通过错误信息获得交易的估计gas

encodeTransactionData

此函数用于编码交易数据,此函数使用的参数我们在前文都进行过相关介绍。对于此函数,我们不会进行详细介绍,读者可以参考我之前的博客基于链下链上双视角深入解析以太坊签名与验证

getTransactionHash

此函数主要依赖于encodeTransactionData函数,仅进行了keccak256哈希操作。值得注意的是,此函数的返回结果就是签名者用于签名的信息。

总结

我们完成了GnosisSafe的代理相关合约和最为复杂的主合约的分析,相信读者也可以理解GnosisSafe的基本运作模式。

Proxy相关合约中,src/proxies/GnosisSafeProxy.sol提供具体的代理合约代码,而src/proxies/GnosisSafeProxy.sol提供多种函数供用户进行代理合约部署。

src/GnosisSafe.sol合约中,核心函数为execTransaction,其他函数基本都为此服务。读者可以以此函数为主线进行研究。当然,由于GnosisSafe的野望,合约内存在大量为中继商设计的函数,这一部分由于很难看到交易实例,所以我个人给出的内容可以存在于现实不符的情况。当然此部分对于核心实现没有很大影响。

如果读者谋求较为简单的实现,可以前往此仓库