mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
refactor: select swaps from trusted mints
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+8
-4
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user