Skip to content
On this page

L3-8: Multisig Extension(多签扩展)

1. 问题

单个私钥控制大量资金是极其危险的——私钥被盗、丢失或持有者作恶都会导致不可逆的资金损失。多签钱包通过要求 N 个签名者中的 M 个批准才能执行交易来分散风险。但多签钱包本身也面临设计挑战:签名如何收集和验证?如何防止同一签名被重放到不同交易中?多签钱包本身如何升级(例如增减签名者)?

Gnosis Safe(现 Safe)是 Gnosis Safe 是生产级的解决方案,锁定了数百亿美元资产。这个挑战构建的是一个精简版——直接使用 eth_sign 兼容的 ECDSA 签名验证,不依赖 EIP-712 的完整结构。理解多签的实现是构建团队金库、DAO 财务管理和机构级托管工具的基础。

2. 原因

多签是 Web3 组织资金管理的基础设施。几乎所有 DAO 金库都使用多签(通常通过 Safe),核心团队的资金管理同样依赖多签。理解其内部机制——从签名者集合管理,到交易哈希构建,到签名排序防重放——不仅是合约开发技能,也是理解协作安全管理的重要知识。

与单签相比,多签在以下场景提供了关键的安全增强:(1) 防止单一作恶——需要合谋;(2) 防止私钥丢失——其余签名者仍可操作;(3) 层级审批——不同阈值对应不同操作敏感度。这个挑战也展示了 ECDSA 签名恢复 (ecrecover) 的高级应用——按签名者地址排序以防止重复签名的技巧。

3. 方案

MultiSigWallet.sol 实现了 N-of-M 多签钱包,核心功能包括:

构造函数:接收初始所有者列表和阈值(threshold)。验证:阈值 > 0 且 <= 所有者数量,每个地址非零且不重复。

交易执行(execTransaction):这是多签的核心函数。流程如下:

  1. 构建交易哈希:keccak256(abi.encode(to, value, keccak256(data), nonce)) — 使用 eth_sign 兼容格式(无 EIP-712 前缀)
  2. 验证签名:_verifySignatures() 解析拼接的签名字节(每个签名 65 字节 = r(32) + s(32) + v(1)),逐个恢复签名者地址
  3. 签名排序验证:每个恢复的签名者地址必须严格大于上一个(uint160(signer) > uint160(lastSigner)),防止重复签名
  4. 执行:通过 call 发送交易,成功后递增 nonce

Owner 管理addOwner()removeOwner() 只能通过多签本身执行(onlyMultiSig 修饰符要求 msg.sender == address(this))。这意味着增减签名者本身就是一项需要多签批准的操作——自管理和自治理。

签名验证(_verifySignatures):从 65 字节的签名中提取 r、s、v。v 值规范化:如果 v < 27,加 27(兼容以太坊的 v 值编码)。使用 ecrecover(hash, v, r, s) 恢复签名者。

参考源文件:src/level3/MultiSigWallet.sol

4. 遭遇的陷阱

  • 签名排序的边界情况:如果两个签名者地址的 uint160 值非常接近,比较逻辑是否依然有效?
  • ecrecover 的无效签名风险ecrecover 在签名无效时返回 address(0),而不是 revert——如果合约不检查 signer != address(0),可能接受一个伪造的零地址签名
  • nonce 管理的防重放:nonce 随每次成功执行递增。如果签名者同时签署了当前 nonce 和未来 nonce 的交易,执行顺序是否确定?
  • 签名格式与外部工具的不兼容:eth_sign(vm.sign)直接对 bytes32 哈希签名,而 MetaMask 的 personal_sign 添加 \x19Ethereum Signed Message:\n32 前缀——两者恢复出的签名者不同
  • 没有时间锁或过期机制:签名者签署的交易理论上永远有效(直到 nonce 被消耗),如果签名被泄露但交易尚未提交,存在风险

5. 陷阱的原因

ecrecover 的怪异行为是 Solidity 中最经典的陷阱之一。EVM 预编译 ecrecover 在以下情况返回 address(0):(1) 签名格式正确但签名者不是预期的地址;(2) v 值不是 27 或 28;(3) r 或 s 超出 secp256k1 曲线的有效范围。如果合约代码不检查 recovered != address(0),攻击者可能传入一个精心构造的签名,使 ecrecover 返回零地址——而零地址可能恰好是合约中的一个合法签名者(虽然不太可能,因为 zero address 通常被排除在外)。

MultiSigWallet.sol 中,_recoverSigner() 内部执行了 require(v == 27 || v == 28, "Invalid v") 检查,但未检查恢复地址是否为零。_verifySignatures() 中的 isOwner[signer] 检查会捕获零地址(因为构造函数不允许零地址作为 owner),所以这个特定漏洞被间接缓解,但依赖于 isOwner 映射的完整性。

签名格式不兼容的问题来源于以太坊签名生态的碎片化。eth_sign 直接对 bytes32 签名(Foundry 的 vm.sign 使用这种格式),而 personal_sign 添加前缀,EIP-712 又添加了 domain separator。合约中使用的哈希构建方式必须与前端签名方式完全匹配。在 MultiSigWallet.sol 中,getTransactionHash() 返回 keccak256(abi.encode(to, value, keccak256(data), _nonce)),前端需要用相同的编码方式计算哈希并用 Foundry 的 vm.sign 签名。

6. 如何解决陷阱

ecrecover 安全封装

solidity
function _recoverSigner(bytes32 hash, bytes memory sig) internal pure returns (address) {
    require(sig.length == 65, "Invalid sig len");
    // ... 提取 r, s, v ...
    address signer = ecrecover(hash, v, r, s);
    require(signer != address(0), "Invalid signature");
    return signer;
}

签名者排序防重放:当前排序设计(strictly increasing by address)已经可以有效防止重复签名和签名者顺序被修改。重要的是在 JavaScript 端也要对签名按 signer 地址排序:

javascript
signatures.sort((a, b) => 
  BigInt(a.signer) < BigInt(b.signer) ? -1 : 1
);

nonce 锁定:在签署交易时明确指定当前 nonce,前端通过 multisig.nonce() 查询最新 nonce。交易一旦执行成功,nonce 递增,所有针对旧 nonce 的签名作废。

添加到期时间:生产级实现应在交易哈希中包含 validUntil 时间戳,使签名在指定时间后失效。

7. 技术要点

技术点说明
N-of-M 阈值threshold 个 owner 签名即可执行
签名排序按 uint160(signer) 升序排列,防重复
签名格式每个签名 65 字节:r(32) + s(32) + v(1)
ecrecoverEVM 预编译,恢复 ECDSA 签名者地址
nonce 防重放每次成功执行递增,使旧签名失效
onlyMultiSig管理操作自身需要通过多签批准
eth_sign 兼容直接签名 bytes32 哈希,匹配 Foundry vm.sign
swap-and-pop从 owners 数组中移除成员的经典模式

Built with AiAda