Appearance
L2-14: Social Recovery (Social Recovery Wallet) #
1. Problem #
Private key loss is the number one cause of permanent cryptocurrency loss. The mnemonic backup mechanism relies on users safely storing 12-24 words, which in practice is a massive UX failure -- the paper gets lost, a photo leaks, a fire destroys it, heirs don't know about it -- any single link in the chain breaking means total disaster. Centralized recovery solutions (like having an exchange custody your keys) introduce counterparty risk.
Social Recovery is a solution that Vitalik Buterin has long championed: the user designates a set of "Guardians," typically friends, family, or trusted institutions. When the user loses their private key, as long as more than a threshold number of Guardians agree, ownership of the wallet can be transferred to a new address. This is a paradigm of "social consensus replacing cryptography" -- using a social trust network to replace a single key material.
This challenge (refer to src/level2/SocialRecoveryWallet.sol) requires implementing a smart contract wallet that supports Guardian management, unanimous-consent recovery (all Guardians agree = ownership transfer), and the Owner's ability to make arbitrary contract calls.
2. Why #
Social recovery is not a theoretical concept -- it has already been deployed in production. The Argent wallet uses social recovery as its core security model; the Loopring wallet uses a hybrid "Guardians + daily limit" approach; Safe (formerly Gnosis Safe)'s multi-sig is essentially a special case of M-of-N social recovery. Understanding the smart contract implementation of social recovery is a key entry point into the world of Smart Wallets and Account Abstraction.
At the technical level, SocialRecoveryWallet is a minimal viable implementation of a "smart contract wallet." Its fundamental difference from traditional EOA wallets is that the wallet's ownership is not a private key but a contract address -- the Owner uses the wallet by calling contract functions, not by signing transactions. This "contract as wallet" pattern is the foundation of ERC-4337 account abstraction: the wallet's behavior is defined by contract code, and users can customize recovery logic, transaction limits, gas payment methods, and more.
Social recovery is also a micro-demonstration of "consensus mechanisms": all N out of N Guardians (this implementation uses unanimous consent, i.e., N-of-N) must reach consensus to change the Owner -- this is fundamentally the same pattern as a blockchain's consensus protocol (validators reaching agreement on new blocks), just with the participants scaled down from thousands of validators to a handful of Guardians.
3. Solution #
The SocialRecoveryWallet contract (refer to src/level2/SocialRecoveryWallet.sol) implements a three-function core wallet:
Architecture #
SocialRecoveryWallet
├── Wallet execution layer
│ └── call(callee, value, data) <- onlyOwner
├── Social recovery layer
│ └── signalNewOwner(proposedOwner) <- onlyGuardian
│ When all Guardians agree, automatically transfers Owner
├── Guardian management layer
│ ├── addGuardian(guardian) <- onlyOwner
│ └── removeGuardian(guardian) <- onlyOwner
└── Fund reception
└── receive() <- accepts ETH
Key Implementation Details #
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;
}
// ===== Wallet Execution =====
function call(address callee, uint256 value, bytes calldata data)
external onlyOwner
{
(bool success, ) = callee.call{value: value}(data);
if (!success) revert CallFailed();
}
// ===== Social Recovery Core =====
function signalNewOwner(address _proposedOwner) external onlyGuardian {
// Each Guardian gets one vote
if (hasSignaled[_proposedOwner][msg.sender]) return;
hasSignaled[_proposedOwner][msg.sender] = true;
signalCount[_proposedOwner] += 1;
emit NewOwnerSignaled(msg.sender, _proposedOwner);
// Unanimous consent -> immediately execute recovery
if (signalCount[_proposedOwner] == guardianCount) {
owner = _proposedOwner;
emit RecoveryExecuted(_proposedOwner);
}
}
// ===== Guardian Management =====
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 to remove from array
for (uint256 i; i < guardians.length; ++i) {
if (guardians[i] == _guardian) {
guardians[i] = guardians[guardians.length - 1];
guardians.pop();
break;
}
}
}
}
Recovery Flow #
1. User loses private key
2. Anyone proposes a new Owner address -> signalNewOwner(newOwner)
3. Each Guardian votes to agree
4. When signalCount == guardianCount (unanimous consent)
5. owner is automatically changed to newOwner
6. The new Owner can now call call() to execute arbitrary operations
Design Trade-off: N-of-N vs M-of-N #
This implementation uses N-of-N (unanimous consent), meaning all Guardians must vote for recovery to succeed. This is a security-conservative choice -- an attacker would need to compromise every Guardian's private key to steal the wallet. In practice, however, M-of-N (e.g., 3-of-5) is more common because:
- Guardians themselves may lose private keys or become unavailable (death, disappearance)
- An M < N threshold provides redundancy -- recovery can still complete even if a minority of Guardians are unavailable
- M-of-N balances security and availability
Changes needed to convert N-of-N to M-of-N: add a threshold state variable, and change the unanimous check signalCount == guardianCount to signalCount >= threshold.
4. Pitfalls Encountered #
- Guardian collusion attack: If all Guardians collude (or are controlled by the same attacker), they can replace the Owner at any time and steal all funds -- N-of-N is extremely risky when the number of Guardians is small
- Guardian unavailability: If one Guardian loses their private key or becomes unreachable, recovery can never complete (because unanimous consent is required) -- funds in the wallet are permanently locked
- No recovery delay: Once all Guardians agree, the Owner changes immediately -- there is no time window for the original Owner to cancel the recovery upon detecting anomalies
- No cooldown period: The same set of Guardians can replace the Owner repeatedly -- after one recovery completes, the voting state needs to be "reset" before the next recovery can proceed
- Residual voting state: After a successful recovery,
hasSignaledandsignalCountretain previous voting records -- if the same proposedOwner address is proposed again, the previous votes are still valid - Guardian set cannot self-recover: Guardians can only be managed (add/remove) by the Owner -- if the Owner loses their private key and there are not enough Guardians, even the Guardian list cannot be updated
- Arbitrariness risk of the call function:
call(callee, value, data)can execute arbitrary contract calls -- if the Owner is maliciously seized, the attacker can immediately drain all assets viacall
5. Why the Pitfalls Exist #
Guardian collusion is an inherent problem in any trust-based system. In the context of social recovery, Guardians are typically people the user knows -- friends, family, colleagues. If the user chooses unreliable Guardians (or too few of them), the barrier to collusion is low. This is why production-grade wallets (like Argent) introduce additional defense mechanisms beyond Guardians, such as "daily limits" and "large transfer delays."
The absence of a recovery delay simplifies the recovery process to pure voting logic but ignores the scenario where "the original Owner may still control the wallet." An ideal recovery flow should be:
- Guardians vote to agree on recovery
- Enter a delay period (e.g., 48 hours)
- During this period, the original Owner can cancel the recovery request
- After the delay period, execute automatically
This delay period (Timelock) is key to defending against the scenario where "Guardians are compromised but the original Owner is not."
The arbitrariness of call is the "double-edged sword" of smart contract wallets -- it grants the Owner full on-chain operational capability (transfer ETH, call DEXs, authorize ERC-20, etc.), but also means that once the Owner is compromised, the attacker has the same full capability. In more advanced implementations, restrictions can be placed on call: daily limits (exceeding requires multi-sig approval), target address whitelists (only interact with known contracts), or requiring Guardian co-signatures for large transactions.
Guardian set management authority is concentrated in the Owner's hands, which creates a paradox: the recovery feature exists to save the Owner when they lose their private key, but modifications to the Guardian list also require the Owner -- if the Owner loses their private key, the Guardian list is frozen and cannot be adjusted to reflect changing social relationships.
6. How to Resolve the Pitfalls #
Introduce a recovery delay: Add recoveryDelay and a pendingRecovery struct so recovery is not immediate:
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;
// Threshold reached -> initiate delayed recovery (rather than immediate execution)
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;
// Reset voting state
}
function cancelRecovery() external onlyOwner {
delete pendingRecovery;
}
Convert from N-of-N to M-of-N: Add a threshold parameter so recovery can still complete when some Guardians are unavailable:
solidity
uint256 public threshold; // e.g., 3 (need 3 out of 5 to agree)
if (signalCount[_proposedOwner] >= threshold) { // rather than == guardianCount
// Execute recovery
}
Reset voting state: After recovery completes, clear old voting records:
solidity
function _clearSignals(address _proposedOwner) internal {
for (uint256 i = 0; i < guardians.length; i++) {
hasSignaled[_proposedOwner][guardians[i]] = false;
}
signalCount[_proposedOwner] = 0;
}
Guardian self-management: Allow Guardians to vote to add/remove other Guardians -- but this makes the system more complex and requires careful permission model design.
Restrict call capabilities: Add daily limits and delayed transfers as protective layers:
solidity
uint256 public dailyLimit = 1 ether;
uint256 public dailySpent;
uint256 public lastResetDay;
function call(address callee, uint256 value, bytes calldata data) external onlyOwner {
// Impose a delay on large transfers exceeding the daily limit
if (value > dailyLimit) {
// Enter timelock queue, executable after 24h
}
// Small transfers execute immediately
(bool success, ) = callee.call{value: value}(data);
if (!success) revert CallFailed();
}
7. Key Technical Points #
| Point | Description |
|---|---|
| Social recovery model | N Guardians -> M agree -> change Owner |
| Unanimous vs threshold | N-of-N (unanimous) is safer but fragile; M-of-N provides redundancy |
| Recovery delay (Timelock) | Wait N hours after voting completes before executing -- gives original Owner a cancellation window |
| Guardian management | onlyOwner add/remove Guardians; must use swap+pop for deletion |
| Contract wallet pattern | call(callee, value, data) executes arbitrary operations |
| Dual mapping tracking | hasSignaled[proposed][guardian] + signalCount[proposed] |
| No single point of failure | Owner lost -> Guardians vote to recover, no reliance on mnemonics |
| Prerequisite | Based on the Dead Man's Switch (L1-11) time-based permission model |