diff --git a/migrations/versions/f3a1c7b9e2d4_add_refunds_table.py b/migrations/versions/3a0fbd387f10_add_refunds_table.py similarity index 94% rename from migrations/versions/f3a1c7b9e2d4_add_refunds_table.py rename to migrations/versions/3a0fbd387f10_add_refunds_table.py index e0b3c823..e53e4484 100644 --- a/migrations/versions/f3a1c7b9e2d4_add_refunds_table.py +++ b/migrations/versions/3a0fbd387f10_add_refunds_table.py @@ -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 diff --git a/routstr/balance.py b/routstr/balance.py index 7c37331a..2f4140cf 100644 --- a/routstr/balance.py +++ b/routstr/balance.py @@ -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 diff --git a/routstr/refund.py b/routstr/refund.py index 0ff8b9a5..5c328a57 100644 --- a/routstr/refund.py +++ b/routstr/refund.py @@ -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( diff --git a/scripts/refund_token_to_lightning.py b/scripts/refund_token_to_lightning.py new file mode 100644 index 00000000..df5979d3 --- /dev/null +++ b/scripts/refund_token_to_lightning.py @@ -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 [--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() diff --git a/tests/integration/test_refund_claims.py b/tests/integration/test_refund_claims.py index dac2f70a..57e66795 100644 --- a/tests/integration/test_refund_claims.py +++ b/tests/integration/test_refund_claims.py @@ -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"