5月28日 01:26

Solidity 中如何实现多签钱包(Multi-Sig Wallet)?

多签钱包(Multi-Signature Wallet)是一种需要多个私钥共同授权才能执行交易的智能合约。比如 3/5 多签表示 5 个持有者中至少 3 人确认才能转账,任何单点私钥泄露都无法独自挪走资金。

这类问题在 Solidity 面试中出现频率很高,考察的是你对合约安全设计、状态管理和外部调用的综合理解。

1. 核心数据结构设计

多签钱包的状态管理围绕三个核心映射展开:

solidity
contract MultiSigWallet { address[] public owners; mapping(address => bool) public isOwner; uint256 public numConfirmationsRequired; struct Transaction { address to; uint256 value; bytes data; bool executed; uint256 numConfirmations; } Transaction[] public transactions; mapping(uint256 => mapping(address => bool)) public isConfirmed; }

isOwner 映射用于 O(1) 权限校验,避免遍历数组。isConfirmed 二维映射记录每笔交易每个所有者的确认状态,防止重复确认。构造函数中必须校验:所有者不为空、地址不为零、地址不重复、阈值在有效范围内。

2. 交易生命周期:提交-确认-执行

这是多签钱包最核心的业务流程:

solidity
// 提交交易 function submitTransaction( address _to, uint256 _value, bytes memory _data ) public onlyOwner { uint256 txIndex = transactions.length; transactions.push(Transaction({ to: _to, value: _value, data: _data, executed: false, numConfirmations: 0 })); emit SubmitTransaction(msg.sender, txIndex, _to, _value, _data); } // 确认交易 function confirmTransaction(uint256 _txIndex) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) notConfirmed(_txIndex) { Transaction storage transaction = transactions[_txIndex]; transaction.numConfirmations += 1; isConfirmed[_txIndex][msg.sender] = true; emit ConfirmTransaction(msg.sender, _txIndex); } // 执行交易 function executeTransaction(uint256 _txIndex) public onlyOwner txExists(_txIndex) notExecuted(_txIndex) { Transaction storage transaction = transactions[_txIndex]; require( transaction.numConfirmations >= numConfirmationsRequired, "confirmations not enough" ); transaction.executed = true; (bool success, ) = transaction.to.call{value: transaction.value}(transaction.data); require(success, "tx failed"); emit ExecuteTransaction(msg.sender, _txIndex); }

关键点:executeTransaction 中必须先标记 executed = true 再执行外部调用,这是 CEI(Checks-Effects-Interactions)模式的核心——先修改状态,再与外部合约交互,防止重入攻击。外部调用使用低级 .call 而非 .transfer.send,因为后两者 gas 限制为 2300,无法适配现代合约。

3. 重入攻击防护

多签钱包天然部分缓解了重入风险——交易执行需要多人确认。但仍需严格遵守 CEI:

solidity
// 正确:先改状态,再调用 transaction.executed = true; (bool success, ) = transaction.to.call{value: transaction.value}(transaction.data); // 错误:先调用,再改状态 → 重入漏洞 (bool success, ) = transaction.to.call{value: transaction.value}(transaction.data); transaction.executed = true; // 攻击者可在回调中再次执行

如果项目对安全性要求更高,可以加 ReentrancyGuard:

solidity
modifier nonReentrant() { require(!locked, "reentrant call"); locked = true; _; locked = false; }

4. ERC20 代币兼容

多签钱包处理 ETH 转账比较直接,但 ERC20 代币有两处坑:

第一,部分 ERC20 代币(如 USDT)的 transfer 不返回 bool,直接调用会因为 Solidity 0.8 强制检查返回值而 revert。解决方案是用 IERC20 接口做底层调用:

solidity
function safeTransfer(IERC20 token, address to, uint256 amount) internal { (bool success, bytes memory data) = address(token).call( abi.encodeWithSelector(token.transfer.selector, to, amount) ); require(success && (data.length == 0 || abi.decode(data, (bool))), "ERC20 transfer failed"); }

第二,ERC20 的 approve 存在竞态问题。从零改到非零时,应先 approve(0)approve(newAmount),或使用 OpenZeppelin 的 SafeERC20 库。

5. 动态管理:增删所有者与修改阈值

生产环境需要动态调整所有者列表和确认阈值。关键在于:这些操作必须由多签合约自身发起(onlyWallet),而非某个所有者直接调用。流程是:所有者提交一笔类型为"添加所有者"的交易 -> 多人确认 -> 合约执行时调用内部函数修改状态。

