diff --git a/plans/other-mints-balance-tracking-plan.md b/plans/other-mints-balance-tracking-plan.md new file mode 100644 index 00000000..b688b0ba --- /dev/null +++ b/plans/other-mints-balance-tracking-plan.md @@ -0,0 +1,355 @@ +# Plan: Track unsupported incoming mints in `other_mints` and include them in balances + +## Goal + +When a Cashu token arrives from a mint that is **not** in `settings.cashu_mints`, we currently create/use a wallet for that mint and swap value into the primary mint. Any change/surplus left behind after `melt()` remains in the foreign `token_wallet`, but that balance is not surfaced by `fetch_all_balances()` because it only iterates over configured mints. + +This plan adds persistent tracking for those foreign mints in a new database table called `other_mints`, and updates balance reporting to include them. + +--- + +## Current behavior + +### Incoming unsupported mint flow + +In `routstr/wallet.py`: + +- `recieve_token()` deserializes the token +- if `token_obj.mint not in settings.cashu_mints`, it calls `swap_to_primary_mint(token_obj, wallet)` +- `swap_to_primary_mint()` calls `token_wallet.melt(...)` to pay the primary mint invoice + +### Important detail: change is retained, not discarded + +The underlying Cashu wallet library keeps any melt change: + +- `Wallet.melt()` constructs blank outputs for change +- when the melt succeeds, returned change is reconstructed into proofs +- those proofs are appended to `self.proofs` and stored in the wallet DB + +So surplus from unsupported mints is **not discarded**, but it may become invisible operationally. + +### Visibility problem + +`fetch_all_balances()` currently only loops over: + +- `settings.cashu_mints` +- units `sat` and `msat` + +This means balances left on unsupported mints are not shown in admin balance reporting. + +--- + +## Proposed design + +## 1. Add a new DB table: `other_mints` + +Add a small table in `routstr/core/db.py` to persist unsupported mints we have seen in incoming tokens. + +Suggested schema: + +- `mint_url: str` primary key +- `created_at: int` +- `last_seen_at: int` + +Minimal model: + +```python +class OtherMint(SQLModel, table=True): + __tablename__ = "other_mints" + + mint_url: str = Field(primary_key=True) + created_at: int = Field(default_factory=lambda: int(time.time())) + last_seen_at: int = Field(default_factory=lambda: int(time.time())) +``` + +Why minimal: + +- the only required function is mint discovery/tracking +- unit handling can remain dynamic via existing balance queries over `sat` and `msat` + +--- + +## 2. Add DB helpers for `other_mints` + +In `routstr/core/db.py`, add helper functions: + +### `register_other_mint(mint_url: str) -> None` + +Behavior: + +- if the mint is not present, insert it +- if it already exists, update `last_seen_at` + +### `list_other_mints(session) -> list[str]` + +Behavior: + +- return all tracked unsupported mint URLs + +Optional later: + +- `delete_other_mint(...)` +- admin cleanup helpers + +--- + +## 3. Register unsupported mints during token receipt + +Update `recieve_token()` in `routstr/wallet.py`. + +Current logic: + +```python +if token_obj.mint not in settings.cashu_mints: + return await swap_to_primary_mint(token_obj, wallet) +``` + +Planned logic: + +```python +if token_obj.mint not in settings.cashu_mints: + await db.register_other_mint(token_obj.mint) + return await swap_to_primary_mint(token_obj, wallet) +``` + +Why here: + +- this is the earliest reliable point where we know the mint came in via an actual token +- this is exactly the path that can leave foreign-mint change behind +- it avoids needing to infer unsupported mints later from wallet internals + +--- + +## 4. Update `fetch_all_balances()` to include `other_mints` + +Current behavior only includes configured mints. + +Planned behavior: + +- load tracked unsupported mints from DB +- combine them with `settings.cashu_mints` +- dedupe while preserving order +- fetch balances for all tracked mints across requested units + +Conceptual flow: + +```python +tracked_mints = dedupe(settings.cashu_mints + other_mints_from_db) +``` + +Then existing per-mint/per-unit balance logic can remain mostly unchanged. + +This ensures that retained change on unsupported mints becomes visible in admin balance reporting. + +--- + +## 5. Add a balance source marker + +Extend `BalanceDetail` in `routstr/wallet.py` to identify whether a balance row comes from a configured mint or an `other_mints` entry. + +Suggested field: + +- `source: str` with values: + - `"configured"` + - `"other"` + +Updated shape: + +```python +class BalanceDetail(TypedDict, total=False): + mint_url: str + unit: str + source: str + wallet_balance: int + user_balance: int + owner_balance: int + error: str +``` + +Why this helps: + +- admin can distinguish normal configured wallet balances from foreign/unsupported balances +- avoids confusion if unexpected mint URLs show up in the balances API/UI + +--- + +## 6. Admin/API impact + +Backend impact is minimal because `/admin/api/balances` already returns `fetch_all_balances()` output. + +Effects: + +- supported mints continue to show as before +- tracked unsupported mints will also appear +- UI can optionally display the new `source` field + +No API contract break is expected if the frontend ignores unknown fields. + +--- + +## 7. Payout behavior: do not change in phase 1 + +`periodic_payout()` currently only iterates over `settings.cashu_mints`. + +Recommendation for this change: + +- **do not** expand `periodic_payout()` to include `other_mints` yet +- only improve visibility through balance reporting + +Reason: + +- automatic payout from unsupported/foreign mints may be operationally undesirable +- visibility should come first, automation second + +Possible future phase: + +- add optional sweeping/payout support for `other_mints` +- or provide an admin-triggered withdrawal/sweep flow + +--- + +## 8. Logging improvements (optional) + +Optional follow-up improvement in `swap_to_primary_mint()`: + +- capture the return value from `token_wallet.melt(...)` +- if feasible, log any reported change amount +- otherwise, rely on wallet balance reporting to surface residual amounts + +This is useful but not required for the first implementation. + +--- + +## Files to change + +### `routstr/core/db.py` + +Add: + +- `OtherMint` SQLModel +- `register_other_mint()` +- `list_other_mints()` + +### `migrations/versions/_add_other_mints_table.py` + +Create migration to add the `other_mints` table. + +### `routstr/wallet.py` + +Update: + +- `recieve_token()` to register unsupported mints +- `BalanceDetail` to include `source` +- `fetch_all_balances()` to include both configured and tracked unsupported mints + +### `routstr/core/admin.py` + +Likely no backend changes required unless a dedicated `other_mints` API is desired. + +--- + +## Behavior rules + +### Register a mint when + +- an incoming token is processed +- the token mint is not in `settings.cashu_mints` + +### Do not remove automatically when + +- balance reaches zero + +Reason: + +- historical visibility is useful +- avoids flapping entries in the admin balance list +- mint may receive additional unsupported tokens later + +Potential future enhancement: + +- admin endpoint to prune zero-balance `other_mints` + +--- + +## Edge cases + +### A mint later becomes configured + +If a mint in `other_mints` is later added to `settings.cashu_mints`: + +- deduplication prevents duplicate balance rows +- `source` should resolve to `configured` + +### Unsupported mint with zero balance + +A tracked unsupported mint may show zero balances. + +Initial recommendation: + +- allow it to appear +- consider later filtering zero-balance `other` rows if the UI becomes noisy + +### Units + +Balance fetching can continue to query both `sat` and `msat` for each tracked mint. + +If a mint has no proofs in one unit, current error/zero handling can continue to apply. + +--- + +## Test plan + +### DB tests + +- registering a new unsupported mint inserts a row +- registering the same mint again updates `last_seen_at` without duplication +- listing other mints returns expected mint URLs + +### Wallet tests + +#### `recieve_token()` + +- when mint is unsupported, `db.register_other_mint()` is called before swap +- when mint is configured, `db.register_other_mint()` is not called + +#### `fetch_all_balances()` + +- includes configured mints +- includes `other_mints` from DB +- dedupes if a mint exists in both configured and other lists +- sets `source` correctly + +### Regression tests + +- existing trusted mint balance reporting remains unchanged +- `/admin/api/balances` continues to work + +--- + +## Recommended implementation order + +1. Add `OtherMint` model to `routstr/core/db.py` +2. Add Alembic migration for `other_mints` +3. Add `register_other_mint()` and `list_other_mints()` helpers +4. Update `recieve_token()` to register unsupported mints +5. Update `fetch_all_balances()` to union configured + tracked other mints +6. Add `source` to `BalanceDetail` +7. Add/adjust tests + +--- + +## Summary + +This change solves an operational visibility problem: + +- unsupported incoming mints can leave retained change in foreign wallets +- those funds are currently preserved but not surfaced in balance reporting +- introducing `other_mints` makes those mints discoverable and auditable +- expanding `fetch_all_balances()` ensures their balances are visible in admin tooling + +Recommended scope for the first pass: + +- track unsupported mints in DB +- include them in balance reporting +- mark them as `source="other"` +- do not yet change payout/sweeping behavior diff --git a/pyproject.toml b/pyproject.toml index c3dc2f16..dcdf4844 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "routstr" -version = "0.4.0" +version = "0.4.1" description = "Payment proxy for your LLM endpoint using cashu and nostr." readme = "README.md" requires-python = ">=3.11" diff --git a/routstr/core/main.py b/routstr/core/main.py index 63eb61d4..e959086c 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -36,9 +36,9 @@ setup_logging() logger = get_logger(__name__) if os.getenv("VERSION_SUFFIX") is not None: - __version__ = f"0.4.0-{os.getenv('VERSION_SUFFIX')}" + __version__ = f"0.4.1-{os.getenv('VERSION_SUFFIX')}" else: - __version__ = "0.4.0" + __version__ = "0.4.1" @asynccontextmanager diff --git a/routstr/payment/models.py b/routstr/payment/models.py index a5cefbc0..ae780153 100644 --- a/routstr/payment/models.py +++ b/routstr/payment/models.py @@ -403,7 +403,9 @@ async def update_sats_pricing() -> None: @models_router.get("/v1/models") -@models_router.get("/models", include_in_schema=False) +@models_router.get("/v1/models/", include_in_schema=False) +@models_router.get("/models") +@models_router.get("/models/", include_in_schema=False) async def models(session: AsyncSession = Depends(get_session)) -> dict: """Get all available models from all providers with database overrides applied.""" from ..proxy import get_unique_models diff --git a/routstr/wallet.py b/routstr/wallet.py index 89939da1..9c7fb829 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1,5 +1,4 @@ import asyncio -import math import time from typing import TypedDict @@ -60,6 +59,76 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str return token +async def _calculate_swap_amount( + amount_msat: int, + token_unit: str, + token_mint_url: str, + token_wallet: Wallet, + primary_wallet: Wallet, +) -> int: + """ + Calculate the amount to mint on the primary mint after accounting for + potential swap fees (melt fees) on the foreign mint. + """ + if settings.primary_mint_unit == "sat": + receive_amount = amount_msat // 1000 + else: + receive_amount = amount_msat + + if token_mint_url == settings.primary_mint: + logger.info( + "swap_to_primary_mint: skipping fee estimation (same mint)", + extra={"minted_amount": receive_amount}, + ) + return int(receive_amount) + + logger.info( + "swap_to_primary_mint: estimating fees", + extra={ + "dummy_amount": receive_amount, + "unit": settings.primary_mint_unit, + }, + ) + + try: + dummy_mint_quote = await primary_wallet.request_mint(receive_amount) + dummy_melt_quote = await token_wallet.melt_quote(dummy_mint_quote.request) + + fee_reserve = dummy_melt_quote.fee_reserve + if token_unit == "sat": + fee_msat = fee_reserve * 1000 + else: + fee_msat = fee_reserve + + amount_msat_after_fee = amount_msat - fee_msat + + if settings.primary_mint_unit == "sat": + minted_amount = int(amount_msat_after_fee // 1000) + else: + minted_amount = int(amount_msat_after_fee) + + if minted_amount <= 0: + raise ValueError(f"Fees ({fee_reserve} {token_unit}) exceed token amount") + + logger.info( + "swap_to_primary_mint: fee estimation result", + extra={ + "token_amount_sat": amount_msat // 1000, + "estimated_fee_sat": fee_msat // 1000, + "minted_amount": minted_amount, + "minted_unit": settings.primary_mint_unit, + }, + ) + return minted_amount + + except Exception as e: + logger.error( + "swap_to_primary_mint: fee estimation failed", + extra={"error": str(e)}, + ) + raise ValueError(f"Failed to estimate fees: {e}") from e + + async def swap_to_primary_mint( token_obj: Token, token_wallet: Wallet ) -> tuple[int, str, str]: @@ -84,23 +153,14 @@ async def swap_to_primary_mint( amount_msat = token_amount else: raise ValueError("Invalid unit") - estimated_fee_sat = math.ceil(max(amount_msat // 1000 * 0.01, 2)) + 1 - amount_msat_after_fee = amount_msat - estimated_fee_sat * 1000 primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit) - if settings.primary_mint_unit == "sat": - minted_amount = int(amount_msat_after_fee // 1000) - else: - minted_amount = int(amount_msat_after_fee) - - logger.info( - "swap_to_primary_mint: fee estimation", - extra={ - "token_amount_sat": amount_msat // 1000, - "estimated_fee_sat": estimated_fee_sat, - "minted_amount": minted_amount, - "minted_unit": settings.primary_mint_unit, - }, + minted_amount = await _calculate_swap_amount( + amount_msat, + token_obj.unit, + token_obj.mint, + token_wallet, + primary_wallet, ) mint_quote = await primary_wallet.request_mint(minted_amount) diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 35ef085e..6635b24f 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -217,3 +217,77 @@ async def test_recieve_token_untrusted_mint() -> None: assert amount == 900 assert unit == "sat" assert mint == "http://mint:3338" + + +@pytest.mark.asyncio +async def test_swap_to_primary_mint_success() -> None: + """Test successful swap with dynamic fee calculation.""" + from routstr.wallet import swap_to_primary_mint + + mock_token = Mock() + mock_token.mint = "http://foreign:3338" + mock_token.unit = "sat" + mock_token.amount = 1000 + mock_token.keysets = ["keyset1"] + mock_token.proofs = [{"amount": 1000}] + + mock_token_wallet = Mock() + mock_token_wallet.load_mint = AsyncMock() + mock_token_wallet.load_proofs = AsyncMock() + + mock_primary_wallet = Mock() + mock_primary_wallet.load_mint = AsyncMock() + mock_primary_wallet.load_proofs = AsyncMock() + + # Mocks for the estimation phase + # 1. request_mint(dummy_amount=1000) -> invoice_dummy + # 2. melt_quote(invoice_dummy) -> fee=10 + + # Mocks for the execution phase + # 3. request_mint(minted_amount=990) -> invoice_real + # 4. melt_quote(invoice_real) -> amount=990, fee=10 + # 5. melt() -> success + # 6. mint() -> success + + mock_mint_quote_dummy = Mock(quote="dummy_quote", request="lnbc_dummy") + mock_mint_quote_real = Mock(quote="real_quote", request="lnbc_real") + + # side_effect for request_mint to return dummy then real + mock_primary_wallet.request_mint = AsyncMock( + side_effect=[mock_mint_quote_dummy, mock_mint_quote_real] + ) + + mock_melt_quote_dummy = Mock(amount=1000, fee_reserve=10) + mock_melt_quote_real = Mock(amount=990, fee_reserve=10) + + # side_effect for melt_quote + mock_token_wallet.melt_quote = AsyncMock( + side_effect=[mock_melt_quote_dummy, mock_melt_quote_real] + ) + + mock_token_wallet.melt = AsyncMock(return_value="melted_proofs") + mock_primary_wallet.mint = AsyncMock(return_value="minted_proofs") + + from routstr.core.settings import settings + + with patch.object(settings, "primary_mint", "http://primary:3338"): + with patch.object(settings, "primary_mint_unit", "sat"): + with patch("routstr.wallet.get_wallet", return_value=mock_primary_wallet): + amount, unit, mint = await swap_to_primary_mint( + mock_token, mock_token_wallet + ) + + assert amount == 990 # 1000 - 10 + assert unit == "sat" + assert mint == "http://primary:3338" + + # Verify call order/counts + assert mock_primary_wallet.request_mint.call_count == 2 + # First call with full amount for estimation + mock_primary_wallet.request_mint.assert_any_call(1000) + # Second call with calculated amount + mock_primary_wallet.request_mint.assert_any_call(990) + + assert mock_token_wallet.melt_quote.call_count == 2 + assert mock_token_wallet.melt.called + assert mock_primary_wallet.mint.called