From 9f34faa5a3e0e0c26b25791ff3cc71e5be5605b3 Mon Sep 17 00:00:00 2001 From: thefux Date: Thu, 13 Aug 2026 20:27:32 +0000 Subject: [PATCH 1/7] fix(cashu): expand short NUT-02 v2 keyset ids before redeem/melt minibits migrated to NUT-02 Keyset ID v2; tokens now carry 16-char short keyset ids (01 + first 7 bytes of the 66-char full id). The mint is agnostic of short ids, so the redeeming wallet must expand them to the full id before any melt/swap, else redemption fails with "A short keyset ID v2 was encountered, but got no keysets to map it to" (500 Internal error during token redemption). - add _expand_short_keysets helper (no-op for v1/full-length ids, lazily loads keysets, wraps unmappable ids in a clear ValueError) - reuse a single token.proofs capture in both redeem paths (property rebuilds fresh Proof objects on every access, so in-place id mutation is otherwise discarded) - regression tests covering expansion + both redeem paths --- routstr/wallet.py | 54 +++++- tests/unit/test_keyset_expansion.py | 256 ++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 tests/unit/test_keyset_expansion.py diff --git a/routstr/wallet.py b/routstr/wallet.py index 3ab20c78..c76d9e8a 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -299,6 +299,32 @@ async def get_balance(unit: str) -> int: return wallet.available_balance.amount +async def _expand_short_keysets(wallet: "_CashuWallet", proofs: list[Proof]) -> None: + """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to full 66-char + ids in place; the mint won't accept a short id on melt/swap.""" + short_ids = { + p.id + for p in proofs + if isinstance(getattr(p, "id", None), str) and 0 < len(p.id) < 66 + } + if not short_ids: + return + if not wallet.keysets: + await run_mint_operation( + lambda: wallet.load_mint(), + op_name="load_mint_for_keyset_expansion", + mint_url=wallet.url, + retry_timeouts=False, + ) + try: + await wallet._expand_short_keyset_ids(proofs) + except KeyError as e: + raise ValueError( + "Token carries a short keyset id that cannot be mapped to a " + f"mint keyset (NUT-02 v2 migration): {e}" + ) from e + + async def _redeem_same_mint( wallet: Wallet, token_obj: Token ) -> tuple[int, str, str]: # amount, unit, mint_url @@ -336,11 +362,17 @@ async def _redeem_same_mint( ) from error raise - wallet.verify_proofs_dleq(token_obj.proofs) - input_fees = wallet.get_fees_for_proofs(token_obj.proofs) + # token_obj.proofs rebuilds fresh Proof objects on every access, so + # capture it once and reuse below — otherwise _expand_short_keysets' + # in-place id mutation gets silently discarded. + proofs = token_obj.proofs + await _expand_short_keysets(wallet, proofs) + + wallet.verify_proofs_dleq(proofs) + input_fees = wallet.get_fees_for_proofs(proofs) try: await run_mint_operation( - lambda: wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True), + lambda: wallet.split(proofs=proofs, amount=0, include_fees=True), op_name="redeem_split", mint_url=token_obj.mint, retry_timeouts=False, @@ -1285,6 +1317,12 @@ async def swap_to_trusted_mint( ) return await _redeem_same_mint(token_wallet, token_obj) + # token_obj.proofs rebuilds fresh Proof objects on every access, so + # capture it once and reuse below — otherwise _expand_short_keysets' + # in-place id mutation gets silently discarded. + proofs = token_obj.proofs + await _expand_short_keysets(token_wallet, proofs) + primary_wallet: Wallet | None = None minted_amount = await _calculate_swap_amount( @@ -1293,7 +1331,7 @@ async def swap_to_trusted_mint( token_obj.mint, token_wallet, primary_wallet, - token_obj.proofs, + proofs, destination_candidates, ) @@ -1372,7 +1410,7 @@ async def swap_to_trusted_mint( "Issuing Cashu mint is unreachable" ) from error raise - input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs) + input_fees = token_wallet.get_fees_for_proofs(proofs) total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees logger.info( "swap_to_trusted_mint: melt quote received", @@ -1426,7 +1464,7 @@ async def swap_to_trusted_mint( try: melt_response = await run_mint_operation( lambda: token_wallet.melt( - proofs=token_obj.proofs, + proofs=proofs, invoice=mint_quote.request, fee_reserve_sat=melt_quote.fee_reserve, quote_id=melt_quote.quote, @@ -1436,7 +1474,7 @@ async def swap_to_trusted_mint( retry_timeouts=False, ) await _confirm_melt_paid( - token_wallet, melt_quote.quote, token_obj.proofs, melt_response + token_wallet, melt_quote.quote, proofs, melt_response ) except Exception as e: shortfall = _melt_insufficient_shortfall(e) @@ -1449,7 +1487,7 @@ async def swap_to_trusted_mint( ) from e if is_mint_connection_error(e): await _reconcile_ambiguous_melt( - token_wallet, melt_quote.quote, token_obj.proofs + token_wallet, melt_quote.quote, proofs ) logger.info( "Source melt reconciled as paid; minting on destination", diff --git a/tests/unit/test_keyset_expansion.py b/tests/unit/test_keyset_expansion.py new file mode 100644 index 00000000..0f1bb1d2 --- /dev/null +++ b/tests/unit/test_keyset_expansion.py @@ -0,0 +1,256 @@ +"""Regression tests for NUT-02 keyset ID v2 short-id expansion (minibits migration). + +minibits migrated to NUT-02 Keyset ID v2: tokens now carry 16-char SHORT +keyset IDs (version byte ``01`` + first 7 bytes of the full 66-char id). The +Cashu mint is agnostic of short ids — the redeeming wallet MUST expand them to +the full id before any melt/swap, otherwise redemption fails with +``A short keyset ID v2 was encountered, but got no keysets to map it to`` +(500 "Internal error during token redemption"). + +These tests pin the behavior of :func:`routstr.wallet._expand_short_keysets` +and verify both redeem paths (same-mint split and cross-mint melt) invoke it. +""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from cashu.core.base import Proof +from cashu.wallet.keyset_manager import KeysetManager + +# Matches the live minibits mint: active v2 keyset and its 16-char short id. +FULL_V2_ID = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821" +SHORT_V2_ID = "01fc0ec0e59cd6fa" + +# Legacy v1 short id (16 hex, version byte 00) == the full id (no expansion). +V1_LEGACY_ID = "00107937db0cc865" + + +def _keyset(full_id: str): + """Construct a fake keyset object carrying the given full id.""" + return type("KS", (object,), {"id": full_id})() + + +def _proof(keyset_id: str, amount: int = 1) -> Proof: + return Proof( + id=keyset_id, + amount=amount, + secret="DEADBEEF", + C="03" + "00" * 32, + ) + + +class _ExpandingWallet: + """A wallet stub whose ``_expand_short_keyset_ids`` is the *real* logic. + + Mirrors the cashu lib's wallet.proofs._expand_short_keyset_ids, so the test + exercises the actual short->full mapping, not a mock. + """ + + def __init__(self, keysets, url: str = "https://mint.example"): + self.keysets = keysets # {full_id: obj-with-.id} + self.url = url + self.load_mint_called = False + + async def load_mint(self, keyset_id: str = ""): + self.load_mint_called = True + + async def _expand_short_keyset_ids(self, proofs): + manager = KeysetManager() + keysets_dict = {k.id: k for k in self.keysets.values()} + for p in proofs: + if p.id.startswith("01") and len(p.id) == 16: + p.id = manager.get_full_keyset_id(p.id, keysets_dict) + + +# ============================================================= helper behavior + + +@pytest.mark.asyncio +async def test_expand_short_keysets_noop_for_v1_legacy_ids(): + """v1 (``00``) short ids are the full id — no expansion, no keyset load.""" + from routstr.wallet import _expand_short_keysets + + wallet = _ExpandingWallet( + {V1_LEGACY_ID: _keyset(V1_LEGACY_ID)}, url="https://mint.example" + ) + proofs = [_proof(V1_LEGACY_ID)] + await _expand_short_keysets(wallet, proofs) + assert proofs[0].id == V1_LEGACY_ID + assert wallet.load_mint_called is False + + +@pytest.mark.asyncio +async def test_expand_short_keysets_noop_for_full_v2_ids(): + """Already-full 66-char v2 ids are left untouched.""" + from routstr.wallet import _expand_short_keysets + + wallet = _ExpandingWallet({FULL_V2_ID: _keyset(FULL_V2_ID)}) + proofs = [_proof(FULL_V2_ID)] + await _expand_short_keysets(wallet, proofs) + assert proofs[0].id == FULL_V2_ID + assert wallet.load_mint_called is False + + +@pytest.mark.asyncio +async def test_expand_short_keysets_expands_v2_short_to_full(): + """16-char ``01`` short id is expanded to the full 66-char id.""" + from routstr.wallet import _expand_short_keysets + + wallet = _ExpandingWallet( + { + FULL_V2_ID: _keyset(FULL_V2_ID), + V1_LEGACY_ID: _keyset(V1_LEGACY_ID), + } + ) + proofs = [_proof(SHORT_V2_ID), _proof(V1_LEGACY_ID)] + await _expand_short_keysets(wallet, proofs) + assert proofs[0].id == FULL_V2_ID # expanded short->full + assert proofs[1].id == V1_LEGACY_ID # v1 untouched + + +@pytest.mark.asyncio +async def test_expand_short_keysets_loads_keysets_when_empty(): + """When the wallet has no keysets loaded, load_mint is called first.""" + from routstr.wallet import _expand_short_keysets + + wallet = _ExpandingWallet({}, url="https://mint.example") + # NOTE: real load would populate keysets; here we assert the calls we make. + # After load_mint the (empty) keysets can't resolve the short id -> error. + with pytest.raises(ValueError): + await _expand_short_keysets(wallet, [_proof(SHORT_V2_ID)]) + assert wallet.load_mint_called is True + + +@pytest.mark.asyncio +async def test_expand_short_keysets_wraps_unresolvable_short_id(): + """A short id that can't be mapped surfaces as a clear ValueError.""" + from routstr.wallet import _expand_short_keysets + + wallet = _ExpandingWallet({FULL_V2_ID: _keyset(FULL_V2_ID)}) + stray = "0111111111111111" # 16-char short id with no matching keyset + with pytest.raises(ValueError, match="cannot be mapped"): + await _expand_short_keysets(wallet, [_proof(stray)]) + + +# ======================================================= redeem paths invoke it + + +class _PropertyToken: + """Mimics TokenV4: ``.proofs`` rebuilds fresh Proof objects from scratch + on every access, so mutating a returned Proof's ``id`` in place is only + visible to whoever captured that particular list. A caller that re-reads + ``.proofs`` after expansion would silently get the short id back.""" + + def __init__(self, mint, unit, amount, keysets, keyset_id, proof_amount): + self.mint = mint + self.unit = unit + self.amount = amount + self.keysets = keysets + self._keyset_id = keyset_id + self._proof_amount = proof_amount + self.proofs_access_count = 0 + + @property + def proofs(self): + self.proofs_access_count += 1 + return [_proof(self._keyset_id, amount=self._proof_amount)] + + +def _mutate_short_to_full(proofs): + for p in proofs: + if p.id == SHORT_V2_ID: + p.id = FULL_V2_ID + + +@pytest.mark.asyncio +async def test_redeem_same_mint_expands_keysets_before_split(): + """Same-mint redemption expands short ids and split() sees the full id + even though token.proofs is a property re-generated on every access.""" + from routstr.wallet import _redeem_same_mint + + token = _PropertyToken( + mint="https://mint.example", + unit="sat", + amount=1, + keysets=[SHORT_V2_ID], + keyset_id=SHORT_V2_ID, + proof_amount=1, + ) + + wallet = AsyncMock() + wallet.keysets = {FULL_V2_ID: _keyset(FULL_V2_ID)} + wallet.load_mint = AsyncMock() + wallet._expand_short_keyset_ids = AsyncMock(side_effect=_mutate_short_to_full) + wallet.verify_proofs_dleq = AsyncMock() + wallet.get_fees_for_proofs = Mock(return_value=0) + wallet.split = AsyncMock(return_value=([], [])) + wallet.url = "https://mint.example" + + with patch("routstr.wallet.run_mint_operation", new=lambda f, **k: f()): + await _redeem_same_mint(wallet, token) + + split_proofs = wallet.split.call_args.kwargs["proofs"] + assert split_proofs[0].id == FULL_V2_ID + assert token.proofs_access_count == 1 + + +@pytest.mark.asyncio +async def test_swap_to_trusted_mint_expands_keysets_before_melt(): + """Cross-mint swap expands short ids and melt() sees the full id even + though token.proofs is a property re-generated on every access.""" + import routstr.wallet as wallet_mod + + token = _PropertyToken( + mint="https://foreign.example", + unit="sat", + amount=1000, + keysets=[SHORT_V2_ID], + keyset_id=SHORT_V2_ID, + proof_amount=1000, + ) + + token_wallet = AsyncMock() + token_wallet.keysets = {FULL_V2_ID: _keyset(FULL_V2_ID)} + token_wallet.url = "https://foreign.example" + token_wallet._expand_short_keyset_ids = AsyncMock(side_effect=_mutate_short_to_full) + token_wallet.melt_quote = AsyncMock( + return_value=Mock(quote="melt-q", amount=1000, fee_reserve=0) + ) + token_wallet.melt = AsyncMock(return_value=Mock(state="PAID")) + token_wallet.get_fees_for_proofs = Mock(return_value=0) + + dest_wallet = AsyncMock() + dest_wallet.available_balance.amount = 0 + + with ( + patch.object( + wallet_mod, + "_calculate_swap_amount", + new=AsyncMock(return_value=1000), + ), + patch.object( + wallet_mod, + "_request_mint_with_fallback", + new=AsyncMock( + return_value=(dest_wallet, "https://mint.example", Mock(quote="q")) + ), + ), + patch.object( + wallet_mod, + "_confirm_melt_paid", + new=AsyncMock(), + ), + patch("routstr.wallet.run_mint_operation", new=lambda f, **k: f()), + ): + with patch.object( + wallet_mod, + "_trusted_destination_candidates", + return_value=["https://mint.example"], + ): + await wallet_mod.swap_to_trusted_mint( + token, token_wallet, destination_mints=["https://mint.example"] + ) + + melt_proofs = token_wallet.melt.call_args.kwargs["proofs"] + assert melt_proofs[0].id == FULL_V2_ID + assert token.proofs_access_count == 1 From bad01b0e92adf687ce64a3251aee057f3d043ad7 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 13 Aug 2026 23:08:28 +0200 Subject: [PATCH 2/7] fix(cashu): tighten keyset expansion regression coverage --- routstr/wallet.py | 27 ++++++--- tests/unit/test_keyset_expansion.py | 85 +++++++++++++++++++++-------- 2 files changed, 80 insertions(+), 32 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index c76d9e8a..d0fb164a 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -302,22 +302,33 @@ async def get_balance(unit: str) -> int: async def _expand_short_keysets(wallet: "_CashuWallet", proofs: list[Proof]) -> None: """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to full 66-char ids in place; the mint won't accept a short id on melt/swap.""" - short_ids = { - p.id - for p in proofs - if isinstance(getattr(p, "id", None), str) and 0 < len(p.id) < 66 - } - if not short_ids: + has_short_v2_id = any( + p.id.startswith("01") and len(p.id) == 16 for p in proofs + ) + if not has_short_v2_id: return - if not wallet.keysets: + + had_keysets = bool(wallet.keysets) + + async def load_keysets() -> None: await run_mint_operation( lambda: wallet.load_mint(), op_name="load_mint_for_keyset_expansion", mint_url=wallet.url, retry_timeouts=False, ) + + if not had_keysets: + await load_keysets() try: - await wallet._expand_short_keyset_ids(proofs) + try: + await wallet._expand_short_keyset_ids(proofs) + except KeyError: + if not had_keysets: + raise + # Cached keysets may predate the token's issuing keyset. + await load_keysets() + await wallet._expand_short_keyset_ids(proofs) except KeyError as e: raise ValueError( "Token carries a short keyset id that cannot be mapped to a " diff --git a/tests/unit/test_keyset_expansion.py b/tests/unit/test_keyset_expansion.py index 0f1bb1d2..2d247e77 100644 --- a/tests/unit/test_keyset_expansion.py +++ b/tests/unit/test_keyset_expansion.py @@ -11,6 +11,7 @@ These tests pin the behavior of :func:`routstr.wallet._expand_short_keysets` and verify both redeem paths (same-mint split and cross-mint melt) invoke it. """ +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest @@ -25,9 +26,9 @@ SHORT_V2_ID = "01fc0ec0e59cd6fa" V1_LEGACY_ID = "00107937db0cc865" -def _keyset(full_id: str): +def _keyset(full_id: str) -> SimpleNamespace: """Construct a fake keyset object carrying the given full id.""" - return type("KS", (object,), {"id": full_id})() + return SimpleNamespace(id=full_id) def _proof(keyset_id: str, amount: int = 1) -> Proof: @@ -46,15 +47,25 @@ class _ExpandingWallet: exercises the actual short->full mapping, not a mock. """ - def __init__(self, keysets, url: str = "https://mint.example"): + def __init__( + self, + keysets: dict[str, SimpleNamespace], + url: str = "https://mint.example", + keysets_after_load: dict[str, SimpleNamespace] | None = None, + ) -> None: self.keysets = keysets # {full_id: obj-with-.id} self.url = url + self.keysets_after_load = keysets_after_load self.load_mint_called = False + self.expand_called = False - async def load_mint(self, keyset_id: str = ""): + async def load_mint(self, keyset_id: str = "") -> None: self.load_mint_called = True + if self.keysets_after_load is not None: + self.keysets = self.keysets_after_load - async def _expand_short_keyset_ids(self, proofs): + async def _expand_short_keyset_ids(self, proofs: list[Proof]) -> None: + self.expand_called = True manager = KeysetManager() keysets_dict = {k.id: k for k in self.keysets.values()} for p in proofs: @@ -66,21 +77,20 @@ class _ExpandingWallet: @pytest.mark.asyncio -async def test_expand_short_keysets_noop_for_v1_legacy_ids(): +async def test_expand_short_keysets_noop_for_v1_legacy_ids() -> None: """v1 (``00``) short ids are the full id — no expansion, no keyset load.""" from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet( - {V1_LEGACY_ID: _keyset(V1_LEGACY_ID)}, url="https://mint.example" - ) + wallet = _ExpandingWallet({}, url="https://mint.example") proofs = [_proof(V1_LEGACY_ID)] await _expand_short_keysets(wallet, proofs) assert proofs[0].id == V1_LEGACY_ID assert wallet.load_mint_called is False + assert wallet.expand_called is False @pytest.mark.asyncio -async def test_expand_short_keysets_noop_for_full_v2_ids(): +async def test_expand_short_keysets_noop_for_full_v2_ids() -> None: """Already-full 66-char v2 ids are left untouched.""" from routstr.wallet import _expand_short_keysets @@ -92,7 +102,7 @@ async def test_expand_short_keysets_noop_for_full_v2_ids(): @pytest.mark.asyncio -async def test_expand_short_keysets_expands_v2_short_to_full(): +async def test_expand_short_keysets_expands_v2_short_to_full() -> None: """16-char ``01`` short id is expanded to the full 66-char id.""" from routstr.wallet import _expand_short_keysets @@ -109,20 +119,39 @@ async def test_expand_short_keysets_expands_v2_short_to_full(): @pytest.mark.asyncio -async def test_expand_short_keysets_loads_keysets_when_empty(): - """When the wallet has no keysets loaded, load_mint is called first.""" +async def test_expand_short_keysets_loads_keysets_when_empty() -> None: + """When the wallet has no keysets loaded, load them before expansion.""" from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet({}, url="https://mint.example") - # NOTE: real load would populate keysets; here we assert the calls we make. - # After load_mint the (empty) keysets can't resolve the short id -> error. - with pytest.raises(ValueError): - await _expand_short_keysets(wallet, [_proof(SHORT_V2_ID)]) + wallet = _ExpandingWallet( + {}, + url="https://mint.example", + keysets_after_load={FULL_V2_ID: _keyset(FULL_V2_ID)}, + ) + proofs = [_proof(SHORT_V2_ID)] + await _expand_short_keysets(wallet, proofs) assert wallet.load_mint_called is True + assert proofs[0].id == FULL_V2_ID @pytest.mark.asyncio -async def test_expand_short_keysets_wraps_unresolvable_short_id(): +async def test_expand_short_keysets_refreshes_stale_keysets() -> None: + """Retry with refreshed keysets when a populated cache cannot map the id.""" + from routstr.wallet import _expand_short_keysets + + stale_id = "01" + "11" * 32 + wallet = _ExpandingWallet( + {stale_id: _keyset(stale_id)}, + keysets_after_load={FULL_V2_ID: _keyset(FULL_V2_ID)}, + ) + proofs = [_proof(SHORT_V2_ID)] + await _expand_short_keysets(wallet, proofs) + assert wallet.load_mint_called is True + assert proofs[0].id == FULL_V2_ID + + +@pytest.mark.asyncio +async def test_expand_short_keysets_wraps_unresolvable_short_id() -> None: """A short id that can't be mapped surfaces as a clear ValueError.""" from routstr.wallet import _expand_short_keysets @@ -141,7 +170,15 @@ class _PropertyToken: visible to whoever captured that particular list. A caller that re-reads ``.proofs`` after expansion would silently get the short id back.""" - def __init__(self, mint, unit, amount, keysets, keyset_id, proof_amount): + def __init__( + self, + mint: str, + unit: str, + amount: int, + keysets: list[str], + keyset_id: str, + proof_amount: int, + ) -> None: self.mint = mint self.unit = unit self.amount = amount @@ -151,19 +188,19 @@ class _PropertyToken: self.proofs_access_count = 0 @property - def proofs(self): + def proofs(self) -> list[Proof]: self.proofs_access_count += 1 return [_proof(self._keyset_id, amount=self._proof_amount)] -def _mutate_short_to_full(proofs): +def _mutate_short_to_full(proofs: list[Proof]) -> None: for p in proofs: if p.id == SHORT_V2_ID: p.id = FULL_V2_ID @pytest.mark.asyncio -async def test_redeem_same_mint_expands_keysets_before_split(): +async def test_redeem_same_mint_expands_keysets_before_split() -> None: """Same-mint redemption expands short ids and split() sees the full id even though token.proofs is a property re-generated on every access.""" from routstr.wallet import _redeem_same_mint @@ -195,7 +232,7 @@ async def test_redeem_same_mint_expands_keysets_before_split(): @pytest.mark.asyncio -async def test_swap_to_trusted_mint_expands_keysets_before_melt(): +async def test_swap_to_trusted_mint_expands_keysets_before_melt() -> None: """Cross-mint swap expands short ids and melt() sees the full id even though token.proofs is a property re-generated on every access.""" import routstr.wallet as wallet_mod From 6f5bfda8d26ebb9e501bbb47a9758f739f0dc4cc Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 13 Aug 2026 23:11:24 +0200 Subject: [PATCH 3/7] style(cashu): format keyset expansion helper --- routstr/wallet.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index d0fb164a..7361569d 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -302,9 +302,7 @@ async def get_balance(unit: str) -> int: async def _expand_short_keysets(wallet: "_CashuWallet", proofs: list[Proof]) -> None: """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to full 66-char ids in place; the mint won't accept a short id on melt/swap.""" - has_short_v2_id = any( - p.id.startswith("01") and len(p.id) == 16 for p in proofs - ) + has_short_v2_id = any(p.id.startswith("01") and len(p.id) == 16 for p in proofs) if not has_short_v2_id: return From ef87b51f37d44d7555be4d01da8fe0754a263550 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 13 Aug 2026 23:12:56 +0200 Subject: [PATCH 4/7] fix(cashu): preserve keyset refresh failures --- routstr/wallet.py | 2 +- tests/unit/test_keyset_expansion.py | 32 ++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index 7361569d..eacac341 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -310,7 +310,7 @@ async def _expand_short_keysets(wallet: "_CashuWallet", proofs: list[Proof]) -> async def load_keysets() -> None: await run_mint_operation( - lambda: wallet.load_mint(), + lambda: wallet.load_mint_keysets(), op_name="load_mint_for_keyset_expansion", mint_url=wallet.url, retry_timeouts=False, diff --git a/tests/unit/test_keyset_expansion.py b/tests/unit/test_keyset_expansion.py index 2d247e77..911d5e87 100644 --- a/tests/unit/test_keyset_expansion.py +++ b/tests/unit/test_keyset_expansion.py @@ -56,11 +56,11 @@ class _ExpandingWallet: self.keysets = keysets # {full_id: obj-with-.id} self.url = url self.keysets_after_load = keysets_after_load - self.load_mint_called = False + self.load_keysets_called = False self.expand_called = False - async def load_mint(self, keyset_id: str = "") -> None: - self.load_mint_called = True + async def load_mint_keysets(self) -> None: + self.load_keysets_called = True if self.keysets_after_load is not None: self.keysets = self.keysets_after_load @@ -85,7 +85,7 @@ async def test_expand_short_keysets_noop_for_v1_legacy_ids() -> None: proofs = [_proof(V1_LEGACY_ID)] await _expand_short_keysets(wallet, proofs) assert proofs[0].id == V1_LEGACY_ID - assert wallet.load_mint_called is False + assert wallet.load_keysets_called is False assert wallet.expand_called is False @@ -98,7 +98,7 @@ async def test_expand_short_keysets_noop_for_full_v2_ids() -> None: proofs = [_proof(FULL_V2_ID)] await _expand_short_keysets(wallet, proofs) assert proofs[0].id == FULL_V2_ID - assert wallet.load_mint_called is False + assert wallet.load_keysets_called is False @pytest.mark.asyncio @@ -130,10 +130,28 @@ async def test_expand_short_keysets_loads_keysets_when_empty() -> None: ) proofs = [_proof(SHORT_V2_ID)] await _expand_short_keysets(wallet, proofs) - assert wallet.load_mint_called is True + assert wallet.load_keysets_called is True assert proofs[0].id == FULL_V2_ID +@pytest.mark.asyncio +async def test_expand_short_keysets_propagates_keyset_load_failure() -> None: + """Loading failures retain their original error instead of blaming the token.""" + from routstr.wallet import _expand_short_keysets + + wallet = _ExpandingWallet({}) + load_error = RuntimeError("mint unavailable") + with ( + patch.object( + wallet, + "load_mint_keysets", + new=AsyncMock(side_effect=load_error), + ), + pytest.raises(RuntimeError, match="mint unavailable"), + ): + await _expand_short_keysets(wallet, [_proof(SHORT_V2_ID)]) + + @pytest.mark.asyncio async def test_expand_short_keysets_refreshes_stale_keysets() -> None: """Retry with refreshed keysets when a populated cache cannot map the id.""" @@ -146,7 +164,7 @@ async def test_expand_short_keysets_refreshes_stale_keysets() -> None: ) proofs = [_proof(SHORT_V2_ID)] await _expand_short_keysets(wallet, proofs) - assert wallet.load_mint_called is True + assert wallet.load_keysets_called is True assert proofs[0].id == FULL_V2_ID From 91e8be39313eb44b0ccb46c13b1dd3e502bb3576 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 13 Aug 2026 23:23:00 +0200 Subject: [PATCH 5/7] fix(cashu): tolerate incomplete proof test doubles --- routstr/wallet.py | 7 ++++++- tests/unit/test_keyset_expansion.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index eacac341..88684a60 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -302,7 +302,12 @@ async def get_balance(unit: str) -> int: async def _expand_short_keysets(wallet: "_CashuWallet", proofs: list[Proof]) -> None: """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to full 66-char ids in place; the mint won't accept a short id on melt/swap.""" - has_short_v2_id = any(p.id.startswith("01") and len(p.id) == 16 for p in proofs) + has_short_v2_id = any( + isinstance(proof_id := getattr(proof, "id", None), str) + and proof_id.startswith("01") + and len(proof_id) == 16 + for proof in proofs + ) if not has_short_v2_id: return diff --git a/tests/unit/test_keyset_expansion.py b/tests/unit/test_keyset_expansion.py index 911d5e87..c71e192a 100644 --- a/tests/unit/test_keyset_expansion.py +++ b/tests/unit/test_keyset_expansion.py @@ -89,6 +89,17 @@ async def test_expand_short_keysets_noop_for_v1_legacy_ids() -> None: assert wallet.expand_called is False +@pytest.mark.asyncio +async def test_expand_short_keysets_noop_for_incomplete_test_proofs() -> None: + """Proof-like test doubles without string ids are safely ignored.""" + from routstr.wallet import _expand_short_keysets + + wallet = _ExpandingWallet({}) + await _expand_short_keysets(wallet, [Mock(), {"amount": 1}]) # type: ignore[list-item] + assert wallet.load_keysets_called is False + assert wallet.expand_called is False + + @pytest.mark.asyncio async def test_expand_short_keysets_noop_for_full_v2_ids() -> None: """Already-full 66-char v2 ids are left untouched.""" From d6666ca16de4a457d1b17ef43f329e99d9b8435e Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 13 Aug 2026 23:39:46 +0200 Subject: [PATCH 6/7] create compat --- routstr/token_compat.py | 102 ++++++++++++++++++++++++++++ routstr/wallet.py | 63 ++++------------- tests/unit/test_keyset_expansion.py | 47 ++++++++----- 3 files changed, 143 insertions(+), 69 deletions(-) create mode 100644 routstr/token_compat.py diff --git a/routstr/token_compat.py b/routstr/token_compat.py new file mode 100644 index 00000000..c88cb27f --- /dev/null +++ b/routstr/token_compat.py @@ -0,0 +1,102 @@ +"""Compatibility shims for incoming Cashu token proofs. + +Ecosystem migrations (NUT spec revisions, wallet-specific quirks) periodically +change the shape of tokens we receive. Each such fix is a +:class:`ProofCompatShim`; :func:`normalize_token_proofs` is the single entry +point the redeem/swap paths call before touching a token's proofs. + +To add a new shim: subclass :class:`ProofCompatShim` and append an instance to +:data:`PROOF_SHIMS`. +""" + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from cashu.core.base import Proof, Token + +from .mint import run_mint_operation + +if TYPE_CHECKING: + from cashu.wallet.wallet import Wallet as _CashuWallet + + +def is_short_v2_keyset_id(keyset_id: object) -> bool: + """True for a 16-char NUT-02 v2 short keyset id (version byte ``01``). + + Short ids exist only inside tokens; mint keysets are keyed by the full + 66-char id, so a short id can never be activated or looked up directly. + """ + return ( + isinstance(keyset_id, str) + and keyset_id.startswith("01") + and len(keyset_id) == 16 + ) + + +class ProofCompatShim(ABC): + """One token-format compatibility fix applied to proofs in place.""" + + @abstractmethod + def applies(self, proofs: list[Proof]) -> bool: + """Cheap check whether this shim is needed for these proofs.""" + + @abstractmethod + async def apply(self, wallet: "_CashuWallet", proofs: list[Proof]) -> None: + """Mutate ``proofs`` in place; may talk to the mint via ``wallet``.""" + + +class ShortKeysetIdExpansion(ProofCompatShim): + """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to full + 66-char ids in place; the mint won't accept a short id on melt/swap.""" + + def applies(self, proofs: list[Proof]) -> bool: + return any( + is_short_v2_keyset_id(getattr(proof, "id", None)) for proof in proofs + ) + + async def apply(self, wallet: "_CashuWallet", proofs: list[Proof]) -> None: + had_keysets = bool(wallet.keysets) + + async def load_keysets() -> None: + await run_mint_operation( + lambda: wallet.load_mint_keysets(), + op_name="load_mint_for_keyset_expansion", + mint_url=wallet.url, + retry_timeouts=False, + ) + + if not had_keysets: + await load_keysets() + try: + try: + await wallet._expand_short_keyset_ids(proofs) + except KeyError: + if not had_keysets: + raise + # Cached keysets may predate the token's issuing keyset. + await load_keysets() + await wallet._expand_short_keyset_ids(proofs) + except KeyError as e: + raise ValueError( + "Token carries a short keyset id that cannot be mapped to a " + f"mint keyset (NUT-02 v2 migration): {e}" + ) from e + + +PROOF_SHIMS: tuple[ProofCompatShim, ...] = (ShortKeysetIdExpansion(),) + + +async def normalize_token_proofs( + wallet: "_CashuWallet", token_obj: Token +) -> list[Proof]: + """Capture a token's proofs once and run every applicable compat shim. + + ``token_obj.proofs`` rebuilds fresh Proof objects on every access, so + callers must use the returned list — re-reading ``token_obj.proofs`` after + this call silently discards the shims' in-place mutations. + """ + proofs = token_obj.proofs + for shim in PROOF_SHIMS: + if shim.applies(proofs): + await shim.apply(wallet, proofs) + return proofs diff --git a/routstr/wallet.py b/routstr/wallet.py index 88684a60..51eb1375 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -33,6 +33,7 @@ from .mint import ( run_mint_operation, ) from .payment.lnurl import raw_send_to_lnurl +from .token_compat import is_short_v2_keyset_id, normalize_token_proofs # Backwards-compatible aliases for callers/tests that imported the former # wallet-local policy. Production modules use the public routstr.mint API. @@ -299,46 +300,6 @@ async def get_balance(unit: str) -> int: return wallet.available_balance.amount -async def _expand_short_keysets(wallet: "_CashuWallet", proofs: list[Proof]) -> None: - """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to full 66-char - ids in place; the mint won't accept a short id on melt/swap.""" - has_short_v2_id = any( - isinstance(proof_id := getattr(proof, "id", None), str) - and proof_id.startswith("01") - and len(proof_id) == 16 - for proof in proofs - ) - if not has_short_v2_id: - return - - had_keysets = bool(wallet.keysets) - - async def load_keysets() -> None: - await run_mint_operation( - lambda: wallet.load_mint_keysets(), - op_name="load_mint_for_keyset_expansion", - mint_url=wallet.url, - retry_timeouts=False, - ) - - if not had_keysets: - await load_keysets() - try: - try: - await wallet._expand_short_keyset_ids(proofs) - except KeyError: - if not had_keysets: - raise - # Cached keysets may predate the token's issuing keyset. - await load_keysets() - await wallet._expand_short_keyset_ids(proofs) - except KeyError as e: - raise ValueError( - "Token carries a short keyset id that cannot be mapped to a " - f"mint keyset (NUT-02 v2 migration): {e}" - ) from e - - async def _redeem_same_mint( wallet: Wallet, token_obj: Token ) -> tuple[int, str, str]: # amount, unit, mint_url @@ -350,9 +311,17 @@ async def _redeem_same_mint( that, not the face value, or routstr over-credits the user and its wallet drifts insolvent. """ + # A short NUT-02 v2 keyset id (e.g. minibits tokens) can't be activated: + # mint keysets are keyed by full ids, activate_keyset silently fails inside + # load_mint, and split() later dies with "No active keyset". Let load_mint + # pick an active keyset for the wallet's unit instead; the proofs' short + # ids are expanded via normalize_token_proofs below. + load_keyset_id = token_obj.keysets[0] + if is_short_v2_keyset_id(load_keyset_id): + load_keyset_id = "" try: await run_mint_operation( - lambda: wallet.load_mint(keyset_id=token_obj.keysets[0]), + lambda: wallet.load_mint(keyset_id=load_keyset_id), op_name="redeem_load_mint", mint_url=token_obj.mint, ) @@ -376,11 +345,7 @@ async def _redeem_same_mint( ) from error raise - # token_obj.proofs rebuilds fresh Proof objects on every access, so - # capture it once and reuse below — otherwise _expand_short_keysets' - # in-place id mutation gets silently discarded. - proofs = token_obj.proofs - await _expand_short_keysets(wallet, proofs) + proofs = await normalize_token_proofs(wallet, token_obj) wallet.verify_proofs_dleq(proofs) input_fees = wallet.get_fees_for_proofs(proofs) @@ -1331,11 +1296,7 @@ async def swap_to_trusted_mint( ) return await _redeem_same_mint(token_wallet, token_obj) - # token_obj.proofs rebuilds fresh Proof objects on every access, so - # capture it once and reuse below — otherwise _expand_short_keysets' - # in-place id mutation gets silently discarded. - proofs = token_obj.proofs - await _expand_short_keysets(token_wallet, proofs) + proofs = await normalize_token_proofs(token_wallet, token_obj) primary_wallet: Wallet | None = None diff --git a/tests/unit/test_keyset_expansion.py b/tests/unit/test_keyset_expansion.py index c71e192a..4a979c62 100644 --- a/tests/unit/test_keyset_expansion.py +++ b/tests/unit/test_keyset_expansion.py @@ -7,8 +7,10 @@ the full id before any melt/swap, otherwise redemption fails with ``A short keyset ID v2 was encountered, but got no keysets to map it to`` (500 "Internal error during token redemption"). -These tests pin the behavior of :func:`routstr.wallet._expand_short_keysets` -and verify both redeem paths (same-mint split and cross-mint melt) invoke it. +These tests pin the behavior of +:class:`routstr.token_compat.ShortKeysetIdExpansion` and verify both redeem +paths (same-mint split and cross-mint melt) invoke it via +:func:`routstr.token_compat.normalize_token_proofs`. """ from types import SimpleNamespace @@ -18,6 +20,16 @@ import pytest from cashu.core.base import Proof from cashu.wallet.keyset_manager import KeysetManager +from routstr.token_compat import ShortKeysetIdExpansion + + +async def _expand_short_keysets(wallet, proofs) -> None: # type: ignore[no-untyped-def] + """Run the shim the way normalize_token_proofs does: applies() then apply().""" + shim = ShortKeysetIdExpansion() + if shim.applies(proofs): + await shim.apply(wallet, proofs) + + # Matches the live minibits mint: active v2 keyset and its 16-char short id. FULL_V2_ID = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821" SHORT_V2_ID = "01fc0ec0e59cd6fa" @@ -79,8 +91,6 @@ class _ExpandingWallet: @pytest.mark.asyncio async def test_expand_short_keysets_noop_for_v1_legacy_ids() -> None: """v1 (``00``) short ids are the full id — no expansion, no keyset load.""" - from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet({}, url="https://mint.example") proofs = [_proof(V1_LEGACY_ID)] await _expand_short_keysets(wallet, proofs) @@ -92,8 +102,6 @@ async def test_expand_short_keysets_noop_for_v1_legacy_ids() -> None: @pytest.mark.asyncio async def test_expand_short_keysets_noop_for_incomplete_test_proofs() -> None: """Proof-like test doubles without string ids are safely ignored.""" - from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet({}) await _expand_short_keysets(wallet, [Mock(), {"amount": 1}]) # type: ignore[list-item] assert wallet.load_keysets_called is False @@ -103,8 +111,6 @@ async def test_expand_short_keysets_noop_for_incomplete_test_proofs() -> None: @pytest.mark.asyncio async def test_expand_short_keysets_noop_for_full_v2_ids() -> None: """Already-full 66-char v2 ids are left untouched.""" - from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet({FULL_V2_ID: _keyset(FULL_V2_ID)}) proofs = [_proof(FULL_V2_ID)] await _expand_short_keysets(wallet, proofs) @@ -115,8 +121,6 @@ async def test_expand_short_keysets_noop_for_full_v2_ids() -> None: @pytest.mark.asyncio async def test_expand_short_keysets_expands_v2_short_to_full() -> None: """16-char ``01`` short id is expanded to the full 66-char id.""" - from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet( { FULL_V2_ID: _keyset(FULL_V2_ID), @@ -132,8 +136,6 @@ async def test_expand_short_keysets_expands_v2_short_to_full() -> None: @pytest.mark.asyncio async def test_expand_short_keysets_loads_keysets_when_empty() -> None: """When the wallet has no keysets loaded, load them before expansion.""" - from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet( {}, url="https://mint.example", @@ -148,8 +150,6 @@ async def test_expand_short_keysets_loads_keysets_when_empty() -> None: @pytest.mark.asyncio async def test_expand_short_keysets_propagates_keyset_load_failure() -> None: """Loading failures retain their original error instead of blaming the token.""" - from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet({}) load_error = RuntimeError("mint unavailable") with ( @@ -166,8 +166,6 @@ async def test_expand_short_keysets_propagates_keyset_load_failure() -> None: @pytest.mark.asyncio async def test_expand_short_keysets_refreshes_stale_keysets() -> None: """Retry with refreshed keysets when a populated cache cannot map the id.""" - from routstr.wallet import _expand_short_keysets - stale_id = "01" + "11" * 32 wallet = _ExpandingWallet( {stale_id: _keyset(stale_id)}, @@ -182,8 +180,6 @@ async def test_expand_short_keysets_refreshes_stale_keysets() -> None: @pytest.mark.asyncio async def test_expand_short_keysets_wraps_unresolvable_short_id() -> None: """A short id that can't be mapped surfaces as a clear ValueError.""" - from routstr.wallet import _expand_short_keysets - wallet = _ExpandingWallet({FULL_V2_ID: _keyset(FULL_V2_ID)}) stray = "0111111111111111" # 16-char short id with no matching keyset with pytest.raises(ValueError, match="cannot be mapped"): @@ -258,6 +254,21 @@ async def test_redeem_same_mint_expands_keysets_before_split() -> None: split_proofs = wallet.split.call_args.kwargs["proofs"] assert split_proofs[0].id == FULL_V2_ID assert token.proofs_access_count == 1 + # A short id can't be activated (mint keysets are keyed by full ids and + # load_mint swallows the KeysetNotFoundError, leaving keyset_id unset → + # split dies with "No active keyset"). load_mint must be asked for an + # active keyset of the wallet's unit instead. + assert wallet.load_mint.call_args.kwargs["keyset_id"] == "" + + +def test_is_short_v2_keyset_id() -> None: + from routstr.token_compat import is_short_v2_keyset_id + + assert is_short_v2_keyset_id(SHORT_V2_ID) is True + assert is_short_v2_keyset_id(FULL_V2_ID) is False # full v2 id + assert is_short_v2_keyset_id(V1_LEGACY_ID) is False # v1 short == full id + assert is_short_v2_keyset_id(None) is False + assert is_short_v2_keyset_id(16) is False @pytest.mark.asyncio From 8d6655f43f3652598d4ac04ea8b866a7d6e9034f Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 14 Aug 2026 00:07:17 +0200 Subject: [PATCH 7/7] clean up --- routstr/token_compat.py | 36 ++++++++++------------------ routstr/wallet.py | 17 +++++++++---- tests/unit/test_keyset_expansion.py | 7 ++---- tests/unit/test_wallet.py | 37 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 34 deletions(-) diff --git a/routstr/token_compat.py b/routstr/token_compat.py index c88cb27f..03529581 100644 --- a/routstr/token_compat.py +++ b/routstr/token_compat.py @@ -1,12 +1,7 @@ -"""Compatibility shims for incoming Cashu token proofs. +"""Compatibility shims applied to incoming token proofs before redemption. -Ecosystem migrations (NUT spec revisions, wallet-specific quirks) periodically -change the shape of tokens we receive. Each such fix is a -:class:`ProofCompatShim`; :func:`normalize_token_proofs` is the single entry -point the redeem/swap paths call before touching a token's proofs. - -To add a new shim: subclass :class:`ProofCompatShim` and append an instance to -:data:`PROOF_SHIMS`. +Add a shim by subclassing ProofCompatShim and appending an instance to +PROOF_SHIMS. """ from abc import ABC, abstractmethod @@ -21,11 +16,8 @@ if TYPE_CHECKING: def is_short_v2_keyset_id(keyset_id: object) -> bool: - """True for a 16-char NUT-02 v2 short keyset id (version byte ``01``). - - Short ids exist only inside tokens; mint keysets are keyed by the full - 66-char id, so a short id can never be activated or looked up directly. - """ + """16-char NUT-02 v2 short id (version byte ``01``). Mint keysets are + keyed by the full 66-char id, so short ids can't be looked up directly.""" return ( isinstance(keyset_id, str) and keyset_id.startswith("01") @@ -34,20 +26,17 @@ def is_short_v2_keyset_id(keyset_id: object) -> bool: class ProofCompatShim(ABC): - """One token-format compatibility fix applied to proofs in place.""" - @abstractmethod - def applies(self, proofs: list[Proof]) -> bool: - """Cheap check whether this shim is needed for these proofs.""" + def applies(self, proofs: list[Proof]) -> bool: ... @abstractmethod async def apply(self, wallet: "_CashuWallet", proofs: list[Proof]) -> None: - """Mutate ``proofs`` in place; may talk to the mint via ``wallet``.""" + """Fix ``proofs`` in place; may talk to the mint via ``wallet``.""" class ShortKeysetIdExpansion(ProofCompatShim): - """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to full - 66-char ids in place; the mint won't accept a short id on melt/swap.""" + """Expand short NUT-02 v2 keyset ids (e.g. minibits tokens) to the full + 66-char id; the mint rejects short ids on melt/swap.""" def applies(self, proofs: list[Proof]) -> bool: return any( @@ -89,11 +78,10 @@ PROOF_SHIMS: tuple[ProofCompatShim, ...] = (ShortKeysetIdExpansion(),) async def normalize_token_proofs( wallet: "_CashuWallet", token_obj: Token ) -> list[Proof]: - """Capture a token's proofs once and run every applicable compat shim. + """Run every applicable shim and return the proof list to use from here on. - ``token_obj.proofs`` rebuilds fresh Proof objects on every access, so - callers must use the returned list — re-reading ``token_obj.proofs`` after - this call silently discards the shims' in-place mutations. + token_obj.proofs rebuilds fresh Proof objects on every access — callers + must use the returned list or the in-place fixes are lost. """ proofs = token_obj.proofs for shim in PROOF_SHIMS: diff --git a/routstr/wallet.py b/routstr/wallet.py index 51eb1375..7867e758 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -133,6 +133,16 @@ class Wallet(_CashuWallet): ) _CashuWallet.raise_on_error_request(resp) + async def load_mint( + self, keyset_id: str = "", force_old_keysets: bool = False + ) -> None: + """Upstream load_mint minus its blanket ``except Exception: pass`` — + the swallow hides 429/transport failures and leaves the wallet + keyset-less, so redemption later dies with "No active keyset".""" + await self.load_mint_keysets(force_old_keysets) + await self.activate_keyset(keyset_id) + await self.load_mint_info(reload=True) + class MintConnectionError(Exception): """The mint could not be reached (network transport failure). @@ -311,11 +321,8 @@ async def _redeem_same_mint( that, not the face value, or routstr over-credits the user and its wallet drifts insolvent. """ - # A short NUT-02 v2 keyset id (e.g. minibits tokens) can't be activated: - # mint keysets are keyed by full ids, activate_keyset silently fails inside - # load_mint, and split() later dies with "No active keyset". Let load_mint - # pick an active keyset for the wallet's unit instead; the proofs' short - # ids are expanded via normalize_token_proofs below. + # A short v2 id can't be activated (keysets are keyed by full ids); let + # load_mint pick an active keyset and expand the proofs' ids below. load_keyset_id = token_obj.keysets[0] if is_short_v2_keyset_id(load_keyset_id): load_keyset_id = "" diff --git a/tests/unit/test_keyset_expansion.py b/tests/unit/test_keyset_expansion.py index 4a979c62..017b8872 100644 --- a/tests/unit/test_keyset_expansion.py +++ b/tests/unit/test_keyset_expansion.py @@ -24,7 +24,7 @@ from routstr.token_compat import ShortKeysetIdExpansion async def _expand_short_keysets(wallet, proofs) -> None: # type: ignore[no-untyped-def] - """Run the shim the way normalize_token_proofs does: applies() then apply().""" + # Run the shim the way normalize_token_proofs does. shim = ShortKeysetIdExpansion() if shim.applies(proofs): await shim.apply(wallet, proofs) @@ -254,10 +254,7 @@ async def test_redeem_same_mint_expands_keysets_before_split() -> None: split_proofs = wallet.split.call_args.kwargs["proofs"] assert split_proofs[0].id == FULL_V2_ID assert token.proofs_access_count == 1 - # A short id can't be activated (mint keysets are keyed by full ids and - # load_mint swallows the KeysetNotFoundError, leaving keyset_id unset → - # split dies with "No active keyset"). load_mint must be asked for an - # active keyset of the wallet's unit instead. + # Short ids can't be activated; load_mint must pick an active keyset. assert wallet.load_mint.call_args.kwargs["keyset_id"] == "" diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index c8cecc55..da228c1b 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -2927,3 +2927,40 @@ async def test_payout_reloads_wallet_snapshot_under_guard() -> None: mock_get_wallet.assert_awaited_once_with( "https://mint.example.com", "sat", force_reload=True ) + + +@pytest.mark.asyncio +async def test_load_mint_propagates_rate_limit() -> None: + """Unlike upstream, our load_mint must not swallow a 429 — that leaves the + wallet keyset-less and split() dies with "No active keyset".""" + from routstr.mint import MintRateLimitedError + from routstr.wallet import Wallet + + wallet = Wallet.__new__(Wallet) + error = MintRateLimitedError( + "Cashu mint rate limited", + request=httpx.Request("GET", "https://mint.example/v1/keysets"), + response=httpx.Response(429), + ) + with ( + patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)), + pytest.raises(MintRateLimitedError), + ): + await wallet.load_mint() + + +@pytest.mark.asyncio +async def test_load_mint_runs_keysets_activation_and_info() -> None: + from routstr.wallet import Wallet + + wallet = Wallet.__new__(Wallet) + with ( + patch.object(wallet, "load_mint_keysets", new=AsyncMock()) as load_keysets, + patch.object(wallet, "activate_keyset", new=AsyncMock()) as activate, + patch.object(wallet, "load_mint_info", new=AsyncMock()) as load_info, + ): + await wallet.load_mint(keyset_id="abc") + + load_keysets.assert_awaited_once_with(False) + activate.assert_awaited_once_with("abc") + load_info.assert_awaited_once_with(reload=True)