深入解析Safe多签钱包智能合约:代理部署与核心合约

概述

读者可以前往我的博客获得更好的阅读体验

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

代理工厂合约

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

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获得需要部署合约的创建字节码。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WongSSH

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值