From b475d269dc6d8eb347f67ed3d9adb76aa0d89e65 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Fri, 18 Sep 2026 01:31:16 +0200 Subject: [PATCH] perf: cut mint requests per payout cycle --- routstr/wallet.py | 32 +++++++---- tests/unit/test_periodic_payout.py | 87 +++++++++++++++++++++++++++++- tests/unit/test_wallet.py | 15 +++++- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index beecde99..74a72d52 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -1165,7 +1165,15 @@ async def get_wallet( retry_on_rate_limit: bool = True, force_reload: bool = False, load_proofs: bool = True, + force_reload_proofs: bool = False, ) -> Wallet: + """Return the cached wallet for a mint/unit, refreshing state as needed. + + ``force_reload`` refreshes both mint keysets and proofs from scratch. + ``force_reload_proofs`` only bypasses the proof reload interval, keeping + the cached keysets: proofs live in the local DB, so this costs no mint + requests beyond the normal keyset refresh interval. + """ global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks id = f"{mint_url}_{unit}" lock = _wallet_load_locks.setdefault(id, asyncio.Lock()) @@ -1197,6 +1205,7 @@ async def get_wallet( last_proof_load = _wallet_last_load.get(id) if ( force_reload + or force_reload_proofs or last_proof_load is None or now - last_proof_load >= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS @@ -1545,12 +1554,20 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None: try: # Runs under wallet_operation_guard; a cached wallet may carry a proof # snapshot up to 30s stale from another process's reservation, so the - # cross-process lock is only safe with a fresh reload. - wallet = await get_wallet(mint_url, unit, force_reload=True) + # cross-process lock is only safe with a fresh proof reload. Proofs + # come from the local DB; keysets stay on their normal refresh interval + # so each cycle does not re-fetch every keyset from the mint. + wallet = await get_wallet(mint_url, unit, force_reload_proofs=True) proofs = get_proofs_per_mint_and_unit(wallet, mint_url, unit, not_reserved=True) - if not proofs: - # Nothing to pay out, so skip the settle delay rather than hold the - # cross-process guard (and block credits) for a wallet with no funds. + min_amount = ( + settings.min_payout_sat + if unit == "sat" + else _sats_to_msats(settings.min_payout_sat) + ) + if sum(proof.amount for proof in proofs) <= min_amount: + # Nothing payable even before subtracting liabilities, so skip the + # proof-state check and settle delay rather than spend mint quota + # and hold the cross-process guard (blocking credits) for nothing. return proofs = await slow_filter_spend_proofs(proofs, wallet) await asyncio.sleep(5) @@ -1582,11 +1599,6 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None: user_balance = _msats_to_sats_ceil(user_balance) proofs_balance = sum(proof.amount for proof in proofs) available_balance = proofs_balance - user_balance - min_amount = ( - settings.min_payout_sat - if unit == "sat" - else _sats_to_msats(settings.min_payout_sat) - ) if available_balance > min_amount: amount_received = await raw_send_to_lnurl( wallet, diff --git a/tests/unit/test_periodic_payout.py b/tests/unit/test_periodic_payout.py index fb105cd5..a0bcd980 100644 --- a/tests/unit/test_periodic_payout.py +++ b/tests/unit/test_periodic_payout.py @@ -156,7 +156,7 @@ async def test_periodic_payout_isolates_failing_mint() -> None: from routstr.core.settings import settings async def _get_wallet( - mint_url: str, unit: str, force_reload: bool = False + mint_url: str, unit: str, force_reload_proofs: bool = False ) -> MagicMock: if mint_url == "http://bad:3338": raise RuntimeError("mint unreachable") @@ -226,7 +226,9 @@ async def test_periodic_payout_handles_session_creation_failure() -> None: patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())), patch( "routstr.wallet.get_proofs_per_mint_and_unit", - MagicMock(return_value=[MagicMock(amount=100_000)]), + # Above min_payout_sat for both sat and msat wallets so neither + # unit is skipped before the liability session is opened. + MagicMock(return_value=[MagicMock(amount=1_000_000)]), ), patch( "routstr.wallet.slow_filter_spend_proofs", @@ -255,3 +257,84 @@ async def test_payout_units_excludes_units_the_sender_cannot_pay() -> None: AsyncMock(return_value=["usd", "sat", "eur", "msat"]), ): assert await _payout_units("http://mint:3338") == ["sat", "msat"] + + +@pytest.mark.asyncio +async def test_periodic_payout_reloads_proofs_without_refetching_keysets() -> None: + """The payout cycle must not force a full keyset refresh from the mint.""" + from routstr.core.settings import settings + + get_wallet = AsyncMock(return_value=MagicMock()) + + 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("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", get_wallet), + patch( + "routstr.wallet.get_proofs_per_mint_and_unit", + MagicMock(return_value=[MagicMock(amount=100_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.raw_send_to_lnurl", AsyncMock(return_value=1000)), + ): + with pytest.raises(_LoopBreak): + await periodic_payout() + + get_wallet.assert_awaited_once_with( + "http://mint:3338", "sat", force_reload_proofs=True + ) + + +@pytest.mark.asyncio +async def test_periodic_payout_skips_proof_state_check_below_min_payout() -> None: + """A wallet at or below min_payout_sat costs no mint requests.""" + from routstr.core.settings import settings + + check_state = AsyncMock(side_effect=lambda proofs, wallet: proofs) + raw_send = AsyncMock(return_value=1000) + + 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", 210), + 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=200)]), + ), + patch("routstr.wallet.slow_filter_spend_proofs", check_state), + patch( + "routstr.wallet.db.total_user_liability", + AsyncMock(return_value=0), + ), + patch("routstr.wallet.raw_send_to_lnurl", raw_send), + ): + with pytest.raises(_LoopBreak): + await periodic_payout() + + check_state.assert_not_awaited() + raw_send.assert_not_awaited() diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 86c90b63..6849514a 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -143,6 +143,19 @@ async def test_get_wallet_force_reload_bypasses_reload_interval() -> None: assert mock_wallet.load_proofs.await_count == 2 +@pytest.mark.asyncio +async def test_get_wallet_force_reload_proofs_keeps_cached_keysets() -> None: + from routstr.wallet import get_wallet + + mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock()) + with patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)): + await get_wallet("http://mint:3338", "sat") + await get_wallet("http://mint:3338", "sat", force_reload_proofs=True) + + assert mock_wallet.load_mint.await_count == 1 + assert mock_wallet.load_proofs.await_count == 2 + + @pytest.mark.asyncio async def test_public_recieve_token_holds_wallet_operation_guard() -> None: inside_guard = False @@ -2017,7 +2030,7 @@ async def test_payout_reloads_wallet_snapshot_under_guard() -> None: await _payout_mint_and_unit("https://mint.example.com", "sat") mock_get_wallet.assert_awaited_once_with( - "https://mint.example.com", "sat", force_reload=True + "https://mint.example.com", "sat", force_reload_proofs=True )