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_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
@@ -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. |
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user