This commit is contained in:
9qeklajc
2026-09-16 13:15:32 +02:00
parent c3970de510
commit e49fb7d5c1
9 changed files with 504 additions and 98 deletions
+8 -5
View File
@@ -488,7 +488,7 @@ Authorization: Bearer sk-...
### Refund Balance ### Refund Balance
Pay out the remaining balance and close the key. The payout goes to a Lightning address when one is given (in the request or stored on the key), otherwise a Cashu token is returned. Pay out the current balance. The key remains valid at zero balance and can be topped up again. The payout goes to a Lightning address when one is given (in the request or stored on the key), otherwise a Cashu token is returned.
```http ```http
POST /v1/balance/refund POST /v1/balance/refund
@@ -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 before any balance is debited. | | `lightning_address` | string | No | Key's stored refund address | Lightning address or LNURL to pay. Overrides the stored address for this request. Resolved only for a request that can open a new claim, before any balance is debited. |
**Response (Lightning):** **Response (Lightning):**
@@ -534,14 +534,16 @@ Content-Type: application/json
} }
``` ```
The amount field is `sats` or `msats` depending on the key's refund currency. The amount field is `sats` or `msats` depending on the key's refund currency. It reports the gross balance debited by the claim. For Lightning refunds, mint and input fees can reduce the amount actually delivered to the recipient.
**Behaviour:** **Behaviour:**
- The balance is debited and a refund claim is recorded before the payout is attempted. A key has at most one open claim at a time. - 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.
- 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.
- Calling again on a zero-balance key returns the last paid Lightning refund, or the previously issued Cashu token while it remains uncollected. - An unresolved claim is reported before any replay: a request on a key with an open claim returns `409` with that claim's `refund_id` and `status`.
- Calling again on a zero-balance key with no open claim returns the last paid Lightning refund, or the Cashu token issued by the last paid claim while it remains uncollected.
**Errors:** **Errors:**
@@ -551,7 +553,8 @@ The amount field is `sats` or `msats` depending on the key's refund currency.
| `400` | Ongoing requests are still reserving balance on this key | | `400` | Ongoing requests are still reserving balance on this key |
| `401` | Unknown key | | `401` | Unknown key |
| `409` | Balance changed concurrently. Retry. | | `409` | Balance changed concurrently. Retry. |
| `409` | `refund_in_progress`: another refund claim for this key is still open | | `409` | `refund_in_progress`: another refund claim for this key is still open. The body carries its `refund_id` and `status` |
| `409` | `refund_unresolved`: a claim for this key is `stuck` and needs operator reconciliation |
| `410` | Previously issued Cashu refund token has been swept | | `410` | Previously issued Cashu refund token has been swept |
| `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. Balance restored. Retry later. |
+23 -16
View File
@@ -241,17 +241,16 @@ async def _lookup_key_no_create(
async def _get_persisted_api_key_refund( async def _get_persisted_api_key_refund(
key: ApiKey, session: AsyncSession key: ApiKey, session: AsyncSession, token: str | None = None
) -> dict[str, str] | None: ) -> dict[str, str] | None:
result = await session.exec( query = select(CashuTransaction).where(
select(CashuTransaction)
.where(
CashuTransaction.api_key_hashed_key == key.hashed_key, CashuTransaction.api_key_hashed_key == key.hashed_key,
CashuTransaction.type == "out", CashuTransaction.type == "out",
CashuTransaction.source == "apikey", CashuTransaction.source == "apikey",
) )
.order_by(col(CashuTransaction.created_at).desc()) if token is not None:
) query = query.where(CashuTransaction.token == token)
result = await session.exec(query.order_by(col(CashuTransaction.created_at).desc()))
refund = result.first() refund = result.first()
if refund is None: if refund is None:
return None return None
@@ -352,25 +351,28 @@ async def refund_wallet_endpoint(
}, },
) )
requested = refund_request.lightning_address if refund_request else None # Check for an open claim before any replay or destination lookup.
if requested: if open_claim := await refund.latest_open(session, key):
await refund.validate_lightning_destination(requested) raise refund.refund_in_progress_error(open_claim)
destination = requested or key.refund_address
if key.total_balance <= 0: if key.total_balance <= 0:
paid = await refund.latest_terminal(session, key) paid = await refund.latest_terminal(session, key)
if paid and paid.method == "lightning": if paid and paid.method == "lightning":
return refund.describe(paid) return refund.describe(paid)
# cashu_transactions tracks collection and sweeping, so it takes if paid and paid.token:
# precedence; the claim row covers a token whose ledger write failed. # Match the ledger row to this claim's token, not the latest one.
if persisted := await _get_persisted_api_key_refund(
key, session, paid.token
):
return persisted
return refund.describe(paid)
# Legacy payouts predate the claim row, so fall back to the ledger.
if persisted := await _get_persisted_api_key_refund(key, session): if persisted := await _get_persisted_api_key_refund(key, session):
return persisted return persisted
if paid: if paid:
return refund.describe(paid) return refund.describe(paid)
# Balance reads zero because a prior refund already debited it and is if stuck := await refund.latest_stuck(session, key):
# still settling; surface that as 409 rather than "no balance". raise refund.refund_in_progress_error(stuck)
if await refund.latest_open(session, key):
raise refund.refund_in_progress_error()
if key.reserved_balance > 0: if key.reserved_balance > 0:
# Release only durable reservations old enough to be stale. A newer # Release only durable reservations old enough to be stale. A newer
@@ -403,6 +405,11 @@ async def refund_wallet_endpoint(
elif remaining_balance <= 0: elif remaining_balance <= 0:
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
if requested:
await refund.validate_lightning_destination(requested)
destination = requested or key.refund_address
claim = await refund.open_claim( claim = await refund.open_claim(
session, session,
key, key,
+15 -2
View File
@@ -570,6 +570,9 @@ class CashuTransaction(SQLModel, table=True): # type: ignore
REFUND_OPEN_STATUSES = ("pending", "ambiguous") REFUND_OPEN_STATUSES = ("pending", "ambiguous")
# Debited from the key but neither paid out nor restored, so still owed.
REFUND_UNRESOLVED_STATUSES = ("pending", "ambiguous", "stuck")
_REFUND_OPEN_PREDICATE = "status IN ('pending', 'ambiguous')" _REFUND_OPEN_PREDICATE = "status IN ('pending', 'ambiguous')"
@@ -1003,8 +1006,18 @@ async def complete_routstr_fee_payout(
async def total_user_liability(db_session: AsyncSession) -> int: async def total_user_liability(db_session: AsyncSession) -> int:
"""Return all outstanding API-key balances in millisatoshis.""" """Return all outstanding user funds in millisatoshis.
result = await db_session.exec(select(func.sum(ApiKey.balance)))
Key balances and unresolved refunds are summed in one statement so a
claim opened between two reads cannot be missed by both.
"""
key_balances = select(func.coalesce(func.sum(ApiKey.balance), 0)).scalar_subquery()
unresolved_refunds = (
select(func.coalesce(func.sum(Refund.amount_msats), 0))
.where(col(Refund.status).in_(REFUND_UNRESOLVED_STATUSES))
.scalar_subquery()
)
result = await db_session.exec(select(key_balances + unresolved_refunds))
return int(result.one() or 0) return int(result.one() or 0)
+96 -44
View File
@@ -4,9 +4,10 @@ import asyncio
import time import time
from typing import Any from typing import Any
import httpx
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select, update from sqlmodel import col, func, select, update
from .core.db import ( from .core.db import (
REFUND_OPEN_STATUSES, REFUND_OPEN_STATUSES,
@@ -55,6 +56,10 @@ async def validate_lightning_destination(destination: str) -> None:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"Invalid lightning destination: {e}" status_code=400, detail=f"Invalid lightning destination: {e}"
) )
except httpx.HTTPError as e:
raise HTTPException(
status_code=400, detail=f"Lightning destination unreachable: {e}"
)
async def open_claim( async def open_claim(
@@ -66,6 +71,14 @@ async def open_claim(
) -> Refund: ) -> Refund:
"""Zero the balance and insert the claim in one transaction.""" """Zero the balance and insert the claim in one transaction."""
unit = refund_unit(key) unit = refund_unit(key)
# created_at has second resolution; step past the previous claim so the
# newest claim for a key always sorts first.
latest = await session.exec(
select(func.max(col(Refund.created_at))).where(
Refund.api_key_hashed_key == key.hashed_key
)
)
created_at = max(int(time.time()), (latest.one() or 0) + 1)
refund = Refund( refund = Refund(
api_key_hashed_key=key.hashed_key, api_key_hashed_key=key.hashed_key,
method=method, method=method,
@@ -74,6 +87,8 @@ async def open_claim(
unit=unit, unit=unit,
mint_url=refund_mint(key), mint_url=refund_mint(key),
claimed_at=int(time.time()), claimed_at=int(time.time()),
created_at=created_at,
updated_at=created_at,
) )
debit = ( debit = (
update(ApiKey) update(ApiKey)
@@ -119,8 +134,11 @@ async def _close(
return bool(result.rowcount) return bool(result.rowcount)
async def record_quote(refund: Refund, quote_id: str) -> None: async def record_quote(refund: Refund, quote_id: str, mint_url: str) -> None:
"""Store the melt quote 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.
Mint fallback can issue the quote on a different mint than the claim's.
"""
async with create_session() as session: async with create_session() as session:
result = await session.exec( # type: ignore[call-overload] result = await session.exec( # type: ignore[call-overload]
update(Refund) update(Refund)
@@ -129,6 +147,7 @@ async def record_quote(refund: Refund, quote_id: str) -> None:
# Renew the lease so the reconciler leaves the payout alone. # Renew the lease so the reconciler leaves the payout alone.
.values( .values(
quote_id=quote_id, quote_id=quote_id,
mint_url=mint_url,
claimed_at=int(time.time()), claimed_at=int(time.time()),
updated_at=int(time.time()), updated_at=int(time.time()),
) )
@@ -137,6 +156,7 @@ async def record_quote(refund: Refund, quote_id: str) -> None:
if not result.rowcount: if not result.rowcount:
raise LNURLError("Refund claim closed before the melt was dispatched") raise LNURLError("Refund claim closed before the melt was dispatched")
refund.quote_id = quote_id refund.quote_id = quote_id
refund.mint_url = mint_url
async def settle( async def settle(
@@ -196,18 +216,34 @@ async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> N
await session.commit() await session.commit()
def refund_in_progress_error() -> HTTPException: def refund_in_progress_error(refund: Refund | None = None) -> HTTPException:
"""The 409 raised when a key already has an in-flight refund claim.""" """The 409 raised when a key already has an unresolved refund claim."""
return HTTPException( stuck = refund is not None and refund.status == "stuck"
status_code=409, error: dict[str, str] = {
detail={ "message": (
"error": { "A refund for this key is unresolved and requires operator reconciliation."
"message": "A refund for this key is already in progress.", if stuck
else "A refund for this key is already in progress."
),
"type": "invalid_request_error", "type": "invalid_request_error",
"code": "refund_in_progress", "code": "refund_unresolved" if stuck else "refund_in_progress",
} }
}, if refund is not None:
error["refund_id"] = refund.id
error["status"] = refund.status
return HTTPException(status_code=409, detail={"error": error})
async def _latest_with_status(
session: AsyncSession, key: ApiKey, statuses: tuple[str, ...]
) -> Refund | None:
result = await session.exec(
select(Refund)
.where(Refund.api_key_hashed_key == key.hashed_key)
.where(col(Refund.status).in_(statuses))
.order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc())
) )
return result.first()
async def latest_open(session: AsyncSession, key: ApiKey) -> Refund | None: async def latest_open(session: AsyncSession, key: ApiKey) -> Refund | None:
@@ -216,13 +252,12 @@ async def latest_open(session: AsyncSession, key: ApiKey) -> Refund | None:
An open claim means a prior refund already debited the balance and is still An open claim means a prior refund already debited the balance and is still
settling, so the balance reads as zero even though a refund is under way. settling, so the balance reads as zero even though a refund is under way.
""" """
result = await session.exec( return await _latest_with_status(session, key, REFUND_OPEN_STATUSES)
select(Refund)
.where(Refund.api_key_hashed_key == key.hashed_key)
.where(col(Refund.status).in_(REFUND_OPEN_STATUSES)) async def latest_stuck(session: AsyncSession, key: ApiKey) -> Refund | None:
.order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc()) """Latest claim the reconciler gave up on; needs operator recovery."""
) return await _latest_with_status(session, key, ("stuck",))
return result.first()
async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None: async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None:
@@ -232,13 +267,7 @@ async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None:
collection and sweeping; the claim row is the fallback when that ledger collection and sweeping; the claim row is the fallback when that ledger
write failed after the token was already issued. write failed after the token was already issued.
""" """
result = await session.exec( return await _latest_with_status(session, key, ("paid",))
select(Refund)
.where(Refund.api_key_hashed_key == key.hashed_key)
.where(Refund.status == "paid")
.order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc())
)
return result.first()
def describe(refund: Refund) -> dict[str, str]: def describe(refund: Refund) -> dict[str, str]:
@@ -255,12 +284,8 @@ def describe(refund: Refund) -> dict[str, str]:
async def _pay_lightning(session: AsyncSession, refund: Refund) -> None: async def _pay_lightning(session: AsyncSession, refund: Refund) -> None:
quote_id: str | None = None async def capture_quote(quote: str, mint_url: str) -> None:
await record_quote(refund, quote, mint_url)
async def capture_quote(quote: str) -> None:
nonlocal quote_id
quote_id = quote
await record_quote(refund, quote)
try: try:
await send_to_lnurl( await send_to_lnurl(
@@ -271,18 +296,18 @@ async def _pay_lightning(session: AsyncSession, refund: Refund) -> None:
on_melt_quote=capture_quote, on_melt_quote=capture_quote,
) )
except MeltOutcomeAmbiguousError as e: except MeltOutcomeAmbiguousError as e:
await hold(session, refund, quote_id) await hold(session, refund, refund.quote_id)
logger.error( logger.error(
"refund outcome ambiguous; balance withheld pending reconciliation", "refund outcome ambiguous; balance withheld pending reconciliation",
extra={ extra={
"refund_id": refund.id, "refund_id": refund.id,
"error": str(e), "error": str(e),
"key_hash": refund.api_key_hashed_key[:8], "key_hash": refund.api_key_hashed_key[:8],
"quote_id": quote_id, "quote_id": refund.quote_id,
}, },
) )
raise raise
await settle(session, refund, quote_id=quote_id) await settle(session, refund, quote_id=refund.quote_id)
async def _pay_cashu(session: AsyncSession, refund: Refund) -> None: async def _pay_cashu(session: AsyncSession, refund: Refund) -> None:
@@ -320,6 +345,39 @@ async def _record_cashu_payout(refund: Refund) -> None:
) )
def unresolved_refund_error() -> HTTPException:
return HTTPException(
status_code=502,
detail=(
"Refund was dispatched but its outcome is unconfirmed; the "
"balance is withheld until reconciliation completes"
),
)
async def _abort(session: AsyncSession, refund: Refund) -> None:
"""Fail the claim, or withhold it once a melt quote exists.
A recorded quote means the mint may already have paid, so the balance
must not be restored.
"""
if refund.quote_id is None:
await release(session, refund)
return
await hold(session, refund, refund.quote_id)
logger.error(
"refund failed after its melt quote was recorded; balance withheld "
"pending reconciliation",
extra={
"refund_id": refund.id,
"key_hash": refund.api_key_hashed_key[:8],
"quote_id": refund.quote_id,
"mint_url": refund.mint_url,
},
)
raise unresolved_refund_error()
async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]: async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
try: try:
if refund.method == "lightning": if refund.method == "lightning":
@@ -328,18 +386,12 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
await _pay_cashu(session, refund) 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 HTTPException( raise unresolved_refund_error()
status_code=502,
detail=(
"Refund was dispatched but its outcome is unconfirmed; the "
"balance is withheld until reconciliation completes"
),
)
except HTTPException: except HTTPException:
await release(session, refund) await _abort(session, refund)
raise raise
except Exception as e: except Exception as e:
await release(session, refund) await _abort(session, refund)
logger.error( logger.error(
"refund payout failed", "refund payout failed",
extra={ extra={
+15 -2
View File
@@ -1993,14 +1993,25 @@ async def periodic_routstr_fee_payout() -> None:
) )
def _quote_callback(
notify: Callable[[str, str], Awaitable[None]], mint: str
) -> Callable[[str], Awaitable[None]]:
async def callback(quote_id: str) -> None:
await notify(quote_id, mint)
return callback
async def send_to_lnurl( async def send_to_lnurl(
amount: int, amount: int,
unit: str, unit: str,
mint: str, mint: str,
address: str, address: str,
*, *,
on_melt_quote: Callable[[str], Awaitable[None]] | None = None, on_melt_quote: Callable[[str, str], Awaitable[None]] | None = None,
) -> int: ) -> int:
"""``on_melt_quote`` gets the quote id and the mint that issued it, since
fallback may pick a different mint than requested."""
async with wallet_operation_guard(): async with wallet_operation_guard():
mint = await find_trusted_mint_with_funds(amount, unit, mint, force_reload=True) mint = await find_trusted_mint_with_funds(amount, unit, mint, force_reload=True)
wallet = await get_wallet(mint, unit) wallet = await get_wallet(mint, unit)
@@ -2014,7 +2025,9 @@ async def send_to_lnurl(
address, address,
unit, unit,
amount=amount, amount=amount,
on_melt_quote=on_melt_quote, on_melt_quote=(
None if on_melt_quote is None else _quote_callback(on_melt_quote, mint)
),
) )
+25 -1
View File
@@ -13,11 +13,35 @@ API key to check whether it settled.
""" """
import argparse import argparse
import ipaddress
import sys import sys
from urllib.parse import urlparse
import httpx import httpx
def _is_loopback(host: str) -> bool:
if host == "localhost":
return True
try:
return ipaddress.ip_address(host.strip("[]")).is_loopback
except ValueError:
return False
def check_url(url: str) -> str:
"""Reject a URL that would put the token and the API key on the wire."""
parsed = urlparse(url)
if parsed.scheme == "https":
return url
if parsed.scheme == "http" and _is_loopback(parsed.hostname or ""):
return url
raise SystemExit(
f"Refusing to send a cashu token and bearer key to {url!r}: "
"use https, or http only for a loopback host."
)
def create_balance(client: httpx.Client, token: str) -> str: def create_balance(client: httpx.Client, token: str) -> str:
response = client.post("/v1/balance/create", json={"initial_balance_token": token}) response = client.post("/v1/balance/create", json={"initial_balance_token": token})
response.raise_for_status() response.raise_for_status()
@@ -45,7 +69,7 @@ def main() -> None:
parser.add_argument("--url", default="http://localhost:8000", help="routstr URL") parser.add_argument("--url", default="http://localhost:8000", help="routstr URL")
args = parser.parse_args() args = parser.parse_args()
with httpx.Client(base_url=args.url, timeout=120.0) as client: with httpx.Client(base_url=check_url(args.url), timeout=120.0) as client:
api_key = ( api_key = (
args.token args.token
if args.token.startswith("sk-") if args.token.startswith("sk-")
+250 -9
View File
@@ -9,13 +9,21 @@ import time
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx
import pytest import pytest
from fastapi import HTTPException from fastapi import HTTPException
from sqlmodel import select from sqlmodel import select
from routstr import refund from routstr import refund
from routstr.balance import RefundRequest, refund_wallet_endpoint from routstr.balance import RefundRequest, refund_wallet_endpoint
from routstr.core.db import ApiKey, AsyncSession, Refund from routstr.core.db import (
ApiKey,
AsyncSession,
CashuTransaction,
Refund,
store_cashu_transaction_with_retry,
total_user_liability,
)
from routstr.payment.lnurl import LNURLError, MeltOutcomeAmbiguousError from routstr.payment.lnurl import LNURLError, MeltOutcomeAmbiguousError
KEY_HASH = "refundclaimkey" KEY_HASH = "refundclaimkey"
@@ -170,6 +178,8 @@ async def test_release_after_settle_is_a_noop(
def _lnurl_stub( def _lnurl_stub(
outcome: BaseException | None = None, outcome: BaseException | None = None,
*,
quoted: bool = True,
) -> Callable[..., Awaitable[int]]: ) -> Callable[..., Awaitable[int]]:
async def send( async def send(
amount: int, amount: int,
@@ -177,10 +187,10 @@ def _lnurl_stub(
mint: str, mint: str,
address: str, address: str,
*, *,
on_melt_quote: Callable[[str], Awaitable[None]] | None = None, on_melt_quote: Callable[[str, str], Awaitable[None]] | None = None,
) -> int: ) -> int:
if on_melt_quote is not None: if quoted and on_melt_quote is not None:
await on_melt_quote("quote-123") await on_melt_quote("quote-123", mint)
if outcome is not None: if outcome is not None:
raise outcome raise outcome
return amount return amount
@@ -199,7 +209,7 @@ async def test_execute_persists_quote_before_melt_and_settles(
seen: list[str | None] = [] seen: list[str | None] = []
async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int: async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int:
await on_melt_quote("quote-123") await on_melt_quote("quote-123", claim.mint_url)
row = await _load_refund(integration_session, claim.id) row = await _load_refund(integration_session, claim.id)
seen.append(row.quote_id) seen.append(row.quote_id)
return 5000 return 5000
@@ -247,7 +257,10 @@ async def test_execute_clean_failure_restores_balance(
claim = await refund.open_claim( claim = await refund.open_claim(
integration_session, key, method="lightning", destination=ADDRESS integration_session, key, method="lightning", destination=ADDRESS
) )
with patch("routstr.refund.send_to_lnurl", _lnurl_stub(LNURLError("limits"))): with patch(
"routstr.refund.send_to_lnurl",
_lnurl_stub(LNURLError("limits"), quoted=False),
):
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await refund.execute(integration_session, claim) await refund.execute(integration_session, claim)
@@ -257,6 +270,52 @@ async def test_execute_clean_failure_restores_balance(
assert (await _load_key(integration_session)).balance == BALANCE_MSATS assert (await _load_key(integration_session)).balance == BALANCE_MSATS
@pytest.mark.asyncio
async def test_execute_failure_after_quote_withholds_balance(
integration_session: AsyncSession, patched_db_engine: None
) -> None:
"""The mint may have paid the quote, so a later local failure must not restore."""
key = await _seed_key(integration_session)
claim = await refund.open_claim(
integration_session, key, method="lightning", destination=ADDRESS
)
with patch("routstr.refund.send_to_lnurl", _lnurl_stub(RuntimeError("local"))):
with pytest.raises(HTTPException) as exc_info:
await refund.execute(integration_session, claim)
assert exc_info.value.status_code == 502
row = await _load_refund(integration_session, claim.id)
assert (row.status, row.quote_id) == ("ambiguous", "quote-123")
assert (await _load_key(integration_session)).balance == 0
@pytest.mark.asyncio
async def test_execute_records_mint_that_issued_the_quote(
integration_session: AsyncSession, patched_db_engine: None
) -> None:
"""Mint fallback must leave reconciliation pointed at the issuing mint."""
key = await _seed_key(integration_session)
claim = await refund.open_claim(
integration_session, key, method="lightning", destination=ADDRESS
)
fallback_mint = "https://fallback.mint.example"
async def send(*args: Any, on_melt_quote: Any = None, **kwargs: Any) -> int:
await on_melt_quote("quote-fallback", fallback_mint)
raise MeltOutcomeAmbiguousError("unknown")
with patch("routstr.refund.send_to_lnurl", send):
with pytest.raises(MeltOutcomeAmbiguousError):
await refund._pay_lightning(integration_session, claim)
row = await _load_refund(integration_session, claim.id)
assert (row.mint_url, row.quote_id, row.status) == (
fallback_mint,
"quote-fallback",
"ambiguous",
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_aborts_melt_when_claim_was_released( async def test_execute_aborts_melt_when_claim_was_released(
integration_engine: Any, integration_session: AsyncSession, patched_db_engine: None integration_engine: Any, integration_session: AsyncSession, patched_db_engine: None
@@ -272,7 +331,7 @@ async def test_execute_aborts_melt_when_claim_was_released(
nonlocal melted nonlocal melted
async with AsyncSession(integration_engine, expire_on_commit=False) as other: async with AsyncSession(integration_engine, expire_on_commit=False) as other:
await refund.release(other, await _load_refund(other, claim.id)) await refund.release(other, await _load_refund(other, claim.id))
await on_melt_quote("quote-123") await on_melt_quote("quote-123", claim.mint_url)
melted = True melted = True
return 5000 return 5000
@@ -383,7 +442,7 @@ async def test_reconcile_keeps_claim_that_gained_quote_mid_pass(
async def lease_then_quote(refund_id: str, now: int, cutoff: int) -> bool: async def lease_then_quote(refund_id: str, now: int, cutoff: int) -> bool:
leased = await real_lease(refund_id, now, cutoff) leased = await real_lease(refund_id, now, cutoff)
await refund.record_quote(claim, "late-quote") await refund.record_quote(claim, "late-quote", claim.mint_url)
return leased return leased
with patch("routstr.refund._lease", lease_then_quote): with patch("routstr.refund._lease", lease_then_quote):
@@ -438,7 +497,7 @@ async def test_reconcile_queries_mint_for_crashed_claim_with_quote(
claim = await refund.open_claim( claim = await refund.open_claim(
integration_session, key, method="lightning", destination=ADDRESS integration_session, key, method="lightning", destination=ADDRESS
) )
await refund.record_quote(claim, "quote-crash") await refund.record_quote(claim, "quote-crash", claim.mint_url)
await _age_claim(integration_session, claim.id, 600) await _age_claim(integration_session, claim.id, 600)
with patch( with patch(
"routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="paid") "routstr.refund.check_bolt11_payment_status", AsyncMock(return_value="paid")
@@ -642,3 +701,185 @@ async def test_cashu_token_survives_failed_ledger_write(
assert isinstance(second, dict) assert isinstance(second, dict)
assert second["refund_id"] == first["refund_id"] assert second["refund_id"] == first["refund_id"]
assert second["token"] == "cashuAtoken" assert second["token"] == "cashuAtoken"
async def _topup(session: AsyncSession, amount: int = BALANCE_MSATS) -> None:
key = await _load_key(session)
key.balance = amount
session.add(key)
await session.commit()
async def _refund_cashu(
session: AsyncSession, token: str, *, ledger: bool
) -> dict[str, str]:
store = (
AsyncMock(side_effect=RuntimeError("db down"))
if not ledger
else store_cashu_transaction_with_retry
)
with (
patch("routstr.refund.send_token", AsyncMock(return_value=token)),
patch("routstr.refund.token_mint_url", lambda t, mint: mint),
patch("routstr.refund.store_cashu_transaction", store),
):
body = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=session,
)
assert isinstance(body, dict)
return body
@pytest.mark.asyncio
async def test_replay_prefers_the_token_of_the_newest_paid_claim(
integration_session: AsyncSession, patched_db_engine: None
) -> None:
"""An older ledger row must not answer for a newer claim whose write failed."""
await _seed_key(integration_session)
await _refund_cashu(integration_session, "cashuAold", ledger=True)
await _topup(integration_session)
newest = await _refund_cashu(integration_session, "cashuAnew", ledger=False)
replay = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=integration_session,
)
assert isinstance(replay, dict)
assert replay["token"] == "cashuAnew"
assert replay["refund_id"] == newest["refund_id"]
@pytest.mark.asyncio
async def test_swept_older_ledger_row_does_not_reject_the_newest_claim(
integration_session: AsyncSession, patched_db_engine: None
) -> None:
await _seed_key(integration_session)
await _refund_cashu(integration_session, "cashuAold", ledger=True)
await _topup(integration_session)
await _refund_cashu(integration_session, "cashuAnew", ledger=False)
result = await integration_session.exec(
select(CashuTransaction).where(CashuTransaction.token == "cashuAold")
)
old_tx = result.one()
old_tx.swept = True
integration_session.add(old_tx)
await integration_session.commit()
replay = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=integration_session,
)
assert isinstance(replay, dict)
assert replay["token"] == "cashuAnew"
@pytest.mark.asyncio
async def test_open_claim_is_reported_over_an_older_paid_claim(
integration_session: AsyncSession, patched_db_engine: None
) -> None:
"""A paid claim from a previous cycle must not be replayed as the outcome
of the claim that is still settling."""
await _seed_key(integration_session, address=ADDRESS)
with patch("routstr.refund.send_to_lnurl", _lnurl_stub()):
paid = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=integration_session,
)
assert isinstance(paid, dict)
await _topup(integration_session)
key = await _load_key(integration_session)
open_claim = await refund.open_claim(
integration_session, key, method="lightning", destination=ADDRESS
)
await refund.hold(integration_session, open_claim, "quote-open")
validate = AsyncMock()
with patch("routstr.refund.get_lnurl_data", validate):
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
refund_request=RefundRequest(lightning_address=ADDRESS),
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=integration_session,
)
assert exc_info.value.status_code == 409
assert isinstance(exc_info.value.detail, dict)
error = exc_info.value.detail["error"]
assert (error["refund_id"], error["status"]) == (open_claim.id, "ambiguous")
# A drained key must not be able to drive outbound destination lookups.
validate.assert_not_awaited()
@pytest.mark.asyncio
async def test_stuck_claim_is_reported_instead_of_no_balance(
integration_session: AsyncSession, patched_db_engine: None
) -> None:
key = await _seed_key(integration_session)
claim = await refund.open_claim(
integration_session, key, method="cashu", destination=None
)
await refund._close(integration_session, claim, status="stuck")
await integration_session.commit()
with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=integration_session,
)
assert exc_info.value.status_code == 409
assert isinstance(exc_info.value.detail, dict)
error = exc_info.value.detail["error"]
assert (error["code"], error["refund_id"]) == ("refund_unresolved", claim.id)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("status", "counted"),
[
("pending", True),
("ambiguous", True),
("stuck", True),
("paid", False),
("failed", False),
],
)
async def test_liability_covers_claims_until_they_resolve(
integration_session: AsyncSession,
patched_db_engine: None,
status: str,
counted: bool,
) -> None:
"""Money in flight is still owed to the customer; an owner payout that read
only key balances could spend its backing."""
key = await _seed_key(integration_session)
before = await total_user_liability(integration_session)
assert before == BALANCE_MSATS
claim = await refund.open_claim(
integration_session, key, method="cashu", destination=None
)
assert await total_user_liability(integration_session) == BALANCE_MSATS
await refund._close(integration_session, claim, status=status)
await integration_session.commit()
expected = BALANCE_MSATS if counted else 0
assert await total_user_liability(integration_session) == expected
@pytest.mark.asyncio
async def test_unreachable_destination_is_a_client_error() -> None:
with patch(
"routstr.refund.get_lnurl_data",
AsyncMock(side_effect=httpx.ConnectError("All connection attempts failed")),
):
with pytest.raises(HTTPException) as exc_info:
await refund.validate_lightning_destination(ADDRESS)
assert exc_info.value.status_code == 400
+18 -14
View File
@@ -37,6 +37,10 @@ def _exec_result(tx: CashuTransaction | None) -> MagicMock:
def _update_result(rowcount: int) -> MagicMock: def _update_result(rowcount: int) -> MagicMock:
result = MagicMock() result = MagicMock()
result.rowcount = rowcount result.rowcount = rowcount
# Claim and ledger lookups share this stubbed session; an empty row set
# means the key has no prior refund to replay, report, or order after.
result.first.return_value = None
result.one.return_value = None
return result return result
@@ -276,6 +280,7 @@ async def test_apikey_refund_returns_persisted_token_after_cache_loss() -> None:
session.rollback = AsyncMock() session.rollback = AsyncMock()
with ( with (
patch("routstr.refund.latest_open", AsyncMock(return_value=None)),
patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)), patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)),
patch("routstr.refund.send_token", AsyncMock()) as mock_send_token, patch("routstr.refund.send_token", AsyncMock()) as mock_send_token,
): ):
@@ -314,7 +319,10 @@ async def test_apikey_refund_rejects_persisted_token_after_sweep() -> None:
session.commit = AsyncMock() session.commit = AsyncMock()
session.rollback = AsyncMock() session.rollback = AsyncMock()
with patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)): with (
patch("routstr.refund.latest_open", AsyncMock(return_value=None)),
patch("routstr.refund.latest_terminal", AsyncMock(return_value=None)),
):
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await refund_wallet_endpoint( await refund_wallet_endpoint(
authorization="Bearer sk-testhash", authorization="Bearer sk-testhash",
@@ -494,10 +502,8 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
# First exec call = debit (succeeds), second = restore # First exec call = debit (succeeds), second = restore
session = MagicMock() session = MagicMock()
session.get = AsyncMock(return_value=key) session.get = AsyncMock(return_value=key)
# debit, then the claim close and the balance restore # claim lookup, claim ordering, debit, claim close, balance restore
session.exec = AsyncMock( session.exec = AsyncMock(side_effect=[_update_result(1)] * 5)
side_effect=[_update_result(1), _update_result(1), _update_result(1)]
)
session.commit = AsyncMock() session.commit = AsyncMock()
session.rollback = AsyncMock() session.rollback = AsyncMock()
@@ -519,8 +525,8 @@ async def test_apikey_refund_restores_balance_on_mint_failure() -> None:
assert exc_info.value.status_code == 503 assert exc_info.value.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
# debit, claim close, balance restore # claim lookup, claim ordering, debit, claim close, balance restore
assert session.exec.await_count == 3 assert session.exec.await_count == 5
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -533,10 +539,8 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
session = MagicMock() session = MagicMock()
session.get = AsyncMock(return_value=key) session.get = AsyncMock(return_value=key)
# debit, then the claim close and the balance restore # claim lookup, claim ordering, debit, claim close, balance restore
session.exec = AsyncMock( session.exec = AsyncMock(side_effect=[_update_result(1)] * 5)
side_effect=[_update_result(1), _update_result(1), _update_result(1)]
)
session.commit = AsyncMock() session.commit = AsyncMock()
session.rollback = AsyncMock() session.rollback = AsyncMock()
@@ -557,7 +561,7 @@ async def test_apikey_refund_generic_failure_is_sanitized_500() -> None:
assert exc_info.value.status_code == 500 assert exc_info.value.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
assert session.exec.await_count == 3 assert session.exec.await_count == 5
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -878,7 +882,7 @@ async def test_apikey_refund_ambiguous_melt_does_not_restore_balance() -> None:
session = MagicMock() session = MagicMock()
session.get = AsyncMock(return_value=key) session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=MagicMock(rowcount=1)) session.exec = AsyncMock(return_value=_update_result(1))
session.commit = AsyncMock() session.commit = AsyncMock()
session.rollback = AsyncMock() session.rollback = AsyncMock()
@@ -909,7 +913,7 @@ async def test_apikey_refund_clean_failure_still_restores_balance() -> None:
session = MagicMock() session = MagicMock()
session.get = AsyncMock(return_value=key) session.get = AsyncMock(return_value=key)
session.exec = AsyncMock(return_value=MagicMock(rowcount=1)) session.exec = AsyncMock(return_value=_update_result(1))
session.commit = AsyncMock() session.commit = AsyncMock()
session.rollback = AsyncMock() session.rollback = AsyncMock()
@@ -0,0 +1,49 @@
"""The refund helper puts a cashu token and a bearer key on the wire, so it
must not speak cleartext to a remote host."""
import importlib.util
import sys
from pathlib import Path
from types import ModuleType
import pytest
SCRIPT = (
Path(__file__).resolve().parents[2] / "scripts" / "refund_token_to_lightning.py"
)
def _load() -> ModuleType:
spec = importlib.util.spec_from_file_location("refund_token_to_lightning", SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize(
"url",
[
"https://node.example.com",
"http://localhost:8000",
"http://127.0.0.1:8000",
"http://[::1]:8000",
],
)
def test_accepts_https_and_loopback_http(url: str) -> None:
assert _load().check_url(url) == url
@pytest.mark.parametrize(
"url",
[
"http://node.example.com",
"http://192.168.1.10:8000",
"ftp://node.example.com",
"node.example.com",
],
)
def test_rejects_remote_cleartext_and_other_schemes(url: str) -> None:
with pytest.raises(SystemExit):
_load().check_url(url)