title | tags | ||||
---|---|---|---|---|---|
50. 多签钱包 |
|
我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。
所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity
V神曾说过,多签钱包要比硬件钱包更加安全(推文)。这一讲,我们将介绍多签钱包,并且写一个极简版多签钱包合约。教学代码(150行代码)由gnosis safe合约(几千行代码)简化而成。
多签钱包是一种电子钱包,特点是交易被多个私钥持有者(多签人)授权后才能执行:例如钱包由3
个多签人管理,每笔交易需要至少2
人签名授权。多签钱包可以防止单点故障(私钥丢失,单人作恶),更加去中心化,更加安全,被很多DAO采用。
Gnosis Safe多签钱包是以太坊最流行的多签钱包,管理近400亿美元资产,合约经过审计和实战测试,支持多链(以太坊,BSC,Polygon等),并提供丰富的DAPP支持。更多信息可以阅读我在21年12月写的Gnosis Safe使用教程。
在以太坊上的多签钱包其实是智能合约,属于合约钱包。下面我们写一个极简版多签钱包MultisigWallet
合约,它的逻辑非常简单:
-
设置多签人和门槛(链上):部署多签合约时,我们需要初始化多签人列表和执行门槛(至少n个多签人签名授权后,交易才能执行)。Gnosis Safe多签钱包支持增加/删除多签人以及改变执行门槛,但在咱们的极简版中不考虑这一功能。
-
创建交易(链下):一笔待授权的交易包含以下内容
to
:目标合约。value
:交易发送的以太坊数量。data
:calldata,包含调用函数的选择器和参数。nonce
:初始为0
,随着多签合约每笔成功执行的交易递增的值,可以防止签名重放攻击。chainid
:链id,防止不同链的签名重放攻击。
-
收集多签签名(链下):将上一步的交易ABI编码并计算哈希,得到交易哈希,然后让多签人签名,并拼接到一起得到打包签名。对ABI编码和哈希不了解的,可以看WTF Solidity极简教程第27讲和第28讲。
交易哈希: 0xc1b055cf8e78338db21407b425114a2e258b0318879327945b661bfdea570e66 多签人A签名: 0x014db45aa753fefeca3f99c2cb38435977ebb954f779c2b6af6f6365ba4188df542031ace9bdc53c655ad2d4794667ec2495196da94204c56b1293d0fbfacbb11c 多签人B签名: 0xbe2e0e6de5574b7f65cad1b7062be95e7d73fe37dd8e888cef5eb12e964ddc597395fa48df1219e7f74f48d86957f545d0fbce4eee1adfbaff6c267046ade0d81c 打包签名: 0x014db45aa753fefeca3f99c2cb38435977ebb954f779c2b6af6f6365ba4188df542031ace9bdc53c655ad2d4794667ec2495196da94204c56b1293d0fbfacbb11cbe2e0e6de5574b7f65cad1b7062be95e7d73fe37dd8e888cef5eb12e964ddc597395fa48df1219e7f74f48d86957f545d0fbce4eee1adfbaff6c267046ade0d81c
-
调用多签合约的执行函数,验证签名并执行交易(链上)。对验证签名和执行交易不了解的,可以看WTF Solidity极简教程第22讲和第37讲。
MultisigWallet
合约有2
个事件,ExecutionSuccess
和ExecutionFailure
,分别在交易成功和失败时释放,参数为交易哈希。
event ExecutionSuccess(bytes32 txHash); // 交易成功事件
event ExecutionFailure(bytes32 txHash); // 交易失败事件
MultisigWallet
合约有5
个状态变量:
owners
:多签持有人数组isOwner
:address => bool
的映射,记录一个地址是否为多签持有人。ownerCount
:多签持有人数量threshold
:多签执行门槛,交易至少有n个多签人签名才能被执行。nonce
:初始为0
,随着多签合约每笔成功执行的交易递增的值,可以防止签名重放攻击。
address[] public owners; // 多签持有人数组
mapping(address => bool) public isOwner; // 记录一个地址是否为多签持有人
uint256 public ownerCount; // 多签持有人数量
uint256 public threshold; // 多签执行门槛,交易至少有n个多签人签名才能被执行。
uint256 public nonce; // nonce,防止签名重放攻击
MultisigWallet
合约有6
个函数:
-
构造函数:调用
_setupOwners()
,初始化和多签持有人和执行门槛相关的变量。// 构造函数,初始化owners, isOwner, ownerCount, threshold constructor( address[] memory _owners, uint256 _threshold ) { _setupOwners(_owners, _threshold); }
-
_setupOwners()
:在合约部署时被构造函数调用,初始化owners
,isOwner
,ownerCount
,threshold
状态变量。传入的参数中,执行门槛需大于等于1
且小于等于多签人数;多签地址不能为0
地址且不能重复。/// @dev 初始化owners, isOwner, ownerCount,threshold /// @param _owners: 多签持有人数组 /// @param _threshold: 多签执行门槛,至少有几个多签人签署了交易 function _setupOwners(address[] memory _owners, uint256 _threshold) internal { // threshold没被初始化过 require(threshold == 0, "WTF5000"); // 多签执行门槛 小于或等于 多签人数 require(_threshold <= _owners.length, "WTF5001"); // 多签执行门槛至少为1 require(_threshold >= 1, "WTF5002"); for (uint256 i = 0; i < _owners.length; i++) { address owner = _owners[i]; // 多签人不能为0地址,本合约地址,不能重复 require(owner != address(0) && owner != address(this) && !isOwner[owner], "WTF5003"); owners.push(owner); isOwner[owner] = true; } ownerCount = _owners.length; threshold = _threshold; }
-
execTransaction()
:在收集足够的多签签名后,验证签名并执行交易。传入的参数为目标地址to
,发送的以太坊数额value
,数据data
,以及打包签名signatures
。打包签名就是将收集的多签人对交易哈希的签名,按多签持有人地址从小到大顺序,打包到一个[bytes]数据中。这一步调用了encodeTransactionData()
编码交易,调用了checkSignatures()
检验签名是否有效、数量是否达到执行门槛。/// @dev 在收集足够的多签签名后,执行交易 /// @param to 目标合约地址 /// @param value msg.value,支付的以太坊 /// @param data calldata /// @param signatures 打包的签名,对应的多签地址由小到达,方便检查。 ({bytes32 r}{bytes32 s}{uint8 v}) (第一个多签的签名, 第二个多签的签名 ... ) function execTransaction( address to, uint256 value, bytes memory data, bytes memory signatures ) public payable virtual returns (bool success) { // 编码交易数据,计算哈希 bytes32 txHash = encodeTransactionData(to, value, data, nonce, block.chainid); nonce++; // 增加nonce checkSignatures(txHash, signatures); // 检查签名 // 利用call执行交易,并获取交易结果 (success, ) = to.call{value: value}(data); require(success , "WTF5004"); if (success) emit ExecutionSuccess(txHash); else emit ExecutionFailure(txHash); }
-
checkSignatures()
:检查签名和交易数据的哈希是否对应,数量是否达到门槛,若否,交易会revert。单个签名长度为65字节,因此打包签名的长度要长于或等于threshold * 65
。调用了signatureSplit()
分离出单个签名。这个函数的大致思路:- 用ecdsa获取签名地址.
- 利用
currentOwner > lastOwner
确定签名来自不同多签(多签地址递增)。 - 利用
isOwner[currentOwner]
确定签名者为多签持有人。
/** * @dev 检查签名和交易数据是否对应。如果是无效签名,交易会revert * @param dataHash 交易数据哈希 * @param signatures 几个多签签名打包在一起 */ function checkSignatures( bytes32 dataHash, bytes memory signatures ) public view { // 读取多签执行门槛 uint256 _threshold = threshold; require(_threshold > 0, "WTF5005"); // 检查签名长度足够长 require(signatures.length >= _threshold * 65, "WTF5006"); // 通过一个循环,检查收集的签名是否有效 // 大概思路: // 1. 用ecdsa先验证签名是否有效 // 2. 利用 currentOwner > lastOwner 确定签名来自不同多签(多签地址递增) // 3. 利用 isOwner[currentOwner] 确定签名者为多签持有人 address lastOwner = address(0); address currentOwner; uint8 v; bytes32 r; bytes32 s; uint256 i; for (i = 0; i < _threshold; i++) { (v, r, s) = signatureSplit(signatures, i); // 利用ecrecover检查签名是否有效 currentOwner = ecrecover(keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", dataHash)), v, r, s); require(currentOwner > lastOwner && isOwner[currentOwner], "WTF5007"); lastOwner = currentOwner; } }
-
signatureSplit()
:将单个签名从打包的签名分离出来,参数分别为打包签名signatures
和要读取的签名位置pos
。利用了内联汇编,将签名的r
,s
,和v
三个值分离出来。/// 将单个签名从打包的签名分离出来 /// @param signatures 打包签名 /// @param pos 要读取的多签index. function signatureSplit(bytes memory signatures, uint256 pos) internal pure returns ( uint8 v, bytes32 r, bytes32 s ) { // 签名的格式:{bytes32 r}{bytes32 s}{uint8 v} assembly { let signaturePos := mul(0x41, pos) r := mload(add(signatures, add(signaturePos, 0x20))) s := mload(add(signatures, add(signaturePos, 0x40))) v := and(mload(add(signatures, add(signaturePos, 0x41))), 0xff) } }
-
encodeTransactionData()
:将交易数据打包并计算哈希,利用了abi.encode()
和keccak256()
函数。这个函数可以计算出一个交易的哈希,然后在链下让多签人签名并收集,再调用execTransaction()
函数执行。/// @dev 编码交易数据 /// @param to 目标合约地址 /// @param value msg.value,支付的以太坊 /// @param data calldata /// @param _nonce 交易的nonce. /// @param chainid 链id /// @return 交易哈希bytes. function encodeTransactionData( address to, uint256 value, bytes memory data, uint256 _nonce, uint256 chainid ) public pure returns (bytes32) { bytes32 safeTxHash = keccak256( abi.encode( to, value, keccak256(data), _nonce, chainid ) ); return safeTxHash; }
-
部署多签合约,
2
个多签地址,交易执行门槛设为2
。多签地址1: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 多签地址2: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
-
转账
1 ETH
到多签合约地址。 -
调用
encodeTransactionData()
,编码并计算向多签地址1转账1 ETH
的交易哈希。参数 to: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4 value: 1000000000000000000 data: 0x _nonce: 0 chainid: 1 结果 交易哈希: 0xb43ad6901230f2c59c3f7ef027c9a372f199661c61beeec49ef5a774231fc39b
-
利用Remix中ACCOUNT旁边的笔记图案的按钮进行签名,内容输入上面的交易哈希,获得签名,两个钱包都要签。
多签地址1的签名: 0xa3f3e4375f54ad0a8070f5abd64e974b9b84306ac0dd5f59834efc60aede7c84454813efd16923f1a8c320c05f185bd90145fd7a7b741a8d13d4e65a4722687e1b 多签地址2的签名: 0x6b228b6033c097e220575f826560226a5855112af667e984aceca50b776f4c885e983f1f2155c294c86a905977853c6b1bb630c488502abcc838f9a225c813811c 将两个签名拼接到一起,得到打包签名: 0xa3f3e4375f54ad0a8070f5abd64e974b9b84306ac0dd5f59834efc60aede7c84454813efd16923f1a8c320c05f185bd90145fd7a7b741a8d13d4e65a4722687e1b6b228b6033c097e220575f826560226a5855112af667e984aceca50b776f4c885e983f1f2155c294c86a905977853c6b1bb630c488502abcc838f9a225c813811c
-
调用
execTransaction()
函数执行交易,将第3步中的交易参数和打包签名作为参数传入。可以看到交易执行成功,ETH
被转出多签。
这一讲,我们介绍了多签钱包,并写了一个极简版的多签钱包合约,仅有不到150行代码。
我与多签钱包很有缘分,2021年因为PeopleDAO创建国库而学习了Gnosis Safe并写了中英文的使用教程,之后很幸运的做了3个国库的多签人维护资产安全,现在又成为了Safe的守护者深度参与治理。希望大家的资产都更加安全。