fix: preserve refund payouts through recovery failures

This commit is contained in:
9qeklajc
2026-09-17 22:54:22 +02:00
parent e49fb7d5c1
commit 4d8b333483
8 changed files with 521 additions and 40 deletions
+4 -3
View File
@@ -510,7 +510,7 @@ Content-Type: application/json
| Parameter | Type | Required | Default | Description | | 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):** **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. - 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 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. - 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`. - 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. - 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_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 | | `409` | `refund_unresolved`: a claim for this key is `stuck` and needs operator reconciliation |
| `410` | Previously issued Cashu refund token has been swept | | `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. | | `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:** **X-Cashu refunds:**
+3 -2
View File
@@ -406,9 +406,10 @@ async def refund_wallet_endpoint(
raise HTTPException(status_code=400, detail="No balance to refund") raise HTTPException(status_code=400, detail="No balance to refund")
requested = refund_request.lightning_address if refund_request else None requested = refund_request.lightning_address if refund_request else None
if requested:
await refund.validate_lightning_destination(requested)
destination = requested or key.refund_address 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( claim = await refund.open_claim(
session, session,
+10 -4
View File
@@ -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_REDIRECTS = 3
_MAX_LNURL_RESPONSE_BYTES = 64 * 1024 _MAX_LNURL_RESPONSE_BYTES = 64 * 1024
_NON_PUBLIC_HOST_SUFFIXES = (".localhost", ".local", ".internal") _NON_PUBLIC_HOST_SUFFIXES = (".localhost", ".local", ".internal")
@@ -97,9 +105,7 @@ async def _require_public_https_destination(url: httpx.URL) -> None:
try: try:
resolved = ipaddress.ip_address(info[4][0]) resolved = ipaddress.ip_address(info[4][0])
except ValueError as e: except ValueError as e:
raise LNURLError( raise LNURLError("LNURL destination resolved to an invalid address") from e
"LNURL destination resolved to an invalid address"
) from e
if not resolved.is_global: if not resolved.is_global:
raise LNURLError("LNURL destination is not a public host") raise LNURLError("LNURL destination is not a public host")
@@ -446,7 +452,7 @@ async def raw_send_to_lnurl(
return final_amount return final_amount
if melt_state == MeltQuoteState.unpaid: if melt_state == MeltQuoteState.unpaid:
await wallet.set_reserved_for_send(proofs, reserved=False) 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: try:
quote = await run_mint_operation( quote = await run_mint_operation(
+98 -20
View File
@@ -11,6 +11,7 @@ from sqlmodel import col, func, select, update
from .core.db import ( from .core.db import (
REFUND_OPEN_STATUSES, REFUND_OPEN_STATUSES,
REFUND_UNRESOLVED_STATUSES,
ApiKey, ApiKey,
AsyncSession, AsyncSession,
Refund, Refund,
@@ -21,7 +22,12 @@ from .core.db import (
) )
from .core.logging import get_logger from .core.logging import get_logger
from .core.settings import settings 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 ( from .wallet import (
check_bolt11_payment_status, check_bolt11_payment_status,
is_mint_connection_error, is_mint_connection_error,
@@ -118,22 +124,38 @@ async def _close(
refund: Refund, refund: Refund,
*, *,
require_no_quote: bool = False, require_no_quote: bool = False,
require_no_token: bool = False,
from_statuses: tuple[str, ...] = REFUND_OPEN_STATUSES,
**values: object, **values: object,
) -> bool: ) -> bool:
stmt = ( stmt = (
update(Refund) update(Refund)
.where(col(Refund.id) == refund.id) .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: if require_no_quote:
# A quote recorded since the row was read means a melt may be in flight. # A quote recorded since the row was read means a melt may be in flight.
stmt = stmt.where(col(Refund.quote_id).is_(None)) 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] result = await session.exec( # type: ignore[call-overload]
stmt.values(claimed_at=None, updated_at=int(time.time()), **values) stmt.values(claimed_at=None, updated_at=int(time.time()), **values)
) )
return bool(result.rowcount) 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: 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. """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 values["token"] = token
if mint_url is not None: if mint_url is not None:
values["mint_url"] = mint_url 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() await session.commit()
if not settled: if not settled:
logger.warning( logger.warning(
@@ -211,8 +237,19 @@ async def release(
return True return True
async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> None: async def hold(
await _close(session, refund, status="ambiguous", quote_id=quote_id) 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() await session.commit()
@@ -283,7 +320,7 @@ def describe(refund: Refund) -> dict[str, str]:
return body 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: async def capture_quote(quote: str, mint_url: str) -> None:
await record_quote(refund, quote, mint_url) await record_quote(refund, quote, mint_url)
@@ -307,16 +344,18 @@ async def _pay_lightning(session: AsyncSession, refund: Refund) -> None:
}, },
) )
raise 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) amount = amount_in_unit(refund.amount_msats, refund.unit)
await renew_lease(refund)
token = await send_token(amount, refund.unit, refund.mint_url) token = await send_token(amount, refund.unit, refund.mint_url)
mint_url = token_mint_url(token, refund.mint_url) # From here the token is bearer money: keep it on the claim so a failed
await settle(session, refund, token=token, mint_url=mint_url) # settle withholds the balance instead of restoring it.
refund.token = token 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: 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: 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 A recorded quote means the mint may already have paid; an issued token is
must not be restored. 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) await release(session, refund)
return return
await hold(session, refund, refund.quote_id) await hold(session, refund, refund.quote_id, token=refund.token)
logger.error( logger.error(
"refund failed after its melt quote was recorded; balance withheld " "refund failed after its payout was dispatched; balance withheld "
"pending reconciliation", "pending reconciliation",
extra={ extra={
"refund_id": refund.id, "refund_id": refund.id,
"key_hash": refund.api_key_hashed_key[:8], "key_hash": refund.api_key_hashed_key[:8],
"quote_id": refund.quote_id, "quote_id": refund.quote_id,
"has_token": refund.token is not None,
"mint_url": refund.mint_url, "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]: 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: try:
if refund.method == "lightning": if refund.method == "lightning":
await _pay_lightning(session, refund) settled = await _pay_lightning(session, refund)
else: else:
await _pay_cashu(session, refund) settled = await _pay_cashu(session, refund)
except MeltOutcomeAmbiguousError: except MeltOutcomeAmbiguousError:
# Already held by _pay_lightning; releasing here would pay out twice. # Already held by _pay_lightning; releasing here would pay out twice.
raise unresolved_refund_error() 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: except HTTPException:
await session.rollback()
await _abort(session, refund) await _abort(session, refund)
raise raise
except Exception as e: except Exception as e:
await session.rollback()
await _abort(session, refund) await _abort(session, refund)
logger.error( logger.error(
"refund payout failed", "refund payout failed",
@@ -410,8 +473,13 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
if refund.method == "cashu": if refund.method == "cashu":
await _record_cashu_payout(refund) await _record_cashu_payout(refund)
if settled:
refund.status = "paid" refund.status = "paid"
refund.claimed_at = None 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( logger.info(
"refund paid", "refund paid",
extra={ 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: async def _reconcile(refund: Refund, now: int) -> None:
if refund.method != "lightning": 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. # No quote to query for cashu; withhold the balance and alert once.
async with create_session() as session: 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() await session.commit()
logger.critical( logger.critical(
"cashu refund stuck; balance withheld, manual reconciliation required", "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, "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 return
if refund.quote_id is None: if refund.quote_id is None:
+8 -2
View File
@@ -599,7 +599,10 @@ async def test_endpoint_replays_paid_lightning_refund_on_empty_balance(
integration_session: AsyncSession, patched_db_engine: None integration_session: AsyncSession, patched_db_engine: None
) -> None: ) -> None:
await _seed_key(integration_session, address=ADDRESS) 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( first = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}", authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None, 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 """A paid claim from a previous cycle must not be replayed as the outcome
of the claim that is still settling.""" of the claim that is still settling."""
await _seed_key(integration_session, address=ADDRESS) 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( paid = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}", authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None, x_cashu=None,
@@ -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
+5 -2
View File
@@ -7,7 +7,7 @@ import asyncio
import base64 import base64
import json import json
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import AsyncMock, patch
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
@@ -622,7 +622,10 @@ async def test_refund_with_expired_key(
integration_client.headers["Authorization"] = f"Bearer {api_key}" integration_client.headers["Authorization"] = f"Bearer {api_key}"
# Mock the refund to LN address # 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 mock_send_to_lnurl.return_value = 500
response = await integration_client.post("/v1/wallet/refund") response = await integration_client.post("/v1/wallet/refund")
+23 -5
View File
@@ -349,7 +349,10 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
session.rollback = AsyncMock() session.rollback = AsyncMock()
with ( 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, patch("routstr.refund.store_cashu_transaction", AsyncMock()) as mock_store,
): ):
result = await refund_wallet_endpoint( result = await refund_wallet_endpoint(
@@ -358,6 +361,7 @@ async def test_apikey_refund_stores_cashu_transaction_with_apikey_source() -> No
session=session, session=session,
) )
mock_send_token.assert_awaited_once()
assert isinstance(result, dict) assert isinstance(result, dict)
assert result["token"] == refund_token assert result["token"] == refund_token
@@ -382,7 +386,10 @@ async def test_apikey_refund_logs_token() -> None:
session.rollback = AsyncMock() session.rollback = AsyncMock()
with ( 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.store_cashu_transaction", AsyncMock()),
patch("routstr.refund.logger") as mock_logger, patch("routstr.refund.logger") as mock_logger,
): ):
@@ -392,6 +399,7 @@ async def test_apikey_refund_logs_token() -> None:
session=session, session=session,
) )
mock_send_token.assert_awaited_once()
calls = [str(c) for c in mock_logger.info.call_args_list] calls = [str(c) for c in mock_logger.info.call_args_list]
assert any("refund paid" in c for c in calls) 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() session.rollback = AsyncMock()
with ( 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.store_cashu_transaction", AsyncMock()),
patch("routstr.refund.logger") as mock_logger, patch("routstr.refund.logger") as mock_logger,
): ):
@@ -419,6 +430,7 @@ async def test_apikey_refund_log_identifies_the_claim() -> None:
session=session, session=session,
) )
mock_send_token.assert_awaited_once()
paid_calls = [ paid_calls = [
c c
for c in mock_logger.info.call_args_list 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() session.rollback = AsyncMock()
with ( with (
patch("routstr.refund.renew_lease", AsyncMock()),
patch( patch(
"routstr.refund.send_token", "routstr.refund.send_token",
AsyncMock(side_effect=MintConnectionError("raw mint outage detail")), AsyncMock(side_effect=MintConnectionError("raw mint outage detail")),
), ) as mock_send_token,
patch("routstr.refund.store_cashu_transaction", AsyncMock()), patch("routstr.refund.store_cashu_transaction", AsyncMock()),
patch("routstr.refund.logger"), patch("routstr.refund.logger"),
): ):
@@ -522,6 +535,7 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
session=session, session=session,
) )
mock_send_token.assert_awaited_once()
assert exc_info.value.status_code == 503 assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Mint service unavailable" assert exc_info.value.detail == "Mint service unavailable"
assert "raw mint outage detail" not in exc_info.value.detail 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() session.rollback = AsyncMock()
with ( with (
patch("routstr.refund.renew_lease", AsyncMock()),
patch( patch(
"routstr.refund.send_token", AsyncMock(side_effect=RuntimeError(raw_error)) "routstr.refund.send_token", AsyncMock(side_effect=RuntimeError(raw_error))
), ) as mock_send_token,
patch("routstr.refund.store_cashu_transaction", AsyncMock()), patch("routstr.refund.store_cashu_transaction", AsyncMock()),
patch("routstr.refund.logger"), patch("routstr.refund.logger"),
): ):
@@ -558,6 +573,7 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
session=session, session=session,
) )
mock_send_token.assert_awaited_once()
assert exc_info.value.status_code == 500 assert exc_info.value.status_code == 500
assert exc_info.value.detail == "Refund failed" assert exc_info.value.detail == "Refund failed"
assert raw_error not in exc_info.value.detail 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")), AsyncMock(side_effect=MeltOutcomeAmbiguousError("outcome is ambiguous")),
), ),
patch("routstr.refund.release", AsyncMock()) as mock_restore, patch("routstr.refund.release", AsyncMock()) as mock_restore,
patch("routstr.refund.get_lnurl_data", AsyncMock()),
): ):
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint( 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")), AsyncMock(side_effect=RuntimeError("mint rejected melt")),
), ),
patch("routstr.refund.release", AsyncMock()) as mock_restore, patch("routstr.refund.release", AsyncMock()) as mock_restore,
patch("routstr.refund.get_lnurl_data", AsyncMock()),
): ):
with pytest.raises(HTTPException): with pytest.raises(HTTPException):
await refund_wallet_endpoint( await refund_wallet_endpoint(