mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 11:04:36 +00:00
The three credit_balance unit tests mock the DB session but let
credit_balance call the real store_cashu_transaction_with_retry, which
opens its own session against the global engine. In CI that database has
no cashu_transactions table; since storage failures now propagate
(a60b04ae) instead of being silently swallowed, the tests failed with
sqlite3.OperationalError. Patch the audit store like the existing
propagation test already does.
2930 lines
110 KiB
Python
2930 lines
110 KiB
Python
import asyncio
|
|
import base64
|
|
import json
|
|
import socket
|
|
from collections.abc import AsyncIterator, Generator
|
|
from contextlib import asynccontextmanager
|
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from cashu.core.base import MeltQuoteState
|
|
|
|
from routstr.core.db import ApiKey
|
|
from routstr.wallet import (
|
|
Bolt11PaymentAmbiguous,
|
|
Bolt11PaymentNotAttempted,
|
|
MintConnectionError,
|
|
TokenConsumedError,
|
|
_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,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_wallet_runtime_state() -> 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
|
|
|
|
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_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_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_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_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 = 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 — not swap_to_primary_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 = 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_uses_only_requested_destination_mint() -> None:
|
|
from routstr.core.settings import settings
|
|
|
|
source = "http://foreign:3338"
|
|
destination = "http://key-mint:3338"
|
|
token = Mock(
|
|
mint=source,
|
|
unit="sat",
|
|
amount=100,
|
|
keysets=["keyset1"],
|
|
proofs=[Mock(amount=100)],
|
|
)
|
|
source_wallet = Mock()
|
|
swap = AsyncMock(return_value=(99, "sat", destination))
|
|
|
|
with (
|
|
patch.object(settings, "primary_mint", destination),
|
|
patch.object(settings, "cashu_mints", [destination]),
|
|
patch("routstr.wallet.deserialize_token_from_string", return_value=token),
|
|
patch("routstr.wallet.get_wallet", AsyncMock(return_value=source_wallet)),
|
|
patch("routstr.wallet.swap_to_trusted_mint", swap),
|
|
):
|
|
result = await recieve_token(
|
|
"cashuAtoken", destination_mint=destination, destination_unit="sat"
|
|
)
|
|
|
|
assert result == (99, "sat", destination)
|
|
swap.assert_awaited_once_with(token, source_wallet, destination_mints=[destination])
|
|
|
|
|
|
@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.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_mint="http://key-mint:3338",
|
|
destination_unit="sat",
|
|
)
|
|
|
|
get_wallet.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recieve_token_cross_mint_output_unit_must_match() -> None:
|
|
token = Mock(mint="http://foreign:3338", unit="msat", keysets=["keyset"])
|
|
get_wallet = AsyncMock()
|
|
|
|
with (
|
|
patch("routstr.wallet.deserialize_token_from_string", return_value=token),
|
|
patch("routstr.wallet.settings.primary_mint_unit", "sat"),
|
|
patch("routstr.wallet.get_wallet", get_wallet),
|
|
pytest.raises(ValueError, match="liability unit"),
|
|
):
|
|
await recieve_token(
|
|
"cashuAtoken",
|
|
destination_mint="http://key-mint:3338",
|
|
destination_unit="msat",
|
|
)
|
|
|
|
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=AsyncMock(side_effect=httpx.ConnectError("mint unavailable"))
|
|
)
|
|
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=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=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_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_credit_balance_constrains_redemption_to_key_mint() -> None:
|
|
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_mint=key_mint, 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_swap_to_primary_mint_insufficient_for_fees() -> None:
|
|
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve.
|
|
The quote mocks are static, so every retry observes the same shortfall —
|
|
the swap must still give up and raise."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token = Mock()
|
|
mock_token.mint = "http://foreign:3338"
|
|
mock_token.unit = "sat"
|
|
mock_token.amount = 404
|
|
mock_token.keysets = ["keyset1"]
|
|
mock_token.proofs = [{"amount": 404}]
|
|
|
|
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_primary_wallet = Mock()
|
|
mock_primary_wallet.load_mint = AsyncMock()
|
|
mock_primary_wallet.load_proofs = AsyncMock()
|
|
|
|
mock_mint_quote = Mock()
|
|
mock_mint_quote.quote = "mint_quote_123"
|
|
mock_mint_quote.request = "lnbc1..."
|
|
mock_primary_wallet.request_mint = AsyncMock(return_value=mock_mint_quote)
|
|
|
|
mock_melt_quote = Mock()
|
|
mock_melt_quote.quote = "melt_quote_123"
|
|
mock_melt_quote.amount = 400
|
|
mock_melt_quote.fee_reserve = 12 # total needed: 412 > 404
|
|
mock_token_wallet.melt_quote = AsyncMock(return_value=mock_melt_quote)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(ValueError, match="insufficient to cover melt fees"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
# melt should never have been called
|
|
mock_token_wallet.melt.assert_not_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
|
|
|
|
mock_wallet.load_mint = AsyncMock()
|
|
mock_wallet.load_proofs = AsyncMock()
|
|
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
|
|
with patch(
|
|
"routstr.wallet.swap_to_trusted_mint",
|
|
return_value=(900, "sat", "http://mint:3338"),
|
|
):
|
|
amount, unit, mint = await recieve_token("test_token")
|
|
assert amount == 900
|
|
assert unit == "sat"
|
|
assert mint == "http://mint:3338"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_to_primary_mint_already_on_primary() -> None:
|
|
"""Same-mint shortcut: the token is already on the primary mint.
|
|
|
|
No cross-mint swap (no melt/mint), but the same-mint split(include_fees=True)
|
|
still burns the mint's NUT-02 input fee, so the credited amount must be face
|
|
minus the input fee — not full face value (the over-credit bug). DLEQ is
|
|
verified too, matching the trusted same-mint receive path.
|
|
"""
|
|
from routstr.core.settings import settings
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token = Mock()
|
|
mock_token.mint = settings.primary_mint
|
|
mock_token.keysets = ["keyset1"]
|
|
mock_token.amount = 1000
|
|
mock_token.unit = "sat"
|
|
mock_token.proofs = [{"amount": 1000}]
|
|
|
|
mock_token_wallet = Mock()
|
|
mock_token_wallet.load_mint = AsyncMock()
|
|
mock_token_wallet.load_proofs = AsyncMock()
|
|
mock_token_wallet.verify_proofs_dleq = Mock()
|
|
# Mock a 3-sat input fee from the Cashu wallet API.
|
|
mock_token_wallet.get_fees_for_proofs = Mock(return_value=3)
|
|
mock_token_wallet.split = AsyncMock(return_value=None)
|
|
mock_token_wallet.request_mint = AsyncMock()
|
|
mock_token_wallet.melt_quote = AsyncMock()
|
|
|
|
with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)):
|
|
amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
assert amount == 997 # 1000 face - 3 sat input fee
|
|
assert unit == "sat"
|
|
assert mint == settings.primary_mint
|
|
mock_token_wallet.verify_proofs_dleq.assert_called_once_with(mock_token.proofs)
|
|
mock_token_wallet.get_fees_for_proofs.assert_called_once_with(mock_token.proofs)
|
|
mock_token_wallet.split.assert_called_once()
|
|
mock_token_wallet.request_mint.assert_not_called()
|
|
mock_token_wallet.melt_quote.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Swap fee estimation and reactive retry
|
|
#
|
|
# Spec: the estimation pass subtracts only observed fees (no safety buffer).
|
|
# swap_to_primary_mint then runs the mint-quote/melt-quote/melt cycle and, when
|
|
# the foreign mint demands more than estimated (at quote or at melt time),
|
|
# retries with the amount recomputed from the observed fee — at most 3 attempts.
|
|
# Melt failures unrelated to fees are not retried.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_swap_mocks(
|
|
token_amount: int,
|
|
fee_reserves: list[int],
|
|
input_fees: int = 0,
|
|
mint_url: str = "http://foreign-mint:3338",
|
|
) -> tuple[Mock, Mock, Mock]:
|
|
"""Return (token, token_wallet, primary_wallet) mocks that act like a mint.
|
|
|
|
Mint quotes pass the requested amount through their ``request`` field and
|
|
melt quotes echo that amount back, so the mocks stay consistent for
|
|
whatever amounts the implementation requests. ``fee_reserves`` supplies the
|
|
fee_reserve of each successive melt quote (the first serves the estimation
|
|
pass); requesting more quotes than provided fails the test.
|
|
"""
|
|
mock_token = Mock()
|
|
mock_token.mint = mint_url
|
|
mock_token.unit = "sat"
|
|
mock_token.amount = token_amount
|
|
mock_token.keysets = ["keyset1"]
|
|
mock_token.proofs = [Mock(amount=token_amount)]
|
|
|
|
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=input_fees)
|
|
|
|
mock_primary_wallet = Mock()
|
|
mock_primary_wallet.load_mint = AsyncMock()
|
|
mock_primary_wallet.load_proofs = AsyncMock()
|
|
mock_primary_wallet.available_balance = Mock(amount=0)
|
|
mock_primary_wallet.mint = AsyncMock(return_value=Mock())
|
|
|
|
fees = iter(fee_reserves)
|
|
|
|
def _next_fee() -> int:
|
|
try:
|
|
return next(fees)
|
|
except StopIteration:
|
|
raise AssertionError(
|
|
"more melt quotes requested than fee_reserves provided"
|
|
) from None
|
|
|
|
mock_primary_wallet.request_mint = AsyncMock(
|
|
side_effect=lambda amount: Mock(quote=f"mint_quote_{amount}", request=amount)
|
|
)
|
|
mock_token_wallet.melt_quote = AsyncMock(
|
|
side_effect=lambda invoice: Mock(
|
|
quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee()
|
|
)
|
|
)
|
|
mock_token_wallet.melt = AsyncMock(return_value=Mock(state=MeltQuoteState.paid))
|
|
|
|
return mock_token, mock_token_wallet, mock_primary_wallet
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_to_primary_mint_success() -> None:
|
|
"""No retry needed: real quote matches the estimate, full net amount minted."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
amount, unit, mint = await swap_to_primary_mint(
|
|
mock_token, mock_token_wallet
|
|
)
|
|
|
|
assert amount == 990 # 1000 - fee_reserve(10), no buffer subtracted
|
|
assert unit == "sat"
|
|
assert mint == "http://primary:3338"
|
|
assert mock_primary_wallet.request_mint.call_count == 2
|
|
mock_primary_wallet.request_mint.assert_any_call(1000)
|
|
mock_primary_wallet.request_mint.assert_any_call(990)
|
|
assert mock_token_wallet.melt_quote.call_count == 2
|
|
assert mock_token_wallet.melt.call_count == 1
|
|
assert mock_primary_wallet.mint.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("fee_reserve", [1, 10, 100])
|
|
async def test_calculate_swap_amount_subtracts_only_observed_fees(
|
|
fee_reserve: int,
|
|
) -> None:
|
|
"""Estimation: minted_amount = token - fee_reserve, with no safety buffer."""
|
|
from routstr.wallet import _calculate_swap_amount
|
|
|
|
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[fee_reserve]
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
result = await _calculate_swap_amount(
|
|
amount_msat=1_000_000,
|
|
token_unit="sat",
|
|
token_mint_url="http://foreign-mint:3338",
|
|
token_wallet=mock_token_wallet,
|
|
primary_wallet=mock_primary_wallet,
|
|
proofs=[],
|
|
)
|
|
|
|
assert result == 1000 - fee_reserve
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calculate_swap_amount_includes_input_fees() -> None:
|
|
"""Estimation subtracts NUT-02 input fees alongside the melt fee_reserve."""
|
|
from routstr.wallet import _calculate_swap_amount
|
|
|
|
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
500, fee_reserves=[10], input_fees=3
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
result = await _calculate_swap_amount(
|
|
amount_msat=500_000,
|
|
token_unit="sat",
|
|
token_mint_url="http://foreign-mint:3338",
|
|
token_wallet=mock_token_wallet,
|
|
primary_wallet=mock_primary_wallet,
|
|
proofs=[],
|
|
)
|
|
|
|
assert result == 487 # 500 - 10 - 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_retries_when_real_quote_exceeds_estimate() -> None:
|
|
"""The real melt quote demands a higher fee than the estimate (20 → 23).
|
|
Instead of failing, the swap recomputes the amount from the observed fee
|
|
and re-quotes: 1000 - 23 = 977, which fits (977 + 23 <= 1000)."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[20, 23, 23]
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
amount, unit, mint = await swap_to_primary_mint(
|
|
mock_token, mock_token_wallet
|
|
)
|
|
|
|
assert amount == 977
|
|
assert unit == "sat"
|
|
mock_primary_wallet.request_mint.assert_any_call(980)
|
|
mock_primary_wallet.request_mint.assert_any_call(977)
|
|
assert mock_token_wallet.melt_quote.call_count == 3 # estimation + 2 attempts
|
|
assert mock_token_wallet.melt.call_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_retries_when_melt_demands_more_than_quoted() -> None:
|
|
"""The mint.cubabitcoin.org incident: every quote reports fee_reserve=1,
|
|
but the mint demands 2 sats at melt time ("Provided: 179, needed: 180").
|
|
The swap must retry with a smaller invoice (177) so the second melt fits,
|
|
instead of failing the topup."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
179, fee_reserves=[1, 1, 1], mint_url="http://mint.cubabitcoin.org"
|
|
)
|
|
mock_token_wallet.melt.side_effect = [
|
|
Exception(
|
|
"Mint Error: not enough inputs provided for melt. "
|
|
"Provided: 179, needed: 180 (Code: 11000)"
|
|
),
|
|
Mock(state=MeltQuoteState.paid),
|
|
]
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
amount, unit, mint = await swap_to_primary_mint(
|
|
mock_token, mock_token_wallet
|
|
)
|
|
|
|
assert amount == 177 # 179 - 1 (estimate) - 1 (observed melt shortfall)
|
|
assert mock_token_wallet.melt.call_count == 2
|
|
mock_primary_wallet.request_mint.assert_any_call(178)
|
|
mock_primary_wallet.request_mint.assert_any_call(177)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_retries_on_cdk_unbalanced_error() -> None:
|
|
"""cdk-based mints report insufficient melt inputs as the registered code
|
|
11005 (TransactionUnbalanced) with their own message wording — no
|
|
Provided/needed amounts to parse. The retry must classify it by code and
|
|
fall back to shrinking by 1."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
179, fee_reserves=[1, 1, 1]
|
|
)
|
|
mock_token_wallet.melt.side_effect = [
|
|
Exception("Mint Error: Transaction unbalanced: 179, 178, 2 (Code: 11005)"),
|
|
Mock(state=MeltQuoteState.paid),
|
|
]
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
amount, unit, mint = await swap_to_primary_mint(
|
|
mock_token, mock_token_wallet
|
|
)
|
|
|
|
assert amount == 177
|
|
assert mock_token_wallet.melt.call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_quote_retries_exhausted() -> None:
|
|
"""A mint that escalates fee_reserve on every re-quote exhausts the retry
|
|
budget (3 attempts) and fails cleanly; melt is never executed."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[1, 10, 25, 50]
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(ValueError, match="insufficient to cover melt fees"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
assert mock_token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts
|
|
mock_token_wallet.melt.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_melt_retries_exhausted() -> None:
|
|
"""A mint that always demands more at melt time than it quoted exhausts
|
|
the retry budget; the last melt failure is wrapped as ValueError."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
5000, fee_reserves=[50, 50, 50, 50]
|
|
)
|
|
mock_token_wallet.melt = AsyncMock(
|
|
side_effect=Exception(
|
|
"Mint Error: not enough inputs provided for melt. "
|
|
"Provided: 5000, needed: 5200 (Code: 11000)"
|
|
)
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(ValueError, match="Failed to melt token"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
assert mock_token_wallet.melt.call_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"primary_unit,token_unit,amount_msat,fees,expected",
|
|
[
|
|
("sat", "sat", 179_000, 2, 177),
|
|
("msat", "sat", 179_000, 2, 177_000),
|
|
("sat", "msat", 179_000, 2_000, 177),
|
|
],
|
|
)
|
|
async def test_net_minted_amount_unit_conversions(
|
|
primary_unit: str, token_unit: str, amount_msat: int, fees: int, expected: int
|
|
) -> None:
|
|
"""Fee subtraction converts correctly between sat and msat on either side."""
|
|
from routstr.core.settings import settings
|
|
from routstr.wallet import _net_minted_amount
|
|
|
|
with patch.object(settings, "primary_mint_unit", primary_unit):
|
|
assert _net_minted_amount(amount_msat, token_unit, fees) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message,expected",
|
|
[
|
|
# nutshell: retryable with exact shortfall from the detail text
|
|
(
|
|
"Mint Error: not enough inputs provided for melt. "
|
|
"Provided: 179, needed: 182 (Code: 11000)",
|
|
3,
|
|
),
|
|
# verbatim production error from issue #468, including cashu-py's
|
|
# "could not pay invoice" wrapper around the mint detail
|
|
(
|
|
"could not pay invoice: Mint Error: not enough inputs provided "
|
|
"for melt. Provided: 179, needed: 180 (Code: 11000)",
|
|
1,
|
|
),
|
|
# cdk: registered TransactionUnbalanced code, no parsable amounts
|
|
("Mint Error: Transaction unbalanced: 179, 178, 2 (Code: 11005)", 1),
|
|
# nutshell wording without a code suffix
|
|
("not enough inputs provided for melt", 1),
|
|
# nonsensical amounts (needed <= provided) fall back to the minimal step
|
|
(
|
|
"Mint Error: not enough inputs provided for melt. "
|
|
"Provided: 180, needed: 179 (Code: 11000)",
|
|
1,
|
|
),
|
|
# a generic 11000 without the shortfall text is not a fee shortfall:
|
|
# 11000 is nutshell's catch-all TransactionError, so retrying (shrinking
|
|
# the invoice) would never help and only masks the real error
|
|
("Mint Error: Duplicate inputs provided. (Code: 11000)", None),
|
|
# spent proofs must never be retried: the funds are gone
|
|
("Mint Error: Token already spent. (Code: 11001)", None),
|
|
# Lightning failures must never be retried: a smaller invoice won't help
|
|
("Mint Error: Lightning payment failed. (Code: 20004)", None),
|
|
# unrecognizable errors (timeouts, bugs) must never be retried
|
|
("Connection timeout", None),
|
|
],
|
|
)
|
|
def test_melt_shortfall_classifier(message: str, expected: int | None) -> None:
|
|
"""Retry classification across mint implementations and failure classes."""
|
|
from routstr.wallet import _melt_insufficient_shortfall
|
|
|
|
assert _melt_insufficient_shortfall(Exception(message)) == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calculate_swap_amount_same_mint_short_circuit() -> None:
|
|
"""When the token is already on the primary mint no fees apply and no
|
|
quotes are requested."""
|
|
from routstr.wallet import _calculate_swap_amount
|
|
|
|
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(1000, fee_reserves=[])
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
result = await _calculate_swap_amount(
|
|
amount_msat=1_000_000,
|
|
token_unit="sat",
|
|
token_mint_url="http://primary:3338",
|
|
token_wallet=mock_token_wallet,
|
|
primary_wallet=mock_primary_wallet,
|
|
proofs=[],
|
|
)
|
|
|
|
assert result == 1000
|
|
mock_primary_wallet.request_mint.assert_not_called()
|
|
mock_token_wallet.melt_quote.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calculate_swap_amount_msat_primary_unit() -> None:
|
|
"""With an msat primary mint the dummy quote and result stay in msats."""
|
|
from routstr.wallet import _calculate_swap_amount
|
|
|
|
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(179, fee_reserves=[2])
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "msat"):
|
|
result = await _calculate_swap_amount(
|
|
amount_msat=179_000,
|
|
token_unit="sat",
|
|
token_mint_url="http://foreign-mint:3338",
|
|
token_wallet=mock_token_wallet,
|
|
primary_wallet=mock_primary_wallet,
|
|
proofs=[],
|
|
)
|
|
|
|
assert result == 177_000 # 179_000 msat - 2 sat fee
|
|
mock_primary_wallet.request_mint.assert_called_once_with(179_000)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calculate_swap_amount_fees_exceed_token() -> None:
|
|
"""Fees larger than the token itself fail fast, before any melt."""
|
|
from routstr.wallet import _calculate_swap_amount
|
|
|
|
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
179, fee_reserves=[200]
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with pytest.raises(ValueError, match="exceed token amount"):
|
|
await _calculate_swap_amount(
|
|
amount_msat=179_000,
|
|
token_unit="sat",
|
|
token_mint_url="http://foreign-mint:3338",
|
|
token_wallet=mock_token_wallet,
|
|
primary_wallet=mock_primary_wallet,
|
|
proofs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calculate_swap_amount_wraps_estimation_failure() -> None:
|
|
"""Estimation infrastructure failures surface as a single clear ValueError."""
|
|
from routstr.wallet import _calculate_swap_amount
|
|
|
|
_, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(179, fee_reserves=[])
|
|
mock_primary_wallet.request_mint = AsyncMock(side_effect=Exception("mint offline"))
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with pytest.raises(ValueError, match="Failed to estimate fees"):
|
|
await _calculate_swap_amount(
|
|
amount_msat=179_000,
|
|
token_unit="sat",
|
|
token_mint_url="http://foreign-mint:3338",
|
|
token_wallet=mock_token_wallet,
|
|
primary_wallet=mock_primary_wallet,
|
|
proofs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_coerces_non_integer_amount() -> None:
|
|
"""Token amounts arriving as floats are coerced before any arithmetic."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
mock_token.amount = 1000.0
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
amount, unit, mint = await swap_to_primary_mint(
|
|
mock_token, mock_token_wallet
|
|
)
|
|
|
|
assert amount == 990
|
|
assert isinstance(amount, int)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_rejects_unknown_unit() -> None:
|
|
"""Units other than sat/msat are rejected before any quote is requested."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[]
|
|
)
|
|
mock_token.unit = "usd"
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(ValueError, match="Invalid unit"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
mock_primary_wallet.request_mint.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_msat_token_already_on_primary() -> None:
|
|
"""msat-denominated tokens on the primary mint short-circuit unchanged."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, _ = _make_swap_mocks(
|
|
179_000, fee_reserves=[], mint_url="http://primary:3338"
|
|
)
|
|
mock_token.unit = "msat"
|
|
mock_token_wallet.split = AsyncMock()
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_token_wallet):
|
|
amount, unit, mint = await swap_to_primary_mint(
|
|
mock_token, mock_token_wallet
|
|
)
|
|
|
|
assert (amount, unit, mint) == (179_000, "msat", "http://primary:3338")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mint-on-primary failure handling after a successful melt
|
|
#
|
|
# At this point the foreign proofs are already spent: failures here mean funds
|
|
# are in limbo, so errors must propagate (never be swallowed) and recovery must
|
|
# never credit proofs the wallet does not actually hold.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _with_recovery_mocks(
|
|
mock_primary_wallet: Mock, mint_error: str, balances: list[int]
|
|
) -> None:
|
|
"""Make primary mint() fail and stage available_balance per load_proofs call."""
|
|
mock_primary_wallet.mint = AsyncMock(side_effect=Exception(mint_error))
|
|
mock_primary_wallet.keysets = ["keyset_primary"]
|
|
balance_iter = iter(balances)
|
|
|
|
def advance_balance(reload: bool = False) -> None:
|
|
mock_primary_wallet.available_balance = Mock(amount=next(balance_iter))
|
|
|
|
mock_primary_wallet.load_proofs = AsyncMock(side_effect=advance_balance)
|
|
mock_primary_wallet.restore_tokens_for_keyset = AsyncMock()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_mint_failure_after_melt_is_token_consumed() -> None:
|
|
"""A non-recoverable mint failure after melt is a non-retryable
|
|
TokenConsumedError (the melt already spent the foreign proofs), with the
|
|
original error preserved in the cause chain."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
_with_recovery_mocks(
|
|
mock_primary_wallet, "Mint Error: Quote is expired (Code: 20007)", [0]
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(TokenConsumedError) as exc_info:
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
assert "Quote is expired" in str(exc_info.value.__cause__)
|
|
assert mock_token_wallet.melt.call_count == 1
|
|
mock_primary_wallet.restore_tokens_for_keyset.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_recovers_orphaned_proofs_on_outputs_already_signed() -> None:
|
|
"""11003 (outputs already signed): a recovery scan that restores the full
|
|
minted amount lets the swap complete normally."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
_with_recovery_mocks(
|
|
mock_primary_wallet,
|
|
"Mint Error: outputs already signed (Code: 11003)",
|
|
[0, 990], # pre-mint balance, post-recovery balance
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
amount, unit, mint = await swap_to_primary_mint(
|
|
mock_token, mock_token_wallet
|
|
)
|
|
|
|
assert amount == 990
|
|
mock_primary_wallet.restore_tokens_for_keyset.assert_awaited_once_with(
|
|
"keyset_primary", to=1, batch=25
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_recovery_shortfall_refuses_credit() -> None:
|
|
"""When the recovery scan restores less than the minted amount, the swap
|
|
must fail rather than credit proofs the wallet does not hold."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
_with_recovery_mocks(
|
|
mock_primary_wallet,
|
|
"Mint Error: outputs already signed (Code: 11003)",
|
|
[0, 100], # recovery restores only 100 of the expected 990
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(TokenConsumedError, match="Swap recovery failed"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_recovery_failure_wrapped() -> None:
|
|
"""When the recovery scan itself fails, the error is wrapped and raised —
|
|
never swallowed."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
_with_recovery_mocks(
|
|
mock_primary_wallet,
|
|
"Mint Error: outputs already signed (Code: 11003)",
|
|
[0],
|
|
)
|
|
mock_primary_wallet.restore_tokens_for_keyset = AsyncMock(
|
|
side_effect=Exception("wallet db locked")
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(TokenConsumedError, match="recovery unsuccessful"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_recieve_token_rejects_multiple_keysets() -> None:
|
|
"""Multi-keyset tokens are rejected before touching any wallet."""
|
|
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
|
mock_token = Mock()
|
|
mock_token.keysets = ["keyset1", "keyset2"]
|
|
mock_deserialize.return_value = mock_token
|
|
|
|
with pytest.raises(ValueError, match="Multiple keysets"):
|
|
await recieve_token("cashuAmultikeyset")
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_does_not_retry_on_payment_failure() -> None:
|
|
"""Melt failures unrelated to fees (e.g. routing failure) are not retried:
|
|
a smaller invoice would not help, and the error must surface immediately."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
mock_token_wallet.melt = AsyncMock(
|
|
side_effect=Exception("Mint Error: Lightning payment failed. (Code: 20004)")
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(ValueError, match="Failed to melt token"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
assert mock_token_wallet.melt.call_count == 1
|
|
assert mock_primary_wallet.request_mint.call_count == 2
|
|
|
|
|
|
# --- 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 rate-limited; retry after cooldown",
|
|
"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_swap_mint_transport_error_after_melt_is_not_retryable() -> None:
|
|
"""A transport error minting on the primary mint (after the foreign melt
|
|
already spent the proofs) classifies as a non-retryable token_consumed 500,
|
|
never a retryable mint_unreachable 503."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
# Melt succeeds (proofs spent); minting on primary hits a transport error.
|
|
mock_primary_wallet.mint = AsyncMock(
|
|
side_effect=httpx.ConnectError("primary mint down")
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(TokenConsumedError) as exc_info:
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
classified = classify_redemption_error(exc_info.value)
|
|
assert classified is not None
|
|
_type, status, _msg, code = classified
|
|
assert status == 500
|
|
assert code == "cashu_token_consumed"
|
|
assert is_mint_connection_error(exc_info.value) is False
|
|
assert mock_token_wallet.melt.call_count == 1
|
|
|
|
|
|
@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_swap_fee_estimation_transport_error_raises_mint_connection_error() -> (
|
|
None
|
|
):
|
|
"""A transport failure while estimating fees is surfaced as
|
|
MintConnectionError (→ 503), not a generic fee ValueError (→ 422)."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10]
|
|
)
|
|
mock_primary_wallet.request_mint = AsyncMock(
|
|
side_effect=httpx.ConnectError("All connection attempts failed")
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(MintConnectionError):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
mock_token_wallet.melt.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_melt_transport_error_is_never_reported_reusable() -> None:
|
|
"""A timed-out melt remains ambiguous even when an immediate snapshot says
|
|
UNPAID/UNSPENT, so callers must not receive the original token for retry."""
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
mock_token, mock_token_wallet, mock_primary_wallet = _make_swap_mocks(
|
|
1000, fee_reserves=[10, 10]
|
|
)
|
|
mock_token_wallet.melt = AsyncMock(side_effect=httpx.ConnectTimeout("timed out"))
|
|
from cashu.core.base import MeltQuoteState, ProofSpentState
|
|
|
|
mock_token_wallet.get_melt_quote = AsyncMock(
|
|
return_value=Mock(state=MeltQuoteState.unpaid)
|
|
)
|
|
mock_token_wallet.check_proof_state = AsyncMock(
|
|
return_value=Mock(
|
|
states=[Mock(state=ProofSpentState.unspent) for _ in mock_token.proofs]
|
|
)
|
|
)
|
|
|
|
from routstr.core.settings import settings
|
|
|
|
with patch.object(settings, "primary_mint", "http://primary:3338"):
|
|
with patch.object(settings, "primary_mint_unit", "sat"):
|
|
with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet):
|
|
with pytest.raises(TokenConsumedError, match="ambiguous"):
|
|
await swap_to_primary_mint(mock_token, mock_token_wallet)
|
|
|
|
assert mock_token_wallet.melt.call_count == 1
|
|
|
|
|
|
@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 routstr.wallet import slow_filter_spend_proofs
|
|
|
|
proofs = [Mock() for _ in range(250)]
|
|
states = [Mock(state="UNSPENT") for _ 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()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_swap_falls_back_when_primary_wallet_cannot_load() -> None:
|
|
from routstr.core.settings import settings
|
|
from routstr.wallet import swap_to_primary_mint
|
|
|
|
primary = "http://primary:3338"
|
|
secondary = "http://secondary:3338"
|
|
foreign = "http://foreign:3338"
|
|
|
|
token = Mock(
|
|
mint=foreign,
|
|
unit="sat",
|
|
amount=1000,
|
|
keysets=["keyset1"],
|
|
proofs=[Mock(amount=1000)],
|
|
)
|
|
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(state=MeltQuoteState.paid)),
|
|
)
|
|
|
|
mint_quote = Mock(quote="mint_q_secondary", request="lnbc1secondary")
|
|
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()),
|
|
)
|
|
|
|
async def get_wallet(mint: str, *args: object, **kwargs: object) -> Mock:
|
|
if mint == primary:
|
|
raise httpx.ConnectError("primary down")
|
|
return secondary_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,
|
|
patch("routstr.wallet.logger.info") as info,
|
|
):
|
|
amount, unit, mint_url = await swap_to_primary_mint(token, source_wallet)
|
|
|
|
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, *info.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
|
|
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_wallet_request_mint_fallback_rejects_zero_amount() -> None:
|
|
"""Zero or negative amounts must be rejected before reaching the mint."""
|
|
from routstr.wallet import _request_mint_with_fallback
|
|
|
|
with pytest.raises(ValueError, match="amount must be > 0"):
|
|
await _request_mint_with_fallback(0, op_name="test")
|
|
|
|
with pytest.raises(ValueError, match="amount must be > 0"):
|
|
await _request_mint_with_fallback(-1, op_name="test")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wallet_fallback_on_429_no_in_place_retry() -> None:
|
|
"""A 429 from the primary mint must trigger immediate fallback to the
|
|
secondary — _mint_operation must NOT retry in-place when
|
|
retry_on_rate_limit=False is set by _request_mint_with_fallback."""
|
|
from routstr.core.settings import settings
|
|
from routstr.wallet 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.wallet.get_wallet", side_effect=mock_get
|
|
):
|
|
_, mint_url, _ = await _request_mint_with_fallback(
|
|
1000, op_name="test_429_fallback"
|
|
)
|
|
|
|
assert mint_url == secondary
|
|
assert primary_call_count == 1
|
|
mock_secondary_wallet.request_mint.assert_called_once()
|
|
mock_sleep.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wallet_fallback_on_timeout_no_in_place_retry() -> None:
|
|
"""A timeout from one destination must immediately try the next mint."""
|
|
from routstr.core.settings import settings
|
|
from routstr.wallet import _request_mint_with_fallback
|
|
|
|
primary = "http://primary:3338"
|
|
secondary = "http://secondary:3338"
|
|
primary_wallet = Mock(
|
|
request_mint=AsyncMock(side_effect=httpx.TimeoutException("timed out"))
|
|
)
|
|
quote = Mock(quote="q_secondary", request="lnbc1secondary")
|
|
secondary_wallet = Mock(request_mint=AsyncMock(return_value=quote))
|
|
wallets = {primary: primary_wallet, secondary: secondary_wallet}
|
|
|
|
with (
|
|
patch.object(settings, "primary_mint", primary),
|
|
patch.object(settings, "cashu_mints", [primary, secondary]),
|
|
patch.object(settings, "mint_retry_max_attempts", 3),
|
|
patch.object(settings, "mint_max_concurrency", 0),
|
|
patch.object(settings, "mint_operation_timeout_seconds", 0),
|
|
patch("routstr.mint.asyncio.sleep", AsyncMock()) as sleep,
|
|
patch(
|
|
"routstr.wallet.get_wallet",
|
|
AsyncMock(side_effect=lambda mint, *args, **kwargs: wallets[mint]),
|
|
),
|
|
):
|
|
_, mint_url, _ = await _request_mint_with_fallback(
|
|
1000, op_name="test_timeout_fallback"
|
|
)
|
|
|
|
assert mint_url == secondary
|
|
primary_wallet.request_mint.assert_awaited_once_with(1000)
|
|
secondary_wallet.request_mint.assert_awaited_once_with(1000)
|
|
sleep.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wallet_fallback_skips_mint_during_cooldown() -> None:
|
|
from routstr.core.settings import settings
|
|
from routstr.wallet import _MintRateGuard, _request_mint_with_fallback
|
|
|
|
primary = "http://primary:3338"
|
|
secondary = "http://secondary:3338"
|
|
primary_wallet = Mock(request_mint=AsyncMock())
|
|
quote = Mock(quote="q_secondary", request="lnbc1secondary")
|
|
secondary_wallet = Mock(request_mint=AsyncMock(return_value=quote))
|
|
wallets = {primary: primary_wallet, secondary: secondary_wallet}
|
|
|
|
with (
|
|
patch.object(settings, "primary_mint", primary),
|
|
patch.object(settings, "cashu_mints", [primary, secondary]),
|
|
patch.object(settings, "mint_max_concurrency", 0),
|
|
patch.object(settings, "mint_operation_timeout_seconds", 0),
|
|
patch("routstr.mint.time.monotonic", return_value=10),
|
|
patch("routstr.mint.asyncio.sleep", AsyncMock()) as sleep,
|
|
patch(
|
|
"routstr.wallet.get_wallet",
|
|
AsyncMock(side_effect=lambda mint, *args, **kwargs: wallets[mint]),
|
|
),
|
|
):
|
|
_MintRateGuard.get(primary).apply_cooldown(60)
|
|
_, mint_url, _ = await _request_mint_with_fallback(
|
|
1000, op_name="test_cooldown_fallback"
|
|
)
|
|
|
|
assert mint_url == secondary
|
|
primary_wallet.request_mint.assert_not_awaited()
|
|
secondary_wallet.request_mint.assert_awaited_once_with(1000)
|
|
sleep.assert_not_awaited()
|
|
|
|
|
|
@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=True
|
|
)
|