Appearance
L2-14: Social Recovery(社交恢复钱包) #
1. 问题 #
私钥丢失是导致加密货币永久损失的首要原因。助记词备份机制依赖用户妥善保管 12-24 个单词,这在实践中是巨大的 UX 失败——纸条丢失、拍照泄露、火灾烧毁、继承人不知情——任何一个环节出问题就万劫不复。而中心化的恢复方案(如让交易所托管密钥)又引入了对手方风险。
社交恢复(Social Recovery)是 Vitalik Buterin 长期推崇的解决方案:用户指定一组 "Guardians"(监护人),通常是朋友、家人或可信机构。当用户丢失了私钥,只需要超过阈值数量的 Guardians 同意,就可以将钱包的所有权转移到新地址。这是一种"社会共识替代密码学"的范式——用社交信任网络替代单一的密钥材料。
本挑战(参考 src/level2/SocialRecoveryWallet.sol)要求实现一个智能合约钱包,支持 Guardian 管理、全票同意恢复(所有 Guardians 同意即转移所有权)、以及 Owner 的任意合约调用能力。
2. 原因 #
社交恢复不是理论概念——它已经在生产环境中落地。Argent 钱包使用社交恢复作为其核心安全模型;Loopring 钱包采用"Guardians + 每日限额"的混合方案;Safe(原 Gnosis Safe)的多签本质上是 M-of-N 社交恢复的特例。理解社交恢复的智能合约实现是进入智能钱包(Smart Wallet)和账户抽象(Account Abstraction)领域的关键入口。
在技术层面,SocialRecoveryWallet 是"智能合约钱包"的最小可行实现。它与传统 EOA 钱包的根本区别在于:钱包的所有权不是一个私钥,而是一个合约地址——Owner 通过调用合约函数来使用钱包,而非通过签名交易。这种"合约作为钱包"的模式是 ERC-4337 账户抽象的基础:钱包的行为由合约代码定义,用户可以自定义恢复逻辑、交易限制、Gas 支付方式等。
社交恢复也是"共识机制"的微观演示:N 个 Guardians 中的全部 N 个(本实现使用全员同意,即 N-of-N)需要达成共识才能更换 Owner——这与区块链的共识协议(验证者就新区块达成一致)在本质上是相同的模式,只是参与者从几千个验证者缩小到了几个 Guardians。
3. 方案 #
SocialRecoveryWallet 合约(参考 src/level2/SocialRecoveryWallet.sol)实现了一个三功能核心的钱包:
架构 #
SocialRecoveryWallet
├── 钱包执行层
│ └── call(callee, value, data) ← onlyOwner
├── 社交恢复层
│ └── signalNewOwner(proposedOwner) ← onlyGuardian
│ 当全部 Guardian 同意后自动转移 Owner
├── Guardian 管理层
│ ├── addGuardian(guardian) ← onlyOwner
│ └── removeGuardian(guardian) ← onlyOwner
└── 资金接收
└── receive() ← 接受 ETH
关键实现细节 #
solidity
contract SocialRecoveryWallet {
address public owner;
address[] public guardians;
mapping(address => bool) public isGuardian;
uint256 public guardianCount;
// proposedOwner → guardianAddr → hasSignaled
mapping(address => mapping(address => bool)) public hasSignaled;
// proposedOwner → signalCount
mapping(address => uint256) public signalCount;
constructor(address _owner, address[] memory _guardians) {
owner = _owner;
for (uint256 i; i < _guardians.length; ++i) {
address guardian = _guardians[i];
isGuardian[guardian] = true;
guardians.push(guardian);
}
guardianCount = _guardians.length;
}
// ===== 钱包执行 =====
function call(address callee, uint256 value, bytes calldata data)
external onlyOwner
{
(bool success, ) = callee.call{value: value}(data);
if (!success) revert CallFailed();
}
// ===== 社交恢复核心 =====
function signalNewOwner(address _proposedOwner) external onlyGuardian {
// 每个 Guardian 限投一票
if (hasSignaled[_proposedOwner][msg.sender]) return;
hasSignaled[_proposedOwner][msg.sender] = true;
signalCount[_proposedOwner] += 1;
emit NewOwnerSignaled(msg.sender, _proposedOwner);
// 全员同意 → 立即执行恢复
if (signalCount[_proposedOwner] == guardianCount) {
owner = _proposedOwner;
emit RecoveryExecuted(_proposedOwner);
}
}
// ===== Guardian 管理 =====
function addGuardian(address _guardian) external onlyOwner {
if (isGuardian[_guardian]) revert AlreadyGuardian();
isGuardian[_guardian] = true;
guardians.push(_guardian);
guardianCount += 1;
}
function removeGuardian(address _guardian) external onlyOwner {
if (!isGuardian[_guardian]) revert NotAGuardian();
isGuardian[_guardian] = false;
guardianCount -= 1;
// swap + pop 从数组中移除
for (uint256 i; i < guardians.length; ++i) {
if (guardians[i] == _guardian) {
guardians[i] = guardians[guardians.length - 1];
guardians.pop();
break;
}
}
}
}
恢复流程 #
1. 用户丢失私钥
2. 任何人提议新 Owner 地址 → signalNewOwner(newOwner)
3. 每个 Guardian 投票同意
4. 当 signalCount == guardianCount(全员同意)
5. owner 自动变更为 newOwner
6. 新 Owner 现在可以调用 call() 执行任意操作
设计的权衡:N-of-N vs M-of-N #
本实现使用 N-of-N(全员同意),即所有 Guardians 都必须投票才能恢复。这是一个安全保守的选择——攻击者必须攻破所有 Guardian 的私钥才能窃取钱包。但在实践中,M-of-N(如 3-of-5)更常用,因为:
- Guardian 本身可能丢失私钥或不可用(死亡、失踪)
- M < N 的阈值提供了冗余——即使少数 Guardian 不可用,恢复仍能完成
- M-of-N 在安全性和可用性之间取得平衡
将 N-of-N 改为 M-of-N 需要的变更:添加 threshold 状态变量,将全票检查 signalCount == guardianCount 改为 signalCount >= threshold。
4. 遭遇的陷阱 #
- Guardian 共谋攻击:如果所有 Guardians 串通(或被同一攻击者控制),他们可以随时更换 Owner 并盗取所有资金——N-of-N 在 Guardian 数量少时风险极高
- Guardian 不可用:如果一个 Guardian 丢失了私钥或不可联系,恢复永远无法完成(因为需要全员同意)——钱包中的资金被永久锁定
- 无恢复延迟:一旦所有 Guardians 同意,Owner 立即变更——没有时间窗口让原 Owner 在发现异常时取消恢复
- 无冷却期:同一组 Guardians 可以反复更换 Owner——一次恢复完成后,需要"重置"投票状态才能进行下一次恢复
- 残留投票状态:成功恢复后,
hasSignaled和signalCount中残留了之前的投票记录——如果同一个 proposedOwner 地址再次被提议,之前的投票仍然有效 - Guardian 集无法自恢复:Guardian 只能由 Owner 管理(add/remove)——如果 Owner 丢了私钥且 Guardians 数量不足,连 Guardian 列表都无法更新
- call 函数的任意性风险:
call(callee, value, data)可以执行任意合约调用——如果 Owner 被恶意夺取,攻击者可以立即通过 call 转走所有资产
5. 陷阱的原因 #
Guardian 共谋是任何基于信任的系统都面临的固有问题。在社交恢复的上下文中,Guardians 通常是用户认识的人——朋友、家人、同事。如果用户选择了不可靠的 Guardians(或选择太少),他们合谋攻击的门槛很低。这就是为什么生产级钱包(如 Argent)除了 Guardians 还引入了"每日限额"和"大额转账延迟"等额外防御机制。
无恢复延迟将恢复过程简化为纯投票逻辑,但忽略了"原 Owner 可能仍然控制钱包"的场景。理想的恢复流程应该是:
- Guardians 投票同意恢复
- 进入延迟期(如 48 小时)
- 期间原 Owner 可以取消恢复请求
- 延迟期过后自动执行
这个延迟期(Timelock)是防御"Guardians 被攻破但原 Owner 未被攻破"场景的关键。
call 的任意性是智能合约钱包的"双刃剑"——它赋予了 Owner 完整的链上操作能力(转账 ETH、调用 DEX、授权 ERC-20 等),但也意味着一旦 Owner 失陷,攻击者拥有同样的完整能力。在更高级的实现中,可以对 call 添加限制:每日限额(超过需多签审批)、目标地址白名单(只能与已知合约交互)、或要求 Guardian 共同签名大额交易。
Guardian 集的管理权限集中在 Owner 手中,这创造了一个悖论:恢复功能的存在是为了在 Owner 丢失私钥时拯救他,但 Guardian 列表的修改又需要 Owner——如果 Owner 丢失了私钥,Guardian 列表就被冻结了,无法根据变化的社交关系进行调整。
6. 如何解决陷阱 #
引入恢复延迟:添加 recoveryDelay 和 pendingRecovery 结构,使恢复不是即时的:
solidity
struct RecoveryRequest {
address proposedOwner;
uint256 initiatedAt;
}
uint256 public recoveryDelay = 2 days;
RecoveryRequest public pendingRecovery;
function signalNewOwner(address _proposedOwner) external onlyGuardian {
if (hasSignaled[_proposedOwner][msg.sender]) return;
hasSignaled[_proposedOwner][msg.sender] = true;
signalCount[_proposedOwner] += 1;
// 达到阈值 → 启动延迟恢复(而非立即执行)
if (signalCount[_proposedOwner] == guardianCount) {
pendingRecovery = RecoveryRequest(_proposedOwner, block.timestamp);
emit RecoveryInitiated(_proposedOwner, block.timestamp + recoveryDelay);
}
}
function executeRecovery() external {
require(pendingRecovery.proposedOwner != address(0), "No pending recovery");
require(block.timestamp >= pendingRecovery.initiatedAt + recoveryDelay, "Delay not elapsed");
owner = pendingRecovery.proposedOwner;
delete pendingRecovery;
// 重置投票状态
}
function cancelRecovery() external onlyOwner {
delete pendingRecovery;
}
从 N-of-N 改为 M-of-N:添加 threshold 参数,允许部分 Guardian 不可用时仍能完成恢复:
solidity
uint256 public threshold; // 如 3(需要 5 个中的 3 个同意)
if (signalCount[_proposedOwner] >= threshold) { // 而非 == guardianCount
// 执行恢复
}
重置投票状态:恢复完成后,清除旧的投票记录:
solidity
function _clearSignals(address _proposedOwner) internal {
for (uint256 i = 0; i < guardians.length; i++) {
hasSignaled[_proposedOwner][guardians[i]] = false;
}
signalCount[_proposedOwner] = 0;
}
Guardian 自我管理:允许 Guardians 投票添加/移除其他 Guardians——但这会使系统更复杂,需要仔细设计权限模型。
限制 call 的能力:添加每日限额和延迟转账作为防护层:
solidity
uint256 public dailyLimit = 1 ether;
uint256 public dailySpent;
uint256 public lastResetDay;
function call(address callee, uint256 value, bytes calldata data) external onlyOwner {
// 对超过每日限额的大额转账施加延迟
if (value > dailyLimit) {
// 进入 timelock 队列,24h 后才能执行
}
// 小额转账立即执行
(bool success, ) = callee.call{value: value}(data);
if (!success) revert CallFailed();
}
7. 技术要点 #
| 要点 | 说明 |
|---|---|
| 社交恢复模型 | N 个 Guardians → M 个同意 → 更换 Owner |
| 全票 vs 阈值 | N-of-N(全员)更安全但脆弱;M-of-N 提供冗余 |
| 恢复延迟 (Timelock) | 投票完成后等待 N 小时再执行——给原 Owner 取消窗口 |
| Guardian 管理 | onlyOwner add/remove Guardians;需要 swap+pop 删除 |
| 合约钱包模式 | call(callee, value, data) 执行任意操作 |
| 双映射追踪 | hasSignaled[proposed][guardian] + signalCount[proposed] |
| 无单点故障 | Owner 丢失 → Guardians 投票恢复,不依赖助记词 |
| 前置依赖 | 基于 Dead Man's Switch(L1-11)的时间权限模型 |