From 65ea28cb8541b1579ec8378732efd0b3c777c81a Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 24 Jul 2026 01:10:57 +0200 Subject: [PATCH] add test --- routstr/wallet.py | 2 + scripts/reconcile_reserved_proofs.py | 235 ----------------------- scripts/retry_minibits_reconciliation.sh | 62 ------ tests/unit/test_wallet.py | 64 ++++++ 4 files changed, 66 insertions(+), 297 deletions(-) delete mode 100755 scripts/reconcile_reserved_proofs.py delete mode 100755 scripts/retry_minibits_reconciliation.sh diff --git a/routstr/wallet.py b/routstr/wallet.py index 5ea4025b..cdfa6914 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -221,6 +221,8 @@ async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int wallet: Wallet = await get_wallet(effective_mint_url, unit) all_proofs = get_proofs_per_mint_and_unit(wallet, effective_mint_url, unit) proofs = [proof for proof in all_proofs if not proof.reserved] + # Fallback must compare the requested amount with liquid proofs only. Counting + # reserved proofs here can suppress fallback even though they cannot be sent. proofs_for_mint = sum(p.amount for p in proofs) reserved_for_mint = sum(p.amount for p in all_proofs if p.reserved) diff --git a/scripts/reconcile_reserved_proofs.py b/scripts/reconcile_reserved_proofs.py deleted file mode 100755 index 8148b145..00000000 --- a/scripts/reconcile_reserved_proofs.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 -"""Reconcile Routstr's reserved Cashu proofs against their mints. - -Safe default is dry-run. --apply mutates wallet.sqlite3 and keys.db. -Run only while no process is using these databases and after verified backups. -""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import os -import sqlite3 -import time -from collections import defaultdict -from pathlib import Path - -import httpx -from cashu.core.base import Proof -from cashu.wallet.helpers import deserialize_token_from_string - - -def proof_from_row(row: sqlite3.Row) -> Proof: - return Proof(amount=row["amount"], C=row["C"], secret=row["secret"], id=row["id"]) - - -async def fetch_states( - client: httpx.AsyncClient, - mint_url: str, - proofs: list[Proof], - batch_size: int, -) -> dict[str, str]: - states: dict[str, str] = {} - endpoint = mint_url.rstrip("/") + "/v1/checkstate" - for offset in range(0, len(proofs), batch_size): - batch = proofs[offset : offset + batch_size] - last_error: Exception | None = None - for attempt in range(4): - try: - response = await client.post(endpoint, json={"Ys": [proof.Y for proof in batch]}) - response.raise_for_status() - for item in response.json().get("states", []): - states[item["Y"]] = item["state"] - last_error = None - break - except (httpx.TimeoutException, httpx.TransportError) as exc: - last_error = exc - await asyncio.sleep(2**attempt) - if last_error is not None: - raise last_error - return states - - -def load_pending_refund_secrets(keys: sqlite3.Connection) -> tuple[set[str], dict[str, set[str]]]: - pending: set[str] = set() - transaction_secrets: dict[str, set[str]] = {} - rows = keys.execute( - """ - SELECT id, token - FROM cashu_transactions - WHERE type = 'out' AND collected = 0 AND swept = 0 - """ - ).fetchall() - for row in rows: - try: - token = deserialize_token_from_string(row["token"]) - except Exception: - continue - secrets = {proof.secret for proof in token.proofs} - transaction_secrets[row["id"]] = secrets - pending.update(secrets) - return pending, transaction_secrets - - -async def run(args: argparse.Namespace) -> int: - root = Path(args.root).resolve() - keys_path = root / "keys.db" - wallet_path = root / ".wallet" / "wallet.sqlite3" - if not keys_path.exists() or not wallet_path.exists(): - raise SystemExit("keys.db or .wallet/wallet.sqlite3 not found") - - keys = sqlite3.connect(keys_path) - wallet = sqlite3.connect(wallet_path) - keys.row_factory = sqlite3.Row - wallet.row_factory = sqlite3.Row - keys.execute("PRAGMA foreign_keys=ON") - wallet.execute("PRAGMA foreign_keys=ON") - - if keys.execute("PRAGMA integrity_check").fetchone()[0] != "ok": - raise SystemExit("keys.db integrity check failed") - if wallet.execute("PRAGMA integrity_check").fetchone()[0] != "ok": - raise SystemExit("wallet.sqlite3 integrity check failed") - - pending_secrets, transaction_secrets = load_pending_refund_secrets(keys) - rows = wallet.execute( - """ - SELECT p.rowid AS proof_rowid, p.*, k.mint_url, k.unit - FROM proofs p - JOIN keysets k ON k.id = p.id - WHERE COALESCE(p.reserved, 0) != 0 - ORDER BY k.mint_url, k.unit, p.time_reserved - """ - ).fetchall() - grouped: dict[tuple[str, str], list[sqlite3.Row]] = defaultdict(list) - for row in rows: - grouped[(row["mint_url"], row["unit"])].append(row) - - mint_reports: dict[str, object] = {} - errors: dict[str, str] = {} - report: dict[str, object] = { - "mode": "apply" if args.apply else "dry-run", - "root": str(root), - "started_at": int(time.time()), - "pending_refund_secrets": len(pending_secrets), - "mints": mint_reports, - "errors": errors, - } - state_by_secret: dict[str, str] = {} - - async with httpx.AsyncClient(timeout=args.timeout) as client: - for (mint_url, unit), mint_rows in grouped.items(): - proofs = [proof_from_row(row) for row in mint_rows] - try: - states = await fetch_states(client, mint_url, proofs, args.batch_size) - except Exception as exc: - errors[f"{mint_url}|{unit}"] = f"{type(exc).__name__}: {exc}" - continue - - summary: dict[str, dict[str, int]] = defaultdict(lambda: {"proofs": 0, "amount": 0}) - actions = {"delete_spent": 0, "release_untracked_unspent": 0, "preserve_pending": 0, "preserve_unknown": 0} - for row, proof in zip(mint_rows, proofs): - state = states.get(proof.Y, "MISSING") - state_by_secret[row["secret"]] = state - summary[state]["proofs"] += 1 - summary[state]["amount"] += row["amount"] - - if state == "SPENT": - actions["delete_spent"] += 1 - if args.apply: - wallet.execute( - """ - INSERT OR IGNORE INTO proofs_used - (amount, C, secret, time_used, id, derivation_path, mint_id, melt_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - row["amount"], row["C"], row["secret"], - row["time_reserved"] or int(time.time()), row["id"], - row["derivation_path"], row["mint_id"], row["melt_id"], - ), - ) - wallet.execute("DELETE FROM proofs WHERE rowid = ?", (row["proof_rowid"],)) - elif state == "UNSPENT" and row["secret"] not in pending_secrets: - actions["release_untracked_unspent"] += 1 - if args.apply: - wallet.execute( - "UPDATE proofs SET reserved = 0, send_id = NULL, time_reserved = NULL WHERE rowid = ?", - (row["proof_rowid"],), - ) - elif state == "UNSPENT": - actions["preserve_pending"] += 1 - else: - actions["preserve_unknown"] += 1 - - mint_reports[f"{mint_url}|{unit}"] = { - "states": dict(summary), - "actions": actions, - } - - # Mark pending outgoing tokens collected only when every proof the mint reported is SPENT. - collected_transactions: list[str] = [] - for transaction_id, secrets in transaction_secrets.items(): - known = [state_by_secret.get(secret) for secret in secrets] - if known and all(state == "SPENT" for state in known): - collected_transactions.append(transaction_id) - if args.apply: - keys.execute( - "UPDATE cashu_transactions SET collected = 1 WHERE id = ? AND collected = 0 AND swept = 0", - (transaction_id,), - ) - report["mark_collected_transactions"] = len(collected_transactions) - - malformed = keys.execute( - "SELECT COUNT(*), COALESCE(SUM(balance), 0) FROM api_keys WHERE refund_mint_url = ?", - ("https://mint.minibits.cash/Bi",), - ).fetchone() - report["canonicalize_minibits_url"] = {"keys": malformed[0], "balance_msat": malformed[1]} - if args.apply: - keys.execute( - "UPDATE api_keys SET refund_mint_url = ? WHERE refund_mint_url = ?", - ("https://mint.minibits.cash/Bitcoin", "https://mint.minibits.cash/Bi"), - ) - - negative_rows = keys.execute( - "SELECT hashed_key, balance FROM api_keys WHERE balance < 0 ORDER BY balance" - ).fetchall() - report["negative_balances"] = { - "keys": len(negative_rows), - "amount_msat": sum(row["balance"] for row in negative_rows), - "key_prefixes": [row["hashed_key"][:12] for row in negative_rows], - } - if args.apply: - keys.execute("UPDATE api_keys SET balance = 0 WHERE balance < 0") - - if args.apply: - wallet.commit() - keys.commit() - else: - wallet.rollback() - keys.rollback() - - report["finished_at"] = int(time.time()) - output = root / f"reconciliation-{'applied' if args.apply else 'dry-run'}-{report['finished_at']}.json" - output.write_text(json.dumps(report, indent=2, sort_keys=True)) - os.chmod(output, 0o600) - print(json.dumps(report, indent=2, sort_keys=True)) - print(f"report={output}") - - wallet.close() - keys.close() - return 0 - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--root", default=".") - parser.add_argument("--apply", action="store_true") - parser.add_argument("--batch-size", type=int, default=300) - parser.add_argument("--timeout", type=float, default=45.0) - return asyncio.run(run(parser.parse_args())) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/retry_minibits_reconciliation.sh b/scripts/retry_minibits_reconciliation.sh deleted file mode 100755 index 8851ea14..00000000 --- a/scripts/retry_minibits_reconciliation.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="${HOME}/proxy" -MARKER="${ROOT}/.minibits-reconciliation-complete" -LOCK="${ROOT}/.minibits-reconciliation.lock" -LOG="${ROOT}/logs/minibits-reconciliation.log" -TAG="ROUTSTR-MINIBITS-RECONCILE" - -cd "$ROOT" -[[ -e "$MARKER" ]] && exit 0 -exec 9>"$LOCK" -flock -n 9 || exit 0 - -printf '%s starting reconciliation retry\n' "$(date -Is)" >>"$LOG" -if ! .venv/bin/python scripts/reconcile_reserved_proofs.py --root . --timeout 90 --apply >>"$LOG" 2>&1; then - printf '%s reconciliation command failed\n' "$(date -Is)" >>"$LOG" - exit 0 -fi - -latest=$(ls -1t reconciliation-applied-*.json 2>/dev/null | head -1 || true) -[[ -n "$latest" ]] || exit 0 -if .venv/bin/python - "$latest" <<'PY' -import json, sys -report = json.load(open(sys.argv[1])) -error_keys = report.get("errors", {}) -raise SystemExit(any(key.startswith("https://mint.minibits.cash/Bitcoin|") for key in error_keys)) -PY -then - if .venv/bin/python - <<'PY' >>"$LOG" 2>&1 -import sqlite3 -keys = sqlite3.connect("keys.db") -wallet = sqlite3.connect(".wallet/wallet.sqlite3") -positive_balances = keys.execute( - "SELECT COALESCE(SUM(CASE WHEN balance > 0 THEN balance ELSE 0 END), 0) FROM api_keys" -).fetchone()[0] -pending_refunds = keys.execute( - "SELECT COALESCE(SUM(CASE WHEN unit = 'sat' THEN amount * 1000 ELSE amount END), 0) " - "FROM cashu_transactions WHERE type = 'out' AND collected = 0 AND swept = 0" -).fetchone()[0] -fees = keys.execute("SELECT COALESCE(SUM(accumulated_msats), 0) FROM routstr_fees").fetchone()[0] -liquid_msats = wallet.execute( - "SELECT COALESCE(SUM(CASE WHEN k.unit = 'msat' THEN p.amount ELSE p.amount * 1000 END), 0) " - "FROM proofs p JOIN keysets k ON k.id = p.id WHERE COALESCE(p.reserved, 0) = 0" -).fetchone()[0] -obligations = positive_balances + pending_refunds + fees -print(f"liquid_msats={liquid_msats} obligations_msats={obligations} surplus_msats={liquid_msats-obligations}") -keys.close(); wallet.close() -raise SystemExit(0 if liquid_msats >= obligations else 1) -PY - then - touch "$MARKER" - chmod 600 "$MARKER" - printf '%s Minibits reconciliation completed and solvency verified; starting Routstr\n' "$(date -Is)" >>"$LOG" - docker compose up -d routstr >>"$LOG" 2>&1 - (crontab -l 2>/dev/null | grep -v "$TAG" || true) | crontab - - else - printf '%s reconciliation completed but liquid assets remain below obligations; node stays stopped\n' "$(date -Is)" >>"$LOG" - fi -else - printf '%s Minibits remains unavailable; retry retained\n' "$(date -Is)" >>"$LOG" -fi diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 3bb36a28..b42e5278 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -15,6 +15,7 @@ from routstr.wallet import ( get_balance, is_mint_connection_error, recieve_token, + send, send_token, ) @@ -147,6 +148,69 @@ async def test_send_token() -> None: assert token == "test_token" +@pytest.mark.asyncio +async def test_send_falls_back_when_preferred_mint_has_only_reserved_balance() -> None: + from routstr.core.settings import settings + + preferred_wallet = Mock(keysets={}, proofs=[]) + preferred_wallet.select_to_send = AsyncMock() + primary_wallet = Mock(keysets={}, proofs=[]) + primary_wallet.select_to_send = AsyncMock() + primary_wallet.serialize_proofs = AsyncMock(return_value="primary-token") + primary_wallet.set_reserved_for_send = AsyncMock() + + preferred_liquid = Mock(amount=500, reserved=False) + preferred_reserved = Mock(amount=600, reserved=True) + primary_liquid = Mock(amount=1000, reserved=False) + primary_wallet.select_to_send.return_value = ([primary_liquid], None) + + async def get_wallet(mint_url: str, unit: str) -> Mock: + assert unit == "sat" + return primary_wallet if mint_url == "http://primary:3338" else preferred_wallet + + def get_proofs(wallet: Mock, mint_url: str, unit: str) -> list[Mock]: + assert unit == "sat" + if wallet is primary_wallet: + assert mint_url == "http://primary:3338" + return [primary_liquid] + assert mint_url == "http://preferred:3338" + return [preferred_liquid, preferred_reserved] + + with ( + patch.object(settings, "primary_mint", "http://primary:3338"), + patch("routstr.wallet.get_wallet", side_effect=get_wallet), + patch("routstr.wallet.get_proofs_per_mint_and_unit", side_effect=get_proofs), + ): + amount, token = await send(1000, "sat", "http://preferred:3338") + + assert (amount, token) == (1000, "primary-token") + preferred_wallet.select_to_send.assert_not_awaited() + primary_wallet.select_to_send.assert_awaited_once_with( + [primary_liquid], 1000, set_reserved=False, include_fees=False + ) + + +@pytest.mark.asyncio +async def test_send_primary_with_only_reserved_proofs_still_raises() -> None: + from routstr.core.settings import settings + + wallet = Mock(keysets={}, proofs=[]) + wallet.select_to_send = AsyncMock(side_effect=RuntimeError("balance too low")) + reserved = Mock(amount=1000, reserved=True) + + with ( + patch.object(settings, "primary_mint", "http://primary:3338"), + patch("routstr.wallet.get_wallet", AsyncMock(return_value=wallet)), + patch("routstr.wallet.get_proofs_per_mint_and_unit", return_value=[reserved]), + pytest.raises(RuntimeError, match="balance too low"), + ): + await send(1000, "sat", "http://primary:3338") + + wallet.select_to_send.assert_awaited_once_with( + [], 1000, set_reserved=False, include_fees=False + ) + + @pytest.mark.asyncio async def test_credit_balance() -> None: token_data = {