From b535e88282dcff67f60af796117196a3db5d694e Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Mon, 9 Mar 2026 22:02:02 +0100 Subject: [PATCH] make claude fix cashu migration issue --- routstr/core/db.py | 135 +++++++++++++++++++++++++++++++++++++++++---- routstr/wallet.py | 54 ++++++++++++++++-- 2 files changed, 173 insertions(+), 16 deletions(-) diff --git a/routstr/core/db.py b/routstr/core/db.py index ba1d3d88..d12a08a0 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -183,11 +183,112 @@ async def create_session() -> AsyncGenerator[AsyncSession, None]: yield session +def _get_table_columns(cursor: sqlite3.Cursor, table: str) -> set[str]: + """Return the set of column names for a table, or empty set if table doesn't exist.""" + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,) + ) + if not cursor.fetchone(): + return set() + cursor.execute(f"PRAGMA table_info({table})") + return {row[1] for row in cursor.fetchall()} + + +def _detect_schema_version(cursor: sqlite3.Cursor) -> int: + """ + Detect the actual schema version of a cashu wallet database by inspecting + which columns/tables exist, matching them to migration milestones. + + Cashu wallet migrations (m000-m015) add columns non-idempotently. + We detect the highest migration that has already been fully applied. + """ + proofs_cols = _get_table_columns(cursor, "proofs") + keysets_cols = _get_table_columns(cursor, "keysets") + + if not proofs_cols: + return 0 # no proofs table means nothing has run + + # Walk backwards from the highest migration to find the actual version. + # Each check tests for schema artifacts introduced by that migration. + + # m015: mints table + mints_cols = _get_table_columns(cursor, "mints") + if mints_cols: + return 15 + + # m014: bolt11_mint_quotes.key column + mint_quotes_cols = _get_table_columns(cursor, "bolt11_mint_quotes") + if "key" in mint_quotes_cols: + return 14 + + # m013: bolt11_mint_quotes table + if mint_quotes_cols: + return 13 + + # m012: keysets.input_fee_ppk + if "input_fee_ppk" in keysets_cols: + return 12 + + # m011: keysets.unit + if "unit" in keysets_cols: + return 11 + + # m010: proofs.dleq, proofs.mint_id + if "mint_id" in proofs_cols: + return 10 + if "dleq" in proofs_cols: + return 10 + + # m009: keysets.counter, proofs.derivation_path + if "derivation_path" in proofs_cols: + return 9 + + # m008: keysets.public_keys + if "public_keys" in keysets_cols: + return 8 + + # m007: nostr table + nostr_cols = _get_table_columns(cursor, "nostr") + if nostr_cols: + return 7 + + # m006: invoices table + invoices_cols = _get_table_columns(cursor, "invoices") + if invoices_cols: + return 6 + + # m005: proofs.id column, keysets table + if "id" in proofs_cols and keysets_cols: + return 5 + + # m004: p2sh table + p2sh_cols = _get_table_columns(cursor, "p2sh") + if p2sh_cols: + return 4 + + # m003: proofs.send_id + if "send_id" in proofs_cols: + return 3 + + # m002: proofs.reserved + if "reserved" in proofs_cols: + return 2 + + # m001: proofs table exists + return 1 + + def fix_cashu_migrations() -> None: """ Fixes Cashu wallet migrations that are not idempotent. - This specifically addresses the 'duplicate column name: public_keys' error - in the keysets table of Cashu's internal SQLite databases. + + Cashu's migration runner uses ALTER TABLE ADD COLUMN without checking + if the column already exists. If the dbversions table is out of sync + with the actual schema, re-running migrations crashes. + + This function detects the real schema version by inspecting existing + columns/tables and updates dbversions accordingly so cashu skips + already-applied migrations. """ project_root = pathlib.Path(__file__).resolve().parents[2] wallet_dir = project_root / ".wallet" @@ -202,21 +303,35 @@ def fix_cashu_migrations() -> None: conn = sqlite3.connect(db_file) cursor = conn.cursor() - # Check if keysets table exists + detected_version = _detect_schema_version(cursor) + if detected_version == 0: + conn.close() + continue + + # Ensure dbversions table and row exist cursor.execute( - "SELECT name FROM sqlite_master WHERE type='table' AND name='keysets'" + "SELECT name FROM sqlite_master WHERE type='table' AND name='dbversions'" ) if not cursor.fetchone(): conn.close() continue - # Check if public_keys column exists - cursor.execute("PRAGMA table_info(keysets)") - columns = [info[1] for info in cursor.fetchall()] + cursor.execute("SELECT version FROM dbversions WHERE db = 'wallet'") + row = cursor.fetchone() + if row is None: + conn.close() + continue - if "public_keys" not in columns: - logger.info(f"Adding missing public_keys column to {db_file.name}") - cursor.execute("ALTER TABLE keysets ADD COLUMN public_keys TEXT") + recorded_version = row[0] + if recorded_version < detected_version: + logger.info( + f"Fixing {db_file.name}: dbversions says v{recorded_version} " + f"but schema is v{detected_version}" + ) + cursor.execute( + "UPDATE dbversions SET version = ? WHERE db = 'wallet'", + (detected_version,), + ) conn.commit() conn.close() diff --git a/routstr/wallet.py b/routstr/wallet.py index 71ea18ac..4859619b 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -2,6 +2,7 @@ import asyncio import math from typing import TypedDict +import httpx from cashu.core.base import Proof, Token from cashu.wallet.helpers import deserialize_token_from_string from cashu.wallet.wallet import Wallet @@ -13,6 +14,41 @@ from .payment.lnurl import raw_send_to_lnurl logger = get_logger(__name__) +# Cache of mint_url -> list of supported unit strings +_mint_units_cache: dict[str, list[str]] = {} + + +async def get_mint_units(mint_url: str) -> list[str]: + """Query a mint's /v1/keysets endpoint to discover its supported units.""" + if mint_url in _mint_units_cache: + return _mint_units_cache[mint_url] + + try: + url = f"{mint_url.rstrip('/')}/v1/keysets" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get(url) + resp.raise_for_status() + data = resp.json() + + units = list( + { + ks["unit"] + for ks in data.get("keysets", []) + if ks.get("active", False) + } + ) + if not units: + logger.warning(f"No active keysets found for {mint_url}, defaulting to sat") + units = ["sat"] + + _mint_units_cache[mint_url] = units + logger.info(f"Mint {mint_url} supports units: {units}") + return units + except Exception as e: + logger.warning(f"Failed to query keysets for {mint_url}: {e}, defaulting to sat") + _mint_units_cache[mint_url] = ["sat"] + return ["sat"] + async def get_balance(unit: str) -> int: wallet = await get_wallet(settings.primary_mint, unit) @@ -224,8 +260,6 @@ async def fetch_all_balances( - Total user balance in sats - Owner balance in sats (wallet - user) """ - if units is None: - units = ["sat", "msat"] async def fetch_balance( session: db.AsyncSession, mint_url: str, unit: str @@ -261,12 +295,19 @@ async def fetch_all_balances( } return error_result - # Create tasks for all mint/unit combinations + # Discover supported units for each mint, then create tasks + mint_units_map: dict[str, list[str]] = {} + for mint_url in settings.cashu_mints: + if units is not None: + mint_units_map[mint_url] = units + else: + mint_units_map[mint_url] = await get_mint_units(mint_url) + async with db.create_session() as session: tasks = [ fetch_balance(session, mint_url, unit) - for mint_url in settings.cashu_mints - for unit in units + for mint_url, mint_units in mint_units_map.items() + for unit in mint_units ] # Run all tasks concurrently @@ -313,7 +354,8 @@ async def periodic_payout() -> None: try: async with db.create_session() as session: for mint_url in settings.cashu_mints: - for unit in ["sat", "msat"]: + mint_supported_units = await get_mint_units(mint_url) + for unit in mint_supported_units: wallet = await get_wallet(mint_url, unit) proofs = get_proofs_per_mint_and_unit( wallet, mint_url, unit, not_reserved=True