diff --git a/.env.example b/.env.example index dd01ef23..8104030e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs/provider/configuration.md b/docs/provider/configuration.md index 24599bb6..9c337abc 100644 --- a/docs/provider/configuration.md +++ b/docs/provider/configuration.md @@ -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 diff --git a/routstr/balance.py b/routstr/balance.py index 00ef8ca2..f0d1f9f5 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -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, diff --git a/routstr/foreign_mint_swap.py b/routstr/foreign_mint_swap.py index 61d876df..daeaa65a 100644 --- a/routstr/foreign_mint_swap.py +++ b/routstr/foreign_mint_swap.py @@ -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: diff --git a/routstr/wallet.py b/routstr/wallet.py index 6cb25987..ec2f4df7 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -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, - retry_on_rate_limit=False, - load_proofs=False, - ) + # 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, + ) + 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, diff --git a/tests/unit/test_fetch_all_balances.py b/tests/unit/test_fetch_all_balances.py index 12e57fd4..d4828ecb 100644 --- a/tests/unit/test_fetch_all_balances.py +++ b/tests/unit/test_fetch_all_balances.py @@ -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 diff --git a/tests/unit/test_foreign_mint_swap.py b/tests/unit/test_foreign_mint_swap.py index 40a91f6e..98352974 100644 --- a/tests/unit/test_foreign_mint_swap.py +++ b/tests/unit/test_foreign_mint_swap.py @@ -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