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(