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)