mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: preserve refund payouts through recovery failures
This commit is contained in:
@@ -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:**
|
||||
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+98
-20
@@ -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)
|
||||
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user