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)与执行流程

交易提案,说白了就是「我想花这笔钱,大家来签个字」。整个流程分三步:

  1. 创建提案:某个所有者发起一笔交易,指定目标地址、金额、调用数据
  2. 收集签名:其他所有者陆续签名,直到达到阈值
  3. 执行交易:调用合约的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项目里就吃过亏。当时没加时间锁,结果有个提案刚创建就被恶意执行了——虽然签名数量够,但有个所有者的私钥泄露了。加了时间锁之后,至少给了大家发现问题和撤销提案的机会。

最后总结一下:多签钱包的核心就是「签名验证 + 阈值控制 + 提案管理」。这三个东西搞明白了,剩下的就是各种优化和扩展了。下一章咱们聊聊怎么给多签钱包加升级功能,让它能适应各种复杂场景。