多签钱包(MultiSig Wallet)
一、什么是多签钱包?
多签钱包是一种需要多个授权人共同签名才能执行交易的智能合约钱包。
生活类比
想象一个公司保险柜,需要 3 把钥匙中的 2 把同时使用才能打开(2-of-3 多签)。即使某个人的钥匙被盗,小偷也无法单独打开保险柜。
应用场景
- DAO 国库管理:防止单人跑路
- 团队资金:多个创始人共管
- 大额交易安全:个人资产多重保护
- 知名项目:Gnosis Safe 就是最流行的多签钱包
二、核心概念
| 概念 | 说明 |
|---|---|
| owners | 有权签名的地址列表 |
| threshold | 执行交易所需的最低签名数(如 3 个 owner 中至少 2 个) |
| nonce | 交易计数器,防止同一笔交易被重复执行(重放攻击) |
| chainId | 链 ID,防止在 A 链签的名被拿到 B 链使用(跨链重放) |
三、工作流程
┌───────────────────────────────────────────────────────────┐
│ 链下(Off-chain) │
├───────────────────────────────────────────────────────────┤
│ 1. 发起者构造交易内容(to, value, data) │
│ 2. 计算交易哈希 txHash = encode(to, value, data, nonce, chainId) │
│ 3. 各 owner 分别用自己的私钥对 txHash 签名 │
│ 4. 收集签名,按 owner 地址从小到大排序后拼接 │
└───────────────────────────────────────────────────────────┘
│
▼ 提交拼接好的 signatures
┌───────────────────────────────────────────────────────────┐
│ 链上(On-chain) │
├───────────────────────────────────────────────────────────┤
│ 1. 用相同参数重新计算 txHash │
│ 2. nonce++(防重放) │
│ 3. 逐个验证签名: │
│ - ecrecover 恢复签名者地址 │
│ - 检查是否为合法 owner │
│ - 检查地址严格递增(防重复签名) │
│ 4. 验证通过 → 执行交易 to.call{value}(data) │
└───────────────────────────────────────────────────────────┘
四、安全机制详解
4.1 防重放攻击(nonce)
没有 nonce 的情况:
owner们签了一笔 "转 1 ETH 给 Bob" 的交易
Bob 收到钱后,再次提交相同的 signatures → 又转了 1 ETH!
反复提交 → 钱包被掏空
有 nonce 的情况:
第一笔: nonce=0, txHash = hash(to, value, data, 0, chainId)
执行后: nonce 变成 1
Bob 再次提交: 合约计算 hash(to, value, data, 1, chainId) → 不同的 txHash
→ 签名验证失败 → 交易被拒绝 ✅
4.2 防跨链重放(chainId)
ETH 主网 chainId = 1
BSC chainId = 56
签名包含 chainId=1 → 只在 ETH 主网有效
拿到 BSC 上: 合约计算 hash(..., 56) → 不同 txHash → 签名无效 ✅
4.3 防重复签名(地址递增)
假设 3 个 owner: A(0x1...), B(0x5...), C(0x9...)
threshold = 2
攻击: A 签两次提交 [sigA, sigA]
防御: 验证时要求地址严格递增
第1个签名恢复出 A(0x1...)
第2个签名恢复出 A(0x1...) → 0x1... > 0x1... ? No! → revert ✅
正确: [sigA, sigB] → A(0x1...) < B(0x5...) ✅
五、代码详解
5.1 状态变量与初始化
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MultiSigWallet {
event ExecutionSuccess(bytes32 indexed txHash);
event ExecutionFailure(bytes32 indexed txHash);
address[] public owners; // owner 列表
mapping(address => bool) public ownerMap; // 快速查找是否为 owner
uint256 public ownerCount; // owner 总数
uint256 public threshold; // 最低签名数
uint256 public nonce; // 交易计数器
receive() external payable {} // 接收 ETH
constructor(address[] memory _owners, uint256 _threshold) {
_setUpOwners(_owners, _threshold);
}
function _setUpOwners(address[] memory _owners, uint256 _threshold) internal {
require(threshold == 0, "Already initialized");
require(_threshold <= _owners.length, "Threshold too high");
require(_threshold >= 1, "Threshold too low");
for (uint256 i; i < _owners.length; i++) {
address owner = _owners[i];
require(owner != address(0), "Zero address");
require(owner != address(this), "Self address");
require(!ownerMap[owner], "Duplicate owner");
ownerMap[owner] = true;
owners.push(owner);
}
ownerCount = _owners.length;
threshold = _threshold;
}
}
5.2 执行交易(核心入口)
/// @notice 验证签名并执行交易
function execTransaction(
address to,
uint256 value,
bytes memory data,
bytes memory signatures
) public payable virtual returns (bool success) {
// 1. 计算交易哈希(包含 nonce 和 chainId)
bytes32 txHash = encodeTransactionData(
to, value, data, nonce, block.chainid
);
// 2. nonce 递增(即使后续失败也递增,防重入重放)
nonce++;
// 3. 验证签名
checkSignatures(txHash, signatures);
// 4. 执行实际交易
(success, ) = to.call{value: value}(data);
// 5. 触发事件
if (success) emit ExecutionSuccess(txHash);
else emit ExecutionFailure(txHash);
}
5.3 签名验证
/// @notice 验证签名是否来自足够数量的合法 owner
function checkSignatures(bytes32 txHash, bytes memory signatures) public view {
uint256 _threshold = threshold;
require(_threshold > 0, "Not initialized");
require(signatures.length >= _threshold * 65, "Not enough signatures");
address lastOwner = address(0); // 初始为最小地址
address currentOwner;
uint8 v; bytes32 r; bytes32 s;
for (uint256 i = 0; i < _threshold; i++) {
// 提取第 i 个签名的 v, r, s
(v, r, s) = signatureSplit(signatures, i);
// 用 ecrecover 恢复签名者地址(加以太坊前缀 EIP-191)
currentOwner = ecrecover(
keccak256(abi.encodePacked(
"\x19Ethereum Signed Message:\n32", txHash
)),
v, r, s
);
// 检查: 地址严格递增 + 是合法 owner
require(
currentOwner > lastOwner && ownerMap[currentOwner],
"Invalid signature"
);
lastOwner = currentOwner;
}
}
5.4 签名拆分(内联汇编)
/// @notice 从拼接签名中提取第 pos 个签名
function signatureSplit(
bytes memory signatures, uint256 pos
) internal pure returns (uint8 v, bytes32 r, bytes32 s) {
assembly {
// 每个签名 65 字节 = 0x41
let signaturePos := mul(0x41, pos)
// 跳过 bytes 的 32 字节长度前缀
r := mload(add(signatures, add(signaturePos, 0x20)))
s := mload(add(signatures, add(signaturePos, 0x40)))
v := and(mload(add(signatures, add(signaturePos, 0x41))), 0xff)
}
}
5.5 交易哈希编码
/// @notice 将交易参数编码为唯一哈希
function encodeTransactionData(
address _to,
uint256 _value,
bytes memory _data,
uint256 _nonce,
uint256 _chainId
) public pure returns (bytes32) {
return keccak256(
abi.encode(_to, _value, keccak256(_data), _nonce, _chainId)
);
}
六、完整使用示例
6.1 部署
owners: [0x191b...1344, 0x4916...3420]
threshold: 2 (2-of-2 多签)
6.2 链下签名(ethers.js)
const { ethers } = require("ethers");
// 两个 owner 的钱包
const owner1 = new ethers.Wallet("私钥1");
const owner2 = new ethers.Wallet("私钥2");
// 构造交易参数
const to = "0x191b...1344"; // 转账目标
const value = ethers.parseEther("0.01");
const data = "0x"; // 纯转账
const nonce = 0;
const chainId = 11155111; // Sepolia
// 计算交易哈希(与合约中 encodeTransactionData 逻辑一致)
const txHash = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["address", "uint256", "bytes32", "uint256", "uint256"],
[to, value, ethers.keccak256(data), nonce, chainId]
)
);
// 各 owner 签名(ethers.js 自动加以太坊前缀)
const sig1 = await owner1.signMessage(ethers.getBytes(txHash));
const sig2 = await owner2.signMessage(ethers.getBytes(txHash));
// 按地址排序后拼接(去掉 0x 前缀再拼接)
// 假设 owner1 地址 < owner2 地址
const signatures = sig1 + sig2.slice(2); // 拼接为一个完整的 bytes
// 调用合约
await multiSig.execTransaction(to, value, data, signatures);
6.3 签名排序的重要性
owner1 地址: 0x191b...1344 (较小)
owner2 地址: 0x4916...3420 (较大)
正确顺序: signatures = sig1 + sig2 (地址递增: 0x191b < 0x4916 ✅)
错误顺序: signatures = sig2 + sig1 (地址递减: 0x4916 > 0x191b ❌ revert!)
七、与 EOA 钱包的对比
| 特性 | EOA 钱包 | 多签钱包 |
|---|---|---|
| 控制方式 | 单个私钥 | 多个私钥共同控制 |
| 安全性 | 私钥丢失=资产丢失 | 丢一个私钥不影响 |
| 作恶防范 | 无 | 少数人无法独自操作 |
| 使用成本 | 低 | 高(链下协调+链上验证) |
| 适用场景 | 个人日常使用 | 团队/DAO/大额资产 |
八、安全注意事项
- threshold 不能设为 1:否则退化为单签,失去多签意义
- owner 列表不可变:部署后无法增减 owner(此版本)
- 签名必须排序:按 owner 地址升序拼接
- nonce 管理:链下签名时必须获取当前 nonce,过期的 nonce 签名无效
- call 返回值:即使
to.call失败,多签交易本身不会 revert(只是返回 success=false)