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 |
|
| 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
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
+100
-22
@@ -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)
|
||||||
|
|
||||||
refund.status = "paid"
|
if settled:
|
||||||
refund.claimed_at = None
|
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(
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user