From eed132b422734a99e5d095a3c94099cd8ae60a45 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 4 Oct 2026 20:03:00 +0200 Subject: [PATCH] refactor: make foreign mint swaps automatic --- .env.example | 3 +- docs/api/errors.md | 9 ++-- docs/provider/configuration.md | 14 +++--- routstr/balance.py | 5 +-- routstr/core/settings.py | 9 +--- routstr/foreign_mint_swap.py | 10 +---- .../integration/test_topup_untrusted_mint.py | 45 ++++++------------- tests/unit/test_balance.py | 2 +- tests/unit/test_foreign_mint_swap.py | 25 +---------- tests/unit/test_settings.py | 7 --- 10 files changed, 30 insertions(+), 99 deletions(-) diff --git a/.env.example b/.env.example index 8d16580d..afea1974 100644 --- a/.env.example +++ b/.env.example @@ -51,8 +51,7 @@ ROUTSTR_SECRET_KEY= # MINT_OPERATION_TIMEOUT_SECONDS=30 # MINT_MAX_CONCURRENCY=4 # MINT_RETRY_MAX_ATTEMPTS=3 -# Foreign token policy: reject, or swap into PRIMARY_MINT_URL over Lightning. -# FOREIGN_MINT_POLICY=reject +# Foreign top-up tokens are swapped into PRIMARY_MINT_URL over Lightning. # FOREIGN_MINT_OPERATION_TIMEOUT_SECONDS=5 # FOREIGN_MINT_MAX_CONCURRENCY=4 # SWAP_RECONCILE_INTERVAL_SECONDS=60 diff --git a/docs/api/errors.md b/docs/api/errors.md index 1764befd..53d40820 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -193,8 +193,8 @@ 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 NUT-02 input fees. | -| `untrusted_mint` | 400 | `cashu_untrusted_source_mint` | No | The token was issued by a mint this node does not accept. With the default `FOREIGN_MINT_POLICY=reject` only the node's configured mints (`PRIMARY_MINT_URL` / `CASHU_MINTS`) are redeemable. Bearer and X-Cashu payments always answer this for a foreign mint; only `/v1/wallet/topup` swaps when the operator enabled it. | -| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Top-up only, `FOREIGN_MINT_POLICY=swap`: the token could not be swapped into the node's mint (fees exceed its value, unsupported unit, non-HTTPS mint URL, or the issuing mint refused the payment). Nothing was spent; the token is still yours. | +| `untrusted_mint` | 400 | `cashu_untrusted_source_mint` | No | The token was issued by a mint this node does not accept. Bearer and X-Cashu payments always answer this for a foreign mint; `/v1/wallet/topup` swaps foreign tokens into the primary mint. | +| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Top-up only: the foreign token could not be swapped into the node's mint (fees exceed its value, unsupported unit, non-HTTPS mint URL, or the issuing mint refused the payment). Nothing was spent; the token is still yours. | | `swap_pending` | 409 | `cashu_swap_pending` | No | Top-up only: the swap's Lightning payment was dispatched but the issuing mint has not confirmed it. Do **not** resend the token (its proofs may be spent). The balance is credited automatically once the payment is confirmed; poll `/v1/wallet/info`. | | `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. | @@ -209,9 +209,8 @@ granularity) on any of them. 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` or enables `FOREIGN_MINT_POLICY=swap` - for top-ups. Use exponential backoff for the + `untrusted_mint` is permanent for bearer and X-Cashu payments; foreign + `/v1/wallet/topup` tokens are swapped automatically. 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`. diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index e7c3b0a9..b415fecb 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -173,14 +173,11 @@ trusted. #### Tokens from other mints -By default a token issued by a mint outside this list is refused offline with -`cashu_untrusted_source_mint`; the node never contacts a mint it does not trust. - -`FOREIGN_MINT_POLICY=swap` lets `/v1/wallet/topup` accept such tokens by -melting them over Lightning into the primary mint. Bearer and X-Cashu payments -still refuse foreign mints (those paths run on every request and must not wait -on a third-party mint). Refunds of a key funded this way are swapped back to the -user's own mint, net of fees. Safeguards when enabled: +`/v1/wallet/topup` accepts tokens issued by mints outside this list by melting +them over Lightning into the primary mint. Bearer and X-Cashu payments still +refuse foreign mints (those paths run on every request and must not wait on a +third-party mint). Refunds of a key funded this way are swapped back to the +user's own mint, net of fees. Safeguards: - The token's mint URL must be HTTPS to a public address. - Calls to the foreign mint get one attempt with a short deadline and share a @@ -258,7 +255,6 @@ Use environment variables for: | `MINT_OPERATION_TIMEOUT_SECONDS` | Per-attempt timeout for mint network calls | `30` | | `MINT_MAX_CONCURRENCY` | Concurrent operations allowed per mint (`0` disables the limit) | `4` | | `MINT_RETRY_MAX_ATTEMPTS` | Retries after a timeout or HTTP 429 (`0` disables retries) | `3` | -| `FOREIGN_MINT_POLICY` | `reject` refuses top-up tokens from unconfigured mints; `swap` melts them into the primary mint (see above) | `reject` | | `FOREIGN_MINT_OPERATION_TIMEOUT_SECONDS` | Single-attempt deadline for calls to an unconfigured mint | `5` | | `FOREIGN_MINT_MAX_CONCURRENCY` | Process-wide cap on in-flight calls to unconfigured mints | `4` | | `SWAP_RECONCILE_INTERVAL_SECONDS` | How often unfinished swaps are re-checked against their mints | `60` | diff --git a/routstr/balance.py b/routstr/balance.py index cd67078f..00ef8ca2 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -20,10 +20,9 @@ from .core.db import ( ) from .core.logging import get_logger from .core.settings import settings -from .foreign_mint_swap import swap_enabled, swap_in_and_credit +from .foreign_mint_swap import swap_in_and_credit from .lightning import lightning_router from .wallet import ( - UntrustedSourceMintError, classify_redemption_error, credit_balance, is_trusted_source_mint, @@ -187,8 +186,6 @@ async def topup_wallet_endpoint( ) try: if source_mint != "unknown" and not is_trusted_source_mint(source_mint): - if not swap_enabled(): - raise UntrustedSourceMintError(f"Untrusted source mint: {source_mint}") # Top-up is the only entry point that swaps: the caller is already # waiting on a long operation here, unlike bearer auth or X-Cashu. amount_msats = await swap_in_and_credit(cashu_token, billing_key, session) diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 0e71ae1c..4f26bab4 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -6,7 +6,7 @@ import os import secrets import time from datetime import datetime, timezone -from typing import Any, Literal +from typing import Any from pydantic.v1 import BaseModel, BaseSettings, Field from sqlmodel.ext.asyncio.session import AsyncSession @@ -100,13 +100,6 @@ class Settings(BaseSettings): mint_max_concurrency: int = Field(default=4, ge=0, env="MINT_MAX_CONCURRENCY") # Max retries when a mint returns 429 or times out (exponential backoff). mint_retry_max_attempts: int = Field(default=3, ge=0, env="MINT_RETRY_MAX_ATTEMPTS") - # What to do with a top-up token issued by a mint outside primary_mint / - # cashu_mints. "reject" refuses it offline. "swap" melts it over Lightning - # into the primary mint (and refunds back the same way) under a separate, - # short budget so a hostile or dead mint can never hold wallet state. - foreign_mint_policy: Literal["reject", "swap"] = Field( - default="reject", env="FOREIGN_MINT_POLICY" - ) # Single-attempt deadline for any call to a mint the operator did not # configure. No retries: the sender chose that mint, not the operator. foreign_mint_operation_timeout_seconds: float = Field( diff --git a/routstr/foreign_mint_swap.py b/routstr/foreign_mint_swap.py index c986ebc9..57541078 100644 --- a/routstr/foreign_mint_swap.py +++ b/routstr/foreign_mint_swap.py @@ -75,14 +75,10 @@ _FOREIGN_FAILURE_EXCEPTIONS: tuple[type[BaseException], ...] = ( ) -def swap_enabled() -> bool: - return settings.foreign_mint_policy.strip().lower() == "swap" - - def refund_destination_mint(key: ApiKey) -> str | None: - """The user's own mint to refund to, when it is foreign and swaps are on.""" + """Return the user's own mint when a refund needs a reverse swap.""" mint = key.refund_mint_url - if not mint or not swap_enabled() or resolve_trusted_source_mint(mint): + if not mint or resolve_trusted_source_mint(mint): return None return mint @@ -319,8 +315,6 @@ async def swap_in_and_credit( ``ValueError``) and ``SwapPendingError`` once the melt was dispatched but not confirmed. """ - if not swap_enabled(): - raise ForeignMintSwapError("Foreign-mint swaps are disabled on this node") token_obj = deserialize_token_from_string(cashu_token) source_mint = str(token_obj.mint) if resolve_trusted_source_mint(source_mint) is not None: diff --git a/tests/integration/test_topup_untrusted_mint.py b/tests/integration/test_topup_untrusted_mint.py index 99604fd3..d4e20016 100644 --- a/tests/integration/test_topup_untrusted_mint.py +++ b/tests/integration/test_topup_untrusted_mint.py @@ -1,44 +1,26 @@ -""" -Integration test for the wallet topup endpoint with a foreign-mint token. +"""Integration coverage for automatic foreign-mint wallet top-ups.""" -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 +from unittest.mock import AsyncMock, 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" +PRIMARY_MINT = "https://primary.example" +FOREIGN_MINT = "https://foreign.example" @pytest.mark.integration @pytest.mark.asyncio -async def test_topup_with_foreign_mint_token_is_rejected_without_mint_contact( +async def test_topup_with_foreign_mint_token_swaps_automatically( 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() + swap = AsyncMock(return_value=997_000) 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("routstr.balance.token_mint_url", return_value=FOREIGN_MINT), + patch("routstr.balance.swap_in_and_credit", swap), patch.object(settings, "primary_mint", PRIMARY_MINT), patch.object(settings, "primary_mint_unit", "sat"), patch.object(settings, "cashu_mints", [PRIMARY_MINT]), @@ -48,9 +30,8 @@ async def test_topup_with_foreign_mint_token_is_rejected_without_mint_contact( 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() + assert response.status_code == 200 + assert response.json() == {"msats": 997_000} + swap.assert_awaited_once() + assert swap.await_args is not None + assert swap.await_args.args[0] == "cashuAtest_foreign_token" diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index 8ffdec9f..cdbaecae 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -244,7 +244,7 @@ def test_cashu_transaction_source_can_be_apikey() -> None: def _make_api_key( balance: int = 5000, refund_currency: str | None = "sat", - refund_mint_url: str | None = "https://mint.example.com", + refund_mint_url: str | None = None, refund_address: str | None = None, ) -> ApiKey: key = ApiKey(hashed_key="testhash") diff --git a/tests/unit/test_foreign_mint_swap.py b/tests/unit/test_foreign_mint_swap.py index 4f59ceaf..f995c7ad 100644 --- a/tests/unit/test_foreign_mint_swap.py +++ b/tests/unit/test_foreign_mint_swap.py @@ -62,7 +62,6 @@ async def engine( monkeypatch.setattr(settings, "primary_mint", PRIMARY) monkeypatch.setattr(settings, "primary_mint_unit", "sat") monkeypatch.setattr(settings, "cashu_mints", [PRIMARY]) - monkeypatch.setattr(settings, "foreign_mint_policy", "swap") monkeypatch.setattr(settings, "foreign_mint_operation_timeout_seconds", 0.2) monkeypatch.setattr(settings, "foreign_mint_max_concurrency", 4) monkeypatch.setattr(fms, "_foreign_slots", None) @@ -224,23 +223,7 @@ def _mint_recovered() -> None: MintRateGuard._guards.clear() -# --- policy and budget ------------------------------------------------------ - - -def test_swap_is_off_by_default() -> None: - from routstr.core.settings import Settings - - assert Settings.__fields__["foreign_mint_policy"].default == "reject" - - -@pytest.mark.asyncio -async def test_swap_in_refused_when_policy_is_reject(engine: AsyncEngine) -> None: - settings.foreign_mint_policy = "reject" - key = ApiKey(hashed_key=KEY_HASH) - with patch.object(fms, "deserialize_token_from_string") as parse: - with pytest.raises(ForeignMintSwapError): - await fms.swap_in_and_credit("cashuA", key, Mock()) - parse.assert_not_called() +# --- foreign-mint budget --------------------------------------------------- @pytest.mark.asyncio @@ -619,9 +602,7 @@ async def test_reconciler_leaves_fresh_rows_alone( # --- refund back to the user's mint ------------------------------------------- -def test_refund_destination_requires_foreign_mint_and_swap_policy( - engine: AsyncEngine, -) -> None: +def test_refund_destination_requires_foreign_mint(engine: AsyncEngine) -> None: foreign_key = ApiKey(hashed_key=KEY_HASH, refund_mint_url=FOREIGN) assert fms.refund_destination_mint(foreign_key) == FOREIGN assert fms.refund_destination_mint(ApiKey(hashed_key=KEY_HASH)) is None @@ -631,8 +612,6 @@ def test_refund_destination_requires_foreign_mint_and_swap_policy( ) is None ) - settings.foreign_mint_policy = "reject" - assert fms.refund_destination_mint(foreign_key) is None async def _open_cashu_refund(session: AsyncSession, key: ApiKey) -> Refund: diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 35011cf0..73670190 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -3,7 +3,6 @@ import os import subprocess import sys from pathlib import Path -from typing import Any import pytest from pydantic.v1 import ValidationError @@ -66,12 +65,6 @@ def test_payout_settings_have_sensible_defaults() -> None: assert s.payout_interval_seconds == 900 -def test_foreign_mint_policy_rejects_typos() -> None: - bad_policy: Any = "swpa" - with pytest.raises(ValidationError): - Settings(foreign_mint_policy=bad_policy) - - def test_cashu_import_cannot_override_operator_environment() -> None: env = dict(os.environ) env["CASHU_MINTS"] = "https://mint.operator.example"