solidity
modifier onlyWallet() { require(msg.sender == address(this), "only wallet itself"); _; } function addOwner(address _owner) external onlyWallet { require(_owner != address(0) && !isOwner[_owner], "invalid owner"); isOwner[_owner] = true; owners.push(_owner); emit OwnerAdded(_owner); }

移除所有者时要用"交换删除"而非遍历移位,保持 O(1) 复杂度:

solidity
function removeOwner(address _owner) external onlyWallet { require(isOwner[_owner], "not owner"); uint256 index = ownerIndex[_owner]; address lastOwner = owners[owners.length - 1]; owners[index] = lastOwner; ownerIndex[lastOwner] = index; owners.pop(); delete isOwner[_owner]; delete ownerIndex[_owner]; emit OwnerRemoved(_owner); }

修改阈值时必须校验新阈值不超过当前所有者数量,否则合约会被锁死。

6. Gnosis Safe 的 EIP-712 签名方案

Gnosis Safe(现称 Safe)是多签钱包的工业标准,它不要求链上逐笔确认,而是收集链下签名后一次性提交执行:

solidity
bytes32 private constant SAFE_TX_TYPEHASH = keccak256( "SafeTx(address to,uint256 value,bytes data,uint8 operation," "uint256 safeTxGas,uint256 baseGas,uint256 gasPrice," "address gasToken,address refundReceiver,uint256 nonce)" ); function getTransactionHash(/* 参数 */) public view returns (bytes32) { bytes32 safeTxHash = keccak256(abi.encode( SAFE_TX_TYPEHASH, to, value, keccak256(data), operation, safeTxGas, baseGas, gasPrice, gasToken, refundReceiver, nonce )); return keccak256(abi.encodePacked( bytes1(0x19), bytes1(0x01), domainSeparator(), safeTxHash )); }

签名按地址升序排列,执行时逐一恢复签名者并验证:

solidity
function checkSignatures(bytes32 dataHash, bytes memory signatures) public view { require(signatures.length >= threshold * 65, "not enough signatures"); address lastSigner = address(0); for (uint i = 0; i < threshold; i++) { bytes memory sig = slice(signatures, i * 65, 65); address signer = ECDSA.recover(dataHash, sig); require(isOwner[signer] && signer > lastSigner, "invalid signer"); lastSigner = signer; } }

链下签名的优势:省 gas、支持硬件钱包签名、可批量提交。

7. 安全加固清单

措施说明
时间锁交易提交后延迟 24h 才能执行,给社区反应时间
每日限额防止单日大额转出,超限需更高阈值
白名单限制交易目标地址,防止误转或恶意转账
紧急暂停发现漏洞时可暂停所有交易执行
代理升级使用 UUPS 或 Transparent Proxy 支持合约升级
事件审计所有关键操作触发事件,便于链下监控

生产环境建议直接使用经过审计的 Safe 合约,而非自行实现。自研多签适合学习,上线必须审计。

8. 面试高频追问

Q: 多签钱包如何防重入? CEI 模式:先标记 executed = true 再执行外部调用。isConfirmed 映射天然防重复确认。如需更强保障,加 nonReentrant 修饰器。

Q: 为什么用 .call 而不用 .transfer .transfer 固定 2300 gas,EIP-1884 后很多合约的 fallback 超过此限制会 revert。.call 转发剩余 gas 并返回 bool,更灵活也更安全。

Q: Gnosis Safe 和简单多签有什么区别? 简单多签每次确认都是链上交易,gas 高。Safe 收集 EIP-712 链下签名,执行时一次性提交,省 gas 且支持硬件钱包。Safe 还支持模块化插件、代理升级和批量操作。

Q: 所有者被移除后,其已确认的交易怎么办? 应在移除所有者时遍历所有未执行交易,清除其确认并减少 numConfirmations。否则确认数虚高,可能低于阈值就能执行,这是真实审计中发现过的漏洞。

Q: 如何处理 ERC20 代币的 USDT 不返回 bool 问题? 用低级 .call 调用 transfer 选择器,手动解码返回值:data.length == 0 视为成功(USDT 场景),否则 abi.decode(data, (bool)) 必须为 true。或直接用 SafeERC20 库的 safeTransfer

标签:Solidity