概述
读者可以前往我的博客获得更好的阅读体验
Safe
(或称Gnosis Safe
)是目前在以太坊中使用最为广泛的多签钱包。本文主要解析此钱包的逻辑设计和代码编写。
读者可以前往Safe Contracts获得源代码。
预备知识
Safe优势
作为智能合约钱包,Safe
支持多签名批准交易。这带来了以下优势:
-
更高的安全性。将资产放置在多签钱包内可以有效避免因为个人单一私钥的泄露而导致的资产丢失。用户可以将多签设置为
2-of-3
形式,个人保存两个私钥并将第三个私钥作为备份。当遭受黑客攻击时,泄露1个私钥对资产安全性没有影响。 -
更加高级的交易设置。相对于以太坊用户,智能合约具有可编程性,这意味着用户可以自行编辑一些交易逻辑,比如将多个交易聚合起来一起执行(
batched transactions
)。此部分由Safe Library contracts
提供。 -
更加灵活的访问管理。用户可以在钱包内加入具有特定功能的模块,比如限制单一用户每日最大可批准金额。这对于DAO是十分有用的。此部分由`Safe Modules提供。
上述仅仅是对Safe
优势的简单介绍。如果读者想了解更多关于此方面的介绍,请参考Gnosis Safe 官网
以太坊账户
在以太坊网络中,具有地址的账户被分为以下两类:
-
EOA(externally owned accounts) 我们平常使用的使用账户均属于这一类型。这一类型的账户具有公钥和私钥。
-
Contract accounts 合约账户。我们创建的合约也均有对应的区块地址,但没有私钥可以用于签名等操作,这一类型的账户被称为合约账户。与EOA相比,合约账户内存在代码逻辑,可以进行编写一些复杂操作。
值得注意的是,在以太坊中,EOA与合约账户是被同等对待的。合约账户可以发送交易,也可以接受ETH。
多签钱包
多签钱包是指需要使用多个私钥进行签名完成交易的钱包。它们的形式一般被标记为m-of-n
,即需要n
个签名人中的m
个签名人进行签名确认。在实际形式上,存在一些加密算法可以实现签名聚合等操作,比如schnorr
、BLS
等算法都可以实现原生上的多签。
但上述方法一般依赖于一些特定的密码学算法,构建基于这些算法的钱包具有一定的复杂性而且要求设计者具有较高的密码学造诣。而使用智能合约实现多签钱包较为简单,因为智能合约具有数据存储和处理功能,这大大降低了多签钱包智能合约的设计难度。
我们会在后文向读者介绍Gnosis Safe
的多签钱包的构造逻辑和代码。
中继商
在以太坊生态内,用户只能使用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
网站中的各个合约地址:
代理工厂合约
当我们获取代码后,我们先研究合约的部署过程。参见下图:
这一部分的代码主要参考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
变量为0xd9Db270c1B5E3Bd161E8c8503c55cEABeE709552
,data
为一个复杂用于合约初始化的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
获得需要部署合约的创建字节码。