mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-10 03:07:06 +00:00
fix fallback
This commit is contained in:
+55
-3
@@ -145,6 +145,17 @@ class TopupRequest(BaseModel):
|
||||
cashu_token: str
|
||||
|
||||
|
||||
def _error_chain(error: BaseException) -> list[dict[str, str]]:
|
||||
chain: list[dict[str, str]] = []
|
||||
current: BaseException | None = error
|
||||
seen: set[int] = set()
|
||||
while current is not None and id(current) not in seen:
|
||||
seen.add(id(current))
|
||||
chain.append({"type": type(current).__name__, "message": str(current)})
|
||||
current = current.__cause__ or current.__context__
|
||||
return chain
|
||||
|
||||
|
||||
@router.post("/topup")
|
||||
async def topup_wallet_endpoint(
|
||||
cashu_token: str | None = None,
|
||||
@@ -162,6 +173,18 @@ async def topup_wallet_endpoint(
|
||||
cashu_token = cashu_token.replace("\n", "").replace("\r", "").replace("\t", "")
|
||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||
|
||||
source_mint = token_mint_url(cashu_token, "unknown")
|
||||
logger.warning(
|
||||
"Cashu wallet top-up started",
|
||||
extra={
|
||||
"event": "cashu_topup_started",
|
||||
"source_mint": source_mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"trusted_mints": settings.cashu_mints,
|
||||
"key_hash": billing_key.hashed_key[:8],
|
||||
},
|
||||
)
|
||||
try:
|
||||
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||
except Exception as e:
|
||||
@@ -170,12 +193,41 @@ async def topup_wallet_endpoint(
|
||||
classified = classify_redemption_error(e)
|
||||
if classified is None:
|
||||
logger.error(
|
||||
"topup_wallet_endpoint: unhandled error",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
"Cashu wallet top-up failed with an unhandled error",
|
||||
extra={
|
||||
"event": "cashu_topup_failed",
|
||||
"source_mint": source_mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"trusted_mints": settings.cashu_mints,
|
||||
"error_chain": _error_chain(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
_type, status_code, message, _code = classified
|
||||
error_type, status_code, message, error_code = classified
|
||||
logger.warning(
|
||||
"Cashu wallet top-up failed",
|
||||
extra={
|
||||
"event": "cashu_topup_failed",
|
||||
"source_mint": source_mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"trusted_mints": settings.cashu_mints,
|
||||
"status_code": status_code,
|
||||
"error_type": error_type,
|
||||
"error_code": error_code,
|
||||
"error_chain": _error_chain(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=status_code, detail=message)
|
||||
|
||||
logger.warning(
|
||||
"Cashu wallet top-up completed",
|
||||
extra={
|
||||
"event": "cashu_topup_completed",
|
||||
"source_mint": source_mint,
|
||||
"credited_msats": amount_msats,
|
||||
"key_hash": billing_key.hashed_key[:8],
|
||||
},
|
||||
)
|
||||
return {"msats": amount_msats}
|
||||
|
||||
|
||||
|
||||
+232
-51
@@ -53,6 +53,8 @@ class TokenConsumedError(Exception):
|
||||
|
||||
# httpx base classes cover their subclasses. HTTPStatusError is excluded on
|
||||
# purpose — that means the mint answered, just with an error status.
|
||||
_MINT_TRANSPORT_COOLDOWN_SECONDS = 30.0
|
||||
|
||||
_TRANSPORT_EXC_TYPES: tuple[type[BaseException], ...] = (
|
||||
httpx.NetworkError,
|
||||
httpx.TimeoutException,
|
||||
@@ -400,8 +402,30 @@ async def recieve_token(
|
||||
wallet.keyset_id = token_obj.keysets[0]
|
||||
|
||||
if token_obj.mint not in settings.cashu_mints:
|
||||
destinations = list(
|
||||
dict.fromkeys([settings.primary_mint, *settings.cashu_mints])
|
||||
)
|
||||
logger.warning(
|
||||
"Cashu cross-mint swap required",
|
||||
extra={
|
||||
"event": "cashu_swap_started",
|
||||
"source_mint": token_obj.mint,
|
||||
"source_unit": token_obj.unit,
|
||||
"source_amount": token_obj.amount,
|
||||
"destination_candidates": destinations,
|
||||
},
|
||||
)
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
|
||||
logger.info(
|
||||
"Cashu same-mint redemption selected",
|
||||
extra={
|
||||
"event": "cashu_same_mint_redemption",
|
||||
"source_mint": token_obj.mint,
|
||||
"source_unit": token_obj.unit,
|
||||
"source_amount": token_obj.amount,
|
||||
},
|
||||
)
|
||||
return await _redeem_same_mint(wallet, token_obj)
|
||||
|
||||
|
||||
@@ -595,20 +619,43 @@ async def _request_mint_with_fallback(
|
||||
candidates = [settings.primary_mint] + [
|
||||
m for m in settings.cashu_mints if m != settings.primary_mint
|
||||
]
|
||||
logger.warning(
|
||||
"Trying trusted destination mints",
|
||||
extra={
|
||||
"event": "cashu_destination_candidates",
|
||||
"op_name": op_name,
|
||||
"amount": amount,
|
||||
"unit": settings.primary_mint_unit,
|
||||
"candidates": candidates,
|
||||
},
|
||||
)
|
||||
tried: list[str] = []
|
||||
for mint_url in candidates:
|
||||
for candidate_index, mint_url in enumerate(candidates, start=1):
|
||||
cooldown = _mint_cooldown_remaining(mint_url)
|
||||
if cooldown > 0:
|
||||
tried.append(f"{mint_url}: cooling down")
|
||||
logger.info(
|
||||
"Skipping rate-limited mint",
|
||||
logger.warning(
|
||||
"Skipping unavailable destination mint",
|
||||
extra={
|
||||
"event": "cashu_destination_skipped",
|
||||
"mint_url": mint_url,
|
||||
"cooldown_seconds": round(cooldown, 2),
|
||||
"op_name": op_name,
|
||||
"candidate_index": candidate_index,
|
||||
"candidate_count": len(candidates),
|
||||
},
|
||||
)
|
||||
continue
|
||||
logger.warning(
|
||||
"Trying destination mint",
|
||||
extra={
|
||||
"event": "cashu_destination_attempt",
|
||||
"mint_url": mint_url,
|
||||
"op_name": op_name,
|
||||
"candidate_index": candidate_index,
|
||||
"candidate_count": len(candidates),
|
||||
},
|
||||
)
|
||||
try:
|
||||
if mint_url == settings.primary_mint and primary_wallet is not None:
|
||||
wallet = primary_wallet
|
||||
@@ -624,21 +671,54 @@ async def _request_mint_with_fallback(
|
||||
mint_url=mint_url,
|
||||
retry_on_rate_limit=False,
|
||||
)
|
||||
return wallet, mint_url, quote
|
||||
except Exception as e:
|
||||
tried.append(f"{mint_url}: {type(e).__name__}")
|
||||
if not is_mint_connection_error(e) and not _is_mint_rate_limited(e):
|
||||
raise
|
||||
logger.warning(
|
||||
"request_mint failed, trying fallback mint",
|
||||
"Destination mint selected",
|
||||
extra={
|
||||
"event": "cashu_destination_selected",
|
||||
"mint_url": mint_url,
|
||||
"op_name": op_name,
|
||||
"candidate_index": candidate_index,
|
||||
"fallback_used": candidate_index > 1,
|
||||
},
|
||||
)
|
||||
return wallet, mint_url, quote
|
||||
except Exception as error:
|
||||
tried.append(f"{mint_url}: {type(error).__name__}")
|
||||
connection_failure = is_mint_connection_error(error)
|
||||
rate_limited = _is_mint_rate_limited(error)
|
||||
if not connection_failure and not rate_limited:
|
||||
raise
|
||||
if connection_failure:
|
||||
_MintRateGuard.get(mint_url).apply_cooldown(
|
||||
_MINT_TRANSPORT_COOLDOWN_SECONDS
|
||||
)
|
||||
logger.warning(
|
||||
"Destination mint failed",
|
||||
extra={
|
||||
"event": "cashu_destination_failed",
|
||||
"failed_mint": mint_url,
|
||||
"error": str(e),
|
||||
"error": str(error),
|
||||
"error_type": type(error).__name__,
|
||||
"connection_failure": connection_failure,
|
||||
"rate_limited": rate_limited,
|
||||
"tried": tried,
|
||||
"op_name": op_name,
|
||||
"candidate_index": candidate_index,
|
||||
"candidate_count": len(candidates),
|
||||
},
|
||||
)
|
||||
continue
|
||||
logger.error(
|
||||
"All trusted destination mints failed",
|
||||
extra={
|
||||
"event": "cashu_destination_exhausted",
|
||||
"op_name": op_name,
|
||||
"amount": amount,
|
||||
"unit": settings.primary_mint_unit,
|
||||
"candidates": candidates,
|
||||
"tried": tried,
|
||||
},
|
||||
)
|
||||
raise MintConnectionError(f"All mints failed for {op_name}: {tried}")
|
||||
|
||||
|
||||
@@ -647,7 +727,7 @@ async def _calculate_swap_amount(
|
||||
token_unit: str,
|
||||
token_mint_url: str,
|
||||
token_wallet: Wallet,
|
||||
primary_wallet: Wallet,
|
||||
primary_wallet: Wallet | None,
|
||||
proofs: list,
|
||||
) -> int:
|
||||
"""
|
||||
@@ -700,12 +780,14 @@ async def _calculate_swap_amount(
|
||||
},
|
||||
)
|
||||
|
||||
stage = "destination_fee_quote"
|
||||
try:
|
||||
_, _, dummy_mint_quote = await _request_mint_with_fallback(
|
||||
receive_amount,
|
||||
op_name="swap_fee_est_mint_quote",
|
||||
primary_wallet=primary_wallet,
|
||||
)
|
||||
stage = "source_fee_quote"
|
||||
dummy_melt_quote = await _mint_operation(
|
||||
lambda: token_wallet.melt_quote(dummy_mint_quote.request),
|
||||
op_name="swap_fee_est_melt_quote",
|
||||
@@ -738,8 +820,10 @@ async def _calculate_swap_amount(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"swap_to_primary_mint: fee estimation failed",
|
||||
"Cashu swap fee estimation failed",
|
||||
extra={
|
||||
"event": "cashu_swap_fee_estimation_failed",
|
||||
"stage": stage,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"amount_msat": amount_msat,
|
||||
@@ -751,6 +835,15 @@ async def _calculate_swap_amount(
|
||||
},
|
||||
)
|
||||
if is_mint_connection_error(e):
|
||||
if stage == "source_fee_quote":
|
||||
logger.error(
|
||||
"Source mint is unreachable; destination fallback cannot spend its proofs",
|
||||
extra={
|
||||
"event": "cashu_source_mint_unreachable",
|
||||
"source_mint": token_mint_url,
|
||||
"stage": stage,
|
||||
},
|
||||
)
|
||||
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||
raise ValueError(f"Failed to estimate fees: {e}") from e
|
||||
|
||||
@@ -758,10 +851,11 @@ async def _calculate_swap_amount(
|
||||
async def swap_to_primary_mint(
|
||||
token_obj: Token, token_wallet: Wallet
|
||||
) -> tuple[int, str, str]:
|
||||
logger.info(
|
||||
"swap_to_primary_mint: starting",
|
||||
logger.warning(
|
||||
"Starting Cashu cross-mint swap",
|
||||
extra={
|
||||
"foreign_mint": token_obj.mint,
|
||||
"event": "cashu_swap_started",
|
||||
"source_mint": token_obj.mint,
|
||||
"token_amount": token_obj.amount,
|
||||
"unit": token_obj.unit,
|
||||
"primary_mint": settings.primary_mint,
|
||||
@@ -793,7 +887,7 @@ async def swap_to_primary_mint(
|
||||
)
|
||||
return await _redeem_same_mint(token_wallet, token_obj)
|
||||
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
primary_wallet: Wallet | None = None
|
||||
|
||||
minted_amount = await _calculate_swap_amount(
|
||||
amount_msat,
|
||||
@@ -843,11 +937,37 @@ async def swap_to_primary_mint(
|
||||
},
|
||||
)
|
||||
|
||||
melt_quote = await _mint_operation(
|
||||
lambda: token_wallet.melt_quote(mint_quote.request),
|
||||
op_name="swap_melt_quote",
|
||||
mint_url=token_obj.mint,
|
||||
logger.warning(
|
||||
"Requesting melt quote from source mint",
|
||||
extra={
|
||||
"event": "cashu_source_melt_quote_attempt",
|
||||
"source_mint": token_obj.mint,
|
||||
"destination_mint": dest_mint_url,
|
||||
"attempt": attempt,
|
||||
},
|
||||
)
|
||||
try:
|
||||
melt_quote = await _mint_operation(
|
||||
lambda: token_wallet.melt_quote(mint_quote.request),
|
||||
op_name="swap_melt_quote",
|
||||
mint_url=token_obj.mint,
|
||||
)
|
||||
except Exception as error:
|
||||
if is_mint_connection_error(error):
|
||||
logger.error(
|
||||
"Source mint is unreachable; destination fallback cannot spend its proofs",
|
||||
extra={
|
||||
"event": "cashu_source_mint_unreachable",
|
||||
"source_mint": token_obj.mint,
|
||||
"destination_mint": dest_mint_url,
|
||||
"stage": "source_melt_quote",
|
||||
"error": str(error),
|
||||
"error_type": type(error).__name__,
|
||||
"attempt": attempt,
|
||||
},
|
||||
)
|
||||
raise MintConnectionError("Cashu mint is unreachable") from error
|
||||
raise
|
||||
input_fees = token_wallet.get_fees_for_proofs(token_obj.proofs)
|
||||
total_needed = melt_quote.amount + melt_quote.fee_reserve + input_fees
|
||||
logger.info(
|
||||
@@ -915,8 +1035,16 @@ async def swap_to_primary_mint(
|
||||
# A down mint won't fix itself by retrying with a smaller amount.
|
||||
if is_mint_connection_error(e):
|
||||
logger.error(
|
||||
"swap_to_primary_mint: melt failed — mint unreachable",
|
||||
extra={"error": str(e), "foreign_mint": token_obj.mint},
|
||||
"Source mint became unreachable during melt",
|
||||
extra={
|
||||
"event": "cashu_source_mint_unreachable",
|
||||
"stage": "source_melt",
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"source_mint": token_obj.mint,
|
||||
"destination_mint": dest_mint_url,
|
||||
"attempt": attempt,
|
||||
},
|
||||
)
|
||||
raise MintConnectionError("Cashu mint is unreachable") from e
|
||||
shortfall = _melt_insufficient_shortfall(e)
|
||||
@@ -957,9 +1085,10 @@ async def swap_to_primary_mint(
|
||||
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: melt succeeded, minting on destination",
|
||||
logger.warning(
|
||||
"Source melt succeeded; minting on destination",
|
||||
extra={
|
||||
"event": "cashu_destination_mint_attempt",
|
||||
"minted_amount": minted_amount,
|
||||
"mint_quote_id": mint_quote.quote,
|
||||
"dest_mint": dest_mint_url,
|
||||
@@ -1040,10 +1169,11 @@ async def swap_to_primary_mint(
|
||||
"Mint on primary failed after successful melt"
|
||||
) from e
|
||||
|
||||
logger.info(
|
||||
"swap_to_primary_mint: completed successfully",
|
||||
logger.warning(
|
||||
"Cashu cross-mint swap completed",
|
||||
extra={
|
||||
"foreign_mint": token_obj.mint,
|
||||
"event": "cashu_swap_completed",
|
||||
"source_mint": token_obj.mint,
|
||||
"dest_mint": dest_mint_url,
|
||||
"original_amount": token_amount,
|
||||
"minted_amount": minted_amount,
|
||||
@@ -1058,8 +1188,11 @@ async def credit_balance(
|
||||
cashu_token: str, key: db.ApiKey, session: db.AsyncSession
|
||||
) -> int:
|
||||
logger.info(
|
||||
"credit_balance: Starting token redemption",
|
||||
extra={"token_preview": cashu_token[:50]},
|
||||
"Starting Cashu balance credit",
|
||||
extra={
|
||||
"event": "cashu_credit_started",
|
||||
"key_hash": key.hashed_key[:8],
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -1212,7 +1345,12 @@ def get_proofs_per_mint_and_unit(
|
||||
return proofs
|
||||
|
||||
|
||||
async def slow_filter_spend_proofs(proofs: list[Proof], wallet: Wallet) -> list[Proof]:
|
||||
async def slow_filter_spend_proofs(
|
||||
proofs: list[Proof],
|
||||
wallet: Wallet,
|
||||
*,
|
||||
retry_on_rate_limit: bool = True,
|
||||
) -> list[Proof]:
|
||||
if not proofs:
|
||||
return []
|
||||
_proofs = []
|
||||
@@ -1226,6 +1364,7 @@ async def slow_filter_spend_proofs(proofs: list[Proof], wallet: Wallet) -> list[
|
||||
lambda: wallet.check_proof_state(pb),
|
||||
op_name="check_proof_state",
|
||||
mint_url=str(wallet.url),
|
||||
retry_on_rate_limit=retry_on_rate_limit,
|
||||
)
|
||||
for proof, state in zip(pb, proof_states.states):
|
||||
if str(state.state) != "spent":
|
||||
@@ -1251,6 +1390,22 @@ class BalanceDetail(TypedDict, total=False):
|
||||
error: str
|
||||
|
||||
|
||||
_BALANCE_FETCH_RETRY_SECONDS = 60.0
|
||||
_balance_fetch_failures: dict[tuple[str, str], tuple[float, str]] = {}
|
||||
_balance_fetch_locks: dict[tuple[str, str], asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _balance_error(mint_url: str, unit: str, error: str) -> BalanceDetail:
|
||||
return {
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"wallet_balance": 0,
|
||||
"user_balance": 0,
|
||||
"owner_balance": 0,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_all_balances(
|
||||
units: list[str] | None = None,
|
||||
) -> tuple[list[BalanceDetail], int, int, int]:
|
||||
@@ -1269,18 +1424,56 @@ async def fetch_all_balances(
|
||||
async def fetch_balance(
|
||||
session: db.AsyncSession, mint_url: str, unit: str
|
||||
) -> BalanceDetail:
|
||||
try:
|
||||
wallet = await get_wallet(mint_url, unit)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||
user_balance = await db.balances_for_mint_and_unit(session, mint_url, unit)
|
||||
key = (mint_url, unit)
|
||||
lock = _balance_fetch_locks.setdefault(key, asyncio.Lock())
|
||||
async with lock:
|
||||
now = time.monotonic()
|
||||
failure = _balance_fetch_failures.get(key)
|
||||
if failure is not None and now < failure[0]:
|
||||
return _balance_error(mint_url, unit, failure[1])
|
||||
|
||||
cooldown = _mint_cooldown_remaining(mint_url)
|
||||
if cooldown > 0:
|
||||
error = "Mint is cooling down after a rate limit"
|
||||
_balance_fetch_failures[key] = (now + cooldown, error)
|
||||
return _balance_error(mint_url, unit, error)
|
||||
|
||||
try:
|
||||
wallet = await get_wallet(
|
||||
mint_url, unit, retry_on_rate_limit=False
|
||||
)
|
||||
proofs = get_proofs_per_mint_and_unit(
|
||||
wallet, mint_url, unit, not_reserved=True
|
||||
)
|
||||
proofs = await slow_filter_spend_proofs(
|
||||
proofs, wallet, retry_on_rate_limit=False
|
||||
)
|
||||
user_balance = await db.balances_for_mint_and_unit(
|
||||
session, mint_url, unit
|
||||
)
|
||||
except Exception as error:
|
||||
retry_delay = max(
|
||||
_BALANCE_FETCH_RETRY_SECONDS,
|
||||
_mint_cooldown_remaining(mint_url),
|
||||
)
|
||||
retry_at = time.monotonic() + retry_delay
|
||||
_balance_fetch_failures[key] = (retry_at, str(error))
|
||||
logger.warning(
|
||||
"Unable to refresh mint balance",
|
||||
extra={
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"error": str(error),
|
||||
"retry_seconds": round(retry_delay, 2),
|
||||
},
|
||||
)
|
||||
return _balance_error(mint_url, unit, str(error))
|
||||
|
||||
_balance_fetch_failures.pop(key, None)
|
||||
if unit == "sat":
|
||||
user_balance = user_balance // 1000
|
||||
proofs_balance = sum(proof.amount for proof in proofs)
|
||||
|
||||
result: BalanceDetail = {
|
||||
return {
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"wallet_balance": proofs_balance,
|
||||
@@ -1289,18 +1482,6 @@ async def fetch_all_balances(
|
||||
if proofs_balance != 0
|
||||
else 0,
|
||||
}
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting balance for {mint_url} {unit}: {e}")
|
||||
error_result: BalanceDetail = {
|
||||
"mint_url": mint_url,
|
||||
"unit": unit,
|
||||
"wallet_balance": 0,
|
||||
"user_balance": 0,
|
||||
"owner_balance": 0,
|
||||
"error": str(e),
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Build the set of mints to inspect. Received tokens are stored against
|
||||
# ``primary_mint`` (which defaults to a real mint even when ``cashu_mints``
|
||||
|
||||
@@ -89,7 +89,12 @@ def _make_swap_mocks(
|
||||
def _wallet_router(primary_wallet: Mock, token_wallet: Mock) -> Callable[..., Mock]:
|
||||
"""Route get_wallet calls to the primary or foreign wallet mock by URL."""
|
||||
|
||||
def fake_get_wallet(mint_url: str, unit: str = "sat", load: bool = True) -> Mock:
|
||||
def fake_get_wallet(
|
||||
mint_url: str,
|
||||
unit: str = "sat",
|
||||
load: bool = True,
|
||||
**kwargs: object,
|
||||
) -> Mock:
|
||||
return primary_wallet if mint_url == PRIMARY_MINT else token_wallet
|
||||
|
||||
return fake_get_wallet
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
from collections.abc import Generator
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from routstr.wallet import fetch_all_balances
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_balance_fetch_state() -> Generator[None, None, None]:
|
||||
from routstr import wallet
|
||||
|
||||
wallet._balance_fetch_failures.clear()
|
||||
wallet._balance_fetch_locks.clear()
|
||||
yield
|
||||
wallet._balance_fetch_failures.clear()
|
||||
wallet._balance_fetch_locks.clear()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session(): # type: ignore[no-untyped-def]
|
||||
yield MagicMock()
|
||||
@@ -21,7 +34,7 @@ def _patches(proof_amount: int = 1000): # type: ignore[no-untyped-def]
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.slow_filter_spend_proofs",
|
||||
AsyncMock(side_effect=lambda proofs, wallet: proofs),
|
||||
AsyncMock(side_effect=lambda proofs, wallet, **kwargs: proofs),
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.db.balances_for_mint_and_unit",
|
||||
@@ -52,6 +65,40 @@ async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
|
||||
assert total_wallet == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_backs_off_after_connection_failure() -> None:
|
||||
from routstr.core.settings import settings
|
||||
|
||||
get_wallet = AsyncMock(side_effect=httpx.ConnectError("mint unavailable"))
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch.object(settings, "primary_mint", "http://mint:3338"),
|
||||
patch("routstr.wallet.get_wallet", get_wallet),
|
||||
patch("routstr.wallet.db.create_session", _fake_session),
|
||||
patch("routstr.wallet.time.monotonic", return_value=10),
|
||||
patch("routstr.wallet.logger.warning") as warning,
|
||||
):
|
||||
first = await fetch_all_balances(units=["sat"])
|
||||
second = await fetch_all_balances(units=["sat"])
|
||||
|
||||
assert first[0][0]["error"] == "mint unavailable"
|
||||
assert second[0][0]["error"] == "mint unavailable"
|
||||
assert get_wallet.await_count == 1
|
||||
warning.assert_called_once()
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch.object(settings, "primary_mint", "http://mint:3338"),
|
||||
patch("routstr.wallet.get_wallet", get_wallet),
|
||||
patch("routstr.wallet.db.create_session", _fake_session),
|
||||
patch("routstr.wallet.time.monotonic", return_value=71),
|
||||
patch("routstr.wallet.logger.warning"),
|
||||
):
|
||||
await fetch_all_balances(units=["sat"])
|
||||
|
||||
assert get_wallet.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_no_duplicate_primary_mint() -> None:
|
||||
"""primary_mint already in cashu_mints is not inspected twice."""
|
||||
|
||||
+53
-52
@@ -1710,71 +1710,72 @@ async def test_lightning_mint_fallback_for_topups() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_falls_back_to_secondary_mint() -> None:
|
||||
"""When the primary mint is unreachable, swap_to_primary_mint falls back
|
||||
to a secondary trusted mint as the swap destination."""
|
||||
async def test_swap_falls_back_when_primary_wallet_cannot_load() -> None:
|
||||
from routstr.core.settings import settings
|
||||
from routstr.wallet import _wallet_last_load, _wallets, swap_to_primary_mint
|
||||
|
||||
_wallets.clear()
|
||||
_wallet_last_load.clear()
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
primary = "http://primary:3338"
|
||||
secondary = "http://secondary:3338"
|
||||
foreign = "http://foreign:3338"
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = foreign
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 1000
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.proofs = [Mock(amount=1000)]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
mock_token_wallet.get_fees_for_proofs = Mock(return_value=0)
|
||||
mock_token_wallet.melt_quote = AsyncMock(
|
||||
return_value=Mock(quote="melt_q", amount=990, fee_reserve=10)
|
||||
token = Mock(
|
||||
mint=foreign,
|
||||
unit="sat",
|
||||
amount=1000,
|
||||
keysets=["keyset1"],
|
||||
proofs=[Mock(amount=1000)],
|
||||
)
|
||||
mock_token_wallet.melt = AsyncMock(return_value=Mock())
|
||||
|
||||
mock_primary_wallet = Mock()
|
||||
mock_primary_wallet.request_mint = AsyncMock(
|
||||
side_effect=httpx.ConnectError("primary down")
|
||||
source_wallet = Mock(
|
||||
load_mint=AsyncMock(),
|
||||
load_proofs=AsyncMock(),
|
||||
get_fees_for_proofs=Mock(return_value=0),
|
||||
melt_quote=AsyncMock(
|
||||
return_value=Mock(quote="melt_q", amount=990, fee_reserve=10)
|
||||
),
|
||||
melt=AsyncMock(return_value=Mock()),
|
||||
)
|
||||
|
||||
mint_quote = Mock(quote="mint_q_secondary", request="lnbc1secondary")
|
||||
mock_secondary_wallet = Mock()
|
||||
mock_secondary_wallet.load_mint = AsyncMock()
|
||||
mock_secondary_wallet.load_proofs = AsyncMock()
|
||||
mock_secondary_wallet.available_balance = Mock(amount=0)
|
||||
mock_secondary_wallet.keysets = ["ks_secondary"]
|
||||
mock_secondary_wallet.restore_tokens_for_keyset = AsyncMock()
|
||||
mock_secondary_wallet.request_mint = AsyncMock(return_value=mint_quote)
|
||||
mock_secondary_wallet.mint = AsyncMock(return_value=Mock())
|
||||
secondary_wallet = Mock(
|
||||
load_mint=AsyncMock(),
|
||||
load_proofs=AsyncMock(),
|
||||
available_balance=Mock(amount=0),
|
||||
keysets=["ks_secondary"],
|
||||
restore_tokens_for_keyset=AsyncMock(),
|
||||
request_mint=AsyncMock(return_value=mint_quote),
|
||||
mint=AsyncMock(return_value=Mock()),
|
||||
)
|
||||
|
||||
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
|
||||
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
|
||||
async def get_wallet(mint: str, *args: object, **kwargs: object) -> Mock:
|
||||
if mint == primary:
|
||||
raise httpx.ConnectError("primary down")
|
||||
return secondary_wallet
|
||||
|
||||
with patch.object(settings, "primary_mint", primary):
|
||||
with patch.object(settings, "primary_mint_unit", "sat"):
|
||||
with patch.object(settings, "cashu_mints", [primary, secondary]):
|
||||
with patch.object(settings, "mint_max_concurrency", 0):
|
||||
with patch.object(settings, "mint_operation_timeout_seconds", 0):
|
||||
with patch("asyncio.sleep", AsyncMock()):
|
||||
with patch(
|
||||
"routstr.wallet.get_wallet", side_effect=mock_get
|
||||
):
|
||||
amount, unit, mint_url = await swap_to_primary_mint(
|
||||
mock_token, mock_token_wallet
|
||||
)
|
||||
mock_get = AsyncMock(side_effect=get_wallet)
|
||||
with (
|
||||
patch.object(settings, "primary_mint", primary),
|
||||
patch.object(settings, "primary_mint_unit", "sat"),
|
||||
patch.object(settings, "cashu_mints", [primary, secondary]),
|
||||
patch.object(settings, "mint_max_concurrency", 0),
|
||||
patch.object(settings, "mint_operation_timeout_seconds", 0),
|
||||
patch("asyncio.sleep", AsyncMock()),
|
||||
patch("routstr.wallet.get_wallet", side_effect=mock_get),
|
||||
patch("routstr.wallet.logger.warning") as warning,
|
||||
):
|
||||
amount, unit, mint_url = await swap_to_primary_mint(token, source_wallet)
|
||||
|
||||
assert mint_url == secondary
|
||||
assert amount == 990 # 1000 - 10 fee_reserve
|
||||
assert unit == "sat"
|
||||
mock_secondary_wallet.mint.assert_called_once()
|
||||
mock_primary_wallet.mint.assert_not_called()
|
||||
assert (amount, unit, mint_url) == (990, "sat", secondary)
|
||||
secondary_wallet.mint.assert_awaited_once()
|
||||
assert mock_get.await_args_list[0].args[0] == primary
|
||||
assert any(call.args[0] == secondary for call in mock_get.await_args_list)
|
||||
events = {
|
||||
call.kwargs["extra"]["event"]
|
||||
for call in warning.call_args_list
|
||||
if "extra" in call.kwargs and "event" in call.kwargs["extra"]
|
||||
}
|
||||
assert "cashu_destination_failed" in events
|
||||
assert "cashu_destination_selected" in events
|
||||
assert "cashu_swap_completed" in events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user