fix fallback

This commit is contained in:
9qeklajc
2026-07-14 00:54:44 +02:00
parent 6fa3610423
commit d44b98fd0d
5 changed files with 394 additions and 108 deletions
+55 -3
View File
@@ -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
View File
@@ -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``
+6 -1
View File
@@ -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
+48 -1
View File
@@ -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
View File
@@ -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