From eec227a88f2202533495f8fa33a18fbba9687b4c Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Mon, 7 Sep 2026 22:02:34 +0200 Subject: [PATCH 1/8] added lightning refund --- .../f3a1c7b9e2d4_add_refunds_table.py | 58 ++ routstr/balance.py | 237 +------- routstr/core/db.py | 55 +- routstr/core/main.py | 7 + routstr/core/settings.py | 9 +- routstr/refund.py | 429 ++++++++++++++ routstr/wallet.py | 20 +- tests/integration/conftest.py | 4 +- .../test_error_handling_edge_cases.py | 2 +- tests/integration/test_refund_claims.py | 527 ++++++++++++++++++ tests/integration/test_wallet_refund.py | 6 +- tests/unit/test_balance.py | 171 +++--- tests/unit/test_stale_reservations.py | 22 +- 13 files changed, 1240 insertions(+), 307 deletions(-) create mode 100644 migrations/versions/f3a1c7b9e2d4_add_refunds_table.py create mode 100644 routstr/refund.py create mode 100644 tests/integration/test_refund_claims.py diff --git a/migrations/versions/f3a1c7b9e2d4_add_refunds_table.py b/migrations/versions/f3a1c7b9e2d4_add_refunds_table.py new file mode 100644 index 00000000..e0b3c823 --- /dev/null +++ b/migrations/versions/f3a1c7b9e2d4_add_refunds_table.py @@ -0,0 +1,58 @@ +"""add refunds table + +Revision ID: f3a1c7b9e2d4 +Revises: e5a6b7c8d9f0 +Create Date: 2026-09-07 + +""" + +import sqlalchemy as sa +import sqlmodel +from alembic import op + +revision = "f3a1c7b9e2d4" +down_revision = "e5a6b7c8d9f0" +branch_labels = None +depends_on = None + +OPEN_STATUSES = "status IN ('pending', 'ambiguous')" + + +def upgrade() -> None: + op.create_table( + "refunds", + sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column( + "api_key_hashed_key", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.Column("method", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("destination", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("amount_msats", sa.Integer(), nullable=False), + sa.Column("unit", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("mint_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("status", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("quote_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("token", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("claimed_at", sa.Integer(), nullable=True), + sa.Column("created_at", sa.Integer(), nullable=False), + sa.Column("updated_at", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["api_key_hashed_key"], ["api_keys.hashed_key"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_refunds_api_key_hashed_key", "refunds", ["api_key_hashed_key"]) + op.create_index("ix_refunds_status", "refunds", ["status"]) + op.create_index( + "ux_refunds_open_per_key", + "refunds", + ["api_key_hashed_key"], + unique=True, + sqlite_where=sa.text(OPEN_STATUSES), + postgresql_where=sa.text(OPEN_STATUSES), + ) + + +def downgrade() -> None: + op.drop_index("ux_refunds_open_per_key", table_name="refunds") + op.drop_index("ix_refunds_status", table_name="refunds") + op.drop_index("ix_refunds_api_key_hashed_key", table_name="refunds") + op.drop_table("refunds") diff --git a/routstr/balance.py b/routstr/balance.py index ecd770b5..7c37331a 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -1,13 +1,12 @@ -import asyncio import hashlib -from time import monotonic from typing import Annotated, NoReturn from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel -from sqlmodel import col, select, update +from sqlmodel import col, select +from . import refund from .auth import ( redemption_error_to_http_exception, validate_bearer_key, @@ -19,20 +18,13 @@ from .core.db import ( get_session, release_stale_reservations, ) -from .core.db import ( - store_cashu_transaction_with_retry as store_cashu_transaction, -) from .core.logging import get_logger from .core.settings import settings from .lightning import lightning_router -from .payment.lnurl import MeltOutcomeAmbiguousError from .wallet import ( classify_redemption_error, credit_balance, - is_mint_connection_error, recieve_token, - send_to_lnurl, - send_token, token_mint_url, ) @@ -236,35 +228,6 @@ async def topup_wallet_endpoint( return {"msats": amount_msats} -_REFUND_CACHE_TTL_SECONDS: int = settings.refund_cache_ttl_seconds -_refund_cache_lock: asyncio.Lock = asyncio.Lock() -_refund_cache: dict[str, tuple[float, dict[str, str]]] = {} - - -def _cache_key_for_authorization(authorization: str) -> str: - return hashlib.sha256(authorization.strip().encode()).hexdigest() - - -async def _refund_cache_get(authorization: str) -> dict[str, str] | None: - key = _cache_key_for_authorization(authorization) - async with _refund_cache_lock: - item = _refund_cache.get(key) - if item is None: - return None - expires_at, value = item - if expires_at <= monotonic(): - del _refund_cache[key] - return None - return value - - -async def _refund_cache_set(authorization: str, value: dict[str, str]) -> None: - key = _cache_key_for_authorization(authorization) - expiry = monotonic() + _REFUND_CACHE_TTL_SECONDS - async with _refund_cache_lock: - _refund_cache[key] = (expiry, value) - - async def _lookup_key_no_create( bearer_value: str, session: AsyncSession ) -> ApiKey | None: @@ -307,36 +270,13 @@ async def _get_persisted_api_key_refund( return persisted -async def _restore_balance( - session: AsyncSession, - hashed_key: str, - balance: int, - reserved_balance: int, - mint_url: str, -) -> None: - """Restore balance after a failed refund mint attempt.""" - restore_stmt = ( - update(ApiKey) - .where(col(ApiKey.hashed_key) == hashed_key) - .values( - balance=col(ApiKey.balance) + balance, - reserved_balance=col(ApiKey.reserved_balance) + reserved_balance, - ) - ) - await session.exec(restore_stmt) # type: ignore[call-overload] - await session.commit() - logger.info( - "refund_wallet_endpoint: balance restored after mint failure", - extra={ - "key_hash": hashed_key[:8], - "restored_balance": balance, - "mint_url": mint_url, - }, - ) +class RefundRequest(BaseModel): + lightning_address: str | None = None @router.post("/refund", response_model=None) async def refund_wallet_endpoint( + refund_request: RefundRequest | None = None, authorization: Annotated[str | None, Header()] = None, x_cashu: Annotated[str | None, Header()] = None, session: AsyncSession = Depends(get_session), @@ -412,9 +352,14 @@ async def refund_wallet_endpoint( }, ) + requested = refund_request.lightning_address if refund_request else None + if requested: + await refund.validate_lightning_destination(requested) + destination = requested or key.refund_address + if key.total_balance <= 0: - if cached := await _refund_cache_get(bearer_value): - return cached + if paid := await refund.latest_terminal(session, key): + return refund.describe(paid) if persisted := await _get_persisted_api_key_refund(key, session): return persisted @@ -441,162 +386,21 @@ async def refund_wallet_endpoint( ) remaining_balance_msats: int = key.total_balance - - if key.refund_currency == "sat": - remaining_balance = remaining_balance_msats // 1000 - else: - remaining_balance = remaining_balance_msats + unit = refund.refund_unit(key) + remaining_balance = refund.amount_in_unit(remaining_balance_msats, unit) if remaining_balance_msats > 0 and remaining_balance <= 0: raise HTTPException(status_code=400, detail="Balance too small to refund") elif remaining_balance <= 0: raise HTTPException(status_code=400, detail="No balance to refund") - # Capture values before debit — the session may refresh key after commit - pre_debit_balance = key.balance - pre_debit_reserved = key.reserved_balance - - # --- DEBIT FIRST: atomically zero the balance before minting tokens --- - # This prevents the race where a concurrent topup/spend happens between - # reading the balance and minting the refund token (double-spend). - debit_stmt = ( - update(ApiKey) - .where(col(ApiKey.hashed_key) == key.hashed_key) - .where(col(ApiKey.balance) == pre_debit_balance) - .where(col(ApiKey.reserved_balance) == pre_debit_reserved) - .values(balance=0, reserved_balance=0, reserved_at=None) + claim = await refund.open_claim( + session, + key, + method="lightning" if destination else "cashu", + destination=destination, ) - debit_result = await session.exec(debit_stmt) # type: ignore[call-overload] - await session.commit() - - if debit_result.rowcount == 0: - # Balance changed between read and debit — another request is active - raise HTTPException( - status_code=409, - detail="Balance changed concurrently. Please retry the refund.", - ) - - # The balance is locked at zero, so it is safe to create the refund token. - effective_refund_mint = ( - key.refund_mint_url - if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints - else settings.primary_mint - ) - try: - refund_currency = key.refund_currency or "sat" - if key.refund_address: - await send_to_lnurl( - remaining_balance, - key.refund_currency or "sat", - effective_refund_mint, - key.refund_address, - ) - result = {"recipient": key.refund_address} - else: - token = await send_token( - remaining_balance, refund_currency, effective_refund_mint - ) - effective_refund_mint = token_mint_url(token, effective_refund_mint) - result = {"token": token} - - if key.refund_currency == "sat": - result["sats"] = str(remaining_balance_msats // 1000) - else: - result["msats"] = str(remaining_balance_msats) - - if "token" in result: - logger.info( - "refund_wallet_endpoint: cashu token issued", - extra={ - "path": "/v1/wallet/refund", - "token_length": len(result["token"]), - "amount": remaining_balance, - "currency": key.refund_currency or "sat", - }, - ) - - except MeltOutcomeAmbiguousError as e: - # The melt was dispatched and may still settle. Restoring the balance - # here would let the same debit be paid out twice; keep the debit and - # leave the outcome to reconciliation. - logger.error( - "refund_wallet_endpoint: melt outcome ambiguous; balance withheld " - "pending reconciliation", - extra={ - "error": str(e), - "key_hash": key.hashed_key[:8], - "remaining_balance": remaining_balance, - "refund_currency": key.refund_currency, - "refund_mint_url": key.refund_mint_url, - }, - ) - raise HTTPException( - status_code=502, - detail=( - "Refund was dispatched but its outcome is unconfirmed; the " - "balance is withheld until reconciliation completes" - ), - ) - except HTTPException: - # Minting failed — restore the debited balance - await _restore_balance( - session, - key.hashed_key, - pre_debit_balance, - pre_debit_reserved, - key.refund_mint_url or "", - ) - raise - except Exception as e: - # Minting failed — restore the debited balance - await _restore_balance( - session, - key.hashed_key, - pre_debit_balance, - pre_debit_reserved, - key.refund_mint_url or "", - ) - error_msg = str(e) - logger.error( - "refund_wallet_endpoint: mint/send failed", - extra={ - "error": error_msg, - "error_type": type(e).__name__, - "key_hash": key.hashed_key[:8], - "remaining_balance": remaining_balance, - "refund_currency": key.refund_currency, - "refund_mint_url": key.refund_mint_url, - "has_refund_address": bool(key.refund_address), - }, - ) - if is_mint_connection_error(e): - raise HTTPException(status_code=503, detail="Mint service unavailable") - else: - raise HTTPException(status_code=500, detail="Refund failed") - - await _refund_cache_set(bearer_value, result) - - if "token" in result: - await store_cashu_transaction( - token=result["token"], - amount=remaining_balance, - unit=key.refund_currency or "sat", - mint_url=effective_refund_mint, - typ="out", - collected=False, - source="apikey", - api_key_hashed_key=key.hashed_key, - ) - - logger.info( - "refund_wallet_endpoint: refund successful", - extra={ - "refunded_msats": remaining_balance_msats, - "previous_reserved_balance": key.reserved_balance, - }, - ) - - return result + return await refund.execute(session, claim) @router.get("/history") @@ -640,6 +444,7 @@ async def donate(token: str, ref: str | None = None) -> str: except Exception: return "Invalid token." + @router.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], diff --git a/routstr/core/db.py b/routstr/core/db.py index 4c4d1a62..55693d41 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -12,7 +12,7 @@ from typing import AsyncGenerator from alembic import command from alembic.config import Config from alembic.util.exc import CommandError -from sqlalchemy import Index, UniqueConstraint, case, delete, event, or_ +from sqlalchemy import Index, UniqueConstraint, case, delete, event, or_, text from sqlalchemy.engine import make_url from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.ext.asyncio import AsyncEngine @@ -300,9 +300,7 @@ async def release_stale_reservations( col(ApiKey.reserved_at) < cutoff ) else: - legacy_query = legacy_query.where( - col(ApiKey.hashed_key) == key_hash - ).where( + legacy_query = legacy_query.where(col(ApiKey.hashed_key) == key_hash).where( or_(col(ApiKey.reserved_at).is_(None), col(ApiKey.reserved_at) < cutoff) ) @@ -557,6 +555,55 @@ class CashuTransaction(SQLModel, table=True): # type: ignore ) +REFUND_OPEN_STATUSES = ("pending", "ambiguous") + +_REFUND_OPEN_PREDICATE = "status IN ('pending', 'ambiguous')" + + +class Refund(SQLModel, table=True): # type: ignore + """A durable claim on an API key's balance for a single payout. + + The partial unique index is the double-refund guarantee: a key can have at + most one open claim, so a Cashu refund cannot start while a Lightning + refund is in flight, and neither survives a crash without a record. + """ + + __tablename__ = "refunds" + __table_args__ = ( + Index( + "ux_refunds_open_per_key", + "api_key_hashed_key", + unique=True, + sqlite_where=text(_REFUND_OPEN_PREDICATE), + postgresql_where=text(_REFUND_OPEN_PREDICATE), + ), + ) + + id: str = Field(primary_key=True, default_factory=lambda: uuid.uuid4().hex) + api_key_hashed_key: str = Field(foreign_key="api_keys.hashed_key", index=True) + method: str = Field(description="Payout method: lightning or cashu") + destination: str | None = Field( + default=None, description="Lightning address or LNURL, NULL for cashu" + ) + amount_msats: int = Field(description="Balance debited when the claim opened") + unit: str = Field(description="Mint unit the payout is denominated in") + mint_url: str = Field(description="Mint the payout is drawn from") + status: str = Field( + default="pending", + index=True, + description="pending, paid, failed, ambiguous, or stuck", + ) + quote_id: str | None = Field( + default=None, description="Melt quote id, for reconciling an ambiguous payout" + ) + token: str | None = Field(default=None, description="Issued cashu token") + claimed_at: int | None = Field( + default=None, description="Reconciler lease timestamp" + ) + created_at: int = Field(default_factory=lambda: int(time.time())) + updated_at: int = Field(default_factory=lambda: int(time.time())) + + async def store_cashu_transaction( token: str, amount: int, diff --git a/routstr/core/main.py b/routstr/core/main.py index fdb3b913..938e2144 100644 --- a/routstr/core/main.py +++ b/routstr/core/main.py @@ -28,6 +28,7 @@ from ..nostr.discovery import providers_router from ..payment.models import models_router, update_sats_pricing from ..payment.price import update_prices_periodically from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically +from ..refund import periodic_refund_reconcile from ..upstream.auto_topup import periodic_auto_topup from ..upstream.deepseek_v4_pricing_shim import register_deepseek_v4_pricing from ..upstream.litellm_routing import configure_litellm @@ -68,6 +69,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: dead_key_prune_task = None auto_topup_task = None refund_sweep_task = None + refund_reconcile_task = None routstr_fee_task = None invoice_watcher_task = None @@ -158,6 +160,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: dead_key_prune_task = asyncio.create_task(periodic_dead_key_prune()) auto_topup_task = asyncio.create_task(periodic_auto_topup()) refund_sweep_task = asyncio.create_task(periodic_refund_sweep()) + refund_reconcile_task = asyncio.create_task(periodic_refund_reconcile()) routstr_fee_task = asyncio.create_task(periodic_routstr_fee_payout()) invoice_watcher_task = asyncio.create_task(periodic_invoice_watcher()) @@ -201,6 +204,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: auto_topup_task.cancel() if refund_sweep_task is not None: refund_sweep_task.cancel() + if refund_reconcile_task is not None: + refund_reconcile_task.cancel() if routstr_fee_task is not None: routstr_fee_task.cancel() if invoice_watcher_task is not None: @@ -234,6 +239,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]: tasks_to_wait.append(auto_topup_task) if refund_sweep_task is not None: tasks_to_wait.append(refund_sweep_task) + if refund_reconcile_task is not None: + tasks_to_wait.append(refund_reconcile_task) if routstr_fee_task is not None: tasks_to_wait.append(routstr_fee_task) if invoice_watcher_task is not None: diff --git a/routstr/core/settings.py b/routstr/core/settings.py index 971cec19..2104aeb3 100644 --- a/routstr/core/settings.py +++ b/routstr/core/settings.py @@ -124,7 +124,6 @@ class Settings(BaseSettings): enable_model_paths_refresh: bool = Field( default=True, env="ENABLE_MODEL_PATHS_REFRESH" ) - refund_cache_ttl_seconds: int = Field(default=3600, env="REFUND_CACHE_TTL_SECONDS") # Uncollected refund tokens are swept after ~6 months (180 days). # Fixed for now: not configurable via env or the settings DB/admin API # (empty env list disables env binding; see FIXED_FIELDS). @@ -132,6 +131,14 @@ class Settings(BaseSettings): refund_sweep_claim_timeout_seconds: int = Field( default=900, gt=0, env="REFUND_SWEEP_CLAIM_TIMEOUT_SECONDS" ) + # How long an open refund claim may sit before the reconciler asks the mint + # what became of it. Doubles as the reconciler's per-row lease. + refund_claim_timeout_seconds: int = Field( + default=300, gt=0, env="REFUND_CLAIM_TIMEOUT_SECONDS" + ) + refund_reconcile_interval_seconds: int = Field( + default=60, gt=0, env="REFUND_RECONCILE_INTERVAL_SECONDS" + ) # Database connection-pool controls (advanced). Capacity defaults provide # headroom for Routstr's concurrent request and background-payment workload. diff --git a/routstr/refund.py b/routstr/refund.py new file mode 100644 index 00000000..75c2a0db --- /dev/null +++ b/routstr/refund.py @@ -0,0 +1,429 @@ +"""Durable, mutually exclusive refund claims for API key balances. + +A claim debits the balance and records the payout in one transaction, so a key +can never have two payouts in flight and no crash can leave a debited balance +without a record of why. +""" + +import asyncio +import time + +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError +from sqlmodel import col, select, update + +from .core.db import ( + REFUND_OPEN_STATUSES, + ApiKey, + AsyncSession, + Refund, + create_session, +) +from .core.db import ( + store_cashu_transaction_with_retry as store_cashu_transaction, +) +from .core.logging import get_logger +from .core.settings import settings +from .payment.lnurl import LNURLError, MeltOutcomeAmbiguousError, get_lnurl_data +from .wallet import ( + check_bolt11_payment_status, + is_mint_connection_error, + send_to_lnurl, + send_token, + token_mint_url, +) + +logger = get_logger(__name__) + +RECONCILE_BATCH_LIMIT = 100 + + +def refund_unit(key: ApiKey) -> str: + return key.refund_currency or "sat" + + +def amount_in_unit(amount_msats: int, unit: str) -> int: + return amount_msats // 1000 if unit == "sat" else amount_msats + + +def refund_mint(key: ApiKey) -> str: + """Persisted mint preferences must not outlive the trusted-mint config.""" + if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints: + return key.refund_mint_url + return settings.primary_mint + + +async def validate_lightning_destination(destination: str) -> None: + """Resolve the destination before claiming, so a bad address never debits.""" + try: + await get_lnurl_data(destination) + except LNURLError as e: + raise HTTPException( + status_code=400, detail=f"Invalid lightning destination: {e}" + ) + + +async def open_claim( + session: AsyncSession, + key: ApiKey, + *, + method: str, + destination: str | None, +) -> Refund: + """Debit the balance to zero and record the claim in one transaction. + + The claim starts leased (``claimed_at``) to the request that opened it, so + the reconciler leaves it alone until ``refund_claim_timeout_seconds`` have + passed; a payout still in flight is never released underneath itself. + """ + unit = refund_unit(key) + refund = Refund( + api_key_hashed_key=key.hashed_key, + method=method, + destination=destination, + amount_msats=key.total_balance, + unit=unit, + mint_url=refund_mint(key), + claimed_at=int(time.time()), + ) + debit = ( + update(ApiKey) + .where(col(ApiKey.hashed_key) == key.hashed_key) + .where(col(ApiKey.balance) == key.balance) + .where(col(ApiKey.reserved_balance) == key.reserved_balance) + .values(balance=0, reserved_balance=0, reserved_at=None) + ) + try: + debited = await session.exec(debit) # type: ignore[call-overload] + if debited.rowcount == 0: + await session.rollback() + raise HTTPException( + status_code=409, + detail="Balance changed concurrently. Please retry the refund.", + ) + session.add(refund) + await session.commit() + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=409, + detail={ + "error": { + "message": "A refund for this key is already in progress.", + "type": "invalid_request_error", + "code": "refund_in_progress", + } + }, + ) + return refund + + +async def _close(session: AsyncSession, refund: Refund, **values: object) -> bool: + result = await session.exec( # type: ignore[call-overload] + update(Refund) + .where(col(Refund.id) == refund.id) + .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .values(claimed_at=None, updated_at=int(time.time()), **values) + ) + return bool(result.rowcount) + + +async def record_quote(refund: Refund, quote_id: str) -> None: + """Persist the melt quote before the melt is dispatched. + + Once the quote is on disk the reconciler can ask the mint what became of + it, so a crash after this point can never be mistaken for "never sent". + Raises if the claim is no longer open, which aborts the payout. + """ + async with create_session() as session: + result = await session.exec( # type: ignore[call-overload] + update(Refund) + .where(col(Refund.id) == refund.id) + .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .values(quote_id=quote_id, updated_at=int(time.time())) + ) + await session.commit() + if not result.rowcount: + raise LNURLError("Refund claim closed before the melt was dispatched") + refund.quote_id = quote_id + + +async def settle( + session: AsyncSession, + refund: Refund, + *, + quote_id: str | None = None, + token: str | None = None, + mint_url: str | None = None, +) -> bool: + values: dict[str, object] = {"status": "paid"} + if quote_id is not None: + values["quote_id"] = quote_id + if token is not None: + values["token"] = token + if mint_url is not None: + values["mint_url"] = mint_url + settled = await _close(session, refund, **values) + await session.commit() + if not settled: + logger.warning( + "refund paid but its claim was already closed", + extra={"refund_id": refund.id, "prior_status": refund.status}, + ) + return settled + + +async def release(session: AsyncSession, refund: Refund) -> bool: + """Close the claim and return the debited balance in the same transaction.""" + if not await _close(session, refund, status="failed"): + # Nothing changed; commit rather than roll back so the session stays + # usable (an async rollback after an ORM-enabled UPDATE expires the + # identity map and later loads fail outside the greenlet). + await session.commit() + return False + await session.exec( # type: ignore[call-overload] + update(ApiKey) + .where(col(ApiKey.hashed_key) == refund.api_key_hashed_key) + .values(balance=col(ApiKey.balance) + refund.amount_msats) + ) + await session.commit() + logger.info( + "refund released; balance restored", + extra={ + "refund_id": refund.id, + "key_hash": refund.api_key_hashed_key[:8], + "restored_msats": refund.amount_msats, + }, + ) + return True + + +async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> None: + """Keep the debit and the claim open until reconciliation resolves it.""" + await _close(session, refund, status="ambiguous", quote_id=quote_id) + await session.commit() + + +async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None: + """Most recent paid Lightning refund, for idempotent re-requests. + + Cashu payouts are deliberately excluded: their token lives in + ``cashu_transactions`` whose ``collected``/``swept`` flags decide whether + it may still be handed out. + """ + result = await session.exec( + select(Refund) + .where(Refund.api_key_hashed_key == key.hashed_key) + .where(Refund.status == "paid") + .where(Refund.method == "lightning") + .order_by(col(Refund.created_at).desc()) + ) + return result.first() + + +def describe(refund: Refund) -> dict[str, str]: + body: dict[str, str] = {"refund_id": refund.id, "status": refund.status} + if refund.token: + body["token"] = refund.token + if refund.destination: + body["recipient"] = refund.destination + if refund.unit == "sat": + body["sats"] = str(refund.amount_msats // 1000) + else: + body["msats"] = str(refund.amount_msats) + return body + + +async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: + """Pay out an open claim, closing it on every outcome the mint makes known.""" + amount = amount_in_unit(refund.amount_msats, refund.unit) + quote_id: str | None = None + + async def capture_quote(quote: str) -> None: + nonlocal quote_id + quote_id = quote + await record_quote(refund, quote) + + try: + if refund.method == "lightning": + await send_to_lnurl( + amount, + refund.unit, + refund.mint_url, + str(refund.destination), + on_melt_quote=capture_quote, + ) + await settle(session, refund, quote_id=quote_id) + else: + token = await send_token(amount, refund.unit, refund.mint_url) + mint_url = token_mint_url(token, refund.mint_url) + await settle(session, refund, token=token, mint_url=mint_url) + await store_cashu_transaction( + token=token, + amount=amount, + unit=refund.unit, + mint_url=mint_url, + typ="out", + collected=False, + source="apikey", + api_key_hashed_key=refund.api_key_hashed_key, + ) + refund.token = token + refund.mint_url = mint_url + except MeltOutcomeAmbiguousError as e: + await hold(session, refund, quote_id) + logger.error( + "refund outcome ambiguous; balance withheld pending reconciliation", + extra={ + "refund_id": refund.id, + "error": str(e), + "key_hash": refund.api_key_hashed_key[:8], + "quote_id": quote_id, + }, + ) + raise HTTPException( + status_code=502, + detail=( + "Refund was dispatched but its outcome is unconfirmed; the " + "balance is withheld until reconciliation completes" + ), + ) + except HTTPException: + await release(session, refund) + raise + except Exception as e: + await release(session, refund) + logger.error( + "refund payout failed", + extra={ + "refund_id": refund.id, + "error": str(e), + "error_type": type(e).__name__, + "key_hash": refund.api_key_hashed_key[:8], + "method": refund.method, + "mint_url": refund.mint_url, + }, + ) + if is_mint_connection_error(e): + raise HTTPException(status_code=503, detail="Mint service unavailable") + raise HTTPException(status_code=500, detail="Refund failed") + + refund.status = "paid" + refund.claimed_at = None + logger.info( + "refund paid", + extra={ + "refund_id": refund.id, + "method": refund.method, + "amount_msats": refund.amount_msats, + "key_hash": refund.api_key_hashed_key[:8], + }, + ) + return describe(refund) + + +async def _lease(refund_id: str, now: int, lease_cutoff: int) -> bool: + async with create_session() as session: + result = await session.exec( # type: ignore[call-overload] + update(Refund) + .where(col(Refund.id) == refund_id) + .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .where( + col(Refund.claimed_at).is_(None) + | (col(Refund.claimed_at) < lease_cutoff) + ) + .values(claimed_at=now) + ) + await session.commit() + return bool(result.rowcount) + + +async def _reconcile(refund: Refund) -> None: + if refund.method != "lightning": + # A cashu payout leaves no quote to query: the token either reached the + # client or was lost with the process. Close the claim as ``stuck`` so + # the balance stays withheld and the operator is told exactly once. + async with create_session() as session: + if await _close(session, refund, status="stuck"): + await session.commit() + logger.critical( + "cashu refund stuck; balance withheld, manual reconciliation required", + extra={ + "refund_id": refund.id, + "key_hash": refund.api_key_hashed_key[:8], + "amount_msats": refund.amount_msats, + }, + ) + return + + if refund.quote_id is None: + # No melt quote exists, so the mint was never asked to pay. + async with create_session() as session: + await release(session, refund) + return + + status = await check_bolt11_payment_status( + refund.mint_url, refund.unit, refund.quote_id + ) + if status == "paid": + async with create_session() as session: + await settle(session, refund) + elif status == "unpaid": + async with create_session() as session: + await release(session, refund) + else: + logger.warning( + "refund still unresolved at the mint", + extra={"refund_id": refund.id, "melt_status": status}, + ) + + +async def reconcile_once() -> None: + """Resolve open claims whose lease has lapsed. + + A fresh claim is leased to the request paying it out; ``hold`` drops that + lease so an ambiguous outcome is queried on the next pass. + """ + now = int(time.time()) + lease_cutoff = now - settings.refund_claim_timeout_seconds + async with create_session() as session: + result = await session.exec( + select(Refund) + .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .where( + col(Refund.claimed_at).is_(None) + | (col(Refund.claimed_at) < lease_cutoff) + ) + .order_by(col(Refund.created_at)) + .limit(RECONCILE_BATCH_LIMIT) + ) + stale = list(result.all()) + + for refund in stale: + if not await _lease(refund.id, now, lease_cutoff): + continue + try: + await _reconcile(refund) + except Exception as e: + logger.error( + "refund reconciliation failed", + extra={ + "refund_id": refund.id, + "error": str(e), + "error_type": type(e).__name__, + }, + ) + + +async def periodic_refund_reconcile() -> None: + while True: + await asyncio.sleep(settings.refund_reconcile_interval_seconds) + try: + await reconcile_once() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error( + "refund reconcile loop error", + extra={"error": str(e), "error_type": type(e).__name__}, + ) diff --git a/routstr/wallet.py b/routstr/wallet.py index 910d77e3..023cb404 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -8,7 +8,7 @@ from contextlib import asynccontextmanager from contextvars import ContextVar from dataclasses import dataclass from pathlib import Path -from typing import AsyncGenerator, TypedDict +from typing import AsyncGenerator, Awaitable, Callable, TypedDict from urllib.parse import urlsplit, urlunsplit import httpx @@ -1995,7 +1995,14 @@ async def periodic_routstr_fee_payout() -> None: ) -async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int: +async def send_to_lnurl( + amount: int, + unit: str, + mint: str, + address: str, + *, + on_melt_quote: Callable[[str], Awaitable[None]] | None = None, +) -> int: async with wallet_operation_guard(): mint = await find_trusted_mint_with_funds(amount, unit, mint, force_reload=True) wallet = await get_wallet(mint, unit) @@ -2003,7 +2010,14 @@ async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int: # Hand over unreserved proofs: raw_send_to_lnurl reserves only once the # destination, the invoice amount and the melt quote have all been # accepted, so a rejected refund cannot strand locked proofs. - return await raw_send_to_lnurl(wallet, available, address, unit, amount=amount) + return await raw_send_to_lnurl( + wallet, + available, + address, + unit, + amount=amount, + on_melt_quote=on_melt_quote, + ) # class Payment: diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index f369182c..f8babb17 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -554,8 +554,8 @@ async def integration_app( patch("routstr.wallet.send_to_lnurl", testmint_wallet.send_to_lnurl), patch("routstr.wallet.recieve_token", testmint_wallet.redeem_token), patch("routstr.wallet.get_balance", testmint_wallet.get_balance), - patch("routstr.balance.send_token", testmint_wallet.send_token), - patch("routstr.balance.send_to_lnurl", testmint_wallet.send_to_lnurl), + patch("routstr.refund.send_token", testmint_wallet.send_token), + patch("routstr.refund.send_to_lnurl", testmint_wallet.send_to_lnurl), patch("websockets.connect") as mock_websockets, patch("routstr.payment.price.btc_usd_price", return_value=50000.0), patch("routstr.payment.price.sats_usd_price", return_value=0.0005), diff --git a/tests/integration/test_error_handling_edge_cases.py b/tests/integration/test_error_handling_edge_cases.py index 4587fd69..cfd8c221 100644 --- a/tests/integration/test_error_handling_edge_cases.py +++ b/tests/integration/test_error_handling_edge_cases.py @@ -31,7 +31,7 @@ class TestNetworkFailureScenarios: AsyncMock(side_effect=ConnectError("Mint service unavailable")), ), patch( - "routstr.balance.send_token", + "routstr.refund.send_token", AsyncMock(side_effect=ConnectError("Mint service unavailable")), ), ): diff --git a/tests/integration/test_refund_claims.py b/tests/integration/test_refund_claims.py new file mode 100644 index 00000000..0ba4d7fa --- /dev/null +++ b/tests/integration/test_refund_claims.py @@ -0,0 +1,527 @@ +"""Refund claim lifecycle against a real SQLite database. + +Covers the guarantees the ``refunds`` table exists to provide: one open claim +per key, a persisted melt quote before the melt is dispatched, and a +reconciler that never restores a balance whose payout may have settled. +""" + +import time +from typing import Any, Awaitable, Callable +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException +from sqlmodel import select + +from routstr import refund +from routstr.balance import RefundRequest, refund_wallet_endpoint +from routstr.core.db import ApiKey, AsyncSession, Refund +from routstr.payment.lnurl import LNURLError, MeltOutcomeAmbiguousError + +KEY_HASH = "refundclaimkey" +ADDRESS = "user@ln.example.com" +BALANCE_MSATS = 5_000_000 + + +async def _seed_key( + session: AsyncSession, *, balance: int = BALANCE_MSATS, address: str | None = None +) -> ApiKey: + key = ApiKey(hashed_key=KEY_HASH) + key.balance = balance + key.reserved_balance = 0 + key.refund_currency = "sat" + key.refund_address = address + key.total_spent = 0 + key.total_requests = 0 + session.add(key) + await session.commit() + await session.refresh(key) + return key + + +async def _load_key(session: AsyncSession) -> ApiKey: + key = await session.get(ApiKey, KEY_HASH) + assert key is not None + await session.refresh(key) + return key + + +async def _load_refund(session: AsyncSession, refund_id: str) -> Refund: + row = await session.get(Refund, refund_id) + assert row is not None + await session.refresh(row) + return row + + +async def _age_claim(session: AsyncSession, refund_id: str, seconds: int) -> None: + row = await _load_refund(session, refund_id) + row.claimed_at = int(time.time()) - seconds + row.created_at = int(time.time()) - seconds + session.add(row) + await session.commit() + + +@pytest.fixture +def short_timeout() -> Any: + with patch.object(refund.settings, "refund_claim_timeout_seconds", 300): + yield + + +# --- exclusivity ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_second_claim_on_open_key_is_rejected( + integration_session: AsyncSession, +) -> None: + key = await _seed_key(integration_session) + first = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + key = await _load_key(integration_session) + assert key.balance == 0 + + with pytest.raises(HTTPException) as exc_info: + await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + detail = exc_info.value.detail + assert exc_info.value.status_code == 409 + assert isinstance(detail, dict) + assert detail["error"]["code"] == "refund_in_progress" + + rows = (await integration_session.exec(select(Refund))).all() + assert [row.id for row in rows] == [first.id] + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_claim_from_second_session_hits_the_index( + integration_engine: Any, integration_session: AsyncSession +) -> None: + key = await _seed_key(integration_session) + await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + + async with AsyncSession(integration_engine, expire_on_commit=False) as other: + other_key = await _load_key(other) + with pytest.raises(HTTPException) as exc_info: + await refund.open_claim( + other, other_key, method="lightning", destination=ADDRESS + ) + assert exc_info.value.status_code == 409 + + +@pytest.mark.asyncio +async def test_claim_rejects_stale_balance_snapshot( + integration_session: AsyncSession, +) -> None: + key = await _seed_key(integration_session) + integration_session.expunge(key) # a stale, detached snapshot + key.balance = BALANCE_MSATS + 1 + with pytest.raises(HTTPException) as exc_info: + await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + assert exc_info.value.status_code == 409 + assert (await integration_session.exec(select(Refund))).all() == [] + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +@pytest.mark.asyncio +async def test_retry_after_failed_claim_pays_once( + integration_session: AsyncSession, +) -> None: + key = await _seed_key(integration_session) + first = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + assert await refund.release(integration_session, first) + key = await _load_key(integration_session) + assert key.balance == BALANCE_MSATS + + second = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + assert second.id != first.id + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_release_after_settle_is_a_noop( + integration_session: AsyncSession, +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + assert await refund.settle(integration_session, claim, quote_id="q1") + assert not await refund.release(integration_session, claim) + assert (await _load_key(integration_session)).balance == 0 + row = await _load_refund(integration_session, claim.id) + assert row.status == "paid" + assert row.claimed_at is None + + +# --- execute --------------------------------------------------------------- + + +def _lnurl_stub( + outcome: BaseException | None = None, +) -> Callable[..., Awaitable[int]]: + async def send( + amount: int, + unit: str, + mint: str, + address: str, + *, + on_melt_quote: Callable[[str], Awaitable[None]] | None = None, + ) -> int: + if on_melt_quote is not None: + await on_melt_quote("quote-123") + if outcome is not None: + raise outcome + return amount + + return send + + +@pytest.mark.asyncio +async def test_execute_persists_quote_before_melt_and_settles( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + seen: list[str | None] = [] + + async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int: + await on_melt_quote("quote-123") + row = await _load_refund(integration_session, claim.id) + seen.append(row.quote_id) + return 5000 + + with patch("routstr.refund.send_to_lnurl", send): + body = await refund.execute(integration_session, claim) + + assert seen == ["quote-123"], "quote must be on disk before the melt runs" + assert body["status"] == "paid" + assert body["recipient"] == ADDRESS + assert body["sats"] == "5000" + row = await _load_refund(integration_session, claim.id) + assert (row.status, row.quote_id, row.claimed_at) == ("paid", "quote-123", None) + + +@pytest.mark.asyncio +async def test_execute_ambiguous_holds_claim_with_quote( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + with patch( + "routstr.refund.send_to_lnurl", _lnurl_stub(MeltOutcomeAmbiguousError("?")) + ): + with pytest.raises(HTTPException) as exc_info: + await refund.execute(integration_session, claim) + + assert exc_info.value.status_code == 502 + row = await _load_refund(integration_session, claim.id) + assert (row.status, row.quote_id, row.claimed_at) == ( + "ambiguous", + "quote-123", + None, + ) + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_execute_clean_failure_restores_balance( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + with patch("routstr.refund.send_to_lnurl", _lnurl_stub(LNURLError("limits"))): + with pytest.raises(HTTPException) as exc_info: + await refund.execute(integration_session, claim) + + assert exc_info.value.status_code == 500 + row = await _load_refund(integration_session, claim.id) + assert row.status == "failed" + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +@pytest.mark.asyncio +async def test_execute_aborts_melt_when_claim_was_released( + integration_engine: Any, integration_session: AsyncSession, patched_db_engine: None +) -> None: + """A reconciler that released the claim first must stop the melt.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + melted = False + + async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int: + nonlocal melted + async with AsyncSession(integration_engine, expire_on_commit=False) as other: + await refund.release(other, await _load_refund(other, claim.id)) + await on_melt_quote("quote-123") + melted = True + return 5000 + + with patch("routstr.refund.send_to_lnurl", send): + with pytest.raises(HTTPException): + await refund.execute(integration_session, claim) + + assert melted is False + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +# --- reconciler ------------------------------------------------------------ + + +async def _open_ambiguous(session: AsyncSession, quote_id: str | None) -> Refund: + key = await _seed_key(session) + claim = await refund.open_claim( + session, key, method="lightning", destination=ADDRESS + ) + await refund.hold(session, claim, quote_id) + return claim + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("mint_status", "expected_status", "expected_balance"), + [ + ("paid", "paid", 0), + ("unpaid", "failed", BALANCE_MSATS), + ("pending", "ambiguous", 0), + ("unknown", "ambiguous", 0), + ], +) +async def test_reconcile_ambiguous_claims( + integration_session: AsyncSession, + patched_db_engine: None, + short_timeout: None, + mint_status: str, + expected_status: str, + expected_balance: int, +) -> None: + claim = await _open_ambiguous(integration_session, "quote-123") + with patch( + "routstr.refund.check_bolt11_payment_status", + AsyncMock(return_value=mint_status), + ) as check: + await refund.reconcile_once() + + check.assert_awaited_once_with(claim.mint_url, "sat", "quote-123") + row = await _load_refund(integration_session, claim.id) + assert row.status == expected_status + assert (await _load_key(integration_session)).balance == expected_balance + + +@pytest.mark.asyncio +async def test_reconcile_credits_balance_once_across_passes( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + await _open_ambiguous(integration_session, "quote-123") + with patch( + "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="unpaid") + ): + await refund.reconcile_once() + await refund.reconcile_once() + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +@pytest.mark.asyncio +async def test_reconcile_leaves_fresh_pending_claim_alone( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + with patch("routstr.refund.check_bolt11_payment_status", AsyncMock()) as check: + await refund.reconcile_once() + check.assert_not_awaited() + row = await _load_refund(integration_session, claim.id) + assert row.status == "pending" + assert row.claimed_at is not None + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_reconcile_releases_expired_claim_without_quote( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + """No quote on disk means the mint was never asked to pay.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + await _age_claim(integration_session, claim.id, 600) + with patch("routstr.refund.check_bolt11_payment_status", AsyncMock()) as check: + await refund.reconcile_once() + check.assert_not_awaited() + assert (await _load_refund(integration_session, claim.id)).status == "failed" + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +@pytest.mark.asyncio +async def test_reconcile_queries_mint_for_crashed_claim_with_quote( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + """Crash after the quote was persisted: the mint decides, not the timeout.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + await refund.record_quote(claim, "quote-crash") + await _age_claim(integration_session, claim.id, 600) + with patch( + "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="paid") + ) as check: + await refund.reconcile_once() + check.assert_awaited_once_with(claim.mint_url, "sat", "quote-crash") + assert (await _load_refund(integration_session, claim.id)).status == "paid" + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_reconcile_marks_expired_cashu_claim_stuck( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + await _age_claim(integration_session, claim.id, 600) + with patch("routstr.refund.logger") as log: + await refund.reconcile_once() + await refund.reconcile_once() + assert log.critical.call_count == 1 + row = await _load_refund(integration_session, claim.id) + assert (row.status, row.claimed_at) == ("stuck", None) + assert (await _load_key(integration_session)).balance == 0 + # A stuck claim is closed, so the key is not permanently locked out. + key = await _load_key(integration_session) + key.balance = 1000 + integration_session.add(key) + await integration_session.commit() + await refund.open_claim(integration_session, key, method="cashu", destination=None) + + +@pytest.mark.asyncio +async def test_reconcile_survives_one_failing_row( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + claim = await _open_ambiguous(integration_session, "quote-123") + with patch( + "routstr.refund.check_bolt11_payment_status", + AsyncMock(side_effect=RuntimeError("mint down")), + ): + await refund.reconcile_once() + row = await _load_refund(integration_session, claim.id) + assert row.status == "ambiguous" + assert row.claimed_at is not None, "lease is kept until the next pass" + + +# --- endpoint -------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_endpoint_uses_requested_address_over_persisted( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + await _seed_key(integration_session, address="stored@ln.example.com") + send = AsyncMock(side_effect=_lnurl_stub()) + with ( + patch("routstr.refund.get_lnurl_data", AsyncMock()) as resolve, + patch("routstr.refund.send_to_lnurl", send), + ): + body = await refund_wallet_endpoint( + refund_request=RefundRequest(lightning_address=ADDRESS), + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + resolve.assert_awaited_once_with(ADDRESS) + assert isinstance(body, dict) + assert body["recipient"] == ADDRESS + assert send.await_args is not None + assert send.await_args.args[3] == ADDRESS + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_endpoint_rejects_bad_address_without_debit( + integration_session: AsyncSession, +) -> None: + await _seed_key(integration_session) + with patch( + "routstr.refund.get_lnurl_data", AsyncMock(side_effect=LNURLError("nope")) + ): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + refund_request=RefundRequest(lightning_address="bad@example"), + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert exc_info.value.status_code == 400 + assert (await integration_session.exec(select(Refund))).all() == [] + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +@pytest.mark.asyncio +async def test_endpoint_replays_paid_lightning_refund_on_empty_balance( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + await _seed_key(integration_session, address=ADDRESS) + with patch("routstr.refund.send_to_lnurl", _lnurl_stub()): + first = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + second = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(first, dict) and isinstance(second, dict) + assert second["refund_id"] == first["refund_id"] + assert second["status"] == "paid" + + +@pytest.mark.asyncio +async def test_endpoint_refund_while_ambiguous_returns_409( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + await _open_ambiguous(integration_session, "quote-123") + key = await _load_key(integration_session) + key.balance = 2_000_000 # topped up while the melt is unresolved + integration_session.add(key) + await integration_session.commit() + + send = AsyncMock() + with ( + patch("routstr.refund.get_lnurl_data", AsyncMock()), + patch("routstr.refund.send_to_lnurl", send), + ): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + refund_request=RefundRequest(lightning_address=ADDRESS), + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert exc_info.value.status_code == 409 + send.assert_not_awaited() + assert (await _load_key(integration_session)).balance == 2_000_000 diff --git a/tests/integration/test_wallet_refund.py b/tests/integration/test_wallet_refund.py index 1cbd6b4b..83f4ec7f 100644 --- a/tests/integration/test_wallet_refund.py +++ b/tests/integration/test_wallet_refund.py @@ -204,7 +204,7 @@ async def test_refund_with_lightning_address( await db_snapshot.capture() # Mock send_to_lnurl function directly - with patch("routstr.balance.send_to_lnurl") as mock_send_to_lnurl: + with patch("routstr.refund.send_to_lnurl") as mock_send_to_lnurl: mock_send_to_lnurl.return_value = { "amount_sent": balance, "unit": "msat", @@ -508,7 +508,7 @@ async def test_mint_unavailability_handling( # Make the send_token method raise a typed mint connection exception. with patch( - "routstr.balance.send_token", + "routstr.refund.send_token", side_effect=MintConnectionError(raw_error), ): response = await authenticated_client.post("/v1/wallet/refund") @@ -622,7 +622,7 @@ async def test_refund_with_expired_key( integration_client.headers["Authorization"] = f"Bearer {api_key}" # Mock the refund to LN address - with patch("routstr.balance.send_to_lnurl") as mock_send_to_lnurl: + with patch("routstr.refund.send_to_lnurl") as mock_send_to_lnurl: mock_send_to_lnurl.return_value = 500 response = await integration_client.post("/v1/wallet/refund") diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index 75045168..cc970d40 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -20,7 +20,9 @@ def _make_cashu_tx( swept: bool = False, collected: bool = False, ) -> CashuTransaction: - tx = CashuTransaction(token=token, amount=amount, unit=unit, type=type, request_id=request_id) + tx = CashuTransaction( + token=token, amount=amount, unit=unit, type=type, request_id=request_id + ) tx.swept = swept tx.collected = collected return tx @@ -41,13 +43,22 @@ def _update_result(rowcount: int) -> MagicMock: @pytest.mark.asyncio async def test_refund_x_cashu_returns_token() -> None: x_cashu_token = "cashuAtest_token_value" - in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-abc") - out_tx = _make_cashu_tx(token="cashuArefund_token", amount=1000, unit="msat", type="out", request_id="req-abc") + in_tx = _make_cashu_tx( + token=x_cashu_token, amount=0, unit="msat", type="in", request_id="req-abc" + ) + out_tx = _make_cashu_tx( + token="cashuArefund_token", + amount=1000, + unit="msat", + type="out", + request_id="req-abc", + ) session = MagicMock() session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() result = await refund_wallet_endpoint( authorization="Bearer sk-somekey", @@ -66,13 +77,22 @@ async def test_refund_x_cashu_returns_token() -> None: @pytest.mark.asyncio async def test_refund_x_cashu_sat_unit() -> None: x_cashu_token = "cashuAsat_token" - in_tx = _make_cashu_tx(token=x_cashu_token, amount=0, unit="sat", type="in", request_id="req-sat") - out_tx = _make_cashu_tx(token="cashuArefund_sat", amount=500, unit="sat", type="out", request_id="req-sat") + in_tx = _make_cashu_tx( + token=x_cashu_token, amount=0, unit="sat", type="in", request_id="req-sat" + ) + out_tx = _make_cashu_tx( + token="cashuArefund_sat", + amount=500, + unit="sat", + type="out", + request_id="req-sat", + ) session = MagicMock() session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() result = await refund_wallet_endpoint( authorization="Bearer sk-somekey", @@ -124,6 +144,7 @@ async def test_refund_x_cashu_pending_raises_425() -> None: session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(None)]) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( @@ -167,8 +188,21 @@ async def test_refund_x_cashu_in_tx_without_request_id_raises_404() -> None: async def test_refund_x_cashu_swept_raises_410() -> None: from fastapi import HTTPException - in_tx = _make_cashu_tx(token="cashuAswept_token", amount=0, unit="msat", type="in", request_id="req-swept") - out_tx = _make_cashu_tx(token="cashuAswept", amount=100, unit="msat", type="out", request_id="req-swept", swept=True) + in_tx = _make_cashu_tx( + token="cashuAswept_token", + amount=0, + unit="msat", + type="in", + request_id="req-swept", + ) + out_tx = _make_cashu_tx( + token="cashuAswept", + amount=100, + unit="msat", + type="out", + request_id="req-swept", + swept=True, + ) session = MagicMock() session.exec = AsyncMock(side_effect=[_exec_result(in_tx), _exec_result(out_tx)]) @@ -239,10 +273,11 @@ async def test_apikey_refund_returns_persisted_token_after_cache_loss() -> None: session.exec = AsyncMock(return_value=_exec_result(refund_tx)) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() with ( - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance.send_token", AsyncMock()) as mock_send_token, + patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)), + patch("routstr.refund.send_token", AsyncMock()) as mock_send_token, ): result = await refund_wallet_endpoint( authorization="Bearer sk-testhash", @@ -277,8 +312,9 @@ async def test_apikey_refund_rejects_persisted_token_after_sweep() -> None: session.exec = AsyncMock(return_value=_exec_result(refund_tx)) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() - with patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)): + with patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)): with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( authorization="Bearer sk-testhash", @@ -302,12 +338,11 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() with ( - patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)), - patch("routstr.balance.store_cashu_transaction", AsyncMock()) as mock_store, - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), + patch("routstr.refund.send_token", AsyncMock(return_value=refund_token)), + patch("routstr.refund.store_cashu_transaction", AsyncMock()) as mock_store, ): result = await refund_wallet_endpoint( authorization="Bearer sk-testhash", @@ -336,13 +371,12 @@ async def test_apikey_refund_logs_token() -> None: session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() with ( - patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)), - patch("routstr.balance.store_cashu_transaction", AsyncMock()), - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), - patch("routstr.balance.logger") as mock_logger, + patch("routstr.refund.send_token", AsyncMock(return_value=refund_token)), + patch("routstr.refund.store_cashu_transaction", AsyncMock()), + patch("routstr.refund.logger") as mock_logger, ): await refund_wallet_endpoint( authorization="Bearer sk-testhash", @@ -351,11 +385,11 @@ async def test_apikey_refund_logs_token() -> None: ) calls = [str(c) for c in mock_logger.info.call_args_list] - assert any("cashu token issued" in c for c in calls) + assert any("refund paid" in c for c in calls) @pytest.mark.asyncio -async def test_apikey_refund_log_includes_path() -> None: +async def test_apikey_refund_log_identifies_the_claim() -> None: key = _make_api_key(balance=5000, refund_currency="sat") refund_token = "cashuApath_token" @@ -364,13 +398,12 @@ async def test_apikey_refund_log_includes_path() -> None: session.exec = AsyncMock(return_value=_update_result(1)) session.add = MagicMock() session.commit = AsyncMock() + session.rollback = AsyncMock() with ( - patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)), - patch("routstr.balance.store_cashu_transaction", AsyncMock()), - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), - patch("routstr.balance.logger") as mock_logger, + patch("routstr.refund.send_token", AsyncMock(return_value=refund_token)), + patch("routstr.refund.store_cashu_transaction", AsyncMock()), + patch("routstr.refund.logger") as mock_logger, ): await refund_wallet_endpoint( authorization="Bearer sk-testhash", @@ -378,14 +411,15 @@ async def test_apikey_refund_log_includes_path() -> None: session=session, ) - # Find the "cashu token issued" call and verify extra contains the path - token_issued_calls = [ - c for c in mock_logger.info.call_args_list - if c.args and "cashu token issued" in c.args[0] + paid_calls = [ + c + for c in mock_logger.info.call_args_list + if c.args and "refund paid" in c.args[0] ] - assert len(token_issued_calls) == 1 - extra = token_issued_calls[0].kwargs.get("extra", {}) - assert extra.get("path") == "/v1/wallet/refund" + assert len(paid_calls) == 1 + extra = paid_calls[0].kwargs.get("extra", {}) + assert extra.get("method") == "cashu" + assert extra.get("refund_id") @pytest.mark.asyncio @@ -400,14 +434,13 @@ async def test_apikey_refund_rejects_on_concurrent_balance_change() -> None: # Debit returns rowcount=0 → balance changed concurrently session.exec = AsyncMock(return_value=_update_result(0)) session.commit = AsyncMock() + session.rollback = AsyncMock() mock_send_token = AsyncMock(return_value="cashuAshould_not_be_minted") with ( - patch("routstr.balance.send_token", mock_send_token), - patch("routstr.balance.store_cashu_transaction", AsyncMock()), - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), + patch("routstr.refund.send_token", mock_send_token), + patch("routstr.refund.store_cashu_transaction", AsyncMock()), ): with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( @@ -427,6 +460,7 @@ async def test_credit_balance_stores_apikey_transaction_history() -> None: session = MagicMock() session.exec = AsyncMock(return_value=_update_result(1)) session.commit = AsyncMock() + session.rollback = AsyncMock() session.refresh = AsyncMock() with ( @@ -460,18 +494,20 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: # First exec call = debit (succeeds), second = restore session = MagicMock() session.get = AsyncMock(return_value=key) - session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)]) + # debit, then the claim close and the balance restore + session.exec = AsyncMock( + side_effect=[_update_result(1), _update_result(1), _update_result(1)] + ) session.commit = AsyncMock() + session.rollback = AsyncMock() with ( patch( - "routstr.balance.send_token", + "routstr.refund.send_token", AsyncMock(side_effect=MintConnectionError("raw mint outage detail")), ), - patch("routstr.balance.store_cashu_transaction", AsyncMock()), - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), - patch("routstr.balance.logger"), + patch("routstr.refund.store_cashu_transaction", AsyncMock()), + patch("routstr.refund.logger"), ): with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( @@ -483,8 +519,8 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: assert exc_info.value.status_code == 503 assert exc_info.value.detail == "Mint service unavailable" assert "raw mint outage detail" not in exc_info.value.detail - # Verify two exec calls: debit + restore - assert session.exec.await_count == 2 + # debit, claim close, balance restore + assert session.exec.await_count == 3 @pytest.mark.asyncio @@ -497,15 +533,19 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None: session = MagicMock() session.get = AsyncMock(return_value=key) - session.exec = AsyncMock(side_effect=[_update_result(1), _update_result(1)]) + # debit, then the claim close and the balance restore + session.exec = AsyncMock( + side_effect=[_update_result(1), _update_result(1), _update_result(1)] + ) session.commit = AsyncMock() + session.rollback = AsyncMock() with ( - patch("routstr.balance.send_token", AsyncMock(side_effect=RuntimeError(raw_error))), - patch("routstr.balance.store_cashu_transaction", AsyncMock()), - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), - patch("routstr.balance.logger"), + patch( + "routstr.refund.send_token", AsyncMock(side_effect=RuntimeError(raw_error)) + ), + patch("routstr.refund.store_cashu_transaction", AsyncMock()), + patch("routstr.refund.logger"), ): with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( @@ -517,7 +557,7 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None: assert exc_info.value.status_code == 500 assert exc_info.value.detail == "Refund failed" assert raw_error not in exc_info.value.detail - assert session.exec.await_count == 2 + assert session.exec.await_count == 3 # --------------------------------------------------------------------------- @@ -575,6 +615,7 @@ async def test_refund_unknown_sk_bearer_returns_401() -> None: # --- Topup redemption error taxonomy (POST /v1/wallet/topup) ------------------ + def _envelope(exc: HTTPException) -> dict: """Extract the error object from a top-up HTTPException.""" detail = exc.detail @@ -633,7 +674,9 @@ async def test_topup_mint_unreachable_returns_503( @pytest.mark.asyncio -async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible() -> None: +async def test_topup_unreachable_source_mint_explains_why_fallback_is_impossible() -> ( + None +): from fastapi import HTTPException from routstr.wallet import SourceMintConnectionError @@ -697,7 +740,9 @@ async def test_topup_zero_value_returns_400_zero_value_message() -> None: patch( "routstr.balance.credit_balance", AsyncMock( - side_effect=ValueError("Redeemed token amount must be positive, got 0 msats") + side_effect=ValueError( + "Redeemed token amount must be positive, got 0 msats" + ) ), ), ): @@ -758,9 +803,7 @@ async def test_topup_token_consumed_returns_500() -> None: "Token value is too small to cover swap fees", ), ( - ValueError( - "Token amount (5 sat) is insufficient to cover melt fees." - ), + ValueError("Token amount (5 sat) is insufficient to cover melt fees."), 422, "mint_error", "cashu_token_swap_fees_exceed_amount", @@ -837,15 +880,14 @@ async def test_apikey_refund_ambiguous_melt_does_not_restore_balance() -> None: session.get = AsyncMock(return_value=key) session.exec = AsyncMock(return_value=MagicMock(rowcount=1)) session.commit = AsyncMock() + session.rollback = AsyncMock() with ( - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), patch( - "routstr.balance.send_to_lnurl", + "routstr.refund.send_to_lnurl", AsyncMock(side_effect=MeltOutcomeAmbiguousError("outcome is ambiguous")), ), - patch("routstr.balance._restore_balance", AsyncMock()) as mock_restore, + patch("routstr.refund.release", AsyncMock()) as mock_restore, ): with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( @@ -869,15 +911,14 @@ async def test_apikey_refund_clean_failure_still_restores_balance() -> None: session.get = AsyncMock(return_value=key) session.exec = AsyncMock(return_value=MagicMock(rowcount=1)) session.commit = AsyncMock() + session.rollback = AsyncMock() with ( - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), patch( - "routstr.balance.send_to_lnurl", + "routstr.refund.send_to_lnurl", AsyncMock(side_effect=RuntimeError("mint rejected melt")), ), - patch("routstr.balance._restore_balance", AsyncMock()) as mock_restore, + patch("routstr.refund.release", AsyncMock()) as mock_restore, ): with pytest.raises(HTTPException): await refund_wallet_endpoint( diff --git a/tests/unit/test_stale_reservations.py b/tests/unit/test_stale_reservations.py index 47061dda..498b57f5 100644 --- a/tests/unit/test_stale_reservations.py +++ b/tests/unit/test_stale_reservations.py @@ -200,10 +200,8 @@ async def test_reset_all_reserved_balances_clears_reserved_at( def _refund_patches(refund_token: str = "cashuArefund"): # type: ignore[no-untyped-def] return ( - patch("routstr.balance.send_token", AsyncMock(return_value=refund_token)), - patch("routstr.balance.store_cashu_transaction", AsyncMock()), - patch("routstr.balance._refund_cache_get", AsyncMock(return_value=None)), - patch("routstr.balance._refund_cache_set", AsyncMock()), + patch("routstr.refund.send_token", AsyncMock(return_value=refund_token)), + patch("routstr.refund.store_cashu_transaction", AsyncMock()), ) @@ -224,8 +222,8 @@ async def test_refund_self_heals_stale_reservation(session: AsyncSession) -> Non reserved_at=int(time.time()) - 10_000, ) - p1, p2, p3, p4 = _refund_patches() - with p1, p2, p3, p4: + p1, p2 = _refund_patches() + with p1, p2: result = await refund_wallet_endpoint( authorization="Bearer sk-stalerefund", x_cashu=None, @@ -253,8 +251,8 @@ async def test_refund_self_heals_legacy_null_reserved_at(session: AsyncSession) reserved_at=None, ) - p1, p2, p3, p4 = _refund_patches() - with p1, p2, p3, p4: + p1, p2 = _refund_patches() + with p1, p2: result = await refund_wallet_endpoint( authorization="Bearer sk-legacyrefund", x_cashu=None, @@ -280,8 +278,8 @@ async def test_refund_rejects_recent_reservation(session: AsyncSession) -> None: reserved_at=int(time.time()), ) - p1, p2, p3, p4 = _refund_patches() - with p1, p2, p3, p4: + p1, p2 = _refund_patches() + with p1, p2: with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( authorization="Bearer sk-activerefund", @@ -302,8 +300,8 @@ async def test_refund_without_reservation_still_works(session: AsyncSession) -> reserved_balance=0, ) - p1, p2, p3, p4 = _refund_patches() - with p1, p2, p3, p4: + p1, p2 = _refund_patches() + with p1, p2: result = await refund_wallet_endpoint( authorization="Bearer sk-plainrefund", x_cashu=None, From 9940f6a7ea346b902fabf83e7533842c315314e7 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 12 Sep 2026 12:12:29 +0200 Subject: [PATCH 2/8] clean up --- routstr/core/db.py | 15 +-- routstr/refund.py | 102 +++++++++--------- tests/integration/test_prune_dead_api_keys.py | 30 ++++++ tests/integration/test_refund_claims.py | 58 +++++++++- 4 files changed, 147 insertions(+), 58 deletions(-) diff --git a/routstr/core/db.py b/routstr/core/db.py index 55693d41..e3bdbd3a 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -367,6 +367,12 @@ async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> in ) ).exists() + has_refund_claim = ( + select(Refund.id).where( + col(Refund.api_key_hashed_key) == col(ApiKey.hashed_key) + ) + ).exists() + eligible_hashes = ( select(ApiKey.hashed_key) .where(col(ApiKey.balance) == 0) @@ -375,6 +381,8 @@ async def prune_dead_api_keys(session: AsyncSession, min_age_seconds: int) -> in .where(col(ApiKey.total_requests) == 0) .where((col(ApiKey.created_at).is_(None)) | (col(ApiKey.created_at) < cutoff)) .where(~settleable_invoice) + # refunds holds a non-null FK to the key. + .where(~has_refund_claim) ) # Unlink transactions rather than cascade-deleting them, so the financial @@ -561,12 +569,7 @@ _REFUND_OPEN_PREDICATE = "status IN ('pending', 'ambiguous')" class Refund(SQLModel, table=True): # type: ignore - """A durable claim on an API key's balance for a single payout. - - The partial unique index is the double-refund guarantee: a key can have at - most one open claim, so a Cashu refund cannot start while a Lightning - refund is in flight, and neither survives a crash without a record. - """ + """One payout claim; the partial unique index allows one open claim per key.""" __tablename__ = "refunds" __table_args__ = ( diff --git a/routstr/refund.py b/routstr/refund.py index 75c2a0db..0ff8b9a5 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -1,12 +1,8 @@ -"""Durable, mutually exclusive refund claims for API key balances. - -A claim debits the balance and records the payout in one transaction, so a key -can never have two payouts in flight and no crash can leave a debited balance -without a record of why. -""" +"""Refund claims: one open payout per API key, recorded before it is paid.""" import asyncio import time +from typing import Any from fastapi import HTTPException from sqlalchemy.exc import IntegrityError @@ -47,14 +43,12 @@ def amount_in_unit(amount_msats: int, unit: str) -> int: def refund_mint(key: ApiKey) -> str: - """Persisted mint preferences must not outlive the trusted-mint config.""" if key.refund_mint_url and key.refund_mint_url in settings.cashu_mints: return key.refund_mint_url return settings.primary_mint async def validate_lightning_destination(destination: str) -> None: - """Resolve the destination before claiming, so a bad address never debits.""" try: await get_lnurl_data(destination) except LNURLError as e: @@ -70,12 +64,7 @@ async def open_claim( method: str, destination: str | None, ) -> Refund: - """Debit the balance to zero and record the claim in one transaction. - - The claim starts leased (``claimed_at``) to the request that opened it, so - the reconciler leaves it alone until ``refund_claim_timeout_seconds`` have - passed; a payout still in flight is never released underneath itself. - """ + """Zero the balance and insert the claim in one transaction.""" unit = refund_unit(key) refund = Refund( api_key_hashed_key=key.hashed_key, @@ -118,29 +107,40 @@ async def open_claim( return refund -async def _close(session: AsyncSession, refund: Refund, **values: object) -> bool: - result = await session.exec( # type: ignore[call-overload] +async def _close( + session: AsyncSession, + refund: Refund, + *, + require_no_quote: bool = False, + **values: object, +) -> bool: + stmt = ( update(Refund) .where(col(Refund.id) == refund.id) .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) - .values(claimed_at=None, updated_at=int(time.time()), **values) + ) + if require_no_quote: + # A quote recorded since the row was read means a melt may be in flight. + stmt = stmt.where(col(Refund.quote_id).is_(None)) + result = await session.exec( # type: ignore[call-overload] + stmt.values(claimed_at=None, updated_at=int(time.time()), **values) ) return bool(result.rowcount) async def record_quote(refund: Refund, quote_id: str) -> None: - """Persist the melt quote before the melt is dispatched. - - Once the quote is on disk the reconciler can ask the mint what became of - it, so a crash after this point can never be mistaken for "never sent". - Raises if the claim is no longer open, which aborts the payout. - """ + """Store the melt quote before the melt is sent; raises if the claim closed.""" async with create_session() as session: result = await session.exec( # type: ignore[call-overload] update(Refund) .where(col(Refund.id) == refund.id) .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) - .values(quote_id=quote_id, updated_at=int(time.time())) + # Renew the lease so the reconciler leaves the payout alone. + .values( + quote_id=quote_id, + claimed_at=int(time.time()), + updated_at=int(time.time()), + ) ) await session.commit() if not result.rowcount: @@ -156,7 +156,7 @@ async def settle( token: str | None = None, mint_url: str | None = None, ) -> bool: - values: dict[str, object] = {"status": "paid"} + values: dict[str, Any] = {"status": "paid"} if quote_id is not None: values["quote_id"] = quote_id if token is not None: @@ -173,12 +173,14 @@ async def settle( return settled -async def release(session: AsyncSession, refund: Refund) -> bool: - """Close the claim and return the debited balance in the same transaction.""" - if not await _close(session, refund, status="failed"): - # Nothing changed; commit rather than roll back so the session stays - # usable (an async rollback after an ORM-enabled UPDATE expires the - # identity map and later loads fail outside the greenlet). +async def release( + session: AsyncSession, refund: Refund, *, require_no_quote: bool = False +) -> bool: + """Mark the claim failed and restore the balance.""" + if not await _close( + session, refund, require_no_quote=require_no_quote, status="failed" + ): + # Commit, not rollback: rollback after an ORM UPDATE breaks later loads. await session.commit() return False await session.exec( # type: ignore[call-overload] @@ -199,18 +201,12 @@ async def release(session: AsyncSession, refund: Refund) -> bool: async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> None: - """Keep the debit and the claim open until reconciliation resolves it.""" await _close(session, refund, status="ambiguous", quote_id=quote_id) await session.commit() async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None: - """Most recent paid Lightning refund, for idempotent re-requests. - - Cashu payouts are deliberately excluded: their token lives in - ``cashu_transactions`` whose ``collected``/``swept`` flags decide whether - it may still be handed out. - """ + """Latest paid Lightning refund. Cashu is served from cashu_transactions.""" result = await session.exec( select(Refund) .where(Refund.api_key_hashed_key == key.hashed_key) @@ -235,7 +231,6 @@ def describe(refund: Refund) -> dict[str, str]: async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: - """Pay out an open claim, closing it on every outcome the mint makes known.""" amount = amount_in_unit(refund.amount_msats, refund.unit) quote_id: str | None = None @@ -338,11 +333,9 @@ async def _lease(refund_id: str, now: int, lease_cutoff: int) -> bool: return bool(result.rowcount) -async def _reconcile(refund: Refund) -> None: +async def _reconcile(refund: Refund, now: int) -> None: if refund.method != "lightning": - # A cashu payout leaves no quote to query: the token either reached the - # client or was lost with the process. Close the claim as ``stuck`` so - # the balance stays withheld and the operator is told exactly once. + # No quote to query for cashu; withhold the balance and alert once. async with create_session() as session: if await _close(session, refund, status="stuck"): await session.commit() @@ -357,9 +350,13 @@ async def _reconcile(refund: Refund) -> None: return if refund.quote_id is None: - # No melt quote exists, so the mint was never asked to pay. + # Never sent, unless a quote appeared since the row was read. async with create_session() as session: - await release(session, refund) + if not await release(session, refund, require_no_quote=True): + logger.info( + "refund gained a melt quote during reconciliation; left open", + extra={"refund_id": refund.id}, + ) return status = await check_bolt11_payment_status( @@ -369,6 +366,13 @@ async def _reconcile(refund: Refund) -> None: async with create_session() as session: await settle(session, refund) elif status == "unpaid": + # A fresh melt can report unpaid briefly; trust it only after a timeout. + if refund.updated_at > now - settings.refund_claim_timeout_seconds: + logger.info( + "refund unpaid at the mint but too recent to release; waiting", + extra={"refund_id": refund.id, "updated_at": refund.updated_at}, + ) + return async with create_session() as session: await release(session, refund) else: @@ -379,11 +383,7 @@ async def _reconcile(refund: Refund) -> None: async def reconcile_once() -> None: - """Resolve open claims whose lease has lapsed. - - A fresh claim is leased to the request paying it out; ``hold`` drops that - lease so an ambiguous outcome is queried on the next pass. - """ + """Resolve open claims whose lease has lapsed.""" now = int(time.time()) lease_cutoff = now - settings.refund_claim_timeout_seconds async with create_session() as session: @@ -403,7 +403,7 @@ async def reconcile_once() -> None: if not await _lease(refund.id, now, lease_cutoff): continue try: - await _reconcile(refund) + await _reconcile(refund, now) except Exception as e: logger.error( "refund reconciliation failed", diff --git a/tests/integration/test_prune_dead_api_keys.py b/tests/integration/test_prune_dead_api_keys.py index aaacecad..d4b54570 100644 --- a/tests/integration/test_prune_dead_api_keys.py +++ b/tests/integration/test_prune_dead_api_keys.py @@ -293,3 +293,33 @@ async def test_periodic_prune_disabled_returns_immediately( await auth.periodic_dead_key_prune() sleep_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_refund_claim_protects_key(patched_db_engine: None) -> None: + """A key with a refund row is the audit anchor for its payout and holds a + non-null FK, so the janitor must leave it alone.""" + from routstr.core.db import Refund + + key = _dead_key(LONG_AGO) + async with create_session() as session: + session.add(key) + await session.commit() + session.add( + Refund( + api_key_hashed_key=key.hashed_key, + method="lightning", + destination="user@ln.example.com", + amount_msats=1000, + unit="sat", + mint_url="https://mint.example.com", + status="paid", + ) + ) + await session.commit() + + async with create_session() as session: + pruned = await prune_dead_api_keys(session, OLD) + + assert pruned == 0 + assert await _exists(key.hashed_key) diff --git a/tests/integration/test_refund_claims.py b/tests/integration/test_refund_claims.py index 0ba4d7fa..dac2f70a 100644 --- a/tests/integration/test_refund_claims.py +++ b/tests/integration/test_refund_claims.py @@ -57,6 +57,7 @@ async def _age_claim(session: AsyncSession, refund_id: str, seconds: int) -> Non row = await _load_refund(session, refund_id) row.claimed_at = int(time.time()) - seconds row.created_at = int(time.time()) - seconds + row.updated_at = int(time.time()) - seconds session.add(row) await session.commit() @@ -314,6 +315,7 @@ async def test_reconcile_ambiguous_claims( expected_balance: int, ) -> None: claim = await _open_ambiguous(integration_session, "quote-123") + await _age_claim(integration_session, claim.id, 600) with patch( "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value=mint_status), @@ -330,7 +332,8 @@ async def test_reconcile_ambiguous_claims( async def test_reconcile_credits_balance_once_across_passes( integration_session: AsyncSession, patched_db_engine: None, short_timeout: None ) -> None: - await _open_ambiguous(integration_session, "quote-123") + claim = await _open_ambiguous(integration_session, "quote-123") + await _age_claim(integration_session, claim.id, 600) with patch( "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="unpaid") ): @@ -339,6 +342,59 @@ async def test_reconcile_credits_balance_once_across_passes( assert (await _load_key(integration_session)).balance == BALANCE_MSATS +@pytest.mark.asyncio +async def test_reconcile_waits_before_trusting_recent_unpaid( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + """A just-dispatched melt can report unpaid before it turns pending, so an + unpaid quote is only final once the claim has been quiet for a timeout.""" + claim = await _open_ambiguous(integration_session, "quote-123") + with patch( + "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="unpaid") + ): + await refund.reconcile_once() + row = await _load_refund(integration_session, claim.id) + assert row.status == "ambiguous" + assert (await _load_key(integration_session)).balance == 0 + + await _age_claim(integration_session, claim.id, 600) + with patch( + "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="unpaid") + ): + await refund.reconcile_once() + row = await _load_refund(integration_session, claim.id) + assert row.status == "failed" + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +@pytest.mark.asyncio +async def test_reconcile_keeps_claim_that_gained_quote_mid_pass( + integration_session: AsyncSession, patched_db_engine: None, short_timeout: None +) -> None: + """The payout records its quote between the reconciler's read and its + release: the stale ``quote_id is None`` must not restore the balance.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + await _age_claim(integration_session, claim.id, 600) + + real_lease = refund._lease + + async def lease_then_quote(refund_id: str, now: int, cutoff: int) -> bool: + leased = await real_lease(refund_id, now, cutoff) + await refund.record_quote(claim, "late-quote") + return leased + + with patch("routstr.refund._lease", lease_then_quote): + await refund.reconcile_once() + + row = await _load_refund(integration_session, claim.id) + assert row.status == "pending" + assert row.quote_id == "late-quote" + assert (await _load_key(integration_session)).balance == 0 + + @pytest.mark.asyncio async def test_reconcile_leaves_fresh_pending_claim_alone( integration_session: AsyncSession, patched_db_engine: None, short_timeout: None From e60dab39a2dd8105a23bb5be49aee6bdedec7a35 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Sat, 12 Sep 2026 12:17:22 +0200 Subject: [PATCH 3/8] add doc --- docs/api/endpoints.md | 73 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 10 deletions(-) diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index dfd619f2..9c7430a9 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -486,34 +486,87 @@ Authorization: Bearer sk-... } ``` -### Withdraw Funds +### Refund Balance -Withdraw balance as eCash. +Pay out the remaining balance and close the key. The payout goes to a Lightning address when one is given (in the request or stored on the key), otherwise a Cashu token is returned. ```http -POST /v1/wallet/withdraw +POST /v1/balance/refund Authorization: Bearer sk-... +Content-Type: application/json ``` -**Request Body:** +`/v1/wallet/refund` is a deprecated alias. + +**Request Body** (optional): ```json { - "amount": 5000, - "mint": "https://mint.example.com" + "lightning_address": "user@getalby.com" } ``` -**Response:** +**Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `lightning_address` | string | No | Key's stored refund address | Lightning address or LNURL to pay. Overrides the stored address for this request. Resolved before any balance is debited. | + +**Response (Lightning):** ```json { - "cashu_token": "cashuAeyJ0...", - "amount": 5000, - "mint": "https://mint.example.com" + "refund_id": "3f9c1e2d8b7a4c6e9f0a1b2c3d4e5f60", + "status": "paid", + "recipient": "user@getalby.com", + "sats": "4500" } ``` +**Response (Cashu):** + +```json +{ + "refund_id": "3f9c1e2d8b7a4c6e9f0a1b2c3d4e5f60", + "status": "paid", + "token": "cashuAeyJ0...", + "sats": "4500" +} +``` + +The amount field is `sats` or `msats` depending on the key's refund currency. + +**Behaviour:** + +- The balance is debited and a refund claim is recorded before the payout is attempted. A key has at most one open claim at a time. +- If the payout fails cleanly, the claim is closed and the balance is restored. Retry the request. +- If the Lightning payment is dispatched but the mint cannot confirm the outcome, the request returns `502`, the balance stays withheld, and a background reconciler asks the mint until it answers. The balance is restored if the mint reports the payment unpaid. +- Calling again on a zero-balance key returns the last paid Lightning refund, or the previously issued Cashu token while it remains uncollected. + +**Errors:** + +| Status | Meaning | +|--------|---------| +| `400` | Invalid Lightning destination, no balance, or balance too small for the refund unit | +| `400` | Ongoing requests are still reserving balance on this key | +| `401` | Unknown key | +| `409` | Balance changed concurrently. Retry. | +| `409` | `refund_in_progress`: another refund claim for this key is still open | +| `410` | Previously issued Cashu refund token has been swept | +| `502` | Payment dispatched, outcome unconfirmed. Balance withheld pending reconciliation. Do not retry. | +| `503` | Mint unavailable. Balance restored. Retry later. | + +**X-Cashu refunds:** + +Requests paid per-call with an `X-Cashu` header get their change from this endpoint by sending the same header instead of `Authorization`: + +```http +POST /v1/balance/refund +X-Cashu: cashuAeyJ0... +``` + +Returns the change token in the body and in an `X-Cashu` response header. `404` if no matching request exists, `425` while the change is still being minted, `410` if it was swept. + ## Provider Discovery ## Admin Settings From ac47c0dd352b09060a693cc5048887841f8e3990 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 16 Sep 2026 01:35:03 +0200 Subject: [PATCH 4/8] clean up --- ...e.py => 3a0fbd387f10_add_refunds_table.py} | 10 +- routstr/balance.py | 7 +- routstr/refund.py | 95 +++++++++++++------ scripts/refund_token_to_lightning.py | 65 +++++++++++++ tests/integration/test_refund_claims.py | 35 +++++++ 5 files changed, 176 insertions(+), 36 deletions(-) rename migrations/versions/{f3a1c7b9e2d4_add_refunds_table.py => 3a0fbd387f10_add_refunds_table.py} (94%) create mode 100644 scripts/refund_token_to_lightning.py diff --git a/migrations/versions/f3a1c7b9e2d4_add_refunds_table.py b/migrations/versions/3a0fbd387f10_add_refunds_table.py similarity index 94% rename from migrations/versions/f3a1c7b9e2d4_add_refunds_table.py rename to migrations/versions/3a0fbd387f10_add_refunds_table.py index e0b3c823..e53e4484 100644 --- a/migrations/versions/f3a1c7b9e2d4_add_refunds_table.py +++ b/migrations/versions/3a0fbd387f10_add_refunds_table.py @@ -1,8 +1,8 @@ """add refunds table -Revision ID: f3a1c7b9e2d4 -Revises: e5a6b7c8d9f0 -Create Date: 2026-09-07 +Revision ID: 3a0fbd387f10 +Revises: a3f1b6c204de +Create Date: 2026-09-16 """ @@ -10,8 +10,8 @@ import sqlalchemy as sa import sqlmodel from alembic import op -revision = "f3a1c7b9e2d4" -down_revision = "e5a6b7c8d9f0" +revision = "3a0fbd387f10" +down_revision = "a3f1b6c204de" branch_labels = None depends_on = None diff --git a/routstr/balance.py b/routstr/balance.py index 7c37331a..2f4140cf 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -358,10 +358,15 @@ async def refund_wallet_endpoint( destination = requested or key.refund_address if key.total_balance <= 0: - if paid := await refund.latest_terminal(session, key): + paid = await refund.latest_terminal(session, key) + if paid and paid.method == "lightning": return refund.describe(paid) + # cashu_transactions tracks collection and sweeping, so it takes + # precedence; the claim row covers a token whose ledger write failed. if persisted := await _get_persisted_api_key_refund(key, session): return persisted + if paid: + return refund.describe(paid) if key.reserved_balance > 0: # Release only durable reservations old enough to be stale. A newer diff --git a/routstr/refund.py b/routstr/refund.py index 0ff8b9a5..5c328a57 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -206,13 +206,17 @@ async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> N async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None: - """Latest paid Lightning refund. Cashu is served from cashu_transactions.""" + """Latest paid refund of either method. + + Cashu tokens are normally served from cashu_transactions, which tracks + collection and sweeping; the claim row is the fallback when that ledger + write failed after the token was already issued. + """ result = await session.exec( select(Refund) .where(Refund.api_key_hashed_key == key.hashed_key) .where(Refund.status == "paid") - .where(Refund.method == "lightning") - .order_by(col(Refund.created_at).desc()) + .order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc()) ) return result.first() @@ -230,8 +234,7 @@ def describe(refund: Refund) -> dict[str, str]: return body -async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: - amount = amount_in_unit(refund.amount_msats, refund.unit) +async def _pay_lightning(session: AsyncSession, refund: Refund) -> None: quote_id: str | None = None async def capture_quote(quote: str) -> None: @@ -240,31 +243,13 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: await record_quote(refund, quote) try: - if refund.method == "lightning": - await send_to_lnurl( - amount, - refund.unit, - refund.mint_url, - str(refund.destination), - on_melt_quote=capture_quote, - ) - await settle(session, refund, quote_id=quote_id) - else: - token = await send_token(amount, refund.unit, refund.mint_url) - mint_url = token_mint_url(token, refund.mint_url) - await settle(session, refund, token=token, mint_url=mint_url) - await store_cashu_transaction( - token=token, - amount=amount, - unit=refund.unit, - mint_url=mint_url, - typ="out", - collected=False, - source="apikey", - api_key_hashed_key=refund.api_key_hashed_key, - ) - refund.token = token - refund.mint_url = mint_url + await send_to_lnurl( + amount_in_unit(refund.amount_msats, refund.unit), + refund.unit, + refund.mint_url, + str(refund.destination), + on_melt_quote=capture_quote, + ) except MeltOutcomeAmbiguousError as e: await hold(session, refund, quote_id) logger.error( @@ -276,6 +261,53 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: "quote_id": quote_id, }, ) + raise + await settle(session, refund, quote_id=quote_id) + + +async def _pay_cashu(session: AsyncSession, refund: Refund) -> None: + amount = amount_in_unit(refund.amount_msats, refund.unit) + token = await send_token(amount, refund.unit, refund.mint_url) + mint_url = token_mint_url(token, refund.mint_url) + await settle(session, refund, token=token, mint_url=mint_url) + refund.token = token + refund.mint_url = mint_url + + +async def _record_cashu_payout(refund: Refund) -> None: + """Ledger write for an issued token; the claim row already holds the token, + so a failure here must not fail the request or release the balance.""" + try: + await store_cashu_transaction( + token=str(refund.token), + amount=amount_in_unit(refund.amount_msats, refund.unit), + unit=refund.unit, + mint_url=refund.mint_url, + typ="out", + collected=False, + source="apikey", + api_key_hashed_key=refund.api_key_hashed_key, + ) + except Exception as e: + logger.error( + "refund token issued but cashu transaction was not recorded", + extra={ + "refund_id": refund.id, + "error": str(e), + "error_type": type(e).__name__, + "key_hash": refund.api_key_hashed_key[:8], + }, + ) + + +async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: + try: + if refund.method == "lightning": + await _pay_lightning(session, refund) + else: + await _pay_cashu(session, refund) + except MeltOutcomeAmbiguousError: + # Already held by _pay_lightning; releasing here would pay out twice. raise HTTPException( status_code=502, detail=( @@ -303,6 +335,9 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: raise HTTPException(status_code=503, detail="Mint service unavailable") raise HTTPException(status_code=500, detail="Refund failed") + if refund.method == "cashu": + await _record_cashu_payout(refund) + refund.status = "paid" refund.claimed_at = None logger.info( diff --git a/scripts/refund_token_to_lightning.py b/scripts/refund_token_to_lightning.py new file mode 100644 index 00000000..df5979d3 --- /dev/null +++ b/scripts/refund_token_to_lightning.py @@ -0,0 +1,65 @@ +"""Redeem a cashu token into a balance and pay it out to a Lightning address. + +Usage: + python scripts/refund_token_to_lightning.py [--url http://localhost:8000] + +Steps: + 1. POST /v1/balance/create redeems the token into a fresh API key + 2. POST /v1/balance/refund pays the full balance to the Lightning address + +A 502 from the refund means the melt was dispatched but unconfirmed; the +balance is withheld until the server reconciles it. Re-run with the printed +API key to check whether it settled. +""" + +import argparse +import sys + +import httpx + + +def create_balance(client: httpx.Client, token: str) -> str: + response = client.post("/v1/balance/create", json={"initial_balance_token": token}) + response.raise_for_status() + data = response.json() + print(f"Redeemed token: balance {data['balance']} msats, key {data['api_key']}") + return str(data["api_key"]) + + +def refund_to_lightning(client: httpx.Client, api_key: str, address: str) -> dict: + response = client.post( + "/v1/balance/refund", + headers={"Authorization": f"Bearer {api_key}"}, + json={"lightning_address": address}, + ) + if response.status_code >= 400: + print(f"Refund failed ({response.status_code}): {response.text}") + sys.exit(1) + return dict(response.json()) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("token", help="cashu token, or sk-... key from a prior run") + parser.add_argument("lightning_address", help="Lightning address or LNURL") + parser.add_argument("--url", default="http://localhost:8000", help="routstr URL") + args = parser.parse_args() + + with httpx.Client(base_url=args.url, timeout=120.0) as client: + api_key = ( + args.token + if args.token.startswith("sk-") + else create_balance(client, args.token) + ) + result = refund_to_lightning(client, api_key, args.lightning_address) + + amount = result.get("sats") or result.get("msats") + unit = "sats" if "sats" in result else "msats" + print( + f"Refund {result['refund_id']} {result['status']}: " + f"{amount} {unit} -> {result.get('recipient', args.lightning_address)}" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/test_refund_claims.py b/tests/integration/test_refund_claims.py index dac2f70a..57e66795 100644 --- a/tests/integration/test_refund_claims.py +++ b/tests/integration/test_refund_claims.py @@ -581,3 +581,38 @@ async def test_endpoint_refund_while_ambiguous_returns_409( assert exc_info.value.status_code == 409 send.assert_not_awaited() assert (await _load_key(integration_session)).balance == 2_000_000 + + +@pytest.mark.asyncio +async def test_cashu_token_survives_failed_ledger_write( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """The token is issued once the mint signs it; a failed cashu_transactions + insert must neither fail the request nor release the balance, and a retry + must replay the token from the claim row.""" + await _seed_key(integration_session) + with ( + patch("routstr.refund.send_token", AsyncMock(return_value="cashuAtoken")), + patch("routstr.refund.token_mint_url", lambda token, mint: mint), + patch( + "routstr.refund.store_cashu_transaction", + AsyncMock(side_effect=RuntimeError("db down")), + ), + ): + first = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(first, dict) + assert (first["token"], first["status"]) == ("cashuAtoken", "paid") + assert (await _load_key(integration_session)).balance == 0 + + second = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(second, dict) + assert second["refund_id"] == first["refund_id"] + assert second["token"] == "cashuAtoken" From c3970de51011f612268f093814640092db62bde8 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 16 Sep 2026 12:08:03 +0200 Subject: [PATCH 5/8] fix: harden LNURL refund destination checks and surface in-progress refunds --- routstr/balance.py | 4 ++ routstr/payment/lnurl.py | 72 +++++++++++++++---- routstr/refund.py | 40 ++++++++--- tests/integration/test_refund_claims.py | 26 +++++++ .../unit/test_lnurl_amount_and_destination.py | 56 +++++++++++++++ 5 files changed, 173 insertions(+), 25 deletions(-) diff --git a/routstr/balance.py b/routstr/balance.py index 2f4140cf..4e163a53 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -367,6 +367,10 @@ async def refund_wallet_endpoint( return persisted if paid: return refund.describe(paid) + # Balance reads zero because a prior refund already debited it and is + # still settling; surface that as 409 rather than "no balance". + if await refund.latest_open(session, key): + raise refund.refund_in_progress_error() if key.reserved_balance > 0: # Release only durable reservations old enough to be stale. A newer diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index 2f5c59a6..9201bca7 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -1,6 +1,9 @@ from __future__ import annotations +import asyncio import ipaddress +import json +import socket from collections.abc import Awaitable, Callable from typing import Any, TypedDict @@ -48,15 +51,18 @@ class MeltOutcomeAmbiguousError(LNURLError): _MAX_LNURL_REDIRECTS = 3 +_MAX_LNURL_RESPONSE_BYTES = 64 * 1024 _NON_PUBLIC_HOST_SUFFIXES = (".localhost", ".local", ".internal") -def _require_public_https_destination(url: httpx.URL) -> None: +async def _require_public_https_destination(url: httpx.URL) -> None: """Reject anything that is not a public HTTPS endpoint. LNURL destinations and their redirect targets are attacker-influenced, so every hop has to be re-checked: a single ``https://`` origin says nothing - about where a 302 points. + about where a 302 points. A bare hostname check is not enough either: a + public-looking name can resolve to a loopback/link-local/private address + (SSRF), so DNS is resolved here and every resulting address must be global. """ if url.scheme != "https": raise LNURLError("LNURL destination must be an HTTPS URL") @@ -66,15 +72,37 @@ def _require_public_https_destination(url: httpx.URL) -> None: raise LNURLError("LNURL destination has no host") try: - address = ipaddress.ip_address(host) + literal = ipaddress.ip_address(host) except ValueError: - if host == "localhost" or host.endswith(_NON_PUBLIC_HOST_SUFFIXES): - raise LNURLError("LNURL destination is not a public host") from None + literal = None + + if literal is not None: + if not literal.is_global: + raise LNURLError("LNURL destination is not a public host") return - if not address.is_global: + if host == "localhost" or host.endswith(_NON_PUBLIC_HOST_SUFFIXES): raise LNURLError("LNURL destination is not a public host") + port = url.port or 443 + try: + infos = await asyncio.get_running_loop().getaddrinfo( + host, port, proto=socket.IPPROTO_TCP + ) + except socket.gaierror as e: + raise LNURLError("LNURL destination could not be resolved") from e + if not infos: + raise LNURLError("LNURL destination could not be resolved") + for info in infos: + try: + resolved = ipaddress.ip_address(info[4][0]) + except ValueError as e: + raise LNURLError( + "LNURL destination resolved to an invalid address" + ) from e + if not resolved.is_global: + raise LNURLError("LNURL destination is not a public host") + async def _fetch_lnurl_json( url: str, params: dict[str, int] | None = None @@ -88,21 +116,34 @@ async def _fetch_lnurl_json( target = httpx.URL(url, params=params) if params else httpx.URL(url) except httpx.InvalidURL as e: raise LNURLError("LNURL destination is not a usable URL") from e - _require_public_https_destination(target) + await _require_public_https_destination(target) + raw: bytes | None = None async with httpx.AsyncClient() as client: for _ in range(_MAX_LNURL_REDIRECTS + 1): - response = await client.get(target, follow_redirects=False, timeout=10) - if not response.is_redirect: - break - target = target.join(response.headers.get("location", "")) - _require_public_https_destination(target) + async with client.stream( + "GET", target, follow_redirects=False, timeout=10 + ) as response: + if response.is_redirect: + target = target.join(response.headers.get("location", "")) + await _require_public_https_destination(target) + continue + response.raise_for_status() + chunks = bytearray() + async for chunk in response.aiter_bytes(): + chunks.extend(chunk) + if len(chunks) > _MAX_LNURL_RESPONSE_BYTES: + raise LNURLError("LNURL response exceeded the size limit") + raw = bytes(chunks) + break else: raise LNURLError("LNURL destination exceeded the redirect limit") - response.raise_for_status() + + if raw is None: + raise LNURLError("LNURL destination exceeded the redirect limit") try: - data = response.json() + data = json.loads(raw) except ValueError as e: raise LNURLError("LNURL response was not valid JSON") from e @@ -190,9 +231,10 @@ async def get_lnurl_data(lnurl: str) -> LNURLData: if not isinstance(callback_url, str): raise LNURLError("Invalid LNURL payRequest: missing callback URL") try: - _require_public_https_destination(httpx.URL(callback_url)) + callback_target = httpx.URL(callback_url) except httpx.InvalidURL as e: raise LNURLError("Invalid LNURL callback URL") from e + await _require_public_https_destination(callback_target) min_sendable = lnurl_data.get("minSendable", 1000) # Default 1 sat max_sendable = lnurl_data.get("maxSendable", 1000000000) # Default 1000 BTC diff --git a/routstr/refund.py b/routstr/refund.py index 5c328a57..028b3323 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -94,16 +94,7 @@ async def open_claim( await session.commit() except IntegrityError: await session.rollback() - raise HTTPException( - status_code=409, - detail={ - "error": { - "message": "A refund for this key is already in progress.", - "type": "invalid_request_error", - "code": "refund_in_progress", - } - }, - ) + raise refund_in_progress_error() return refund @@ -205,6 +196,35 @@ async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> N await session.commit() +def refund_in_progress_error() -> HTTPException: + """The 409 raised when a key already has an in-flight refund claim.""" + return HTTPException( + status_code=409, + detail={ + "error": { + "message": "A refund for this key is already in progress.", + "type": "invalid_request_error", + "code": "refund_in_progress", + } + }, + ) + + +async def latest_open(session: AsyncSession, key: ApiKey) -> Refund | None: + """Latest non-terminal (in-flight) claim for the key, if any. + + An open claim means a prior refund already debited the balance and is still + settling, so the balance reads as zero even though a refund is under way. + """ + result = await session.exec( + select(Refund) + .where(Refund.api_key_hashed_key == key.hashed_key) + .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc()) + ) + return result.first() + + async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None: """Latest paid refund of either method. diff --git a/tests/integration/test_refund_claims.py b/tests/integration/test_refund_claims.py index 57e66795..aa8a4a32 100644 --- a/tests/integration/test_refund_claims.py +++ b/tests/integration/test_refund_claims.py @@ -583,6 +583,32 @@ async def test_endpoint_refund_while_ambiguous_returns_409( assert (await _load_key(integration_session)).balance == 2_000_000 +@pytest.mark.asyncio +async def test_endpoint_zero_balance_with_open_claim_returns_409( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """A prior refund debited the balance and is still settling: the retry must + report refund_in_progress (409), not "no balance to refund" (400).""" + await _open_ambiguous(integration_session, "quote-123") + assert (await _load_key(integration_session)).balance == 0 + + send = AsyncMock() + with ( + patch("routstr.refund.get_lnurl_data", AsyncMock()), + patch("routstr.refund.send_to_lnurl", send), + ): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert exc_info.value.status_code == 409 + assert isinstance(exc_info.value.detail, dict) + assert exc_info.value.detail["error"]["code"] == "refund_in_progress" + send.assert_not_awaited() + + @pytest.mark.asyncio async def test_cashu_token_survives_failed_ledger_write( integration_session: AsyncSession, patched_db_engine: None diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py index ab6a996e..a8e08dfb 100644 --- a/tests/unit/test_lnurl_amount_and_destination.py +++ b/tests/unit/test_lnurl_amount_and_destination.py @@ -6,6 +6,7 @@ is what stops a pre-dispatch failure from stranding proofs. """ import math +import socket from collections.abc import Callable from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -347,6 +348,61 @@ async def test_send_to_lnurl_does_not_reserve_before_lnurl_validation() -> None: assert raw_send.await_args.kwargs["amount"] == 1000 +def _patch_getaddrinfo(ip: str) -> Any: + """Force DNS resolution of any hostname to a single fixed IP.""" + + async def fake_getaddrinfo(host: str, port: int, **_kw: object) -> list[Any]: + return [ + (socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (ip, port)) + ] + + loop = MagicMock() + loop.getaddrinfo = fake_getaddrinfo + return patch.object( + lnurl_module.asyncio, "get_running_loop", return_value=loop + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "private_ip", ["127.0.0.1", "10.0.0.5", "169.254.169.254", "192.168.1.1"] +) +async def test_guard_rejects_public_hostname_resolving_to_private( + private_ip: str, +) -> None: + """A public-looking name must be rejected when DNS points it inward (SSRF).""" + with ( + _patch_getaddrinfo(private_ip), + pytest.raises(LNURLError, match="public host"), + ): + await lnurl_module._require_public_https_destination( + httpx.URL("https://totally-public.example.com/cb") + ) + + +@pytest.mark.asyncio +async def test_guard_allows_public_hostname_resolving_to_public() -> None: + with _patch_getaddrinfo("93.184.216.34"): + await lnurl_module._require_public_https_destination( + httpx.URL("https://totally-public.example.com/cb") + ) + + +@pytest.mark.asyncio +async def test_get_lnurl_data_rejects_oversized_response() -> None: + big = b"x" * (lnurl_module._MAX_LNURL_RESPONSE_BYTES + 1) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=big) + + with ( + _patch_getaddrinfo("93.184.216.34"), + _mock_client(handler), + pytest.raises(LNURLError, match="size limit"), + ): + await get_lnurl_data("owner@ln.tld") + + def test_select_melt_proofs_ignores_fees_for_unneeded_wallet_proofs() -> None: from routstr.payment.lnurl import _select_melt_proofs From e49fb7d5c1e15a259d10f3deff9e9728775cf6d1 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 16 Sep 2026 13:15:32 +0200 Subject: [PATCH 6/8] clean up --- docs/api/endpoints.md | 13 +- routstr/balance.py | 45 ++-- routstr/core/db.py | 17 +- routstr/refund.py | 144 +++++++---- routstr/wallet.py | 17 +- scripts/refund_token_to_lightning.py | 26 +- tests/integration/test_refund_claims.py | 259 +++++++++++++++++++- tests/unit/test_balance.py | 32 +-- tests/unit/test_refund_script_url_policy.py | 49 ++++ 9 files changed, 504 insertions(+), 98 deletions(-) create mode 100644 tests/unit/test_refund_script_url_policy.py diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 9c7430a9..43d13705 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -488,7 +488,7 @@ Authorization: Bearer sk-... ### Refund Balance -Pay out the remaining balance and close the key. The payout goes to a Lightning address when one is given (in the request or stored on the key), otherwise a Cashu token is returned. +Pay out the current balance. The key remains valid at zero balance and can be topped up again. The payout goes to a Lightning address when one is given (in the request or stored on the key), otherwise a Cashu token is returned. ```http POST /v1/balance/refund @@ -510,7 +510,7 @@ Content-Type: application/json | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| -| `lightning_address` | string | No | Key's stored refund address | Lightning address or LNURL to pay. Overrides the stored address for this request. Resolved before any balance is debited. | +| `lightning_address` | string | No | Key's stored refund address | Lightning address or LNURL to pay. Overrides the stored address for this request. Resolved only for a request that can open a new claim, before any balance is debited. | **Response (Lightning):** @@ -534,14 +534,16 @@ Content-Type: application/json } ``` -The amount field is `sats` or `msats` depending on the key's refund currency. +The amount field is `sats` or `msats` depending on the key's refund currency. It reports the gross balance debited by the claim. For Lightning refunds, mint and input fees can reduce the amount actually delivered to the recipient. **Behaviour:** - The balance is debited and a refund claim is recorded before the payout is attempted. A key has at most one open claim at a time. - If the payout fails cleanly, the claim is closed and the balance is restored. Retry the request. +- Once a melt quote has been recorded the mint may already have paid it, so any later failure returns `502` and withholds the balance rather than restoring it. - If the Lightning payment is dispatched but the mint cannot confirm the outcome, the request returns `502`, the balance stays withheld, and a background reconciler asks the mint until it answers. The balance is restored if the mint reports the payment unpaid. -- Calling again on a zero-balance key returns the last paid Lightning refund, or the previously issued Cashu token while it remains uncollected. +- An unresolved claim is reported before any replay: a request on a key with an open claim returns `409` with that claim's `refund_id` and `status`. +- Calling again on a zero-balance key with no open claim returns the last paid Lightning refund, or the Cashu token issued by the last paid claim while it remains uncollected. **Errors:** @@ -551,7 +553,8 @@ The amount field is `sats` or `msats` depending on the key's refund currency. | `400` | Ongoing requests are still reserving balance on this key | | `401` | Unknown key | | `409` | Balance changed concurrently. Retry. | -| `409` | `refund_in_progress`: another refund claim for this key is still open | +| `409` | `refund_in_progress`: another refund claim for this key is still open. The body carries its `refund_id` and `status` | +| `409` | `refund_unresolved`: a claim for this key is `stuck` and needs operator reconciliation | | `410` | Previously issued Cashu refund token has been swept | | `502` | Payment dispatched, outcome unconfirmed. Balance withheld pending reconciliation. Do not retry. | | `503` | Mint unavailable. Balance restored. Retry later. | diff --git a/routstr/balance.py b/routstr/balance.py index 4e163a53..09956faf 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -241,17 +241,16 @@ async def _lookup_key_no_create( async def _get_persisted_api_key_refund( - key: ApiKey, session: AsyncSession + key: ApiKey, session: AsyncSession, token: str | None = None ) -> dict[str, str] | None: - result = await session.exec( - select(CashuTransaction) - .where( - CashuTransaction.api_key_hashed_key == key.hashed_key, - CashuTransaction.type == "out", - CashuTransaction.source == "apikey", - ) - .order_by(col(CashuTransaction.created_at).desc()) + query = select(CashuTransaction).where( + CashuTransaction.api_key_hashed_key == key.hashed_key, + CashuTransaction.type == "out", + CashuTransaction.source == "apikey", ) + if token is not None: + query = query.where(CashuTransaction.token == token) + result = await session.exec(query.order_by(col(CashuTransaction.created_at).desc())) refund = result.first() if refund is None: return None @@ -352,25 +351,28 @@ async def refund_wallet_endpoint( }, ) - requested = refund_request.lightning_address if refund_request else None - if requested: - await refund.validate_lightning_destination(requested) - destination = requested or key.refund_address + # Check for an open claim before any replay or destination lookup. + if open_claim := await refund.latest_open(session, key): + raise refund.refund_in_progress_error(open_claim) if key.total_balance <= 0: paid = await refund.latest_terminal(session, key) if paid and paid.method == "lightning": return refund.describe(paid) - # cashu_transactions tracks collection and sweeping, so it takes - # precedence; the claim row covers a token whose ledger write failed. + if paid and paid.token: + # Match the ledger row to this claim's token, not the latest one. + if persisted := await _get_persisted_api_key_refund( + key, session, paid.token + ): + return persisted + return refund.describe(paid) + # Legacy payouts predate the claim row, so fall back to the ledger. if persisted := await _get_persisted_api_key_refund(key, session): return persisted if paid: return refund.describe(paid) - # Balance reads zero because a prior refund already debited it and is - # still settling; surface that as 409 rather than "no balance". - if await refund.latest_open(session, key): - raise refund.refund_in_progress_error() + if stuck := await refund.latest_stuck(session, key): + raise refund.refund_in_progress_error(stuck) if key.reserved_balance > 0: # Release only durable reservations old enough to be stale. A newer @@ -403,6 +405,11 @@ async def refund_wallet_endpoint( elif remaining_balance <= 0: raise HTTPException(status_code=400, detail="No balance to refund") + requested = refund_request.lightning_address if refund_request else None + if requested: + await refund.validate_lightning_destination(requested) + destination = requested or key.refund_address + claim = await refund.open_claim( session, key, diff --git a/routstr/core/db.py b/routstr/core/db.py index 99aab956..eb4ba2fe 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -570,6 +570,9 @@ class CashuTransaction(SQLModel, table=True): # type: ignore REFUND_OPEN_STATUSES = ("pending", "ambiguous") +# Debited from the key but neither paid out nor restored, so still owed. +REFUND_UNRESOLVED_STATUSES = ("pending", "ambiguous", "stuck") + _REFUND_OPEN_PREDICATE = "status IN ('pending', 'ambiguous')" @@ -1003,8 +1006,18 @@ async def complete_routstr_fee_payout( async def total_user_liability(db_session: AsyncSession) -> int: - """Return all outstanding API-key balances in millisatoshis.""" - result = await db_session.exec(select(func.sum(ApiKey.balance))) + """Return all outstanding user funds in millisatoshis. + + Key balances and unresolved refunds are summed in one statement so a + claim opened between two reads cannot be missed by both. + """ + key_balances = select(func.coalesce(func.sum(ApiKey.balance), 0)).scalar_subquery() + unresolved_refunds = ( + select(func.coalesce(func.sum(Refund.amount_msats), 0)) + .where(col(Refund.status).in_(REFUND_UNRESOLVED_STATUSES)) + .scalar_subquery() + ) + result = await db_session.exec(select(key_balances + unresolved_refunds)) return int(result.one() or 0) diff --git a/routstr/refund.py b/routstr/refund.py index 028b3323..deeeb99b 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -4,9 +4,10 @@ import asyncio import time from typing import Any +import httpx from fastapi import HTTPException from sqlalchemy.exc import IntegrityError -from sqlmodel import col, select, update +from sqlmodel import col, func, select, update from .core.db import ( REFUND_OPEN_STATUSES, @@ -55,6 +56,10 @@ async def validate_lightning_destination(destination: str) -> None: raise HTTPException( status_code=400, detail=f"Invalid lightning destination: {e}" ) + except httpx.HTTPError as e: + raise HTTPException( + status_code=400, detail=f"Lightning destination unreachable: {e}" + ) async def open_claim( @@ -66,6 +71,14 @@ async def open_claim( ) -> Refund: """Zero the balance and insert the claim in one transaction.""" unit = refund_unit(key) + # created_at has second resolution; step past the previous claim so the + # newest claim for a key always sorts first. + latest = await session.exec( + select(func.max(col(Refund.created_at))).where( + Refund.api_key_hashed_key == key.hashed_key + ) + ) + created_at = max(int(time.time()), (latest.one() or 0) + 1) refund = Refund( api_key_hashed_key=key.hashed_key, method=method, @@ -74,6 +87,8 @@ async def open_claim( unit=unit, mint_url=refund_mint(key), claimed_at=int(time.time()), + created_at=created_at, + updated_at=created_at, ) debit = ( update(ApiKey) @@ -119,8 +134,11 @@ async def _close( return bool(result.rowcount) -async def record_quote(refund: Refund, quote_id: str) -> None: - """Store the melt quote before the melt is sent; raises if the claim closed.""" +async def record_quote(refund: Refund, quote_id: str, mint_url: str) -> None: + """Store the quote and its mint before the melt is sent; raises if the claim closed. + + Mint fallback can issue the quote on a different mint than the claim's. + """ async with create_session() as session: result = await session.exec( # type: ignore[call-overload] update(Refund) @@ -129,6 +147,7 @@ async def record_quote(refund: Refund, quote_id: str) -> None: # Renew the lease so the reconciler leaves the payout alone. .values( quote_id=quote_id, + mint_url=mint_url, claimed_at=int(time.time()), updated_at=int(time.time()), ) @@ -137,6 +156,7 @@ async def record_quote(refund: Refund, quote_id: str) -> None: if not result.rowcount: raise LNURLError("Refund claim closed before the melt was dispatched") refund.quote_id = quote_id + refund.mint_url = mint_url async def settle( @@ -196,18 +216,34 @@ async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> N await session.commit() -def refund_in_progress_error() -> HTTPException: - """The 409 raised when a key already has an in-flight refund claim.""" - return HTTPException( - status_code=409, - detail={ - "error": { - "message": "A refund for this key is already in progress.", - "type": "invalid_request_error", - "code": "refund_in_progress", - } - }, +def refund_in_progress_error(refund: Refund | None = None) -> HTTPException: + """The 409 raised when a key already has an unresolved refund claim.""" + stuck = refund is not None and refund.status == "stuck" + error: dict[str, str] = { + "message": ( + "A refund for this key is unresolved and requires operator reconciliation." + if stuck + else "A refund for this key is already in progress." + ), + "type": "invalid_request_error", + "code": "refund_unresolved" if stuck else "refund_in_progress", + } + if refund is not None: + error["refund_id"] = refund.id + error["status"] = refund.status + return HTTPException(status_code=409, detail={"error": error}) + + +async def _latest_with_status( + session: AsyncSession, key: ApiKey, statuses: tuple[str, ...] +) -> Refund | None: + result = await session.exec( + select(Refund) + .where(Refund.api_key_hashed_key == key.hashed_key) + .where(col(Refund.status).in_(statuses)) + .order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc()) ) + return result.first() async def latest_open(session: AsyncSession, key: ApiKey) -> Refund | None: @@ -216,13 +252,12 @@ async def latest_open(session: AsyncSession, key: ApiKey) -> Refund | None: An open claim means a prior refund already debited the balance and is still settling, so the balance reads as zero even though a refund is under way. """ - result = await session.exec( - select(Refund) - .where(Refund.api_key_hashed_key == key.hashed_key) - .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) - .order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc()) - ) - return result.first() + return await _latest_with_status(session, key, REFUND_OPEN_STATUSES) + + +async def latest_stuck(session: AsyncSession, key: ApiKey) -> Refund | None: + """Latest claim the reconciler gave up on; needs operator recovery.""" + return await _latest_with_status(session, key, ("stuck",)) async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None: @@ -232,13 +267,7 @@ async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None: collection and sweeping; the claim row is the fallback when that ledger write failed after the token was already issued. """ - result = await session.exec( - select(Refund) - .where(Refund.api_key_hashed_key == key.hashed_key) - .where(Refund.status == "paid") - .order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc()) - ) - return result.first() + return await _latest_with_status(session, key, ("paid",)) def describe(refund: Refund) -> dict[str, str]: @@ -255,12 +284,8 @@ def describe(refund: Refund) -> dict[str, str]: async def _pay_lightning(session: AsyncSession, refund: Refund) -> None: - quote_id: str | None = None - - async def capture_quote(quote: str) -> None: - nonlocal quote_id - quote_id = quote - await record_quote(refund, quote) + async def capture_quote(quote: str, mint_url: str) -> None: + await record_quote(refund, quote, mint_url) try: await send_to_lnurl( @@ -271,18 +296,18 @@ async def _pay_lightning(session: AsyncSession, refund: Refund) -> None: on_melt_quote=capture_quote, ) except MeltOutcomeAmbiguousError as e: - await hold(session, refund, quote_id) + await hold(session, refund, refund.quote_id) logger.error( "refund outcome ambiguous; balance withheld pending reconciliation", extra={ "refund_id": refund.id, "error": str(e), "key_hash": refund.api_key_hashed_key[:8], - "quote_id": quote_id, + "quote_id": refund.quote_id, }, ) raise - await settle(session, refund, quote_id=quote_id) + await settle(session, refund, quote_id=refund.quote_id) async def _pay_cashu(session: AsyncSession, refund: Refund) -> None: @@ -320,6 +345,39 @@ async def _record_cashu_payout(refund: Refund) -> None: ) +def unresolved_refund_error() -> HTTPException: + return HTTPException( + status_code=502, + detail=( + "Refund was dispatched but its outcome is unconfirmed; the " + "balance is withheld until reconciliation completes" + ), + ) + + +async def _abort(session: AsyncSession, refund: Refund) -> None: + """Fail the claim, or withhold it once a melt quote exists. + + A recorded quote means the mint may already have paid, so the balance + must not be restored. + """ + if refund.quote_id is None: + await release(session, refund) + return + await hold(session, refund, refund.quote_id) + logger.error( + "refund failed after its melt quote was recorded; balance withheld " + "pending reconciliation", + extra={ + "refund_id": refund.id, + "key_hash": refund.api_key_hashed_key[:8], + "quote_id": refund.quote_id, + "mint_url": refund.mint_url, + }, + ) + raise unresolved_refund_error() + + async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: try: if refund.method == "lightning": @@ -328,18 +386,12 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: await _pay_cashu(session, refund) except MeltOutcomeAmbiguousError: # Already held by _pay_lightning; releasing here would pay out twice. - raise HTTPException( - status_code=502, - detail=( - "Refund was dispatched but its outcome is unconfirmed; the " - "balance is withheld until reconciliation completes" - ), - ) + raise unresolved_refund_error() except HTTPException: - await release(session, refund) + await _abort(session, refund) raise except Exception as e: - await release(session, refund) + await _abort(session, refund) logger.error( "refund payout failed", extra={ diff --git a/routstr/wallet.py b/routstr/wallet.py index e8b643bf..beecde99 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1993,14 +1993,25 @@ async def periodic_routstr_fee_payout() -> None: ) +def _quote_callback( + notify: Callable[[str, str], Awaitable[None]], mint: str +) -> Callable[[str], Awaitable[None]]: + async def callback(quote_id: str) -> None: + await notify(quote_id, mint) + + return callback + + async def send_to_lnurl( amount: int, unit: str, mint: str, address: str, *, - on_melt_quote: Callable[[str], Awaitable[None]] | None = None, + on_melt_quote: Callable[[str, str], Awaitable[None]] | None = None, ) -> int: + """``on_melt_quote`` gets the quote id and the mint that issued it, since + fallback may pick a different mint than requested.""" async with wallet_operation_guard(): mint = await find_trusted_mint_with_funds(amount, unit, mint, force_reload=True) wallet = await get_wallet(mint, unit) @@ -2014,7 +2025,9 @@ async def send_to_lnurl( address, unit, amount=amount, - on_melt_quote=on_melt_quote, + on_melt_quote=( + None if on_melt_quote is None else _quote_callback(on_melt_quote, mint) + ), ) diff --git a/scripts/refund_token_to_lightning.py b/scripts/refund_token_to_lightning.py index df5979d3..7a7d0dae 100644 --- a/scripts/refund_token_to_lightning.py +++ b/scripts/refund_token_to_lightning.py @@ -13,11 +13,35 @@ API key to check whether it settled. """ import argparse +import ipaddress import sys +from urllib.parse import urlparse import httpx +def _is_loopback(host: str) -> bool: + if host == "localhost": + return True + try: + return ipaddress.ip_address(host.strip("[]")).is_loopback + except ValueError: + return False + + +def check_url(url: str) -> str: + """Reject a URL that would put the token and the API key on the wire.""" + parsed = urlparse(url) + if parsed.scheme == "https": + return url + if parsed.scheme == "http" and _is_loopback(parsed.hostname or ""): + return url + raise SystemExit( + f"Refusing to send a cashu token and bearer key to {url!r}: " + "use https, or http only for a loopback host." + ) + + def create_balance(client: httpx.Client, token: str) -> str: response = client.post("/v1/balance/create", json={"initial_balance_token": token}) response.raise_for_status() @@ -45,7 +69,7 @@ def main() -> None: parser.add_argument("--url", default="http://localhost:8000", help="routstr URL") args = parser.parse_args() - with httpx.Client(base_url=args.url, timeout=120.0) as client: + with httpx.Client(base_url=check_url(args.url), timeout=120.0) as client: api_key = ( args.token if args.token.startswith("sk-") diff --git a/tests/integration/test_refund_claims.py b/tests/integration/test_refund_claims.py index aa8a4a32..93edb854 100644 --- a/tests/integration/test_refund_claims.py +++ b/tests/integration/test_refund_claims.py @@ -9,13 +9,21 @@ import time from typing import Any, Awaitable, Callable from unittest.mock import AsyncMock, patch +import httpx import pytest from fastapi import HTTPException from sqlmodel import select from routstr import refund from routstr.balance import RefundRequest, refund_wallet_endpoint -from routstr.core.db import ApiKey, AsyncSession, Refund +from routstr.core.db import ( + ApiKey, + AsyncSession, + CashuTransaction, + Refund, + store_cashu_transaction_with_retry, + total_user_liability, +) from routstr.payment.lnurl import LNURLError, MeltOutcomeAmbiguousError KEY_HASH = "refundclaimkey" @@ -170,6 +178,8 @@ async def test_release_after_settle_is_a_noop( def _lnurl_stub( outcome: BaseException | None = None, + *, + quoted: bool = True, ) -> Callable[..., Awaitable[int]]: async def send( amount: int, @@ -177,10 +187,10 @@ def _lnurl_stub( mint: str, address: str, *, - on_melt_quote: Callable[[str], Awaitable[None]] | None = None, + on_melt_quote: Callable[[str, str], Awaitable[None]] | None = None, ) -> int: - if on_melt_quote is not None: - await on_melt_quote("quote-123") + if quoted and on_melt_quote is not None: + await on_melt_quote("quote-123", mint) if outcome is not None: raise outcome return amount @@ -199,7 +209,7 @@ async def test_execute_persists_quote_before_melt_and_settles( seen: list[str | None] = [] async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int: - await on_melt_quote("quote-123") + await on_melt_quote("quote-123", claim.mint_url) row = await _load_refund(integration_session, claim.id) seen.append(row.quote_id) return 5000 @@ -247,7 +257,10 @@ async def test_execute_clean_failure_restores_balance( claim = await refund.open_claim( integration_session, key, method="lightning", destination=ADDRESS ) - with patch("routstr.refund.send_to_lnurl", _lnurl_stub(LNURLError("limits"))): + with patch( + "routstr.refund.send_to_lnurl", + _lnurl_stub(LNURLError("limits"), quoted=False), + ): with pytest.raises(HTTPException) as exc_info: await refund.execute(integration_session, claim) @@ -257,6 +270,52 @@ async def test_execute_clean_failure_restores_balance( assert (await _load_key(integration_session)).balance == BALANCE_MSATS +@pytest.mark.asyncio +async def test_execute_failure_after_quote_withholds_balance( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """The mint may have paid the quote, so a later local failure must not restore.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + with patch("routstr.refund.send_to_lnurl", _lnurl_stub(RuntimeError("local"))): + with pytest.raises(HTTPException) as exc_info: + await refund.execute(integration_session, claim) + + assert exc_info.value.status_code == 502 + row = await _load_refund(integration_session, claim.id) + assert (row.status, row.quote_id) == ("ambiguous", "quote-123") + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_execute_records_mint_that_issued_the_quote( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """Mint fallback must leave reconciliation pointed at the issuing mint.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + fallback_mint = "https://fallback.mint.example" + + async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int: + await on_melt_quote("quote-fallback", fallback_mint) + raise MeltOutcomeAmbiguousError("unknown") + + with patch("routstr.refund.send_to_lnurl", send): + with pytest.raises(MeltOutcomeAmbiguousError): + await refund._pay_lightning(integration_session, claim) + + row = await _load_refund(integration_session, claim.id) + assert (row.mint_url, row.quote_id, row.status) == ( + fallback_mint, + "quote-fallback", + "ambiguous", + ) + + @pytest.mark.asyncio async def test_execute_aborts_melt_when_claim_was_released( integration_engine: Any, integration_session: AsyncSession, patched_db_engine: None @@ -272,7 +331,7 @@ async def test_execute_aborts_melt_when_claim_was_released( nonlocal melted async with AsyncSession(integration_engine, expire_on_commit=False) as other: await refund.release(other, await _load_refund(other, claim.id)) - await on_melt_quote("quote-123") + await on_melt_quote("quote-123", claim.mint_url) melted = True return 5000 @@ -383,7 +442,7 @@ async def test_reconcile_keeps_claim_that_gained_quote_mid_pass( async def lease_then_quote(refund_id: str, now: int, cutoff: int) -> bool: leased = await real_lease(refund_id, now, cutoff) - await refund.record_quote(claim, "late-quote") + await refund.record_quote(claim, "late-quote", claim.mint_url) return leased with patch("routstr.refund._lease", lease_then_quote): @@ -438,7 +497,7 @@ async def test_reconcile_queries_mint_for_crashed_claim_with_quote( claim = await refund.open_claim( integration_session, key, method="lightning", destination=ADDRESS ) - await refund.record_quote(claim, "quote-crash") + await refund.record_quote(claim, "quote-crash", claim.mint_url) await _age_claim(integration_session, claim.id, 600) with patch( "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="paid") @@ -642,3 +701,185 @@ async def test_cashu_token_survives_failed_ledger_write( assert isinstance(second, dict) assert second["refund_id"] == first["refund_id"] assert second["token"] == "cashuAtoken" + + +async def _topup(session: AsyncSession, amount: int = BALANCE_MSATS) -> None: + key = await _load_key(session) + key.balance = amount + session.add(key) + await session.commit() + + +async def _refund_cashu( + session: AsyncSession, token: str, *, ledger: bool +) -> dict[str, str]: + store = ( + AsyncMock(side_effect=RuntimeError("db down")) + if not ledger + else store_cashu_transaction_with_retry + ) + with ( + patch("routstr.refund.send_token", AsyncMock(return_value=token)), + patch("routstr.refund.token_mint_url", lambda t, mint: mint), + patch("routstr.refund.store_cashu_transaction", store), + ): + body = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=session, + ) + assert isinstance(body, dict) + return body + + +@pytest.mark.asyncio +async def test_replay_prefers_the_token_of_the_newest_paid_claim( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """An older ledger row must not answer for a newer claim whose write failed.""" + await _seed_key(integration_session) + await _refund_cashu(integration_session, "cashuAold", ledger=True) + await _topup(integration_session) + newest = await _refund_cashu(integration_session, "cashuAnew", ledger=False) + + replay = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(replay, dict) + assert replay["token"] == "cashuAnew" + assert replay["refund_id"] == newest["refund_id"] + + +@pytest.mark.asyncio +async def test_swept_older_ledger_row_does_not_reject_the_newest_claim( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + await _seed_key(integration_session) + await _refund_cashu(integration_session, "cashuAold", ledger=True) + await _topup(integration_session) + await _refund_cashu(integration_session, "cashuAnew", ledger=False) + + result = await integration_session.exec( + select(CashuTransaction).where(CashuTransaction.token == "cashuAold") + ) + old_tx = result.one() + old_tx.swept = True + integration_session.add(old_tx) + await integration_session.commit() + + replay = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(replay, dict) + assert replay["token"] == "cashuAnew" + + +@pytest.mark.asyncio +async def test_open_claim_is_reported_over_an_older_paid_claim( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """A paid claim from a previous cycle must not be replayed as the outcome + of the claim that is still settling.""" + await _seed_key(integration_session, address=ADDRESS) + with patch("routstr.refund.send_to_lnurl", _lnurl_stub()): + paid = await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(paid, dict) + await _topup(integration_session) + key = await _load_key(integration_session) + open_claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + await refund.hold(integration_session, open_claim, "quote-open") + + validate = AsyncMock() + with patch("routstr.refund.get_lnurl_data", validate): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + refund_request=RefundRequest(lightning_address=ADDRESS), + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + + assert exc_info.value.status_code == 409 + assert isinstance(exc_info.value.detail, dict) + error = exc_info.value.detail["error"] + assert (error["refund_id"], error["status"]) == (open_claim.id, "ambiguous") + # A drained key must not be able to drive outbound destination lookups. + validate.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_stuck_claim_is_reported_instead_of_no_balance( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + await refund._close(integration_session, claim, status="stuck") + await integration_session.commit() + + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert exc_info.value.status_code == 409 + assert isinstance(exc_info.value.detail, dict) + error = exc_info.value.detail["error"] + assert (error["code"], error["refund_id"]) == ("refund_unresolved", claim.id) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("status", "counted"), + [ + ("pending", True), + ("ambiguous", True), + ("stuck", True), + ("paid", False), + ("failed", False), + ], +) +async def test_liability_covers_claims_until_they_resolve( + integration_session: AsyncSession, + patched_db_engine: None, + status: str, + counted: bool, +) -> None: + """Money in flight is still owed to the customer; an owner payout that read + only key balances could spend its backing.""" + key = await _seed_key(integration_session) + before = await total_user_liability(integration_session) + assert before == BALANCE_MSATS + + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + assert await total_user_liability(integration_session) == BALANCE_MSATS + + await refund._close(integration_session, claim, status=status) + await integration_session.commit() + expected = BALANCE_MSATS if counted else 0 + assert await total_user_liability(integration_session) == expected + + +@pytest.mark.asyncio +async def test_unreachable_destination_is_a_client_error() -> None: + with patch( + "routstr.refund.get_lnurl_data", + AsyncMock(side_effect=httpx.ConnectError("All connection attempts failed")), + ): + with pytest.raises(HTTPException) as exc_info: + await refund.validate_lightning_destination(ADDRESS) + assert exc_info.value.status_code == 400 diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index cc970d40..9381753d 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -37,6 +37,10 @@ def _exec_result(tx: CashuTransaction | None) -> MagicMock: def _update_result(rowcount: int) -> MagicMock: result = MagicMock() result.rowcount = rowcount + # Claim and ledger lookups share this stubbed session; an empty row set + # means the key has no prior refund to replay, report, or order after. + result.first.return_value = None + result.one.return_value = None return result @@ -276,6 +280,7 @@ async def test_apikey_refund_returns_persisted_token_after_cache_loss() -> None: session.rollback = AsyncMock() with ( + patch("routstr.refund.latest_open", AsyncMock(return_value=None)), patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)), patch("routstr.refund.send_token", AsyncMock()) as mock_send_token, ): @@ -314,7 +319,10 @@ async def test_apikey_refund_rejects_persisted_token_after_sweep() -> None: session.commit = AsyncMock() session.rollback = AsyncMock() - with patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)): + with ( + patch("routstr.refund.latest_open", AsyncMock(return_value=None)), + patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)), + ): with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( authorization="Bearer sk-testhash", @@ -494,10 +502,8 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: # First exec call = debit (succeeds), second = restore session = MagicMock() session.get = AsyncMock(return_value=key) - # debit, then the claim close and the balance restore - session.exec = AsyncMock( - side_effect=[_update_result(1), _update_result(1), _update_result(1)] - ) + # claim lookup, claim ordering, debit, claim close, balance restore + session.exec = AsyncMock(side_effect=[_update_result(1)] * 5) session.commit = AsyncMock() session.rollback = AsyncMock() @@ -519,8 +525,8 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: assert exc_info.value.status_code == 503 assert exc_info.value.detail == "Mint service unavailable" assert "raw mint outage detail" not in exc_info.value.detail - # debit, claim close, balance restore - assert session.exec.await_count == 3 + # claim lookup, claim ordering, debit, claim close, balance restore + assert session.exec.await_count == 5 @pytest.mark.asyncio @@ -533,10 +539,8 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None: session = MagicMock() session.get = AsyncMock(return_value=key) - # debit, then the claim close and the balance restore - session.exec = AsyncMock( - side_effect=[_update_result(1), _update_result(1), _update_result(1)] - ) + # claim lookup, claim ordering, debit, claim close, balance restore + session.exec = AsyncMock(side_effect=[_update_result(1)] * 5) session.commit = AsyncMock() session.rollback = AsyncMock() @@ -557,7 +561,7 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None: assert exc_info.value.status_code == 500 assert exc_info.value.detail == "Refund failed" assert raw_error not in exc_info.value.detail - assert session.exec.await_count == 3 + assert session.exec.await_count == 5 # --------------------------------------------------------------------------- @@ -878,7 +882,7 @@ async def test_apikey_refund_ambiguous_melt_does_not_restore_balance() -> None: session = MagicMock() session.get = AsyncMock(return_value=key) - session.exec = AsyncMock(return_value=MagicMock(rowcount=1)) + session.exec = AsyncMock(return_value=_update_result(1)) session.commit = AsyncMock() session.rollback = AsyncMock() @@ -909,7 +913,7 @@ async def test_apikey_refund_clean_failure_still_restores_balance() -> None: session = MagicMock() session.get = AsyncMock(return_value=key) - session.exec = AsyncMock(return_value=MagicMock(rowcount=1)) + session.exec = AsyncMock(return_value=_update_result(1)) session.commit = AsyncMock() session.rollback = AsyncMock() diff --git a/tests/unit/test_refund_script_url_policy.py b/tests/unit/test_refund_script_url_policy.py new file mode 100644 index 00000000..2ca8d502 --- /dev/null +++ b/tests/unit/test_refund_script_url_policy.py @@ -0,0 +1,49 @@ +"""The refund helper puts a cashu token and a bearer key on the wire, so it +must not speak cleartext to a remote host.""" + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +import pytest + +SCRIPT = ( + Path(__file__).resolve().parents[2] / "scripts" / "refund_token_to_lightning.py" +) + + +def _load() -> ModuleType: + spec = importlib.util.spec_from_file_location("refund_token_to_lightning", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + "url", + [ + "https://node.example.com", + "http://localhost:8000", + "http://127.0.0.1:8000", + "http://[::1]:8000", + ], +) +def test_accepts_https_and_loopback_http(url: str) -> None: + assert _load().check_url(url) == url + + +@pytest.mark.parametrize( + "url", + [ + "http://node.example.com", + "http://192.168.1.10:8000", + "ftp://node.example.com", + "node.example.com", + ], +) +def test_rejects_remote_cleartext_and_other_schemes(url: str) -> None: + with pytest.raises(SystemExit): + _load().check_url(url) From 4d8b3334835e71a9019cb984120c6737a32db214 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 17 Sep 2026 22:54:22 +0200 Subject: [PATCH 7/8] fix: preserve refund payouts through recovery failures --- docs/api/endpoints.md | 7 +- routstr/balance.py | 5 +- routstr/payment/lnurl.py | 14 +- routstr/refund.py | 122 ++++-- tests/integration/test_refund_claims.py | 10 +- .../test_refund_payout_recovery.py | 368 ++++++++++++++++++ tests/integration/test_wallet_refund.py | 7 +- tests/unit/test_balance.py | 28 +- 8 files changed, 521 insertions(+), 40 deletions(-) create mode 100644 tests/integration/test_refund_payout_recovery.py diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 43d13705..8e524c72 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -510,7 +510,7 @@ Content-Type: application/json | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| -| `lightning_address` | string | No | Key's stored refund address | Lightning address or LNURL to pay. Overrides the stored address for this request. Resolved only for a request that can open a new claim, before any balance is debited. | +| `lightning_address` | string | No | Key's stored refund address | Lightning address or LNURL to pay. Overrides the stored address for this request. The effective address (request or stored) is resolved only for a request that can open a new claim, before any balance is debited. | **Response (Lightning):** @@ -540,7 +540,7 @@ The amount field is `sats` or `msats` depending on the key's refund currency. It - The balance is debited and a refund claim is recorded before the payout is attempted. A key has at most one open claim at a time. - If the payout fails cleanly, the claim is closed and the balance is restored. Retry the request. -- Once a melt quote has been recorded the mint may already have paid it, so any later failure returns `502` and withholds the balance rather than restoring it. +- Once a melt quote has been recorded or a Cashu token has been issued, the payout may already have happened, so any later failure returns `502` and withholds the balance rather than restoring it. The exception is the mint answering the melt itself with `unpaid`: that is proof nothing was sent, so the balance is restored at once and the request returns `503`. - If the Lightning payment is dispatched but the mint cannot confirm the outcome, the request returns `502`, the balance stays withheld, and a background reconciler asks the mint until it answers. The balance is restored if the mint reports the payment unpaid. - An unresolved claim is reported before any replay: a request on a key with an open claim returns `409` with that claim's `refund_id` and `status`. - Calling again on a zero-balance key with no open claim returns the last paid Lightning refund, or the Cashu token issued by the last paid claim while it remains uncollected. @@ -556,8 +556,9 @@ The amount field is `sats` or `msats` depending on the key's refund currency. It | `409` | `refund_in_progress`: another refund claim for this key is still open. The body carries its `refund_id` and `status` | | `409` | `refund_unresolved`: a claim for this key is `stuck` and needs operator reconciliation | | `410` | Previously issued Cashu refund token has been swept | +| `500` | Payout failed before anything was dispatched. Balance restored. Retry. | | `502` | Payment dispatched, outcome unconfirmed. Balance withheld pending reconciliation. Do not retry. | -| `503` | Mint unavailable. Balance restored. Retry later. | +| `503` | Mint unavailable, or the mint reported the Lightning payment unpaid. Balance restored. Retry later. | **X-Cashu refunds:** diff --git a/routstr/balance.py b/routstr/balance.py index 09956faf..f887f5ae 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -406,9 +406,10 @@ async def refund_wallet_endpoint( raise HTTPException(status_code=400, detail="No balance to refund") requested = refund_request.lightning_address if refund_request else None - if requested: - await refund.validate_lightning_destination(requested) destination = requested or key.refund_address + if destination: + # Stored addresses can rot too; reject before any balance is debited. + await refund.validate_lightning_destination(destination) claim = await refund.open_claim( session, diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py index 9201bca7..caf551db 100644 --- a/routstr/payment/lnurl.py +++ b/routstr/payment/lnurl.py @@ -50,6 +50,14 @@ class MeltOutcomeAmbiguousError(LNURLError): """ +class MeltUnpaidError(LNURLError): + """The mint answered the melt request itself with ``unpaid``. + + Unlike :class:`MeltOutcomeAmbiguousError` this is proof that no Lightning + payment was made, so callers may restore what they debited. + """ + + _MAX_LNURL_REDIRECTS = 3 _MAX_LNURL_RESPONSE_BYTES = 64 * 1024 _NON_PUBLIC_HOST_SUFFIXES = (".localhost", ".local", ".internal") @@ -97,9 +105,7 @@ async def _require_public_https_destination(url: httpx.URL) -> None: try: resolved = ipaddress.ip_address(info[4][0]) except ValueError as e: - raise LNURLError( - "LNURL destination resolved to an invalid address" - ) from e + raise LNURLError("LNURL destination resolved to an invalid address") from e if not resolved.is_global: raise LNURLError("LNURL destination is not a public host") @@ -446,7 +452,7 @@ async def raw_send_to_lnurl( return final_amount if melt_state == MeltQuoteState.unpaid: await wallet.set_reserved_for_send(proofs, reserved=False) - raise LNURLError("Cashu mint confirmed that the melt was unpaid") + raise MeltUnpaidError("Cashu mint confirmed that the melt was unpaid") try: quote = await run_mint_operation( diff --git a/routstr/refund.py b/routstr/refund.py index deeeb99b..3fe637b9 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -11,6 +11,7 @@ from sqlmodel import col, func, select, update from .core.db import ( REFUND_OPEN_STATUSES, + REFUND_UNRESOLVED_STATUSES, ApiKey, AsyncSession, Refund, @@ -21,7 +22,12 @@ from .core.db import ( ) from .core.logging import get_logger from .core.settings import settings -from .payment.lnurl import LNURLError, MeltOutcomeAmbiguousError, get_lnurl_data +from .payment.lnurl import ( + LNURLError, + MeltOutcomeAmbiguousError, + MeltUnpaidError, + get_lnurl_data, +) from .wallet import ( check_bolt11_payment_status, is_mint_connection_error, @@ -118,22 +124,38 @@ async def _close( refund: Refund, *, require_no_quote: bool = False, + require_no_token: bool = False, + from_statuses: tuple[str, ...] = REFUND_OPEN_STATUSES, **values: object, ) -> bool: stmt = ( update(Refund) .where(col(Refund.id) == refund.id) - .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .where(col(Refund.status).in_(from_statuses)) ) if require_no_quote: # A quote recorded since the row was read means a melt may be in flight. stmt = stmt.where(col(Refund.quote_id).is_(None)) + if require_no_token: + stmt = stmt.where(col(Refund.token).is_(None)) result = await session.exec( # type: ignore[call-overload] stmt.values(claimed_at=None, updated_at=int(time.time()), **values) ) return bool(result.rowcount) +async def renew_lease(refund: Refund) -> None: + """Push the reconciler lease forward before a slow mint step.""" + async with create_session() as session: + await session.exec( # type: ignore[call-overload] + update(Refund) + .where(col(Refund.id) == refund.id) + .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .values(claimed_at=int(time.time())) + ) + await session.commit() + + async def record_quote(refund: Refund, quote_id: str, mint_url: str) -> None: """Store the quote and its mint before the melt is sent; raises if the claim closed. @@ -174,7 +196,11 @@ async def settle( values["token"] = token if mint_url is not None: values["mint_url"] = mint_url - settled = await _close(session, refund, **values) + # The payout side knows the money moved, so a claim the reconciler gave up + # on (stuck) is closed as paid too. + settled = await _close( + session, refund, from_statuses=REFUND_UNRESOLVED_STATUSES, **values + ) await session.commit() if not settled: logger.warning( @@ -211,8 +237,19 @@ async def release( return True -async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> None: - await _close(session, refund, status="ambiguous", quote_id=quote_id) +async def hold( + session: AsyncSession, + refund: Refund, + quote_id: str | None, + *, + token: str | None = None, +) -> None: + """Withhold the balance; the quote or token names what the mint may have paid.""" + values: dict[str, Any] = {"status": "ambiguous", "quote_id": quote_id} + if token is not None: + values["token"] = token + values["mint_url"] = refund.mint_url + await _close(session, refund, **values) await session.commit() @@ -283,7 +320,7 @@ def describe(refund: Refund) -> dict[str, str]: return body -async def _pay_lightning(session: AsyncSession, refund: Refund) -> None: +async def _pay_lightning(session: AsyncSession, refund: Refund) -> bool: async def capture_quote(quote: str, mint_url: str) -> None: await record_quote(refund, quote, mint_url) @@ -307,16 +344,18 @@ async def _pay_lightning(session: AsyncSession, refund: Refund) -> None: }, ) raise - await settle(session, refund, quote_id=refund.quote_id) + return await settle(session, refund, quote_id=refund.quote_id) -async def _pay_cashu(session: AsyncSession, refund: Refund) -> None: +async def _pay_cashu(session: AsyncSession, refund: Refund) -> bool: amount = amount_in_unit(refund.amount_msats, refund.unit) + await renew_lease(refund) token = await send_token(amount, refund.unit, refund.mint_url) - mint_url = token_mint_url(token, refund.mint_url) - await settle(session, refund, token=token, mint_url=mint_url) + # From here the token is bearer money: keep it on the claim so a failed + # settle withholds the balance instead of restoring it. refund.token = token - refund.mint_url = mint_url + refund.mint_url = token_mint_url(token, refund.mint_url) + return await settle(session, refund, token=token, mint_url=refund.mint_url) async def _record_cashu_payout(refund: Refund) -> None: @@ -356,22 +395,23 @@ def unresolved_refund_error() -> HTTPException: async def _abort(session: AsyncSession, refund: Refund) -> None: - """Fail the claim, or withhold it once a melt quote exists. + """Fail the claim, or withhold it once a melt quote or token exists. - A recorded quote means the mint may already have paid, so the balance - must not be restored. + A recorded quote means the mint may already have paid; an issued token is + already bearer money. In both cases the balance must not be restored. """ - if refund.quote_id is None: + if refund.quote_id is None and refund.token is None: await release(session, refund) return - await hold(session, refund, refund.quote_id) + await hold(session, refund, refund.quote_id, token=refund.token) logger.error( - "refund failed after its melt quote was recorded; balance withheld " + "refund failed after its payout was dispatched; balance withheld " "pending reconciliation", extra={ "refund_id": refund.id, "key_hash": refund.api_key_hashed_key[:8], "quote_id": refund.quote_id, + "has_token": refund.token is not None, "mint_url": refund.mint_url, }, ) @@ -379,18 +419,41 @@ async def _abort(session: AsyncSession, refund: Refund) -> None: async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: + attached_refund = refund + # Keep payout evidence outside the identity map: a failed flush/commit can + # expire attached attributes, including the only copy of an issued token. + refund = Refund(**refund.model_dump()) try: if refund.method == "lightning": - await _pay_lightning(session, refund) + settled = await _pay_lightning(session, refund) else: - await _pay_cashu(session, refund) + settled = await _pay_cashu(session, refund) except MeltOutcomeAmbiguousError: # Already held by _pay_lightning; releasing here would pay out twice. raise unresolved_refund_error() + except MeltUnpaidError as e: + # The mint answered the melt itself with unpaid: proof that nothing was + # sent, so the balance goes back now rather than after reconciliation. + await release(session, refund) + logger.warning( + "refund melt unpaid at the mint; balance restored", + extra={ + "refund_id": refund.id, + "error": str(e), + "key_hash": refund.api_key_hashed_key[:8], + "quote_id": refund.quote_id, + }, + ) + raise HTTPException( + status_code=503, + detail="Lightning payment failed at the mint; balance restored. Retry later.", + ) except HTTPException: + await session.rollback() await _abort(session, refund) raise except Exception as e: + await session.rollback() await _abort(session, refund) logger.error( "refund payout failed", @@ -410,8 +473,13 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: if refund.method == "cashu": await _record_cashu_payout(refund) - refund.status = "paid" - refund.claimed_at = None + if settled: + refund.status = "paid" + refund.claimed_at = None + else: + # Report the row as it stands rather than a status that was not written. + await session.refresh(attached_refund) + refund = attached_refund logger.info( "refund paid", extra={ @@ -442,9 +510,14 @@ async def _lease(refund_id: str, now: int, lease_cutoff: int) -> bool: async def _reconcile(refund: Refund, now: int) -> None: if refund.method != "lightning": + if refund.token is not None: + # The token was issued and kept on the claim; the payout is done. + async with create_session() as session: + await settle(session, refund) + return # No quote to query for cashu; withhold the balance and alert once. async with create_session() as session: - if await _close(session, refund, status="stuck"): + if await _close(session, refund, require_no_token=True, status="stuck"): await session.commit() logger.critical( "cashu refund stuck; balance withheld, manual reconciliation required", @@ -454,6 +527,11 @@ async def _reconcile(refund: Refund, now: int) -> None: "amount_msats": refund.amount_msats, }, ) + else: + await session.commit() + current = await session.get(Refund, refund.id) + if current is not None and current.token is not None: + await settle(session, current) return if refund.quote_id is None: diff --git a/tests/integration/test_refund_claims.py b/tests/integration/test_refund_claims.py index 93edb854..cda5735e 100644 --- a/tests/integration/test_refund_claims.py +++ b/tests/integration/test_refund_claims.py @@ -599,7 +599,10 @@ async def test_endpoint_replays_paid_lightning_refund_on_empty_balance( integration_session: AsyncSession, patched_db_engine: None ) -> None: await _seed_key(integration_session, address=ADDRESS) - with patch("routstr.refund.send_to_lnurl", _lnurl_stub()): + with ( + patch("routstr.refund.get_lnurl_data", AsyncMock()), + patch("routstr.refund.send_to_lnurl", _lnurl_stub()), + ): first = await refund_wallet_endpoint( authorization=f"Bearer sk-{KEY_HASH}", x_cashu=None, @@ -785,7 +788,10 @@ async def test_open_claim_is_reported_over_an_older_paid_claim( """A paid claim from a previous cycle must not be replayed as the outcome of the claim that is still settling.""" await _seed_key(integration_session, address=ADDRESS) - with patch("routstr.refund.send_to_lnurl", _lnurl_stub()): + with ( + patch("routstr.refund.get_lnurl_data", AsyncMock()), + patch("routstr.refund.send_to_lnurl", _lnurl_stub()), + ): paid = await refund_wallet_endpoint( authorization=f"Bearer sk-{KEY_HASH}", x_cashu=None, diff --git a/tests/integration/test_refund_payout_recovery.py b/tests/integration/test_refund_payout_recovery.py new file mode 100644 index 00000000..fb4d37bc --- /dev/null +++ b/tests/integration/test_refund_payout_recovery.py @@ -0,0 +1,368 @@ +"""Refund payouts that finish after the claim row stopped cooperating. + +Each test pins one guarantee of the claim table that the happy path cannot +exercise: the payout side has authoritative knowledge of what the mint did, +and the claim row must end up agreeing with it. +""" + +import time +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException +from sqlalchemy import event +from sqlalchemy.exc import OperationalError + +from routstr import refund +from routstr.balance import RefundRequest, refund_wallet_endpoint +from routstr.core.db import ApiKey, AsyncSession, Refund, total_user_liability +from routstr.payment.lnurl import MeltUnpaidError + +KEY_HASH = "refundrecoverykey" +ADDRESS = "user@ln.example.com" +BALANCE_MSATS = 5_000_000 + + +async def _seed_key(session: AsyncSession, *, address: str | None = None) -> ApiKey: + key = ApiKey(hashed_key=KEY_HASH) + key.balance = BALANCE_MSATS + key.reserved_balance = 0 + key.refund_currency = "sat" + key.refund_address = address + key.total_spent = 0 + key.total_requests = 0 + session.add(key) + await session.commit() + await session.refresh(key) + return key + + +async def _load_key(session: AsyncSession) -> ApiKey: + key = await session.get(ApiKey, KEY_HASH) + assert key is not None + await session.refresh(key) + return key + + +async def _load_refund(session: AsyncSession, refund_id: str) -> Refund: + row = await session.get(Refund, refund_id) + assert row is not None + await session.refresh(row) + return row + + +def _cashu_payout(token: str, send: Any | None = None) -> Any: + return ( + patch("routstr.refund.send_token", send or AsyncMock(return_value=token)), + patch("routstr.refund.token_mint_url", lambda t, mint: mint), + patch("routstr.refund.store_cashu_transaction", AsyncMock()), + ) + + +# --- cashu token issued after the reconciler gave up ----------------------- + + +@pytest.mark.asyncio +async def test_cashu_token_issued_after_reconciler_marked_claim_stuck_settles_it( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """A token in the customer's hands must leave its claim ``paid``: a row + left ``stuck`` keeps the amount in liability forever and tells the operator + to reconcile a payout that already happened.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + + async def slow_send_token(amount: int, unit: str, mint_url: str) -> str: + # The lease lapses while the mint is still working. + row = await _load_refund(integration_session, claim.id) + row.claimed_at = (row.claimed_at or 0) - 10_000 + integration_session.add(row) + await integration_session.commit() + await refund.reconcile_once() + assert (await _load_refund(integration_session, claim.id)).status == "stuck" + return "cashuAlate" + + send, mint, store = _cashu_payout("cashuAlate", slow_send_token) + with send, mint, store: + body = await refund.execute(integration_session, claim) + + assert (body["token"], body["status"]) == ("cashuAlate", "paid") + row = await _load_refund(integration_session, claim.id) + assert (row.status, row.token, row.claimed_at) == ("paid", "cashuAlate", None) + assert await total_user_liability(integration_session) == 0 + + +@pytest.mark.asyncio +async def test_cashu_payout_renews_lease_before_asking_the_mint( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """The reconciler leaves a claim alone while its lease is fresh, so the + payout renews it right before the slow step.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + row = await _load_refund(integration_session, claim.id) + row.claimed_at = (row.claimed_at or 0) - 10_000 + integration_session.add(row) + await integration_session.commit() + + async def send_token(amount: int, unit: str, mint_url: str) -> str: + await refund.reconcile_once() + return "cashuAfresh" + + send, mint, store = _cashu_payout("cashuAfresh", send_token) + with send, mint, store, patch("routstr.refund.logger") as log: + await refund.execute(integration_session, claim) + + log.critical.assert_not_called() + assert (await _load_refund(integration_session, claim.id)).status == "paid" + + +# --- cashu token issued, claim write failed -------------------------------- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure_point", ["execute", "autoflush", "commit"]) +async def test_cashu_claim_write_failure_after_token_creation_withholds_balance( + integration_session: AsyncSession, + patched_db_engine: None, + integration_engine: Any, + failure_point: str, +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + claim_id = claim.id + fired = False + armed = False + + def fail_once(*args: Any) -> None: + nonlocal fired + statement = args[2] if failure_point != "commit" else "COMMIT" + if ( + armed + and not fired + and (statement.startswith("UPDATE refunds") or statement == "COMMIT") + ): + fired = True + raise OperationalError(statement, {}, Exception("database is locked")) + + async def send_token(amount: int, unit: str, mint_url: str) -> str: + nonlocal armed + if failure_point == "autoflush": + # A pending ORM write makes SQLAlchemy invalidate the transaction + # and expire attached objects when the actual SQL execution fails. + claim.updated_at -= 1 + armed = True + return "cashuAstranded" + + event_name = "commit" if failure_point == "commit" else "before_cursor_execute" + event.listen(integration_engine.sync_engine, event_name, fail_once) + send, _, store = _cashu_payout("cashuAstranded", send_token) + try: + with ( + send, + store, + patch( + "routstr.refund.token_mint_url", return_value="https://fallback.mint" + ), + ): + with pytest.raises(HTTPException) as exc_info: + await refund.execute(integration_session, claim) + finally: + event.remove(integration_engine.sync_engine, event_name, fail_once) + + assert fired + assert exc_info.value.status_code == 502 + row = await _load_refund(integration_session, claim_id) + assert (row.status, row.token, row.mint_url) == ( + "ambiguous", + "cashuAstranded", + "https://fallback.mint", + ) + assert (await _load_key(integration_session)).balance == 0 + assert await total_user_liability(integration_session) == BALANCE_MSATS + + await refund.reconcile_once() + row = await _load_refund(integration_session, claim_id) + assert (row.status, row.token) == ("paid", "cashuAstranded") + assert await total_user_liability(integration_session) == 0 + with patch("routstr.refund.send_token", AsyncMock()) as send_again: + replay = await refund_wallet_endpoint( + refund_request=RefundRequest(), + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(replay, dict) + assert replay["token"] == "cashuAstranded" + send_again.assert_not_awaited() + assert (await _load_key(integration_session)).balance == 0 + + +@pytest.mark.asyncio +async def test_reconciler_settles_held_cashu_claim_that_carries_a_token( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """A held cashu claim whose row carries the token is a completed payout; + the reconciler closes it as paid instead of escalating it to stuck.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + await refund.hold(integration_session, claim, None, token="cashuAheld") + row = await _load_refund(integration_session, claim.id) + row.claimed_at = None + row.updated_at -= 10_000 + integration_session.add(row) + await integration_session.commit() + + with patch("routstr.refund.logger") as log: + await refund.reconcile_once() + + log.critical.assert_not_called() + row = await _load_refund(integration_session, claim.id) + assert (row.status, row.token) == ("paid", "cashuAheld") + assert await total_user_liability(integration_session) == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("hold_before_lease", [True, False]) +async def test_stale_cashu_reconciliation_preserves_newly_held_token( + integration_session: AsyncSession, + patched_db_engine: None, + integration_engine: Any, + hold_before_lease: bool, +) -> None: + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + claim_id = claim.id + now = int(time.time()) + cutoff = now - refund.settings.refund_claim_timeout_seconds + claim.claimed_at = cutoff - 1 + await integration_session.commit() + async with AsyncSession(integration_engine, expire_on_commit=False) as session: + stale = await session.get(Refund, claim_id) + assert stale is not None and stale.token is None + + if hold_before_lease: + await refund.hold(integration_session, claim, None, token="cashuAheld") + assert await refund._lease(claim_id, now, cutoff) + real_close = refund._close + + async def hold_before_close(session: AsyncSession, row: Refund, **kw: Any) -> bool: + if not hold_before_lease and kw.get("status") == "stuck": + # Token arrives at the last moment, even after a potential reload. + await real_close( + integration_session, claim, status="ambiguous", token="cashuAheld" + ) + await integration_session.commit() + return await real_close(session, row, **kw) + + with patch.object(refund, "_close", hold_before_close): + await refund._reconcile(stale, now) + + row = await _load_refund(integration_session, claim_id) + assert (row.status, row.token) == ("paid", "cashuAheld") + assert (await _load_key(integration_session)).balance == 0 + assert await total_user_liability(integration_session) == 0 + with patch("routstr.refund.send_token", AsyncMock()) as send_again: + replay = await refund_wallet_endpoint( + refund_request=RefundRequest(), + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + assert isinstance(replay, dict) + assert replay["token"] == "cashuAheld" + send_again.assert_not_awaited() + + +# --- mint proved the melt unpaid ------------------------------------------- + + +@pytest.mark.asyncio +async def test_mint_confirmed_unpaid_melt_restores_balance_immediately( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """The mint answering ``unpaid`` to the melt itself is proof no payment + happened, so the customer gets the balance back now, not after the + reconciler timeout.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="lightning", destination=ADDRESS + ) + + async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int: + await on_melt_quote("quote-unpaid", claim.mint_url) + raise MeltUnpaidError("Cashu mint confirmed that the melt was unpaid") + + with patch("routstr.refund.send_to_lnurl", send): + with pytest.raises(HTTPException) as exc_info: + await refund.execute(integration_session, claim) + + assert exc_info.value.status_code == 503 + row = await _load_refund(integration_session, claim.id) + assert (row.status, row.quote_id) == ("failed", "quote-unpaid") + assert (await _load_key(integration_session)).balance == BALANCE_MSATS + + +# --- response reflects the persisted claim --------------------------------- + + +@pytest.mark.asyncio +async def test_response_status_reflects_persisted_claim( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """When ``settle`` closes nothing the response must not invent ``paid``.""" + key = await _seed_key(integration_session) + claim = await refund.open_claim( + integration_session, key, method="cashu", destination=None + ) + + async def send_token(amount: int, unit: str, mint_url: str) -> str: + # Somebody closed the row as failed while the mint was working. + await refund._close(integration_session, claim, status="failed") + await integration_session.commit() + return "cashuAorphan" + + send, mint, store = _cashu_payout("cashuAorphan", send_token) + with send, mint, store: + body = await refund.execute(integration_session, claim) + + assert body["status"] == "failed" + assert (await _load_refund(integration_session, claim.id)).status == "failed" + + +@pytest.mark.asyncio +async def test_stored_refund_address_is_validated_before_debit( + integration_session: AsyncSession, patched_db_engine: None +) -> None: + """A bad address stored on the key is a client error, not a payout failure.""" + await _seed_key(integration_session, address="nobody@invalid.example") + send = AsyncMock() + with ( + patch( + "routstr.refund.get_lnurl_data", + AsyncMock(side_effect=refund.LNURLError("no such user")), + ), + patch("routstr.refund.send_to_lnurl", send), + ): + with pytest.raises(HTTPException) as exc_info: + await refund_wallet_endpoint( + refund_request=RefundRequest(), + authorization=f"Bearer sk-{KEY_HASH}", + x_cashu=None, + session=integration_session, + ) + + assert exc_info.value.status_code == 400 + send.assert_not_awaited() + assert (await _load_key(integration_session)).balance == BALANCE_MSATS diff --git a/tests/integration/test_wallet_refund.py b/tests/integration/test_wallet_refund.py index 83f4ec7f..1029d073 100644 --- a/tests/integration/test_wallet_refund.py +++ b/tests/integration/test_wallet_refund.py @@ -7,7 +7,7 @@ import asyncio import base64 import json from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient @@ -622,7 +622,10 @@ async def test_refund_with_expired_key( integration_client.headers["Authorization"] = f"Bearer {api_key}" # Mock the refund to LN address - with patch("routstr.refund.send_to_lnurl") as mock_send_to_lnurl: + with ( + patch("routstr.refund.get_lnurl_data", AsyncMock()), + patch("routstr.refund.send_to_lnurl") as mock_send_to_lnurl, + ): mock_send_to_lnurl.return_value = 500 response = await integration_client.post("/v1/wallet/refund") diff --git a/tests/unit/test_balance.py b/tests/unit/test_balance.py index 9381753d..cb262d70 100644 --- a/tests/unit/test_balance.py +++ b/tests/unit/test_balance.py @@ -349,7 +349,10 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No session.rollback = AsyncMock() with ( - patch("routstr.refund.send_token", AsyncMock(return_value=refund_token)), + patch("routstr.refund.renew_lease", AsyncMock()), + patch( + "routstr.refund.send_token", AsyncMock(return_value=refund_token) + ) as mock_send_token, patch("routstr.refund.store_cashu_transaction", AsyncMock()) as mock_store, ): result = await refund_wallet_endpoint( @@ -358,6 +361,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No session=session, ) + mock_send_token.assert_awaited_once() assert isinstance(result, dict) assert result["token"] == refund_token @@ -382,7 +386,10 @@ async def test_apikey_refund_logs_token() -> None: session.rollback = AsyncMock() with ( - patch("routstr.refund.send_token", AsyncMock(return_value=refund_token)), + patch("routstr.refund.renew_lease", AsyncMock()), + patch( + "routstr.refund.send_token", AsyncMock(return_value=refund_token) + ) as mock_send_token, patch("routstr.refund.store_cashu_transaction", AsyncMock()), patch("routstr.refund.logger") as mock_logger, ): @@ -392,6 +399,7 @@ async def test_apikey_refund_logs_token() -> None: session=session, ) + mock_send_token.assert_awaited_once() calls = [str(c) for c in mock_logger.info.call_args_list] assert any("refund paid" in c for c in calls) @@ -409,7 +417,10 @@ async def test_apikey_refund_log_identifies_the_claim() -> None: session.rollback = AsyncMock() with ( - patch("routstr.refund.send_token", AsyncMock(return_value=refund_token)), + patch("routstr.refund.renew_lease", AsyncMock()), + patch( + "routstr.refund.send_token", AsyncMock(return_value=refund_token) + ) as mock_send_token, patch("routstr.refund.store_cashu_transaction", AsyncMock()), patch("routstr.refund.logger") as mock_logger, ): @@ -419,6 +430,7 @@ async def test_apikey_refund_log_identifies_the_claim() -> None: session=session, ) + mock_send_token.assert_awaited_once() paid_calls = [ c for c in mock_logger.info.call_args_list @@ -508,10 +520,11 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: session.rollback = AsyncMock() with ( + patch("routstr.refund.renew_lease", AsyncMock()), patch( "routstr.refund.send_token", AsyncMock(side_effect=MintConnectionError("raw mint outage detail")), - ), + ) as mock_send_token, patch("routstr.refund.store_cashu_transaction", AsyncMock()), patch("routstr.refund.logger"), ): @@ -522,6 +535,7 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None: session=session, ) + mock_send_token.assert_awaited_once() assert exc_info.value.status_code == 503 assert exc_info.value.detail == "Mint service unavailable" assert "raw mint outage detail" not in exc_info.value.detail @@ -545,9 +559,10 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None: session.rollback = AsyncMock() with ( + patch("routstr.refund.renew_lease", AsyncMock()), patch( "routstr.refund.send_token", AsyncMock(side_effect=RuntimeError(raw_error)) - ), + ) as mock_send_token, patch("routstr.refund.store_cashu_transaction", AsyncMock()), patch("routstr.refund.logger"), ): @@ -558,6 +573,7 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None: session=session, ) + mock_send_token.assert_awaited_once() assert exc_info.value.status_code == 500 assert exc_info.value.detail == "Refund failed" assert raw_error not in exc_info.value.detail @@ -892,6 +908,7 @@ async def test_apikey_refund_ambiguous_melt_does_not_restore_balance() -> None: AsyncMock(side_effect=MeltOutcomeAmbiguousError("outcome is ambiguous")), ), patch("routstr.refund.release", AsyncMock()) as mock_restore, + patch("routstr.refund.get_lnurl_data", AsyncMock()), ): with pytest.raises(HTTPException) as exc_info: await refund_wallet_endpoint( @@ -923,6 +940,7 @@ async def test_apikey_refund_clean_failure_still_restores_balance() -> None: AsyncMock(side_effect=RuntimeError("mint rejected melt")), ), patch("routstr.refund.release", AsyncMock()) as mock_restore, + patch("routstr.refund.get_lnurl_data", AsyncMock()), ): with pytest.raises(HTTPException): await refund_wallet_endpoint( From 4a2cde0572812316b671635031c0b37e65a839cd Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Thu, 17 Sep 2026 23:41:19 +0200 Subject: [PATCH 8/8] fix: renew refund lease on the request session --- routstr/refund.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/routstr/refund.py b/routstr/refund.py index 3fe637b9..2aee20e8 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -144,16 +144,15 @@ async def _close( return bool(result.rowcount) -async def renew_lease(refund: Refund) -> None: +async def renew_lease(session: AsyncSession, refund: Refund) -> None: """Push the reconciler lease forward before a slow mint step.""" - async with create_session() as session: - await session.exec( # type: ignore[call-overload] - update(Refund) - .where(col(Refund.id) == refund.id) - .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) - .values(claimed_at=int(time.time())) - ) - await session.commit() + await session.exec( # type: ignore[call-overload] + update(Refund) + .where(col(Refund.id) == refund.id) + .where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) + .values(claimed_at=int(time.time())) + ) + await session.commit() async def record_quote(refund: Refund, quote_id: str, mint_url: str) -> None: @@ -349,7 +348,7 @@ async def _pay_lightning(session: AsyncSession, refund: Refund) -> bool: async def _pay_cashu(session: AsyncSession, refund: Refund) -> bool: amount = amount_in_unit(refund.amount_msats, refund.unit) - await renew_lease(refund) + await renew_lease(session, refund) token = await send_token(amount, refund.unit, refund.mint_url) # From here the token is bearer money: keep it on the claim so a failed # settle withholds the balance instead of restoring it.