mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
feat: show Lightning payouts in transaction history
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Add direction to lightning_invoices
|
||||
|
||||
Revision ID: e4c7a1b9d520
|
||||
Revises: 3a0fbd387f10
|
||||
Create Date: 2026-09-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e4c7a1b9d520"
|
||||
down_revision = "3a0fbd387f10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"lightning_invoices",
|
||||
sa.Column("direction", sa.String(), nullable=False, server_default="in"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("lightning_invoices", "direction")
|
||||
@@ -2051,6 +2051,7 @@ async def get_transactions_api(
|
||||
async def get_lightning_invoices_api(
|
||||
status: str | None = None,
|
||||
purpose: str | None = None,
|
||||
direction: str | None = None,
|
||||
search: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
@@ -2063,6 +2064,8 @@ async def get_lightning_invoices_api(
|
||||
base = base.where(LightningInvoice.status == status)
|
||||
if purpose:
|
||||
base = base.where(LightningInvoice.purpose == purpose)
|
||||
if direction:
|
||||
base = base.where(LightningInvoice.direction == direction)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
base = base.where(
|
||||
|
||||
+57
-2
@@ -509,14 +509,17 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
||||
status: str = Field(
|
||||
default="pending",
|
||||
description=(
|
||||
"pending, settlement_pending, paid, expired, cancelled, "
|
||||
"pending, settlement_pending, paid, failed, expired, cancelled, "
|
||||
"reconciliation_required"
|
||||
),
|
||||
)
|
||||
api_key_hash: str | None = Field(
|
||||
default=None, description="Associated API key hash for topup operations"
|
||||
)
|
||||
purpose: str = Field(description="create or topup")
|
||||
direction: str = Field(
|
||||
default="in", description="in for incoming invoices, out for payouts"
|
||||
)
|
||||
purpose: str = Field(description="create, topup or payout")
|
||||
mint_url: str | None = Field(
|
||||
default=None,
|
||||
description="Mint URL where the quote was created (fallback tracking)",
|
||||
@@ -1005,6 +1008,58 @@ async def complete_routstr_fee_payout(
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
async def record_lightning_payout(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
quote_id: str,
|
||||
bolt11: str,
|
||||
amount_sats: int,
|
||||
mint_url: str,
|
||||
destination: str,
|
||||
) -> None:
|
||||
"""Record a dispatched payout so it shows up in the Lightning history."""
|
||||
session.add(
|
||||
LightningInvoice(
|
||||
id=uuid.uuid4().hex,
|
||||
bolt11=bolt11,
|
||||
amount_sats=amount_sats,
|
||||
description=f"Payout to {destination}",
|
||||
payment_hash=quote_id,
|
||||
status="pending",
|
||||
direction="out",
|
||||
purpose="payout",
|
||||
mint_url=mint_url,
|
||||
# Payouts settle or fail at the mint; they never expire on our side.
|
||||
expires_at=int(time.time()),
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def settle_lightning_payout(
|
||||
session: AsyncSession,
|
||||
quote_id: str,
|
||||
*,
|
||||
status: str,
|
||||
amount_sats: int | None = None,
|
||||
) -> None:
|
||||
result = await session.exec(
|
||||
select(LightningInvoice)
|
||||
.where(col(LightningInvoice.payment_hash) == quote_id)
|
||||
.where(col(LightningInvoice.direction) == "out")
|
||||
)
|
||||
payout = result.first()
|
||||
if payout is None:
|
||||
return
|
||||
payout.status = status
|
||||
if status == "paid":
|
||||
payout.paid_at = int(time.time())
|
||||
if amount_sats is not None:
|
||||
payout.amount_sats = amount_sats
|
||||
session.add(payout)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def total_user_liability(db_session: AsyncSession) -> int:
|
||||
"""Return all outstanding user funds in millisatoshis.
|
||||
|
||||
|
||||
@@ -942,6 +942,7 @@ async def _expire_overdue_invoices(now: int) -> int:
|
||||
expired = await expiry_session.exec( # type: ignore[call-overload]
|
||||
update(LightningInvoice)
|
||||
.where(
|
||||
col(LightningInvoice.direction) == "in",
|
||||
col(LightningInvoice.status) == "pending",
|
||||
col(LightningInvoice.expires_at) < now,
|
||||
)
|
||||
@@ -959,13 +960,17 @@ async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) ->
|
||||
logger.info("Expired overdue invoices", extra={"invoice_count": swept})
|
||||
settling = await session.exec(
|
||||
select(LightningInvoice)
|
||||
.where(col(LightningInvoice.status) == "settlement_pending")
|
||||
.where(
|
||||
col(LightningInvoice.direction) == "in",
|
||||
col(LightningInvoice.status) == "settlement_pending",
|
||||
)
|
||||
.order_by(col(LightningInvoice.created_at))
|
||||
.limit(INVOICE_WATCH_BATCH_LIMIT // 2)
|
||||
)
|
||||
unpaid = await session.exec(
|
||||
select(LightningInvoice)
|
||||
.where(
|
||||
col(LightningInvoice.direction) == "in",
|
||||
col(LightningInvoice.status) == "pending",
|
||||
col(LightningInvoice.expires_at) >= now,
|
||||
)
|
||||
@@ -975,6 +980,7 @@ async def _process_invoice_watch_batch(session: AsyncSession, prev_now: int) ->
|
||||
recoverable = await session.exec(
|
||||
select(LightningInvoice)
|
||||
.where(
|
||||
col(LightningInvoice.direction) == "in",
|
||||
col(LightningInvoice.status) == "expired",
|
||||
col(LightningInvoice.expires_at) > now - INVOICE_EXPIRY_GRACE_SECONDS,
|
||||
)
|
||||
|
||||
@@ -325,7 +325,7 @@ async def raw_send_to_lnurl(
|
||||
unit: str,
|
||||
amount: int | None = None,
|
||||
*,
|
||||
on_melt_quote: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_melt_quote: Callable[[str, str], Awaitable[None]] | None = None,
|
||||
) -> int:
|
||||
"""Send funds to an LNURL address.
|
||||
|
||||
@@ -413,7 +413,7 @@ async def raw_send_to_lnurl(
|
||||
raise LNURLError("Cashu melt fees exceed the requested gross amount")
|
||||
|
||||
if on_melt_quote is not None:
|
||||
await on_melt_quote(melt_quote_resp.quote)
|
||||
await on_melt_quote(melt_quote_resp.quote, bolt11_invoice)
|
||||
|
||||
assert selected_proofs is not None
|
||||
proofs = selected_proofs
|
||||
|
||||
+101
-11
@@ -35,7 +35,7 @@ from .mint import (
|
||||
mint_cooldown_remaining,
|
||||
run_mint_operation,
|
||||
)
|
||||
from .payment.lnurl import raw_send_to_lnurl
|
||||
from .payment.lnurl import MeltOutcomeAmbiguousError, raw_send_to_lnurl
|
||||
|
||||
# cashu 0.20.x passes the `proxies` kwarg httpx removed in 0.28; see the module
|
||||
# docstring. Installed at import so no mint call can run before the patch.
|
||||
@@ -1540,6 +1540,27 @@ async def fetch_all_balances(
|
||||
)
|
||||
|
||||
|
||||
async def _settle_payout_history(
|
||||
quote_id: str, *, status: str, amount_sats: int | None = None
|
||||
) -> None:
|
||||
"""Best-effort history update after the external payment outcome is known."""
|
||||
try:
|
||||
async with db.create_session() as session:
|
||||
await db.settle_lightning_payout(
|
||||
session, quote_id, status=status, amount_sats=amount_sats
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to update Lightning payout history",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"quote_id": quote_id,
|
||||
"status": status,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
|
||||
"""Send only conservatively proven owner funds for one wallet."""
|
||||
try:
|
||||
@@ -1588,13 +1609,51 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
|
||||
else _sats_to_msats(settings.min_payout_sat)
|
||||
)
|
||||
if available_balance > min_amount:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet,
|
||||
proofs,
|
||||
settings.receive_ln_address,
|
||||
unit,
|
||||
amount=available_balance,
|
||||
)
|
||||
payout_quote_id: str | None = None
|
||||
|
||||
async def record_payout(quote_id: str, bolt11: str) -> None:
|
||||
nonlocal payout_quote_id
|
||||
payout_quote_id = quote_id
|
||||
async with db.create_session() as session:
|
||||
await db.record_lightning_payout(
|
||||
session,
|
||||
quote_id=quote_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=(
|
||||
available_balance
|
||||
if unit == "sat"
|
||||
else _msats_to_sats(available_balance)
|
||||
),
|
||||
mint_url=mint_url,
|
||||
destination=settings.receive_ln_address,
|
||||
)
|
||||
|
||||
try:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
wallet,
|
||||
proofs,
|
||||
settings.receive_ln_address,
|
||||
unit,
|
||||
amount=available_balance,
|
||||
on_melt_quote=record_payout,
|
||||
)
|
||||
except Exception as e:
|
||||
if payout_quote_id is not None:
|
||||
await _settle_payout_history(
|
||||
payout_quote_id,
|
||||
status=(
|
||||
"reconciliation_required"
|
||||
if isinstance(e, MeltOutcomeAmbiguousError)
|
||||
else "failed"
|
||||
),
|
||||
)
|
||||
raise
|
||||
if payout_quote_id is not None:
|
||||
await _settle_payout_history(
|
||||
payout_quote_id,
|
||||
status="paid",
|
||||
amount_sats=_msats_to_sats(amount_received),
|
||||
)
|
||||
logger.info(
|
||||
"Payout sent successfully",
|
||||
extra={
|
||||
@@ -1866,6 +1925,7 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
payout_unit,
|
||||
)
|
||||
if completed:
|
||||
await _settle_payout_history(payout_quote_id, status="paid")
|
||||
logger.info(
|
||||
"Routstr fee payout reconciled as paid",
|
||||
extra={"payout_quote_id": payout_quote_id},
|
||||
@@ -1880,6 +1940,9 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
payout_unit,
|
||||
)
|
||||
if restored:
|
||||
await _settle_payout_history(
|
||||
payout_quote_id, status="failed"
|
||||
)
|
||||
logger.warning(
|
||||
"Routstr fee payout reconciled as unpaid and restored for retry",
|
||||
extra={"payout_quote_id": payout_quote_id},
|
||||
@@ -1910,7 +1973,7 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
|
||||
attempt_quote_id: str | None = None
|
||||
|
||||
async def checkpoint_quote(quote_id: str) -> None:
|
||||
async def checkpoint_quote(quote_id: str, bolt11: str) -> None:
|
||||
nonlocal attempt_quote_id
|
||||
async with db.create_session() as session:
|
||||
checkpointed = await db.reset_routstr_fee(
|
||||
@@ -1923,6 +1986,15 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
if not checkpointed:
|
||||
raise _RoutstrFeePayoutAlreadyClaimed
|
||||
attempt_quote_id = quote_id
|
||||
async with db.create_session() as session:
|
||||
await db.record_lightning_payout(
|
||||
session,
|
||||
quote_id=quote_id,
|
||||
bolt11=bolt11,
|
||||
amount_sats=accumulated_sats,
|
||||
mint_url=settings.primary_mint,
|
||||
destination=ROUTSTR_LN_ADDRESS,
|
||||
)
|
||||
|
||||
try:
|
||||
amount_received = await raw_send_to_lnurl(
|
||||
@@ -1949,6 +2021,12 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
extra={"payout_in_progress_msats": paid_msats},
|
||||
exc_info=isinstance(e, Exception),
|
||||
)
|
||||
async with db.create_session() as session:
|
||||
await db.settle_lightning_payout(
|
||||
session,
|
||||
attempt_quote_id,
|
||||
status="reconciliation_required",
|
||||
)
|
||||
if not isinstance(e, Exception):
|
||||
raise
|
||||
continue
|
||||
@@ -1969,6 +2047,9 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
extra={"payout_in_progress_msats": paid_msats},
|
||||
exc_info=isinstance(e, Exception),
|
||||
)
|
||||
await _settle_payout_history(
|
||||
attempt_quote_id, status="reconciliation_required"
|
||||
)
|
||||
if not isinstance(e, Exception):
|
||||
raise
|
||||
continue
|
||||
@@ -1977,8 +2058,17 @@ async def periodic_routstr_fee_payout() -> None:
|
||||
"Routstr fee payout sent but checkpoint was not completed; awaiting quote reconciliation",
|
||||
extra={"payout_in_progress_msats": paid_msats},
|
||||
)
|
||||
await _settle_payout_history(
|
||||
attempt_quote_id, status="reconciliation_required"
|
||||
)
|
||||
continue
|
||||
|
||||
await _settle_payout_history(
|
||||
attempt_quote_id,
|
||||
status="paid",
|
||||
amount_sats=_msats_to_sats(amount_received),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Routstr fee payout sent",
|
||||
extra={
|
||||
@@ -1995,8 +2085,8 @@ 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:
|
||||
) -> Callable[[str, str], Awaitable[None]]:
|
||||
async def callback(quote_id: str, _bolt11: str) -> None:
|
||||
await notify(quote_id, mint)
|
||||
|
||||
return callback
|
||||
|
||||
@@ -17,8 +17,10 @@ import pytest
|
||||
from cashu.core.base import Proof
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.core import db
|
||||
from routstr.core.db import ApiKey, LightningInvoice
|
||||
from routstr.lightning import _create_api_key_record
|
||||
|
||||
@@ -81,6 +83,41 @@ async def test_invoice_persists_validity_date(
|
||||
assert stored.validity_date == expiry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_payout_history_is_recorded_and_settled(
|
||||
integration_session: AsyncSession,
|
||||
) -> None:
|
||||
await db.record_lightning_payout(
|
||||
integration_session,
|
||||
quote_id="payout-quote",
|
||||
bolt11="lnbc1payout",
|
||||
amount_sats=1_000,
|
||||
mint_url="https://mint.test",
|
||||
destination="owner@example.com",
|
||||
)
|
||||
|
||||
result = await integration_session.exec(
|
||||
select(LightningInvoice).where(LightningInvoice.payment_hash == "payout-quote")
|
||||
)
|
||||
payout = result.one()
|
||||
assert payout.direction == "out"
|
||||
assert payout.purpose == "payout"
|
||||
assert payout.status == "pending"
|
||||
assert payout.mint_url == "https://mint.test"
|
||||
|
||||
await db.settle_lightning_payout(
|
||||
integration_session,
|
||||
"payout-quote",
|
||||
status="paid",
|
||||
amount_sats=995,
|
||||
)
|
||||
await integration_session.refresh(payout)
|
||||
|
||||
assert payout.status == "paid"
|
||||
assert payout.amount_sats == 995
|
||||
assert payout.paid_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Propagation to ApiKey
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -58,7 +58,8 @@ async def test_invoice_read_transaction_closes_before_external_mint_io(
|
||||
return wallet
|
||||
|
||||
with patch(
|
||||
"routstr.lightning.get_wallet", side_effect=get_wallet_without_open_db_transaction
|
||||
"routstr.lightning.get_wallet",
|
||||
side_effect=get_wallet_without_open_db_transaction,
|
||||
):
|
||||
await check_invoice_payment(stored, integration_session)
|
||||
|
||||
@@ -191,9 +192,7 @@ async def test_failed_final_commit_rolls_back_claim_and_credit_for_retry(
|
||||
assert unchanged.balance == 100_000
|
||||
|
||||
async with AsyncSession(integration_engine, expire_on_commit=False) as retry:
|
||||
settled, _ = await _finalize_invoice_settlement(
|
||||
snapshot, retry, 1_700_000_001
|
||||
)
|
||||
settled, _ = await _finalize_invoice_settlement(snapshot, retry, 1_700_000_001)
|
||||
assert settled
|
||||
|
||||
async with AsyncSession(integration_engine, expire_on_commit=False) as verify:
|
||||
@@ -302,9 +301,7 @@ async def test_expiry_cas_cannot_overwrite_concurrent_paid_invoice(
|
||||
assert result.rowcount == 1
|
||||
await paid.commit()
|
||||
|
||||
expired = await _expire_invoice_if_authoritatively_unpaid(
|
||||
stale, caller, True
|
||||
)
|
||||
expired = await _expire_invoice_if_authoritatively_unpaid(stale, caller, True)
|
||||
|
||||
assert expired is False
|
||||
assert stale.status == "paid"
|
||||
@@ -381,8 +378,9 @@ async def test_sweep_expires_only_overdue_pending_invoices(
|
||||
overdue = _lightning_invoice(expires_at=now - 1)
|
||||
fresh = _lightning_invoice(expires_at=now + 3600)
|
||||
settling = _lightning_invoice(expires_at=now - 1, status="settlement_pending")
|
||||
outgoing = _lightning_invoice(expires_at=now - 1, direction="out")
|
||||
async with AsyncSession(integration_engine, expire_on_commit=False) as seed:
|
||||
seed.add_all([overdue, fresh, settling])
|
||||
seed.add_all([overdue, fresh, settling, outgoing])
|
||||
await seed.commit()
|
||||
|
||||
await _expire_overdue_invoices(now)
|
||||
@@ -392,6 +390,7 @@ async def test_sweep_expires_only_overdue_pending_invoices(
|
||||
(overdue, "expired"),
|
||||
(fresh, "pending"),
|
||||
(settling, "settlement_pending"),
|
||||
(outgoing, "pending"),
|
||||
):
|
||||
stored = await verify.get(LightningInvoice, invoice.id)
|
||||
assert stored is not None
|
||||
@@ -409,8 +408,11 @@ async def test_watch_batch_expires_overdue_invoices_and_keeps_settling_rows(
|
||||
settling = _lightning_invoice(
|
||||
expires_at=now - 86_400, created_at=now - 86_400, status="settlement_pending"
|
||||
)
|
||||
outgoing = _lightning_invoice(
|
||||
expires_at=now + 3600, created_at=now, direction="out"
|
||||
)
|
||||
async with AsyncSession(integration_engine, expire_on_commit=False) as seed:
|
||||
seed.add_all([overdue, fresh, settling])
|
||||
seed.add_all([overdue, fresh, settling, outgoing])
|
||||
await seed.commit()
|
||||
|
||||
polled: list[str] = []
|
||||
@@ -425,6 +427,7 @@ async def test_watch_batch_expires_overdue_invoices_and_keeps_settling_rows(
|
||||
|
||||
assert fresh.id in polled
|
||||
assert settling.id in polled
|
||||
assert outgoing.id not in polled
|
||||
|
||||
async with AsyncSession(integration_engine, expire_on_commit=False) as verify:
|
||||
stored = await verify.get(LightningInvoice, overdue.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
@@ -29,6 +29,21 @@ def _session_context(session: Mock) -> _SessionContext:
|
||||
return _SessionContext(session)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_lightning_payout_history() -> Generator[
|
||||
tuple[AsyncMock, AsyncMock], None, None
|
||||
]:
|
||||
with (
|
||||
patch(
|
||||
"routstr.wallet.db.record_lightning_payout", new_callable=AsyncMock
|
||||
) as record,
|
||||
patch(
|
||||
"routstr.wallet.db.settle_lightning_payout", new_callable=AsyncMock
|
||||
) as settle,
|
||||
):
|
||||
yield record, settle
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fee_payout_checkpoint_is_atomic_and_durable() -> None:
|
||||
engine = create_async_engine("sqlite+aiosqlite://")
|
||||
@@ -178,7 +193,7 @@ async def test_fee_payout_prepares_wallet_then_checkpoints_before_sending() -> N
|
||||
|
||||
async def send(*_args: object, **kwargs: object) -> int:
|
||||
checkpoint_quote = kwargs["on_melt_quote"]
|
||||
await checkpoint_quote("quote-1") # type: ignore[operator]
|
||||
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
|
||||
events.append("send")
|
||||
return 5
|
||||
|
||||
@@ -256,7 +271,7 @@ async def test_fee_payout_lost_checkpoint_race_does_not_send() -> None:
|
||||
|
||||
async def send(*_args: object, **kwargs: object) -> int:
|
||||
checkpoint_quote = kwargs["on_melt_quote"]
|
||||
await checkpoint_quote("quote-1") # type: ignore[operator]
|
||||
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
|
||||
await dispatched()
|
||||
return 5
|
||||
|
||||
@@ -289,7 +304,9 @@ async def test_fee_payout_lost_checkpoint_race_does_not_send() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fee_payout_finalizes_a_paid_unresolved_quote_without_resending() -> None:
|
||||
async def test_fee_payout_finalizes_a_paid_unresolved_quote_without_resending(
|
||||
_mock_lightning_payout_history: tuple[AsyncMock, AsyncMock],
|
||||
) -> None:
|
||||
session = Mock()
|
||||
fee = SimpleNamespace(
|
||||
accumulated_msats=10_000,
|
||||
@@ -331,10 +348,14 @@ async def test_fee_payout_finalizes_a_paid_unresolved_quote_without_resending()
|
||||
)
|
||||
restore.assert_not_awaited()
|
||||
send.assert_not_awaited()
|
||||
_, settle = _mock_lightning_payout_history
|
||||
settle.assert_awaited_once_with(session, "quote-1", status="paid", amount_sats=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fee_payout_restores_only_an_unpaid_quote_and_retries() -> None:
|
||||
async def test_fee_payout_restores_only_an_unpaid_quote_and_retries(
|
||||
_mock_lightning_payout_history: tuple[AsyncMock, AsyncMock],
|
||||
) -> None:
|
||||
session = Mock()
|
||||
unresolved_fee = SimpleNamespace(
|
||||
accumulated_msats=10_000,
|
||||
@@ -351,7 +372,9 @@ async def test_fee_payout_restores_only_an_unpaid_quote_and_retries() -> None:
|
||||
)
|
||||
|
||||
async def send(*_args: object, **kwargs: object) -> int:
|
||||
await kwargs["on_melt_quote"]("quote-2") # type: ignore[index,operator]
|
||||
await kwargs["on_melt_quote"]( # type: ignore[index,operator]
|
||||
"quote-2", "lnbc1payout"
|
||||
)
|
||||
return 15
|
||||
|
||||
with (
|
||||
@@ -398,6 +421,8 @@ async def test_fee_payout_restores_only_an_unpaid_quote_and_retries() -> None:
|
||||
session, 15_000, "quote-2", wallet.settings.primary_mint, "sat"
|
||||
)
|
||||
raw_send.assert_awaited_once()
|
||||
_, settle = _mock_lightning_payout_history
|
||||
settle.assert_any_await(session, "quote-1", status="failed", amount_sats=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -579,7 +604,7 @@ async def test_fee_payout_keeps_checkpoint_when_send_outcome_is_unknown() -> Non
|
||||
|
||||
async def send(*_args: object, **kwargs: object) -> int:
|
||||
checkpoint_quote = kwargs["on_melt_quote"]
|
||||
await checkpoint_quote("quote-1") # type: ignore[operator]
|
||||
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
|
||||
raise TimeoutError("unknown outcome")
|
||||
|
||||
with (
|
||||
@@ -620,7 +645,7 @@ async def test_fee_payout_cancellation_during_send_alerts_and_propagates() -> No
|
||||
|
||||
async def cancel_send(*_args: object, **kwargs: object) -> int:
|
||||
checkpoint_quote = kwargs["on_melt_quote"]
|
||||
await checkpoint_quote("quote-1") # type: ignore[operator]
|
||||
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
|
||||
raise asyncio.CancelledError
|
||||
|
||||
with (
|
||||
@@ -666,6 +691,8 @@ async def test_fee_payout_completion_failures_use_sent_checkpoint_alert(
|
||||
side_effect=[
|
||||
_session_context(session),
|
||||
_session_context(session),
|
||||
_session_context(session),
|
||||
RuntimeError("pool unavailable"),
|
||||
RuntimeError("pool unavailable"),
|
||||
]
|
||||
)
|
||||
@@ -675,7 +702,7 @@ async def test_fee_payout_completion_failures_use_sent_checkpoint_alert(
|
||||
|
||||
async def send(*_args: object, **kwargs: object) -> int:
|
||||
checkpoint_quote = kwargs["on_melt_quote"]
|
||||
await checkpoint_quote("quote-1") # type: ignore[operator]
|
||||
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
|
||||
return 5
|
||||
|
||||
with (
|
||||
@@ -726,7 +753,7 @@ async def test_fee_payout_releases_db_connection_during_send(tmp_path: object) -
|
||||
async def send(*_args: object, **kwargs: object) -> int:
|
||||
assert engine.pool.checkedout() == 0 # type: ignore[attr-defined]
|
||||
checkpoint_quote = kwargs["on_melt_quote"]
|
||||
await checkpoint_quote("quote-1") # type: ignore[operator]
|
||||
await checkpoint_quote("quote-1", "lnbc1payout") # type: ignore[operator]
|
||||
assert engine.pool.checkedout() == 0 # type: ignore[attr-defined]
|
||||
return 5
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ async def test_raw_send_to_lnurl_requotes_for_exact_input_fees_without_recursion
|
||||
|
||||
assert paid == 485_000
|
||||
assert wallet.melt_quote.await_count == 2
|
||||
checkpoint.assert_awaited_once_with("q2")
|
||||
checkpoint.assert_awaited_once_with("q2", "lnbc1...")
|
||||
wallet.select_to_send.assert_not_called()
|
||||
selected = wallet.melt.await_args.kwargs["proofs"]
|
||||
assert sum(proof.amount for proof in selected) == 500
|
||||
@@ -358,9 +358,7 @@ def _patch_getaddrinfo(ip: str) -> Any:
|
||||
|
||||
loop = MagicMock()
|
||||
loop.getaddrinfo = fake_getaddrinfo
|
||||
return patch.object(
|
||||
lnurl_module.asyncio, "get_running_loop", return_value=loop
|
||||
)
|
||||
return patch.object(lnurl_module.asyncio, "get_running_loop", return_value=loop)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -319,8 +319,9 @@ async def test_raw_send_to_lnurl_checkpoints_quote_before_melt_dispatch() -> Non
|
||||
wallet, proofs = _wallet()
|
||||
events: list[str] = []
|
||||
|
||||
async def checkpoint(quote_id: str) -> None:
|
||||
async def checkpoint(quote_id: str, bolt11: str) -> None:
|
||||
assert quote_id == "q"
|
||||
assert bolt11 == "lnbc1..."
|
||||
events.append("checkpoint")
|
||||
|
||||
async def melt(**_kwargs: object) -> MagicMock:
|
||||
|
||||
@@ -13,7 +13,7 @@ Covers two regressions from the auto-payout / primary-mint audit
|
||||
from collections.abc import Callable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -53,11 +53,20 @@ def _one_cycle_sleep() -> Callable[[float], Coroutine[Any, Any, None]]:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> None:
|
||||
"""primary_mint absent from cashu_mints is still paid out."""
|
||||
"""primary_mint absent from cashu_mints is paid out and recorded."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
get_wallet = AsyncMock(return_value=MagicMock())
|
||||
raw_send = AsyncMock(return_value=1000)
|
||||
record_payout = AsyncMock()
|
||||
settle_payout = AsyncMock()
|
||||
|
||||
async def send(*args: object, **kwargs: object) -> int:
|
||||
await kwargs["on_melt_quote"]( # type: ignore[index,operator]
|
||||
"quote-1", "lnbc1payout"
|
||||
)
|
||||
return 1_000_000
|
||||
|
||||
raw_send = AsyncMock(side_effect=send)
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", []),
|
||||
@@ -84,6 +93,8 @@ async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> Non
|
||||
"routstr.wallet.db.total_user_liability",
|
||||
AsyncMock(return_value=0),
|
||||
),
|
||||
patch("routstr.wallet.db.record_lightning_payout", record_payout),
|
||||
patch("routstr.wallet.db.settle_lightning_payout", settle_payout),
|
||||
patch("routstr.wallet.raw_send_to_lnurl", raw_send),
|
||||
):
|
||||
with pytest.raises(_LoopBreak):
|
||||
@@ -92,6 +103,20 @@ async def test_periodic_payout_includes_primary_mint_not_in_cashu_mints() -> Non
|
||||
processed = {call.args[0] for call in get_wallet.await_args_list}
|
||||
assert processed == {"http://primary:3338"}
|
||||
assert raw_send.await_count >= 1
|
||||
record_payout.assert_awaited_once_with(
|
||||
ANY,
|
||||
quote_id="quote-1",
|
||||
bolt11="lnbc1payout",
|
||||
amount_sats=100_000,
|
||||
mint_url="http://primary:3338",
|
||||
destination="owner@ln.tld",
|
||||
)
|
||||
settle_payout.assert_awaited_once_with(
|
||||
ANY,
|
||||
"quote-1",
|
||||
status="paid",
|
||||
amount_sats=1_000,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -264,7 +264,8 @@ function LightningInvoiceTable({
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No invoices found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Lightning invoices created via /lightning/invoice will show here.
|
||||
Lightning invoices created via /lightning/invoice and payouts sent
|
||||
to your Lightning address will show here.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
@@ -290,6 +291,33 @@ function LightningInvoiceTable({
|
||||
Expired
|
||||
</Badge>
|
||||
);
|
||||
if (status === 'failed')
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-red-500/20 bg-red-500/10 text-red-500'
|
||||
>
|
||||
Failed
|
||||
</Badge>
|
||||
);
|
||||
if (status === 'settlement_pending')
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-amber-500/20 bg-amber-500/10 text-amber-500'
|
||||
>
|
||||
Settling
|
||||
</Badge>
|
||||
);
|
||||
if (status === 'reconciliation_required')
|
||||
return (
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='border-amber-500/20 bg-amber-500/10 text-amber-500'
|
||||
>
|
||||
Reconciling
|
||||
</Badge>
|
||||
);
|
||||
if (status === 'cancelled')
|
||||
return (
|
||||
<Badge
|
||||
@@ -315,6 +343,7 @@ function LightningInvoiceTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Purpose</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
@@ -328,6 +357,11 @@ function LightningInvoiceTable({
|
||||
<TableBody>
|
||||
{invoices.map((inv) => (
|
||||
<TableRow key={inv.id}>
|
||||
<TableCell>
|
||||
<Badge variant='outline' className='capitalize'>
|
||||
{inv.direction === 'out' ? 'Sent' : 'Received'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className='capitalize'>{inv.purpose}</span>
|
||||
</TableCell>
|
||||
@@ -514,15 +548,27 @@ export default function TransactionsPage() {
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const LIGHTNING_STATUSES = ['pending', 'paid', 'expired', 'cancelled'];
|
||||
const LIGHTNING_STATUSES = [
|
||||
'pending',
|
||||
'settlement_pending',
|
||||
'paid',
|
||||
'failed',
|
||||
'expired',
|
||||
'cancelled',
|
||||
'reconciliation_required',
|
||||
];
|
||||
const lightningStatusParam = LIGHTNING_STATUSES.includes(status)
|
||||
? status
|
||||
: undefined;
|
||||
const lightningDirectionParam = ['in', 'out'].includes(type)
|
||||
? type
|
||||
: undefined;
|
||||
|
||||
const lightningQuery = useQuery({
|
||||
queryKey: [
|
||||
'lightning-invoices',
|
||||
lightningStatusParam,
|
||||
lightningDirectionParam,
|
||||
searchParam,
|
||||
lightningPage,
|
||||
],
|
||||
@@ -530,6 +576,7 @@ export default function TransactionsPage() {
|
||||
AdminService.getLightningInvoices(
|
||||
lightningStatusParam,
|
||||
undefined,
|
||||
lightningDirectionParam,
|
||||
searchParam,
|
||||
PAGE_SIZE,
|
||||
lightningPage * PAGE_SIZE
|
||||
@@ -727,6 +774,13 @@ export default function TransactionsPage() {
|
||||
<SelectItem value='collected'>Collected</SelectItem>
|
||||
<SelectItem value='swept'>Swept</SelectItem>
|
||||
<SelectItem value='paid'>Paid (Lightning)</SelectItem>
|
||||
<SelectItem value='settlement_pending'>
|
||||
Settling (Lightning)
|
||||
</SelectItem>
|
||||
<SelectItem value='failed'>Failed (Lightning)</SelectItem>
|
||||
<SelectItem value='reconciliation_required'>
|
||||
Reconciling (Lightning)
|
||||
</SelectItem>
|
||||
<SelectItem value='expired'>Expired (Lightning)</SelectItem>
|
||||
<SelectItem value='cancelled'>
|
||||
Cancelled (Lightning)
|
||||
|
||||
@@ -938,6 +938,7 @@ export class AdminService {
|
||||
static async getLightningInvoices(
|
||||
status?: string,
|
||||
purpose?: string,
|
||||
direction?: string,
|
||||
search?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
@@ -945,6 +946,7 @@ export class AdminService {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.append('status', status);
|
||||
if (purpose) params.append('purpose', purpose);
|
||||
if (direction) params.append('direction', direction);
|
||||
if (search) params.append('search', search);
|
||||
params.append('limit', limit.toString());
|
||||
params.append('offset', offset.toString());
|
||||
@@ -1304,9 +1306,17 @@ export interface LightningInvoice {
|
||||
amount_sats: number;
|
||||
description: string;
|
||||
payment_hash: string;
|
||||
status: 'pending' | 'paid' | 'expired' | 'cancelled';
|
||||
status:
|
||||
| 'pending'
|
||||
| 'settlement_pending'
|
||||
| 'paid'
|
||||
| 'failed'
|
||||
| 'expired'
|
||||
| 'cancelled'
|
||||
| 'reconciliation_required';
|
||||
api_key_hash: string | null;
|
||||
purpose: 'create' | 'topup';
|
||||
direction: 'in' | 'out';
|
||||
purpose: 'create' | 'topup' | 'payout';
|
||||
created_at: number;
|
||||
expires_at: number;
|
||||
paid_at: number | null;
|
||||
|
||||
Reference in New Issue
Block a user