From 2ad8b006742b7fb39a0f06062ffdfc2f693a3ae6 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 22 Sep 2026 23:08:54 +0200 Subject: [PATCH] fix: mark owner payout history failed only on confirmed unpaid melt --- routstr/wallet.py | 8 ++-- tests/unit/test_periodic_payout.py | 63 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index f0873b91..2c592b3b 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -36,7 +36,7 @@ from .mint import ( mint_cooldown_remaining, run_mint_operation, ) -from .payment.lnurl import MeltOutcomeAmbiguousError, raw_send_to_lnurl +from .payment.lnurl import MeltUnpaidError, 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. @@ -1709,9 +1709,9 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None: await _settle_payout_history( payout_quote_id, status=( - "reconciliation_required" - if isinstance(e, MeltOutcomeAmbiguousError) - else "failed" + "failed" + if isinstance(e, MeltUnpaidError) + else "reconciliation_required" ), ) raise diff --git a/tests/unit/test_periodic_payout.py b/tests/unit/test_periodic_payout.py index af178357..03871a82 100644 --- a/tests/unit/test_periodic_payout.py +++ b/tests/unit/test_periodic_payout.py @@ -18,6 +18,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest +from routstr.payment.lnurl import MeltOutcomeAmbiguousError, MeltUnpaidError from routstr.wallet import ( _payout_units, _reconcile_stale_payout_history, @@ -399,6 +400,68 @@ async def test_payout_history_records_the_capped_amount() -> None: ) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("error", "expected_status"), + [ + (MeltUnpaidError("mint confirmed unpaid"), "failed"), + (MeltOutcomeAmbiguousError("outcome unknown"), "reconciliation_required"), + (RuntimeError("HTTP 500 after dispatch"), "reconciliation_required"), + ], +) +async def test_payout_history_marks_failed_only_on_proven_non_payment( + error: Exception, expected_status: str +) -> None: + """Only a mint-confirmed unpaid melt is recorded as failed.""" + from routstr.core.settings import settings + + settle_payout = AsyncMock() + + async def send(*args: object, **kwargs: object) -> int: + await kwargs["on_melt_quote"]( # type: ignore[index,operator] + "quote-err", "lnbc1err" + ) + raise error + + with ( + patch.object(settings, "cashu_mints", ["http://mint:3338"]), + patch.object(settings, "primary_mint", "http://mint:3338"), + patch.object(settings, "receive_ln_address", "owner@ln.tld"), + patch.object(settings, "payout_interval_seconds", _INTERVAL), + patch.object(settings, "min_payout_sat", 10), + patch.object(settings, "max_payout_sat", 250_000), + patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()), + patch("routstr.wallet.db.create_session", _fake_session), + patch( + "routstr.wallet._get_supported_mint_units", + AsyncMock(return_value=["sat"]), + ), + patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())), + patch( + "routstr.wallet.get_proofs_per_mint_and_unit", + MagicMock(return_value=[MagicMock(amount=1_000_000)]), + ), + patch( + "routstr.wallet.slow_filter_spend_proofs", + AsyncMock(side_effect=lambda proofs, wallet: proofs), + ), + patch("routstr.wallet.db.total_user_liability", AsyncMock(return_value=0)), + patch( + "routstr.wallet.db.list_unsettled_lightning_payouts", + AsyncMock(return_value=[]), + ), + patch("routstr.wallet.db.record_lightning_payout", AsyncMock()), + patch("routstr.wallet.db.settle_lightning_payout", settle_payout), + patch("routstr.wallet.raw_send_to_lnurl", AsyncMock(side_effect=send)), + ): + with pytest.raises(_LoopBreak): + await periodic_payout() + + settle_payout.assert_awaited_once_with( + ANY, "quote-err", status=expected_status, amount_sats=None + ) + + @pytest.mark.asyncio async def test_payout_history_write_failure_does_not_block_payout() -> None: """A failing history insert is logged; the melt and settlement still run."""