diff --git a/routstr/core/settings.py b/routstr/core/settings.py index b947599b..d3df46d8 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -133,7 +133,7 @@ class Settings(BaseSettings): database_pool_size: int = Field(default=10, ge=1, env="DATABASE_POOL_SIZE") database_max_overflow: int = Field(default=20, ge=0, env="DATABASE_MAX_OVERFLOW") database_pool_timeout: float = Field( - default=15.0, gt=0, env="DATABASE_POOL_TIMEOUT" + default=30.0, gt=0, env="DATABASE_POOL_TIMEOUT" ) database_pool_recycle: int = Field(default=1800, ge=0, env="DATABASE_POOL_RECYCLE") database_pool_pre_ping: bool = Field(default=False, env="DATABASE_POOL_PRE_PING") diff --git a/routstr/token_compat.py b/routstr/token_compat.py new file mode 100644 index 00000000..03529581 --- /dev/null +++ b/routstr/token_compat.py @@ -0,0 +1,90 @@ +"""Compatibility shims applied to incoming token proofs before redemption. + +Add a shim by subclassing ProofCompatShim and appending an instance to +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: + """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") + and len(keyset_id) == 16 + ) + + +class ProofCompatShim(ABC): + @abstractmethod + def applies(self, proofs: list[Proof]) -> bool: ... + + @abstractmethod + async def apply(self, wallet: "_CashuWallet", proofs: list[Proof]) -> None: + """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 the full + 66-char id; the mint rejects short ids 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]: + """Run every applicable shim and return the proof list to use from here on. + + 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: + if shim.applies(proofs): + await shim.apply(wallet, proofs) + return proofs diff --git a/routstr/wallet.py b/routstr/wallet.py index 3ab20c78..7867e758 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. @@ -132,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). @@ -310,9 +321,14 @@ async def _redeem_same_mint( that, not the face value, or routstr over-credits the user and its wallet drifts insolvent. """ + # 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 = "" 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, ) @@ -336,11 +352,13 @@ 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) + proofs = await normalize_token_proofs(wallet, token_obj) + + 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 +1303,8 @@ async def swap_to_trusted_mint( ) return await _redeem_same_mint(token_wallet, token_obj) + proofs = await normalize_token_proofs(token_wallet, token_obj) + primary_wallet: Wallet | None = None minted_amount = await _calculate_swap_amount( @@ -1293,7 +1313,7 @@ async def swap_to_trusted_mint( token_obj.mint, token_wallet, primary_wallet, - token_obj.proofs, + proofs, destination_candidates, ) @@ -1372,7 +1392,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 +1446,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 +1456,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 +1469,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..017b8872 --- /dev/null +++ b/tests/unit/test_keyset_expansion.py @@ -0,0 +1,330 @@ +"""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 +: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 +from unittest.mock import AsyncMock, Mock, patch + +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. + 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" + +# Legacy v1 short id (16 hex, version byte 00) == the full id (no expansion). +V1_LEGACY_ID = "00107937db0cc865" + + +def _keyset(full_id: str) -> SimpleNamespace: + """Construct a fake keyset object carrying the given full id.""" + return SimpleNamespace(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: 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_keysets_called = False + self.expand_called = False + + 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 + + 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: + 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() -> None: + """v1 (``00``) short ids are the full id — no expansion, no keyset load.""" + 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_keysets_called is False + 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.""" + 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.""" + 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_keysets_called is False + + +@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.""" + 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() -> None: + """When the wallet has no keysets loaded, load them before expansion.""" + 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_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.""" + 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.""" + 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_keysets_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.""" + 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: str, + unit: str, + amount: int, + keysets: list[str], + keyset_id: str, + proof_amount: int, + ) -> None: + 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) -> list[Proof]: + self.proofs_access_count += 1 + return [_proof(self._keyset_id, amount=self._proof_amount)] + + +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() -> 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 + + 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 + # Short ids can't be activated; load_mint must pick an active keyset. + 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 +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 + + 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 diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 34fa7101..23834665 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -66,7 +66,7 @@ def test_database_pool_defaults_provide_concurrency_headroom() -> None: s = Settings() assert s.database_pool_size == 10 assert s.database_max_overflow == 20 - assert s.database_pool_timeout == 15.0 + assert s.database_pool_timeout == 30.0 assert s.database_pool_recycle == 1800 assert s.database_pool_pre_ping is False assert s.database_pool_hold_warn_seconds == 10.0 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)