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