harden cashu path

This commit is contained in:
9qeklajc
2026-09-04 01:35:43 +02:00
parent 2749d62ac8
commit cc55868eca
15 changed files with 676 additions and 2366 deletions
+12 -8
View File
@@ -131,28 +131,32 @@ granularity) on any of them.
|--------|--------|--------|-----------|---------|
| `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. |
| `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. |
| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's swap/melt fees. |
| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. |
| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's NUT-02 input fees. |
| `untrusted_mint` | 400 | `cashu_untrusted_source_mint` | No | The token was issued by a mint this node does not accept. Only the node's configured mints (`PRIMARY_MINT_URL` / `CASHU_MINTS`) are redeemable. |
| `mint_unreachable` | 503 | `cashu_source_mint_unreachable` | **Yes** | The mint that issued the token could not be reached; it cannot be redeemed at another mint. |
| `mint_rate_limited` | 503 | `cashu_mint_rate_limited` | **Yes** | The mint rate-limited the request; retry after the cooldown. |
| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection, timeout). The token is fine — retry once the mint recovers. |
| `mint_timeout` | 503 | `cashu_mint_timeout` | **Yes** | The mint did not respond in time; retry later. |
| `mint_unreachable` | 503 | `cashu_mint_unreachable` | **Yes** | The mint could not be reached (DNS failure, refused/reset connection). The token is fine — retry once the mint recovers. |
| `cashu_error` | 400 | `cashu_token_redemption_failed` | No | The token could not be redeemed for another expected reason. |
| `cashu_error` | 400 | `cashu_token_zero_value` | No | The token redeemed to zero (empty/dust token, or value fully consumed by fees). |
| `token_consumed` | 500 | `cashu_token_consumed` | No | The token was **spent** (melted/redeemed) but crediting it then failed. Do not retry — the token is gone; contact support to reconcile. |
| `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. |
!!! important "Retry only transient mint failures"
Only `mint_unreachable` and `mint_rate_limited` (503) are retryable — the
same token may work again later. Everything else is a permanent property of
the token and must not be blindly retried. Use exponential backoff for the
Only `mint_unreachable`, `mint_rate_limited` and `mint_timeout` (503) are
retryable — the same token may work again later. Everything else is a
permanent property of the token and must not be blindly retried.
`untrusted_mint` is permanent: the node will never accept that mint until
an operator adds it to `CASHU_MINTS`. Use exponential backoff for the
503 responses, and honor the mint's cooldown for `mint_rate_limited`. In
particular, a `token_consumed` 500 means the mint already spent the token,
so a retry would fail as `token_already_spent`.
#### Mint failures (retryable)
`mint_unreachable` and `mint_rate_limited` are retryable redemption errors. For
`mint_rate_limited`, honor the mint's cooldown before retrying.
`mint_unreachable`, `mint_rate_limited` and `mint_timeout` are retryable
redemption errors. For `mint_rate_limited`, honor the mint's cooldown before
retrying.
```json
{
+15 -17
View File
@@ -37,6 +37,7 @@ from .wallet import (
classify_redemption_error,
credit_balance,
deserialize_token_from_string,
resolve_trusted_source_mint,
wallet_operation_guard,
)
@@ -380,24 +381,20 @@ async def _validate_bearer_key_locked(
"has_expiry_time": bool(key_expiry_time),
},
)
if token_obj.mint == settings.primary_mint:
if token_obj.unit != settings.primary_mint_unit:
raise redemption_error_to_http_exception(
ValueError(
"Cashu token unit does not match the configured primary "
f"mint unit: expected {settings.primary_mint_unit}, "
f"got {token_obj.unit}"
)
token_mint = resolve_trusted_source_mint(token_obj.mint) or token_obj.mint
if (
token_mint == settings.primary_mint
and token_obj.unit != settings.primary_mint_unit
):
raise redemption_error_to_http_exception(
ValueError(
"Cashu token unit does not match the configured primary "
f"mint unit: expected {settings.primary_mint_unit}, "
f"got {token_obj.unit}"
)
refund_currency = token_obj.unit
refund_mint_url = settings.primary_mint
elif token_obj.mint in settings.cashu_mints:
refund_currency = token_obj.unit
refund_mint_url = token_obj.mint
else:
# Foreign tokens are swapped into the configured primary mint.
refund_currency = settings.primary_mint_unit
refund_mint_url = settings.primary_mint
)
refund_currency = token_obj.unit
refund_mint_url = token_mint
new_key = ApiKey(
hashed_key=hashed_key,
@@ -449,6 +446,7 @@ async def _validate_bearer_key_locked(
"cashu_source_mint_unreachable",
"cashu_mint_unreachable",
"cashu_mint_rate_limited",
"cashu_mint_timeout",
}
log = (
logger.info
+20 -1
View File
@@ -14,10 +14,16 @@ from ..core import get_logger
from ..core.exceptions import UpstreamError
from ..core.redaction import redact_org_ids
from ..core.settings import settings
from ..wallet import deserialize_token_from_string
from ..wallet import (
UntrustedSourceMintError,
classify_redemption_error,
deserialize_token_from_string,
is_trusted_source_mint,
)
logger = get_logger(__name__)
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
if x_cashu := headers.get("x-cashu", None):
cashu_token = x_cashu
@@ -68,6 +74,19 @@ def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> N
detail="Invalid authentication token format",
)
if not is_trusted_source_mint(token_obj.mint):
classified = classify_redemption_error(
UntrustedSourceMintError(f"Untrusted source mint: {token_obj.mint}")
)
assert classified is not None
error_type, status_code, message, error_code = classified
raise HTTPException(
status_code=status_code,
detail={
"error": {"message": message, "type": error_type, "code": error_code}
},
)
amount_msat = (
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
)
+30 -1
View File
@@ -40,7 +40,12 @@ from ..payment.cost_calculation import (
)
from ..payment.helpers import create_error_response
from ..payment.models import Model
from ..wallet import recieve_token, send_token
from ..wallet import (
SPENT_TOKEN_CODES,
classify_redemption_error,
recieve_token,
send_token,
)
from .tinfoil_trailer import forward_with_trailer
logger = get_logger(__name__)
@@ -1070,6 +1075,30 @@ async def forward_ehbp_x_cashu_request(
},
)
if not redeemed:
classified = classify_redemption_error(e)
if classified is not None:
error_type, status_code, message, error_code = classified
# Never re-offer a spent/consumed token.
echo_token = None if error_code in SPENT_TOKEN_CODES else x_cashu_token
return create_error_response(
error_type,
message,
status_code,
request=request,
token=echo_token,
code=error_code,
)
# Raw exception text may contain the attacker-supplied mint URL.
return create_error_response(
"api_error",
"Internal error during token redemption",
500,
request=request,
token=x_cashu_token,
code="internal_error",
)
if "already spent" in error_message.lower():
return create_error_response(
"token_already_spent",
+105 -818
View File
File diff suppressed because it is too large Load Diff
-210
View File
@@ -1,210 +0,0 @@
"""
Integration tests for reactive swap fee retries via the wallet topup endpoint.
Foreign-mint tokens are swapped to the primary mint using the foreign mint's
melt quote, whose fee_reserve is a non-binding estimate (NUT-05): the mint may
demand more when re-quoting or at melt execution. These tests cover the
endpoint behaviour in those cases:
1. The mint demands one sat more at melt time than every quote reported
(the mint.cubabitcoin.org incident): the swap retries with a smaller
invoice and the topup succeeds, crediting the recomputed amount.
2. The real melt quote reports a higher fee_reserve than the estimate: the
swap re-quotes from the observed fee and the topup succeeds.
3. The mint escalates its fee demands on every attempt: the retry budget is
exhausted and the endpoint returns 400 with a clear error (never 500),
without ever executing a melt.
"""
from collections.abc import Callable
from unittest.mock import AsyncMock, Mock, patch
import pytest
from cashu.core.base import MeltQuoteState
from httpx import AsyncClient, Response
from routstr.core.settings import settings
# Captured at collection time, before the integration_app fixture replaces it
# with the testmint stub that bypasses swapping (see conftest.py).
from routstr.wallet import recieve_token as _real_recieve_token
# Match the authenticated fixture's persisted refund mint: existing-key topups
# are intentionally constrained to that mint for collateral provenance.
PRIMARY_MINT = "http://localhost:3338"
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_keysets = AsyncMock()
mock_token_wallet.activate_keyset = AsyncMock()
mock_token_wallet._expand_short_keyset_ids = 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
def _wallet_router(primary_wallet: Mock, token_wallet: Mock) -> Callable[..., Mock]:
"""Route get_wallet calls to the primary or foreign wallet mock by URL."""
def fake_get_wallet(
mint_url: str,
unit: str = "sat",
load: bool = True,
**kwargs: object,
) -> Mock:
return primary_wallet if mint_url == PRIMARY_MINT else token_wallet
return fake_get_wallet
async def _post_topup(
client: AsyncClient,
mock_token: Mock,
token_wallet: Mock,
primary_wallet: Mock,
) -> Response:
"""POST /v1/wallet/topup with the swap layer mocked at the mint boundary.
The conftest's testmint stub for recieve_token is swapped back for the
real implementation so the request exercises the actual swap path.
"""
with patch("routstr.wallet.recieve_token", _real_recieve_token):
with patch(
"routstr.wallet.deserialize_token_from_string", return_value=mock_token
):
with patch(
"routstr.wallet.get_wallet",
side_effect=_wallet_router(primary_wallet, token_wallet),
):
with patch.object(settings, "primary_mint", PRIMARY_MINT):
with patch.object(settings, "primary_mint_unit", "sat"):
with patch.object(settings, "cashu_mints", [PRIMARY_MINT]):
return await client.post(
"/v1/wallet/topup",
params={"cashu_token": "cashuAtest_foreign_token"},
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_retries_when_melt_demands_more_than_quoted(
authenticated_client: AsyncClient,
) -> None:
"""A 179-sat token where every quote reports fee_reserve=1 but the mint
rejects the first melt demanding 180. The retry shrinks the invoice to 177
and the topup credits 177 sats (177_000 msats)."""
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
179, fee_reserves=[1, 1, 1], mint_url="http://mint.cubabitcoin.org"
)
token_wallet.melt.side_effect = [
Exception(
"Mint Error: not enough inputs provided for melt. "
"Provided: 179, needed: 180 (Code: 11000)"
),
Mock(state=MeltQuoteState.paid),
]
response = await _post_topup(
authenticated_client, mock_token, token_wallet, primary_wallet
)
assert response.status_code == 200
assert response.json()["msats"] == 177_000
assert token_wallet.melt.call_count == 2
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_retries_when_quote_fee_exceeds_estimate(
authenticated_client: AsyncClient,
) -> None:
"""A 1000-sat token estimated at fee 20, but the real quote demands 23.
The retry recomputes 1000 - 23 = 977, which fits, and the topup credits
977 sats (977_000 msats) with a single melt."""
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
1000, fee_reserves=[20, 23, 23]
)
response = await _post_topup(
authenticated_client, mock_token, token_wallet, primary_wallet
)
assert response.status_code == 200
assert response.json()["msats"] == 977_000
assert token_wallet.melt.call_count == 1
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_returns_422_when_retries_exhausted(
authenticated_client: AsyncClient,
) -> None:
"""A mint that escalates fee_reserve on every re-quote (1 → 10 → 25 → 50)
exhausts the retry budget: clean 422 mint_error/too-small taxonomy, melt
never executed."""
mock_token, token_wallet, primary_wallet = _make_swap_mocks(
1000, fee_reserves=[1, 10, 25, 50]
)
response = await _post_topup(
authenticated_client, mock_token, token_wallet, primary_wallet
)
assert response.status_code == 422
raw_detail = response.json()["detail"]
message = (
raw_detail["error"]["message"] if isinstance(raw_detail, dict) else raw_detail
)
assert "too small to cover swap fees" in message
assert token_wallet.melt_quote.call_count == 4 # estimation + 3 attempts
token_wallet.melt.assert_not_called()
@@ -0,0 +1,56 @@
"""
Integration test for the wallet topup endpoint with a foreign-mint token.
Tokens are only accepted from trusted mints (primary_mint plus cashu_mints)
and are always redeemed on the mint that issued them. A token from any other
mint is rejected offline, before any network contact with that mint, with a
dedicated error type and code. This replaces the former cross-mint swap
path, so there is no fee-retry behaviour left to exercise here.
"""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from httpx import AsyncClient
from routstr.core.settings import settings
# Captured at collection time, before the integration_app fixture replaces it
# with the testmint stub (see conftest.py).
from routstr.wallet import recieve_token as _real_recieve_token
PRIMARY_MINT = "http://localhost:3338"
FOREIGN_MINT = "http://foreign-mint:3338"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_with_foreign_mint_token_is_rejected_without_mint_contact(
authenticated_client: AsyncClient,
) -> None:
mock_token = Mock()
mock_token.mint = FOREIGN_MINT
mock_token.unit = "sat"
mock_token.amount = 1000
mock_token.keysets = ["keyset"]
get_wallet = AsyncMock()
with (
patch("routstr.wallet.recieve_token", _real_recieve_token),
patch("routstr.wallet.deserialize_token_from_string", return_value=mock_token),
patch("routstr.wallet.get_wallet", get_wallet),
patch.object(settings, "primary_mint", PRIMARY_MINT),
patch.object(settings, "primary_mint_unit", "sat"),
patch.object(settings, "cashu_mints", [PRIMARY_MINT]),
):
response = await authenticated_client.post(
"/v1/wallet/topup",
params={"cashu_token": "cashuAtest_foreign_token"},
)
assert response.status_code == 400
error = response.json()["detail"]["error"]
assert error["type"] == "untrusted_mint"
assert error["code"] == "cashu_untrusted_source_mint"
assert FOREIGN_MINT not in error["message"]
get_wallet.assert_not_awaited()
+6 -15
View File
@@ -88,7 +88,7 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
httpx.ConnectError("All connection attempts failed"),
503,
"mint_unreachable",
"Cashu mint is unreachable",
"Cashu mint is unreachable; retry later",
"cashu_mint_unreachable",
),
(
@@ -96,7 +96,7 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
MintConnectionError("connect to http://mint:3338 refused"),
503,
"mint_unreachable",
"Cashu mint is unreachable",
"Cashu mint is unreachable; retry later",
"cashu_mint_unreachable",
),
(
@@ -104,16 +104,16 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
_value_error_wrapping_transport(),
503,
"mint_unreachable",
"Cashu mint is unreachable",
"Cashu mint is unreachable; retry later",
"cashu_mint_unreachable",
),
(
# asyncio.TimeoutError is builtin TimeoutError on 3.11+.
TimeoutError("Timed out connecting to Cashu mint http://mint:3338"),
503,
"mint_unreachable",
"Cashu mint is unreachable",
"cashu_mint_unreachable",
"mint_timeout",
"Cashu mint did not respond in time; retry later",
"cashu_mint_timeout",
),
(
ValueError(
@@ -134,15 +134,6 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
"Token value is too small to cover swap fees",
"cashu_token_swap_fees_exceed_amount",
),
(
ValueError(
"Failed to melt token from foreign mint http://foreign:3338: boom"
),
422,
"mint_error",
"Failed to swap token from foreign mint",
"cashu_foreign_mint_swap_failed",
),
(
ValueError("could not decode token"),
400,
+26 -16
View File
@@ -585,14 +585,31 @@ def _envelope(exc: HTTPException) -> dict:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error",
("error", "expected_type", "expected_code", "expected_message"),
[
httpx.ConnectError("All connection attempts failed"),
MintConnectionError("connect to mint refused"),
TimeoutError("timed out connecting to mint"),
(
httpx.ConnectError("All connection attempts failed"),
"mint_unreachable",
"cashu_mint_unreachable",
"Cashu mint is unreachable; retry later",
),
(
MintConnectionError("connect to mint refused"),
"mint_unreachable",
"cashu_mint_unreachable",
"Cashu mint is unreachable; retry later",
),
(
TimeoutError("timed out connecting to mint"),
"mint_timeout",
"cashu_mint_timeout",
"Cashu mint did not respond in time; retry later",
),
],
)
async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
async def test_topup_mint_unreachable_returns_503(
error: Exception, expected_type: str, expected_code: str, expected_message: str
) -> None:
"""A down mint must surface 503 (retryable), not 400 or 500 — the token is
fine, so the client should retry once the mint recovers."""
from fastapi import HTTPException
@@ -610,9 +627,9 @@ async def test_topup_mint_unreachable_returns_503(error: Exception) -> None:
assert exc_info.value.status_code == 503
err = _envelope(exc_info.value)
assert err["type"] == "mint_unreachable"
assert err["code"] == "cashu_mint_unreachable"
assert err["message"] == "Cashu mint is unreachable"
assert err["type"] == expected_type
assert err["code"] == expected_code
assert err["message"] == expected_message
@pytest.mark.asyncio
@@ -637,7 +654,7 @@ async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible
err = _envelope(exc_info.value)
assert err["type"] == "mint_unreachable"
assert err["code"] == "cashu_source_mint_unreachable"
assert "cannot be redeemed at another mint" in err["message"]
assert "retry later" in err["message"]
@pytest.mark.asyncio
@@ -749,13 +766,6 @@ async def test_topup_token_consumed_returns_500() -> None:
"cashu_token_swap_fees_exceed_amount",
"Token value is too small to cover swap fees",
),
(
ValueError("Failed to melt token from foreign mint http://m: boom"),
422,
"mint_error",
"cashu_foreign_mint_swap_failed",
"Failed to swap token from foreign mint",
),
],
)
async def test_topup_fee_and_swap_failures_return_422(
@@ -0,0 +1,345 @@
"""Tokens issued by an untrusted mint are rejected before any mint contact.
A client-supplied Cashu token names its own mint. Every redemption path used
to load that mint's keysets under ``wallet_operation_guard`` with the full
timeout-retry window, so a silent mint could hold the shared wallet lock for
minutes per request from unauthenticated endpoints. Now the mint must be
``primary_mint`` or one of ``cashu_mints``; anything else fails offline with a
dedicated error type and code.
"""
from contextlib import ExitStack, contextmanager
from types import SimpleNamespace
from typing import AsyncGenerator, Iterator, cast
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.auth import validate_bearer_key
from routstr.core.settings import settings
from routstr.mint import MintCooldownError
from routstr.payment.helpers import check_token_balance
from routstr.wallet import (
SourceMintConnectionError,
TokenConsumedError,
UntrustedSourceMintError,
classify_redemption_error,
is_mint_timeout,
is_trusted_source_mint,
recieve_token,
resolve_trusted_source_mint,
)
PRIMARY = "http://primary:3338"
SECONDARY = "http://secondary:3338"
UNTRUSTED = "http://evil:3338"
@pytest.fixture
async def session() -> AsyncGenerator[AsyncSession, None]:
engine = create_async_engine(
"sqlite+aiosqlite://",
poolclass=StaticPool,
connect_args={"check_same_thread": False},
)
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
db_session = AsyncSession(engine, expire_on_commit=False)
try:
yield db_session
finally:
await db_session.close()
await engine.dispose()
@contextmanager
def _trusted_mints() -> Iterator[None]:
with ExitStack() as stack:
stack.enter_context(patch.object(settings, "primary_mint", PRIMARY))
stack.enter_context(patch.object(settings, "cashu_mints", [SECONDARY]))
yield
def _token(mint: str) -> SimpleNamespace:
return SimpleNamespace(mint=mint, unit="sat", amount=100, keysets=["k"])
def test_is_trusted_source_mint() -> None:
with _trusted_mints():
assert is_trusted_source_mint(PRIMARY)
assert is_trusted_source_mint(SECONDARY)
assert not is_trusted_source_mint(UNTRUSTED)
@pytest.mark.parametrize(
"configured,token_mint",
[
("https://mint.example", "https://mint.example/"),
("https://mint.example/", "https://mint.example"),
("https://mint.example", "https://mint.example///"),
("https://mint.example", "HTTPS://MINT.EXAMPLE"),
("HTTPS://Mint.Example", "https://mint.example"),
("https://mint.example", "https://mint.example:443"),
("https://mint.example:443", "https://mint.example"),
("http://mint.example", "http://mint.example:80"),
(" https://mint.example/ ", "https://mint.example"),
("https://mint.example/Bitcoin", "https://mint.example/Bitcoin/"),
],
)
def test_trusted_mint_matching_ignores_cosmetic_url_differences(
configured: str, token_mint: str
) -> None:
with patch.object(settings, "primary_mint", configured):
with patch.object(settings, "cashu_mints", []):
assert is_trusted_source_mint(token_mint)
@pytest.mark.parametrize(
"token_mint",
[
"https://mint.example@evil.example",
"https://mint.example:pw@evil.example",
"https://mint.example.evil.example",
"https://evil.example/?x=https://mint.example",
"https://evil.example#https://mint.example",
"https://mint.example:8443",
"http://mint.example",
"https://mint.example.",
"https://mint.example/bitcoin",
"https://evil.example",
],
)
def test_trusted_mint_matching_never_folds_onto_another_host(token_mint: str) -> None:
"""Normalization must not become an accept-bypass: only cosmetic spelling
differences may fold, never anything that can resolve somewhere else."""
with patch.object(settings, "primary_mint", "https://mint.example/Bitcoin"):
with patch.object(settings, "cashu_mints", ["https://mint.example"]):
assert not is_trusted_source_mint(token_mint)
@pytest.mark.parametrize(
"token_mint",
[
"https://mint.exa\tmple",
"https://mint.exa\nmple",
"https://mint.exa\rmple",
],
)
def test_trusted_mint_matching_rejects_embedded_control_characters(
token_mint: str,
) -> None:
"""``urlsplit`` deletes tab/CR/LF before parsing, so such a URL would be
checked as one string and dialled as another."""
with patch.object(settings, "primary_mint", "https://mint.example"):
with patch.object(settings, "cashu_mints", []):
assert not is_trusted_source_mint(token_mint)
@pytest.mark.parametrize(
"token_mint",
["", " ", "https://", "://mint.example", "mint.example"],
)
def test_trusted_mint_matching_rejects_degenerate_urls(token_mint: str) -> None:
with patch.object(settings, "primary_mint", "https://mint.example"):
with patch.object(settings, "cashu_mints", []):
assert not is_trusted_source_mint(token_mint)
def test_unset_primary_mint_never_makes_a_token_trusted() -> None:
"""An unset primary mint must not turn an empty token mint into a match."""
with patch.object(settings, "primary_mint", ""):
with patch.object(settings, "cashu_mints", []):
assert not is_trusted_source_mint("")
assert not is_trusted_source_mint("https://evil.example")
def test_trusted_mint_matching_keeps_path_case_sensitive() -> None:
with patch.object(settings, "primary_mint", "https://mint.minibits.cash/Bitcoin"):
with patch.object(settings, "cashu_mints", []):
assert is_trusted_source_mint("https://mint.minibits.cash/Bitcoin/")
assert not is_trusted_source_mint("https://mint.minibits.cash/bitcoin")
def test_trusted_mint_matching_rejects_unparseable_port() -> None:
with patch.object(settings, "primary_mint", "https://mint.example"):
with patch.object(settings, "cashu_mints", []):
assert not is_trusted_source_mint("https://mint.example:notaport")
def test_trusted_mint_matching_rejects_malformed_url() -> None:
with patch.object(settings, "primary_mint", "https://mint.example"):
with patch.object(settings, "cashu_mints", []):
assert not is_trusted_source_mint("https://[::1/Bitcoin")
assert resolve_trusted_source_mint("https://[::1/Bitcoin") is None
def test_resolve_returns_operator_spelling() -> None:
configured = "https://mint.example/Bitcoin"
with patch.object(settings, "primary_mint", configured):
with patch.object(settings, "cashu_mints", []):
assert (
resolve_trusted_source_mint("HTTPS://MINT.EXAMPLE:443/Bitcoin///")
== configured
)
@pytest.mark.asyncio
async def test_recieve_token_uses_canonical_mint_url() -> None:
variant = PRIMARY.upper() + "///"
get_wallet = AsyncMock(return_value=object())
redeem = AsyncMock(return_value=(90, "sat", variant))
with (
_trusted_mints(),
patch(
"routstr.wallet.deserialize_token_from_string",
return_value=_token(variant),
),
patch("routstr.wallet.get_wallet", get_wallet),
patch("routstr.wallet._redeem_same_mint", redeem),
):
amount, unit, mint_url = await recieve_token("cashuAvariant")
assert (amount, unit, mint_url) == (90, "sat", PRIMARY)
get_wallet.assert_awaited_once_with(PRIMARY, "sat", load=False)
def test_classification_has_dedicated_type_and_code() -> None:
classified = classify_redemption_error(UntrustedSourceMintError("x"))
assert classified == (
"untrusted_mint",
400,
"Cashu token was issued by a mint this node does not accept",
"cashu_untrusted_source_mint",
)
@pytest.mark.asyncio
async def test_recieve_token_rejects_untrusted_mint_before_mint_contact() -> None:
"""The gate runs inside the wallet lock but before ``get_wallet``, so an
untrusted token never reaches the mint over the network."""
get_wallet = AsyncMock()
with (
_trusted_mints(),
patch(
"routstr.wallet.deserialize_token_from_string",
return_value=_token(UNTRUSTED),
),
patch("routstr.wallet.get_wallet", get_wallet),
):
with pytest.raises(UntrustedSourceMintError):
await recieve_token("cashuAuntrusted")
get_wallet.assert_not_awaited()
@pytest.mark.asyncio
async def test_bearer_untrusted_mint_returns_400_with_dedicated_code(
session: AsyncSession,
) -> None:
get_wallet = AsyncMock()
with (
_trusted_mints(),
patch(
"routstr.auth.deserialize_token_from_string",
return_value=_token(UNTRUSTED),
),
patch(
"routstr.wallet.deserialize_token_from_string",
return_value=_token(UNTRUSTED),
),
patch("routstr.wallet.get_wallet", get_wallet),
):
with pytest.raises(HTTPException) as exc_info:
await validate_bearer_key("cashuAuntrusted", session)
assert exc_info.value.status_code == 400
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
assert detail["error"]["type"] == "untrusted_mint"
assert detail["error"]["code"] == "cashu_untrusted_source_mint"
get_wallet.assert_not_awaited()
def test_check_token_balance_rejects_untrusted_mint() -> None:
with (
_trusted_mints(),
patch(
"routstr.payment.helpers.deserialize_token_from_string",
return_value=_token(UNTRUSTED),
),
):
with pytest.raises(HTTPException) as exc_info:
check_token_balance({"x-cashu": "cashuAuntrusted"}, {"model": "m"}, 1)
assert exc_info.value.status_code == 400
detail = cast(dict[str, dict[str, str]], exc_info.value.detail)
assert detail["error"]["type"] == "untrusted_mint"
assert detail["error"]["code"] == "cashu_untrusted_source_mint"
def test_check_token_balance_accepts_trusted_mints() -> None:
for mint in (PRIMARY, SECONDARY):
with (
_trusted_mints(),
patch(
"routstr.payment.helpers.deserialize_token_from_string",
return_value=_token(mint),
),
):
check_token_balance({"x-cashu": "cashuAtrusted"}, {"model": "m"}, 1)
def _http_429(retry_after: str | None) -> httpx.HTTPStatusError:
request = httpx.Request("POST", "http://primary:3338/v1/swap")
headers = {"Retry-After": retry_after} if retry_after else {}
response = httpx.Response(429, request=request, headers=headers)
return httpx.HTTPStatusError("rate limited", request=request, response=response)
@pytest.mark.parametrize(
"error",
[_http_429("42"), _http_429(None), MintCooldownError(PRIMARY, 12.4)],
)
def test_rate_limit_asks_to_retry_later(error: Exception) -> None:
assert classify_redemption_error(error) == (
"mint_rate_limited",
503,
"Cashu mint is rate-limiting requests; retry later",
"cashu_mint_rate_limited",
)
def test_timeout_has_its_own_code() -> None:
wrapped = SourceMintConnectionError("Issuing Cashu mint is unreachable")
wrapped.__cause__ = httpx.ReadTimeout("read timed out")
assert classify_redemption_error(wrapped) == (
"mint_timeout",
503,
"Cashu mint did not respond in time; retry later",
"cashu_mint_timeout",
)
def test_source_mint_unreachable_asks_to_retry() -> None:
wrapped = SourceMintConnectionError("Issuing Cashu mint is unreachable")
wrapped.__cause__ = httpx.ConnectError("refused")
assert classify_redemption_error(wrapped) == (
"mint_unreachable",
503,
"The mint that issued this Cashu token is unreachable; retry later",
"cashu_source_mint_unreachable",
)
def test_timeout_wrapped_in_consumed_token_is_not_retryable() -> None:
consumed = TokenConsumedError("credit failed after melt")
consumed.__cause__ = httpx.ReadTimeout("read timed out")
classified = classify_redemption_error(consumed)
assert classified is not None
assert classified[3] == "cashu_token_consumed"
assert not is_mint_timeout(consumed)
@@ -8,6 +8,8 @@ from unittest.mock import Mock, patch
import pytest
from routstr.core.settings import settings
# ---------------------------------------------------------------------------
# check_token_balance
# ---------------------------------------------------------------------------
@@ -22,6 +24,7 @@ async def test_check_token_balance_x_cashu_present() -> None:
with patch("routstr.payment.helpers.deserialize_token_from_string") as mock_deser:
mock_token = Mock()
mock_token.mint = settings.primary_mint
mock_token.amount = 50000
mock_token.unit = "sat"
mock_deser.return_value = mock_token
@@ -62,6 +65,7 @@ async def test_check_token_balance_insufficient_raises() -> None:
with patch("routstr.payment.helpers.deserialize_token_from_string") as mock_deser:
mock_token = Mock()
mock_token.mint = settings.primary_mint
mock_token.amount = 100 # 100 sat
mock_token.unit = "sat"
mock_deser.return_value = mock_token
-91
View File
@@ -1,91 +0,0 @@
from unittest.mock import AsyncMock, Mock
import pytest
from cashu.core.base import MeltQuoteState, ProofSpentState
from routstr.wallet import (
TokenConsumedError,
_confirm_melt_paid,
_reconcile_ambiguous_melt,
)
@pytest.mark.asyncio
async def test_paid_quote_is_authoritative_when_proof_lookup_would_fail() -> None:
wallet = Mock(
url="http://source-mint:3338",
get_melt_quote=AsyncMock(return_value=Mock(state=MeltQuoteState.paid)),
check_proof_state=AsyncMock(side_effect=RuntimeError("proof API unavailable")),
)
assert await _reconcile_ambiguous_melt(wallet, "quote-1", [Mock()]) is True
wallet.check_proof_state.assert_not_awaited()
@pytest.mark.asyncio
async def test_timeout_snapshot_unpaid_unspent_remains_non_retryable() -> None:
wallet = Mock(
url="http://source-mint:3338",
get_melt_quote=AsyncMock(return_value=Mock(state=MeltQuoteState.unpaid)),
check_proof_state=AsyncMock(
return_value=Mock(states=[Mock(state=ProofSpentState.unspent)])
),
)
with pytest.raises(TokenConsumedError, match="ambiguous"):
await _reconcile_ambiguous_melt(wallet, "quote-2", [Mock()])
@pytest.mark.asyncio
async def test_successful_pending_melt_response_requires_reconciliation() -> None:
wallet = Mock(
url="http://source-mint:3338",
get_melt_quote=AsyncMock(return_value=Mock(state=MeltQuoteState.pending)),
check_proof_state=AsyncMock(
return_value=Mock(states=[Mock(state=ProofSpentState.pending)])
),
)
with pytest.raises(TokenConsumedError, match="ambiguous"):
await _confirm_melt_paid(
wallet,
"quote-pending",
[Mock()],
Mock(state=MeltQuoteState.pending),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("quote_state", "proof_state"),
[
(MeltQuoteState.pending, ProofSpentState.pending),
(MeltQuoteState.unpaid, ProofSpentState.spent),
(MeltQuoteState.unpaid, ProofSpentState.pending),
],
)
async def test_ambiguous_or_consumed_melt_is_never_reported_unspent(
quote_state: MeltQuoteState, proof_state: ProofSpentState
) -> None:
wallet = Mock(
url="http://source-mint:3338",
get_melt_quote=AsyncMock(return_value=Mock(state=quote_state)),
check_proof_state=AsyncMock(
return_value=Mock(states=[Mock(state=proof_state)])
),
)
with pytest.raises(TokenConsumedError, match="reconciliation required"):
await _reconcile_ambiguous_melt(wallet, "quote-3", [Mock()])
@pytest.mark.asyncio
async def test_failed_melt_reconciliation_is_non_retryable() -> None:
wallet = Mock(
url="http://source-mint:3338",
get_melt_quote=AsyncMock(side_effect=RuntimeError("mint unavailable")),
check_proof_state=AsyncMock(),
)
with pytest.raises(TokenConsumedError, match="outcome is unknown"):
await _reconcile_ambiguous_melt(wallet, "quote-4", [Mock()])
+27 -15
View File
@@ -1437,15 +1437,34 @@ async def test_dispatch_uses_url_detected_prefix_for_fireworks_custom_row() -> N
["handle_x_cashu", "handle_x_cashu_responses"],
)
@pytest.mark.parametrize(
"error",
("error", "expected_type", "expected_code", "expected_message"),
[
httpx.ConnectError("All connection attempts failed"),
MintConnectionError("Cashu mint is unreachable"),
TimeoutError("timed out connecting to mint"),
(
httpx.ConnectError("All connection attempts failed"),
"mint_unreachable",
"cashu_mint_unreachable",
"Cashu mint is unreachable; retry later",
),
(
MintConnectionError("connect to http://mint:3338 refused"),
"mint_unreachable",
"cashu_mint_unreachable",
"Cashu mint is unreachable; retry later",
),
(
TimeoutError("timed out connecting to mint"),
"mint_timeout",
"cashu_mint_timeout",
"Cashu mint did not respond in time; retry later",
),
],
)
async def test_x_cashu_mint_unreachable_returns_503(
handler_name: str, error: Exception
handler_name: str,
error: Exception,
expected_type: str,
expected_code: str,
expected_message: str,
) -> None:
"""Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable,
not a generic 400 cashu_error."""
@@ -1467,9 +1486,9 @@ async def test_x_cashu_mint_unreachable_returns_503(
assert response.status_code == 503
body = json.loads(bytes(response.body))
assert body["error"]["type"] == "mint_unreachable"
assert body["error"]["message"] == "Cashu mint is unreachable"
assert body["error"]["code"] == "cashu_mint_unreachable"
assert body["error"]["type"] == expected_type
assert body["error"]["message"] == expected_message
assert body["error"]["code"] == expected_code
if str(error) != body["error"]["message"]:
assert str(error) not in body["error"]["message"]
@@ -1513,13 +1532,6 @@ async def test_x_cashu_mint_unreachable_returns_503(
"Token value is too small to cover swap fees",
"cashu_token_swap_fees_exceed_amount",
),
(
ValueError("Failed to melt token from foreign mint http://m: boom"),
422,
"mint_error",
"Failed to swap token from foreign mint",
"cashu_foreign_mint_swap_failed",
),
(
ValueError("some unexpected wallet condition"),
400,
+1 -56
View File
@@ -1,11 +1,8 @@
from typing import cast
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
from cashu.core.base import (
MeltQuoteState,
Proof,
TokenV4,
TokenV4Proof,
TokenV4Token,
@@ -16,7 +13,6 @@ from routstr.wallet import (
Wallet,
_redeem_same_mint,
classify_redemption_error,
swap_to_trusted_mint,
)
MINT_URL = "https://mint.example"
@@ -163,54 +159,3 @@ async def test_cached_keysets_do_not_mask_a_refresh_failure(
assert classified is not None
assert classified[1] == 503
assert classified[3] == "cashu_source_mint_unreachable"
@pytest.mark.asyncio
async def test_cross_mint_swap_uses_resolved_proofs_and_active_output_keyset() -> None:
token = _token(amounts=(7,))
source_wallet = _wallet_with_keysets(FULL_V2_ID)
source_wallet.melt_quote = AsyncMock(
return_value=Mock(quote="melt-quote", amount=5, fee_reserve=2)
)
async def assert_melt_boundary(**kwargs: object) -> Mock:
assert kwargs["fee_reserve_sat"] == 2
assert source_wallet.keyset_id == FULL_V2_ID
return Mock(state=MeltQuoteState.paid)
source_wallet.melt = AsyncMock(side_effect=assert_melt_boundary)
destination_url = "https://trusted-mint.example"
destination_wallet = Mock(
load_proofs=AsyncMock(),
available_balance=Mock(amount=0),
mint=AsyncMock(),
)
mint_quote = Mock(quote="mint-quote", request="lnbc-test-invoice")
calculate_amount = AsyncMock(return_value=5)
with (
patch("routstr.wallet.settings.primary_mint", destination_url),
patch("routstr.wallet.settings.primary_mint_unit", "sat"),
patch("routstr.wallet.settings.cashu_mints", [destination_url]),
patch(
"routstr.wallet._calculate_swap_amount",
calculate_amount,
),
patch(
"routstr.wallet._request_mint_with_fallback",
AsyncMock(return_value=(destination_wallet, destination_url, mint_quote)),
),
):
assert await swap_to_trusted_mint(token, source_wallet) == (
5,
"sat",
destination_url,
)
calculate_call = calculate_amount.await_args
assert calculate_call is not None
resolved = cast(list[Proof], calculate_call.args[5])
assert resolved[0].id == FULL_V2_ID
assert source_wallet.get_fees_for_proofs.call_args.args[0] is resolved
assert source_wallet.melt.await_args.kwargs["proofs"] is resolved
+29 -1118
View File
File diff suppressed because it is too large Load Diff