mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
refactor: discover trusted mint swap units
This commit is contained in:
@@ -52,6 +52,7 @@ ROUTSTR_SECRET_KEY=
|
||||
# MINT_MAX_CONCURRENCY=4
|
||||
# MINT_RETRY_MAX_ATTEMPTS=3
|
||||
# Foreign top-up tokens are swapped into the first CASHU_MINTS entry over Lightning.
|
||||
# Its unit is discovered from active keysets and enabled NUT-04/NUT-05 Bolt11 methods.
|
||||
# FOREIGN_MINT_OPERATION_TIMEOUT_SECONDS=5
|
||||
# FOREIGN_MINT_MAX_CONCURRENCY=4
|
||||
# SWAP_RECONCILE_INTERVAL_SECONDS=60
|
||||
|
||||
@@ -169,7 +169,11 @@ A fresh node ships with two mints preconfigured:
|
||||
|
||||
Setting `CASHU_MINTS` (env) or editing the list in the dashboard replaces this
|
||||
default entirely. List order is significant: automatic foreign-mint swaps use
|
||||
the first configured trusted mint. With an empty list, foreign top-ups are
|
||||
the first configured trusted mint. Core discovers that mint's active units from
|
||||
its keysets and, when advertised, filters them through its enabled NUT-04/NUT-05
|
||||
Bolt11 methods. For an existing key, its liability unit must remain supported;
|
||||
for a new key, Core prefers the foreign token's unit, then `sat`, then `msat`.
|
||||
With an empty trusted-mint list or no compatible unit, foreign top-ups are
|
||||
rejected before any token proofs are spent.
|
||||
|
||||
#### Tokens from other mints
|
||||
|
||||
@@ -179,7 +179,6 @@ async def topup_wallet_endpoint(
|
||||
extra={
|
||||
"event": "cashu_topup_started",
|
||||
"source_mint": source_mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"trusted_mints": settings.cashu_mints,
|
||||
"key_hash": billing_key.hashed_key[:8],
|
||||
},
|
||||
@@ -201,7 +200,6 @@ async def topup_wallet_endpoint(
|
||||
extra={
|
||||
"event": "cashu_topup_failed",
|
||||
"source_mint": source_mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"trusted_mints": settings.cashu_mints,
|
||||
"error_chain": _error_chain(e),
|
||||
},
|
||||
@@ -213,7 +211,6 @@ async def topup_wallet_endpoint(
|
||||
extra={
|
||||
"event": "cashu_topup_failed",
|
||||
"source_mint": source_mint,
|
||||
"primary_mint": settings.primary_mint,
|
||||
"trusted_mints": settings.cashu_mints,
|
||||
"status_code": status_code,
|
||||
"error_type": error_type,
|
||||
|
||||
@@ -58,6 +58,7 @@ from .wallet import (
|
||||
_execute_bolt11_payment,
|
||||
_wallet_operation_depth,
|
||||
get_proofs_per_mint_and_unit,
|
||||
get_supported_mint_units,
|
||||
get_wallet,
|
||||
preferred_trusted_mint,
|
||||
resolve_trusted_source_mint,
|
||||
@@ -339,6 +340,35 @@ def _trusted_swap_destination() -> str:
|
||||
) from error
|
||||
|
||||
|
||||
async def _trusted_mint_unit(
|
||||
mint_url: str,
|
||||
*,
|
||||
liability_unit: str | None,
|
||||
source_unit: str,
|
||||
bolt11_operation: str,
|
||||
) -> str:
|
||||
supported = [
|
||||
unit
|
||||
for unit in await get_supported_mint_units(
|
||||
mint_url, bolt11_operation=bolt11_operation
|
||||
)
|
||||
if unit in _UNITS
|
||||
]
|
||||
if liability_unit is not None:
|
||||
if liability_unit not in supported:
|
||||
raise ValueError(
|
||||
"Trusted mint does not support the API key liability unit: "
|
||||
f"{liability_unit}"
|
||||
)
|
||||
return liability_unit
|
||||
for candidate in (source_unit, "sat", "msat"):
|
||||
if candidate in supported:
|
||||
return candidate
|
||||
raise ForeignMintSwapError(
|
||||
"Trusted destination mint has no supported Bolt11 sat or msat unit"
|
||||
)
|
||||
|
||||
|
||||
async def swap_in_and_credit(
|
||||
cashu_token: str, key: ApiKey, session: AsyncSession
|
||||
) -> int:
|
||||
@@ -357,20 +387,20 @@ 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 = _trusted_swap_destination()
|
||||
if source_unit not in _UNITS or dest_unit not in _UNITS:
|
||||
if source_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(
|
||||
"Cashu token unit does not match the API key liability unit: "
|
||||
f"expected {key.refund_currency}, got {dest_unit}"
|
||||
)
|
||||
try:
|
||||
await assert_public_https_origin(source_mint)
|
||||
except BlockedDestinationError as error:
|
||||
raise ForeignMintSwapError(str(error)) from error
|
||||
|
||||
dest_mint = _trusted_swap_destination()
|
||||
dest_unit = await _trusted_mint_unit(
|
||||
dest_mint,
|
||||
liability_unit=key.refund_currency,
|
||||
source_unit=source_unit,
|
||||
bolt11_operation="mint",
|
||||
)
|
||||
token_hash = hashlib.sha256(cashu_token.encode()).hexdigest()
|
||||
prior = await _prior_swap_for_token(token_hash)
|
||||
if prior is not None:
|
||||
@@ -707,6 +737,12 @@ async def swap_out_for_refund(
|
||||
unit = refund.unit
|
||||
amount = refund.amount_msats // 1000 if unit == "sat" else refund.amount_msats
|
||||
source_mint = _trusted_swap_destination()
|
||||
await _trusted_mint_unit(
|
||||
source_mint,
|
||||
liability_unit=unit,
|
||||
source_unit=unit,
|
||||
bolt11_operation="melt",
|
||||
)
|
||||
try:
|
||||
await assert_public_https_origin(destination_mint)
|
||||
except BlockedDestinationError as error:
|
||||
|
||||
+65
-14
@@ -1402,22 +1402,62 @@ _BALANCE_FETCH_RETRY_SECONDS = 60.0
|
||||
_MINT_UNITS_CACHE_SECONDS = 300.0
|
||||
_balance_fetch_failures: dict[tuple[str, str], tuple[float, str, str]] = {}
|
||||
_balance_fetch_locks: dict[str, asyncio.Lock] = {}
|
||||
_mint_supported_units: dict[str, tuple[float, list[str]]] = {}
|
||||
_mint_supported_units: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
|
||||
|
||||
|
||||
async def _get_supported_mint_units(mint_url: str) -> list[str]:
|
||||
def _bolt11_units(wallet: Wallet, nut_number: int) -> set[str] | None:
|
||||
mint_info = wallet.mint_info
|
||||
nuts = mint_info.nuts if mint_info is not None else None
|
||||
nut = (nuts.get(nut_number) or nuts.get(str(nut_number))) if nuts else None
|
||||
if not isinstance(nut, dict) or "methods" not in nut:
|
||||
return None
|
||||
if nut.get("disabled") is True:
|
||||
return set()
|
||||
methods = nut.get("methods")
|
||||
if not isinstance(methods, list):
|
||||
return set()
|
||||
return {
|
||||
str(method.get("unit"))
|
||||
for method in methods
|
||||
if isinstance(method, dict)
|
||||
and method.get("method") == "bolt11"
|
||||
and method.get("unit")
|
||||
and method.get("disabled") is not True
|
||||
}
|
||||
|
||||
|
||||
async def get_supported_mint_units(
|
||||
mint_url: str, *, bolt11_operation: str | None = None
|
||||
) -> list[str]:
|
||||
"""Discover active units without activating a unit-specific wallet."""
|
||||
if bolt11_operation not in (None, "mint", "melt"):
|
||||
raise ValueError(f"Unsupported Bolt11 operation: {bolt11_operation}")
|
||||
cache_key = (mint_url, bolt11_operation)
|
||||
now = time.monotonic()
|
||||
cached = _mint_supported_units.get(mint_url)
|
||||
cached = _mint_supported_units.get(cache_key)
|
||||
if cached is not None and now < cached[0]:
|
||||
return cached[1]
|
||||
|
||||
# A metadata load populates Cashu's shared keyset cache for all units.
|
||||
wallet = await get_wallet(
|
||||
mint_url,
|
||||
settings.primary_mint_unit,
|
||||
# Wallet construction requires a unit, but keyset discovery does not. Avoid
|
||||
# load_mint(), which activates that bootstrap unit before we know the mint's
|
||||
# supported units.
|
||||
wallet = await get_wallet(mint_url, "sat", load=False)
|
||||
lock = _mint_metadata_load_locks.setdefault(mint_url, asyncio.Lock())
|
||||
async with lock:
|
||||
await run_mint_operation(
|
||||
wallet.load_mint_keysets,
|
||||
op_name="discover_mint_keysets",
|
||||
mint_url=mint_url,
|
||||
retry_on_rate_limit=False,
|
||||
load_proofs=False,
|
||||
)
|
||||
if bolt11_operation is not None:
|
||||
await run_mint_operation(
|
||||
lambda: wallet.load_mint_info(reload=True),
|
||||
op_name="discover_mint_info",
|
||||
mint_url=mint_url,
|
||||
retry_on_rate_limit=False,
|
||||
)
|
||||
|
||||
keysets = await get_cashu_keysets(mint_url=wallet.url, db=wallet.db)
|
||||
units: list[str] = []
|
||||
for keyset in keysets:
|
||||
@@ -1426,19 +1466,30 @@ async def _get_supported_mint_units(mint_url: str) -> list[str]:
|
||||
unit = keyset.unit if isinstance(keyset.unit, str) else keyset.unit.name
|
||||
if unit and unit not in units:
|
||||
units.append(unit)
|
||||
if not units:
|
||||
units = [settings.primary_mint_unit]
|
||||
elif settings.primary_mint_unit in units:
|
||||
units.remove(settings.primary_mint_unit)
|
||||
units.insert(0, settings.primary_mint_unit)
|
||||
|
||||
_mint_supported_units[mint_url] = (
|
||||
if bolt11_operation is not None:
|
||||
nut_number = 4 if bolt11_operation == "mint" else 5
|
||||
bolt11_units = _bolt11_units(wallet, nut_number)
|
||||
if bolt11_units is not None:
|
||||
units = [unit for unit in units if unit in bolt11_units]
|
||||
|
||||
_mint_supported_units[cache_key] = (
|
||||
time.monotonic() + _MINT_UNITS_CACHE_SECONDS,
|
||||
units,
|
||||
)
|
||||
return units
|
||||
|
||||
|
||||
async def _get_supported_mint_units(mint_url: str) -> list[str]:
|
||||
units = await get_supported_mint_units(mint_url)
|
||||
if not units:
|
||||
return [settings.primary_mint_unit]
|
||||
if settings.primary_mint_unit in units:
|
||||
units = [settings.primary_mint_unit, *units]
|
||||
units = list(dict.fromkeys(units))
|
||||
return units
|
||||
|
||||
|
||||
def _balance_error(
|
||||
mint_url: str,
|
||||
unit: str,
|
||||
|
||||
@@ -137,11 +137,13 @@ async def test_supported_mint_units_come_from_active_keysets() -> None:
|
||||
usd = MagicMock(active=True)
|
||||
usd.unit.name = "usd"
|
||||
wallet = MagicMock(url="http://mint:3338", db=MagicMock())
|
||||
wallet.load_mint_keysets = AsyncMock()
|
||||
get_wallet = AsyncMock(return_value=wallet)
|
||||
get_keysets = AsyncMock(return_value=[usd, msat, sat])
|
||||
|
||||
with (
|
||||
patch.object(settings, "primary_mint_unit", "sat"),
|
||||
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
|
||||
patch("routstr.wallet.get_wallet", get_wallet),
|
||||
patch("routstr.wallet.get_cashu_keysets", get_keysets),
|
||||
):
|
||||
units = await _get_supported_mint_units("http://mint:3338")
|
||||
@@ -149,9 +151,51 @@ async def test_supported_mint_units_come_from_active_keysets() -> None:
|
||||
|
||||
assert units == ["sat", "usd"]
|
||||
assert cached_units == units
|
||||
get_wallet.assert_awaited_once_with("http://mint:3338", "sat", load=False)
|
||||
wallet.load_mint_keysets.assert_awaited_once()
|
||||
get_keysets.assert_awaited_once_with(mint_url=wallet.url, db=wallet.db)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_supported_mint_units_filter_bolt11_mint_methods() -> None:
|
||||
from routstr.wallet import get_supported_mint_units
|
||||
|
||||
sat = MagicMock(active=True, unit="sat")
|
||||
msat = MagicMock(active=True, unit="msat")
|
||||
wallet = MagicMock(url="http://mint:3338", db=MagicMock())
|
||||
wallet.load_mint_keysets = AsyncMock()
|
||||
wallet.load_mint_info = AsyncMock()
|
||||
wallet.mint_info.nuts = {
|
||||
4: {
|
||||
"methods": [
|
||||
{"method": "bolt11", "unit": "sat"},
|
||||
{"method": "bolt11", "unit": "msat", "disabled": True},
|
||||
]
|
||||
},
|
||||
5: {"methods": [{"method": "bolt11", "unit": "msat"}]},
|
||||
}
|
||||
|
||||
with (
|
||||
patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)),
|
||||
patch(
|
||||
"routstr.wallet.get_cashu_keysets",
|
||||
AsyncMock(return_value=[sat, msat]),
|
||||
),
|
||||
):
|
||||
mint_units = await get_supported_mint_units(
|
||||
"http://mint:3338", bolt11_operation="mint"
|
||||
)
|
||||
melt_units = await get_supported_mint_units(
|
||||
"http://mint:3338", bolt11_operation="melt"
|
||||
)
|
||||
|
||||
assert mint_units == ["sat"]
|
||||
assert melt_units == ["msat"]
|
||||
assert wallet.load_mint_keysets.await_count == 2
|
||||
assert wallet.load_mint_info.await_count == 2
|
||||
wallet.load_mint_info.assert_awaited_with(reload=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_backs_off_after_connection_failure() -> None:
|
||||
from routstr.core.settings import settings
|
||||
|
||||
@@ -93,9 +93,11 @@ def _proof(amount: int) -> SimpleNamespace:
|
||||
return SimpleNamespace(amount=amount, reserved=False, secret=f"s{amount}", id="k")
|
||||
|
||||
|
||||
def _token(amount: int = 1000, mint: str = FOREIGN) -> SimpleNamespace:
|
||||
def _token(
|
||||
amount: int = 1000, mint: str = FOREIGN, unit: str = "sat"
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
mint=mint, unit="sat", amount=amount, keysets=["k"], proofs=[_proof(amount)]
|
||||
mint=mint, unit=unit, amount=amount, keysets=["k"], proofs=[_proof(amount)]
|
||||
)
|
||||
|
||||
|
||||
@@ -183,6 +185,7 @@ async def _swap_env(
|
||||
foreign: _ForeignWallet,
|
||||
primary: _PrimaryWallet,
|
||||
token: SimpleNamespace,
|
||||
supported_units: list[str] | None = None,
|
||||
) -> AsyncGenerator[None, None]:
|
||||
wallets = {FOREIGN: foreign, PRIMARY: primary}
|
||||
|
||||
@@ -196,6 +199,11 @@ async def _swap_env(
|
||||
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),
|
||||
patch.object(
|
||||
fms,
|
||||
"get_supported_mint_units",
|
||||
AsyncMock(return_value=supported_units or ["sat"]),
|
||||
),
|
||||
patch.object(fms, "run_mint_operation", run_mint_operation),
|
||||
patch.object(
|
||||
fms,
|
||||
@@ -335,6 +343,42 @@ async def test_swap_in_rejects_non_https_mint_before_any_contact(
|
||||
assert await _swap_rows(session) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trusted_mint_unit_prefers_existing_liability_then_source() -> None:
|
||||
supported = AsyncMock(return_value=["sat", "msat"])
|
||||
with patch.object(fms, "get_supported_mint_units", supported):
|
||||
existing = await fms._trusted_mint_unit(
|
||||
PRIMARY,
|
||||
liability_unit="msat",
|
||||
source_unit="sat",
|
||||
bolt11_operation="mint",
|
||||
)
|
||||
new_key = await fms._trusted_mint_unit(
|
||||
PRIMARY,
|
||||
liability_unit=None,
|
||||
source_unit="sat",
|
||||
bolt11_operation="mint",
|
||||
)
|
||||
|
||||
assert existing == "msat"
|
||||
assert new_key == "sat"
|
||||
supported.assert_awaited_with(PRIMARY, bolt11_operation="mint")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trusted_mint_unit_rejects_unsupported_liability() -> None:
|
||||
with patch.object(
|
||||
fms, "get_supported_mint_units", AsyncMock(return_value=["sat"])
|
||||
):
|
||||
with pytest.raises(ValueError, match="liability unit"):
|
||||
await fms._trusted_mint_unit(
|
||||
PRIMARY,
|
||||
liability_unit="msat",
|
||||
source_unit="sat",
|
||||
bolt11_operation="melt",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_in_requires_a_configured_trusted_destination(
|
||||
engine: AsyncEngine, session: AsyncSession
|
||||
|
||||
Reference in New Issue
Block a user