什么是多签钱包
多签钱包是一种特殊的钱包,可以添加多个签名用户,在执行交易的时候需要多个持有者同时签名才能提交,比如3个用户的多签钱包需要2个以上的用户同时签名。
这种设计可以有效防止单点故障,保证资产的安全,在dao群中有广泛的应用。
实现逻辑
多签钱包其实是一个智能合约,在合约中存储了多签持有者的信息。
执行交易时,需要先将交易组装成data,计算出hash。拿到交易hash后,多签用户需要分别对hash进行签名,并且将得到的签名拼接成最终的签名作为参数。
将交易data和signature作为参数调用合约中的方法,合约做的工作是:
- 判断签名数量需要大于多签规定的签名数量
- 拆分签名
- 对应每条签名通过ecrecover还原出签名地址,判断该地址是否存储于合约中
- 满足签名条件后,根据data执行交易
具体实现
ecrecover
ecrecover是solidity内置的验签方法,定义如下:
function ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) public pure returns (address)
其中hash为交易哈希,也就是签名的msg,v,r,s是从签名中拆分得到的,返回值为签名的公钥。
签名拆分
这里使用的是ECDSA标准的签名,
一个标准的 ECDSA 签名是:
- bytes32 r
- bytes32 s
- uint8 v(27 或 28,也可能是 0 或 1)
总共65字节的内容,因此要写一个对signature进行拆分的方法,得到v、r、s
function signatureSplit(bytes memory signatures, uint256 pos)internalpurereturns (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)}
}
这里使用了内联汇编的写法,因为涉及到了内存的读取。
signatures是拼接后的签名,pos代表的是当前读取的签名在signatures中是第几个,即索引值。
let signaturePos := mul(0x41, pos)是用来定位当前拆分出签名的偏移量,因为每个签名的长度是65字节,换算成16进制就是0x41,所以偏移量就是0x41*pos。
而r := mload(add(signatures, add(signaturePos, 0x20)))中,mload的作用是从某个内存地址开始,向后读取固定32字节的内容,而add(signatures, add(signaturePos, 0x20))从内部到外部的两个add分别表示:
- add(signaturePos, 0x20)表示- signaturePos和- 0x20的偏移量相加,因为bytes类型的前32字节是头部,而非实际数据,所以读取时先偏移到- signaturePos,即签名起点,再向后偏移32个字节。
- add(signatures, pos)指的是从拼接签名的起始位置,偏移到pos的位置
这两个
add,一个是偏移量的相加,一个是位置的移动,但在内联汇编计算中都是使用add方法,因为signatures是一个指针,指向的地址也是用偏移字节数表示的,代表从内存0的位置偏移的数量。
用同样的原理可以取出s和v,要注意的是v的字节数为1,而mload固定取32字节,所以使用and()方法与0xff做了一个与操作,得到最低位1字节的值。
设计好验签逻辑之后,就可以开始写合约内容了。
	address[] public owners;                   // 多签持有人数组 mapping(address => bool) public isOwner;   // 记录一个地址是否为多签持有人uint256 public ownerCount;                 // 多签持有人数量uint256 public threshold;                  // 多签执行门槛,交易至少有n个多签人签名才能被执行。uint256 public nonce;                      // nonce,防止签名重放攻击constructor(        address[] memory _owners,uint256 _threshold
) {_setupOwners(_owners, _threshold);
}/// @param _owners: 多签持有人数组
/// @param _threshold: 多签执行门槛,至少有几个多签人签署了交易
function _setupOwners(address[] memory _owners, uint256 _threshold) internal {// 多签执行门槛 小于或等于 多签人数require(_threshold <= _owners.length, "invalid threshold");// 多签执行门槛至少为1require(_threshold >= 1, "threshold at least 1");for (uint256 i = 0; i < _owners.length; i++) {address owner = _owners[i];// 多签人不能为0地址,本合约地址,不能重复require(owner != address(0) && owner != address(this) && !isOwner[owner], "owner can not be repeated");owners.push(owner);isOwner[owner] = true;}ownerCount = _owners.length;threshold = _threshold;
}
此处是简化多签钱包的逻辑,不做多签持有人的变更,持有人的列表和threshold都是固定不变的。
然后写一个打包交易hash的方法,在参数中增加了一个chainid,这是为了防止拿到签名之后可以去其他链执行,进行交易重放。
/// @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;
}
然后写一个验签方法:
/*** @dev 检查签名和交易数据是否对应。如果是无效签名,交易会revert* @param dataHash 交易数据哈希* @param signatures 几个多签签名打包在一起*/
function checkSignatures(bytes32 dataHash,bytes memory signatures
) public view {// 读取多签执行门槛uint256 _threshold = threshold;require(_threshold > 0, "threshold not set");// 检查签名长度足够长require(signatures.length >= _threshold * 65, "signature not satisify threshold");// 通过一个循环,检查收集的签名是否有效// 大概思路:// 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], "singer not owner");lastOwner = currentOwner;}
}
几个要注意的地方:
- threshold是否满足的判断是由signatures的长度来判断的,因为单个签名的长度固定是65字节。
- ecrecover的时候拼接了\x19Ethereum Signed Message:\n32,这是因为在使用eth_sign或者钱包签名的时候,会自动在前面加上这一段话,用于标识是签名而非真实的交易数据,所以验签的时候也需要加上。
- lastOwner的记录是因为signatures的拼接是根据address从小到大进行拼的,这样保证了多签拼接顺序的固定,所以验签时还需要判断address之间的大小关系。
最后实现一个执行合约的方法:
/// @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++;  // 增加noncecheckSignatures(txHash, signatures); // 检查签名// 利用call执行交易,并获取交易结果(success, ) = to.call{value: value}(data);if (success) emit ExecutionSuccess(txHash);else emit ExecutionFailure(txHash);
}
