多签钱包实现
概述
所谓多签钱包是一种数字钱包,其特点是一笔交易需要被多个私钥持有者(多签人)授权后才能执行:例如钱包由3个多签人管理,每笔交易需要至少2人签名授权。多签钱包可以防止单点故障(私钥丢失,单人作恶),更加去中心化,更加安全,被很多DAO采用
Gonsis Safe
Gonsis 作为当下最出名的以太坊多签钱包,介绍一下其核心原理,一共有三个合约:
- 代理合约工厂:GnosisSafeProxyFactory(创建的时候,与这个交互)
- 代理合约:GnosisSafeProxy(最终我们与这个交互)
- 业务实现合约:GnosisSafeL2(实际业务在这里实现)
简易版多签钱包
分析
接下来我们将实现一个简化版的多签钱包:MultiSigWallet,具体实现步骤:
- 确定多钱策略,为了简化操作,我们不支持动态增减,多签策略设置为:2/3
- 创建一笔待授权的交易,包含以下内容:
- to:目标合约
- value:交易发送的以太坊数量
- data:calldata,包含调用函数的选择器和参数
- nonce:初始为 0,随着多签合约每笔成功的交易递增的值,可以防止签名重放攻击
- chainId:链 id,防止不同链的签名重放攻击
- 获取链下签名,签名的原始内容为上述待授权交易,我们最终其实是对交易 hash(经过 ERC191 处理)进行签名,然后将所有签名拼装在一起,传递给合约,在合约内部对签名进行逐个校验
两个事件
MultisigWallet 合约有2个事件,ExecutionSuccess 和 ExecutionFailure,分别在交易成功和失败时释放,参数为交易哈希
event ExecutionSuccess(bytes32 txHash); // 交易成功事件
event ExecutionFailure(bytes32 txHash); // 交易失败事件
五个状态变量
- 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,防止签名重放攻击
六个函数
构造函数:调用 _setupOwners(),初始化多签持有人和执行门槛相关的变量
// 构造函数,初始化 owners, isOwner, ownerCount, threshold
constructor(address[] memory _owners, uint256 _threshold) {
_setupOwners(_owners, _threshold);
}
_setupOwners():在合约部署时被构造函数调用,初始化 owners,isOwner,ownerCount,threshold 状态变量。传入的参数中,执行门槛需大于等于 1 且小于等于多签人数,多签地址不能为 0 地址且不能重复
function _setupOwners(address[] memory _owners, uint256 _threshold) internal {
// threshold 没被初始化过
require(threshold == 0, "001");
// 多签执行门槛 小于 多签人数
require(_threshold <= _owners.length, "002");
// 多签执行门槛至少为 1
require(_threshold >= 1, "003");
for (uint256 i = 0; i < _owners.length; i++) {
address owner = _owners[i];
// 多签人不能为 0 地址,本合约地址,不能重复
require(owner != address(0) && owner != address(this) && !isOwner[owner], "004");
owners.push(owner);
isOwner[owner] = true;
}
ownerCount = _owners.length;
threshold = _threshold;
}
execTransaction():在收集足够的多签签名后,验证签名并执行交易。传入的参数为目标地址 to,发送的以太坊数额 value,数据 data,以及打包签名 signatures。打包签名就是将收集的多签人对交易哈希的签名,按多签持有人地址从小到大顺序,打包到一个 [bytes] 数据中。这一步调用了 encodeTransactionData() 编码交易,调用了 checkSignatures() 检验签名是否有效、数量是否达到执行门槛
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++;
// 检查签名
checkSignatures(txHash, signatures);
// 利用 call 执行交易,并获取交易结果
(success, ) = to.call{value: value}(data);
require(success , "005");
if (success) emit ExecutionSuccess(txHash);
else emit ExecutionFailure(txHash);
}
checkSignatures():检查签名和交易数据的哈希是否对应,数量是否达到门槛,若否,交易会 revert。单个签名长度为 65 字节,因此打包签名的长度要长于 threshold * 65。调用了 signatureSplit() 分离出单个签名。这个函数的大致思路:
- 用 ECDSA 获取签名地址
- 利用 currentOwner > lastOwner 确定签名来自不同多签(多签地址递增)
- 利用 isOwner[currentOwner] 确定签名者为多签持有人
function checkSignatures(bytes32 dataHash, bytes memory signatures) public view {
// 读取多签执行门槛
uint256 _threshold = threshold;
require(_threshold > 0, "006");
// 检查签名长度足够长
require(signatures.length >= _threshold * 65, "007");
// 通过一个循环,检查收集的签名都是否有效
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], "008");
lastOwner = currentOwner;
}
}
signatureSplit():将单个签名从打包的签名分离出来,参数分别为打包签名 signatures 和要读取的签名位置 pos。利用了内联汇编,将签名的 r,s,v 三个值分离出来
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() 函数执行
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;
}
完整代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
/// 基于签名的多签钱包,由gnosis safe合约简化而来,教学使用。
contract MultisigWallet {
event ExecutionSuccess(bytes32 txHash); // 交易成功事件
event ExecutionFailure(bytes32 txHash); // 交易失败事件
address[] public owners; // 多签持有人数组
mapping(address => bool) public isOwner; // 记录一个地址是否为多签
uint256 public ownerCount; // 多签持有人数量
uint256 public threshold; // 多签执行门槛,交易至少有n个多签人签名才能被执行。
uint256 public nonce; // nonce,防止签名重放攻击
receive() external payable {}
// 构造函数,初始化owners, isOwner, ownerCount, threshold
constructor(address[] memory _owners,uint256 _threshold) {
_setupOwners(_owners, _threshold);
}
/// @dev 初始化owners, isOwner, ownerCount,threshold
/// @param _owners: 多签持有人数组
/// @param _threshold: 多签执行门槛,至少有几个多签人签署了交易
function _setupOwners(address[] memory _owners, uint256 _threshold) internal {
// threshold没被初始化过
require(threshold == 0, "001");
// 多签执行门槛 小于 多签人数
require(_threshold <= _owners.length, "002");
// 多签执行门槛至少为1
require(_threshold >= 1, "003");
for (uint256 i = 0; i < _owners.length; i++) {
address owner = _owners[i];
// 多签人不能为0地址,本合约地址,不能重复
require(owner != address(0) && owner != address(this) && !isOwner[owner], "004");
owners.push(owner);
isOwner[owner] = true;
}
ownerCount = _owners.length;
threshold = _threshold;
}
/// @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 , "005");
if (success) emit ExecutionSuccess(txHash);
else emit ExecutionFailure(txHash);
}
/**
* @dev 检查签名和交易数据是否对应。如果是无效签名,交易会revert
* @param dataHash 交易数据哈希
* @param signatures 几个多签签名打包在一起
*/
function checkSignatures(
bytes32 dataHash,
bytes memory signatures
) public view {
// 读取多签执行门槛
uint256 _threshold = threshold;
require(_threshold > 0, "006");
// 检查签名长度足够长
require(signatures.length >= _threshold * 65, "007");
// 通过一个循环,检查收集的签名是否有效
// 大概思路:
// 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], "008");
lastOwner = currentOwner;
}
}
/// 将单个签名从打包的签名分离出来
/// @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)
}
}
/// @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;
}
}
Remix 验证
部署合约,设置为 2/3 多签
Owner1:0xE8191108261f3234f1C2acA52a0D5C11795Aef9E
Owner2:0xC4109e427A149239e6C1E35Bb2eCD0015B6500B8
Owner3:0x572ed8c1Aa486e6a016A7178E41e9Fc1E59CAe63
["0xE8191108261f3234f1C2acA52a0D5C11795Aef9E","0xC4109e427A149239e6C1E35Bb2eCD0015B6500B8","0x572ed8c1Aa486e6a016A7178E41e9Fc1E59CAe63"], 2
转账 0.1 ETH 到多签合约地址用于测试,构建转账交易,将 0.1ETH 转账到 Owner1 地址,我们将对这笔交易进行多方签名,满足 2/3 后,发送转账交易(我们要对这笔交易的 hash 值进行签名,使用 encodeTransactionData 完成 hash 计算)
# 参数
to: 0xE8191108261f3234f1C2acA52a0D5C11795Aef9E
value: 100000000000000000
data: 0x
_nonce: 0
chainid: 5
# 结果(待签名信息)
交易哈希:0xeaa5d901b5c497a883b2d1ad5321ead3a41a7443c50dfb4bc140ccd792c19d1c
利用 Remix 中 ACCOUNT 旁边的笔记图案的按钮进行签名,内容输入上面的交易哈希,获得签名,注意地址顺序要:由小到大,在校验签名时有大小判断
# 签名地址1: 0x572ed8c1Aa486e6a016A7178E41e9Fc1E59CAe63,
# 待签名hash内容:0xeaa5d901b5c497a883b2d1ad5321ead3a41a7443c50dfb4bc140ccd792c19d1c
# EIP191 hash:0x8c2479bfdefe6a60aa53abb32917d80e50506fa79cd0dd1a66217c9606a3af24
# signature1:
0xa8529b2e088d3bd2ad05582f810f5d56b0ca412578d7a177ab35b3fadbb10e040009fba2108da2d871f7c704611735a304c5f3c6f4bb34b3fc245f40c029188a1c
# 签名地址2: 0xC4109e427A149239e6C1E35Bb2eCD0015B6500B8
# 待签名hash内容:0xeaa5d901b5c497a883b2d1ad5321ead3a41a7443c50dfb4bc140ccd792c19d1c
# EIP191 hash:0x8c2479bfdefe6a60aa53abb32917d80e50506fa79cd0dd1a66217c9606a3af24
# signature2:
0x88bce1e1d3460d43c95ef6d163f6dddc655bd22b78e4489ce50be18c08b6fe1067521326d216bbee7a637ee9b1a4da3468d6938516cc8fb5803eed0cb3d96f831b
# 拼接后到signature为:(去除0x)
0xa8529b2e088d3bd2ad05582f810f5d56b0ca412578d7a177ab35b3fadbb10e040009fba2108da2d871f7c704611735a304c5f3c6f4bb34b3fc245f40c029188a1c88bce1e1d3460d43c95ef6d163f6dddc655bd22b78e4489ce50be18c08b6fe1067521326d216bbee7a637ee9b1a4da3468d6938516cc8fb5803eed0cb3d96f831b
调用 execTransaction() 函数执行交易,将第 3 步中的交易参数和打包签名作为参数传入。可以看到交易执行成功,ETH 被转出多签
浏览器检查,转账成功!
可以将之前世界杯 Dapp 项目中的 owner 改成多签钱包地址,这样后续操作就变成多签管理
本文来自博客园,作者:这个杀手冷死了,转载请注明原文链接:https://www.cnblogs.com/pandacode/p/17033131.html