mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
clean up
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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-")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
Reference in New Issue
Block a user