在深入解析Safe多签钱包智能合约:模块中分析FallbackManager
模块时,限于篇幅限制且fallback
合约自成一体,所以我们没有介绍具体的fallback
模块。此篇文章的主要目的是完成这一缺陷,全面介绍fallback
合约。
本文涉及的代码主要位于src/handler
内,读者可自行查阅此仓库。
此节主要关注于我们为什么需要Fallback
合约这一主题,希望可以为读者在后文阅读源代码时起到提纲挈领的作用。
在上文中,我们可以知道fallback
函数的主体逻辑是进行了代理合约式的处理将逻辑代码交给此处的fallback
合约执行。我们首先应当知道fallback
函数的作用,此函数用于接受一切无法与其他函数名匹配到的调用都会被发送到fallback
函数中,进一步这些调用被转发到fallback
合约内。
我们可以认为fallback
合约提供了对于GnosisSafe
主合约的强大补充,避免了GnosisSafe
因不具有某些函数而导致无法进行关键功能。在目前,fallback
合约提供了以下功能:
1.3.0
之前的safe
合约的功能ERC1155
代币的功能ERC721
NFT 的功能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
地址在这。
我们在上文提到fallback
合约提供了这两个功能:
ERC1155
代币的功能ERC721
NFT 的功能可能有读者比较困惑,我们直接进行转账等行为时似乎没有这些要求。这是因为我们一直使用的时非安全转账函数,如transferFrom
。而ERC721
和ERC1155
都提供了安全转账函数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
是否具有接受代币的资格。当地址满足以下两个条件之一即可接受代币:
接受地址为EOA(即使用私钥控制的地址),此条件提供to.code.length == 0
判断地址是否存在代码来实现。当地址内没有代码时,我们可以认为此地址由用户私钥控制。
接受地址实现了onERC721Received
接口并返回0x150b7a02
值
读者可能已经发现了此处要求接受合约满足实现接口的条件。而GnosisSafe
开发者为了避免用户在使用safe
系列转账函数时,合约无法接受代币,所以在Fallback
合约内实现了这些要求的Receiver
接口。
关于这些接口实现的细节,读者可以自行参考ERC721和ERC1155中的详细规定。
除此之外,我们还在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
主合约中实现的函数,此部分函数很多都已被废弃,我们不建议使用此部分函数用于实际操作。
此函数我们在GnosisSafe
中介绍合约签名曾使用此函数。当合约实现此函数时,就意味着合约可以实现合约签名的特性。该特性由EIP1271
规定,具体可以参考基于链下链上双视角深入解析以太坊签名与验证文章。
此函数需要以下参数:
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
:
_signature
签名为空,但我们可以在GnosisSafe
主合约内查询到该用户在之前对于此messageHash
进行过授权_signature
不为空,且经过调用GnosisSafe
主合约的checkSignatures
函数发现签名正确此函数需要以下参数:
此函数会返回用于签名的代签数据。由于此函数过于简单,我们在此处也给出其核心逻辑的实现函数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
结构化哈希,具体可以参考这篇博客。
此函数用于协调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
编码后的结果也不同,读者不必担心混淆问题。
为兼容老版本函数名所设计的函数,在当前版本种可使用getModulesPaginated
函数代替。其实现就是对getModulesPaginated
的简单封装,不进行解释。
用于模拟代码在目标合约内运行,此函数需要以下参数:
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
指用户输入的参数,由于simulate
和simulateAndRevert
使用参数相同,所以不必继续修改。||
表示拼接。
简单来说,我们只需要将calldata
中的前 4 字节进行替换。我们使用calldatacopy
对calldata
进行复制。calldatacopy
需要以下参数:
calldata
中的起始位置calldata
长度根据上文结论,我们只需要将calldata
中的自第 4 bytes 开始的数据复制到内存中的internalCalldata
的第 4 bytes 后,翻译成代码为:
calldatacopy(add(internalCalldata, 0x04), 0x04, sub(calldatasize(), 0x04))
此代码正好可以完成对internalCalldata
的构建。
接下来,我们进行call
操作,但在进行call
操作前,我们使用了pop
在内联汇编内返回数据。
我们在之前解释的含有内联汇编的函数均没有返回值,此函数作为含有返回值的函数较为特殊
在pop
内部,使用了一个较为传统的call
函数,因为我们在此前已多次解释过此函数,所以在此处不进行详细解释,具体可参考文档。一个很特殊的对方是此处直接选择将call
的success
返回值(即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
。涉及以下内容:
safe
系列交易