From 6c1ff1979b7ed58c566a6a67b11594ad86e2b373 Mon Sep 17 00:00:00 2001 From: kukgini Date: Mon, 6 Jul 2026 22:34:14 +0900 Subject: [PATCH] fix: complete stalled setup when activating a posted revocation registry When no active registry exists, get_or_create_active_registry promoted the oldest posted registry with a bare wallet state change. A registry is only left in the posted state when its setup pipeline stalled between publishing the definition and publishing the initial entry (e.g. the tails file upload to the tails server failed), so the promoted registry had an unresolvable tailsLocation (holders get 404 and cannot build non-revocation proofs) and possibly no initial accumulator entry on the ledger. Every credential subsequently issued against it was silently unverifiable. Resume the stalled pipeline instead: re-upload the tails file (put_file already treats HTTP 409 'already exists' as success, so this is idempotent against the reference tails server) and publish the initial entry, which itself performs the posted -> active transition. On failure the registry stays posted and promotion is retried on the next issuance attempt instead of activating a broken registry. Signed-off-by: kukgini --- acapy_agent/revocation/indy.py | 31 ++++++++++----- acapy_agent/revocation/tests/test_indy.py | 48 +++++++++++++++++++++-- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/acapy_agent/revocation/indy.py b/acapy_agent/revocation/indy.py index 42392b3f2f..4d1af5223a 100644 --- a/acapy_agent/revocation/indy.py +++ b/acapy_agent/revocation/indy.py @@ -7,6 +7,7 @@ from ..core.profile import Profile from ..ledger.base import BaseLedger +from ..ledger.error import LedgerError from ..ledger.multiple_ledger.ledger_requests_executor import ( GET_CRED_DEF, GET_REVOC_REG_DEF, @@ -260,6 +261,7 @@ async def get_or_create_active_registry( except StorageNotFoundError: pass + posted_registries = [] async with self._profile.session() as session: full_registries = await IssuerRevRegRecord.query_by_cred_def_id( session, cred_def_id, None, IssuerRevRegRecord.STATE_FULL, 1 @@ -280,19 +282,30 @@ async def get_or_create_active_registry( cred_def_id, max_cred_num=any_registry.max_cred_num, ) - # if there is a posted registry, activate oldest + # if there is a posted registry, complete its setup and activate it else: posted_registries = await IssuerRevRegRecord.query_by_cred_def_id( session, cred_def_id, IssuerRevRegRecord.STATE_POSTED, None, None ) - if posted_registries: - posted_registries = sorted( - posted_registries, key=lambda r: r.created_at - ) - await self._set_registry_status( - revoc_reg_id=posted_registries[0].revoc_reg_id, - state=IssuerRevRegRecord.STATE_ACTIVE, - ) + + if posted_registries: + # A registry is only left posted when its setup stalled after the + # definition reached the ledger: the tails file upload and/or the + # initial accumulator entry are still outstanding. Complete those + # steps instead of activating with a bare state change, which would + # break every credential later issued against the registry + # (unresolvable tailsLocation, missing initial entry). + rec = min(posted_registries, key=lambda r: r.created_at) + try: + await rec.upload_tails_file(self._profile) + # publishing the initial entry transitions posted -> active + await rec.send_entry(self._profile) + except (RevocationError, LedgerError): + LOGGER.exception( + "Setup of posted revocation registry %s could not be completed; " + "not activating (will retry on next issuance attempt)", + rec.revoc_reg_id, + ) return None async def get_ledger_registry(self, revoc_reg_id: str) -> RevocationRegistry: diff --git a/acapy_agent/revocation/tests/test_indy.py b/acapy_agent/revocation/tests/test_indy.py index 4cc3f6153a..7f8768bbce 100644 --- a/acapy_agent/revocation/tests/test_indy.py +++ b/acapy_agent/revocation/tests/test_indy.py @@ -318,11 +318,51 @@ async def test_get_or_create_active_registry_has_no_active_or_any_registry(self, ], ], ) + @mock.patch.object(IssuerRevRegRecord, "upload_tails_file", mock.CoroutineMock()) + @mock.patch.object(IssuerRevRegRecord, "send_entry", mock.CoroutineMock()) async def test_get_or_create_active_registry_has_no_active_with_posted(self, *_): result = await self.revoc.get_or_create_active_registry("cred_def_id") assert not result - assert ( - self.revoc._set_registry_status.call_args.kwargs["state"] - == IssuerRevRegRecord.STATE_ACTIVE - ) + # the stalled setup pipeline is completed rather than bare-activated: + # the tails file is uploaded and the initial entry published (which + # transitions the record posted -> active) + IssuerRevRegRecord.upload_tails_file.assert_awaited_once() + IssuerRevRegRecord.send_entry.assert_awaited_once() + self.revoc._set_registry_status.assert_not_called() + + @mock.patch( + "acapy_agent.revocation.indy.IndyRevocation.get_active_issuer_rev_reg_record", + mock.CoroutineMock(side_effect=StorageNotFoundError("No such record")), + ) + @mock.patch( + "acapy_agent.revocation.indy.IndyRevocation._set_registry_status", + mock.CoroutineMock(return_value=None), + ) + @mock.patch.object( + IssuerRevRegRecord, + "query_by_cred_def_id", + side_effect=[ + [IssuerRevRegRecord(max_cred_num=3)], + [ + IssuerRevRegRecord( + revoc_reg_id="test-rev-reg-id", + state=IssuerRevRegRecord.STATE_POSTED, + ) + ], + ], + ) + @mock.patch.object( + IssuerRevRegRecord, + "upload_tails_file", + mock.CoroutineMock(side_effect=RevocationError("tails server unavailable")), + ) + @mock.patch.object(IssuerRevRegRecord, "send_entry", mock.CoroutineMock()) + async def test_get_or_create_active_registry_posted_setup_incomplete(self, *_): + result = await self.revoc.get_or_create_active_registry("cred_def_id") + + assert not result + # the registry must stay posted: activating it without a tails file + # on the server would break credentials issued against it + IssuerRevRegRecord.send_entry.assert_not_called() + self.revoc._set_registry_status.assert_not_called()