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管理、全会一致の復旧(全Guardianが同意すれば所有権移転)、およびOwnerの任意のコントラクト呼び出し機能をサポートするスマートコントラクトウォレットの実装が求められます。

2. 理由

ソーシャルリカバリーは理論上の概念ではなく、すでに本番環境で実用化されています。Argentウォレットはソーシャルリカバリーを中核的セキュリティモデルとして採用し、Loopringウォレットは「Guardians + 日次限度額」のハイブリッド方式を採用し、Safe(旧Gnosis Safe)のマルチシグは本質的にM-of-Nソーシャルリカバリーの特殊ケースです。ソーシャルリカバリーのスマートコントラクト実装を理解することは、スマートウォレット(Smart Wallet)およびアカウントアブストラクション(Account Abstraction)の世界への重要な入口です。

技術面では、SocialRecoveryWalletは「スマートコントラクトウォレット」の最小限の実装です。従来のEOAウォレットとの根本的な違いは、ウォレットの所有権が秘密鍵ではなくコントラクトアドレスであることです — Ownerはトランザクションへの署名ではなく、コントラクト関数を呼び出すことでウォレットを使用します。この「コントラクトとしてのウォレット」パターンは、ERC-4337アカウントアブストラクションの基盤です。ウォレットの動作はコントラクトコードによって定義され、ユーザーは復旧ロジック、取引制限、ガス支払い方法などをカスタマイズできます。

ソーシャルリカバリーは「コンセンサスメカニズム」のミクロなデモンストレーションでもあります。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は1票のみ
        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 == guardianCountsignalCount >= threshold に変更します。

4. 遭遇した落とし穴

  • Guardian共謀攻撃:すべてのGuardiansが共謀した場合(または同一の攻撃者に制御された場合)、いつでもOwnerを交代させ全資金を盗むことができます — Guardian数が少ない場合のN-of-Nはリスクが極めて高い
  • Guardianの利用不能:1人のGuardianが秘密鍵を紛失または連絡不能になった場合、復旧は永遠に完了しません(全会一致が必要なため)— ウォレット内の資金は永久にロックされる
  • 復旧遅延なし:全Guardianが同意すると即座にOwnerが変更される — 元のOwnerが異常を検知して復旧をキャンセルする時間枠がない
  • クールダウン期間なし:同じGuardianグループが繰り返しOwnerを交代できる — 1回の復旧完了後、次の復旧のために投票状態を「リセット」する必要がある
  • 残留投票状態:復旧成功後、hasSignaledsignalCount に以前の投票記録が残っている — 同じproposedOwnerアドレスが再度提案された場合、以前の投票が依然有効
  • Guardianセットの自己復旧不可:GuardianはOwnerのみが管理(add/remove)できる — Ownerが秘密鍵を紛失しGuardian数が不足している場合、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) {
        // タイムロックキューに入り、24時間後に実行可能
    }
    // 少額送金は即時実行
    (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でGuardians追加/削除;削除にはswap+popを使用
コントラクトウォレットパターンcall(callee, value, data) で任意の操作を実行
二重マッピング追跡hasSignaled[proposed][guardian] + signalCount[proposed]
単一障害点なしOwner紛失 → Guardians投票で復旧、ニーモニックに依存しない
前提知識Dead Man's Switch(L1-11)の時間ベース権限モデルに基づく

Built with AiAda