Guardian Recovery
Problem
The primary owner key of a critical contract could be lost, stolen, or compromised. I need a social-recovery mechanism where a quorum of trusted guardians (3-of-5) can rotate the owner — but only after a 72-hour timelock that gives the real owner a chance to cancel a malicious recovery.
Solution
contract GuardianRecoverable {
field owner: address = deployer
field guardians: mapping<address, bool>
field guardian_count: u8 = 0
field quorum: u8 = 3
field recovery_delay: u64 = 259200 // 72 hours in seconds
// Pending recoveries
field next_recovery_id: u64 = 0
field recoveries: mapping<u64, Recovery>
field approvals: mapping<u64, mapping<address, bool>>
field approval_count: mapping<u64, u8>
struct Recovery {
proposed_owner: address
proposed_at: timestamp
executed: bool
cancelled: bool
}
event GuardianAdded(guardian: address indexed)
event RecoveryProposed(id: u64 indexed, proposer: address indexed, new_owner: address)
event RecoveryConfirmed(id: u64 indexed, guardian: address indexed)
event RecoveryExecuted(id: u64 indexed, new_owner: address)
event RecoveryCancelled(id: u64 indexed)
error NotOwner()
error NotGuardian()
error AlreadyGuardian()
error RecoveryNotFound()
error AlreadyConfirmed()
error QuorumNotMet()
error TimelockActive()
error AlreadyFinalised()
guard only_owner {
given caller == owner or revert_with NotOwner()
}
guard only_guardian {
given guardians[caller] or revert_with NotGuardian()
}
action add_guardian(g: address) only_owner {
given !guardians[g] or revert_with AlreadyGuardian()
guardians[g] = true
guardian_count = guardian_count + 1
emit GuardianAdded(g)
}
action propose_recovery(new_owner: address)
only_guardian
returns (id: u64) {
let id = next_recovery_id
recoveries[id] = Recovery {
proposed_owner: new_owner,
proposed_at: now,
executed: false,
cancelled: false
}
approvals[id][caller] = true
approval_count[id] = 1
next_recovery_id = next_recovery_id + 1
emit RecoveryProposed(id, caller, new_owner)
return id
}
action confirm_recovery(id: u64) only_guardian {
given id < next_recovery_id or revert_with RecoveryNotFound()
given !recoveries[id].executed and !recoveries[id].cancelled
or revert_with AlreadyFinalised()
given !approvals[id][caller] or revert_with AlreadyConfirmed()
approvals[id][caller] = true
approval_count[id] = approval_count[id] + 1
emit RecoveryConfirmed(id, caller)
}
action execute_recovery(id: u64) only_guardian {
let r = recoveries[id]
given !r.executed and !r.cancelled or revert_with AlreadyFinalised()
given approval_count[id] >= quorum or revert_with QuorumNotMet()
given now >= r.proposed_at + recovery_delay or revert_with TimelockActive()
recoveries[id].executed = true
owner = r.proposed_owner
emit RecoveryExecuted(id, r.proposed_owner)
}
action cancel_recovery(id: u64) only_owner {
given !recoveries[id].executed or revert_with AlreadyFinalised()
recoveries[id].cancelled = true
emit RecoveryCancelled(id)
}
}
Explanation
guardiansis amapping<address, bool>for O(1) membership checks;guardian_counttracks size for governance math.- The proposer’s own confirmation is counted at creation time — no need for a separate
confirmcall from the proposer. execute_recoveryenforces both quorum (3 confirmations) and timelock (72h elapsed) before rotating the owner.cancel_recoverygives the real owner an escape hatch — if they still have their key they can veto a malicious recovery during the 72h window.- The recovery ID monotonically increases, so old proposals cannot be replayed.
Gas Estimate
| Operation | Gas |
|---|---|
add_guardian | ~50,000 |
propose_recovery | ~110,000 |
confirm_recovery | ~45,000 |
execute_recovery | ~35,000 |
cancel_recovery | ~30,000 |
Common Pitfalls
- No timelock: Without the 72h delay, a colluding guardian majority can seize the contract instantly. The delay is what makes the scheme recoverable rather than seizable.
- Owner-is-guardian bootstrap: If the owner registers themselves as a guardian, losing the owner key also burns one guardian slot. Keep guardian set disjoint from the owner.
- Quorum equal to count: A 5-of-5 quorum means losing any single guardian permanently disables recovery. Use majority, not unanimity.
- Guardian collusion: Pick guardians from independent trust domains (family, lawyer, hardware wallet in a safe) — not all from the same team.
- Forgotten cancel: The real owner must monitor
RecoveryProposedevents during the 72h window. Consider off-chain push notifications.
Variations
Weighted guardians
Some guardians count more than others (e.g., a hardware module counts as 2):
field weights: mapping<address, u8>
field total_weight: u64
action confirm_recovery(id: u64) only_guardian {
approvals[id][caller] = true
approval_count[id] = approval_count[id] + weights[caller]
}
Rotating guardian set
Let guardians themselves vote to replace a compromised peer, using the same quorum+timelock scheme applied to a ProposeGuardianSwap action.