Compare commits

...
Author SHA1 Message Date
9qeklajc b535e88282 make claude fix cashu migration issue 2026-03-09 22:02:02 +01:00
2 changed files with 173 additions and 16 deletions
+125 -10
View File
@@ -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()
+48 -6
View File
@@ -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