fix: harden wallet and Cashu operations

This commit is contained in:
thefux
2026-08-24 23:20:49 +00:00
parent f4645bde59
commit f6e71e1512
13 changed files with 381 additions and 136 deletions
+6 -18
View File
@@ -1,4 +1,3 @@
import asyncio
import json
import re
import secrets
@@ -1368,12 +1367,9 @@ async def initiate_provider_topup(
else {}
)
last_status_code = 500
last_error_detail: object = "Failed to create top-up invoice"
# Some upstream Routstr nodes fail the first invoice request after warm-up
# and succeed immediately on retry. Retry once here so the UI stays single-click.
for attempt in range(2):
# This POST creates a Cashu mint quote upstream. Without an
# idempotency key, retrying a timeout or 5xx can create a
# second invoice while abandoning the first.
resp = await client.post(
f"{clean_url}/v1/balance/lightning/invoice",
json=request_json,
@@ -1395,23 +1391,15 @@ async def initiate_provider_topup(
f"Upstream topup request failed: {resp.text}",
extra={
"provider_id": provider_id,
"attempt": attempt + 1,
"status_code": resp.status_code,
},
)
try:
last_error_detail = resp.json()
error_detail: object = resp.json()
except Exception:
last_error_detail = resp.text
last_status_code = resp.status_code
if resp.status_code < 500 or attempt == 1:
break
await asyncio.sleep(0.2)
error_detail = resp.text
raise HTTPException(
status_code=last_status_code, detail=last_error_detail
status_code=resp.status_code, detail=error_detail
)
upstream_instance = _instantiate_provider(provider)
+12 -1
View File
@@ -37,6 +37,14 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
is_memory_sqlite = is_sqlite and url.database in {None, "", ":memory:"}
pool_pre_ping = settings.database_pool_pre_ping or not is_sqlite
options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping}
connect_args: dict[str, object] = {}
if is_sqlite and not is_memory_sqlite:
# SQLite's default busy_timeout is only 5s, and aiosqlite does not set
# one of its own. Without this, concurrent payment-settlement writes
# across the pooled engine wait just 5s, then raise
# sqlite3.OperationalError: database is locked. Give writers a real
# chance to acquire the single SQLite write lock.
connect_args["timeout"] = settings.database_busy_timeout
if not is_memory_sqlite:
options.update(
pool_size=settings.database_pool_size,
@@ -51,9 +59,12 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
"database_url_backend": backend,
"in_memory_sqlite": is_memory_sqlite,
**options,
"connect_args": connect_args,
},
)
created_engine = create_async_engine(database_url, echo=False, **options)
created_engine = create_async_engine(
database_url, echo=False, connect_args=connect_args, **options
)
hold_warn_seconds = settings.database_pool_hold_warn_seconds
def record_pool_checkout(
+8
View File
@@ -148,6 +148,13 @@ class Settings(BaseSettings):
database_pool_hold_warn_seconds: float = Field(
default=10.0, gt=0, env="DATABASE_POOL_HOLD_WARN_SECONDS"
)
# SQLite busy_timeout (seconds): how long a writer waits on a locked DB
# before raising "database is locked". Defaults to SQLite's 5s in stock
# aiosqlite; raise it so concurrent payment-settlement writes can queue
# instead of erroring. Referenced only by create_db_engine for SQLite.
database_busy_timeout: float = Field(
default=30.0, gt=0, env="DATABASE_BUSY_TIMEOUT"
)
# Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL")
@@ -209,6 +216,7 @@ ENV_ONLY_FIELDS = frozenset(
"database_pool_recycle",
"database_pool_pre_ping",
"database_pool_hold_warn_seconds",
"database_busy_timeout",
}
)
+12 -2
View File
@@ -215,11 +215,19 @@ async def _request_mint_with_fallback(
)
continue
try:
wallet = await get_wallet(mint_url, "sat", retry_on_rate_limit=False)
wallet = await get_wallet(
mint_url,
"sat",
retry_on_rate_limit=False,
load_proofs=False,
)
quote = await run_mint_operation(
lambda: wallet.request_mint(amount_sats),
op_name="request_mint_invoice",
mint_url=mint_url,
# Response loss may leave a valid quote at the mint. Creating a
# second quote is not a safe retry without an idempotency key.
retry_timeouts=False,
retry_on_rate_limit=False,
)
return quote.request, quote.quote, mint_url
@@ -471,7 +479,9 @@ async def check_invoice_payment(
await session.commit()
mint_url = settlement.mint_url or settings.primary_mint
wallet = await get_wallet(mint_url, "sat")
# Quote status is remote state and does not inspect local proofs.
# _mint_invoice_quote loads proofs exactly when settlement needs them.
wallet = await get_wallet(mint_url, "sat", load_proofs=False)
try:
mint_status = await run_mint_operation(
lambda: wallet.get_mint_quote(settlement.payment_hash),
+32 -1
View File
@@ -107,6 +107,18 @@ async def _fetch_lnurl_json(
return data
def _contains_mint_transport_error(error: BaseException) -> bool:
"""Detect transport failures wrapped by the Cashu wallet implementation."""
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen:
seen.add(id(current))
if isinstance(current, MINT_TRANSPORT_EXCEPTIONS):
return True
current = current.__cause__ or current.__context__
return False
async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL.
@@ -293,6 +305,8 @@ async def raw_send_to_lnurl(
lambda: wallet.melt_quote(invoice=bolt11_invoice),
op_name="lnurl_melt_quote",
mint_url=str(wallet.url),
# Creating another quote after response loss only abandons the first.
retry_timeouts=False,
)
# The invoice comes from the LNURL service, so its amount is untrusted. The
@@ -331,8 +345,21 @@ async def raw_send_to_lnurl(
# reserved as though a Lightning payment could still settle.
await wallet.set_reserved_for_send(proofs, reserved=False)
raise
if not isinstance(error, MINT_TRANSPORT_EXCEPTIONS):
if not _contains_mint_transport_error(error):
raise
# Cashu 0.20 clears melt reservations before wrapping transport errors
# in a plain Exception. Restore the durable melt association before
# asking for quote state so these proofs cannot be spent again while
# the Lightning outcome is unknown.
try:
await wallet.set_reserved_for_melt(
proofs, reserved=True, quote_id=melt_quote_resp.quote
)
except Exception as reservation_error:
raise MeltOutcomeAmbiguousError(
"Melt outcome is ambiguous and its proof reservation could not "
"be restored; proofs must not be retried"
) from reservation_error
melt_response = None
melt_error: BaseException | None = error
else:
@@ -356,6 +383,10 @@ async def raw_send_to_lnurl(
if quote is not None and quote.state == MeltQuoteState.paid:
return final_amount
if quote is not None and quote.state == MeltQuoteState.unpaid:
# get_melt_quote() has authoritatively released the melt reservation;
# callers may restore their debit and retry with a new payment plan.
raise LNURLError("Cashu mint confirmed that the melt was unpaid") from melt_error
state = getattr(getattr(quote, "state", None), "value", "unknown")
raise MeltOutcomeAmbiguousError(
+9 -32
View File
@@ -3628,54 +3628,32 @@ class BaseUpstreamProvider:
extra={"amount": amount, "unit": unit, "mint": mint},
)
max_retries = 3
last_exception = None
refund_token = None
for attempt in range(max_retries):
try:
# send_token may perform an irreversible Cashu swap to make exact
# denominations. A blanket retry after response loss can dispatch
# a second swap, so this call is intentionally single-attempt.
refund_token = await send_token(amount, unit=unit, mint_url=mint)
break
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
logger.warning(
"Refund token creation failed, retrying",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
else:
except Exception as error:
logger.error(
"Failed to create refund token after all retries",
"Failed to create refund token",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"error": str(error),
"error_type": type(error).__name__,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
if refund_token is None:
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
"message": f"failed to create refund: {error}",
"type": "invalid_request_error",
"code": "send_token_failed",
}
},
)
) from error
logger.info(
"Refund token created successfully",
@@ -3683,7 +3661,6 @@ class BaseUpstreamProvider:
"amount": amount,
"unit": unit,
"mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
+78 -16
View File
@@ -14,6 +14,7 @@ from typing import AsyncGenerator, TypedDict
import httpx
from cashu.core.base import MeltQuote, MeltQuoteState, MintQuote, Proof, Token
from cashu.core.mint_info import MintInfo as _CashuMintInfo
from cashu.wallet.crud import get_keysets as get_cashu_keysets
from cashu.wallet.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet as _CashuWallet
from pydantic_core import PydanticUndefined
@@ -121,6 +122,12 @@ def _mints_to_inspect() -> list[str]:
return mint_urls
_WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS = 30
_WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS = 300
_mint_metadata_last_load: dict[str, float] = {}
_mint_metadata_load_locks: dict[str, asyncio.Lock] = {}
class Wallet(_CashuWallet):
"""Cashu adapter that preserves HTTP 429 for Routstr's mint policy."""
@@ -141,11 +148,37 @@ class Wallet(_CashuWallet):
_CashuWallet.raise_on_error_request(resp)
async def load_mint(
self, keyset_id: str = "", force_old_keysets: bool = False
self,
keyset_id: str = "",
force_old_keysets: bool = False,
*,
force_refresh: bool = False,
) -> None:
"""Load metadata once per mint URL, then hydrate unit wallets locally."""
mint_url = str(self.url)
lock = _mint_metadata_load_locks.setdefault(mint_url, asyncio.Lock())
async with lock:
now = time.monotonic()
last = _mint_metadata_last_load.get(mint_url)
if (
not force_refresh
and last is not None
and now - last < _WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS
):
try:
await self.load_keysets_from_db()
await self.activate_keyset(keyset_id)
await self.load_mint_info(reload=False)
return
except Exception:
# An empty/stale local cache is not authoritative. Fall
# through to one remote refresh under the per-mint lock.
pass
await self.load_mint_keysets(force_old_keysets)
await self.activate_keyset(keyset_id)
await self.load_mint_info(reload=True)
_mint_metadata_last_load[mint_url] = time.monotonic()
class MintConnectionError(Exception):
@@ -1048,6 +1081,7 @@ async def _request_mint_with_fallback(
mint_url,
settings.primary_mint_unit,
retry_on_rate_limit=False,
load_proofs=False,
)
quote = await run_mint_operation(
lambda: wallet.request_mint(amount),
@@ -1179,6 +1213,7 @@ async def _calculate_swap_amount(
lambda: token_wallet.melt_quote(dummy_mint_quote.request),
op_name="swap_fee_est_melt_quote",
mint_url=token_mint_url,
retry_timeouts=False,
)
fee_reserve = dummy_melt_quote.fee_reserve
@@ -1416,6 +1451,7 @@ async def swap_to_trusted_mint(
lambda: token_wallet.melt_quote(mint_quote.request),
op_name="swap_melt_quote",
mint_url=token_obj.mint,
retry_timeouts=False,
)
except Exception as error:
if is_mint_connection_error(error):
@@ -1796,13 +1832,13 @@ async def _credit_balance_locked(
_wallets: dict[str, Wallet] = {}
# Proofs are local SQLite state and need a short refresh window because another
# worker process can reserve or spend them. Mint metadata is remote, shared by
# every operation on a wallet, and changes far less often; refreshing it on the
# proof cadence caused repeated /keysets, /keys, and /info requests.
_wallet_last_load: dict[str, float] = {}
_wallet_last_mint_load: dict[str, float] = {}
_wallet_load_locks: dict[str, asyncio.Lock] = {}
# Minimum seconds between full mint info + proof reloads for the same
# wallet. Prevents redundant mint API calls when get_wallet(load=True)
# is called rapidly by multiple background tasks (balance fetch, payout,
# auto-topup all hitting get_wallet within the same cycle).
_WALLOAD_RELOAD_MIN_INTERVAL_SECONDS = 30
async def get_wallet(
@@ -1811,8 +1847,16 @@ async def get_wallet(
load: bool = True,
retry_on_rate_limit: bool = True,
force_reload: bool = False,
load_proofs: bool = True,
) -> Wallet:
global _wallets, _wallet_last_load, _wallet_load_locks
"""Return a cached wallet, refreshing remote and local state independently.
``load=False`` remains the fully offline path. Quote-only callers can use
``load_proofs=False``: mint metadata is initialized when needed, but local
proofs are not re-read when the operation cannot spend or inspect them.
``force_reload`` still refreshes every requested layer immediately.
"""
global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks
id = f"{mint_url}_{unit}"
lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
async with lock:
@@ -1821,18 +1865,33 @@ async def get_wallet(
if load:
now = time.monotonic()
last = _wallet_last_load.get(id)
last_mint_load = _wallet_last_mint_load.get(id)
if (
force_reload
or last is None
or now - last >= _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS
or last_mint_load is None
or now - last_mint_load >= _WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS
):
await run_mint_operation(
lambda: _wallets[id].load_mint(),
lambda: (
_wallets[id].load_mint(force_refresh=True)
if force_reload
else _wallets[id].load_mint()
),
op_name="load_mint",
mint_url=mint_url,
retry_on_rate_limit=retry_on_rate_limit,
)
_wallet_last_mint_load[id] = time.monotonic()
if load_proofs:
last_proof_load = _wallet_last_load.get(id)
if (
force_reload
or last_proof_load is None
or now - last_proof_load
>= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
):
# cashu's load_proofs is local SQLite I/O, not a mint call.
await run_mint_operation(
lambda: _wallets[id].load_proofs(reload=True),
op_name="load_proofs",
@@ -1912,13 +1971,16 @@ async def _get_supported_mint_units(mint_url: str) -> list[str]:
if cached is not None and now < cached[0]:
return cached[1]
wallet = await get_wallet(mint_url, settings.primary_mint_unit, load=False)
keysets = await run_mint_operation(
lambda: wallet._get_keysets(),
op_name="get_mint_keysets",
mint_url=mint_url,
# One full remote metadata load populates Cashu's shared SQLite keyset
# cache. Discover all advertised units from that cache instead of issuing a
# separate /keysets request before each unit wallet loads.
wallet = await get_wallet(
mint_url,
settings.primary_mint_unit,
retry_on_rate_limit=False,
load_proofs=False,
)
keysets = await get_cashu_keysets(mint_url=wallet.url, db=wallet.db)
units: list[str] = []
for keyset in keysets:
if not keyset.active or keyset.unit is None:
+4 -17
View File
@@ -686,7 +686,7 @@ async def test_no_database_changes_during_provider_operations(
@pytest.mark.integration
@pytest.mark.asyncio
async def test_admin_routstr_topup_retries_transient_upstream_failure(
async def test_admin_routstr_topup_does_not_duplicate_invoice_on_upstream_failure(
integration_client: AsyncClient,
integration_session: Any,
) -> None:
@@ -739,16 +739,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure(
assert json["api_key"] == "sk-upstream-test"
assert headers["Authorization"] == "Bearer sk-upstream-test"
if self.calls == 1:
return MockResponse(500, {"detail": "warmup failure"})
return MockResponse(
200,
{
"bolt11": "lnbc1testinvoice",
"invoice_id": "invoice-123",
},
)
return MockResponse(500, {"detail": "ambiguous upstream failure"})
mock_client = MockAsyncClient()
@@ -759,11 +750,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure(
json={"amount": 10},
)
assert response.status_code == 200
data = response.json()
assert data["ok"] is True
assert data["topup_data"]["payment_request"] == "lnbc1testinvoice"
assert data["topup_data"]["invoice_id"] == "invoice-123"
assert mock_client.calls == 2
assert response.status_code == 500
assert mock_client.calls == 1
finally:
admin_sessions.pop(admin_token, None)
+4 -3
View File
@@ -136,19 +136,20 @@ async def test_supported_mint_units_come_from_active_keysets() -> None:
msat = MagicMock(active=False, unit="msat")
usd = MagicMock(active=True)
usd.unit.name = "usd"
wallet = MagicMock()
wallet._get_keysets = AsyncMock(return_value=[usd, msat, sat])
wallet = MagicMock(url="http://mint:3338", db=MagicMock())
get_keysets = AsyncMock(return_value=[usd, msat, sat])
with (
patch.object(settings, "primary_mint_unit", "sat"),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.wallet.get_cashu_keysets", get_keysets),
):
units = await _get_supported_mint_units("http://mint:3338")
cached_units = await _get_supported_mint_units("http://mint:3338")
assert units == ["sat", "usd"]
assert cached_units == units
wallet._get_keysets.assert_awaited_once()
get_keysets.assert_awaited_once_with(mint_url=wallet.url, db=wallet.db)
@pytest.mark.asyncio
+63 -3
View File
@@ -11,6 +11,7 @@ from cashu.core.base import MeltQuoteState
from routstr.core.settings import settings
from routstr.mint import MintCooldownError, MintRateGuard
from routstr.payment.lnurl import (
LNURLError,
MeltOutcomeAmbiguousError,
raw_send_to_lnurl,
)
@@ -32,7 +33,10 @@ def _wallet() -> tuple[MagicMock, list[MagicMock]]:
wallet.melt_quote = AsyncMock(
return_value=MagicMock(fee_reserve=1, quote="q", amount=QUOTE_AMOUNT_SAT)
)
wallet.melt = AsyncMock()
wallet.select_to_send = AsyncMock(return_value=(proofs, None))
wallet.set_reserved_for_melt = AsyncMock()
wallet.set_reserved_for_send = AsyncMock()
return wallet, proofs
@@ -50,7 +54,7 @@ def _lnurl_patches() -> tuple[Any, Any]:
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_timeout_keeps_unpaid_outcome_ambiguous() -> None:
async def test_raw_send_to_lnurl_timeout_reconciled_unpaid_is_retry_safe() -> None:
wallet, proofs = _wallet()
async def _hang(**kwargs: object) -> None:
@@ -67,12 +71,65 @@ async def test_raw_send_to_lnurl_timeout_keeps_unpaid_outcome_ambiguous() -> Non
patch.object(settings, "mint_retry_max_attempts", 0),
data_patch,
invoice_patch,
pytest.raises(MeltOutcomeAmbiguousError, match="outcome is ambiguous"),
pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised,
):
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
assert not isinstance(raised.value, MeltOutcomeAmbiguousError)
wallet.get_melt_quote.assert_awaited_once_with("q")
wallet.set_reserved_for_melt.assert_not_called()
wallet.set_reserved_for_melt.assert_awaited_once_with(
proofs, reserved=True, quote_id="q"
)
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_wrapped_transport_error_is_reconciled_once() -> None:
wallet, proofs = _wallet()
async def _wrapped_transport_error(**kwargs: object) -> None:
try:
raise httpx.ReadTimeout("response lost")
except httpx.ReadTimeout as transport_error:
raise Exception("could not pay invoice") from transport_error
wallet.melt = AsyncMock(side_effect=_wrapped_transport_error)
wallet.get_melt_quote = AsyncMock(
return_value=MagicMock(state=MeltQuoteState.unpaid)
)
data_patch, invoice_patch = _lnurl_patches()
with (
patch.object(settings, "mint_retry_max_attempts", 3),
data_patch,
invoice_patch,
pytest.raises(LNURLError, match="confirmed that the melt was unpaid") as raised,
):
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
assert not isinstance(raised.value, MeltOutcomeAmbiguousError)
wallet.melt.assert_awaited_once()
wallet.get_melt_quote.assert_awaited_once_with("q")
wallet.set_reserved_for_melt.assert_awaited_once_with(
proofs, reserved=True, quote_id="q"
)
@pytest.mark.asyncio
async def test_raw_send_to_lnurl_does_not_retry_melt_quote_timeout() -> None:
wallet, proofs = _wallet()
wallet.melt_quote = AsyncMock(side_effect=httpx.ReadTimeout("response lost"))
data_patch, invoice_patch = _lnurl_patches()
with (
patch.object(settings, "mint_retry_max_attempts", 3),
data_patch,
invoice_patch,
pytest.raises(httpx.TimeoutException),
):
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
wallet.melt_quote.assert_awaited_once()
wallet.melt.assert_not_awaited()
@pytest.mark.asyncio
@@ -98,6 +155,9 @@ async def test_raw_send_to_lnurl_timeout_reconciled_paid_is_success() -> None:
assert paid > 0
wallet.get_melt_quote.assert_awaited_once_with("q")
wallet.set_reserved_for_melt.assert_awaited_once_with(
proofs, reserved=True, quote_id="q"
)
@pytest.mark.asyncio
+19 -1
View File
@@ -1,7 +1,8 @@
"""Persisted mint preferences must not bypass the configured trusted set."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from routstr.core.settings import settings
@@ -31,6 +32,23 @@ async def test_untrusted_allowed_mints_fall_back_to_trusted_set() -> None:
assert attempted == [TRUSTED]
async def test_mint_quote_timeout_is_not_retried() -> None:
wallet = MagicMock()
wallet.request_mint = AsyncMock(side_effect=httpx.ReadTimeout("response lost"))
with (
patch.object(settings, "primary_mint", TRUSTED),
patch.object(settings, "cashu_mints", [TRUSTED]),
patch.object(settings, "mint_retry_max_attempts", 3),
patch("routstr.lightning.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.lightning.mint_cooldown_remaining", return_value=0.0),
pytest.raises(Exception),
):
await _request_mint_with_fallback(10)
wallet.request_mint.assert_awaited_once_with(10)
async def test_trusted_allowed_mints_are_used_verbatim() -> None:
attempted: list[str] = []
+26
View File
@@ -0,0 +1,26 @@
"""Refund token issuance must not repeat an ambiguous Cashu swap."""
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from fastapi import HTTPException
from routstr.upstream.base import BaseUpstreamProvider
@pytest.mark.asyncio
async def test_send_refund_does_not_retry_ambiguous_token_creation() -> None:
provider = object.__new__(BaseUpstreamProvider)
send_token = AsyncMock(side_effect=httpx.ReadTimeout("swap response lost"))
with (
patch("routstr.upstream.base.send_token", send_token),
pytest.raises(HTTPException) as raised,
):
await provider.send_refund(10, "sat", mint="https://mint.test")
assert raised.value.status_code == 401
send_token.assert_awaited_once_with(
10, unit="sat", mint_url="https://mint.test"
)
+66
View File
@@ -40,13 +40,19 @@ def isolate_wallet_runtime_state() -> Generator[None, None, None]:
wallet_module._MintRateGuard._guards.clear()
wallet_module._wallets.clear()
wallet_module._wallet_last_load.clear()
wallet_module._wallet_last_mint_load.clear()
wallet_module._wallet_load_locks.clear()
wallet_module._mint_metadata_last_load.clear()
wallet_module._mint_metadata_load_locks.clear()
yield
settings.mint_max_concurrency = original_concurrency
wallet_module._MintRateGuard._guards.clear()
wallet_module._wallets.clear()
wallet_module._wallet_last_load.clear()
wallet_module._wallet_last_mint_load.clear()
wallet_module._wallet_load_locks.clear()
wallet_module._mint_metadata_last_load.clear()
wallet_module._mint_metadata_load_locks.clear()
@pytest.mark.asyncio
@@ -66,6 +72,63 @@ async def test_get_balance() -> None:
assert balance == 50000
@pytest.mark.asyncio
async def test_wallet_metadata_is_reused_across_units() -> None:
from routstr.wallet import Wallet
sat_wallet = MagicMock(url="http://mint:3338")
sat_wallet.load_mint_keysets = AsyncMock()
sat_wallet.activate_keyset = AsyncMock()
sat_wallet.load_mint_info = AsyncMock()
sat_wallet.load_keysets_from_db = AsyncMock()
msat_wallet = MagicMock(url="http://mint:3338")
msat_wallet.load_mint_keysets = AsyncMock()
msat_wallet.activate_keyset = AsyncMock()
msat_wallet.load_mint_info = AsyncMock()
msat_wallet.load_keysets_from_db = AsyncMock()
with patch("routstr.wallet.time.monotonic", return_value=1000.0):
await Wallet.load_mint(sat_wallet)
await Wallet.load_mint(msat_wallet)
sat_wallet.load_mint_keysets.assert_awaited_once_with(False)
sat_wallet.load_mint_info.assert_awaited_once_with(reload=True)
msat_wallet.load_mint_keysets.assert_not_awaited()
msat_wallet.load_keysets_from_db.assert_awaited_once_with()
msat_wallet.load_mint_info.assert_awaited_once_with(reload=False)
@pytest.mark.asyncio
async def test_get_wallet_refreshes_local_proofs_without_reloading_mint() -> None:
from routstr import wallet as wallet_module
from routstr.wallet import get_wallet
mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock())
with (
patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)),
patch("routstr.wallet.time.monotonic", return_value=1000.0),
):
await get_wallet("http://mint:3338", "sat")
wallet_module._wallet_last_load["http://mint:3338_sat"] = 900.0
await get_wallet("http://mint:3338", "sat")
assert mock_wallet.load_mint.await_count == 1
assert mock_wallet.load_proofs.await_count == 2
@pytest.mark.asyncio
async def test_get_wallet_quote_only_skips_proof_reload() -> None:
from routstr.wallet import get_wallet
mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock())
with patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)):
await get_wallet("http://mint:3338", "sat", load_proofs=False)
mock_wallet.load_mint.assert_awaited_once_with()
mock_wallet.load_proofs.assert_not_awaited()
@pytest.mark.asyncio
async def test_get_wallet_force_reload_bypasses_reload_interval() -> None:
from routstr.wallet import get_wallet
@@ -2970,6 +3033,7 @@ async def test_load_mint_propagates_rate_limit() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
wallet.url = "https://rate-limited-mint.example"
error = MintRateLimitedError(
"Cashu mint rate limited",
request=httpx.Request("GET", "https://mint.example/v1/keysets"),
@@ -2987,6 +3051,7 @@ async def test_load_mint_propagates_connection_error() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
wallet.url = "https://unavailable-mint.example"
error = httpx.ConnectError("mint unavailable")
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)),
@@ -3002,6 +3067,7 @@ async def test_load_mint_runs_keysets_activation_and_info() -> None:
from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet)
wallet.url = "https://mint-load.example"
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock()) as load_keysets,
patch.object(wallet, "activate_keyset", new=AsyncMock()) as activate,