Files
routstr-core/tests/unit/test_wallet.py
T

2096 lines
76 KiB
Python

import asyncio
import base64
import json
import socket
from collections.abc import AsyncIterator, Generator
from contextlib import asynccontextmanager
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
from routstr.core.db import ApiKey
from routstr.wallet import (
Bolt11PaymentAmbiguous,
Bolt11PaymentNotAttempted,
MintConnectionError,
TokenConsumedError,
UntrustedSourceMintError,
_is_mint_rate_limited,
classify_redemption_error,
credit_balance,
execute_bolt11_payment,
get_balance,
is_mint_connection_error,
prepare_bolt11_payment,
recieve_token,
send,
send_token,
send_token_from_owner_locked,
)
@pytest.fixture(autouse=True)
def isolate_wallet_runtime_state(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> Generator[None, None, None]:
"""Keep production limiter/wallet caches from leaking across unit tests."""
from routstr import wallet as wallet_module
from routstr.core.settings import settings
monkeypatch.setattr(
wallet_module, "_WALLET_OPERATION_LOCK", tmp_path / "wallet.lock"
)
original_concurrency = settings.mint_max_concurrency
settings.mint_max_concurrency = 0
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
async def test_get_balance() -> None:
mock_wallet = Mock()
mock_wallet.available_balance = Mock(amount=50000)
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
# Reset the module-level wallet cache so a real wallet cached by an earlier
# test (e.g. an unmocked admin-withdraw path) can't shadow the mock here.
with (
patch("routstr.wallet._wallets", {}),
patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet),
):
balance = await get_balance("sat")
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
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")
await get_wallet("http://mint:3338", "sat", force_reload=True)
assert mock_wallet.load_mint.await_count == 2
assert mock_wallet.load_proofs.await_count == 2
@pytest.mark.asyncio
async def test_get_wallet_force_reload_proofs_keeps_cached_keysets() -> 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")
await get_wallet("http://mint:3338", "sat", force_reload_proofs=True)
assert mock_wallet.load_mint.await_count == 1
assert mock_wallet.load_proofs.await_count == 2
@pytest.mark.asyncio
async def test_public_recieve_token_holds_wallet_operation_guard() -> None:
inside_guard = False
@asynccontextmanager
async def operation_guard() -> AsyncIterator[None]:
nonlocal inside_guard
inside_guard = True
try:
yield
finally:
inside_guard = False
async def receive_locked(*_args: object, **_kwargs: object) -> tuple[int, str, str]:
assert inside_guard
return 1, "sat", "https://mint.example"
with (
patch("routstr.wallet.wallet_operation_guard", operation_guard),
patch("routstr.wallet._recieve_token_locked", side_effect=receive_locked),
):
assert await recieve_token("cashuAtoken") == (
1,
"sat",
"https://mint.example",
)
assert inside_guard is False
@pytest.mark.asyncio
async def test_recieve_token_valid() -> None:
token_data = {
"token": [
{
"mint": "http://mint:3338",
"proofs": [
{"amount": 1000, "id": "test", "secret": "secret", "C": "curve"}
],
}
],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
# Fee-free trusted mint (e.g. Minibits): nothing deducted.
mock_wallet.get_fees_for_proofs = Mock(return_value=0)
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://mint:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.proofs = [{"amount": 1000}]
mock_deserialize.return_value = mock_token
mock_wallet.load_mint_keysets = AsyncMock()
mock_wallet.activate_keyset = AsyncMock()
mock_wallet._expand_short_keyset_ids = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
amount, unit, mint = await recieve_token(token_str)
assert amount == 1000
assert unit == "sat"
assert mint == "http://mint:3338"
@pytest.mark.asyncio
async def test_recieve_token_trusted_mint_deducts_input_fee() -> None:
"""A trusted mint that charges NUT-02 input fees.
The same-mint receive (`wallet.split(..., include_fees=True)`, a NUT-03 swap
at the same mint) pays the mint's per-proof fee,
so routstr only ends up with `face - input_fee` in fresh proofs. The credited
amount must reflect that, otherwise routstr over-credits the user and its own
wallet drifts toward insolvency.
"""
token_data = {
"token": [
{
"mint": "http://mint:3338",
"proofs": [
{"amount": 1000, "id": "test", "secret": "secret", "C": "curve"}
],
}
],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
# Mock a 3-sat input fee from the Cashu wallet API.
mock_wallet.get_fees_for_proofs = Mock(return_value=3)
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://mint:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.proofs = [{"amount": 1000}]
mock_deserialize.return_value = mock_token
mock_wallet.load_mint_keysets = AsyncMock()
mock_wallet.activate_keyset = AsyncMock()
mock_wallet._expand_short_keyset_ids = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
# Patch get_wallet directly so the module-level `_wallets` cache
# (keyed by mint URL) can't hand back a wallet from another test.
with patch(
"routstr.wallet.get_wallet",
AsyncMock(return_value=mock_wallet),
):
amount, unit, mint = await recieve_token(token_str)
assert amount == 997 # 1000 face - 3 sat input fee paid on swap
assert unit == "sat"
assert mint == "http://mint:3338"
mock_wallet.get_fees_for_proofs.assert_called_once_with(
mock_token.proofs
)
# DLEQ is verified before re-minting the incoming proofs.
mock_wallet.verify_proofs_dleq.assert_called_once_with(
mock_token.proofs
)
@pytest.mark.asyncio
async def test_recieve_token_redeems_on_issuing_mint_never_swaps() -> None:
"""A token from a secondary trusted mint stays on that mint even though a
different primary mint is configured; no cross-mint swap is attempted."""
from routstr.core.settings import settings
source = "http://secondary:3338"
primary = "http://primary:3338"
token = Mock(
mint=source,
unit="sat",
amount=100,
keysets=["keyset1"],
proofs=[Mock(amount=100)],
)
source_wallet = Mock()
redeem = AsyncMock(return_value=(99, "sat", source))
with (
patch.object(settings, "primary_mint", primary),
patch.object(settings, "cashu_mints", [primary, source]),
patch("routstr.wallet.deserialize_token_from_string", return_value=token),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=source_wallet)),
patch("routstr.wallet._redeem_same_mint", redeem),
):
result = await recieve_token("cashuAtoken", destination_unit="sat")
assert result == (99, "sat", source)
redeem.assert_awaited_once_with(source_wallet, token)
@pytest.mark.asyncio
async def test_recieve_token_rejects_unit_mismatch_before_wallet_mutation() -> None:
token = Mock(mint="http://key-mint:3338", unit="msat", keysets=["keyset"])
get_wallet = AsyncMock()
with (
patch("routstr.wallet.settings.cashu_mints", ["http://key-mint:3338"]),
patch("routstr.wallet.deserialize_token_from_string", return_value=token),
patch("routstr.wallet.get_wallet", get_wallet),
pytest.raises(ValueError, match="liability unit"),
):
await recieve_token("cashuAtoken", destination_unit="sat")
get_wallet.assert_not_awaited()
@pytest.mark.asyncio
async def test_primary_mint_failure_does_not_try_another_mint() -> None:
from routstr.core.settings import settings
from routstr.wallet import SourceMintConnectionError
source = "http://primary:3338"
destination = "http://secondary:3338"
token = Mock(
mint=source,
unit="sat",
amount=100,
keysets=["keyset1"],
proofs=[Mock(amount=100)],
)
source_wallet = Mock(
load_mint_keysets=AsyncMock(side_effect=httpx.ConnectError("mint unavailable")),
activate_keyset=AsyncMock(),
)
get_wallet = AsyncMock(return_value=source_wallet)
with (
patch.object(settings, "primary_mint", source),
patch.object(settings, "cashu_mints", [source, destination]),
patch("routstr.wallet.deserialize_token_from_string", return_value=token),
patch("routstr.wallet.get_wallet", get_wallet),
patch("routstr.wallet.logger.warning") as warning,
):
with pytest.raises(SourceMintConnectionError):
await recieve_token("cashuAtoken")
get_wallet.assert_awaited_once_with(source, "sat", load=False)
failure = next(
call.kwargs["extra"]
for call in warning.call_args_list
if call.kwargs.get("extra", {}).get("event")
== "cashu_same_mint_redemption_failed"
)
assert failure["cross_mint_fallback_attempted"] is False
assert failure["action"] == "retry_with_token_from_another_mint"
@pytest.mark.asyncio
async def test_same_mint_split_timeout_is_non_retryable() -> None:
from routstr.wallet import _redeem_same_mint
token = Mock(
keysets=["keyset1"],
mint="http://mint:3338",
unit="sat",
amount=1000,
proofs=[Mock(amount=1000)],
)
wallet = Mock(
load_mint_keysets=AsyncMock(),
activate_keyset=AsyncMock(),
_expand_short_keyset_ids=AsyncMock(),
split=AsyncMock(side_effect=httpx.ReadTimeout("response lost")),
get_fees_for_proofs=Mock(return_value=0),
)
with pytest.raises(TokenConsumedError, match="outcome is ambiguous") as caught:
await _redeem_same_mint(wallet, token)
classified = classify_redemption_error(caught.value)
assert classified is not None
assert classified[0] == "token_consumed"
assert classified[1] == 500
assert classified[3] == "cashu_token_consumed"
@pytest.mark.asyncio
async def test_same_mint_split_connect_error_remains_retryable() -> None:
from routstr.wallet import SourceMintConnectionError, _redeem_same_mint
token = Mock(
keysets=["keyset1"],
mint="http://mint:3338",
unit="sat",
amount=1000,
proofs=[Mock(amount=1000)],
)
wallet = Mock(
load_mint_keysets=AsyncMock(),
activate_keyset=AsyncMock(),
_expand_short_keyset_ids=AsyncMock(),
split=AsyncMock(side_effect=httpx.ConnectError("connect failed")),
get_fees_for_proofs=Mock(return_value=0),
)
with pytest.raises(SourceMintConnectionError):
await _redeem_same_mint(wallet, token)
@pytest.mark.asyncio
async def test_send_token() -> None:
mock_wallet = Mock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.wallet.send", return_value=(1000, "test_token")):
token = await send_token(1000, "sat", "http://mint:3338")
assert token == "test_token"
@pytest.mark.asyncio
async def test_owner_only_token_rejects_customer_backed_proofs() -> None:
mint = "http://mint:3338"
proof = Mock(amount=1000, reserved=False)
wallet = Mock(keysets={}, proofs=[proof], select_to_send=AsyncMock())
with (
patch(
"routstr.wallet.find_trusted_mint_with_funds",
AsyncMock(return_value=mint),
),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
return_value=[proof],
),
patch(
"routstr.wallet._owner_balance_for_mint_and_unit",
AsyncMock(return_value=50),
),
pytest.raises(ValueError, match="Owner Cashu balance"),
):
await send_token_from_owner_locked(100, "sat", mint)
wallet.select_to_send.assert_not_awaited()
@pytest.mark.asyncio
async def test_release_token_reservation_unreserves_local_proofs() -> None:
from routstr.wallet import release_token_reservation
token_proof = Mock(secret="proof-secret", reserved=True)
cached_proof = Mock(secret="proof-secret", reserved=True)
token = Mock(mint="http://mint:3338", unit="sat", proofs=[token_proof])
wallet = Mock(
proofs=[cached_proof],
load_proofs=AsyncMock(),
set_reserved_for_send=AsyncMock(),
)
with (
patch("routstr.wallet.deserialize_token_from_string", return_value=token),
patch(
"routstr.wallet.get_wallet", AsyncMock(return_value=wallet)
) as get_wallet,
):
await release_token_reservation("cashu-token")
get_wallet.assert_awaited_once_with("http://mint:3338", "sat", load=False)
wallet.load_proofs.assert_awaited_once_with(reload=True)
wallet.set_reserved_for_send.assert_awaited_once_with(token.proofs, reserved=False)
assert token_proof.reserved is False
assert cached_proof.reserved is False
@pytest.mark.asyncio
async def test_refund_mint_falls_back_to_trusted_mint_with_funds() -> None:
from routstr.core.settings import settings
from routstr.wallet import find_trusted_mint_with_funds
primary = "http://primary:3338"
secondary = "http://secondary:3338"
def wallet_for(mint: str, amount: int) -> Mock:
keyset = Mock(id=f"keyset-{mint}", mint_url=mint)
keyset.unit.name = "sat"
proof = Mock(id=keyset.id, amount=amount, reserved=False)
return Mock(keysets={keyset.id: keyset}, proofs=[proof])
wallets = {
primary: wallet_for(primary, 50),
secondary: wallet_for(secondary, 200),
}
with (
patch.object(settings, "primary_mint", primary),
patch.object(settings, "cashu_mints", [primary, secondary]),
patch(
"routstr.wallet.get_wallet",
AsyncMock(side_effect=lambda mint, *args, **kwargs: wallets[mint]),
),
):
mint = await find_trusted_mint_with_funds(100, "sat", primary)
assert mint == secondary
@pytest.mark.asyncio
async def test_send_refreshes_reservations_inside_wallet_guard() -> None:
mint = "http://mint:3338"
proof = Mock(amount=1000, reserved=False)
wallet = Mock(
keysets={},
proofs=[proof],
select_to_send=AsyncMock(return_value=([proof], None)),
serialize_proofs=AsyncMock(return_value="token"),
set_reserved_for_send=AsyncMock(),
)
inside_guard = False
@asynccontextmanager
async def operation_guard() -> AsyncIterator[None]:
nonlocal inside_guard
inside_guard = True
try:
yield
finally:
inside_guard = False
async def find_mint(
amount: int,
unit: str,
preferred_mint: str | None,
*,
force_reload: bool,
) -> str:
assert inside_guard
assert (amount, unit, preferred_mint, force_reload) == (
1000,
"sat",
mint,
True,
)
return mint
async def get_loaded_wallet(*_: object, **__: object) -> Mock:
assert inside_guard
return wallet
with (
patch("routstr.wallet.wallet_operation_guard", operation_guard),
patch("routstr.wallet.find_trusted_mint_with_funds", side_effect=find_mint),
patch("routstr.wallet.get_wallet", side_effect=get_loaded_wallet),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
return_value=[proof],
),
):
assert await send(1000, "sat", mint) == (1000, "token")
wallet.set_reserved_for_send.assert_awaited_once_with([proof], reserved=True)
@pytest.mark.asyncio
async def test_send_falls_back_when_preferred_mint_has_only_reserved_balance() -> None:
from routstr.core.settings import settings
preferred = "http://preferred:3338"
primary = "http://primary:3338"
preferred_wallet = Mock(keysets={}, proofs=[])
preferred_wallet.select_to_send = AsyncMock()
primary_wallet = Mock(keysets={}, proofs=[])
primary_wallet.select_to_send = AsyncMock()
primary_wallet.serialize_proofs = AsyncMock(return_value="primary-token")
primary_wallet.set_reserved_for_send = AsyncMock()
preferred_liquid = Mock(amount=500, reserved=False)
preferred_reserved = Mock(amount=600, reserved=True)
primary_liquid = Mock(amount=1000, reserved=False)
primary_wallet.select_to_send.return_value = ([primary_liquid], None)
async def get_wallet(mint_url: str, unit: str, **_: object) -> Mock:
assert unit == "sat"
return primary_wallet if mint_url == primary else preferred_wallet
def get_proofs(
wallet: Mock,
mint_url: str,
unit: str,
*,
not_reserved: bool = False,
) -> list[Mock]:
assert unit == "sat"
if wallet is primary_wallet:
assert mint_url == primary
proofs = [primary_liquid]
else:
assert mint_url == preferred
proofs = [preferred_liquid, preferred_reserved]
return (
[proof for proof in proofs if not proof.reserved]
if not_reserved
else proofs
)
with (
patch.object(settings, "primary_mint", primary),
patch.object(settings, "cashu_mints", [primary, preferred]),
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
patch("routstr.wallet.get_proofs_per_mint_and_unit", side_effect=get_proofs),
):
amount, token = await send(1000, "sat", preferred)
assert (amount, token) == (1000, "primary-token")
preferred_wallet.select_to_send.assert_not_awaited()
primary_wallet.select_to_send.assert_awaited_once_with(
[primary_liquid], 1000, set_reserved=False, include_fees=False
)
@pytest.mark.asyncio
async def test_send_primary_with_only_reserved_proofs_still_raises() -> None:
from routstr.core.settings import settings
primary = "http://primary:3338"
wallet = Mock(keysets={}, proofs=[])
wallet.select_to_send = AsyncMock()
reserved = Mock(amount=1000, reserved=True)
def get_proofs(
_wallet: Mock,
_mint_url: str,
_unit: str,
*,
not_reserved: bool = False,
) -> list[Mock]:
return [] if not_reserved else [reserved]
with (
patch.object(settings, "primary_mint", primary),
patch.object(settings, "cashu_mints", [primary]),
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
patch("routstr.wallet.get_proofs_per_mint_and_unit", side_effect=get_proofs),
pytest.raises(ValueError, match="No trusted mint has"),
):
await send(1000, "sat", primary)
wallet.select_to_send.assert_not_awaited()
@pytest.mark.asyncio
async def test_credit_balance() -> None:
token_data = {
"token": [{"mint": "http://mint:3338", "proofs": [{"amount": 1000}]}],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_key = Mock()
mock_key.balance = 5000000
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
mock_session.exec.return_value.rowcount = 1
# Mock session.refresh to update the balance (simulates DB reload)
async def mock_refresh(key: ApiKey) -> None:
key.balance = 6000000
mock_session.refresh.side_effect = mock_refresh
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(1000, "sat", "http://mint:3338"),
):
with patch("routstr.wallet.store_cashu_transaction", AsyncMock()):
amount = await credit_balance(token_str, mock_key, mock_session)
assert amount == 1000000 # converted to msat
assert mock_key.balance == 6000000 # Should be updated after refresh
# Verify atomic operations were used
assert mock_session.exec.called # Atomic UPDATE statement
assert mock_session.commit.called
assert mock_session.refresh.called
@pytest.mark.asyncio
async def test_concurrent_duplicate_token_credits_exactly_once() -> None:
key = Mock(balance=0, hashed_key="duplicate-key")
session = AsyncMock()
session.exec.return_value.rowcount = 1
session.refresh = AsyncMock()
receive = AsyncMock(
side_effect=[
(1000, "sat", "https://mint.test"),
ValueError("Mint Error: proofs already spent (Code: 11001)"),
]
)
store = AsyncMock()
with (
patch("routstr.wallet.recieve_token", receive),
patch("routstr.wallet.store_cashu_transaction", store),
):
results = await asyncio.gather(
credit_balance("cashuAduplicate", key, session),
credit_balance("cashuAduplicate", key, session),
return_exceptions=True,
)
assert sum(result == 1_000_000 for result in results) == 1
failure = next(result for result in results if isinstance(result, Exception))
classified = classify_redemption_error(failure)
assert classified is not None and classified[3] == "cashu_token_already_spent"
assert session.exec.await_count == 1
store.assert_awaited_once()
@pytest.mark.asyncio
async def test_credit_balance_redeems_on_token_mint_not_key_mint() -> None:
"""Top-ups are redeemed where the token was issued; the key's bound mint is
only a refund preference, never a swap destination."""
key_mint = "http://key-mint:3338"
mock_key = Mock(
balance=1_000_000,
hashed_key="test_hash",
refund_mint_url=key_mint,
refund_currency="sat",
)
mock_session = AsyncMock()
mock_session.exec.return_value.rowcount = 1
receive = AsyncMock(return_value=(1000, "sat", key_mint))
with patch("routstr.wallet.recieve_token", receive):
with patch("routstr.wallet.store_cashu_transaction", AsyncMock()):
await credit_balance("cashuAtoken", mock_key, mock_session)
receive.assert_awaited_once_with("cashuAtoken", destination_unit="sat")
@pytest.mark.asyncio
async def test_credit_balance_rejects_zero_amount() -> None:
"""A zero/dust redemption must raise BEFORE any commit, so no orphan
zero-balance key (balance 0, total_spent 0, total_requests 0) is persisted."""
token_data = {
"token": [{"mint": "http://mint:3338", "proofs": [{"amount": 0}]}],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(0, "sat", "http://mint:3338"),
):
with pytest.raises(ValueError, match="must be positive"):
await credit_balance(token_str, mock_key, mock_session)
# Critically: no balance UPDATE and no commit happened, so the caller's
# uncommitted key row rolls back instead of persisting as an orphan.
assert not mock_session.exec.called
assert not mock_session.commit.called
@pytest.mark.asyncio
async def test_credit_balance_rejects_missing_key() -> None:
"""A top-up must fail if the key was pruned after redemption."""
token_data = {
"token": [{"mint": "http://mint:3338", "proofs": [{"amount": 1000}]}],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
mock_session.exec.return_value.rowcount = 0
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(1000, "sat", "http://mint:3338"),
):
# Post-redemption: token already spent, so a vanished key is a
# non-retryable TokenConsumedError, not a generic token error.
with pytest.raises(TokenConsumedError, match="disappeared") as exc_info:
await credit_balance(token_str, mock_key, mock_session)
classified = classify_redemption_error(exc_info.value)
assert classified is not None
_type, status, _msg, code = classified
assert (status, code) == (500, "cashu_token_consumed")
# UPDATE matched nothing; committing would hide the failed credit.
assert mock_session.exec.called
assert not mock_session.commit.called
@pytest.mark.asyncio
async def test_recieve_token_untrusted_mint() -> None:
mock_wallet = Mock()
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://untrusted:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_deserialize.return_value = mock_token
with_db = AsyncMock(return_value=mock_wallet)
with patch("routstr.wallet.Wallet.with_db", with_db):
with pytest.raises(UntrustedSourceMintError):
await recieve_token("test_token")
with_db.assert_not_awaited()
@pytest.mark.asyncio
async def test_recieve_token_accepts_multiple_keysets() -> None:
"""TokenV4 groups proofs per keyset, and the node itself emits such tokens
after a mint rotates keysets, so redemption must span keysets."""
mock_wallet = Mock()
mock_wallet.split = AsyncMock()
# Cashu sums input_fee_ppk per proof's own keyset.
mock_wallet.get_fees_for_proofs = Mock(return_value=2)
mock_wallet.load_mint_keysets = AsyncMock()
mock_wallet.activate_keyset = AsyncMock()
mock_wallet._expand_short_keyset_ids = AsyncMock()
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1", "keyset2"]
mock_token.mint = "http://mint:3338"
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.proofs = [
{"amount": 600, "id": "keyset1"},
{"amount": 400, "id": "keyset2"},
]
mock_deserialize.return_value = mock_token
with patch(
"routstr.wallet.get_wallet",
AsyncMock(return_value=mock_wallet),
):
amount, unit, mint = await recieve_token("cashuAmultikeyset")
assert (amount, unit, mint) == (998, "sat", "http://mint:3338")
mock_wallet.get_fees_for_proofs.assert_called_once_with(mock_token.proofs)
mock_wallet.split.assert_awaited_once()
@pytest.mark.asyncio
async def test_credit_balance_msat_unit_not_converted() -> None:
"""msat-denominated redemptions are credited as-is, without a 1000x."""
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(1_000_000, "msat", "http://mint:3338"),
):
with patch("routstr.wallet.store_cashu_transaction", AsyncMock()):
amount = await credit_balance("cashuAtest", mock_key, mock_session)
assert amount == 1_000_000
assert mock_session.commit.called
@pytest.mark.asyncio
async def test_credit_balance_propagates_audit_store_failure_after_credit() -> None:
"""A final transaction-history failure propagates after committing credit."""
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(1000, "sat", "http://mint:3338"),
):
with patch(
"routstr.wallet.store_cashu_transaction",
side_effect=Exception("history table locked"),
):
with pytest.raises(Exception, match="history table locked"):
await credit_balance("cashuAtest", mock_key, mock_session)
assert mock_session.commit.called
# --- Mint-unreachable classification (is_mint_connection_error) ---------------
def _chain(outer: BaseException, cause: BaseException) -> BaseException:
"""Attach ``cause`` as the ``__cause__`` of ``outer`` (as ``raise X from Y``
would) and return ``outer``."""
outer.__cause__ = cause
return outer
def test_rate_limited_mint_is_classified_as_unreachable() -> None:
from routstr.wallet import classify_redemption_error
request = httpx.Request("POST", "http://mint:3338/v1/swap")
response = httpx.Response(429, request=request)
error = httpx.HTTPStatusError("rate limited", request=request, response=response)
assert classify_redemption_error(error) == (
"mint_rate_limited",
503,
"Cashu mint is rate-limiting requests; retry later",
"cashu_mint_rate_limited",
)
@pytest.mark.parametrize(
"error",
[
httpx.ConnectError("connection refused"),
httpx.ConnectTimeout("timed out"),
httpx.ReadTimeout("read timed out"), # subclass of TimeoutException
httpx.PoolTimeout("pool timed out"),
httpx.WriteError("write failed"), # subclass of NetworkError
ConnectionRefusedError("refused"), # subclass of ConnectionError
ConnectionResetError("reset"),
socket.gaierror("Name or service not known"),
TimeoutError("timed out"), # asyncio.TimeoutError alias on 3.11+
MintConnectionError("mint down"),
# Wrapped: the real transport error survives in the __cause__ chain.
_chain(ValueError("Failed to estimate fees: boom"), httpx.ConnectError("x")),
# Two levels deep.
_chain(
RuntimeError("outer"),
_chain(ValueError("mid"), httpx.ConnectTimeout("deep")),
),
],
)
def test_is_mint_connection_error_detects_transport_failures(
error: BaseException,
) -> None:
assert is_mint_connection_error(error) is True
@pytest.mark.parametrize(
"error",
[
ValueError("token already spent"),
ValueError("Mint unreachable: all connection attempts failed"), # text only
ValueError("Invalid Cashu token"),
# Mint answered with an error status — reachable, so NOT a connection error.
httpx.HTTPStatusError(
"500",
request=httpx.Request("POST", "http://m"),
response=httpx.Response(500),
),
RuntimeError("some internal fault"),
],
)
def test_is_mint_connection_error_ignores_non_transport(error: BaseException) -> None:
assert is_mint_connection_error(error) is False
def test_is_mint_connection_error_survives_reference_cycle() -> None:
"""A pathological cause/context cycle must not hang the classifier."""
a = ValueError("a")
b = ValueError("b")
a.__cause__ = b
b.__context__ = a
assert is_mint_connection_error(a) is False
def test_token_consumed_seals_transport_cause() -> None:
"""A transport error wrapped in TokenConsumedError is NOT retryable — the
token is spent, so the seal wins over the httpx cause underneath."""
try:
raise httpx.ConnectError("mint down")
except httpx.ConnectError as exc:
consumed = TokenConsumedError("credit failed")
consumed.__cause__ = exc
assert is_mint_connection_error(consumed) is False
classified = classify_redemption_error(consumed)
assert classified is not None
type_, status, _msg, code = classified
assert (type_, status, code) == ("token_consumed", 500, "cashu_token_consumed")
@pytest.mark.parametrize(
"error",
[
# The message credit_balance raises for a dust/zero redemption.
ValueError("Redeemed token amount must be positive, got 0 msats"),
ValueError("Redeemed token amount must be positive, got -5 msats"),
ValueError("Failed to redeem Cashu token: token yielded no value"),
],
)
def test_classify_zero_value(error: ValueError) -> None:
"""A zero/negative redemption gets its own documented code, not the generic
cashu_token_redemption_failed bucket."""
classified = classify_redemption_error(error)
assert classified is not None
type_, status, _msg, code = classified
assert (type_, status, code) == ("cashu_error", 400, "cashu_token_zero_value")
def test_classify_generic_valueerror_is_not_zero_value() -> None:
"""A generic wallet ValueError still falls to the generic bucket — the
zero-value match must not over-trigger."""
classified = classify_redemption_error(
ValueError("some unexpected wallet condition")
)
assert classified is not None
type_, status, _msg, code = classified
assert (type_, status, code) == (
"cashu_error",
400,
"cashu_token_redemption_failed",
)
@pytest.mark.asyncio
async def test_credit_balance_db_transport_error_is_token_consumed() -> None:
"""A transport-like DB failure after the token is redeemed must be
non-retryable (token_consumed), not a retryable mint_unreachable."""
mock_key = Mock()
mock_key.balance = 1000
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
mock_session.exec = AsyncMock(side_effect=ConnectionError("db connection reset"))
with patch(
"routstr.wallet.recieve_token",
return_value=(100, "sat", "https://mint.example"),
):
with pytest.raises(TokenConsumedError) as exc_info:
await credit_balance("cashuAtoken", mock_key, mock_session)
assert is_mint_connection_error(exc_info.value) is False
@pytest.mark.asyncio
async def test_execute_bolt11_payment_rejects_unpaid_melt_state() -> None:
plan = MagicMock()
plan.proofs = [MagicMock(amount=110)]
plan.quote.amount = 100
plan.quote.fee_reserve = 10
plan.quote.quote = "quote-1"
plan.invoice = "lnbc-invoice"
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
plan.wallet.set_reserved_for_send = AsyncMock()
plan.wallet.melt = AsyncMock(return_value=MagicMock(state="UNPAID", change=[]))
with pytest.raises(Bolt11PaymentNotAttempted):
await execute_bolt11_payment(plan)
# An explicit unpaid answer means the proofs are ours again.
plan.wallet.set_reserved_for_send.assert_awaited_with(plan.proofs, reserved=False)
@pytest.mark.asyncio
async def test_execute_bolt11_payment_accepts_legacy_paid_response() -> None:
plan = MagicMock()
plan.proofs = [MagicMock(amount=110)]
plan.quote.amount = 100
plan.quote.fee_reserve = 10
plan.quote.quote = "quote-1"
plan.invoice = "lnbc-invoice"
plan.mint_url = "https://mint.test"
plan.unit = "sat"
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
plan.wallet.set_reserved_for_send = AsyncMock()
plan.wallet.melt = AsyncMock(
return_value=MagicMock(state=None, paid=True, change=[])
)
assert await execute_bolt11_payment(plan) == (
110,
"https://mint.test",
"sat",
)
@pytest.mark.asyncio
async def test_execute_bolt11_payment_keeps_proofs_reserved_when_melt_errors() -> None:
plan = MagicMock()
plan.proofs = [MagicMock(amount=110)]
plan.quote.amount = 100
plan.quote.fee_reserve = 10
plan.quote.quote = "quote-1"
plan.invoice = "lnbc-invoice"
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
plan.wallet.set_reserved_for_send = AsyncMock()
plan.wallet.set_reserved_for_melt = AsyncMock()
plan.wallet.melt = AsyncMock(side_effect=TimeoutError("no answer"))
with pytest.raises(Bolt11PaymentAmbiguous):
await execute_bolt11_payment(plan)
# The mint may still settle with these proofs. cashu's own melt()
# un-reserves them on a mint transport error, so the ambiguous path must
# re-reserve — and it must do so with the melt quote id, because
# get_melt_quote() finds the proofs to settle by melt_id.
plan.wallet.set_reserved_for_melt.assert_awaited_once_with(
plan.proofs, reserved=True, quote_id="quote-1"
)
@pytest.mark.asyncio
async def test_execute_bolt11_payment_does_not_reserve_when_selection_fails() -> None:
plan = MagicMock()
plan.proofs = [MagicMock(amount=110)]
plan.quote.amount = 100
plan.quote.fee_reserve = 10
plan.wallet.select_to_send = AsyncMock(side_effect=ValueError("insufficient"))
plan.wallet.set_reserved_for_send = AsyncMock()
plan.wallet.melt = AsyncMock()
with pytest.raises(Bolt11PaymentNotAttempted):
await execute_bolt11_payment(plan)
plan.wallet.set_reserved_for_send.assert_not_awaited()
plan.wallet.melt.assert_not_awaited()
@pytest.mark.asyncio
async def test_prepare_bolt11_payment_counts_input_fees_in_sufficiency() -> None:
from routstr.core.settings import settings
wallet = MagicMock()
wallet.proofs = [MagicMock(amount=105)]
wallet.melt_quote = AsyncMock(
return_value=MagicMock(amount=100, fee_reserve=2, quote="quote-1")
)
# Balance covers amount + fee_reserve (102) but not the 5 sat input fee.
wallet.get_fees_for_proofs = Mock(return_value=5)
async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock:
if unit == "msat":
raise ValueError("unit unsupported")
return wallet
with (
patch.object(settings, "cashu_mints", ["https://only.test"]),
patch.object(settings, "primary_mint", "https://only.test"),
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
side_effect=lambda wallet, *args, **kwargs: wallet.proofs,
),
patch(
"routstr.wallet.slow_filter_spend_proofs",
side_effect=lambda proofs, wallet: proofs,
),
pytest.raises(ValueError, match="enough balance"),
):
await prepare_bolt11_payment("lnbc-invoice")
@pytest.mark.asyncio
async def test_prepare_bolt11_payment_does_not_spend_user_liabilities() -> None:
from routstr.core.settings import settings
wallet = MagicMock()
wallet.proofs = [MagicMock(amount=500)]
wallet.melt_quote = AsyncMock(
return_value=MagicMock(amount=100, fee_reserve=2, quote="quote-1")
)
wallet.get_fees_for_proofs = Mock(return_value=0)
async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock:
if unit == "msat":
raise ValueError("unit unsupported")
return wallet
with (
patch.object(settings, "cashu_mints", ["https://only.test"]),
patch.object(settings, "primary_mint", "https://only.test"),
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
side_effect=lambda wallet, *args, **kwargs: wallet.proofs,
),
patch(
"routstr.wallet.slow_filter_spend_proofs",
side_effect=lambda proofs, wallet: proofs,
),
patch(
"routstr.wallet._owner_balance_for_mint_and_unit",
AsyncMock(return_value=90),
),
pytest.raises(ValueError, match="user liabilities"),
):
await prepare_bolt11_payment("lnbc-invoice")
@pytest.mark.asyncio
async def test_prepare_bolt11_payment_rounds_user_liability_up_to_whole_sats() -> None:
from routstr.core.settings import settings
wallet = MagicMock()
wallet.proofs = [MagicMock(amount=100)]
wallet.melt_quote = AsyncMock(
return_value=MagicMock(amount=1, fee_reserve=0, quote="quote-1")
)
wallet.get_fees_for_proofs = Mock(return_value=0)
async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock:
if unit == "msat":
raise ValueError("unit unsupported")
return wallet
with (
patch.object(settings, "cashu_mints", ["https://only.test"]),
patch.object(settings, "primary_mint", "https://only.test"),
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
patch(
"routstr.wallet.get_proofs_per_mint_and_unit",
side_effect=lambda wallet, *args, **kwargs: wallet.proofs,
),
patch(
"routstr.wallet.slow_filter_spend_proofs",
side_effect=lambda proofs, wallet: proofs,
),
patch(
"routstr.wallet.db.total_user_liability",
AsyncMock(return_value=99_999),
),
pytest.raises(ValueError, match="user liabilities"),
):
await prepare_bolt11_payment("lnbc-invoice")
@pytest.mark.asyncio
async def test_execute_bolt11_payment_rereserves_when_cancelled() -> None:
plan = MagicMock()
plan.proofs = [MagicMock(amount=110)]
plan.quote.amount = 100
plan.quote.fee_reserve = 10
plan.quote.quote = "quote-1"
plan.invoice = "lnbc-invoice"
plan.mint_url = "https://mint.test"
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
plan.wallet.set_reserved_for_send = AsyncMock()
plan.wallet.set_reserved_for_melt = AsyncMock()
plan.wallet.melt = AsyncMock(side_effect=asyncio.CancelledError())
with pytest.raises(asyncio.CancelledError):
await execute_bolt11_payment(plan)
plan.wallet.set_reserved_for_melt.assert_awaited_once_with(
plan.proofs, reserved=True, quote_id="quote-1"
)
# ---------------------------------------------------------------------------
# Per-mint adaptive guard + _mint_operation factory/retry
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_balance_proof_check_uses_large_batches_to_avoid_rate_limit() -> None:
"""Balance reads must not turn a few hundred proofs into many mint requests."""
from cashu.core.base import ProofSpentState
from routstr.wallet import slow_filter_spend_proofs
proofs = [Mock(Y=str(i)) for i in range(250)]
states = [Mock(Y=proof.Y, state=ProofSpentState.unspent) for proof in proofs]
wallet = Mock()
wallet.url = "http://mint:3338"
wallet.check_proof_state = AsyncMock(return_value=Mock(states=states))
wallet.set_reserved_for_send = AsyncMock()
result = await slow_filter_spend_proofs(proofs, wallet)
assert result == proofs
wallet.check_proof_state.assert_awaited_once_with(proofs)
wallet.set_reserved_for_send.assert_not_awaited()
@pytest.mark.asyncio
async def test_mint_rate_guard_bounds_concurrency() -> None:
from routstr.wallet import _MintRateGuard
guard = _MintRateGuard("http://mint:3338", 2)
active = 0
peak = 0
async def operation() -> None:
nonlocal active, peak
active += 1
peak = max(peak, active)
await asyncio.sleep(0)
active -= 1
await asyncio.gather(*(guard.run(operation) for _ in range(5)))
assert peak == 2
@pytest.mark.asyncio
async def test_mint_rate_guard_waits_for_adaptive_cooldown() -> None:
from routstr.wallet import _MintRateGuard
guard = _MintRateGuard("http://mint:3338", 2)
guard._cooldown_until = 15.0
operation = AsyncMock(return_value="ok")
with patch("routstr.mint.time.monotonic", return_value=10.0):
with patch("routstr.mint.asyncio.sleep", AsyncMock()) as sleep:
assert await guard.run(operation) == "ok"
sleep.assert_awaited_once_with(5.0)
operation.assert_awaited_once()
@pytest.mark.asyncio
async def test_mint_rate_guard_exponentially_backs_off_repeated_429s() -> None:
from routstr.wallet import _MintRateGuard
guard = _MintRateGuard("http://mint:3338", 4)
expected_delays = [60, 120, 240, 480, 960, 1920, 3840, 7680, 15360, 25200]
now = 0.0
with patch("routstr.mint.time.monotonic") as monotonic:
for index, expected in enumerate(expected_delays, start=1):
monotonic.return_value = now
assert guard.apply_rate_limit_cooldown(60) == expected
assert guard._consecutive_rate_limits == index
if index == 1:
# Concurrent responses from the same 429 wave do not escalate
# the retry count before the first cooldown probe.
assert guard.apply_rate_limit_cooldown(60) == expected
assert guard._consecutive_rate_limits == 1
now += expected + 1
monotonic.return_value = now
operation = AsyncMock(return_value="ok")
assert await guard.run(operation) == "ok"
assert guard._consecutive_rate_limits == 0
assert guard.apply_rate_limit_cooldown(60) == 60
@pytest.mark.asyncio
async def test_mint_rate_guard_allows_one_probe_after_cooldown() -> None:
from routstr.wallet import _MintRateGuard
guard = _MintRateGuard("http://mint:3338", 4)
guard.apply_cooldown(0)
probe_started = asyncio.Event()
release_probe = asyncio.Event()
calls = 0
async def operation() -> int:
nonlocal calls
calls += 1
if calls == 1:
probe_started.set()
await release_probe.wait()
return calls
tasks = [asyncio.create_task(guard.run(operation)) for _ in range(5)]
await probe_started.wait()
await asyncio.sleep(0)
assert calls == 1
release_probe.set()
await asyncio.gather(*tasks)
assert calls == 5
assert guard._needs_probe is False
def test_mint_rate_guard_rebuilds_when_setting_changes() -> None:
from routstr.core.settings import settings
from routstr.wallet import _MintRateGuard
with patch.object(settings, "mint_max_concurrency", 4):
first = _MintRateGuard.get("http://mint:3338")
with patch.object(settings, "mint_max_concurrency", 2):
second = _MintRateGuard.get("http://mint:3338")
assert first is not None
assert second is not None
assert first is not second
assert second._max_concurrency == 2
@pytest.mark.asyncio
async def test_mint_rate_guard_keeps_cooldown_when_concurrency_is_unlimited() -> None:
from routstr.core.settings import settings
from routstr.wallet import _MintRateGuard
operation = AsyncMock(return_value="ok")
with (
patch.object(settings, "mint_max_concurrency", 0),
patch("routstr.mint.time.monotonic", return_value=0),
patch("routstr.mint.asyncio.sleep", AsyncMock()) as sleep,
):
guard = _MintRateGuard.get("http://mint:3338")
guard.apply_cooldown(5)
assert await guard.run(operation) == "ok"
sleep.assert_awaited_once_with(5)
operation.assert_awaited_once()
@pytest.mark.asyncio
async def test_mint_operation_honors_retry_after_as_minimum() -> None:
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
request = httpx.Request("POST", "http://mint:3338/v1/mint/quote/bolt11")
response = httpx.Response(429, request=request, headers={"Retry-After": "60"})
calls = 0
async def factory() -> str:
nonlocal calls
calls += 1
if calls == 1:
raise httpx.HTTPStatusError(
"rate limited", request=request, response=response
)
return "ok"
sleep = AsyncMock()
with patch.object(settings, "mint_retry_max_attempts", 1):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch.object(settings, "mint_max_concurrency", 1):
with patch("routstr.mint.time.monotonic", return_value=0.1):
with patch("routstr.mint.asyncio.sleep", sleep):
result = await _mint_operation(
factory, mint_url="http://mint:3338"
)
assert result == "ok"
sleep.assert_awaited_once_with(60.0)
@pytest.mark.asyncio
async def test_mint_operation_timeout_excludes_adaptive_cooldown() -> None:
from routstr.core.settings import settings
from routstr.wallet import _mint_operation, _MintRateGuard
operation = AsyncMock(return_value="ok")
with (
patch.object(settings, "mint_max_concurrency", 1),
patch.object(settings, "mint_operation_timeout_seconds", 0.01),
patch("routstr.mint.asyncio.sleep", AsyncMock()) as sleep,
):
guard = _MintRateGuard.get("http://mint:3338")
guard.apply_cooldown(60)
assert await _mint_operation(operation, mint_url="http://mint:3338") == "ok"
sleep.assert_awaited_once()
operation.assert_awaited_once()
@pytest.mark.asyncio
async def test_default_timeout_allows_retry_after_rate_limit_cooldown() -> None:
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
request = httpx.Request("POST", "http://mint:3338/v1/mint/quote/bolt11")
response = httpx.Response(429, request=request)
operation = AsyncMock(
side_effect=[
httpx.HTTPStatusError("rate limited", request=request, response=response),
"ok",
]
)
with (
patch.object(settings, "mint_retry_max_attempts", 3),
patch.object(settings, "mint_operation_timeout_seconds", 30),
patch.object(settings, "mint_max_concurrency", 1),
patch("routstr.mint.asyncio.sleep", AsyncMock()),
):
assert await _mint_operation(operation, mint_url="http://mint:3338") == "ok"
assert operation.await_count == 2
@pytest.mark.asyncio
async def test_mint_operation_retries_httpx_timeout_only_when_safe() -> None:
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
retrying = AsyncMock(side_effect=[httpx.ReadTimeout("slow"), "ok"])
non_retrying = AsyncMock(side_effect=httpx.ReadTimeout("ambiguous"))
with patch.object(settings, "mint_retry_max_attempts", 2):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch("routstr.mint.asyncio.sleep", AsyncMock()):
assert await _mint_operation(retrying) == "ok"
with pytest.raises(httpx.TimeoutException):
await _mint_operation(non_retrying, retry_timeouts=False)
assert retrying.await_count == 2
assert non_retrying.await_count == 1
@pytest.mark.asyncio
async def test_get_wallet_initializes_and_loads_once_concurrently() -> None:
from routstr.wallet import get_wallet
mock_wallet = Mock()
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch(
"routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)
) as create:
# A fresh wallet must load even when the host has been up for less than
# the reload interval.
with patch("routstr.mint.time.monotonic", return_value=10.0):
first, second = await asyncio.gather(
get_wallet("http://mint:3338"), get_wallet("http://mint:3338")
)
assert first is second is mock_wallet
create.assert_awaited_once()
mock_wallet.load_mint.assert_awaited_once()
mock_wallet.load_proofs.assert_awaited_once_with(reload=True)
@pytest.mark.asyncio
async def test_get_wallet_can_surface_429_without_retrying() -> None:
from routstr.core.settings import settings
from routstr.wallet import get_wallet
request = httpx.Request("GET", "http://mint:3338/v1/info")
response = httpx.Response(429, request=request, headers={"Retry-After": "60"})
wallet = Mock(
load_mint=AsyncMock(
side_effect=httpx.HTTPStatusError(
"rate limited", request=request, response=response
)
),
load_proofs=AsyncMock(),
)
with (
patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=wallet)),
patch.object(settings, "mint_retry_max_attempts", 3),
patch.object(settings, "mint_operation_timeout_seconds", 0),
patch("routstr.mint.asyncio.sleep", AsyncMock()) as sleep,
):
with pytest.raises(httpx.HTTPStatusError):
await get_wallet("http://mint:3338", retry_on_rate_limit=False)
wallet.load_mint.assert_awaited_once()
wallet.load_proofs.assert_not_awaited()
sleep.assert_not_awaited()
@pytest.mark.asyncio
async def test_mint_operation_factory_retry_succeeds() -> None:
"""_mint_operation accepts a zero-arg factory, not a dead coroutine.
A factory that raises twice then succeeds must be retried and return."""
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
calls = 0
async def factory() -> str:
nonlocal calls
calls += 1
if calls < 3:
raise TimeoutError("timeout")
return "ok"
with patch.object(settings, "mint_retry_max_attempts", 3):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch("asyncio.sleep", AsyncMock()):
result = await _mint_operation(
factory, op_name="test_retry", mint_url="http://mint:3338"
)
assert calls == 3
assert result == "ok"
@pytest.mark.asyncio
async def test_mint_operation_factory_retry_exhausted() -> None:
"""When the factory always times out, _mint_operation raises
httpx.TimeoutException after mint_retry_max_attempts + 1 attempts."""
from routstr.core.settings import settings
from routstr.wallet import _mint_operation
calls = 0
async def factory() -> None:
nonlocal calls
calls += 1
raise TimeoutError("always timeout")
with patch.object(settings, "mint_retry_max_attempts", 2):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch("asyncio.sleep", AsyncMock()):
with pytest.raises(httpx.TimeoutException):
await _mint_operation(
factory, op_name="test_exhaust", mint_url="http://mint:3338"
)
assert calls == 3 # max_attempts(2) + 1
# ---------------------------------------------------------------------------
# Trusted-mint fallback
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_lightning_mint_fallback_for_topups() -> None:
"""When the primary mint is unreachable, _request_mint_with_fallback
falls back to a secondary trusted mint."""
from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback
primary = "http://primary:3338"
secondary = "http://secondary:3338"
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(
side_effect=httpx.ConnectError("primary down")
)
mock_quote = Mock()
mock_quote.request = "lnbc1secondary"
mock_quote.quote = "quote_secondary"
mock_secondary_wallet = Mock()
mock_secondary_wallet.request_mint = AsyncMock(return_value=mock_quote)
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
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("routstr.lightning.get_wallet", side_effect=mock_get):
bolt11, quote_id, mint_url = await _request_mint_with_fallback(
1000
)
assert mint_url == secondary
assert bolt11 == "lnbc1secondary"
assert quote_id == "quote_secondary"
mock_primary_wallet.request_mint.assert_called_once()
mock_secondary_wallet.request_mint.assert_called_once()
def test_raise_on_error_request_identifies_cashu_mint_error() -> None:
from routstr.mint import MintError
from routstr.wallet import Wallet
response = httpx.Response(
400,
request=httpx.Request("POST", "https://mint.example/v1/swap"),
json={"detail": "Token already spent.", "code": 11001},
)
with pytest.raises(MintError) as captured:
Wallet.raise_on_error_request(response)
assert captured.value.detail == "Token already spent."
assert captured.value.code == 11001
assert str(captured.value) == "Mint Error: Token already spent. (Code: 11001)"
@pytest.mark.asyncio
async def test_lightning_mint_fallback_on_cashu_json_429() -> None:
"""The real Cashu JSON-error adapter preserves 429 for fallback."""
from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback
from routstr.wallet import MintRateLimitedError, Wallet
primary = "http://primary:3338"
secondary = "http://secondary:3338"
request = httpx.Request("POST", f"{primary}/v1/mint/quote/bolt11")
response = httpx.Response(
429,
request=request,
json={"detail": "too many requests", "code": 0},
)
with pytest.raises(MintRateLimitedError) as captured:
Wallet.raise_on_error_request(response)
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(side_effect=captured.value)
mock_quote = Mock(request="lnbc1secondary", quote="quote_secondary")
mock_secondary_wallet = Mock()
mock_secondary_wallet.request_mint = AsyncMock(return_value=mock_quote)
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
with patch.object(settings, "cashu_mints", [primary, secondary]):
with patch.object(settings, "mint_retry_max_attempts", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch(
"routstr.lightning.get_wallet", side_effect=mock_get
):
(
bolt11,
quote_id,
mint_url,
) = await _request_mint_with_fallback(1000)
assert mint_url == secondary
mock_secondary_wallet.request_mint.assert_called_once()
@pytest.mark.asyncio
async def test_lightning_mint_fallback_all_fail() -> None:
"""When every trusted mint fails, _request_mint_with_fallback raises
MintConnectionError instead of trying indefinitely."""
from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback
from routstr.wallet import MintConnectionError
primary = "http://primary:3338"
secondary = "http://secondary:3338"
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(side_effect=httpx.ConnectError("down"))
mock_secondary_wallet = Mock()
mock_secondary_wallet.request_mint = AsyncMock(
side_effect=httpx.ConnectError("down")
)
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
with patch.object(settings, "cashu_mints", [primary, secondary]):
with patch.object(settings, "mint_retry_max_attempts", 0):
with patch.object(settings, "mint_max_concurrency", 0):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch(
"routstr.lightning.get_wallet", side_effect=mock_get
):
with pytest.raises(MintConnectionError):
await _request_mint_with_fallback(1000)
@pytest.mark.asyncio
async def test_lightning_mint_fallback_rejects_zero_amount() -> None:
"""Zero or negative amounts must be rejected before reaching the mint."""
from routstr.lightning import _request_mint_with_fallback
with pytest.raises(ValueError, match="amount_sats must be > 0"):
await _request_mint_with_fallback(0)
with pytest.raises(ValueError, match="amount_sats must be > 0"):
await _request_mint_with_fallback(-5)
@pytest.mark.asyncio
async def test_lightning_fallback_on_429_no_in_place_retry() -> None:
"""Same as above but for the lightning.py _request_mint_with_fallback."""
from routstr.core.settings import settings
from routstr.lightning import _request_mint_with_fallback
primary = "http://primary:3338"
secondary = "http://secondary:3338"
request = httpx.Request("POST", "http://primary:3338/v1/mint/quote/bolt11")
response = httpx.Response(429, request=request, headers={"Retry-After": "60"})
primary_call_count = 0
async def primary_request_mint(_amount: int) -> None:
nonlocal primary_call_count
primary_call_count += 1
raise httpx.HTTPStatusError("rate limited", request=request, response=response)
mock_primary_wallet = Mock()
mock_primary_wallet.request_mint = AsyncMock(side_effect=primary_request_mint)
mock_quote = Mock(quote="q_secondary", request="lnbc1secondary")
mock_secondary_wallet = Mock()
mock_secondary_wallet.request_mint = AsyncMock(return_value=mock_quote)
wallets_map = {primary: mock_primary_wallet, secondary: mock_secondary_wallet}
mock_get = AsyncMock(side_effect=lambda m, *a, **kw: wallets_map[m])
with patch.object(settings, "primary_mint", primary):
with patch.object(settings, "cashu_mints", [primary, secondary]):
with patch.object(settings, "mint_retry_max_attempts", 3):
with patch.object(settings, "mint_max_concurrency", 0):
with patch.object(settings, "mint_operation_timeout_seconds", 0):
with patch("asyncio.sleep", AsyncMock()) as mock_sleep:
with patch(
"routstr.lightning.get_wallet", side_effect=mock_get
):
_, _, first_mint = await _request_mint_with_fallback(
1000
)
_, _, second_mint = await _request_mint_with_fallback(
1000
)
assert first_mint == second_mint == secondary
assert primary_call_count == 1
assert mock_secondary_wallet.request_mint.await_count == 2
mock_sleep.assert_not_called()
# ---------------------------------------------------------------------------
# _is_mint_rate_limited — strict HTTP 429 only (no substring matching)
# ---------------------------------------------------------------------------
def _http_429_error(message: str = "") -> httpx.HTTPStatusError:
"""Create an HTTP 429 error with optional message in the response body."""
body = json.dumps({"error": message}) if message else "{}"
return httpx.HTTPStatusError(
message or "Too Many Requests",
request=httpx.Request("POST", "http://m"),
response=httpx.Response(429, content=body.encode()),
)
def _http_500_error(message: str = "") -> httpx.HTTPStatusError:
"""Create an HTTP 500 error with optional message in the response body."""
body = json.dumps({"error": message}) if message else "{}"
return httpx.HTTPStatusError(
message or "Internal Server Error",
request=httpx.Request("POST", "http://m"),
response=httpx.Response(500, content=body.encode()),
)
@pytest.mark.parametrize(
"error,expected",
[
# True: HTTP 429 is always a rate limit, regardless of message.
(_http_429_error(""), True),
(_http_429_error("Too Many Requests"), True),
(_http_429_error("completely unrelated message"), True),
# False: HTTP 500 is NOT a rate limit, even if the message says "rate limit".
(_http_500_error(""), False),
(_http_500_error("rate limit exceeded"), False),
(_http_500_error("too many requests"), False),
# False: non-HTTP errors with "rate limit" in message.
(ValueError("rate limit exceeded"), False),
(ValueError("too many requests try again"), False),
(RuntimeError("internal rate limit hit"), False),
# False: generic transport errors.
(httpx.ConnectError("connection refused"), False),
(httpx.ReadTimeout("timed out"), False),
(MintConnectionError("mint down"), False),
# Wrapped: HTTP 429 in the cause chain IS detected.
(_chain(ValueError("wrapped"), _http_429_error()), True),
# Wrapped: HTTP 500 with "rate limit" text in cause is NOT detected.
(
_chain(ValueError("wrapped"), _http_500_error("rate limit exceeded")),
False,
),
],
)
def test_is_mint_rate_limited_strictness(error: BaseException, expected: bool) -> None:
assert _is_mint_rate_limited(error) is expected
def test_is_mint_rate_limited_survives_cycle() -> None:
"""A pathological cause/context cycle must not hang the classifier."""
a = ValueError("a")
b = _http_429_error()
a.__cause__ = b
b.__context__ = a
assert _is_mint_rate_limited(a) is True
# ---------------------------------------------------------------------------
# classify_redemption_error — mint_rate_limited vs mint_unreachable
# ---------------------------------------------------------------------------
def test_classify_rate_limit_returns_mint_rate_limited() -> None:
"""HTTP 429 from a mint is classified as mint_rate_limited, not
mint_unreachable, so callers can distinguish temporary back-off from
permanent mint outages."""
classified = classify_redemption_error(_http_429_error("Too Many Requests"))
assert classified is not None
type_, status, _msg, code = classified
assert type_ == "mint_rate_limited"
assert status == 503
assert code == "cashu_mint_rate_limited"
def test_classify_rate_limit_takes_priority_over_connection_error() -> None:
"""When a 429 is wrapped in a chain that also contains a transport error,
mint_rate_limited wins because it is checked first."""
inner = _http_429_error()
outer = MintConnectionError("outer")
outer.__cause__ = inner
classified = classify_redemption_error(outer)
assert classified is not None
type_, status, _msg, code = classified
assert type_ == "mint_rate_limited"
assert code == "cashu_mint_rate_limited"
def test_classify_connection_error_still_returns_mint_unreachable() -> None:
"""Transport failures without a 429 in the chain are still
classified as mint_unreachable."""
classified = classify_redemption_error(httpx.ConnectError("connection refused"))
assert classified is not None
type_, status, _msg, code = classified
assert type_ == "mint_unreachable"
assert status == 503
assert code == "cashu_mint_unreachable"
def test_classify_500_with_rate_limit_text_is_not_mint_rate_limited() -> None:
"""An HTTP 500 whose body happens to mention 'rate limit' is NOT
classified as mint_rate_limited — it falls through to the generic
error handler."""
classified = classify_redemption_error(
_http_500_error("database rate limit exceeded")
)
# Should NOT be mint_rate_limited or mint_unreachable.
if classified is not None:
type_, _status, _msg, code = classified
assert type_ != "mint_rate_limited"
assert code != "cashu_mint_rate_limited"
# ---------------------------------------------------------------------------
# _MintRateGuard — probe backoff escalation and recovery
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_probe_escalates_consecutive_rate_limits() -> None:
from routstr.mint import MintRateGuard
guard = MintRateGuard("http://mint", max_concurrency=0)
guard.apply_rate_limit_cooldown()
guard._cooldown_until = 0.0
with pytest.raises(httpx.HTTPStatusError):
await guard.run(AsyncMock(side_effect=_http_429_error()))
assert guard._consecutive_rate_limits == 2
assert guard._needs_probe is True
assert guard.cooldown_remaining() > 60
@pytest.mark.asyncio
async def test_probe_recovery_resets_consecutive_rate_limits() -> None:
from routstr.mint import MintRateGuard
guard = MintRateGuard("http://mint", max_concurrency=0)
guard.apply_rate_limit_cooldown()
guard._cooldown_until = 0.0
assert await guard.run(AsyncMock(return_value="ok")) == "ok"
assert guard._consecutive_rate_limits == 0
assert guard._needs_probe is False
assert guard.cooldown_remaining() == 0.0
async def test_payout_reloads_wallet_snapshot_under_guard() -> None:
"""Payout must not trust a cached proof snapshot from before the guard."""
from routstr.wallet import _payout_mint_and_unit
mock_get_wallet = AsyncMock(side_effect=RuntimeError("stop after get_wallet"))
with patch("routstr.wallet.get_wallet", mock_get_wallet):
await _payout_mint_and_unit("https://mint.example.com", "sat")
mock_get_wallet.assert_awaited_once_with(
"https://mint.example.com", "sat", force_reload_proofs=True
)
@pytest.mark.asyncio
async def test_load_mint_propagates_rate_limit() -> None:
from routstr.mint import MintRateLimitedError
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"),
response=httpx.Response(429),
)
with (
patch.object(wallet, "load_mint_keysets", new=AsyncMock(side_effect=error)),
pytest.raises(MintRateLimitedError),
):
await wallet.load_mint()
@pytest.mark.asyncio
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)),
pytest.raises(httpx.ConnectError) as captured,
):
await wallet.load_mint()
assert captured.value is error
@pytest.mark.asyncio
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,
patch.object(wallet, "load_mint_info", new=AsyncMock()) as load_info,
):
await wallet.load_mint(keyset_id="abc")
load_keysets.assert_awaited_once_with(False)
activate.assert_awaited_once_with("abc")
load_info.assert_awaited_once_with(reload=True)