This commit is contained in:
9qeklajc
2026-08-14 00:07:17 +02:00
parent d6666ca16d
commit 8d6655f43f
4 changed files with 63 additions and 34 deletions
+12 -24
View File
@@ -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:
+12 -5
View File
@@ -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 = ""
+2 -5
View File
@@ -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"] == ""
+37
View File
@@ -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)