From fc93c98ddb95425107159f7652a480c71a204d8a Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sun, 4 Oct 2026 23:29:40 +0200 Subject: [PATCH] refactor: select swaps from trusted mints --- .env.example | 2 +- docs/api/errors.md | 4 +-- docs/provider/configuration.md | 14 ++++---- routstr/core/db.py | 6 ++-- routstr/foreign_mint_swap.py | 52 +++++++++++++++++++--------- routstr/refund.py | 12 ++++--- routstr/wallet.py | 9 +++++ tests/unit/test_foreign_mint_swap.py | 26 ++++++++++++-- 8 files changed, 89 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index afea1974..dd01ef23 100644 --- a/.env.example +++ b/.env.example @@ -51,7 +51,7 @@ ROUTSTR_SECRET_KEY= # MINT_OPERATION_TIMEOUT_SECONDS=30 # MINT_MAX_CONCURRENCY=4 # MINT_RETRY_MAX_ATTEMPTS=3 -# Foreign top-up tokens are swapped into PRIMARY_MINT_URL over Lightning. +# Foreign top-up tokens are swapped into the first CASHU_MINTS entry 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 53d40820..9fbc1fcd 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. 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. | +| `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 first configured trusted mint. | +| `mint_error` | 422 | `cashu_foreign_mint_swap_failed` | No | Top-up only: the foreign token could not be swapped into a trusted mint (none is configured, fees exceed its value, the unit is unsupported, the mint URL is not public HTTPS, or a 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. | diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index b415fecb..24599bb6 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -168,16 +168,18 @@ A fresh node ships with two mints preconfigured: - `https://mint.cubabitcoin.org` Setting `CASHU_MINTS` (env) or editing the list in the dashboard replaces this -default entirely. An explicitly empty value leaves only the primary mint -trusted. +default entirely. List order is significant: automatic foreign-mint swaps use +the first configured trusted mint. With an empty list, foreign top-ups are +rejected before any token proofs are spent. #### Tokens from other mints `/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: +them over Lightning into the first configured trusted 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 paid from +the same preferred trusted mint and 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 diff --git a/routstr/core/db.py b/routstr/core/db.py index f8c92f5c..84139643 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -637,9 +637,9 @@ class CashuSwap(SQLModel, table=True): # type: ignore """Journal of one cross-mint swap, written before any Lightning payment. ``in`` swaps melt a token from a mint the operator does not trust into the - primary mint and credit an API key. ``out`` swaps melt owner proofs on the - primary mint to issue a refund token on the user's own mint. Every money - movement is recorded here first so a crash or timeout leaves a row the + preferred trusted mint and credit an API key. ``out`` swaps melt owner + proofs on that trusted mint to issue a refund token on the user's own mint. + Every money movement is recorded here first so a crash or timeout leaves a row the reconciler can finish or fail, never an unknown balance. """ diff --git a/routstr/foreign_mint_swap.py b/routstr/foreign_mint_swap.py index 6f832536..0db617bc 100644 --- a/routstr/foreign_mint_swap.py +++ b/routstr/foreign_mint_swap.py @@ -59,6 +59,7 @@ from .wallet import ( _wallet_operation_depth, get_proofs_per_mint_and_unit, get_wallet, + preferred_trusted_mint, resolve_trusted_source_mint, wallet_operation_guard, ) @@ -283,7 +284,7 @@ def _raise_for_prior_swap(prior: CashuSwap) -> None: raise ValueError("Cashu token already spent") -# --- inbound: foreign token -> primary mint -> API key credit ------------- +# --- inbound: foreign token -> trusted mint -> API key credit ------------- async def _load_foreign_proofs(wallet: Wallet, token_obj: Token) -> list[Proof]: @@ -329,10 +330,22 @@ async def _quote_pair( return mint_quote, melt_quote +def _trusted_swap_destination() -> str: + try: + return preferred_trusted_mint() + except ValueError as error: + raise ForeignMintSwapError( + "No trusted destination mint is configured" + ) from error + + async def swap_in_and_credit( cashu_token: str, key: ApiKey, session: AsyncSession ) -> int: - """Melt a foreign-mint token into the primary mint and credit ``key``. + """Melt a foreign-mint token into a trusted mint and credit ``key``. + + The first configured ``CASHU_MINTS`` entry is the deterministic destination; + list order is the operator's priority order. Returns the credited msats. Raises before anything is spent for every refusal (``ForeignMintSwapError``, ``ForeignMintUnavailableError``, @@ -344,9 +357,9 @@ async def swap_in_and_credit( if resolve_trusted_source_mint(source_mint) is not None: raise ValueError("Token is from a trusted mint; redeem it directly") source_unit = str(token_obj.unit) - dest_unit = settings.primary_mint_unit - dest_mint = settings.primary_mint - if source_unit not in _UNITS or dest_unit not in _UNITS or not dest_mint: + dest_unit = key.refund_currency or source_unit + dest_mint = _trusted_swap_destination() + if source_unit not in _UNITS or dest_unit not in _UNITS: raise ForeignMintSwapError("Unsupported token unit for swap") if key.refund_currency is not None and key.refund_currency != dest_unit: raise ValueError( @@ -675,7 +688,7 @@ async def _finish_swap_in( return credited -# --- outbound: primary mint -> user's mint (refund) ------------------------- +# --- outbound: trusted mint -> user's mint (refund) ------------------------- async def swap_out_for_refund( @@ -683,8 +696,8 @@ async def swap_out_for_refund( ) -> bool: """Pay a refund as a token on the user's own (foreign) mint. - Owner proofs on the primary mint pay a mint quote on the user's mint; the - user receives the net amount after the Lightning fee reserve and input + Owner proofs on the preferred trusted mint pay a mint quote on the user's + mint; the user receives the net amount after the Lightning fee reserve and input fees. The ``Refund`` claim carries the melt quote so the existing refund reconciler can hold or release the balance; the swap row carries the rest. """ @@ -693,7 +706,7 @@ async def swap_out_for_refund( unit = refund.unit amount = refund.amount_msats // 1000 if unit == "sat" else refund.amount_msats - primary = settings.primary_mint + source_mint = _trusted_swap_destination() try: await assert_public_https_origin(destination_mint) except BlockedDestinationError as error: @@ -711,14 +724,14 @@ async def swap_out_for_refund( except Exception as error: raise ForeignMintSwapError("Refund mint has no active keyset") from error - source_wallet = await get_wallet(primary, unit) + source_wallet = await get_wallet(source_mint, unit) async with wallet_operation_guard(): await source_wallet.load_proofs(reload=True) proofs = get_proofs_per_mint_and_unit( - source_wallet, primary, unit, not_reserved=True + source_wallet, source_mint, unit, not_reserved=True ) if sum(p.amount for p in proofs) < amount: - raise ValueError("Primary mint balance cannot cover this refund") + raise ValueError("Trusted mint balance cannot cover this refund") selection, _ = await source_wallet.select_to_send( proofs, amount, set_reserved=False, include_fees=True ) @@ -733,7 +746,7 @@ async def swap_out_for_refund( melt_quote = await run_mint_operation( lambda: source_wallet.melt_quote(mint_quote.request), op_name="refund_swap_melt_quote", - mint_url=primary, + mint_url=source_mint, retry_timeouts=False, ) return mint_quote, melt_quote @@ -752,7 +765,7 @@ async def swap_out_for_refund( status="melting", api_key_hashed_key=refund.api_key_hashed_key, refund_id=refund.id, - source_mint=primary, + source_mint=source_mint, source_unit=unit, source_amount=amount, destination_mint=destination_mint, @@ -764,15 +777,20 @@ async def swap_out_for_refund( melt_quote_id=melt_quote.quote, ) await _save(swap) - await refund_module.record_quote(refund, melt_quote.quote, primary) + await refund_module.record_quote(refund, melt_quote.quote, source_mint) async with wallet_operation_guard(): await source_wallet.load_proofs(reload=True) proofs = get_proofs_per_mint_and_unit( - source_wallet, primary, unit, not_reserved=True + source_wallet, source_mint, unit, not_reserved=True ) plan = Bolt11PaymentPlan( - mint_quote.request, source_wallet, proofs, melt_quote, primary, unit + mint_quote.request, + source_wallet, + proofs, + melt_quote, + source_mint, + unit, ) try: await _execute_bolt11_payment(plan) diff --git a/routstr/refund.py b/routstr/refund.py index eb1a2d6f..2f570458 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -32,6 +32,8 @@ from .payment.lnurl import ( from .wallet import ( check_bolt11_payment_status, is_mint_connection_error, + preferred_trusted_mint, + resolve_trusted_source_mint, send_to_lnurl, send_token, token_mint_url, @@ -54,11 +56,13 @@ def refund_mint(key: ApiKey) -> str: """Trusted mint the payout is drawn from. A foreign refund mint (a key funded by a swapped-in token) is paid from the - primary mint and swapped back; see :func:`swap_destination`. + first configured trusted mint and swapped back; see :func:`swap_destination`. """ - if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints: - return key.refund_mint_url - return settings.primary_mint + if key.refund_mint_url: + trusted_source = resolve_trusted_source_mint(key.refund_mint_url) + if trusted_source is not None: + return trusted_source + return preferred_trusted_mint() def swap_destination(key: ApiKey, method: str) -> str | None: diff --git a/routstr/wallet.py b/routstr/wallet.py index 0b280067..6cb25987 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1054,6 +1054,15 @@ def token_mint_url(token: str, fallback: str | None = None) -> str: return fallback +def preferred_trusted_mint() -> str: + """Return the first configured trusted mint in operator priority order.""" + for mint_url in settings.cashu_mints: + candidate = mint_url.strip() + if candidate: + return candidate + raise ValueError("No trusted mint is configured") + + async def find_trusted_mint_with_funds( amount: int, unit: str, diff --git a/tests/unit/test_foreign_mint_swap.py b/tests/unit/test_foreign_mint_swap.py index 84852737..40a91f6e 100644 --- a/tests/unit/test_foreign_mint_swap.py +++ b/tests/unit/test_foreign_mint_swap.py @@ -34,7 +34,9 @@ from routstr.wallet import ( wallet_operation_guard, ) -PRIMARY = "https://primary.example" +PRIMARY = "https://trusted-first.example" +SECONDARY = "https://trusted-second.example" +LEGACY_PRIMARY = "https://legacy-primary.example" FOREIGN = "https://foreign.example" KEY_HASH = "a" * 64 @@ -59,9 +61,9 @@ async def engine( monkeypatch.setattr(db, "create_session", create_session) monkeypatch.setattr(refund_module, "create_session", create_session) monkeypatch.setattr(wallet, "_WALLET_OPERATION_LOCK", tmp_path / "op.lock") - monkeypatch.setattr(settings, "primary_mint", PRIMARY) + monkeypatch.setattr(settings, "primary_mint", LEGACY_PRIMARY) monkeypatch.setattr(settings, "primary_mint_unit", "sat") - monkeypatch.setattr(settings, "cashu_mints", [PRIMARY]) + monkeypatch.setattr(settings, "cashu_mints", [PRIMARY, SECONDARY]) monkeypatch.setattr(settings, "foreign_mint_operation_timeout_seconds", 0.2) monkeypatch.setattr(settings, "foreign_mint_max_concurrency", 4) monkeypatch.setattr(fms, "_foreign_slots", None) @@ -333,6 +335,24 @@ async def test_swap_in_rejects_non_https_mint_before_any_contact( assert await _swap_rows(session) == [] +@pytest.mark.asyncio +async def test_swap_in_requires_a_configured_trusted_destination( + engine: AsyncEngine, session: AsyncSession +) -> None: + settings.cashu_mints = [] + key = await _make_key(session) + get_wallet = AsyncMock() + with ( + patch.object(fms, "deserialize_token_from_string", return_value=_token()), + patch.object(fms, "assert_public_https_origin", AsyncMock()), + patch.object(fms, "get_wallet", get_wallet), + ): + with pytest.raises(ForeignMintSwapError, match="trusted destination"): + await fms.swap_in_and_credit("cashuAnodestination", key, session) + get_wallet.assert_not_awaited() + assert await _swap_rows(session) == [] + + @pytest.mark.asyncio async def test_swap_in_happy_path_credits_net_and_pins_refund_mint( engine: AsyncEngine, session: AsyncSession