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
+29 -41
View File
@@ -1,4 +1,3 @@
import asyncio
import json import json
import re import re
import secrets import secrets
@@ -1368,50 +1367,39 @@ async def initiate_provider_topup(
else {} else {}
) )
last_status_code = 500 # This POST creates a Cashu mint quote upstream. Without an
last_error_detail: object = "Failed to create top-up invoice" # 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,
headers=headers,
)
# Some upstream Routstr nodes fail the first invoice request after warm-up if resp.status_code == 200:
# and succeed immediately on retry. Retry once here so the UI stays single-click. data = resp.json()
for attempt in range(2): return {
resp = await client.post( "ok": True,
f"{clean_url}/v1/balance/lightning/invoice", "topup_data": {
json=request_json, "payment_request": data.get("bolt11"),
headers=headers, "invoice_id": data.get("invoice_id"),
) "status": "pending",
if resp.status_code == 200:
data = resp.json()
return {
"ok": True,
"topup_data": {
"payment_request": data.get("bolt11"),
"invoice_id": data.get("invoice_id"),
"status": "pending",
},
}
logger.error(
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()
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)
logger.error(
f"Upstream topup request failed: {resp.text}",
extra={
"provider_id": provider_id,
"status_code": resp.status_code,
},
)
try:
error_detail: object = resp.json()
except Exception:
error_detail = resp.text
raise HTTPException( raise HTTPException(
status_code=last_status_code, detail=last_error_detail status_code=resp.status_code, detail=error_detail
) )
upstream_instance = _instantiate_provider(provider) 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:"} is_memory_sqlite = is_sqlite and url.database in {None, "", ":memory:"}
pool_pre_ping = settings.database_pool_pre_ping or not is_sqlite pool_pre_ping = settings.database_pool_pre_ping or not is_sqlite
options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping} 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: if not is_memory_sqlite:
options.update( options.update(
pool_size=settings.database_pool_size, pool_size=settings.database_pool_size,
@@ -51,9 +59,12 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
"database_url_backend": backend, "database_url_backend": backend,
"in_memory_sqlite": is_memory_sqlite, "in_memory_sqlite": is_memory_sqlite,
**options, **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 hold_warn_seconds = settings.database_pool_hold_warn_seconds
def record_pool_checkout( def record_pool_checkout(
+8
View File
@@ -148,6 +148,13 @@ class Settings(BaseSettings):
database_pool_hold_warn_seconds: float = Field( database_pool_hold_warn_seconds: float = Field(
default=10.0, gt=0, env="DATABASE_POOL_HOLD_WARN_SECONDS" 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 # Logging
log_level: str = Field(default="INFO", env="LOG_LEVEL") log_level: str = Field(default="INFO", env="LOG_LEVEL")
@@ -209,6 +216,7 @@ ENV_ONLY_FIELDS = frozenset(
"database_pool_recycle", "database_pool_recycle",
"database_pool_pre_ping", "database_pool_pre_ping",
"database_pool_hold_warn_seconds", "database_pool_hold_warn_seconds",
"database_busy_timeout",
} }
) )
+12 -2
View File
@@ -215,11 +215,19 @@ async def _request_mint_with_fallback(
) )
continue continue
try: 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( quote = await run_mint_operation(
lambda: wallet.request_mint(amount_sats), lambda: wallet.request_mint(amount_sats),
op_name="request_mint_invoice", op_name="request_mint_invoice",
mint_url=mint_url, 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, retry_on_rate_limit=False,
) )
return quote.request, quote.quote, mint_url return quote.request, quote.quote, mint_url
@@ -471,7 +479,9 @@ async def check_invoice_payment(
await session.commit() await session.commit()
mint_url = settlement.mint_url or settings.primary_mint 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: try:
mint_status = await run_mint_operation( mint_status = await run_mint_operation(
lambda: wallet.get_mint_quote(settlement.payment_hash), lambda: wallet.get_mint_quote(settlement.payment_hash),
+32 -1
View File
@@ -107,6 +107,18 @@ async def _fetch_lnurl_json(
return data 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: async def decode_lnurl(lnurl: str) -> str:
"""Decode LNURL to get the actual URL. """Decode LNURL to get the actual URL.
@@ -293,6 +305,8 @@ async def raw_send_to_lnurl(
lambda: wallet.melt_quote(invoice=bolt11_invoice), lambda: wallet.melt_quote(invoice=bolt11_invoice),
op_name="lnurl_melt_quote", op_name="lnurl_melt_quote",
mint_url=str(wallet.url), 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 # 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. # reserved as though a Lightning payment could still settle.
await wallet.set_reserved_for_send(proofs, reserved=False) await wallet.set_reserved_for_send(proofs, reserved=False)
raise raise
if not isinstance(error, MINT_TRANSPORT_EXCEPTIONS): if not _contains_mint_transport_error(error):
raise 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_response = None
melt_error: BaseException | None = error melt_error: BaseException | None = error
else: else:
@@ -356,6 +383,10 @@ async def raw_send_to_lnurl(
if quote is not None and quote.state == MeltQuoteState.paid: if quote is not None and quote.state == MeltQuoteState.paid:
return final_amount 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") state = getattr(getattr(quote, "state", None), "value", "unknown")
raise MeltOutcomeAmbiguousError( raise MeltOutcomeAmbiguousError(
+18 -41
View File
@@ -3628,54 +3628,32 @@ class BaseUpstreamProvider:
extra={"amount": amount, "unit": unit, "mint": mint}, extra={"amount": amount, "unit": unit, "mint": mint},
) )
max_retries = 3 try:
last_exception = None # send_token may perform an irreversible Cashu swap to make exact
refund_token = None # denominations. A blanket retry after response loss can dispatch
# a second swap, so this call is intentionally single-attempt.
for attempt in range(max_retries): refund_token = await send_token(amount, unit=unit, mint_url=mint)
try: except Exception as error:
refund_token = await send_token(amount, unit=unit, mint_url=mint) logger.error(
break "Failed to create refund token",
except Exception as e: extra={
last_exception = e "error": str(error),
if attempt < max_retries - 1: "error_type": type(error).__name__,
logger.warning( "amount": amount,
"Refund token creation failed, retrying", "unit": unit,
extra={ "mint": mint,
"error": str(e), },
"error_type": type(e).__name__, )
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
else:
logger.error(
"Failed to create refund token after all retries",
extra={
"error": str(e),
"error_type": type(e).__name__,
"attempt": attempt + 1,
"max_retries": max_retries,
"amount": amount,
"unit": unit,
"mint": mint,
},
)
if refund_token is None:
raise HTTPException( raise HTTPException(
status_code=401, status_code=401,
detail={ detail={
"error": { "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", "type": "invalid_request_error",
"code": "send_token_failed", "code": "send_token_failed",
} }
}, },
) ) from error
logger.info( logger.info(
"Refund token created successfully", "Refund token created successfully",
@@ -3683,7 +3661,6 @@ class BaseUpstreamProvider:
"amount": amount, "amount": amount,
"unit": unit, "unit": unit,
"mint": mint, "mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..." "token_preview": refund_token[:20] + "..."
if len(refund_token) > 20 if len(refund_token) > 20
else refund_token, else refund_token,
+88 -26
View File
@@ -14,6 +14,7 @@ from typing import AsyncGenerator, TypedDict
import httpx import httpx
from cashu.core.base import MeltQuote, MeltQuoteState, MintQuote, Proof, Token from cashu.core.base import MeltQuote, MeltQuoteState, MintQuote, Proof, Token
from cashu.core.mint_info import MintInfo as _CashuMintInfo 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.helpers import deserialize_token_from_string
from cashu.wallet.wallet import Wallet as _CashuWallet from cashu.wallet.wallet import Wallet as _CashuWallet
from pydantic_core import PydanticUndefined from pydantic_core import PydanticUndefined
@@ -121,6 +122,12 @@ def _mints_to_inspect() -> list[str]:
return mint_urls 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): class Wallet(_CashuWallet):
"""Cashu adapter that preserves HTTP 429 for Routstr's mint policy.""" """Cashu adapter that preserves HTTP 429 for Routstr's mint policy."""
@@ -141,11 +148,37 @@ class Wallet(_CashuWallet):
_CashuWallet.raise_on_error_request(resp) _CashuWallet.raise_on_error_request(resp)
async def load_mint( 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: ) -> None:
await self.load_mint_keysets(force_old_keysets) """Load metadata once per mint URL, then hydrate unit wallets locally."""
await self.activate_keyset(keyset_id) mint_url = str(self.url)
await self.load_mint_info(reload=True) 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): class MintConnectionError(Exception):
@@ -1048,6 +1081,7 @@ async def _request_mint_with_fallback(
mint_url, mint_url,
settings.primary_mint_unit, settings.primary_mint_unit,
retry_on_rate_limit=False, retry_on_rate_limit=False,
load_proofs=False,
) )
quote = await run_mint_operation( quote = await run_mint_operation(
lambda: wallet.request_mint(amount), lambda: wallet.request_mint(amount),
@@ -1179,6 +1213,7 @@ async def _calculate_swap_amount(
lambda: token_wallet.melt_quote(dummy_mint_quote.request), lambda: token_wallet.melt_quote(dummy_mint_quote.request),
op_name="swap_fee_est_melt_quote", op_name="swap_fee_est_melt_quote",
mint_url=token_mint_url, mint_url=token_mint_url,
retry_timeouts=False,
) )
fee_reserve = dummy_melt_quote.fee_reserve 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), lambda: token_wallet.melt_quote(mint_quote.request),
op_name="swap_melt_quote", op_name="swap_melt_quote",
mint_url=token_obj.mint, mint_url=token_obj.mint,
retry_timeouts=False,
) )
except Exception as error: except Exception as error:
if is_mint_connection_error(error): if is_mint_connection_error(error):
@@ -1796,13 +1832,13 @@ async def _credit_balance_locked(
_wallets: dict[str, Wallet] = {} _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_load: dict[str, float] = {}
_wallet_last_mint_load: dict[str, float] = {}
_wallet_load_locks: dict[str, asyncio.Lock] = {} _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( async def get_wallet(
@@ -1811,8 +1847,16 @@ async def get_wallet(
load: bool = True, load: bool = True,
retry_on_rate_limit: bool = True, retry_on_rate_limit: bool = True,
force_reload: bool = False, force_reload: bool = False,
load_proofs: bool = True,
) -> Wallet: ) -> 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}" id = f"{mint_url}_{unit}"
lock = _wallet_load_locks.setdefault(id, asyncio.Lock()) lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
async with lock: async with lock:
@@ -1821,25 +1865,40 @@ async def get_wallet(
if load: if load:
now = time.monotonic() now = time.monotonic()
last = _wallet_last_load.get(id) last_mint_load = _wallet_last_mint_load.get(id)
if ( if (
force_reload force_reload
or last is None or last_mint_load is None
or now - last >= _WALLOAD_RELOAD_MIN_INTERVAL_SECONDS or now - last_mint_load >= _WALLET_MINT_RELOAD_MIN_INTERVAL_SECONDS
): ):
await run_mint_operation( 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", op_name="load_mint",
mint_url=mint_url, mint_url=mint_url,
retry_on_rate_limit=retry_on_rate_limit, retry_on_rate_limit=retry_on_rate_limit,
) )
await run_mint_operation( _wallet_last_mint_load[id] = time.monotonic()
lambda: _wallets[id].load_proofs(reload=True),
op_name="load_proofs", if load_proofs:
mint_url=mint_url, last_proof_load = _wallet_last_load.get(id)
retry_on_rate_limit=retry_on_rate_limit, if (
) force_reload
_wallet_last_load[id] = time.monotonic() 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",
mint_url=mint_url,
retry_on_rate_limit=retry_on_rate_limit,
)
_wallet_last_load[id] = time.monotonic()
return _wallets[id] return _wallets[id]
@@ -1912,13 +1971,16 @@ async def _get_supported_mint_units(mint_url: str) -> list[str]:
if cached is not None and now < cached[0]: if cached is not None and now < cached[0]:
return cached[1] return cached[1]
wallet = await get_wallet(mint_url, settings.primary_mint_unit, load=False) # One full remote metadata load populates Cashu's shared SQLite keyset
keysets = await run_mint_operation( # cache. Discover all advertised units from that cache instead of issuing a
lambda: wallet._get_keysets(), # separate /keysets request before each unit wallet loads.
op_name="get_mint_keysets", wallet = await get_wallet(
mint_url=mint_url, mint_url,
settings.primary_mint_unit,
retry_on_rate_limit=False, retry_on_rate_limit=False,
load_proofs=False,
) )
keysets = await get_cashu_keysets(mint_url=wallet.url, db=wallet.db)
units: list[str] = [] units: list[str] = []
for keyset in keysets: for keyset in keysets:
if not keyset.active or keyset.unit is None: 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.integration
@pytest.mark.asyncio @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_client: AsyncClient,
integration_session: Any, integration_session: Any,
) -> None: ) -> None:
@@ -739,16 +739,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure(
assert json["api_key"] == "sk-upstream-test" assert json["api_key"] == "sk-upstream-test"
assert headers["Authorization"] == "Bearer sk-upstream-test" assert headers["Authorization"] == "Bearer sk-upstream-test"
if self.calls == 1: return MockResponse(500, {"detail": "ambiguous upstream failure"})
return MockResponse(500, {"detail": "warmup failure"})
return MockResponse(
200,
{
"bolt11": "lnbc1testinvoice",
"invoice_id": "invoice-123",
},
)
mock_client = MockAsyncClient() mock_client = MockAsyncClient()
@@ -759,11 +750,7 @@ async def test_admin_routstr_topup_retries_transient_upstream_failure(
json={"amount": 10}, json={"amount": 10},
) )
assert response.status_code == 200 assert response.status_code == 500
data = response.json() assert mock_client.calls == 1
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
finally: finally:
admin_sessions.pop(admin_token, None) 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") msat = MagicMock(active=False, unit="msat")
usd = MagicMock(active=True) usd = MagicMock(active=True)
usd.unit.name = "usd" usd.unit.name = "usd"
wallet = MagicMock() wallet = MagicMock(url="http://mint:3338", db=MagicMock())
wallet._get_keysets = AsyncMock(return_value=[usd, msat, sat]) get_keysets = AsyncMock(return_value=[usd, msat, sat])
with ( with (
patch.object(settings, "primary_mint_unit", "sat"), patch.object(settings, "primary_mint_unit", "sat"),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)), 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") units = await _get_supported_mint_units("http://mint:3338")
cached_units = await _get_supported_mint_units("http://mint:3338") cached_units = await _get_supported_mint_units("http://mint:3338")
assert units == ["sat", "usd"] assert units == ["sat", "usd"]
assert cached_units == units 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 @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.core.settings import settings
from routstr.mint import MintCooldownError, MintRateGuard from routstr.mint import MintCooldownError, MintRateGuard
from routstr.payment.lnurl import ( from routstr.payment.lnurl import (
LNURLError,
MeltOutcomeAmbiguousError, MeltOutcomeAmbiguousError,
raw_send_to_lnurl, raw_send_to_lnurl,
) )
@@ -32,7 +33,10 @@ def _wallet() -> tuple[MagicMock, list[MagicMock]]:
wallet.melt_quote = AsyncMock( wallet.melt_quote = AsyncMock(
return_value=MagicMock(fee_reserve=1, quote="q", amount=QUOTE_AMOUNT_SAT) 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.select_to_send = AsyncMock(return_value=(proofs, None))
wallet.set_reserved_for_melt = AsyncMock()
wallet.set_reserved_for_send = AsyncMock()
return wallet, proofs return wallet, proofs
@@ -50,7 +54,7 @@ def _lnurl_patches() -> tuple[Any, Any]:
@pytest.mark.asyncio @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() wallet, proofs = _wallet()
async def _hang(**kwargs: object) -> None: 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), patch.object(settings, "mint_retry_max_attempts", 0),
data_patch, data_patch,
invoice_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) 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.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 @pytest.mark.asyncio
@@ -98,6 +155,9 @@ async def test_raw_send_to_lnurl_timeout_reconciled_paid_is_success() -> None:
assert paid > 0 assert paid > 0
wallet.get_melt_quote.assert_awaited_once_with("q") 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 @pytest.mark.asyncio
+19 -1
View File
@@ -1,7 +1,8 @@
"""Persisted mint preferences must not bypass the configured trusted set.""" """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 import pytest
from routstr.core.settings import settings 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] 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: async def test_trusted_allowed_mints_are_used_verbatim() -> None:
attempted: list[str] = [] 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._MintRateGuard._guards.clear()
wallet_module._wallets.clear() wallet_module._wallets.clear()
wallet_module._wallet_last_load.clear() wallet_module._wallet_last_load.clear()
wallet_module._wallet_last_mint_load.clear()
wallet_module._wallet_load_locks.clear() wallet_module._wallet_load_locks.clear()
wallet_module._mint_metadata_last_load.clear()
wallet_module._mint_metadata_load_locks.clear()
yield yield
settings.mint_max_concurrency = original_concurrency settings.mint_max_concurrency = original_concurrency
wallet_module._MintRateGuard._guards.clear() wallet_module._MintRateGuard._guards.clear()
wallet_module._wallets.clear() wallet_module._wallets.clear()
wallet_module._wallet_last_load.clear() wallet_module._wallet_last_load.clear()
wallet_module._wallet_last_mint_load.clear()
wallet_module._wallet_load_locks.clear() wallet_module._wallet_load_locks.clear()
wallet_module._mint_metadata_last_load.clear()
wallet_module._mint_metadata_load_locks.clear()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -66,6 +72,63 @@ async def test_get_balance() -> None:
assert balance == 50000 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 @pytest.mark.asyncio
async def test_get_wallet_force_reload_bypasses_reload_interval() -> None: async def test_get_wallet_force_reload_bypasses_reload_interval() -> None:
from routstr.wallet import get_wallet from routstr.wallet import get_wallet
@@ -2970,6 +3033,7 @@ async def test_load_mint_propagates_rate_limit() -> None:
from routstr.wallet import Wallet from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet) wallet = Wallet.__new__(Wallet)
wallet.url = "https://rate-limited-mint.example"
error = MintRateLimitedError( error = MintRateLimitedError(
"Cashu mint rate limited", "Cashu mint rate limited",
request=httpx.Request("GET", "https://mint.example/v1/keysets"), 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 from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet) wallet = Wallet.__new__(Wallet)
wallet.url = "https://unavailable-mint.example"
error = httpx.ConnectError("mint unavailable") error = httpx.ConnectError("mint unavailable")
with ( with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)), 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 from routstr.wallet import Wallet
wallet = Wallet.__new__(Wallet) wallet = Wallet.__new__(Wallet)
wallet.url = "https://mint-load.example"
with ( with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock()) as load_keysets, patch.object(wallet, "load_mint_keysets", new=AsyncMock()) as load_keysets,
patch.object(wallet, "activate_keyset", new=AsyncMock()) as activate, patch.object(wallet, "activate_keyset", new=AsyncMock()) as activate,