mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
Merge branch 'test-v2-cashu' into fix-db-exaustion
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user