mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
harden autotopup
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user