harden autotopup

This commit is contained in:
9qeklajc
2026-08-04 01:32:25 +02:00
parent a2e2a5c662
commit a6c129c02d
8 changed files with 193 additions and 50 deletions
+61 -8
View File
@@ -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,
)
+27 -13
View File
@@ -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
+52 -1
View File
@@ -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
+4 -4
View File
@@ -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",
+7 -15
View File
@@ -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",
]
)
+9 -3
View File
@@ -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({
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => 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
@@ -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,
};
}
+15
View File
@@ -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;
}