mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
harden cashu path
This commit is contained in:
+12
-8
@@ -131,28 +131,32 @@ granularity) on any of them.
|
|||||||
|--------|--------|--------|-----------|---------|
|
|--------|--------|--------|-----------|---------|
|
||||||
| `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. |
|
| `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. |
|
| `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_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's NUT-02 input fees. |
|
||||||
| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Swapping the token from a foreign mint to the primary mint failed. |
|
| `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_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_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_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). |
|
| `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. |
|
| `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. |
|
| `api_error` | 500 | `internal_error` | Maybe | Unexpected server-side fault during redemption. |
|
||||||
|
|
||||||
!!! important "Retry only transient mint failures"
|
!!! important "Retry only transient mint failures"
|
||||||
Only `mint_unreachable` and `mint_rate_limited` (503) are retryable — the
|
Only `mint_unreachable`, `mint_rate_limited` and `mint_timeout` (503) are
|
||||||
same token may work again later. Everything else is a permanent property of
|
retryable — the same token may work again later. Everything else is a
|
||||||
the token and must not be blindly retried. Use exponential backoff for the
|
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
|
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,
|
particular, a `token_consumed` 500 means the mint already spent the token,
|
||||||
so a retry would fail as `token_already_spent`.
|
so a retry would fail as `token_already_spent`.
|
||||||
|
|
||||||
#### Mint failures (retryable)
|
#### Mint failures (retryable)
|
||||||
|
|
||||||
`mint_unreachable` and `mint_rate_limited` are retryable redemption errors. For
|
`mint_unreachable`, `mint_rate_limited` and `mint_timeout` are retryable
|
||||||
`mint_rate_limited`, honor the mint's cooldown before retrying.
|
redemption errors. For `mint_rate_limited`, honor the mint's cooldown before
|
||||||
|
retrying.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
+15
-17
@@ -37,6 +37,7 @@ from .wallet import (
|
|||||||
classify_redemption_error,
|
classify_redemption_error,
|
||||||
credit_balance,
|
credit_balance,
|
||||||
deserialize_token_from_string,
|
deserialize_token_from_string,
|
||||||
|
resolve_trusted_source_mint,
|
||||||
wallet_operation_guard,
|
wallet_operation_guard,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,24 +381,20 @@ async def _validate_bearer_key_locked(
|
|||||||
"has_expiry_time": bool(key_expiry_time),
|
"has_expiry_time": bool(key_expiry_time),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if token_obj.mint == settings.primary_mint:
|
token_mint = resolve_trusted_source_mint(token_obj.mint) or token_obj.mint
|
||||||
if token_obj.unit != settings.primary_mint_unit:
|
if (
|
||||||
raise redemption_error_to_http_exception(
|
token_mint == settings.primary_mint
|
||||||
ValueError(
|
and token_obj.unit != settings.primary_mint_unit
|
||||||
"Cashu token unit does not match the configured primary "
|
):
|
||||||
f"mint unit: expected {settings.primary_mint_unit}, "
|
raise redemption_error_to_http_exception(
|
||||||
f"got {token_obj.unit}"
|
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
|
refund_currency = token_obj.unit
|
||||||
elif token_obj.mint in settings.cashu_mints:
|
refund_mint_url = token_mint
|
||||||
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
|
|
||||||
|
|
||||||
new_key = ApiKey(
|
new_key = ApiKey(
|
||||||
hashed_key=hashed_key,
|
hashed_key=hashed_key,
|
||||||
@@ -449,6 +446,7 @@ async def _validate_bearer_key_locked(
|
|||||||
"cashu_source_mint_unreachable",
|
"cashu_source_mint_unreachable",
|
||||||
"cashu_mint_unreachable",
|
"cashu_mint_unreachable",
|
||||||
"cashu_mint_rate_limited",
|
"cashu_mint_rate_limited",
|
||||||
|
"cashu_mint_timeout",
|
||||||
}
|
}
|
||||||
log = (
|
log = (
|
||||||
logger.info
|
logger.info
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ from ..core import get_logger
|
|||||||
from ..core.exceptions import UpstreamError
|
from ..core.exceptions import UpstreamError
|
||||||
from ..core.redaction import redact_org_ids
|
from ..core.redaction import redact_org_ids
|
||||||
from ..core.settings import settings
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
|
def check_token_balance(headers: dict, body: dict, max_cost_for_model: int) -> None:
|
||||||
if x_cashu := headers.get("x-cashu", None):
|
if x_cashu := headers.get("x-cashu", None):
|
||||||
cashu_token = x_cashu
|
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",
|
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 = (
|
amount_msat = (
|
||||||
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
|
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ from ..payment.cost_calculation import (
|
|||||||
)
|
)
|
||||||
from ..payment.helpers import create_error_response
|
from ..payment.helpers import create_error_response
|
||||||
from ..payment.models import Model
|
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
|
from .tinfoil_trailer import forward_with_trailer
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
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():
|
if "already spent" in error_message.lower():
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"token_already_spent",
|
"token_already_spent",
|
||||||
|
|||||||
+105
-818
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -88,7 +88,7 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
|
|||||||
httpx.ConnectError("All connection attempts failed"),
|
httpx.ConnectError("All connection attempts failed"),
|
||||||
503,
|
503,
|
||||||
"mint_unreachable",
|
"mint_unreachable",
|
||||||
"Cashu mint is unreachable",
|
"Cashu mint is unreachable; retry later",
|
||||||
"cashu_mint_unreachable",
|
"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"),
|
MintConnectionError("connect to http://mint:3338 refused"),
|
||||||
503,
|
503,
|
||||||
"mint_unreachable",
|
"mint_unreachable",
|
||||||
"Cashu mint is unreachable",
|
"Cashu mint is unreachable; retry later",
|
||||||
"cashu_mint_unreachable",
|
"cashu_mint_unreachable",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -104,16 +104,16 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
|
|||||||
_value_error_wrapping_transport(),
|
_value_error_wrapping_transport(),
|
||||||
503,
|
503,
|
||||||
"mint_unreachable",
|
"mint_unreachable",
|
||||||
"Cashu mint is unreachable",
|
"Cashu mint is unreachable; retry later",
|
||||||
"cashu_mint_unreachable",
|
"cashu_mint_unreachable",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
# asyncio.TimeoutError is builtin TimeoutError on 3.11+.
|
# asyncio.TimeoutError is builtin TimeoutError on 3.11+.
|
||||||
TimeoutError("Timed out connecting to Cashu mint http://mint:3338"),
|
TimeoutError("Timed out connecting to Cashu mint http://mint:3338"),
|
||||||
503,
|
503,
|
||||||
"mint_unreachable",
|
"mint_timeout",
|
||||||
"Cashu mint is unreachable",
|
"Cashu mint did not respond in time; retry later",
|
||||||
"cashu_mint_unreachable",
|
"cashu_mint_timeout",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
ValueError(
|
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",
|
"Token value is too small to cover swap fees",
|
||||||
"cashu_token_swap_fees_exceed_amount",
|
"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"),
|
ValueError("could not decode token"),
|
||||||
400,
|
400,
|
||||||
|
|||||||
+26
-16
@@ -585,14 +585,31 @@ def _envelope(exc: HTTPException) -> dict:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error",
|
("error", "expected_type", "expected_code", "expected_message"),
|
||||||
[
|
[
|
||||||
httpx.ConnectError("All connection attempts failed"),
|
(
|
||||||
MintConnectionError("connect to mint refused"),
|
httpx.ConnectError("All connection attempts failed"),
|
||||||
TimeoutError("timed out connecting to mint"),
|
"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
|
"""A down mint must surface 503 (retryable), not 400 or 500 — the token is
|
||||||
fine, so the client should retry once the mint recovers."""
|
fine, so the client should retry once the mint recovers."""
|
||||||
from fastapi import HTTPException
|
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
|
assert exc_info.value.status_code == 503
|
||||||
err = _envelope(exc_info.value)
|
err = _envelope(exc_info.value)
|
||||||
assert err["type"] == "mint_unreachable"
|
assert err["type"] == expected_type
|
||||||
assert err["code"] == "cashu_mint_unreachable"
|
assert err["code"] == expected_code
|
||||||
assert err["message"] == "Cashu mint is unreachable"
|
assert err["message"] == expected_message
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -637,7 +654,7 @@ async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible
|
|||||||
err = _envelope(exc_info.value)
|
err = _envelope(exc_info.value)
|
||||||
assert err["type"] == "mint_unreachable"
|
assert err["type"] == "mint_unreachable"
|
||||||
assert err["code"] == "cashu_source_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
|
@pytest.mark.asyncio
|
||||||
@@ -749,13 +766,6 @@ async def test_topup_token_consumed_returns_500() -> None:
|
|||||||
"cashu_token_swap_fees_exceed_amount",
|
"cashu_token_swap_fees_exceed_amount",
|
||||||
"Token value is too small to cover swap fees",
|
"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(
|
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
|
import pytest
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# check_token_balance
|
# 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:
|
with patch("routstr.payment.helpers.deserialize_token_from_string") as mock_deser:
|
||||||
mock_token = Mock()
|
mock_token = Mock()
|
||||||
|
mock_token.mint = settings.primary_mint
|
||||||
mock_token.amount = 50000
|
mock_token.amount = 50000
|
||||||
mock_token.unit = "sat"
|
mock_token.unit = "sat"
|
||||||
mock_deser.return_value = mock_token
|
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:
|
with patch("routstr.payment.helpers.deserialize_token_from_string") as mock_deser:
|
||||||
mock_token = Mock()
|
mock_token = Mock()
|
||||||
|
mock_token.mint = settings.primary_mint
|
||||||
mock_token.amount = 100 # 100 sat
|
mock_token.amount = 100 # 100 sat
|
||||||
mock_token.unit = "sat"
|
mock_token.unit = "sat"
|
||||||
mock_deser.return_value = mock_token
|
mock_deser.return_value = mock_token
|
||||||
|
|||||||
@@ -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()])
|
|
||||||
@@ -1437,15 +1437,34 @@ async def test_dispatch_uses_url_detected_prefix_for_fireworks_custom_row() -> N
|
|||||||
["handle_x_cashu", "handle_x_cashu_responses"],
|
["handle_x_cashu", "handle_x_cashu_responses"],
|
||||||
)
|
)
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error",
|
("error", "expected_type", "expected_code", "expected_message"),
|
||||||
[
|
[
|
||||||
httpx.ConnectError("All connection attempts failed"),
|
(
|
||||||
MintConnectionError("Cashu mint is unreachable"),
|
httpx.ConnectError("All connection attempts failed"),
|
||||||
TimeoutError("timed out connecting to mint"),
|
"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(
|
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:
|
) -> None:
|
||||||
"""Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable,
|
"""Both X-Cashu entrypoints classify a down mint as 503 mint_unreachable,
|
||||||
not a generic 400 cashu_error."""
|
not a generic 400 cashu_error."""
|
||||||
@@ -1467,9 +1486,9 @@ async def test_x_cashu_mint_unreachable_returns_503(
|
|||||||
|
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
body = json.loads(bytes(response.body))
|
body = json.loads(bytes(response.body))
|
||||||
assert body["error"]["type"] == "mint_unreachable"
|
assert body["error"]["type"] == expected_type
|
||||||
assert body["error"]["message"] == "Cashu mint is unreachable"
|
assert body["error"]["message"] == expected_message
|
||||||
assert body["error"]["code"] == "cashu_mint_unreachable"
|
assert body["error"]["code"] == expected_code
|
||||||
if str(error) != body["error"]["message"]:
|
if str(error) != body["error"]["message"]:
|
||||||
assert str(error) not in 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",
|
"Token value is too small to cover swap fees",
|
||||||
"cashu_token_swap_fees_exceed_amount",
|
"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"),
|
ValueError("some unexpected wallet condition"),
|
||||||
400,
|
400,
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
from typing import cast
|
from unittest.mock import AsyncMock, Mock
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from cashu.core.base import (
|
from cashu.core.base import (
|
||||||
MeltQuoteState,
|
|
||||||
Proof,
|
|
||||||
TokenV4,
|
TokenV4,
|
||||||
TokenV4Proof,
|
TokenV4Proof,
|
||||||
TokenV4Token,
|
TokenV4Token,
|
||||||
@@ -16,7 +13,6 @@ from routstr.wallet import (
|
|||||||
Wallet,
|
Wallet,
|
||||||
_redeem_same_mint,
|
_redeem_same_mint,
|
||||||
classify_redemption_error,
|
classify_redemption_error,
|
||||||
swap_to_trusted_mint,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
MINT_URL = "https://mint.example"
|
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 is not None
|
||||||
assert classified[1] == 503
|
assert classified[1] == 503
|
||||||
assert classified[3] == "cashu_source_mint_unreachable"
|
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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user