Skip to content
On this page

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——一次恢复完成后,需要"重置"投票状态才能进行下一次恢复
  • 残留投票状态:成功恢复后,hasSignaledsignalCount 中残留了之前的投票记录——如果同一个 proposedOwner 地址再次被提议,之前的投票仍然有效
  • Guardian 集无法自恢复:Guardian 只能由 Owner 管理(add/remove)——如果 Owner 丢了私钥且 Guardians 数量不足,连 Guardian 列表都无法更新
  • call 函数的任意性风险call(callee, value, data) 可以执行任意合约调用——如果 Owner 被恶意夺取,攻击者可以立即通过 call 转走所有资产

5. 陷阱的原因

Guardian 共谋是任何基于信任的系统都面临的固有问题。在社交恢复的上下文中,Guardians 通常是用户认识的人——朋友、家人、同事。如果用户选择了不可靠的 Guardians(或选择太少),他们合谋攻击的门槛很低。这就是为什么生产级钱包(如 Argent)除了 Guardians 还引入了"每日限额"和"大额转账延迟"等额外防御机制。

无恢复延迟将恢复过程简化为纯投票逻辑,但忽略了"原 Owner 可能仍然控制钱包"的场景。理想的恢复流程应该是:

  1. Guardians 投票同意恢复
  2. 进入延迟期(如 48 小时)
  3. 期间原 Owner 可以取消恢复请求
  4. 延迟期过后自动执行

这个延迟期(Timelock)是防御"Guardians 被攻破但原 Owner 未被攻破"场景的关键。

call 的任意性是智能合约钱包的"双刃剑"——它赋予了 Owner 完整的链上操作能力(转账 ETH、调用 DEX、授权 ERC-20 等),但也意味着一旦 Owner 失陷,攻击者拥有同样的完整能力。在更高级的实现中,可以对 call 添加限制:每日限额(超过需多签审批)、目标地址白名单(只能与已知合约交互)、或要求 Guardian 共同签名大额交易。

Guardian 集的管理权限集中在 Owner 手中,这创造了一个悖论:恢复功能的存在是为了在 Owner 丢失私钥时拯救他,但 Guardian 列表的修改又需要 Owner——如果 Owner 丢失了私钥,Guardian 列表就被冻结了,无法根据变化的社交关系进行调整。

6. 如何解决陷阱

引入恢复延迟:添加 recoveryDelaypendingRecovery 结构,使恢复不是即时的:

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)的时间权限模型

Built with AiAda