This commit is contained in:
9qeklajc
2026-07-24 01:10:57 +02:00
parent 6c762f0c3d
commit 65ea28cb85
4 changed files with 66 additions and 297 deletions
+2
View File
@@ -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) wallet: Wallet = await get_wallet(effective_mint_url, unit)
all_proofs = get_proofs_per_mint_and_unit(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] 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) proofs_for_mint = sum(p.amount for p in proofs)
reserved_for_mint = sum(p.amount for p in all_proofs if p.reserved) reserved_for_mint = sum(p.amount for p in all_proofs if p.reserved)
-235
View File
@@ -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())
-62
View File
@@ -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
+64
View File
@@ -15,6 +15,7 @@ from routstr.wallet import (
get_balance, get_balance,
is_mint_connection_error, is_mint_connection_error,
recieve_token, recieve_token,
send,
send_token, send_token,
) )
@@ -147,6 +148,69 @@ async def test_send_token() -> None:
assert token == "test_token" 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 @pytest.mark.asyncio
async def test_credit_balance() -> None: async def test_credit_balance() -> None:
token_data = { token_data = {