refactor: discover trusted mint swap units

This commit is contained in:
9qeklajc
2026-10-05 00:22:14 +02:00
parent a13aca1338
commit c3789ece68
7 changed files with 208 additions and 31 deletions
+1
View File
@@ -52,6 +52,7 @@ ROUTSTR_SECRET_KEY=
# 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 the first CASHU_MINTS entry over Lightning. # 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_OPERATION_TIMEOUT_SECONDS=5
# FOREIGN_MINT_MAX_CONCURRENCY=4 # FOREIGN_MINT_MAX_CONCURRENCY=4
# SWAP_RECONCILE_INTERVAL_SECONDS=60 # SWAP_RECONCILE_INTERVAL_SECONDS=60
+5 -1
View File
@@ -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 Setting `CASHU_MINTS` (env) or editing the list in the dashboard replaces this
default entirely. List order is significant: automatic foreign-mint swaps use 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. rejected before any token proofs are spent.
#### Tokens from other mints #### Tokens from other mints
-3
View File
@@ -179,7 +179,6 @@ async def topup_wallet_endpoint(
extra={ extra={
"event": "cashu_topup_started", "event": "cashu_topup_started",
"source_mint": source_mint, "source_mint": source_mint,
"primary_mint": settings.primary_mint,
"trusted_mints": settings.cashu_mints, "trusted_mints": settings.cashu_mints,
"key_hash": billing_key.hashed_key[:8], "key_hash": billing_key.hashed_key[:8],
}, },
@@ -201,7 +200,6 @@ async def topup_wallet_endpoint(
extra={ extra={
"event": "cashu_topup_failed", "event": "cashu_topup_failed",
"source_mint": source_mint, "source_mint": source_mint,
"primary_mint": settings.primary_mint,
"trusted_mints": settings.cashu_mints, "trusted_mints": settings.cashu_mints,
"error_chain": _error_chain(e), "error_chain": _error_chain(e),
}, },
@@ -213,7 +211,6 @@ async def topup_wallet_endpoint(
extra={ extra={
"event": "cashu_topup_failed", "event": "cashu_topup_failed",
"source_mint": source_mint, "source_mint": source_mint,
"primary_mint": settings.primary_mint,
"trusted_mints": settings.cashu_mints, "trusted_mints": settings.cashu_mints,
"status_code": status_code, "status_code": status_code,
"error_type": error_type, "error_type": error_type,
+44 -8
View File
@@ -58,6 +58,7 @@ from .wallet import (
_execute_bolt11_payment, _execute_bolt11_payment,
_wallet_operation_depth, _wallet_operation_depth,
get_proofs_per_mint_and_unit, get_proofs_per_mint_and_unit,
get_supported_mint_units,
get_wallet, get_wallet,
preferred_trusted_mint, preferred_trusted_mint,
resolve_trusted_source_mint, resolve_trusted_source_mint,
@@ -339,6 +340,35 @@ def _trusted_swap_destination() -> str:
) from error ) 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( async def swap_in_and_credit(
cashu_token: str, key: ApiKey, session: AsyncSession cashu_token: str, key: ApiKey, session: AsyncSession
) -> int: ) -> int:
@@ -357,20 +387,20 @@ 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 if source_unit not in _UNITS:
dest_mint = _trusted_swap_destination()
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:
raise ValueError(
"Cashu token unit does not match the API key liability unit: "
f"expected {key.refund_currency}, got {dest_unit}"
)
try: try:
await assert_public_https_origin(source_mint) await assert_public_https_origin(source_mint)
except BlockedDestinationError as error: except BlockedDestinationError as error:
raise ForeignMintSwapError(str(error)) from 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() token_hash = hashlib.sha256(cashu_token.encode()).hexdigest()
prior = await _prior_swap_for_token(token_hash) prior = await _prior_swap_for_token(token_hash)
if prior is not None: if prior is not None:
@@ -707,6 +737,12 @@ 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
source_mint = _trusted_swap_destination() source_mint = _trusted_swap_destination()
await _trusted_mint_unit(
source_mint,
liability_unit=unit,
source_unit=unit,
bolt11_operation="melt",
)
try: try:
await assert_public_https_origin(destination_mint) await assert_public_https_origin(destination_mint)
except BlockedDestinationError as error: except BlockedDestinationError as error:
+65 -14
View File
@@ -1402,22 +1402,62 @@ _BALANCE_FETCH_RETRY_SECONDS = 60.0
_MINT_UNITS_CACHE_SECONDS = 300.0 _MINT_UNITS_CACHE_SECONDS = 300.0
_balance_fetch_failures: dict[tuple[str, str], tuple[float, str, str]] = {} _balance_fetch_failures: dict[tuple[str, str], tuple[float, str, str]] = {}
_balance_fetch_locks: dict[str, asyncio.Lock] = {} _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() 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]: if cached is not None and now < cached[0]:
return cached[1] return cached[1]
# A metadata load populates Cashu's shared keyset cache for all units. # Wallet construction requires a unit, but keyset discovery does not. Avoid
wallet = await get_wallet( # load_mint(), which activates that bootstrap unit before we know the mint's
mint_url, # supported units.
settings.primary_mint_unit, 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, 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) keysets = await get_cashu_keysets(mint_url=wallet.url, db=wallet.db)
units: list[str] = [] units: list[str] = []
for keyset in keysets: 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 unit = keyset.unit if isinstance(keyset.unit, str) else keyset.unit.name
if unit and unit not in units: if unit and unit not in units:
units.append(unit) 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, time.monotonic() + _MINT_UNITS_CACHE_SECONDS,
units, units,
) )
return 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( def _balance_error(
mint_url: str, mint_url: str,
unit: str, unit: str,
+45 -1
View File
@@ -137,11 +137,13 @@ async def test_supported_mint_units_come_from_active_keysets() -> None:
usd = MagicMock(active=True) usd = MagicMock(active=True)
usd.unit.name = "usd" usd.unit.name = "usd"
wallet = MagicMock(url="http://mint:3338", db=MagicMock()) 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]) get_keysets = AsyncMock(return_value=[usd, msat, sat])
with ( with (
patch.object(settings, "primary_mint_unit", "sat"), 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), patch("routstr.wallet.get_cashu_keysets", get_keysets),
): ):
units = await _get_supported_mint_units("http://mint:3338") 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 units == ["sat", "usd"]
assert cached_units == units 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) 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 @pytest.mark.asyncio
async def test_fetch_all_balances_backs_off_after_connection_failure() -> None: async def test_fetch_all_balances_backs_off_after_connection_failure() -> None:
from routstr.core.settings import settings from routstr.core.settings import settings
+46 -2
View File
@@ -93,9 +93,11 @@ def _proof(amount: int) -> SimpleNamespace:
return SimpleNamespace(amount=amount, reserved=False, secret=f"s{amount}", id="k") 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( 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, foreign: _ForeignWallet,
primary: _PrimaryWallet, primary: _PrimaryWallet,
token: SimpleNamespace, token: SimpleNamespace,
supported_units: list[str] | None = None,
) -> AsyncGenerator[None, None]: ) -> AsyncGenerator[None, None]:
wallets = {FOREIGN: foreign, PRIMARY: primary} 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, "deserialize_token_from_string", return_value=token),
patch.object(fms, "assert_public_https_origin", AsyncMock()), patch.object(fms, "assert_public_https_origin", AsyncMock()),
patch.object(fms, "get_wallet", get_wallet), 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, "run_mint_operation", run_mint_operation),
patch.object( patch.object(
fms, fms,
@@ -335,6 +343,42 @@ 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_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 @pytest.mark.asyncio
async def test_swap_in_requires_a_configured_trusted_destination( async def test_swap_in_requires_a_configured_trusted_destination(
engine: AsyncEngine, session: AsyncSession engine: AsyncEngine, session: AsyncSession