refactor: make foreign mint swaps automatic

This commit is contained in:
9qeklajc
2026-10-04 20:03:00 +02:00
parent 0c930e837c
commit eed132b422
10 changed files with 30 additions and 99 deletions
+1 -2
View File
@@ -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
+4 -5
View File
@@ -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`.
+5 -9
View File
@@ -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` |
+1 -4
View File
@@ -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)
+1 -8
View File
@@ -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(
+2 -8
View File
@@ -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:
+13 -32
View File
@@ -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"
+1 -1
View File
@@ -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")
+2 -23
View File
@@ -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:
-7
View File
@@ -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"