3、多签钱包核心逻辑设计:签名验证机制、阈值与所有者设计、交易提案与执行流程
好,咱们今天来啃多签钱包最核心的骨头。说白了,多签钱包就是个「多人共管」的保险箱。你一个人说了不算,得凑够足够多的人点头,才能动里面的钱。这个机制,我当年在做一个DAO国库项目时踩过不少坑,今天一次性给你讲透。
3.1 所有者(Owners)与阈值(Threshold)设计
先搞清楚两个基本概念:所有者是谁,阈值是多少。
所有者就是有权签名的人。一般是个地址数组,比如 [0xAlice, 0xBob, 0xCarol]。阈值就是最少需要几个签名才能执行交易。比如阈值设为2,那就叫「2/3多签」。
我个人习惯把阈值和所有者放在合约的构造函数里一次性初始化,之后就不让改了——除非你加了专门的治理流程。为什么?因为改所有者是个高危操作,我在项目里见过有人利用动态改owner列表搞鬼。
核心约束:
- 阈值必须大于0,且小于等于所有者数量
- 所有者不能有重复地址
- 所有者不能是零地址
来看一段典型的初始化代码:
contract MultiSigWallet {
address[] public owners;
uint public threshold;
mapping(address => bool) public isOwner;
constructor(address[] memory _owners, uint _threshold) {
require(_threshold > 0, "阈值必须大于0");
require(_threshold <= _owners.length, "阈值不能超过所有者数量");
require(_owners.length > 0, "至少需要一个所有者");
for (uint i = 0; i < _owners.length; i++) {
address owner = _owners[i];
require(owner != address(0), "所有者不能为零地址");
require(!isOwner[owner], "所有者不能重复");
isOwner[owner] = true;
owners.push(owner);
}
threshold = _threshold;
}
}
你想想看,这里用了个 isOwner 的mapping,就是为了快速查某个地址是不是所有者。不然每次都要遍历数组,gas费受不了。
3.2 签名验证机制
签名验证是多签钱包的命门。EVM里验证签名,主流有两种方式:
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 链下签名 + 链上验证 | 用户用私钥签名,提交到合约调用ecrecover | 最常用,gas费低 |
| 链上直接签名 | 用户发起交易时,合约内部用tx.origin验证 | 简单场景,但限制多 |
我个人强烈推荐第一种。为什么呢?因为链下签名可以离线完成,用户不需要每次都付gas。你想想,如果每次签名都要上链,那光签名费就够你喝一壶的。
具体怎么验证?核心就是 ecrecover 这个预编译合约。它能把签名拆成r、s、v三个部分,然后还原出签名者的地址。嗯,这里要注意:
我曾经踩过的坑:
- 签名重放攻击:同一个签名被用在不同的交易上。解决办法是给每个交易加一个唯一的nonce。
- 签名顺序问题:如果签名顺序变了,哈希就变了。我建议把所有签名按地址排序后再验证。
- EIP-712结构化签名:别用简单的keccak256拼接,容易出问题。用EIP-712更安全。
来看一个签名验证的核心函数:
function getSignersHash(bytes32 txHash, uint8[] memory v, bytes32[] memory r, bytes32[] memory s)
internal view returns (address[] memory)
{
address[] memory signers = new address[](v.length);
for (uint i = 0; i < v.length; i++) {
address signer = ecrecover(txHash, v[i], r[i], s[i]);
require(isOwner[signer], "签名者不是所有者");
signers[i] = signer;
}
return signers;
}
这里有个细节:ecrecover 返回的地址如果不对,会返回零地址。所以一定要检查 signer != address(0)。我当年第一次写的时候忘了这茬,结果测试时发现签名总是不通过,排查了半天才发现是签名格式不对导致ecrecover返回了零地址。
3.3 交易提案(Proposal)与执行流程
交易提案,说白了就是「我想花这笔钱,大家来签个字」。整个流程分三步:
- 创建提案:某个所有者发起一笔交易,指定目标地址、金额、调用数据
- 收集签名:其他所有者陆续签名,直到达到阈值
- 执行交易:调用合约的execute函数,实际转账或调用目标合约
我建议把提案设计成一个结构体:
struct Proposal {
address to; // 目标地址
uint value; // 转账金额(wei)
bytes data; // 调用数据
bool executed; // 是否已执行
uint nonce; // 防重放
uint approvalCount; // 当前已批准数
mapping(address => bool) approved; // 谁批准了
}
执行流程的核心逻辑是这样的:
function executeProposal(uint proposalId, uint8[] memory v, bytes32[] memory r, bytes32[] memory s)
external
{
Proposal storage prop = proposals[proposalId];
require(!prop.executed, "提案已执行");
// 计算交易哈希
bytes32 txHash = keccak256(abi.encodePacked(
prop.to, prop.value, prop.data, prop.nonce
));
// 验证签名并统计
address[] memory signers = getSignersHash(txHash, v, r, s);
require(signers.length >= threshold, "签名数量不足");
// 去重检查
for (uint i = 0; i < signers.length; i++) {
require(!prop.approved[signers[i]], "重复签名");
prop.approved[signers[i]] = true;
}
prop.approvalCount += signers.length;
require(prop.approvalCount >= threshold, "批准数未达阈值");
// 执行
prop.executed = true;
(bool success, ) = prop.to.call{value: prop.value}(prop.data);
require(success, "交易执行失败");
}
避坑指南:
- 执行交易时一定要用
call而不是transfer,因为transfer有2300 gas限制,容易失败 - nonce要递增,防止同一个交易被重复执行
- 建议加个时间锁,比如提案创建后24小时才能执行,给所有人反应时间
嗯,说到时间锁,我在一个DeFi项目里就吃过亏。当时没加时间锁,结果有个提案刚创建就被恶意执行了——虽然签名数量够,但有个所有者的私钥泄露了。加了时间锁之后,至少给了大家发现问题和撤销提案的机会。
最后总结一下:多签钱包的核心就是「签名验证 + 阈值控制 + 提案管理」。这三个东西搞明白了,剩下的就是各种优化和扩展了。下一章咱们聊聊怎么给多签钱包加升级功能,让它能适应各种复杂场景。