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)