From a6c129c02dbb180a5d174a8e7f5055848cd75ddc Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Tue, 4 Aug 2026 01:32:25 +0200 Subject: [PATCH] harden autotopup --- routstr/upstream/auto_topup.py | 69 ++++++++++++++++--- routstr/wallet.py | 40 +++++++---- .../integration/test_ppq_auto_topup_claim.py | 53 +++++++++++++- tests/unit/test_auto_topup.py | 8 +-- tests/unit/test_wallet.py | 22 ++---- ui/components/provider-card.tsx | 12 +++- .../providers/PPQAutoTopupSettings.tsx | 24 +++++-- ui/lib/api/client.ts | 15 ++++ 8 files changed, 193 insertions(+), 50 deletions(-) diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py index fb1cc914..3bfef74f 100644 --- a/routstr/upstream/auto_topup.py +++ b/routstr/upstream/auto_topup.py @@ -707,6 +707,7 @@ async def _record_ppq_invoice( invoice_id: str, quote_id: str, amount: int, + amount_usd: int, unit: str, mint_url: str, ) -> int: @@ -753,7 +754,10 @@ async def _record_ppq_invoice( CashuTransaction( id=_ppq_payment_id(operation_id), # Do not expose the raw BOLT11 through the transaction API. - token=f"ppq-invoice:{invoice_id}", + # The USD amount is stamped here so the daily spend cap can + # aggregate what each payment was worth when it was made, + # independent of later BTC price moves. + token=f"ppq-invoice:{invoice_id}:usd:{amount_usd}", amount=amount, unit=unit, type="out", @@ -829,25 +833,53 @@ async def _mark_ppq_reconcile( ) -async def _ppq_spent_last_24h_sats() -> int: - """Total sats committed to PPQ top-ups in the last 24 hours. +def _ppq_payment_usd(amount: int, unit: str, token: str, price: float) -> float: + """USD value of one PPQ payment audit row. - Counts every payment audit row, including in-flight and ambiguous ones: - for spend-cap purposes an unresolved payment must be assumed spent. + Prefers the USD amount stamped into the token when the payment was + recorded: converting stored sats at today's price would undercount past + spend whenever the BTC price has fallen since. Falls back to a current + price conversion for rows recorded before the stamp existed. + """ + marker = ":usd:" + if marker in token: + try: + return float(token.rsplit(marker, 1)[1]) + except ValueError: + pass + sats = amount if unit == "sat" else math.ceil(amount / 1000) + return sats * price + + +async def _ppq_spent_last_24h_usd(price: float) -> float: + """Total USD committed to PPQ top-ups in the last 24 hours. + + Counts in-flight and ambiguous payments — for spend-cap purposes an + unresolved payment must be assumed spent — but not rows marked + ``collected=False, swept=True``, which record payments the mint provably + never attempted; those must not starve future top-ups for a day. """ cutoff = int(time.time()) - 24 * 60 * 60 async with create_session() as session: rows = ( await session.exec( - select(CashuTransaction.amount, CashuTransaction.unit).where( + select( + CashuTransaction.amount, + CashuTransaction.unit, + CashuTransaction.token, + ).where( col(CashuTransaction.source) == "ppq_auto_topup", col(CashuTransaction.type) == "out", col(CashuTransaction.created_at) >= cutoff, + or_( + col(CashuTransaction.collected) == True, # noqa: E712 + col(CashuTransaction.swept) == False, # noqa: E712 + ), ) ) ).all() return sum( - amount if unit == "sat" else math.ceil(amount / 1000) for amount, unit in rows + _ppq_payment_usd(amount, unit, token, price) for amount, unit, token in rows ) @@ -884,7 +916,10 @@ async def _check_and_topup_ppq(row: UpstreamProviderRow, settings: dict) -> None ) return - spent_24h_usd = await _ppq_spent_last_24h_sats() * price + # Cheap early check to avoid claim and invoice churn; the authoritative + # re-check happens under the wallet guard just before payment, where no + # concurrent worker can move the total. + spent_24h_usd = await _ppq_spent_last_24h_usd(price) if spent_24h_usd + amount_usd > PPQ_MAX_DAILY_TOPUP_USD: logger.critical( "PPQ auto top-up skipped: rolling 24h spend cap reached", @@ -942,6 +977,23 @@ async def _check_and_topup_ppq(row: UpstreamProviderRow, settings: dict) -> None # proofs between the two calls would invalidate the snapshot. async with wallet_operation_guard(): try: + # Authoritative daily-cap check: the early check above is raceable + # across worker processes, but here the guard serializes every + # payment, so the total cannot move between this read and the + # melt. + spent_24h_usd = await _ppq_spent_last_24h_usd(price) + if spent_24h_usd + amount_usd > PPQ_MAX_DAILY_TOPUP_USD: + logger.critical( + "PPQ auto top-up aborted: rolling 24h spend cap reached", + extra={ + "provider_id": row.id, + "spent_24h_usd": round(spent_24h_usd, 2), + "topup_usd": amount_usd, + "daily_cap_usd": PPQ_MAX_DAILY_TOPUP_USD, + }, + ) + raise ValueError("PPQ auto top-up daily spend cap reached") + plan = await prepare_bolt11_payment(topup.payment_request) if plan.maximum_spend_sats > max_invoice_sats: raise ValueError("PPQ Lightning invoice exceeds the USD spending cap") @@ -953,6 +1005,7 @@ async def _check_and_topup_ppq(row: UpstreamProviderRow, settings: dict) -> None invoice_id=topup.invoice_id, quote_id=str(plan.quote.quote), amount=int(plan.quote.amount + plan.quote.fee_reserve), + amount_usd=amount_usd, unit=plan.unit, mint_url=plan.mint_url, ) diff --git a/routstr/wallet.py b/routstr/wallet.py index cbd2174c..24b146ab 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -19,14 +19,19 @@ from pydantic_core import PydanticUndefined from sqlmodel import col, select, update from .core import db, get_logger -from .core.db import \ - store_cashu_transaction_with_retry as store_cashu_transaction +from .core.db import store_cashu_transaction_with_retry as store_cashu_transaction from .core.settings import settings -from .mint import (MINT_TRANSPORT_COOLDOWN_SECONDS, MINT_TRANSPORT_EXCEPTIONS, - MintRateGuard, MintRateLimitedError, - fail_fast_mint_operations, is_mint_rate_limited, - mint_cooldown_reason, mint_cooldown_remaining, - run_mint_operation) +from .mint import ( + MINT_TRANSPORT_COOLDOWN_SECONDS, + MINT_TRANSPORT_EXCEPTIONS, + MintRateGuard, + MintRateLimitedError, + fail_fast_mint_operations, + is_mint_rate_limited, + mint_cooldown_reason, + mint_cooldown_remaining, + run_mint_operation, +) from .payment.lnurl import raw_send_to_lnurl # Backwards-compatible aliases for callers/tests that imported the former @@ -593,7 +598,10 @@ async def _prepare_bolt11_payment(invoice: str) -> Bolt11PaymentPlan: continue for unit in ("sat", "msat"): try: - wallet = await get_wallet(mint_url, unit) + # force_reload: the guard's flock only serializes access — a + # cached wallet can still hold proof state from before another + # process's reservation landed on disk. + wallet = await get_wallet(mint_url, unit, force_reload=True) proofs = get_proofs_per_mint_and_unit( wallet, mint_url, unit, not_reserved=True ) @@ -736,10 +744,14 @@ async def check_bolt11_payment_status(mint_url: str, unit: str, quote_id: str) - ``get_melt_quote`` also settles the wallet database — invalidating the proofs on ``paid`` and releasing their reservation on ``unpaid`` — so a caller that sees ``"unpaid"`` may safely retry with the same funds. + + Runs under ``wallet_operation_guard`` because of that side effect: it + mutates proof state and must not race other processes' wallet operations. """ try: - wallet = await get_wallet(mint_url, unit) - quote = await wallet.get_melt_quote(quote_id) + async with wallet_operation_guard(): + wallet = await get_wallet(mint_url, unit, force_reload=True) + quote = await wallet.get_melt_quote(quote_id) except Exception as e: logger.warning( "Could not query the mint for a melt quote's status", @@ -2338,8 +2350,11 @@ async def periodic_refund_sweep() -> None: async def periodic_routstr_fee_payout() -> None: - from .auth import (ROUTSTR_FEE_DEFAULT_PAYOUT, - ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS, ROUTSTR_LN_ADDRESS) + from .auth import ( + ROUTSTR_FEE_DEFAULT_PAYOUT, + ROUTSTR_FEE_PAYOUT_INTERVAL_SECONDS, + ROUTSTR_LN_ADDRESS, + ) if not ROUTSTR_LN_ADDRESS: logger.info("ROUTSTR_LN_ADDRESS not set, skipping fee payout") @@ -2469,4 +2484,3 @@ async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int: # def refund_partial(self, amount: int) -> None: # raise NotImplementedError - diff --git a/tests/integration/test_ppq_auto_topup_claim.py b/tests/integration/test_ppq_auto_topup_claim.py index 056f62e6..433389cd 100644 --- a/tests/integration/test_ppq_auto_topup_claim.py +++ b/tests/integration/test_ppq_auto_topup_claim.py @@ -19,7 +19,9 @@ from routstr.upstream.auto_topup import ( PPQ_PHASE_RECONCILE, _claim_ppq_topup, _ppq_payment_id, + _ppq_payment_usd, _ppq_request_id, + _ppq_spent_last_24h_usd, _ppq_state_id_for_provider, _record_ppq_invoice, _set_ppq_state_terminal, @@ -134,6 +136,7 @@ async def test_recording_the_invoice_moves_the_claim_in_flight( invoice_id="invoice-1", quote_id="quote-1", amount=102, + amount_usd=10, unit="sat", mint_url="https://mint.test", ) @@ -267,6 +270,7 @@ async def test_ppq_payment_audit_row_is_visible_and_survives_next_claim( invoice_id="invoice-1", quote_id="quote-1", amount=102, + amount_usd=10, unit="sat", mint_url="https://mint.test", ) @@ -279,7 +283,7 @@ async def test_ppq_payment_audit_row_is_visible_and_survives_next_claim( assert len(transactions) == 1 audit = transactions[0] assert audit["id"] == _ppq_payment_id(operation_id) - assert audit["token"] == "ppq-invoice:invoice-1" + assert audit["token"] == "ppq-invoice:invoice-1:usd:10" assert audit["collected"] is True assert "lnbc-secret-invoice" not in audit["token"] @@ -325,6 +329,7 @@ async def test_stale_token_from_before_a_phase_change_cannot_release( invoice_id="invoice-1", quote_id="quote-1", amount=102, + amount_usd=10, unit="sat", mint_url="https://mint.test", ) @@ -549,3 +554,49 @@ async def test_claim_without_api_key_still_reconciles_via_the_mint( status.assert_awaited_once() row = await _state_row() assert row is not None and row.swept is True + + +def test_ppq_payment_usd_prefers_stamped_amount() -> None: + # Stamped rows must not move with the BTC price. + assert _ppq_payment_usd(102, "sat", "ppq-invoice:a:usd:10", 0.5) == 10.0 + + +def test_ppq_payment_usd_falls_back_to_current_price() -> None: + # Rows recorded before the stamp existed convert sats at today's price. + assert _ppq_payment_usd(2000, "sat", "ppq-invoice:legacy", 0.001) == 2.0 + assert _ppq_payment_usd(2_000_000, "msat", "ppq-invoice:legacy", 0.001) == 2.0 + + +def test_ppq_payment_usd_survives_malformed_stamp() -> None: + assert _ppq_payment_usd(3000, "sat", "ppq-invoice:x:usd:oops", 0.001) == 3.0 + + +async def test_daily_spend_ignores_provably_unattempted_payments( + patched_db_engine: Any, +) -> None: + def _payment( + id_: str, token: str, collected: bool, swept: bool + ) -> CashuTransaction: + return CashuTransaction( + id=id_, + token=token, + amount=1, + unit="sat", + type="out", + source="ppq_auto_topup", + collected=collected, + swept=swept, + ) + + async with create_session() as session: + # Settled, in-flight, and provably-unattempted payments plus a + # pre-stamp row: only the unattempted one must be excluded. + session.add(_payment("pay-usd-1", "ppq-invoice:a:usd:100", True, False)) + session.add(_payment("pay-usd-2", "ppq-invoice:b:usd:50", False, False)) + session.add(_payment("pay-usd-3", "ppq-invoice:c:usd:25", False, True)) + legacy = _payment("pay-usd-4", "ppq-invoice:legacy", True, False) + legacy.amount = 2000 + session.add(legacy) + await session.commit() + + assert await _ppq_spent_last_24h_usd(0.001) == 152.0 diff --git a/tests/unit/test_auto_topup.py b/tests/unit/test_auto_topup.py index c5018991..208156c9 100644 --- a/tests/unit/test_auto_topup.py +++ b/tests/unit/test_auto_topup.py @@ -549,11 +549,11 @@ async def test_ppq_auto_topup_skips_when_daily_spend_cap_reached() -> None: "routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats", AsyncMock(return_value=10_000_000), ), - # 1_000_000 sats * 0.001 USD/sat = 1000 USD, the daily cap: the next - # 10 USD top-up must be refused. + # 1000 USD already spent, exactly the daily cap: the next 10 USD + # top-up must be refused. patch( - "routstr.upstream.auto_topup._ppq_spent_last_24h_sats", - AsyncMock(return_value=1_000_000), + "routstr.upstream.auto_topup._ppq_spent_last_24h_usd", + AsyncMock(return_value=1000.0), ), patch( "routstr.upstream.auto_topup._claim_ppq_topup", diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 1a8c1f82..54e8d4dd 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -243,9 +243,7 @@ async def test_recieve_token_uses_only_requested_destination_mint() -> None: ) assert result == (99, "sat", destination) - swap.assert_awaited_once_with( - token, source_wallet, destination_mints=[destination] - ) + swap.assert_awaited_once_with(token, source_wallet, destination_mints=[destination]) @pytest.mark.asyncio @@ -496,9 +494,7 @@ async def test_send_refreshes_reservations_inside_wallet_guard() -> None: ): assert await send(1000, "sat", mint) == (1000, "token") - wallet.set_reserved_for_send.assert_awaited_once_with( - [proof], reserved=True - ) + wallet.set_reserved_for_send.assert_awaited_once_with([proof], reserved=True) @pytest.mark.asyncio @@ -892,9 +888,7 @@ def _make_swap_mocks( quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee() ) ) - mock_token_wallet.melt = AsyncMock( - return_value=Mock(state=MeltQuoteState.paid) - ) + mock_token_wallet.melt = AsyncMock(return_value=Mock(state=MeltQuoteState.paid)) return mock_token, mock_token_wallet, mock_primary_wallet @@ -1904,7 +1898,7 @@ async def test_prepare_bolt11_payment_counts_input_fees_in_sufficiency() -> None # Balance covers amount + fee_reserve (102) but not the 5 sat input fee. wallet.get_fees_for_proofs = Mock(return_value=5) - async def get_wallet(mint_url: str, unit: str = "sat") -> MagicMock: + async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock: if unit == "msat": raise ValueError("unit unsupported") return wallet @@ -1937,7 +1931,7 @@ async def test_prepare_bolt11_payment_does_not_spend_user_liabilities() -> None: ) wallet.get_fees_for_proofs = Mock(return_value=0) - async def get_wallet(mint_url: str, unit: str = "sat") -> MagicMock: + async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock: if unit == "msat": raise ValueError("unit unsupported") return wallet @@ -1974,7 +1968,7 @@ async def test_prepare_bolt11_payment_rounds_user_liability_up_to_whole_sats() - ) wallet.get_fees_for_proofs = Mock(return_value=0) - async def get_wallet(mint_url: str, unit: str = "sat") -> MagicMock: + async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock: if unit == "msat": raise ValueError("unit unsupported") return wallet @@ -2232,9 +2226,7 @@ async def test_default_timeout_allows_retry_after_rate_limit_cooldown() -> None: response = httpx.Response(429, request=request) operation = AsyncMock( side_effect=[ - httpx.HTTPStatusError( - "rate limited", request=request, response=response - ), + httpx.HTTPStatusError("rate limited", request=request, response=response), "ok", ] ) diff --git a/ui/components/provider-card.tsx b/ui/components/provider-card.tsx index 943fac88..44f05183 100644 --- a/ui/components/provider-card.tsx +++ b/ui/components/provider-card.tsx @@ -30,7 +30,7 @@ import { ProviderBalance } from '@/components/provider-balance'; import { ProviderModelsPanel } from '@/components/provider-models-panel'; import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection'; import { RoutstrProviderService } from '@/lib/api/services/routstr-provider'; -import { ApiError } from '@/lib/api/client'; +import { getErrorStatus } from '@/lib/api/client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -143,7 +143,7 @@ export function ProviderCard({ queryClient.invalidateQueries({ queryKey: ['ppq-auto-topup-state', provider.id], }); - if (error instanceof ApiError && error.status === 409) { + if (getErrorStatus(error) === 409) { // The claim changed since it was reviewed; the stale snapshot is // useless, so force a fresh review. setIsReleaseDialogOpen(false); @@ -352,7 +352,13 @@ export function ProviderCard({ Cancel releasePPQMutation.mutate()} + onClick={(e) => { + // Radix closes the dialog on action click by default; the + // mutation handlers decide whether it closes (kept open on + // transient errors so the admin can retry). + e.preventDefault(); + releasePPQMutation.mutate(); + }} disabled={releasePPQMutation.isPending} > {releasePPQMutation.isPending diff --git a/ui/components/providers/PPQAutoTopupSettings.tsx b/ui/components/providers/PPQAutoTopupSettings.tsx index 2c130336..8a489005 100644 --- a/ui/components/providers/PPQAutoTopupSettings.tsx +++ b/ui/components/providers/PPQAutoTopupSettings.tsx @@ -21,22 +21,34 @@ interface PPQAutoTopupSettingsProps { * Field-level validation shared with the dialog's submit gating. The server * enforces the same bounds authoritatively; this only keeps a knowingly * invalid form from being submitted. + * + * Validation only applies while auto top-up is enabled: a disabled toggle + * hides the fields, and stale out-of-range values behind it must not block + * submission invisibly. When enabled, both fields are required — a blank + * field would otherwise submit and fail server-side. */ export function ppqAutoTopupSettingsErrors(settings: ProviderSettings): { thresholdError?: string; amountError?: string; } { + if (!settings.auto_topup) { + return {}; + } const threshold = settings.topup_threshold; const amount = settings.topup_amount_limit; return { thresholdError: - threshold !== undefined && threshold <= 0 - ? 'Must be greater than 0' - : undefined, + threshold === undefined + ? 'Required when auto top-up is enabled' + : threshold <= 0 + ? 'Must be greater than 0' + : undefined, amountError: - amount !== undefined && (amount < 1 || amount > 500) - ? 'Must be between 1 and 500 USD' - : undefined, + amount === undefined + ? 'Required when auto top-up is enabled' + : amount < 1 || amount > 500 + ? 'Must be between 1 and 500 USD' + : undefined, }; } diff --git a/ui/lib/api/client.ts b/ui/lib/api/client.ts index 0975bf32..fed0161b 100644 --- a/ui/lib/api/client.ts +++ b/ui/lib/api/client.ts @@ -144,3 +144,18 @@ export class ApiError extends Error { this.data = data; } } + +/** + * HTTP status of a caught request error, whatever shape it arrived in. + * apiClient methods rethrow raw Axios errors, so callers must not rely on + * `instanceof ApiError` alone to read a status code. + */ +export function getErrorStatus(error: unknown): number | undefined { + if (error instanceof ApiError) { + return error.status; + } + if (axios.isAxiosError(error)) { + return error.response?.status; + } + return undefined; +}