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