diff --git a/src/keri/core/eventing.py b/src/keri/core/eventing.py index 791a5b0c6..cc51d7750 100644 --- a/src/keri/core/eventing.py +++ b/src/keri/core/eventing.py @@ -5571,6 +5571,7 @@ def processEscrows(self): self.processEscrowPartialWigs() self.processEscrowPartialSigs() self.processEscrowDuplicitous() + self.processEscrowDelegables() self.processQueryNotFound() except Exception as ex: # log diagnostics errors etc @@ -6550,18 +6551,9 @@ def processEscrowDelegables(self): wigers = [Siger(qb64b=bytes(wig)) for wig in wigs] # get delgate seal - couple = self.db.getAes(dgkey) - if couple is not None: # Only try to parse the event if we have the del seal - raw = bytearray(couple) - seqner = coring.Seqner(qb64b=raw, strip=True) - saider = coring.Saider(qb64b=raw) - - # process event - self.processEvent(serder=eserder, sigers=sigers, wigers=wigers, delseqner=seqner, - delsaider=saider, local=esr.local) - else: - raise MissingDelegableApprovalError("No delegation seal found for event.") - + seqner, saider = self._getDelegationSeal(eserder=eserder, dgkey=dgkey) + self.processEvent(serder=eserder, sigers=sigers, wigers=wigers, delseqner=seqner, + delsaider=saider, local=esr.local) except MissingDelegableApprovalError as ex: # still waiting on missing delegation approval if logger.isEnabledFor(logging.DEBUG): @@ -6584,6 +6576,57 @@ def processEscrowDelegables(self): "event=%s", eserder.said) logger.debug(f"Event=\n%s\n", eserder.pretty()) + def _getDelegationSeal(self, eserder: serdering.SerderKERI, dgkey: bytes) -> tuple[coring.Seqner, coring.Saider]: + """ + Get sequence number (delseqner) and event digest (delsaider) + for delegated inception (dip) or rotation (drt) event from AES seal database or KEL state. + + Parameters: + eserder: SerderKERI instance of the delegated event + dgkey: bytes of the digest key of the delegated event + Returns: + (Seqner, Saider): sequence number and event digest + Raises: + MissingDelegableApprovalError: if the delegation seal is not found + """ + # get delgate seal + couple = self.db.getAes(dgkey) + if couple is not None: # Only try to parse the event if we have the del seal + raw = bytearray(couple) + seqner = coring.Seqner(qb64b=raw, strip=True) + saider = coring.Saider(qb64b=raw) + return seqner, saider + else: + # Check KEL for seal (like in valSigsWigsDel) + # This handles the case where delegation was approved + # and the seal is in the delegator's KEL + if eserder.ilk in (Ilks.dip, Ilks.drt): + # Get delpre (delegator prefix) from dip or drt + if eserder.ilk == Ilks.dip: + delpre = eserder.delpre # delegator from dip event + if not delpre: + raise MissingDelegableApprovalError( + f"Empty or missing delegator for delegated inception event = {eserder.said}.") + else: # For drt, delpre is in kever state + # If we have the kever, use it; otherwise we can't process + if eserder.pre in self.kevers: + delpre = self.kevers[eserder.pre].delpre + else: + raise MissingDelegableApprovalError( + f"No kever found for delegated rotation event = {eserder.said}.") + + # Look up seal in delegator's KEL + seal = dict(i=eserder.pre, s=eserder.snh, d=eserder.said) + dserder = self.db.fetchLastSealingEventByEventSeal(pre=delpre, seal=seal) + if dserder is not None: # found seal - use it + seqner = coring.Seqner(sn=dserder.sn) + saider = coring.Saider(qb64=dserder.said) + return seqner, saider + else: + raise MissingDelegableApprovalError("No delegation seal found for event.") + else: + raise MissingDelegableApprovalError("No delegation seal found for event.") + def processQueryNotFound(self): """ diff --git a/src/keri/core/serdering.py b/src/keri/core/serdering.py index 4b00c57ad..d7339c6d8 100644 --- a/src/keri/core/serdering.py +++ b/src/keri/core/serdering.py @@ -776,8 +776,8 @@ def _validate(self): if (self.kind == Kinds.cesr and (self.pvrsn.major < Vrsn_2_0.major or (self.gvrsn is not None and self.gvrsn.major < Vrsn_2_0.major))): - raise ValidationError(f"Invalid major protocol version={pvrsn} and/or" - f" invalid major genus version={gvrsn} " + raise ValidationError(f"Invalid major protocol version={self.pvrsn} and/or" + f" invalid major genus version={self.gvrsn} " f"for native CESR serialization.") if self.pvrsn not in self.Fields[self.proto]: @@ -1465,8 +1465,8 @@ def _loads(self, raw, size=None): case _: # if extra fields this is where logic would be raise DeserializeError(f"Unsupported protocol field label" - f"='{l}' for protocol={proto}" - f" version={pvrsn}.") + f"='{l}' for protocol={self.proto}" + f" version={self.pvrsn}.") elif self.proto == Protocols.acdc: @@ -1586,8 +1586,8 @@ def _loads(self, raw, size=None): case _: # if extra fields this is where logic would be raise DeserializeError(f"Unsupported protocol field label" - f"='{l}' for protocol={proto}" - f" version={pvrsn}.") + f"='{l}' for protocol={self.proto}" + f" version={self.pvrsn}.") else: # unsupported protocol type raise DeserializeError(f"Unsupported protocol={self.proto}.") diff --git a/tests/app/app_helpers.py b/tests/app/app_helpers.py new file mode 100644 index 000000000..1de43f331 --- /dev/null +++ b/tests/app/app_helpers.py @@ -0,0 +1,1096 @@ +""" +tests.app.app_helpers module + +Helpers for test setup including context managers for witnesses, controllers, +and orchestration Doers for multisig and delegation workflows. +""" +import json +from collections import deque +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import List, Generator, Tuple + +from hio.base import Doer, doing, Doist +from hio.help import decking + +from keri import kering +from keri.app import habbing, delegating, grouping, oobiing +from keri.app.agenting import WitnessReceiptor, Receiptor, WitnessInquisitor +from keri.app.configing import Configer +from keri.app.delegating import Anchorer +from keri.app.forwarding import Poster +from keri.app.habbing import openHab, HaberyDoer, Habery, Hab, openHby, GroupHab +from keri.app.indirecting import MailboxDirector, setupWitness +from keri.app.notifying import Notifier +from keri.core import Salter, coring, serdering, indexing +from keri.db import basing, dbing +from keri.help import helping +from keri.peer import exchanging +from keri.peer.exchanging import Exchanger + + +# ============================================================================= +# Data Classes for Structured Returns +# ============================================================================= + +@dataclass +class EscrowDoer(doing.Doer): + """ + Doer that processes escrows for both Habery's Kevery and Counselor. + This Doer is just a testing helper to speed up event processing in tests. + + This fills a gap in the standard controller setup where: + - MailboxDirector.escrowDo processes mbx.kvy escrows (a separate Kevery for remote events) + - Counselor.escrowDo processes counselor escrows but with yield 0.5 delay, not great for tests that should run as fast as possible. + - Nothing processes hby.kvy escrows (the Habery's Kevery for local events) + + This doer runs both processEscrows calls on every recur for faster test execution. + """ + + def __init__(self, hby: Habery, counselor: grouping.Counselor = None, **kwa): + super(EscrowDoer, self).__init__(**kwa) + self.hby = hby + self.counselor = counselor + + def recur(self, tyme): + """Process escrows on every recur call for responsive tests.""" + self.hby.kvy.processEscrows() + if self.counselor is not None: + self.counselor.processEscrows() + return False # Keep running + + +@dataclass +class ControllerContext: + """Structured context for a KERI controller with all its components.""" + hby: Habery + doers: List[Doer] + hbyDoer: HaberyDoer + anchorer: Anchorer + postman: Poster + exc: Exchanger + notifier: Notifier + mbx: MailboxDirector + witReceiptor: WitnessReceiptor + receiptor: Receiptor + witq: WitnessInquisitor = None + counselor: grouping.Counselor = None + + +@dataclass +class WitnessContext: + """Structured context for a KERI witness.""" + hby: Habery + hab: Hab + doers: List[Doer] + oobi: str + pre: str = field(init=False) + + def __post_init__(self): + self.pre = self.hab.pre + + +# ============================================================================= +# Context Managers +# ============================================================================= + +@contextmanager +def openWit(name: str = 'wan', tcpPort: int = 6632, httpPort: int = 6642, + salt: bytes = b'abcdefg0123456789') -> Generator[WitnessContext, None, None]: + """ + Context manager for a KERI witness along with the Doers needed to run it. + Expects the Doers to be run by the caller. + + Returns a WitnessContext with (Habery, Hab, witness Doers, witness controller OOBI URL) + """ + saltQb64 = Salter(raw=salt).qb64 + # Witness config - use temp=True to avoid filesystem permission issues in tests + witCfg = f"""{{ + "dt": "2025-12-11T11:02:30.302010-07:00", + "{name}": {{ + "dt": "2025-12-11T11:02:30.302010-07:00", + "curls": ["tcp://127.0.0.1:{tcpPort}/", "http://127.0.0.1:{httpPort}/"]}}}}""" + cf = Configer(name=name, temp=True, reopen=True, clear=False) + cf.put(json.loads(witCfg)) + with ( + openHab(salt=bytes(saltQb64, 'utf-8'), name=name, transferable=False, temp=True, cf=cf) as (hby, hab) + ): + oobi = f'http://127.0.0.1:{httpPort}/oobi/{hab.pre}/controller?name={name}&tag=witness' + hbyDoer = HaberyDoer(habery=hby) + doers: List[Doer] = [hbyDoer] + doers.extend(setupWitness(alias=name, hby=hby, tcpPort=tcpPort, httpPort=httpPort)) + yield WitnessContext(hby=hby, hab=hab, doers=doers, oobi=oobi) + + +@contextmanager +def openCtrlWited(name: str = 'aceCtlrKS', + salt: bytes = b'aaaaaaa0123456789') -> Generator[ControllerContext, None, None]: + """ + Context manager for setting up a KERI controller that uses a witness as its mailbox and witness. + Sets up the Doers needed to run a controller including both single sig and multi-sig handlers. + Relies on an outer context manager or caller to perform OOBI resolution and inception of the controller AID. + + Expects the Doers to be run by the caller. + + Returns a ControllerContext with all components accessible. + """ + # Note: Avoid puting iurls in config - that causes auto-resolution during init + # which hangs if the witness isn't running yet. Resolve OOBIs manually instead + # unless you make sure the witness context is both created and running before + # creating this controller. + ctlrCfg = f"""{{"dt": "2025-12-11T11:02:30.302010-07:00"}}""" + cf = Configer(name=name, temp=True, reopen=True, clear=False) + cf.put(json.loads(ctlrCfg)) + # Convert raw salt bytes to qb64 format expected by openHby + saltQb64 = Salter(raw=salt).qb64 + with openHby(salt=saltQb64, name=name, temp=True, cf=cf) as hby: + hbyDoer = habbing.HaberyDoer(habery=hby) + anchorer = Anchorer(hby=hby, proxy=None) + postman = Poster(hby=hby) + exc = Exchanger(hby=hby, handlers=[]) + notifier = Notifier(hby=hby) + delegating.loadHandlers(hby=hby, exc=exc, notifier=notifier) + grouping.loadHandlers(exc=exc, mux=grouping.Multiplexor(hby=hby, notifier=notifier)) + mbx = MailboxDirector(hby=hby, exc=exc, topics=['/receipt', '/replay', '/reply', '/delegate', '/multisig']) + witReceiptor = WitnessReceiptor(hby=hby) + receiptor = Receiptor(hby=hby) + witq = WitnessInquisitor(hby=hby) + counselor = grouping.Counselor(hby=hby) + escrowDoer = EscrowDoer(hby=hby, counselor=counselor) + doers = [hbyDoer, anchorer, postman, mbx, witReceiptor, receiptor, witq, counselor, escrowDoer] + yield ControllerContext( + hby=hby, + doers=doers, + hbyDoer=hbyDoer, + anchorer=anchorer, + postman=postman, + exc=exc, + notifier=notifier, + mbx=mbx, + witReceiptor=witReceiptor, + receiptor=receiptor, + witq=witq, + counselor=counselor, + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +class HabHelpers: + """Static helpers for Hab/Habery operations.""" + + @staticmethod + def generateOobi(hby: Habery, alias: str, role: str = kering.Roles.witness) -> str: + """Generate an OOBI URL for the given Hab.""" + hab = hby.habByName(name=alias) + if hab is None: + raise kering.ConfigurationError(f'Hab with alias {alias} not found in Habery.') + + oobi = '' + if role in (kering.Roles.witness,): + if not hab.kever.wits: + raise kering.ConfigurationError(f'{alias} identifier {hab.pre} does not have any witnesses.') + for wit in hab.kever.wits: + urls = hab.fetchUrls(eid=wit, scheme=kering.Schemes.http) or hab.fetchUrls( + eid=wit, scheme=kering.Schemes.https + ) + if not urls: + raise kering.ConfigurationError(f'unable to query witness {wit}, no http endpoint') + url = urls[kering.Schemes.https] if kering.Schemes.https in urls else urls[kering.Schemes.http] + oobi = f'{url.rstrip("/")}/oobi/{hab.pre}/witness' + elif role in (kering.Roles.controller,): + urls = hab.fetchUrls(eid=hab.pre, scheme=kering.Schemes.http) or hab.fetchUrls( + eid=hab.pre, scheme=kering.Schemes.https + ) + if not urls: + raise kering.ConfigurationError(f'{alias} identifier {hab.pre} does not have any controller endpoints') + url = urls[kering.Schemes.https] if kering.Schemes.https in urls else urls[kering.Schemes.http] + oobi = f'{url.rstrip("/")}/oobi/{hab.pre}/controller' + + if oobi: + return oobi + else: + raise kering.ConfigurationError(f'Unable to generate OOBI for {alias} identifier {hab.pre} with role {role}') + + @staticmethod + def resolveOobi(doist: Doist, deeds: deque, hby: Habery, oobi: str, alias: str = None): + """Resolve an OOBI for a given Habery using the provided Doist and deeds.""" + obr = basing.OobiRecord(date=helping.nowIso8601()) + if alias is not None: + obr.oobialias = alias + hby.db.oobis.put(keys=(oobi,), val=obr) + + oobiery = oobiing.Oobiery(hby=hby) + authn = oobiing.Authenticator(hby=hby) + oobiery_deeds = doist.enter(doers=oobiery.doers + authn.doers) + while not oobiery.hby.db.roobi.get(keys=(oobi,)): + # Note: EscrowDoer in controller context handles processEscrows for controller deeds + # but oobiery_deeds are separate so escrows for those may still need processing + doist.recur(deeds=decking.Deck(list(deeds) + list(oobiery_deeds))) + + @staticmethod + def hasDelegables(db: basing.Baser) -> List[Tuple[str, int, bytes]]: + """Check if there are any delegable events in escrow.""" + dlgs = [] + for (pre, sn), edig in db.delegables.getItemIter(): + dlgs.append((pre, sn, edig)) + return dlgs + + @staticmethod + def collectWitnessReceipts(doist: Doist, deeds: deque, wit_receiptor, pre: str, sn: int = None): + """ + Collect witness receipts for an event. + + This queues a request for the WitnessReceiptor to send the event to all + witnesses and collect their receipts. The actual receipts arrive asynchronously + via the MailboxDirector which polls the witness mailbox. + + WitnessReceiptor.cues must be cleared after receipts are collected to ensure a clean + start condition for the next event. + + Parameters: + doist: The Doist running the event loop + deeds: The deeds to recur with (should include wit_receiptor's doers) + wit_receiptor: The WitnessReceiptor instance (from controller context) + pre: The AID prefix of the identifier to collect receipts for + sn: Optional sequence number of event (defaults to latest if not provided) + """ + msg = dict(pre=pre) + if sn is not None: + msg['sn'] = sn + wit_receiptor.msgs.append(msg) + while not wit_receiptor.cues: + doist.recur(deeds=deeds) + wit_receiptor.cues.clear() + + @staticmethod + def delegationSeal(delegateAid: str, delegateSnh: str, delegateEvtSaid: str): + """Returns a delegation seal a delegator can use to approve a delegated inception or rotation event.""" + return dict(i=delegateAid, s=delegateSnh, d=delegateEvtSaid) + + @staticmethod + def clearSentCue(postman: Poster, said: str): + """ + Remove cue(s) from Poster.cues that match the given SAID. + + This is more precise than postman.cues.clear() because it only removes + cues for the specific message, leaving other pending send confirmations intact. + + Parameters: + postman: The Poster instance + said: The SAID of the message to clear from cues + """ + # Build new list without matching cues, then replace contents + remaining = [cue for cue in postman.cues if cue.get("said") != said] + postman.cues.clear() + for cue in remaining: + postman.cues.append(cue) + + +# ============================================================================= +# Orchestration Doers for Multisig +# ============================================================================= + +class MultisigInceptLeader(doing.DoDoer): + """ + Similar to `kli multisig incept`. + Orchestrates multisig inception from the leader's perspective. + + The leader: + 1. Creates the GroupHab with makeGroupHab + 2. Sends /multisig/icp EXN notification to all followers + 3. Starts Counselor to collect signatures + 4. Waits for cgms (confirmed group multisig) + + Counselor completes only when all followers have + + Parameters: + hby: The Habery for this participant + mhab: The member Hab (single-sig AID) for this participant + smids: List of all signing member AIDs (including self) + rmids: List of all rotation member AIDs (including self) + group: Name for the new group AID + isith: Signing threshold + nsith: Next (rotation) threshold + toad: Witness threshold + wits: List of witness prefixes + delpre: Delegator prefix (if this is a delegated multisig) + postman: Poster for sending messages + counselor: Counselor for coordinating multisig + witReceiptor: WitnessReceiptor for getting receipts + """ + + def __init__(self, hby: Habery, mhab: Hab, smids: List[str], rmids: List[str], + group: str, isith: str, nsith: str, toad: int, wits: List[str], + postman: Poster, counselor: grouping.Counselor, witReceiptor: WitnessReceiptor, + delpre: str = None, **kwa): + self.hby = hby + self.mhab = mhab + self.smids = smids + self.rmids = rmids + self.group = group + self.isith = isith + self.nsith = nsith + self.toad = toad + self.wits = wits + self.delpre = delpre + self.postman = postman + self.counselor = counselor + self.witReceiptor = witReceiptor + self.ghab: GroupHab = None + self.cues = decking.Deck() + self.done = False + self.pending_sends = [] # Track SAIDs of messages waiting for delivery confirmation + self.counselor_started = False + + # Note: postman and counselor are NOT included here because they're already + # running via the controller context's doers (all_deeds). The CLI's + # GroupMultisigIncept creates its own instances, but we reuse the existing ones. + super(MultisigInceptLeader, self).__init__(doers=[], **kwa) + + def recur(self, tyme, deeds=None): + """Main orchestration loop for leading a multisig inception.""" + super(MultisigInceptLeader, self).recur(tyme, deeds=deeds) + + # Step 1: Create GroupHab and notify followers + if self.ghab is None: + self._createGroupHabAndNotifyFollowers() + return False + + # Step 2: Wait for sends to complete before starting Counselor + if not self._checkPendingSendsComplete(): + return False + + # Step 3: Start the Counselor (once, after sends complete) + if not self.counselor_started: + self._startCounselor() + return False + + # Step 4: Wait for Counselor to complete (cgms) + if self._isCounselorComplete(): + self.done = True + return True + + return False + + def _createGroupHabAndNotifyFollowers(self): + """Create the GroupHab and send /multisig/icp EXN to all followers.""" + inits = dict( + isith=self.isith, + nsith=self.nsith, + toad=self.toad, + wits=self.wits, + delpre=self.delpre, + ) + self.ghab = self.hby.makeGroupHab( + group=self.group, + mhab=self.mhab, + smids=self.smids, + rmids=self.rmids, + **inits + ) + + # Create and send the inception EXN to followers + icp = self.ghab.makeOwnInception(allowPartiallySigned=True) + exn, ims = grouping.multisigInceptExn( + self.mhab, + smids=self.smids, + rmids=self.rmids, + icp=icp + ) + + self._sendToOtherMembers(exn, ims) + self.pending_sends.append(exn.said) + + def _sendToOtherMembers(self, exn: serdering.SerderKERI, attachment: bytes): + """Send an EXN message to all other multisig members.""" + others = [m for m in self.smids if m != self.mhab.pre] + for recpt in others: + self.postman.send( + src=self.mhab.pre, + dest=recpt, + topic="multisig", + serder=exn, + attachment=attachment + ) + + def _checkPendingSendsComplete(self) -> bool: + """Check if all pending EXN sends have been delivered.""" + if not self.pending_sends: + return True + + for said in list(self.pending_sends): + if self.postman.sent(said=said): + self.pending_sends.remove(said) + HabHelpers.clearSentCue(self.postman, said) + + return not self.pending_sends + + def _startCounselor(self): + """Start the Counselor to coordinate signature collection.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=prefixer.qb64) + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + self.counselor_started = True + + def _isCounselorComplete(self) -> bool: + """Check if Counselor has completed signature coordination.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=self.ghab.pre) + return self.counselor.complete(prefixer=prefixer, seqner=seqner, saider=saider) + + +class MultisigInceptFollower(doing.DoDoer): + """ + Similar to `kli multisig join`. + Joins a multisig inception from a follower's perspective. + + The follower: + 1. Waits for /multisig/icp notification (via notifier) + 2. Creates matching GroupHab + 3. Signs and sends signature to others + 4. Starts Counselor to track completion + + Parameters: + hby: The Habery for this participant + mhab: The member Hab (single-sig AID) for this participant + group: Name for the new group AID (must match leader's) + postman: Poster for sending messages + counselor: Counselor for coordinating multisig + notifier: Notifier for receiving EXN messages + witReceiptor: WitnessReceiptor for getting receipts + auto: Whether to auto-approve (default True for tests) + """ + def __init__(self, hby: Habery, mhab: Hab, group: str, + postman: Poster, counselor: grouping.Counselor, + notifier: Notifier, witReceiptor: WitnessReceiptor, + auto: bool = True, **kwa): + self.hby = hby + self.mhab = mhab + self.group = group + self.postman = postman + self.counselor = counselor + self.notifier = notifier + self.witReceiptor = witReceiptor + self.auto = auto + self.ghab: GroupHab = None + self.started = False + self.done = False + self.pendingSends = [] # Track SAIDs of messages waiting for delivery confirmation + self.counselorStarted = False + + # Note: postman and counselor are NOT included here because they're already + # running via the controller context's doers (all_deeds). + super(MultisigInceptFollower, self).__init__(doers=[], **kwa) + + def recur(self, tyme, deeds=None): + """Main orchestration loop for joining a multisig inception.""" + super(MultisigInceptFollower, self).recur(tyme, deeds=deeds) + + # Step 1: Wait for /multisig/icp notification and create GroupHab + if self.ghab is None: + self._processInceptionNotifications() + return False + + # Step 2: Wait for sends to complete before starting Counselor + if not self._checkPendingSendsComplete(): + return False + + # Step 3: Start Counselor (once, after sends complete) + if not self.counselorStarted: + self._startCounselor() + return False + + # Step 4: Wait for Counselor to complete + if self._isCounselorComplete(): + print(f"[Follower {self.mhab.pre[:8]}] Multisig inception complete for {self.ghab.pre}", flush=True) + self.done = True + return True + + return False + + def _processInceptionNotifications(self): + """ + Scan notifications for /multisig/icp and create GroupHab. + + Uses noter.notes (persistent notifications) not signaler.signals + (transient pings). This pattern matches `kli multisig join`. + """ + if self.notifier.noter.notes.cntAll() == 0: + return # No notifications yet + + for keys, notice in self.notifier.noter.notes.getItemIter(): + if self._processIcpNotification(keys, notice): + break # Successfully processed one notification + + def _processIcpNotification(self, keys, notice) -> bool: + """ + Process a single /multisig/icp notification. + + Returns: + True if successfully processed, False to skip + """ + attrs = notice.attrs + route = attrs['r'] + + if route != '/multisig/icp': + return False # Not an inception notification + + exnSaid = attrs['d'] + exn, _ = exchanging.cloneMessage(self.hby, said=exnSaid) + + # Extract member info from payload + payload = exn.ked['a'] + smids = payload['smids'] + rmids = payload['rmids'] + + # Verify we're a participant + if self.mhab.pre not in smids: + raise ValueError(f"[Follower {self.mhab.pre[:8]}] Not in smids ({self.mhab.pre}), skipping. smids={smids}") + + # Extract inception parameters and create GroupHab + inits = self._extractInceptionParams(exn) + self.ghab = self.hby.makeGroupHab( + group=self.group, + mhab=self.mhab, + smids=smids, + rmids=rmids, + **inits + ) + + # Remove processed notification + self.notifier.noter.notes.rem(keys=keys) + + # Send our signature to others + self._sendSignatureToOthers(smids, rmids) + return True + + def _extractInceptionParams(self, exn: serdering.SerderKERI) -> dict: + """Extract GroupHab initialization parameters from the embedded ICP.""" + embeds = exn.ked['e'] + icpKed = embeds['icp'] + origIcp = serdering.SerderKERI(sad=icpKed) + + return dict( + isith=origIcp.ked["kt"], + nsith=origIcp.ked["nt"], + estOnly=kering.TraitCodex.EstOnly in origIcp.ked['c'], + DnD=kering.TraitCodex.DoNotDelegate in origIcp.ked['c'], + toad=origIcp.ked["bt"], + wits=origIcp.ked["b"], + delpre=origIcp.ked["di"] if "di" in origIcp.ked else None, + ) + + def _sendSignatureToOthers(self, smids: List[str], rmids: List[str]): + """Create and send our signed inception EXN to other members.""" + icp = self.ghab.makeOwnInception(allowPartiallySigned=True) + exn, ims = grouping.multisigInceptExn( + self.mhab, + smids=smids, + rmids=rmids, + icp=icp + ) + + others = [m for m in smids if m != self.mhab.pre] + for recpt in others: + self.postman.send( + src=self.mhab.pre, + dest=recpt, + topic="multisig", + serder=exn, + attachment=ims + ) + self.pendingSends.append(exn.said) + + def _checkPendingSendsComplete(self) -> bool: + """Check if all pending EXN sends have been delivered.""" + if not self.pendingSends: + return True + + for said in list(self.pendingSends): + if self.postman.sent(said=said): + self.pendingSends.remove(said) + HabHelpers.clearSentCue(self.postman, said) + + return not self.pendingSends + + def _startCounselor(self): + """Start the Counselor to coordinate signature collection.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=prefixer.qb64) + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + self.counselorStarted = True + + def _isCounselorComplete(self) -> bool: + """Check if Counselor has completed signature coordination.""" + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=0) + saider = coring.Saider(qb64=self.ghab.pre) + return self.counselor.complete(prefixer=prefixer, seqner=seqner, saider=saider) + + +class MultisigDelegationApprover(doing.DoDoer): + """ + Approves delegation requests for a multisig delegator. + + This coordinates both members of the delegator multisig to: + 1. Watch the delegables escrow for delegation requests + 2. Create anchor events via interact (leader) or wait for coordination (follower) + 3. Coordinate signature collection + 4. Propagate to witnesses + + Parameters: + hby: The Habery for this delegator participant + ghab: The GroupHab for the delegator multisig + mhab: The member Hab for this participant + counselor: Counselor for coordinating multisig + witReceiptor: WitnessReceiptor for getting receipts + witq: WitnessInquisitor for querying witnesses + postman: Poster for sending messages + notifier: Notifier for receiving messages (follower mode) + interact: Whether to use interact (True) or rotate (False) for anchor + auto: Whether to auto-approve all delegation requests + leader: Whether this participant is the leader (creates events) + """ + + def __init__(self, hby: Habery, ghab: GroupHab, mhab: Hab, + counselor: grouping.Counselor, witReceiptor: WitnessReceiptor, + witq: WitnessInquisitor, postman: Poster, + notifier: Notifier = None, + interact: bool = True, auto: bool = True, leader: bool = True, **kwa): + self.hby = hby + self.ghab = ghab + self.mhab = mhab + self.counselor = counselor + self.witReceiptor = witReceiptor + self.witq = witq + self.postman = postman + self.notifier = notifier + self.interact = interact + self.auto = auto + self.leader = leader + self.approved = set() # Track approved delegation (pre, sn) tuples + # Track pending sends: {(pre, sn): {'said': exn_said, 'ixn_sn': sn, 'ixn_said': said}} + self.pendingSends = {} + # Track delegations ready for counselor start + self.readyForCounselor = {} + # Track delegations waiting for counselor completion + # {(pre, sn): {'ixn_sn': int, 'ixn_said': str, 'edig': bytes}} + self.waitingForComplete = {} + + # Note: counselor and postman are NOT included here because they're already + # running via the controller context's doers (all_deeds). + super(MultisigDelegationApprover, self).__init__(doers=[], **kwa) + + def delegablesEscrowed(self) -> List[Tuple[str, int, bytes]]: + """Get list of delegable events in escrow.""" + return [(pre, sn, edig) for (pre, sn), edig in self.hby.db.delegables.getItemIter()] + + def recur(self, tyme, deeds=None): + """ + Main orchestration loop for delegation approval. + + The approval process flows through these stages: + 1. Leader finds delegables → creates anchor → sends EXN to followers + 2. Followers receive EXN notification → sign same anchor → send EXN back + 3. Counselor coordinates signatures across all participants + 4. Once complete, release escrowed delegation by reprocessing with seal + """ + super(MultisigDelegationApprover, self).recur(tyme, deeds=deeds) + + # Process the approval pipeline + self._processPendingSends() + self._startCounselorForReadyDelegations() + self._releaseCompletedDelegations() + + # Leader creates anchors, follower signs from notifications + if self.leader: + self._leaderProcessDelegables() + else: + self._followerProcessNotifications() + + return False # Keep running + + def _processPendingSends(self): + """ + Check for EXN messages that have been successfully delivered. + + After the leader sends an anchor proposal to followers, we wait for + postman to confirm delivery before starting the Counselor coordination. + """ + for key in list(self.pendingSends.keys()): + info = self.pendingSends[key] + if self.postman.sent(said=info['said']): + self.readyForCounselor[key] = info + del self.pendingSends[key] + HabHelpers.clearSentCue(self.postman, info['said']) + + def _startCounselorForReadyDelegations(self): + """ + Start Counselor coordination for delegations that are ready. + + The Counselor collects signatures from all multisig participants + and marks the event complete when threshold is met. + """ + for key in list(self.readyForCounselor.keys()): + info = self.readyForCounselor[key] + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=info['ixn_sn']) + saider = coring.Saider(qb64=info['ixn_said']) + + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + self.waitingForComplete[key] = info + print(f"[DelegationApprover {self.mhab.pre[:8]}] Started counselor for anchor at sn={info['ixn_sn']}") + del self.readyForCounselor[key] + + def _releaseCompletedDelegations(self): + """ + Release escrowed delegations after anchor coordination completes. + + This is necessary because the delegables escrow has no automatic processor. + Delegation approval is an active policy decision (like `kli delegate confirm`). + + Once the multisig anchor is complete, we: + 1. Retrieve the escrowed DIP/DRT event + 2. Reprocess it with the delegation seal (delseqner, delsaider) attached + 3. Remove it from the delegables escrow + """ + for key in list(self.waitingForComplete.keys()): + info = self.waitingForComplete[key] + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=info['ixn_sn']) + + if not self.counselor.complete(prefixer=prefixer, seqner=seqner): + continue + + self._releaseEscrowedDelegation(key, info) + self.approved.add(key) + del self.waitingForComplete[key] + + def _releaseEscrowedDelegation(self, key: Tuple[str, str], info: dict): + """ + Reprocess an escrowed DIP/DRT event with the delegation seal attached. + + Args: + key: (delegate_pre, delegate_sn) tuple identifying the escrowed event + info: dict with 'ixn_sn' and 'ixn_said' for the anchor event + """ + (pre, sn) = key + edigs = self.hby.db.delegables.get(keys=(pre, sn)) + if not edigs: + return + + edig = edigs[0] + dgkey = dbing.dgKey(pre.encode() if isinstance(pre, str) else pre, edig) + eraw = self.hby.db.getEvt(dgkey) + if not eraw: + return + + # Reconstruct the event with signatures + eserder = serdering.SerderKERI(raw=bytes(eraw)) + sigers = [indexing.Siger(qb64b=bytes(sig)) for sig in self.hby.db.getSigs(dgkey)] + wigers = [indexing.Siger(qb64b=bytes(sig)) for sig in self.hby.db.getWigs(dgkey)] + + # Reprocess with the delegation seal + # - now that all signatures and receipts exist event can be processed. + self.hby.kvy.processEvent( + serder=eserder, + sigers=sigers, + wigers=wigers, + delseqner=coring.Seqner(sn=info['ixn_sn']), + delsaider=coring.Saider(qb64=info['ixn_said']) + ) + + self.hby.db.delegables.rem(keys=(pre, sn), val=edig) # now remove from escrow since complete + print(f"[DelegationApprover {self.mhab.pre[:8]}] Released delegation for {pre[:8]} from escrow") + + def _leaderProcessDelegables(self): + """ + Leader: Find delegable events and create anchor proposals. + + The leader is responsible for: + 1. Scanning the delegables escrow for DIP/DRT events we need to approve + 2. Creating an IXN anchor event with the delegation seal + 3. Sending a /multisig/ixn EXN to all other multisig members + """ + for pre, sn, edig in self.delegablesEscrowed(): + key = (pre, sn) + if self._isAlreadyProcessing(key): + continue + + eserder = self._getValidDelegableEvent(pre, edig) + if eserder is None: + continue + + if not self.auto: + continue + + self._createAndSendAnchor(key, eserder) + + def _isAlreadyProcessing(self, key: Tuple[str, str]) -> bool: + """Check if this delegation is already being processed.""" + return (key in self.approved or + key in self.pendingSends or + key in self.readyForCounselor or + key in self.waitingForComplete) + + def _getValidDelegableEvent(self, pre: str, edig: bytes) -> serdering.SerderKERI: + """ + Get a delegable event if it's a valid DIP/DRT for our multisig. + + Returns: + SerderKERI if valid, None otherwise + """ + dgkey = dbing.dgKey(pre, edig) + eraw = self.hby.db.getEvt(dgkey) + if eraw is None: + return None + + eserder = serdering.SerderKERI(raw=bytes(eraw)) + ilk = eserder.sad['t'] + + # Must be a delegated event + if ilk not in (coring.Ilks.dip, coring.Ilks.drt): + return None + + # Get the delegator prefix + if ilk == coring.Ilks.dip: + delpre = eserder.sad['di'] + else: # drt + dkever = self.hby.kevers[eserder.pre] + delpre = dkever.delpre + + # We must be the delegator + if delpre != self.ghab.pre: + return None + + return eserder + + def _createAndSendAnchor(self, key: Tuple[str, str], eserder: serdering.SerderKERI): + """ + Create an anchor IXN and send /multisig/ixn EXN to other members. + + Args: + key: (delegate_pre, delegate_sn) tuple + eserder: The delegated event to approve + """ + print(f"[DelegationApprover {self.mhab.pre[:8]}] Found delegable {eserder.sad['t']} event for {eserder.pre[:8]}") + + # Create the delegation seal + anchor = HabHelpers.delegationSeal(eserder.ked['i'], eserder.snh, eserder.said) + + if not self.interact: + raise ValueError(f"[DelegationApprover {self.mhab.pre[:8]}] delegation approval via rotation not yet supported") + + # Create the anchor IXN (signs and stores locally) + ixn = self.ghab.interact(data=[anchor]) + ixnser = serdering.SerderKERI(raw=ixn) + + # Create and send the multisig coordination EXN + exn, ims = grouping.multisigInteractExn( + ghab=self.ghab, + aids=self.ghab.smids, + ixn=ixn + ) + + self._sendToOtherMembers(exn, ims) # sends exn notification to other members (followers) + + # Track for delivery confirmation + self.pendingSends[key] = { + 'said': exn.said, + 'ixn_sn': ixnser.sn, + 'ixn_said': ixnser.said + } + print(f"[DelegationApprover {self.mhab.pre[:8]}] Created anchor for {eserder.pre[:8]} at sn={ixnser.sn}, waiting for send confirmation") + + def _sendToOtherMembers(self, exn: serdering.SerderKERI, attachment: bytes): + """Send an EXN message to all other multisig members.""" + others = [m for m in self.ghab.smids if m != self.mhab.pre] + for recpt in others: + self.postman.send( + src=self.mhab.pre, + dest=recpt, + topic="multisig", + serder=exn, + attachment=attachment + ) + + def _followerProcessNotifications(self): + """ + Follower: Listen for /multisig/ixn notifications and co-sign the anchor. + + When the leader creates an anchor, they send a /multisig/ixn EXN to + all other members. The follower: + 1. Receives the notification via the Notifier + 2. Extracts the IXN data (which contains the delegation seal) + 3. Creates the SAME IXN locally (this signs it) + 4. Sends their own /multisig/ixn EXN back to coordinate signatures + 5. Starts the Counselor to complete coordination + """ + if self.notifier is None: + return + + for keys, notice in self.notifier.noter.notes.getItemIter(): + result = self._processIxnNotification(keys, notice) + if result: + # Successfully processed, remove the notification + self.notifier.noter.notes.rem(keys=keys) + + def _processIxnNotification(self, keys, notice) -> bool: + """ + Process a single /multisig/ixn notification. + + Returns: + True if successfully processed, False to skip + """ + attrs = notice.attrs + route = attrs.get('r') + + if route != '/multisig/ixn': + return False + + said = attrs.get('d') # EXN SAID + if said is None: + return False + + # Get the EXN message + exn, _ = exchanging.cloneMessage(self.hby, said=said) + if exn is None: + return False + + # Verify this is for our multisig group + payload = exn.ked.get('a', {}) + gid = payload.get('gid') + if gid != self.ghab.pre: + return False + + # Extract the embedded IXN data + embeds = exn.ked.get('e', {}) + ixn_data = embeds.get('ixn', {}) + if not ixn_data: + return False + + # Extract delegation info from the anchor seal + delegate_info = self._extractDelegateInfoFromAnchor(ixn_data) + if delegate_info is None: + return False + + delegate_pre, delegate_sn, anchor_data = delegate_info + + # Sign and coordinate + self._signAndCoordinateAnchor(delegate_pre, delegate_sn, anchor_data) + return True + + def _extractDelegateInfoFromAnchor(self, ixn_data: dict) -> Tuple[str, str, list]: + """ + Extract delegate prefix and sn from the anchor's seal data. + + The anchor IXN contains seals like: {'i': delegate_pre, 's': delegate_sn, 'd': delegate_said} + + Returns: + (delegate_pre, delegate_sn, anchor_data) or None if not found + """ + oixnser = serdering.SerderKERI(sad=ixn_data) + data = oixnser.ked.get('a', []) + + for seal in data: + if isinstance(seal, dict) and 'i' in seal and 's' in seal: + delegate_pre = seal['i'] + # Convert to 32-char hex string to match db.delegables key format + sn_int = int(seal['s'], 16) if isinstance(seal['s'], str) else seal['s'] + delegate_sn = f"{sn_int:032x}" + return (delegate_pre, delegate_sn, data) + + return None + + def _signAndCoordinateAnchor(self, delegate_pre: str, delegate_sn: str, anchor_data: list): + """ + Create the same anchor IXN locally and start coordination. + + By calling ghab.interact() with the same data, we create an event + with the same SAID as the leader's, which allows signature aggregation. + """ + # Create the SAME interaction event (this signs it locally) + ixn = self.ghab.interact(data=anchor_data) + ixnser = serdering.SerderKERI(raw=ixn) + + # Send our signing notification to others + exn_out, ims = grouping.multisigInteractExn( + ghab=self.ghab, + aids=self.ghab.smids, + ixn=ixn + ) + self._sendToOtherMembers(exn_out, ims) + + # Start Counselor coordination + prefixer = coring.Prefixer(qb64=self.ghab.pre) + seqner = coring.Seqner(sn=ixnser.sn) + saider = coring.Saider(qb64=ixnser.said) + self.counselor.start(prefixer=prefixer, seqner=seqner, saider=saider, ghab=self.ghab) + + # Track for completion (key must match delegables format) + self.waitingForComplete[(delegate_pre, delegate_sn)] = { + 'ixn_sn': ixnser.sn, + 'ixn_said': ixnser.said + } + + print(f"[DelegationApprover {self.mhab.pre[:8]}] Signed anchor from leader at sn={ixnser.sn} for delegate {delegate_pre[:8]}", flush=True) + + +class KeystateQueryDoer(doing.Doer): + """ + Queries for keystate to discover delegation approval anchor. + + Parameters: + hby: The Habery making the query + hab: The Hab making the query (source) + target_pre: The prefix to query for + target_sn: The sequence number to wait for (optional) + witq: WitnessInquisitor for making queries + wits: List of witness prefixes to query + """ + + def __init__(self, hby: Habery, hab: Hab, target_pre: str, + witq: WitnessInquisitor, wits: List[str] = None, + target_sn: int = None, **kwa): + self.hby = hby + self.hab = hab + self.target_pre = target_pre + self.target_sn = target_sn + self.witq = witq + self.wits = wits or [] + self.queried = False + super(KeystateQueryDoer, self).__init__(**kwa) + + def recur(self, tyme, deeds=None): + """Query and wait for keystate.""" + if not self.queried: + self._sendQuery() + + if self._hasRequiredKeystate(): + return True + + return False + + def _sendQuery(self): + """Send the keystate query to witnesses.""" + self.witq.query(src=self.hab.pre, pre=self.target_pre, wits=self.wits) + self.queried = True + print(f"[KeystateQuery] Querying for {self.target_pre[:8]}") + + def _hasRequiredKeystate(self) -> bool: + """Check if we have the required keystate.""" + if self.target_pre not in self.hby.kevers: + return False + + kever = self.hby.kevers[self.target_pre] + if self.target_sn is not None and kever.sn < self.target_sn: + return False + + print(f"[KeystateQuery] Found keystate for {self.target_pre[:8]} at sn={kever.sn}") + return True \ No newline at end of file diff --git a/tests/app/cli/test_kli_commands.py b/tests/app/cli/test_kli_commands.py index 4db45e43a..90ed8eaa4 100644 --- a/tests/app/cli/test_kli_commands.py +++ b/tests/app/cli/test_kli_commands.py @@ -1,5 +1,6 @@ import os + import multicommand import pytest @@ -13,6 +14,7 @@ from keri.app.cli import commands from keri.app.cli.common import existing +from tests import conftest TEST_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -259,6 +261,9 @@ def test_incept_and_rotate_opts(helpers, capsys): """ Tests using the command line arguments for incept and the file argument for rotate """ + # Reload commands module to ensure fresh parser objects - see fn docs for explanation + conftest.reload_commands_module() + helpers.remove_test_dirs("test-opts") assert os.path.isdir("/usr/local/var/keri/ks/test-opts") is False diff --git a/tests/app/test_grouping.py b/tests/app/test_grouping.py index e1306f07b..ec67ea288 100644 --- a/tests/app/test_grouping.py +++ b/tests/app/test_grouping.py @@ -3,7 +3,10 @@ tests.app.grouping module """ -from contextlib import contextmanager +from collections import deque +from contextlib import contextmanager, ExitStack + +from hio.base import Doist from keri import kering, core from keri.kering import Vrsn_1_0, Vrsn_2_0 @@ -12,6 +15,11 @@ from keri.vdr import eventing as veventing from keri.db import dbing from keri.peer import exchanging +from tests.app.app_helpers import ( + openWit, openCtrlWited, HabHelpers, + MultisigInceptLeader, MultisigInceptFollower, + MultisigDelegationApprover, KeystateQueryDoer +) def test_counselor(): @@ -807,3 +815,294 @@ def test_multisig_interact_handler(mockHelpingNowUTC): prefixers = hby1.db.maids.get(keys=(esaid,)) assert len(prefixers) == 1 assert prefixers[0].qb64 == ghab2.mhab.pre + + +def test_multisig_delegate(): + """ + End-to-end test for multisig delegation workflow. + + This test covers: + 1. A delegator multisig (dgt) formed by two single-sig participants (dgt1, dgt2) + 2. Two delegate participants (del1, del2) who create a delegated multisig (del) + 3. The delegates having OOBId with the delegator multisig + 4. Delegation approvals from both delegator single-sig AID participants + 5. Keystate queries from delegates to discover the delegation approval seal + 6. Generation of an OOBI for the multisig delegate + 7. Resolution of the multisig delegate OOBI by the delegator + 8. Verifications by the delegator about delegate state + """ + doist = Doist(limit=0.0, tock=0.03125, real=True) + + # Salts for deterministic key generation + DGT1_SALT = b'0ABaQTNARS1U1u7VhP0mnEK1' + DGT2_SALT = b'0ABaQTNARS1U1u7VhP0mnEK2' + DEL1_SALT = b'0AAB_Fidf5WeZf6VFc53IxV1' + DEL2_SALT = b'0AAB_Fidf5WeZf6VFc53IxV2' + + # Use ExitStack to open all contexts and flatten nesting + with ExitStack() as stack: + # Witness + wit_ctx = stack.enter_context(openWit(name='wan', tcpPort=6632, httpPort=6642)) + # delegator contexts - dgt1, dgt2 + dgt1_ctx = stack.enter_context(openCtrlWited(name='dgt1', salt=DGT1_SALT)) + dgt2_ctx = stack.enter_context(openCtrlWited(name='dgt2', salt=DGT2_SALT)) + # delegate contexts - del1, del2 + del1_ctx = stack.enter_context(openCtrlWited(name='del1', salt=DEL1_SALT)) + del2_ctx = stack.enter_context(openCtrlWited(name='del2', salt=DEL2_SALT)) + + # Enter all doers into the Doist + wit_deeds: deque = doist.enter(doers=wit_ctx.doers) + dgt1_deeds: deque = doist.enter(doers=dgt1_ctx.doers) + dgt2_deeds: deque = doist.enter(doers=dgt2_ctx.doers) + del1_deeds: deque = doist.enter(doers=del1_ctx.doers) + del2_deeds: deque = doist.enter(doers=del2_ctx.doers) + all_deeds = wit_deeds + dgt1_deeds + dgt2_deeds + del1_deeds + del2_deeds + + # Resolve witness OOBIs for all participants - rather than have witness OOBI in "iurls" in config + for ctx, name in [ + (dgt1_ctx, 'dgt1'), + (dgt2_ctx, 'dgt2'), + (del1_ctx, 'del1'), + (del2_ctx, 'del2')]: + HabHelpers.resolveOobi(doist, wit_deeds, ctx.hby, wit_ctx.oobi, alias='wan') + print(f" {name} resolved witness OOBI", flush=True) + + # Create single sig AIDs for delegator participants (dgt1, dgt2) + # dgt1 init + incept + dgt1_hab = dgt1_ctx.hby.makeHab(name='dgt1', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, dgt1_ctx.witReceiptor, dgt1_hab.pre) + + # dgt2 init + incept + dgt2_hab = dgt2_ctx.hby.makeHab(name='dgt2', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, dgt2_ctx.witReceiptor, dgt2_hab.pre) + + # OOBI Exchange between dgt1, dgt2 + dgt1_oobi = HabHelpers.generateOobi(dgt1_ctx.hby, alias='dgt1') + dgt2_oobi = HabHelpers.generateOobi(dgt2_ctx.hby, alias='dgt2') + + HabHelpers.resolveOobi(doist, all_deeds, dgt2_ctx.hby, dgt1_oobi, alias='dgt1') + HabHelpers.resolveOobi(doist, all_deeds, dgt1_ctx.hby, dgt2_oobi, alias='dgt2') + + # Create delegator multisig from del1, del2 + # smids and rmids are the same since all participants here are both signing and rotation members + dgt_smids = [dgt1_hab.pre, dgt2_hab.pre] + dgt_rmids = dgt_smids + + # dgt1 is the leader + dgt_leader = MultisigInceptLeader( + hby=dgt1_ctx.hby, + mhab=dgt1_hab, + smids=dgt_smids, + rmids=dgt_rmids, + group='dgt', + isith='2', + nsith='2', + toad=1, + wits=[wit_ctx.pre], + postman=dgt1_ctx.postman, + counselor=dgt1_ctx.counselor, + witReceiptor=dgt1_ctx.witReceiptor, + ) + + # dgt2 is the follower + dgt_follower = MultisigInceptFollower( + hby=dgt2_ctx.hby, + mhab=dgt2_hab, + group='dgt', + postman=dgt2_ctx.postman, + counselor=dgt2_ctx.counselor, + notifier=dgt2_ctx.notifier, + witReceiptor=dgt2_ctx.witReceiptor, + ) + + # Run until multisig inception is complete + dgt_deeds = doist.enter(doers=[dgt_leader, dgt_follower]) + # Wait for both ghabs to be created and counselor to confirm completion + while dgt_leader.ghab is None or dgt_follower.ghab is None: + doist.recur(deeds=all_deeds + dgt_deeds) + # Now wait for counselor completion + prefixer = coring.Prefixer(qb64=dgt_leader.ghab.pre) + seqner = coring.Seqner(sn=0) + while not dgt1_ctx.counselor.complete(prefixer, seqner): + doist.recur(deeds=all_deeds + dgt_deeds) + dgt_ghab = dgt_leader.ghab + + # Verify dgt multisig exists and has correct properties + assert dgt_ghab is not None, "dgt multisig should exist" + assert dgt_ghab.pre in dgt1_ctx.hby.kevers, "dgt1 should have dgt kever" + assert dgt_ghab.pre in dgt2_ctx.hby.kevers, "dgt2 should have dgt kever" + assert len(dgt_ghab.smids) == 2, "dgt should have 2 signing members" + + # Create delegate participants del1, del2 + # Create del1 single-sig AID + del1_hab = del1_ctx.hby.makeHab(name='del1', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, del1_ctx.witReceiptor, del1_hab.pre) + + # Create del2 single-sig AID + del2_hab = del2_ctx.hby.makeHab(name='del2', isith='1', icount=1, toad=1, wits=[wit_ctx.pre]) + HabHelpers.collectWitnessReceipts(doist, all_deeds, del2_ctx.witReceiptor, del2_hab.pre) + + # Delegates resolve delegator (dgt) OOBI + dgt_oobi = HabHelpers.generateOobi(dgt1_ctx.hby, alias='dgt') + HabHelpers.resolveOobi(doist, all_deeds, del1_ctx.hby, dgt_oobi, alias='dgt') + HabHelpers.resolveOobi(doist, all_deeds, del2_ctx.hby, dgt_oobi, alias='dgt') + + # OOBI exchange between del1 and del2 + del1_oobi = HabHelpers.generateOobi(del1_ctx.hby, alias='del1') + del2_oobi = HabHelpers.generateOobi(del2_ctx.hby, alias='del2') + HabHelpers.resolveOobi(doist, all_deeds, del2_ctx.hby, del1_oobi, alias='del1') + HabHelpers.resolveOobi(doist, all_deeds, del1_ctx.hby, del2_oobi, alias='del2') + + # Create delegated multisig from del1 and del2 + del_smids = [del1_hab.pre, del2_hab.pre] + del_rmids = del_smids + + # del1 is the leader for the delegate multisig + del_leader = MultisigInceptLeader( + hby=del1_ctx.hby, + mhab=del1_hab, + smids=del_smids, + rmids=del_rmids, + group='del', + isith='2', + nsith='2', + toad=1, + wits=[wit_ctx.pre], + delpre=dgt_ghab.pre, # dgt is the delegator + postman=del1_ctx.postman, + counselor=del1_ctx.counselor, + witReceiptor=del1_ctx.witReceiptor, + ) + + # del2 is the follower for the delegate multisig + del_follower = MultisigInceptFollower( + hby=del2_ctx.hby, + mhab=del2_hab, + group='del', + postman=del2_ctx.postman, + counselor=del2_ctx.counselor, + notifier=del2_ctx.notifier, + witReceiptor=del2_ctx.witReceiptor, + ) + + del_deeds = doist.enter(doers=[del_leader, del_follower]) + + # Run until the delegate sends the DIP to the delegator + # This will escrow until delegation is approved + while del_leader.ghab is None: + doist.recur(deeds=all_deeds + del_deeds) + del_ghab = del_leader.ghab + + # Delegators approve delegation (dgt1 and dgt2 confirm) + # Wait for delegation request to appear in delegables escrow + while not HabHelpers.hasDelegables(dgt1_ctx.hby.db): + doist.recur(deeds=all_deeds + del_deeds) + + # Both delegator participants approve (dgt1 is leader, dgt2 is follower) + dgt1_approver = MultisigDelegationApprover( + hby=dgt1_ctx.hby, + ghab=dgt_ghab, + mhab=dgt1_hab, + counselor=dgt1_ctx.counselor, + witReceiptor=dgt1_ctx.witReceiptor, + witq=dgt1_ctx.witq, + postman=dgt1_ctx.postman, + leader=True, # dgt1 is the leader + ) + dgt2_approver = MultisigDelegationApprover( + hby=dgt2_ctx.hby, + ghab=dgt2_ctx.hby.habByName('dgt'), # dgt2's copy of dgt + mhab=dgt2_hab, + counselor=dgt2_ctx.counselor, + witReceiptor=dgt2_ctx.witReceiptor, + witq=dgt2_ctx.witq, + postman=dgt2_ctx.postman, + notifier=dgt2_ctx.notifier, + leader=False, # dgt2 is the follower + ) + approver_deeds = doist.enter(doers=[dgt1_approver, dgt2_approver]) + + # Run until delegation is approved (anchor event created) + # Check for the anchor event on the delegator + while dgt_ghab.kever.sn < 1: + doist.recur(deeds=all_deeds + del_deeds + approver_deeds) + + # Get witness receipts for the anchor + HabHelpers.collectWitnessReceipts(doist, all_deeds + approver_deeds, dgt1_ctx.witReceiptor, dgt_ghab.pre, sn=dgt_ghab.kever.sn) + + # Wait for counselor to complete the anchor + prefixer = coring.Prefixer(qb64=dgt_ghab.pre) + seqner = coring.Seqner(sn=dgt_ghab.kever.sn) + while not dgt1_ctx.counselor.complete(prefixer, seqner): + doist.recur(deeds=all_deeds + del_deeds + approver_deeds) + + # Allow approvers to release the escrowed DIP event from delegables + # After counselor completes, the approver's _releaseCompletedDelegations() + # needs to run to reprocess the DIP with the delegation seal attached + while del_ghab.pre not in dgt1_ctx.hby.kevers or del_ghab.pre not in dgt2_ctx.hby.kevers: + doist.recur(deeds=all_deeds + del_deeds + approver_deeds) + + # Delegates query delegator keystate to discover approval anchor and complete delegation + del1_query = KeystateQueryDoer( + hby=del1_ctx.hby, + hab=del1_hab, + target_pre=dgt_ghab.pre, + target_sn=dgt_ghab.kever.sn, + witq=del1_ctx.witq, + wits=[wit_ctx.pre], + ) + del2_query = KeystateQueryDoer( + hby=del2_ctx.hby, + hab=del2_hab, + target_pre=dgt_ghab.pre, + target_sn=dgt_ghab.kever.sn, + witq=del2_ctx.witq, + wits=[wit_ctx.pre], + ) + query_deeds = doist.enter(doers=[del1_query, del2_query]) + + # Run until queries complete - check by looking at the kever in del1's database + while dgt_ghab.pre not in del1_ctx.hby.kevers or del1_ctx.hby.kevers[dgt_ghab.pre].sn < 1: + doist.recur(deeds=all_deeds + del_deeds + query_deeds) + + # Now the del multisig inception should complete - wait for counselor + prefixer = coring.Prefixer(qb64=del_ghab.pre) + seqner = coring.Seqner(sn=0) + while not del1_ctx.counselor.complete(prefixer, seqner): + doist.recur(deeds=all_deeds + del_deeds) + + # Verify del delegated multisig exists and has correct properties + assert del_ghab is not None, "del multisig should exist" + assert del_ghab.kever.delpre == dgt_ghab.pre, "del delegator should be dgt" + assert del_ghab.pre in del1_ctx.hby.kevers, "del1 should have del kever" + assert del_ghab.pre in del2_ctx.hby.kevers, "del2 should have del kever" + + # Before delegators can verify the delegate multisig's events, they need + # the public keys of the multisig members (del1, del2) to verify signatures. + del1_oobi = HabHelpers.generateOobi(del1_ctx.hby, alias='del1') + del2_oobi = HabHelpers.generateOobi(del2_ctx.hby, alias='del2') + HabHelpers.resolveOobi(doist, all_deeds, dgt1_ctx.hby, del1_oobi, alias='del1') + HabHelpers.resolveOobi(doist, all_deeds, dgt1_ctx.hby, del2_oobi, alias='del2') + HabHelpers.resolveOobi(doist, all_deeds, dgt2_ctx.hby, del1_oobi, alias='del1') + HabHelpers.resolveOobi(doist, all_deeds, dgt2_ctx.hby, del2_oobi, alias='del2') + + # Now delegators query for the delegate's KEL - this should work because + # eventing.py now checks for existing seals before escrowing to delegables + dgt1_ctx.witq.query(src=dgt1_hab.pre, pre=del_ghab.pre, sn=0, wits=[wit_ctx.pre]) + dgt2_ctx.witq.query(src=dgt2_hab.pre, pre=del_ghab.pre, sn=0, wits=[wit_ctx.pre]) + + # Wait for delegate to appear in delegator kevers + while del_ghab.pre not in dgt1_ctx.hby.kevers or del_ghab.pre not in dgt2_ctx.hby.kevers: + doist.recur(deeds=all_deeds) + + # Assertions - Verify delegator knows about delegate + assert del_ghab.pre in dgt1_ctx.hby.kevers, "dgt1 should know about del after witness query" + assert del_ghab.pre in dgt2_ctx.hby.kevers, "dgt2 should know about del after witness query" + + # Verify delegation anchor exists + assert dgt_ghab.kever.sn == 1, "dgt should have two events, icp and ixn (with dip approval anchor)" + assert del_ghab.kever.sn == 0, "delegate should have exactly one event - dip" + + # Verify delegables escrow is empty (delegation was properly released) + assert not HabHelpers.hasDelegables(dgt1_ctx.hby.db), "dgt1 delegables escrow should be empty" + assert not HabHelpers.hasDelegables(dgt2_ctx.hby.db), "dgt2 delegables escrow should be empty" diff --git a/tests/conftest.py b/tests/conftest.py index 6ad4a5eaf..ae9256d41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,9 @@ import os import shutil import multicommand +import sys +import importlib +import pkgutil import pytest @@ -392,3 +395,22 @@ def cmdDo(self, tymth, tock=0.0): yield self.tock return True + +def reload_commands_module(): + """ + Reload the commands module and all its submodules to ensure fresh parser objects. + + This is necessary because multicommand modifies parsers in-place by calling + add_subparsers(), and Python 3.12+ doesn't allow calling add_subparsers() + multiple times on the same parser object. + """ + # Reload all submodules first + for importer, modname, ispkg in pkgutil.walk_packages( + path=commands.__path__, + prefix=commands.__name__ + ".", + onerror=lambda x: None + ): + if modname in sys.modules: + importlib.reload(sys.modules[modname]) + # Then reload the main module + importlib.reload(commands) \ No newline at end of file diff --git a/tests/core/test_delegating.py b/tests/core/test_delegating.py index 05db6c079..bcd6cbaaf 100644 --- a/tests/core/test_delegating.py +++ b/tests/core/test_delegating.py @@ -8,8 +8,10 @@ from keri import help from keri import kering, core -from keri.kering import Vrsn_1_0, Vrsn_2_0 -from keri.core import coring, eventing, parsing +from keri.kering import Vrsn_1_0 +from keri.core import coring, eventing, parsing, serdering +from keri.core.eventing import MissingDelegableApprovalError +import pytest from keri.app import keeping, habbing @@ -771,6 +773,246 @@ def test_delegables_escrow(): assert gateHab.pre in torKvy.kevers +def test_get_delegation_seal(): + """ + Test Kevery._getDelegationSeal: + 1. Seal found in AES + 2. Seal not in AES, dip event, delpre exists, seal found in KEL + 3. Seal not in AES, dip event, delpre is empty + 4. Seal not in AES, dip event, delpre exists, seal not found in KEL + 5. Seal not in AES, drt event, kever exists, delpre exists, seal found in KEL + 6. Seal not in AES, drt event, kever doesn't exist + 7. Seal not in AES, drt event, kever exists, delpre exists, seal not found in KEL + 8. Seal not in AES, event is neither dip nor drt + """ + bobSalt = core.Salter(raw=b'0123456789abcdef').qb64 + delSalt = core.Salter(raw=b'abcdef0123456789').qb64 + + with (basing.openDB(name="bob") as bobDB, + keeping.openKS(name="bob") as bobKS, + basing.openDB(name="del") as delDB, + keeping.openKS(name="del") as delKS, + keeping.openKS(name="fake") as fakeKS): + + # Init key pair managers + bobMgr = keeping.Manager(ks=bobKS, salt=bobSalt) + delMgr = keeping.Manager(ks=delKS, salt=delSalt) + + # Init Keverys + bobKvy = eventing.Kevery(db=bobDB) + delKvy = eventing.Kevery(db=delDB) + + # Setup Bob by creating inception event + verfers, digers = bobMgr.incept(stem='bob', temp=True) + bobSrdr = eventing.incept(keys=[verfer.qb64 for verfer in verfers], + ndigs=[diger.qb64 for diger in digers], + code=coring.MtrDex.Blake3_256) + + bob = bobSrdr.ked["i"] + bobMgr.move(old=verfers[0].qb64, new=bob) + + sigers = bobMgr.sign(ser=bobSrdr.raw, verfers=verfers) + msg = bytearray(bobSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + version=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + + # apply msg to bob's Kevery + parsing.Parser(version=Vrsn_1_0).parse(ims=bytearray(msg), kvy=bobKvy) + bobK = bobKvy.kevers[bob] + + # Setup Del's delegated inception event + verfers, digers = delMgr.incept(stem='del', temp=True) + delSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in verfers], + delpre=bobK.prefixer.qb64, + ndigs=[diger.qb64 for diger in digers]) + + delPre = delSrdr.ked["i"] + delMgr.move(old=verfers[0].qb64, new=delPre) + + # Create delegating event for Bob + seal = eventing.SealEvent(i=delPre, + s=delSrdr.ked["s"], + d=delSrdr.said) + bobIxnSrdr = eventing.interact(pre=bobK.prefixer.qb64, + dig=bobK.serder.said, + sn=bobK.sn + 1, + data=[seal._asdict()]) + + sigers = bobMgr.sign(ser=bobIxnSrdr.raw, verfers=bobK.verfers) + msg = bytearray(bobIxnSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + version=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + + # apply msg to bob's Kevery + parsing.Parser(version=Vrsn_1_0).parse(ims=bytearray(msg), kvy=bobKvy) + assert bobK.serder.said == bobIxnSrdr.said + + # Create Saider for the interaction event + bobIxnSaider = coring.Saider(qb64=bobIxnSrdr.said) + + # Test 1: Seal found in AES + dgkey = dbing.dgKey(delPre.encode("utf-8"), delSrdr.saidb) + seqner = coring.Seqner(sn=bobK.sn) + couple = seqner.qb64b + bobIxnSaider.qb64b + bobKvy.db.setAes(dgkey, couple) + + result_seqner, result_saider = bobKvy._getDelegationSeal(eserder=delSrdr, dgkey=dgkey) + assert result_seqner.sn == seqner.sn + assert result_saider.qb64 == bobIxnSaider.qb64 + + # Test 2: Seal not in AES, dip event, delpre exists, seal found in KEL + # Remove from AES to test KEL lookup + bobKvy.db.delAes(dgkey) + # Seal should be found in KEL via fetchLastSealingEventByEventSeal + result_seqner, result_saider = bobKvy._getDelegationSeal(eserder=delSrdr, dgkey=dgkey) + assert result_seqner.sn == bobK.sn + assert result_saider.qb64 == bobIxnSaider.qb64 + + # Test 3: Seal not in AES, dip event, delpre is empty + # Create a dip event with empty delpre by manually creating the sad dict + # and then creating SerderKERI with verify=False + tempDelSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in verfers], + delpre=bob, # valid delpre for creation + ndigs=[diger.qb64 for diger in digers]) + # Create a copy of the sad and set delpre to empty + badSad = dict(tempDelSrdr.sad) + badSad['di'] = "" # set delpre to empty + # Create SerderKERI from the modified sad with verify=False + badDelSrdr = serdering.SerderKERI(sad=badSad, verify=False) + badDgkey = dbing.dgKey(badDelSrdr.pre.encode("utf-8"), badDelSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=badDelSrdr, dgkey=badDgkey) + assert "Empty or missing delegator" in str(exc_info.value) + + # Test 4: Seal not in AES, dip event, delpre exists, seal not found in KEL + # Create a dip event with valid delpre but no seal in KEL + # Use a different Manager with different salt and KS to create a different delegate prefix + fakeSalt = core.Salter(raw=b'fakedelegate012345').qb64 + fakeMgr = keeping.Manager(ks=fakeKS, salt=fakeSalt) + fakeVerfers, fakeDigers = fakeMgr.incept(stem='fake', temp=True) + fakeDelSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in fakeVerfers], + delpre=bob, # valid delpre + ndigs=[diger.qb64 for diger in fakeDigers]) + fakeDgkey = dbing.dgKey(fakeDelSrdr.pre.encode("utf-8"), fakeDelSrdr.saidb) + # Ensure no seal exists in KEL for this event (it's a different delegate) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=fakeDelSrdr, dgkey=fakeDgkey) + assert "No delegation seal found for event" in str(exc_info.value) + + # Test 5: Seal not in AES, drt event, kever exists, delpre exists, seal found in KEL + # First, create a valid dip event and process it so we have a kever + sigers = delMgr.sign(ser=delSrdr.raw, verfers=verfers) + msg = bytearray(delSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + version=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + counter = core.Counter(core.Codens.SealSourceCouples, count=1, + version=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + seqner = coring.Seqner(sn=bobK.sn) + msg.extend(seqner.qb64b) + msg.extend(bobIxnSaider.qb64b) + + # Process the dip event so we have a kever for the delegate + parsing.Parser(version=Vrsn_1_0).parse(ims=bytearray(msg), kvy=bobKvy) + assert delPre in bobKvy.kevers + delK = bobKvy.kevers[delPre] + + # Now create a drt event + verfers, digers = delMgr.rotate(pre=delPre, temp=True) + delRotSrdr = eventing.deltate(pre=delK.prefixer.qb64, + keys=[verfer.qb64 for verfer in verfers], + dig=delK.serder.said, + sn=delK.sn + 1, + ndigs=[diger.qb64 for diger in digers]) + + # Create delegating interaction event for the rotation + rotSeal = eventing.SealEvent(i=delPre, + s=delRotSrdr.ked["s"], + d=delRotSrdr.said) + bobRotIxnSrdr = eventing.interact(pre=bobK.prefixer.qb64, + dig=bobK.serder.said, + sn=bobK.sn + 1, + data=[rotSeal._asdict()]) + + sigers = bobMgr.sign(ser=bobRotIxnSrdr.raw, verfers=bobK.verfers) + msg = bytearray(bobRotIxnSrdr.raw) + counter = core.Counter(core.Codens.ControllerIdxSigs, count=len(sigers), + version=kering.Vrsn_1_0) + msg.extend(counter.qb64b) + for siger in sigers: + msg.extend(siger.qb64b) + + # Process the delegated rotation event + parsing.Parser(version=Vrsn_1_0).parse(ims=bytearray(msg), kvy=bobKvy) + + # Test KEL lookup for drt event + bobRotIxnSaider = coring.Saider(qb64=bobRotIxnSrdr.said) + drtDgkey = dbing.dgKey(delPre.encode("utf-8"), delRotSrdr.saidb) + result_seqner, result_saider = bobKvy._getDelegationSeal(eserder=delRotSrdr, dgkey=drtDgkey) + assert result_seqner.sn == bobK.sn + assert result_saider.qb64 == bobRotIxnSaider.qb64 + + # Test 6: Seal not in AES, drt event, kever doesn't exist + # Create a drt event for a delegate we don't have a kever for + # First create a valid delegate prefix by creating a dip event + fakeVerfers, fakeDigers = fakeMgr.incept(stem='fake2', temp=True) + fakeDipSrdr = eventing.delcept(keys=[verfer.qb64 for verfer in fakeVerfers], + delpre=bob, + ndigs=[diger.qb64 for diger in fakeDigers]) + fakeDelPre = fakeDipSrdr.pre # valid prefix + fakeMgr.move(old=fakeVerfers[0].qb64, new=fakeDelPre) # move key to prefix + # Now create a drt event for this delegate (but kever doesn't exist in bobKvy) + fakeRotVerfers, fakeRotDigers = fakeMgr.rotate(pre=fakeDelPre, temp=True) + fakeDrtSrdr = eventing.deltate(pre=fakeDelPre, + keys=[verfer.qb64 for verfer in fakeRotVerfers], + dig=fakeDipSrdr.said, # use the dip said as prior + sn=1, + ndigs=[diger.qb64 for diger in fakeRotDigers]) + fakeDrtDgkey = dbing.dgKey(fakeDelPre.encode("utf-8"), fakeDrtSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=fakeDrtSrdr, dgkey=fakeDrtDgkey) + assert "No kever found for delegated rotation event" in str(exc_info.value) + + # Test 7: Seal not in AES, drt event, kever exists, delpre exists, seal not found in KEL + # Create a drt event with valid kever and delpre but no seal in KEL + fakeRotSrdr = eventing.deltate(pre=delK.prefixer.qb64, + keys=[verfer.qb64 for verfer in verfers], + dig=delK.serder.said, + sn=delK.sn + 2, # different sn, so no seal + ndigs=[diger.qb64 for diger in digers]) + fakeRotDgkey = dbing.dgKey(delPre.encode("utf-8"), fakeRotSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=fakeRotSrdr, dgkey=fakeRotDgkey) + assert "No delegation seal found for event" in str(exc_info.value) + + # Test 8: Seal not in AES, event is neither dip nor drt + # Create a regular icp event (not dip) + icpVerfers, icpDigers = bobMgr.incept(stem='icp', temp=True) + icpSrdr = eventing.incept(keys=[verfer.qb64 for verfer in icpVerfers], + ndigs=[diger.qb64 for diger in icpDigers], + code=coring.MtrDex.Blake3_256) + icpDgkey = dbing.dgKey(icpSrdr.pre.encode("utf-8"), icpSrdr.saidb) + with pytest.raises(MissingDelegableApprovalError) as exc_info: + bobKvy._getDelegationSeal(eserder=icpSrdr, dgkey=icpDgkey) + assert "No delegation seal found for event" in str(exc_info.value) + + assert not os.path.exists(delKS.path) + assert not os.path.exists(delDB.path) + assert not os.path.exists(bobKS.path) + assert not os.path.exists(bobDB.path) + + """End Test""" + + if __name__ == "__main__": test_delegation() test_delegation_supersede()