mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-09-14 02:35:05 +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. |
|
||||
| `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
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
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"),
|
||||
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
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
)
|
||||
@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,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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user