mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: harden wallet and Cashu operations
This commit is contained in:
+6
-18
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user