refactor: select swaps from trusted mints

This commit is contained in:
9qeklajc
2026-10-04 23:29:40 +02:00
parent 48c7abaab3
commit fc93c98ddb
8 changed files with 89 additions and 36 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ ROUTSTR_SECRET_KEY=
# MINT_OPERATION_TIMEOUT_SECONDS=30 # MINT_OPERATION_TIMEOUT_SECONDS=30
# MINT_MAX_CONCURRENCY=4 # MINT_MAX_CONCURRENCY=4
# MINT_RETRY_MAX_ATTEMPTS=3 # 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_OPERATION_TIMEOUT_SECONDS=5
# FOREIGN_MINT_MAX_CONCURRENCY=4 # FOREIGN_MINT_MAX_CONCURRENCY=4
# SWAP_RECONCILE_INTERVAL_SECONDS=60 # SWAP_RECONCILE_INTERVAL_SECONDS=60
+2 -2
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. | | `token_already_spent` | 400 | `cashu_token_already_spent` | No | The token was already redeemed. |
| `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. | | `invalid_token` | 400 | `invalid_cashu_token` | No | The token is malformed or cannot be decoded. |
| `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's NUT-02 input fees. | | `mint_error` | 422 | `cashu_token_swap_fees_exceed_amount` | No | Token value is too small to cover the mint's NUT-02 input fees. |
| `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. | | `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 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. | | `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`. | | `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_unreachable` | 503 | `cashu_source_mint_unreachable` | **Yes** | The mint that issued the token could not be reached; it cannot be redeemed at another mint. |
| `mint_rate_limited` | 503 | `cashu_mint_rate_limited` | **Yes** | The mint rate-limited the request; retry after the cooldown. | | `mint_rate_limited` | 503 | `cashu_mint_rate_limited` | **Yes** | The mint rate-limited the request; retry after the cooldown. |
+8 -6
View File
@@ -168,16 +168,18 @@ A fresh node ships with two mints preconfigured:
- `https://mint.cubabitcoin.org` - `https://mint.cubabitcoin.org`
Setting `CASHU_MINTS` (env) or editing the list in the dashboard replaces this Setting `CASHU_MINTS` (env) or editing the list in the dashboard replaces this
default entirely. An explicitly empty value leaves only the primary mint default entirely. List order is significant: automatic foreign-mint swaps use
trusted. the first configured trusted mint. With an empty list, foreign top-ups are
rejected before any token proofs are spent.
#### Tokens from other mints #### Tokens from other mints
`/v1/wallet/topup` accepts tokens issued by mints outside this list by melting `/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 them over Lightning into the first configured trusted mint. Bearer and X-Cashu
refuse foreign mints (those paths run on every request and must not wait on a payments still refuse foreign mints (those paths run on every request and must
third-party mint). Refunds of a key funded this way are swapped back to the not wait on a third-party mint). Refunds of a key funded this way are paid from
user's own mint, net of fees. Safeguards: 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. - 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 - Calls to the foreign mint get one attempt with a short deadline and share a
+3 -3
View File
@@ -637,9 +637,9 @@ class CashuSwap(SQLModel, table=True): # type: ignore
"""Journal of one cross-mint swap, written before any Lightning payment. """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 ``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 preferred trusted mint and credit an API key. ``out`` swaps melt owner
primary mint to issue a refund token on the user's own mint. Every money proofs on that trusted mint to issue a refund token on the user's own mint.
movement is recorded here first so a crash or timeout leaves a row the 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. reconciler can finish or fail, never an unknown balance.
""" """
+35 -17
View File
@@ -59,6 +59,7 @@ from .wallet import (
_wallet_operation_depth, _wallet_operation_depth,
get_proofs_per_mint_and_unit, get_proofs_per_mint_and_unit,
get_wallet, get_wallet,
preferred_trusted_mint,
resolve_trusted_source_mint, resolve_trusted_source_mint,
wallet_operation_guard, wallet_operation_guard,
) )
@@ -283,7 +284,7 @@ def _raise_for_prior_swap(prior: CashuSwap) -> None:
raise ValueError("Cashu token already spent") 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]: 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 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( async def swap_in_and_credit(
cashu_token: str, key: ApiKey, session: AsyncSession cashu_token: str, key: ApiKey, session: AsyncSession
) -> int: ) -> 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 Returns the credited msats. Raises before anything is spent for every
refusal (``ForeignMintSwapError``, ``ForeignMintUnavailableError``, refusal (``ForeignMintSwapError``, ``ForeignMintUnavailableError``,
@@ -344,9 +357,9 @@ async def swap_in_and_credit(
if resolve_trusted_source_mint(source_mint) is not None: if resolve_trusted_source_mint(source_mint) is not None:
raise ValueError("Token is from a trusted mint; redeem it directly") raise ValueError("Token is from a trusted mint; redeem it directly")
source_unit = str(token_obj.unit) source_unit = str(token_obj.unit)
dest_unit = settings.primary_mint_unit dest_unit = key.refund_currency or source_unit
dest_mint = settings.primary_mint dest_mint = _trusted_swap_destination()
if source_unit not in _UNITS or dest_unit not in _UNITS or not dest_mint: if source_unit not in _UNITS or dest_unit not in _UNITS:
raise ForeignMintSwapError("Unsupported token unit for swap") raise ForeignMintSwapError("Unsupported token unit for swap")
if key.refund_currency is not None and key.refund_currency != dest_unit: if key.refund_currency is not None and key.refund_currency != dest_unit:
raise ValueError( raise ValueError(
@@ -675,7 +688,7 @@ async def _finish_swap_in(
return credited return credited
# --- outbound: primary mint -> user's mint (refund) ------------------------- # --- outbound: trusted mint -> user's mint (refund) -------------------------
async def swap_out_for_refund( async def swap_out_for_refund(
@@ -683,8 +696,8 @@ async def swap_out_for_refund(
) -> bool: ) -> bool:
"""Pay a refund as a token on the user's own (foreign) mint. """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 Owner proofs on the preferred trusted mint pay a mint quote on the user's
user receives the net amount after the Lightning fee reserve and input 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 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. 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 unit = refund.unit
amount = refund.amount_msats // 1000 if unit == "sat" else refund.amount_msats amount = refund.amount_msats // 1000 if unit == "sat" else refund.amount_msats
primary = settings.primary_mint source_mint = _trusted_swap_destination()
try: try:
await assert_public_https_origin(destination_mint) await assert_public_https_origin(destination_mint)
except BlockedDestinationError as error: except BlockedDestinationError as error:
@@ -711,14 +724,14 @@ async def swap_out_for_refund(
except Exception as error: except Exception as error:
raise ForeignMintSwapError("Refund mint has no active keyset") from 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(): async with wallet_operation_guard():
await source_wallet.load_proofs(reload=True) await source_wallet.load_proofs(reload=True)
proofs = get_proofs_per_mint_and_unit( 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: 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( selection, _ = await source_wallet.select_to_send(
proofs, amount, set_reserved=False, include_fees=True proofs, amount, set_reserved=False, include_fees=True
) )
@@ -733,7 +746,7 @@ async def swap_out_for_refund(
melt_quote = await run_mint_operation( melt_quote = await run_mint_operation(
lambda: source_wallet.melt_quote(mint_quote.request), lambda: source_wallet.melt_quote(mint_quote.request),
op_name="refund_swap_melt_quote", op_name="refund_swap_melt_quote",
mint_url=primary, mint_url=source_mint,
retry_timeouts=False, retry_timeouts=False,
) )
return mint_quote, melt_quote return mint_quote, melt_quote
@@ -752,7 +765,7 @@ async def swap_out_for_refund(
status="melting", status="melting",
api_key_hashed_key=refund.api_key_hashed_key, api_key_hashed_key=refund.api_key_hashed_key,
refund_id=refund.id, refund_id=refund.id,
source_mint=primary, source_mint=source_mint,
source_unit=unit, source_unit=unit,
source_amount=amount, source_amount=amount,
destination_mint=destination_mint, destination_mint=destination_mint,
@@ -764,15 +777,20 @@ async def swap_out_for_refund(
melt_quote_id=melt_quote.quote, melt_quote_id=melt_quote.quote,
) )
await _save(swap) 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(): async with wallet_operation_guard():
await source_wallet.load_proofs(reload=True) await source_wallet.load_proofs(reload=True)
proofs = get_proofs_per_mint_and_unit( proofs = get_proofs_per_mint_and_unit(
source_wallet, primary, unit, not_reserved=True source_wallet, source_mint, unit, not_reserved=True
) )
plan = Bolt11PaymentPlan( plan = Bolt11PaymentPlan(
mint_quote.request, source_wallet, proofs, melt_quote, primary, unit mint_quote.request,
source_wallet,
proofs,
melt_quote,
source_mint,
unit,
) )
try: try:
await _execute_bolt11_payment(plan) await _execute_bolt11_payment(plan)
+8 -4
View File
@@ -32,6 +32,8 @@ from .payment.lnurl import (
from .wallet import ( from .wallet import (
check_bolt11_payment_status, check_bolt11_payment_status,
is_mint_connection_error, is_mint_connection_error,
preferred_trusted_mint,
resolve_trusted_source_mint,
send_to_lnurl, send_to_lnurl,
send_token, send_token,
token_mint_url, token_mint_url,
@@ -54,11 +56,13 @@ def refund_mint(key: ApiKey) -> str:
"""Trusted mint the payout is drawn from. """Trusted mint the payout is drawn from.
A foreign refund mint (a key funded by a swapped-in token) is paid from the 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: if key.refund_mint_url:
return key.refund_mint_url trusted_source = resolve_trusted_source_mint(key.refund_mint_url)
return settings.primary_mint if trusted_source is not None:
return trusted_source
return preferred_trusted_mint()
def swap_destination(key: ApiKey, method: str) -> str | None: def swap_destination(key: ApiKey, method: str) -> str | None:
+9
View File
@@ -1054,6 +1054,15 @@ def token_mint_url(token: str, fallback: str | None = None) -> str:
return fallback 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( async def find_trusted_mint_with_funds(
amount: int, amount: int,
unit: str, unit: str,
+23 -3
View File
@@ -34,7 +34,9 @@ from routstr.wallet import (
wallet_operation_guard, 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" FOREIGN = "https://foreign.example"
KEY_HASH = "a" * 64 KEY_HASH = "a" * 64
@@ -59,9 +61,9 @@ async def engine(
monkeypatch.setattr(db, "create_session", create_session) monkeypatch.setattr(db, "create_session", create_session)
monkeypatch.setattr(refund_module, "create_session", create_session) monkeypatch.setattr(refund_module, "create_session", create_session)
monkeypatch.setattr(wallet, "_WALLET_OPERATION_LOCK", tmp_path / "op.lock") 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, "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_operation_timeout_seconds", 0.2)
monkeypatch.setattr(settings, "foreign_mint_max_concurrency", 4) monkeypatch.setattr(settings, "foreign_mint_max_concurrency", 4)
monkeypatch.setattr(fms, "_foreign_slots", None) 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) == [] 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 @pytest.mark.asyncio
async def test_swap_in_happy_path_credits_net_and_pins_refund_mint( async def test_swap_in_happy_path_credits_net_and_pins_refund_mint(
engine: AsyncEngine, session: AsyncSession engine: AsyncEngine, session: AsyncSession