Source code for blockchainkit.wallets.systems.social_recovery

r"""Social-recovery wallets (Buterin, 2021).

A single seed phrase is a single point of failure twice over: lose it and
the coins are gone, let it be copied and they are stolen. A social-recovery
wallet is a contract with one signing key for daily use and a set of
*guardians*, friends, devices or institutions, any m of whom can replace
the signing key. The owner need back up nothing, and a thief with the
signing key alone can be locked out by the guardians.

With n guardians, each independently lost with probability :math:`p_l`
or corrupt with probability :math:`p_c`, recovery fails when fewer than m
are honest, and the wallet is stolen when m are corrupt:

.. math::

   P(\text{theft}) = \sum_{j \ge m} \binom{n}{j} p_c^{\,j} (1 - p_c)^{n-j} .

Raising m trades theft for lockout. Changing the guardians is delayed, so a
thief who takes the signing key cannot replace them before they act.

Teaching differences: guardians are accounts that send transactions, not
signatures collected off chain, and the wallet holds only ether.
"""

from collections.abc import Sequence
from math import comb

from blockchainkit._validation import integer, probability
from blockchainkit.contracts.systems.world import Contract
from blockchainkit.wallets.core.base import RecoveryRisk

GUARDIAN_DELAY = 100
"""int: Blocks between proposing new guardians and being able to apply them."""


[docs] def recovery_risk(guardians: int, threshold: int, lost: float, corrupt: float) -> RecoveryRisk: """The lockout and theft probabilities of m-of-n guardians failing independently. Parameters ---------- guardians, threshold : int n and m. lost, corrupt : float Each guardian's probability of being unreachable, or of colluding against the owner; the rest are honest. Examples -------- >>> from blockchainkit.wallets import recovery_risk >>> risk = recovery_risk(5, 3, lost=0.1, corrupt=0.05) >>> round(risk.lockout, 4), round(risk.theft, 5) (0.0266, 0.00116) """ integer(threshold, "threshold", 1) integer(guardians, "guardians", threshold) probability(lost, "lost") probability(corrupt, "corrupt") if lost + corrupt > 1: raise ValueError("lost + corrupt is at most 1") honest = 1 - lost - corrupt lockout = sum( comb(guardians, j) * honest**j * (1 - honest) ** (guardians - j) for j in range(threshold) ) theft = sum( comb(guardians, j) * corrupt**j * (1 - corrupt) ** (guardians - j) for j in range(threshold, guardians + 1) ) return RecoveryRisk(lockout, theft)
[docs] class SocialRecoveryWallet(Contract): """A wallet with one owner and m-of-n guardians who can replace the owner. Parameters ---------- owner : str The account allowed to spend. guardians : sequence of str Accounts allowed to vote for a new owner. threshold : int Votes needed. """ layout = ("owner", "guardians", "threshold", "votes", "epoch", "pending")
[docs] def constructor(self, owner: str, guardians: Sequence[str], threshold: int) -> None: self.write("owner", owner) self._set_guardians(guardians, threshold)
def _set_guardians(self, guardians: Sequence[str], threshold: int) -> None: guardians = tuple(guardians) self.require(len(set(guardians)) == len(guardians), "guardians must be distinct") self.require(1 <= threshold <= len(guardians), "threshold must be between 1 and n") self.write("guardians", guardians) self.write("threshold", threshold)
[docs] def receive(self) -> None: self.emit("Deposit", sender=self.sender, amount=self.value)
[docs] def owner(self) -> str: """The account that may spend.""" return str(self.read("owner"))
[docs] def execute(self, to: str, amount: int) -> None: """Pay ``amount`` to ``to``; only the owner may.""" self.require(self.sender == self.read("owner"), "only the owner") self.call(to, None, value=amount)
[docs] def support_recovery(self, new_owner: str) -> bool: """A guardian's vote to make ``new_owner`` the owner; returns True once it takes effect. Votes count toward the current epoch only: a recovery, or a change of guardians, starts a new one. """ self.require(self.sender in self.read("guardians"), "only a guardian") epoch = self.read("epoch") votes = set(self.read("votes", epoch, new_owner) or ()) | {self.sender} if len(votes) < self.read("threshold"): self.write("votes", epoch, new_owner, tuple(sorted(votes))) return False self.write("owner", new_owner) self.write("epoch", epoch + 1) self.write("pending", 0) # A recovery cancels a guardian change the thief proposed. self.emit("Recovered", owner=new_owner) return True
[docs] def propose_guardians(self, guardians: Sequence[str], threshold: int) -> None: """The owner proposes new guardians; they apply after :data:`GUARDIAN_DELAY` blocks.""" self.require(self.sender == self.read("owner"), "only the owner") self.write("pending", (tuple(guardians), threshold, self.block_number))
[docs] def apply_guardians(self) -> None: """Install the proposed guardians once the delay has passed.""" self.require(self.sender == self.read("owner"), "only the owner") pending = self.read("pending") self.require(pending, "no guardian change is pending") guardians, threshold, proposed = pending self.require(self.block_number >= proposed + GUARDIAN_DELAY, "the delay has not passed") self._set_guardians(guardians, threshold) self.write("pending", 0) self.write("epoch", self.read("epoch") + 1)