This commit is contained in:
9qeklajc
2026-09-16 01:35:03 +02:00
parent 7e1ebcdb34
commit ac47c0dd35
5 changed files with 176 additions and 36 deletions
@@ -1,8 +1,8 @@
"""add refunds table
Revision ID: f3a1c7b9e2d4
Revises: e5a6b7c8d9f0
Create Date: 2026-09-07
Revision ID: 3a0fbd387f10
Revises: a3f1b6c204de
Create Date: 2026-09-16
"""
@@ -10,8 +10,8 @@ import sqlalchemy as sa
import sqlmodel
from alembic import op
revision = "f3a1c7b9e2d4"
down_revision = "e5a6b7c8d9f0"
revision = "3a0fbd387f10"
down_revision = "a3f1b6c204de"
branch_labels = None
depends_on = None
+6 -1
View File
@@ -358,10 +358,15 @@ async def refund_wallet_endpoint(
destination = requested or key.refund_address
if key.total_balance <= 0:
if paid := await refund.latest_terminal(session, key):
paid = await refund.latest_terminal(session, key)
if paid and paid.method == "lightning":
return refund.describe(paid)
# cashu_transactions tracks collection and sweeping, so it takes
# precedence; the claim row covers a token whose ledger write failed.
if persisted := await _get_persisted_api_key_refund(key, session):
return persisted
if paid:
return refund.describe(paid)
if key.reserved_balance > 0:
# Release only durable reservations old enough to be stale. A newer
+65 -30
View File
@@ -206,13 +206,17 @@ async def hold(session: AsyncSession, refund: Refund, quote_id: str | None) -> N
async def latest_terminal(session: AsyncSession, key: ApiKey) -> Refund | None:
"""Latest paid Lightning refund. Cashu is served from cashu_transactions."""
"""Latest paid refund of either method.
Cashu tokens are normally served from cashu_transactions, which tracks
collection and sweeping; the claim row is the fallback when that ledger
write failed after the token was already issued.
"""
result = await session.exec(
select(Refund)
.where(Refund.api_key_hashed_key == key.hashed_key)
.where(Refund.status == "paid")
.where(Refund.method == "lightning")
.order_by(col(Refund.created_at).desc())
.order_by(col(Refund.created_at).desc(), col(Refund.updated_at).desc())
)
return result.first()
@@ -230,8 +234,7 @@ def describe(refund: Refund) -> dict[str, str]:
return body
async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
amount = amount_in_unit(refund.amount_msats, refund.unit)
async def _pay_lightning(session: AsyncSession, refund: Refund) -> None:
quote_id: str | None = None
async def capture_quote(quote: str) -> None:
@@ -240,31 +243,13 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
await record_quote(refund, quote)
try:
if refund.method == "lightning":
await send_to_lnurl(
amount,
refund.unit,
refund.mint_url,
str(refund.destination),
on_melt_quote=capture_quote,
)
await settle(session, refund, quote_id=quote_id)
else:
token = await send_token(amount, refund.unit, refund.mint_url)
mint_url = token_mint_url(token, refund.mint_url)
await settle(session, refund, token=token, mint_url=mint_url)
await store_cashu_transaction(
token=token,
amount=amount,
unit=refund.unit,
mint_url=mint_url,
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=refund.api_key_hashed_key,
)
refund.token = token
refund.mint_url = mint_url
await send_to_lnurl(
amount_in_unit(refund.amount_msats, refund.unit),
refund.unit,
refund.mint_url,
str(refund.destination),
on_melt_quote=capture_quote,
)
except MeltOutcomeAmbiguousError as e:
await hold(session, refund, quote_id)
logger.error(
@@ -276,6 +261,53 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
"quote_id": quote_id,
},
)
raise
await settle(session, refund, quote_id=quote_id)
async def _pay_cashu(session: AsyncSession, refund: Refund) -> None:
amount = amount_in_unit(refund.amount_msats, refund.unit)
token = await send_token(amount, refund.unit, refund.mint_url)
mint_url = token_mint_url(token, refund.mint_url)
await settle(session, refund, token=token, mint_url=mint_url)
refund.token = token
refund.mint_url = mint_url
async def _record_cashu_payout(refund: Refund) -> None:
"""Ledger write for an issued token; the claim row already holds the token,
so a failure here must not fail the request or release the balance."""
try:
await store_cashu_transaction(
token=str(refund.token),
amount=amount_in_unit(refund.amount_msats, refund.unit),
unit=refund.unit,
mint_url=refund.mint_url,
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=refund.api_key_hashed_key,
)
except Exception as e:
logger.error(
"refund token issued but cashu transaction was not recorded",
extra={
"refund_id": refund.id,
"error": str(e),
"error_type": type(e).__name__,
"key_hash": refund.api_key_hashed_key[:8],
},
)
async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
try:
if refund.method == "lightning":
await _pay_lightning(session, refund)
else:
await _pay_cashu(session, refund)
except MeltOutcomeAmbiguousError:
# Already held by _pay_lightning; releasing here would pay out twice.
raise HTTPException(
status_code=502,
detail=(
@@ -303,6 +335,9 @@ async def execute(session: AsyncSession, refund: Refund) -> dict[str, str]:
raise HTTPException(status_code=503, detail="Mint service unavailable")
raise HTTPException(status_code=500, detail="Refund failed")
if refund.method == "cashu":
await _record_cashu_payout(refund)
refund.status = "paid"
refund.claimed_at = None
logger.info(
+65
View File
@@ -0,0 +1,65 @@
"""Redeem a cashu token into a balance and pay it out to a Lightning address.
Usage:
python scripts/refund_token_to_lightning.py <cashu-token> <lightning-address> [--url http://localhost:8000]
Steps:
1. POST /v1/balance/create redeems the token into a fresh API key
2. POST /v1/balance/refund pays the full balance to the Lightning address
A 502 from the refund means the melt was dispatched but unconfirmed; the
balance is withheld until the server reconciles it. Re-run with the printed
API key to check whether it settled.
"""
import argparse
import sys
import httpx
def create_balance(client: httpx.Client, token: str) -> str:
response = client.post("/v1/balance/create", json={"initial_balance_token": token})
response.raise_for_status()
data = response.json()
print(f"Redeemed token: balance {data['balance']} msats, key {data['api_key']}")
return str(data["api_key"])
def refund_to_lightning(client: httpx.Client, api_key: str, address: str) -> dict:
response = client.post(
"/v1/balance/refund",
headers={"Authorization": f"Bearer {api_key}"},
json={"lightning_address": address},
)
if response.status_code >= 400:
print(f"Refund failed ({response.status_code}): {response.text}")
sys.exit(1)
return dict(response.json())
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("token", help="cashu token, or sk-... key from a prior run")
parser.add_argument("lightning_address", help="Lightning address or LNURL")
parser.add_argument("--url", default="http://localhost:8000", help="routstr URL")
args = parser.parse_args()
with httpx.Client(base_url=args.url, timeout=120.0) as client:
api_key = (
args.token
if args.token.startswith("sk-")
else create_balance(client, args.token)
)
result = refund_to_lightning(client, api_key, args.lightning_address)
amount = result.get("sats") or result.get("msats")
unit = "sats" if "sats" in result else "msats"
print(
f"Refund {result['refund_id']} {result['status']}: "
f"{amount} {unit} -> {result.get('recipient', args.lightning_address)}"
)
if __name__ == "__main__":
main()
+35
View File
@@ -581,3 +581,38 @@ async def test_endpoint_refund_while_ambiguous_returns_409(
assert exc_info.value.status_code == 409
send.assert_not_awaited()
assert (await _load_key(integration_session)).balance == 2_000_000
@pytest.mark.asyncio
async def test_cashu_token_survives_failed_ledger_write(
integration_session: AsyncSession, patched_db_engine: None
) -> None:
"""The token is issued once the mint signs it; a failed cashu_transactions
insert must neither fail the request nor release the balance, and a retry
must replay the token from the claim row."""
await _seed_key(integration_session)
with (
patch("routstr.refund.send_token", AsyncMock(return_value="cashuAtoken")),
patch("routstr.refund.token_mint_url", lambda token, mint: mint),
patch(
"routstr.refund.store_cashu_transaction",
AsyncMock(side_effect=RuntimeError("db down")),
),
):
first = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=integration_session,
)
assert isinstance(first, dict)
assert (first["token"], first["status"]) == ("cashuAtoken", "paid")
assert (await _load_key(integration_session)).balance == 0
second = await refund_wallet_endpoint(
authorization=f"Bearer sk-{KEY_HASH}",
x_cashu=None,
session=integration_session,
)
assert isinstance(second, dict)
assert second["refund_id"] == first["refund_id"]
assert second["token"] == "cashuAtoken"