Merge branch 'test-v2-cashu' into fix-db-exaustion

This commit is contained in:
9qeklajc
2026-08-14 17:19:37 +02:00
6 changed files with 488 additions and 11 deletions
+1 -1
View File
@@ -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")
+90
View File
@@ -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
+29 -9
View File
@@ -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",
+330
View File
@@ -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
+1 -1
View File
@@ -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
+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)