diff --git a/migrations/versions/e4c7a1b9d520_add_direction_to_lightning_invoices.py b/migrations/versions/e4c7a1b9d520_add_direction_to_lightning_invoices.py
new file mode 100644
index 00000000..a0a0964d
--- /dev/null
+++ b/migrations/versions/e4c7a1b9d520_add_direction_to_lightning_invoices.py
@@ -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")
diff --git a/routstr/core/admin.py b/routstr/core/admin.py
index cc140f6c..365e2f68 100644
--- a/routstr/core/admin.py
+++ b/routstr/core/admin.py
@@ -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(
diff --git a/routstr/core/db.py b/routstr/core/db.py
index eb4ba2fe..ff3bc11f 100644
--- a/routstr/core/db.py
+++ b/routstr/core/db.py
@@ -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.
diff --git a/routstr/lightning.py b/routstr/lightning.py
index b3ece68f..bd4e86a3 100644
--- a/routstr/lightning.py
+++ b/routstr/lightning.py
@@ -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,
)
diff --git a/routstr/payment/lnurl.py b/routstr/payment/lnurl.py
index d5fb7e89..5789ee90 100644
--- a/routstr/payment/lnurl.py
+++ b/routstr/payment/lnurl.py
@@ -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
diff --git a/routstr/wallet.py b/routstr/wallet.py
index beecde99..48bca866 100644
--- a/routstr/wallet.py
+++ b/routstr/wallet.py
@@ -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
diff --git a/tests/integration/test_lightning_invoice_constraints.py b/tests/integration/test_lightning_invoice_constraints.py
index 7370ebe4..f715e67d 100644
--- a/tests/integration/test_lightning_invoice_constraints.py
+++ b/tests/integration/test_lightning_invoice_constraints.py
@@ -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
# ---------------------------------------------------------------------------
diff --git a/tests/integration/test_lightning_settlement.py b/tests/integration/test_lightning_settlement.py
index 28803315..320c5e9b 100644
--- a/tests/integration/test_lightning_settlement.py
+++ b/tests/integration/test_lightning_settlement.py
@@ -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)
diff --git a/tests/unit/test_fee_payout_crash_safety.py b/tests/unit/test_fee_payout_crash_safety.py
index c6fc3073..b60f42a0 100644
--- a/tests/unit/test_fee_payout_crash_safety.py
+++ b/tests/unit/test_fee_payout_crash_safety.py
@@ -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
diff --git a/tests/unit/test_lnurl_amount_and_destination.py b/tests/unit/test_lnurl_amount_and_destination.py
index 72986500..3a4f033e 100644
--- a/tests/unit/test_lnurl_amount_and_destination.py
+++ b/tests/unit/test_lnurl_amount_and_destination.py
@@ -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
diff --git a/tests/unit/test_lnurl_melt_timeout.py b/tests/unit/test_lnurl_melt_timeout.py
index ede3f5af..6bb26734 100644
--- a/tests/unit/test_lnurl_melt_timeout.py
+++ b/tests/unit/test_lnurl_melt_timeout.py
@@ -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:
diff --git a/tests/unit/test_periodic_payout.py b/tests/unit/test_periodic_payout.py
index fb105cd5..34f15c67 100644
--- a/tests/unit/test_periodic_payout.py
+++ b/tests/unit/test_periodic_payout.py
@@ -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
diff --git a/ui/app/transactions/page.tsx b/ui/app/transactions/page.tsx
index 56bc1b74..db5c946a 100644
--- a/ui/app/transactions/page.tsx
+++ b/ui/app/transactions/page.tsx
@@ -264,7 +264,8 @@ function LightningInvoiceTable({
No invoices found
- 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.
@@ -290,6 +291,33 @@ function LightningInvoiceTable({
Expired
);
+ if (status === 'failed')
+ return (
+
+ Failed
+
+ );
+ if (status === 'settlement_pending')
+ return (
+
+ Settling
+
+ );
+ if (status === 'reconciliation_required')
+ return (
+
+ Reconciling
+
+ );
if (status === 'cancelled')
return (
+ Direction
Purpose
Amount
Status
@@ -328,6 +357,11 @@ function LightningInvoiceTable({
{invoices.map((inv) => (
+
+
+ {inv.direction === 'out' ? 'Sent' : 'Received'}
+
+
{inv.purpose}
@@ -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() {
Collected
Swept
Paid (Lightning)
+
+ Settling (Lightning)
+
+ Failed (Lightning)
+
+ Reconciling (Lightning)
+
Expired (Lightning)
Cancelled (Lightning)
diff --git a/ui/lib/api/services/admin.ts b/ui/lib/api/services/admin.ts
index f21e7b64..e510a571 100644
--- a/ui/lib/api/services/admin.ts
+++ b/ui/lib/api/services/admin.ts
@@ -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;