mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
@@ -48,6 +48,68 @@ Connect to your AI provider(s):
|
|||||||
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
| **Upstream URL** | API endpoint (e.g., `https://api.openai.com/v1`) |
|
||||||
| **API Key** | Your provider's API key |
|
| **API Key** | Your provider's API key |
|
||||||
|
|
||||||
|
### PPQ Auto Top-up
|
||||||
|
|
||||||
|
PPQ providers can automatically purchase more credits when their USD balance
|
||||||
|
falls below a configured threshold. Configure this per provider in the Admin
|
||||||
|
Dashboard by editing a **PPQ.AI** provider and opening **PPQ Auto Top-up**.
|
||||||
|
There are no environment variables for this feature.
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
|
||||||
|
Before enabling auto top-up, make sure that:
|
||||||
|
|
||||||
|
- the PPQ provider has a valid API key;
|
||||||
|
- at least one trusted Cashu mint is configured;
|
||||||
|
- the node wallet has enough **node-owned** funds at one mint to pay the
|
||||||
|
Lightning invoice; client balances are never used; and
|
||||||
|
- the node has a current BTC/USD price for validating the invoice amount.
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
| ------- | ----------- |
|
||||||
|
| **Enable Auto Top-up** | Enables automatic PPQ credit purchases for this provider. |
|
||||||
|
| **When credits are below (USD)** | Starts a top-up when the reported PPQ balance is below this positive USD value. |
|
||||||
|
| **Purchase this amount (USD)** | Amount of PPQ credit to buy per top-up. Must be a whole number from **1 to 500 USD**. |
|
||||||
|
|
||||||
|
For example, a threshold of `5` and purchase amount of `20` buys 20 USD of
|
||||||
|
credit when the PPQ balance drops below 5 USD.
|
||||||
|
|
||||||
|
#### How it works
|
||||||
|
|
||||||
|
The worker checks eligible providers approximately once per minute. When the
|
||||||
|
balance is below the threshold, it:
|
||||||
|
|
||||||
|
1. verifies the node has enough owner funds before creating an invoice;
|
||||||
|
2. requests a USD-denominated Lightning top-up invoice from PPQ;
|
||||||
|
3. rejects expired, mismatched, or unexpectedly expensive invoices (more than
|
||||||
|
10% above the local BTC/USD estimate);
|
||||||
|
4. pays from the configured Cashu mint with sufficient owner funds; and
|
||||||
|
5. waits for PPQ to confirm that the credit settled.
|
||||||
|
|
||||||
|
Only one attempt can be active for a provider. An attempt that was active at
|
||||||
|
the start of a cycle suppresses another top-up for that entire cycle, even if
|
||||||
|
PPQ reports it settled immediately. This prevents a temporarily stale PPQ
|
||||||
|
balance from causing a duplicate purchase.
|
||||||
|
|
||||||
|
Completed PPQ payments appear in the dashboard transaction history with source
|
||||||
|
`ppq_auto_topup`. The payment record is separate from the internal claim used
|
||||||
|
to prevent concurrent attempts.
|
||||||
|
|
||||||
|
#### Payment recovery
|
||||||
|
|
||||||
|
If the Cashu mint paid the invoice but PPQ settlement cannot be confirmed, the
|
||||||
|
provider card shows **Auto top-up needs review**. A payment still owned by a
|
||||||
|
running worker is shown as **Paying invoice** and cannot be released.
|
||||||
|
|
||||||
|
Before choosing **Release top-up**, manually verify both PPQ and the Cashu mint.
|
||||||
|
Release the claim only when the previous Lightning payment is definitively
|
||||||
|
unable to settle. Releasing an ambiguous payment allows the next cycle to try
|
||||||
|
again and can therefore cause a duplicate top-up.
|
||||||
|
|
||||||
|
Disabling auto top-up prevents new purchases, but the node continues to
|
||||||
|
reconcile an already active payment until it reaches a safe terminal state or
|
||||||
|
requires operator review.
|
||||||
|
|
||||||
### Node Identity
|
### Node Identity
|
||||||
|
|
||||||
How your node appears to clients:
|
How your node appears to clients:
|
||||||
|
|||||||
+185
-1
@@ -862,6 +862,33 @@ class UpstreamProviderUpdateBySlug(BaseModel):
|
|||||||
provider_settings: dict | None = None
|
provider_settings: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _active_ppq_claim_in_session(session: AsyncSession, provider_id: int) -> bool:
|
||||||
|
"""Check for an active claim inside the caller's transaction.
|
||||||
|
|
||||||
|
Must share the transaction of whatever destructive write it is guarding —
|
||||||
|
a check in its own session leaves a window for a worker to create the
|
||||||
|
claim between the check and the commit.
|
||||||
|
"""
|
||||||
|
from ..upstream.auto_topup import _ppq_state_id_for_provider
|
||||||
|
|
||||||
|
claim = await session.get(CashuTransaction, _ppq_state_id_for_provider(provider_id))
|
||||||
|
return claim is not None and not claim.collected and not claim.swept
|
||||||
|
|
||||||
|
|
||||||
|
def _require_valid_ppq_auto_topup(
|
||||||
|
provider_type: str, settings: dict | None
|
||||||
|
) -> None:
|
||||||
|
"""Reject PPQ auto top-up settings the worker would later refuse."""
|
||||||
|
if provider_type != "ppqai":
|
||||||
|
return
|
||||||
|
|
||||||
|
from ..upstream.auto_topup import validate_ppq_auto_topup_settings
|
||||||
|
|
||||||
|
problem = validate_ppq_auto_topup_settings(settings)
|
||||||
|
if problem is not None:
|
||||||
|
raise HTTPException(status_code=400, detail=problem)
|
||||||
|
|
||||||
|
|
||||||
async def _apply_provider_update(
|
async def _apply_provider_update(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
provider: UpstreamProviderRow,
|
provider: UpstreamProviderRow,
|
||||||
@@ -873,6 +900,29 @@ async def _apply_provider_update(
|
|||||||
await _ensure_unique_slug(session, validated, exclude_id=provider.id)
|
await _ensure_unique_slug(session, validated, exclude_id=provider.id)
|
||||||
provider.slug = validated
|
provider.slug = validated
|
||||||
|
|
||||||
|
provider_type_changed = (
|
||||||
|
payload.provider_type is not None
|
||||||
|
and payload.provider_type != provider.provider_type
|
||||||
|
)
|
||||||
|
ppq_type_changed = provider_type_changed and (
|
||||||
|
provider.provider_type == "ppqai" or payload.provider_type == "ppqai"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
provider_type_changed
|
||||||
|
and provider.provider_type == "ppqai"
|
||||||
|
and provider.id is not None
|
||||||
|
and await _active_ppq_claim_in_session(session, provider.id)
|
||||||
|
):
|
||||||
|
# Changing the type would orphan the claim: the PPQ endpoints refuse
|
||||||
|
# non-ppqai providers, so nobody could ever inspect or release it.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"This provider has an active PPQ auto top-up claim. Release "
|
||||||
|
"it before changing the provider type"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if payload.provider_type is not None:
|
if payload.provider_type is not None:
|
||||||
provider.provider_type = payload.provider_type
|
provider.provider_type = payload.provider_type
|
||||||
if payload.base_url is not None:
|
if payload.base_url is not None:
|
||||||
@@ -885,6 +935,41 @@ async def _apply_provider_update(
|
|||||||
provider.enabled = payload.enabled
|
provider.enabled = payload.enabled
|
||||||
if payload.provider_fee is not None:
|
if payload.provider_fee is not None:
|
||||||
provider.provider_fee = payload.provider_fee
|
provider.provider_fee = payload.provider_fee
|
||||||
|
|
||||||
|
# Auto-top-up fields have provider-specific units and meaning. Reusing
|
||||||
|
# enabled Routstr settings for PPQ (or vice versa) can silently reinterpret
|
||||||
|
# sats as USD, so a type change must provide settings for the new type.
|
||||||
|
if (
|
||||||
|
ppq_type_changed
|
||||||
|
and payload.provider_settings is None
|
||||||
|
and provider.provider_settings
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
stored_settings = json.loads(provider.provider_settings)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
stored_settings = None
|
||||||
|
if isinstance(stored_settings, dict) and stored_settings.get("auto_topup"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
"Changing provider type requires explicit auto-top-up "
|
||||||
|
"settings because the units are provider-specific"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate against the effective type and effective settings.
|
||||||
|
effective_settings = payload.provider_settings
|
||||||
|
if effective_settings is None and payload.provider_type is not None:
|
||||||
|
try:
|
||||||
|
effective_settings = (
|
||||||
|
json.loads(provider.provider_settings)
|
||||||
|
if provider.provider_settings
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
effective_settings = None
|
||||||
|
if effective_settings is not None:
|
||||||
|
_require_valid_ppq_auto_topup(provider.provider_type, effective_settings)
|
||||||
if payload.provider_settings is not None:
|
if payload.provider_settings is not None:
|
||||||
provider.provider_settings = json.dumps(payload.provider_settings)
|
provider.provider_settings = json.dumps(payload.provider_settings)
|
||||||
|
|
||||||
@@ -924,6 +1009,10 @@ async def create_upstream_provider(
|
|||||||
else:
|
else:
|
||||||
slug = await allocate_unique_provider_slug(session, payload.provider_type)
|
slug = await allocate_unique_provider_slug(session, payload.provider_type)
|
||||||
|
|
||||||
|
_require_valid_ppq_auto_topup(
|
||||||
|
payload.provider_type, payload.provider_settings
|
||||||
|
)
|
||||||
|
|
||||||
provider = UpstreamProviderRow(
|
provider = UpstreamProviderRow(
|
||||||
slug=slug,
|
slug=slug,
|
||||||
provider_type=payload.provider_type,
|
provider_type=payload.provider_type,
|
||||||
@@ -1015,6 +1104,25 @@ async def delete_upstream_provider(provider_id: str) -> dict[str, object]:
|
|||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
provider = await _get_upstream_provider_by_ref(session, provider_id)
|
||||||
deleted_id = _provider_pk(provider)
|
deleted_id = _provider_pk(provider)
|
||||||
|
|
||||||
|
# Checked inside the delete transaction: the worker's claim creation
|
||||||
|
# re-reads the provider inside its own transaction, so these two
|
||||||
|
# writes serialise — either the claim lands first and this 409s, or
|
||||||
|
# the delete lands first and the worker refuses to claim.
|
||||||
|
if provider.provider_type == "ppqai" and await _active_ppq_claim_in_session(
|
||||||
|
session, deleted_id
|
||||||
|
):
|
||||||
|
# Deleting now would orphan the claim and any funds it tracks:
|
||||||
|
# the PPQ endpoints 404 without the provider row, so the claim
|
||||||
|
# could never again be inspected or released.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
"This provider has an active PPQ auto top-up claim. "
|
||||||
|
"Resolve and release it before deleting the provider"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(provider)
|
await session.delete(provider)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await reinitialize_upstreams()
|
await reinitialize_upstreams()
|
||||||
@@ -1623,6 +1731,78 @@ async def get_log_dates_api(request: Request) -> dict[str, object]:
|
|||||||
return {"dates": dates}
|
return {"dates": dates}
|
||||||
|
|
||||||
|
|
||||||
|
_PPQ_RELEASE_ERRORS = {
|
||||||
|
"no_active_claim": "No active PPQ claim to release",
|
||||||
|
"stale_state": ("The claim changed since it was reviewed; reload and check again"),
|
||||||
|
"payment_in_flight": (
|
||||||
|
"A Lightning payment is still in flight for this claim. Wait for it to "
|
||||||
|
"finish or expire before releasing"
|
||||||
|
),
|
||||||
|
"claim_changed": (
|
||||||
|
"The claim changed while the release was being applied; reload and check again"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ReleasePPQAutoTopupRequest(BaseModel):
|
||||||
|
confirmed_safe_to_retry: bool
|
||||||
|
# Echoes the state_token the admin reviewed — the claim's full versioned
|
||||||
|
# state, not just its operation id. Any change since the review (a new
|
||||||
|
# attempt, a phase change, a renewed lease) fails the match, so the
|
||||||
|
# release cannot land on a state the admin never saw.
|
||||||
|
state_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_ppq_provider(provider_id: int) -> UpstreamProviderRow:
|
||||||
|
async with create_session() as session:
|
||||||
|
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||||
|
if provider is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
if provider.provider_type != "ppqai":
|
||||||
|
raise HTTPException(status_code=400, detail="Provider is not PPQ")
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get(
|
||||||
|
"/api/upstream-providers/{provider_id}/ppq-auto-topup",
|
||||||
|
dependencies=[Depends(require_admin_api)],
|
||||||
|
)
|
||||||
|
async def get_ppq_auto_topup_api(provider_id: int) -> dict[str, object]:
|
||||||
|
await _require_ppq_provider(provider_id)
|
||||||
|
from ..upstream.auto_topup import get_ppq_auto_topup_state
|
||||||
|
|
||||||
|
return {"ok": True, **await get_ppq_auto_topup_state(provider_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post(
|
||||||
|
"/api/upstream-providers/{provider_id}/ppq-auto-topup/release",
|
||||||
|
dependencies=[Depends(require_admin_api)],
|
||||||
|
)
|
||||||
|
async def release_ppq_auto_topup_api(
|
||||||
|
provider_id: int, payload: ReleasePPQAutoTopupRequest
|
||||||
|
) -> dict[str, object]:
|
||||||
|
await _require_ppq_provider(provider_id)
|
||||||
|
if not payload.confirmed_safe_to_retry:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Confirm the Lightning payment outcome is safe before releasing",
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..upstream.auto_topup import release_ppq_auto_topup_state
|
||||||
|
|
||||||
|
outcome = await release_ppq_auto_topup_state(
|
||||||
|
provider_id, state_token=payload.state_token
|
||||||
|
)
|
||||||
|
if not outcome.released:
|
||||||
|
raise HTTPException(status_code=409, detail=_PPQ_RELEASE_ERRORS[outcome.reason])
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Admin released PPQ auto top-up claim after manual reconciliation",
|
||||||
|
extra={"provider_id": provider_id, "state_token": payload.state_token},
|
||||||
|
)
|
||||||
|
return {"ok": True, "released": True}
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
|
@admin_router.get("/api/transactions", dependencies=[Depends(require_admin_api)])
|
||||||
async def get_transactions_api(
|
async def get_transactions_api(
|
||||||
type: str | None = None,
|
type: str | None = None,
|
||||||
@@ -1635,7 +1815,11 @@ async def get_transactions_api(
|
|||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
from sqlmodel import col, func
|
from sqlmodel import col, func
|
||||||
|
|
||||||
base = select(CashuTransaction)
|
# Hide only the deterministic PPQ claim-lock rows. Append-only PPQ
|
||||||
|
# payment rows remain visible as the audit trail for irreversible melts.
|
||||||
|
base = select(CashuTransaction).where(
|
||||||
|
~col(CashuTransaction.id).like("ppq-auto-topup-%")
|
||||||
|
)
|
||||||
if type:
|
if type:
|
||||||
base = base.where(CashuTransaction.type == type)
|
base = base.where(CashuTransaction.type == type)
|
||||||
if source:
|
if source:
|
||||||
|
|||||||
+947
-10
File diff suppressed because it is too large
Load Diff
@@ -441,7 +441,7 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
"""
|
"""
|
||||||
data = await self.check_balance()
|
data = await self.check_balance()
|
||||||
balance = data.get("balance")
|
balance = data.get("balance")
|
||||||
if isinstance(balance, (int, float)):
|
if isinstance(balance, (int, float)) and not isinstance(balance, bool):
|
||||||
return float(balance)
|
return float(balance)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
+288
-19
@@ -6,11 +6,12 @@ import time
|
|||||||
import typing
|
import typing
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AsyncGenerator, TypedDict
|
from typing import AsyncGenerator, TypedDict
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from cashu.core.base import MeltQuoteState, MintQuote, Proof, Token
|
from cashu.core.base import MeltQuote, MeltQuoteState, MintQuote, Proof, Token
|
||||||
from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
||||||
from cashu.wallet.helpers import deserialize_token_from_string
|
from cashu.wallet.helpers import deserialize_token_from_string
|
||||||
from cashu.wallet.wallet import Wallet as _CashuWallet
|
from cashu.wallet.wallet import Wallet as _CashuWallet
|
||||||
@@ -105,6 +106,11 @@ def _msats_to_sats(amount: int) -> int:
|
|||||||
return amount // 1000
|
return amount // 1000
|
||||||
|
|
||||||
|
|
||||||
|
def _msats_to_sats_ceil(amount: int) -> int:
|
||||||
|
"""Round liabilities up so fractional sats are never treated as owner funds."""
|
||||||
|
return (amount + 999) // 1000
|
||||||
|
|
||||||
|
|
||||||
def _mints_to_inspect() -> list[str]:
|
def _mints_to_inspect() -> list[str]:
|
||||||
"""Return configured mints plus the primary mint, without duplicates."""
|
"""Return configured mints plus the primary mint, without duplicates."""
|
||||||
mint_urls = list(settings.cashu_mints)
|
mint_urls = list(settings.cashu_mints)
|
||||||
@@ -390,9 +396,7 @@ async def _recieve_token_locked(
|
|||||||
else list(dict.fromkeys([settings.primary_mint, *settings.cashu_mints]))
|
else list(dict.fromkeys([settings.primary_mint, *settings.cashu_mints]))
|
||||||
)
|
)
|
||||||
output_unit = (
|
output_unit = (
|
||||||
token_obj.unit
|
token_obj.unit if token_obj.mint in destinations else settings.primary_mint_unit
|
||||||
if token_obj.mint in destinations
|
|
||||||
else settings.primary_mint_unit
|
|
||||||
)
|
)
|
||||||
if destination_unit is not None and output_unit != destination_unit:
|
if destination_unit is not None and output_unit != destination_unit:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -493,6 +497,275 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
class Bolt11PaymentNotAttempted(Exception):
|
||||||
|
"""The invoice was definitively not paid, so the attempt can be retried.
|
||||||
|
|
||||||
|
Raised only where the mint's own answer rules out a settlement: coin
|
||||||
|
selection never reached ``melt``, or ``melt`` returned an explicit unpaid
|
||||||
|
state. Any proofs reserved along the way are released before this is
|
||||||
|
raised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Bolt11PaymentAmbiguous(Exception):
|
||||||
|
"""The payment may or may not have settled, so it must not be retried.
|
||||||
|
|
||||||
|
Raised when ``melt`` errored, timed out, or came back pending. The selected
|
||||||
|
proofs stay reserved: the mint may still complete the payment with them,
|
||||||
|
and spending them elsewhere would be a double spend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Bolt11PaymentPlan:
|
||||||
|
invoice: str
|
||||||
|
wallet: Wallet
|
||||||
|
proofs: list[Proof]
|
||||||
|
quote: MeltQuote
|
||||||
|
mint_url: str
|
||||||
|
unit: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invoice_amount_sats(self) -> int:
|
||||||
|
amount = int(self.quote.amount)
|
||||||
|
return amount if self.unit == "sat" else (amount + 999) // 1000
|
||||||
|
|
||||||
|
@property
|
||||||
|
def maximum_spend_sats(self) -> int:
|
||||||
|
maximum = (
|
||||||
|
int(self.quote.amount)
|
||||||
|
+ int(self.quote.fee_reserve)
|
||||||
|
+ int(self.wallet.get_fees_for_proofs(self.proofs))
|
||||||
|
)
|
||||||
|
return maximum if self.unit == "sat" else (maximum + 999) // 1000
|
||||||
|
|
||||||
|
|
||||||
|
async def _owner_balance_for_mint_and_unit(
|
||||||
|
mint_url: str, unit: str, proofs_balance: int
|
||||||
|
) -> int:
|
||||||
|
"""Return spendable node-owned funds without crossing user liabilities."""
|
||||||
|
async with db.create_session() as session:
|
||||||
|
# Refund mint is a preference, not funding provenance. Mirror payout's
|
||||||
|
# conservative rule and protect the full liability at every mint.
|
||||||
|
user_liability = await db.total_user_liability(session)
|
||||||
|
# API-key balances are stored in msats. Cashu ``sat`` proofs are not.
|
||||||
|
if unit == "sat":
|
||||||
|
user_liability = _msats_to_sats_ceil(user_liability)
|
||||||
|
return max(0, proofs_balance - user_liability)
|
||||||
|
|
||||||
|
|
||||||
|
async def maximum_owner_cashu_balance_sats() -> int:
|
||||||
|
"""Return the largest conservatively owner-funded mint/unit balance."""
|
||||||
|
details, _, _, _ = await fetch_all_balances()
|
||||||
|
async with db.create_session() as session:
|
||||||
|
liability_sats = _msats_to_sats_ceil(await db.total_user_liability(session))
|
||||||
|
balances = [
|
||||||
|
(
|
||||||
|
detail["wallet_balance"]
|
||||||
|
if detail["unit"] == "sat"
|
||||||
|
else _msats_to_sats(detail["wallet_balance"])
|
||||||
|
)
|
||||||
|
- liability_sats
|
||||||
|
for detail in details
|
||||||
|
if not detail.get("error")
|
||||||
|
]
|
||||||
|
return max([0, *balances])
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_bolt11_payment(invoice: str) -> Bolt11PaymentPlan:
|
||||||
|
"""Choose the sufficiently funded configured mint with most owner funds.
|
||||||
|
|
||||||
|
Candidate discovery reads balances, user liabilities, and melt quotes. Coin
|
||||||
|
selection, which may split proofs, is deferred until the winner is known.
|
||||||
|
|
||||||
|
Runs under ``wallet_operation_guard``: the plan snapshots live proof state,
|
||||||
|
which another worker process could otherwise mutate mid-read. Callers that
|
||||||
|
go on to execute the plan should hold the guard across both calls so the
|
||||||
|
snapshot stays valid.
|
||||||
|
"""
|
||||||
|
async with wallet_operation_guard():
|
||||||
|
return await _prepare_bolt11_payment(invoice)
|
||||||
|
|
||||||
|
|
||||||
|
async def _prepare_bolt11_payment(invoice: str) -> Bolt11PaymentPlan:
|
||||||
|
mint_urls = list(dict.fromkeys([*settings.cashu_mints, settings.primary_mint]))
|
||||||
|
candidates: list[tuple[int, Wallet, list[Proof], MeltQuote, str, str]] = []
|
||||||
|
failures: list[dict[str, str]] = []
|
||||||
|
evaluated = 0
|
||||||
|
|
||||||
|
for mint_url in mint_urls:
|
||||||
|
if not mint_url:
|
||||||
|
continue
|
||||||
|
for unit in ("sat", "msat"):
|
||||||
|
try:
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||||
|
proofs_balance = sum(proof.amount for proof in proofs)
|
||||||
|
if proofs_balance <= 0:
|
||||||
|
evaluated += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
quote = await wallet.melt_quote(invoice=invoice)
|
||||||
|
evaluated += 1
|
||||||
|
# select_to_send runs with include_fees=True, so the input fee
|
||||||
|
# has to be part of sufficiency too. Without it a mint passes
|
||||||
|
# this filter and then fails coin selection.
|
||||||
|
required = (
|
||||||
|
quote.amount
|
||||||
|
+ quote.fee_reserve
|
||||||
|
+ wallet.get_fees_for_proofs(proofs)
|
||||||
|
)
|
||||||
|
owner_balance = await _owner_balance_for_mint_and_unit(
|
||||||
|
mint_url, unit, proofs_balance
|
||||||
|
)
|
||||||
|
if owner_balance < required:
|
||||||
|
continue
|
||||||
|
owner_balance_msats = (
|
||||||
|
owner_balance * 1000 if unit == "sat" else owner_balance
|
||||||
|
)
|
||||||
|
candidates.append(
|
||||||
|
(owner_balance_msats, wallet, proofs, quote, mint_url, unit)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
failures.append({"mint_url": mint_url, "unit": unit, "error": str(e)})
|
||||||
|
logger.debug(
|
||||||
|
"Cashu mint cannot fund BOLT11 invoice",
|
||||||
|
extra={"mint_url": mint_url, "unit": unit, "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
if failures:
|
||||||
|
logger.warning(
|
||||||
|
"No Cashu mint could fund the BOLT11 invoice",
|
||||||
|
extra={"evaluated": evaluated, "failures": failures},
|
||||||
|
)
|
||||||
|
if evaluated == 0 and failures:
|
||||||
|
raise RuntimeError("Every configured Cashu mint refused the payment")
|
||||||
|
raise ValueError(
|
||||||
|
"No configured Cashu mint has enough balance after user liabilities to pay invoice"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, wallet, proofs, quote, mint_url, unit = max(candidates, key=lambda item: item[0])
|
||||||
|
return Bolt11PaymentPlan(invoice, wallet, proofs, quote, mint_url, unit)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_bolt11_payment(plan: Bolt11PaymentPlan) -> tuple[int, str, str]:
|
||||||
|
"""Execute a prepared payment, separating retryable from ambiguous failure.
|
||||||
|
|
||||||
|
Raises ``Bolt11PaymentNotAttempted`` when the invoice provably did not
|
||||||
|
settle, and ``Bolt11PaymentAmbiguous`` when the outcome is unknown. Callers
|
||||||
|
may safely retry the first and must never retry the second.
|
||||||
|
|
||||||
|
Runs under ``wallet_operation_guard``: coin selection and reservation must
|
||||||
|
not race another worker process spending the same proofs.
|
||||||
|
"""
|
||||||
|
async with wallet_operation_guard():
|
||||||
|
return await _execute_bolt11_payment(plan)
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_bolt11_payment(plan: Bolt11PaymentPlan) -> tuple[int, str, str]:
|
||||||
|
# Select unreserved, mirroring send_token: a selection failure must not
|
||||||
|
# strand proofs that were never handed to the mint.
|
||||||
|
try:
|
||||||
|
selected, _ = await plan.wallet.select_to_send(
|
||||||
|
plan.proofs,
|
||||||
|
plan.quote.amount + plan.quote.fee_reserve,
|
||||||
|
set_reserved=False,
|
||||||
|
include_fees=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise Bolt11PaymentNotAttempted(f"Coin selection failed: {e}") from e
|
||||||
|
|
||||||
|
await plan.wallet.set_reserved_for_send(selected, reserved=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
plan.wallet.melt(
|
||||||
|
proofs=selected,
|
||||||
|
invoice=plan.invoice,
|
||||||
|
fee_reserve_sat=plan.quote.fee_reserve,
|
||||||
|
quote_id=plan.quote.quote,
|
||||||
|
),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except BaseException as e:
|
||||||
|
# The mint may still be settling with these proofs, so they must stay
|
||||||
|
# reserved — but cashu's melt() un-reserves them itself on a mint
|
||||||
|
# transport error, the exact ambiguous case. Re-reserve with the melt
|
||||||
|
# quote id, not as a send: get_melt_quote() finds the proofs to settle
|
||||||
|
# by melt_id, so a send-style reservation would strand them — paid
|
||||||
|
# proofs never invalidated, unpaid ones never released. BaseException
|
||||||
|
# includes task cancellation after the melt was submitted.
|
||||||
|
try:
|
||||||
|
await asyncio.shield(
|
||||||
|
plan.wallet.set_reserved_for_melt(
|
||||||
|
selected, reserved=True, quote_id=plan.quote.quote
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except BaseException:
|
||||||
|
logger.critical(
|
||||||
|
"Could not re-reserve proofs after an ambiguous melt",
|
||||||
|
extra={"mint_url": plan.mint_url, "quote_id": plan.quote.quote},
|
||||||
|
)
|
||||||
|
if isinstance(e, asyncio.CancelledError):
|
||||||
|
raise
|
||||||
|
raise Bolt11PaymentAmbiguous(f"Cashu melt did not return: {e}") from e
|
||||||
|
|
||||||
|
raw_state = getattr(result, "state", None)
|
||||||
|
state = str(raw_state).lower().rsplit(".", 1)[-1] if raw_state is not None else ""
|
||||||
|
if state == "paid" or getattr(result, "paid", None) is True:
|
||||||
|
change = getattr(result, "change", None) or []
|
||||||
|
paid = sum(proof.amount for proof in selected) - sum(
|
||||||
|
int(item.amount) for item in change
|
||||||
|
)
|
||||||
|
return paid, plan.mint_url, plan.unit
|
||||||
|
|
||||||
|
if state == "unpaid":
|
||||||
|
# The mint is telling us it did not pay, so the proofs are ours again.
|
||||||
|
await plan.wallet.set_reserved_for_send(selected, reserved=False)
|
||||||
|
raise Bolt11PaymentNotAttempted("Cashu mint reported the melt as unpaid")
|
||||||
|
|
||||||
|
raise Bolt11PaymentAmbiguous(
|
||||||
|
f"Cashu melt did not reach a final state: {state or 'unknown'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_bolt11_payment_status(mint_url: str, unit: str, quote_id: str) -> str:
|
||||||
|
"""Ask the mint what became of an earlier melt attempt.
|
||||||
|
|
||||||
|
Returns ``"paid"``, ``"unpaid"``, ``"pending"``, or ``"unknown"``. This is
|
||||||
|
the durable reconciliation path for an ambiguous payment: cashu's
|
||||||
|
``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:
|
||||||
|
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",
|
||||||
|
extra={"mint_url": mint_url, "quote_id": quote_id, "error": str(e)},
|
||||||
|
)
|
||||||
|
return "unknown"
|
||||||
|
if quote is None:
|
||||||
|
return "unknown"
|
||||||
|
state = str(getattr(quote, "state", "")).lower().rsplit(".", 1)[-1]
|
||||||
|
if state in ("paid", "unpaid", "pending"):
|
||||||
|
return state
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
async def release_token_reservation(token: str) -> None:
|
async def release_token_reservation(token: str) -> None:
|
||||||
"""Release a token that was created locally but never handed off."""
|
"""Release a token that was created locally but never handed off."""
|
||||||
async with wallet_operation_guard():
|
async with wallet_operation_guard():
|
||||||
@@ -1724,9 +1997,7 @@ async def fetch_all_balances(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with mint_check_limit:
|
async with mint_check_limit:
|
||||||
wallet = await get_wallet(
|
wallet = await get_wallet(mint_url, unit, retry_on_rate_limit=False)
|
||||||
mint_url, unit, retry_on_rate_limit=False
|
|
||||||
)
|
|
||||||
proofs = get_proofs_per_mint_and_unit(
|
proofs = get_proofs_per_mint_and_unit(
|
||||||
wallet, mint_url, unit, not_reserved=True
|
wallet, mint_url, unit, not_reserved=True
|
||||||
)
|
)
|
||||||
@@ -1840,9 +2111,7 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
|
|||||||
# snapshot up to 30s stale from another process's reservation, so the
|
# snapshot up to 30s stale from another process's reservation, so the
|
||||||
# cross-process lock is only safe with a fresh reload.
|
# cross-process lock is only safe with a fresh reload.
|
||||||
wallet = await get_wallet(mint_url, unit, force_reload=True)
|
wallet = await get_wallet(mint_url, unit, force_reload=True)
|
||||||
proofs = get_proofs_per_mint_and_unit(
|
proofs = get_proofs_per_mint_and_unit(wallet, mint_url, unit, not_reserved=True)
|
||||||
wallet, mint_url, unit, not_reserved=True
|
|
||||||
)
|
|
||||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1948,6 +2217,12 @@ async def _refund_sweep_once(cutoff: int) -> None:
|
|||||||
db.CashuTransaction.type == "out",
|
db.CashuTransaction.type == "out",
|
||||||
db.CashuTransaction.collected == False, # noqa: E712
|
db.CashuTransaction.collected == False, # noqa: E712
|
||||||
db.CashuTransaction.swept == False, # noqa: E712
|
db.CashuTransaction.swept == False, # noqa: E712
|
||||||
|
# PPQ rows describe a Lightning spend or claim lock, not a
|
||||||
|
# refundable Cashu token. Preserve legacy rows without a source.
|
||||||
|
col(db.CashuTransaction.source).is_(None)
|
||||||
|
| col(db.CashuTransaction.source).notin_(
|
||||||
|
["ppq_auto_topup", "ppq_auto_topup_claim"]
|
||||||
|
),
|
||||||
db.CashuTransaction.created_at < cutoff,
|
db.CashuTransaction.created_at < cutoff,
|
||||||
claim_available,
|
claim_available,
|
||||||
)
|
)
|
||||||
@@ -2180,16 +2455,10 @@ async def periodic_routstr_fee_payout() -> None:
|
|||||||
|
|
||||||
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
async def send_to_lnurl(amount: int, unit: str, mint: str, address: str) -> int:
|
||||||
async with wallet_operation_guard():
|
async with wallet_operation_guard():
|
||||||
mint = await find_trusted_mint_with_funds(
|
mint = await find_trusted_mint_with_funds(amount, unit, mint, force_reload=True)
|
||||||
amount, unit, mint, force_reload=True
|
|
||||||
)
|
|
||||||
wallet = await get_wallet(mint, unit)
|
wallet = await get_wallet(mint, unit)
|
||||||
available = get_proofs_per_mint_and_unit(
|
available = get_proofs_per_mint_and_unit(wallet, mint, unit, not_reserved=True)
|
||||||
wallet, mint, unit, not_reserved=True
|
proofs, _ = await wallet.select_to_send(available, amount, set_reserved=True)
|
||||||
)
|
|
||||||
proofs, _ = await wallet.select_to_send(
|
|
||||||
available, amount, set_reserved=True
|
|
||||||
)
|
|
||||||
return await raw_send_to_lnurl(wallet, proofs, address, unit)
|
return await raw_send_to_lnurl(wallet, proofs, address, unit)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,602 @@
|
|||||||
|
"""Real-database tests for the PPQ auto top-up claim lifecycle.
|
||||||
|
|
||||||
|
These exercise the claim against actual SQL rather than mocked sessions,
|
||||||
|
because the guarantees under test are all about what the database will and
|
||||||
|
will not let two concurrent writers do.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from routstr.core.db import CashuTransaction, create_session
|
||||||
|
from routstr.upstream.auto_topup import (
|
||||||
|
PPQ_PHASE_CLAIMED,
|
||||||
|
PPQ_PHASE_IN_FLIGHT,
|
||||||
|
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,
|
||||||
|
get_ppq_auto_topup_state,
|
||||||
|
release_ppq_auto_topup_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
def _row(provider_id: int = 1) -> MagicMock:
|
||||||
|
row = MagicMock()
|
||||||
|
row.id = provider_id
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_provider(provider_id: int = 1, slug: str = "ppq") -> None:
|
||||||
|
"""Claim creation is fenced on the provider row existing; seed it."""
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
session.add(
|
||||||
|
UpstreamProviderRow(
|
||||||
|
id=provider_id,
|
||||||
|
slug=slug,
|
||||||
|
provider_type="ppqai",
|
||||||
|
base_url="https://api.ppq.ai",
|
||||||
|
api_key="secret",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _state_row(provider_id: int = 1) -> CashuTransaction | None:
|
||||||
|
async with create_session() as session:
|
||||||
|
return await session.get(
|
||||||
|
CashuTransaction, _ppq_state_id_for_provider(provider_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_claim(
|
||||||
|
provider_id: int,
|
||||||
|
phase: str,
|
||||||
|
invoice_id: str,
|
||||||
|
lease_expires_at: int,
|
||||||
|
quote_id: str = "quote-1",
|
||||||
|
) -> str:
|
||||||
|
"""Seed a claim row and return its state token (the full request_id)."""
|
||||||
|
token = _ppq_request_id(
|
||||||
|
"operation-1", lease_expires_at, phase, invoice_id, quote_id
|
||||||
|
)
|
||||||
|
async with create_session() as session:
|
||||||
|
session.add(
|
||||||
|
CashuTransaction(
|
||||||
|
id=_ppq_state_id_for_provider(provider_id),
|
||||||
|
token="lnbc-invoice",
|
||||||
|
amount=102,
|
||||||
|
unit="sat",
|
||||||
|
type="out",
|
||||||
|
request_id=token,
|
||||||
|
mint_url="https://mint.test",
|
||||||
|
collected=False,
|
||||||
|
source="ppq_auto_topup",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def test_second_claim_is_refused_while_the_first_is_active(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
await _seed_provider()
|
||||||
|
assert await _claim_ppq_topup(_row()) is not None
|
||||||
|
# The whole point of the claim: a concurrent cycle must not get one.
|
||||||
|
assert await _claim_ppq_topup(_row()) is None
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
rows = (await session.exec(select(CashuTransaction))).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claim_is_reusable_once_the_previous_attempt_finished(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
await _seed_provider()
|
||||||
|
first = await _claim_ppq_topup(_row())
|
||||||
|
assert first is not None
|
||||||
|
assert await _set_ppq_state_terminal(_row(), first, collected=True, swept=False)
|
||||||
|
|
||||||
|
second = await _claim_ppq_topup(_row())
|
||||||
|
assert second is not None and second != first
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recording_the_invoice_moves_the_claim_in_flight(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
await _seed_provider()
|
||||||
|
operation_id = await _claim_ppq_topup(_row())
|
||||||
|
assert operation_id is not None
|
||||||
|
|
||||||
|
state = await get_ppq_auto_topup_state(1)
|
||||||
|
assert state["phase"] == PPQ_PHASE_CLAIMED
|
||||||
|
assert state["releasable"] is True
|
||||||
|
assert state["invoice_id"] is None
|
||||||
|
|
||||||
|
lease = await _record_ppq_invoice(
|
||||||
|
_row(),
|
||||||
|
operation_id,
|
||||||
|
invoice="lnbc-invoice",
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
quote_id="quote-1",
|
||||||
|
amount=102,
|
||||||
|
amount_usd=10,
|
||||||
|
unit="sat",
|
||||||
|
mint_url="https://mint.test",
|
||||||
|
)
|
||||||
|
assert lease > int(time.time())
|
||||||
|
|
||||||
|
state = await get_ppq_auto_topup_state(1)
|
||||||
|
assert state["phase"] == PPQ_PHASE_IN_FLIGHT
|
||||||
|
assert state["invoice_id"] == "invoice-1"
|
||||||
|
# A payment is committed to a mint, so an admin must not sweep it.
|
||||||
|
assert state["releasable"] is False
|
||||||
|
# The raw BOLT11 invoice must never reach the admin API.
|
||||||
|
assert "token" not in state
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_refuses_an_in_flight_claim(patched_db_engine: Any) -> None:
|
||||||
|
token = await _seed_claim(
|
||||||
|
1, PPQ_PHASE_IN_FLIGHT, "invoice-1", int(time.time()) + 900
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = await release_ppq_auto_topup_state(1, state_token=token)
|
||||||
|
|
||||||
|
assert outcome.released is False
|
||||||
|
assert outcome.reason == "payment_in_flight"
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.swept is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_refuses_a_stale_state_token(patched_db_engine: Any) -> None:
|
||||||
|
await _seed_claim(1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) + 900)
|
||||||
|
|
||||||
|
outcome = await release_ppq_auto_topup_state(1, state_token="ppq:stale:token")
|
||||||
|
|
||||||
|
assert outcome.released is False
|
||||||
|
assert outcome.reason == "stale_state"
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.swept is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_accepts_a_reconcile_claim(patched_db_engine: Any) -> None:
|
||||||
|
token = await _seed_claim(
|
||||||
|
1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) + 900
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = await release_ppq_auto_topup_state(1, state_token=token)
|
||||||
|
|
||||||
|
assert outcome.released is True
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.swept is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_in_flight_claim_becomes_releasable(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
# A worker that died mid-payment must not lock the provider forever.
|
||||||
|
token = await _seed_claim(1, PPQ_PHASE_IN_FLIGHT, "invoice-1", int(time.time()) - 1)
|
||||||
|
|
||||||
|
assert (await get_ppq_auto_topup_state(1))["releasable"] is True
|
||||||
|
outcome = await release_ppq_auto_topup_state(1, state_token=token)
|
||||||
|
assert outcome.released is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_reports_no_active_claim_once_swept(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
token = await _seed_claim(
|
||||||
|
1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) + 900
|
||||||
|
)
|
||||||
|
assert (await release_ppq_auto_topup_state(1, state_token=token)).released
|
||||||
|
|
||||||
|
outcome = await release_ppq_auto_topup_state(1, state_token=token)
|
||||||
|
assert outcome.released is False
|
||||||
|
assert outcome.reason == "no_active_claim"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_terminal_write_fails_after_the_claim_was_released(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
"""The symptom an admin release leaves behind for the owning worker."""
|
||||||
|
token = await _seed_claim(
|
||||||
|
1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) + 900
|
||||||
|
)
|
||||||
|
assert (await release_ppq_auto_topup_state(1, state_token=token)).released
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await _set_ppq_state_terminal(
|
||||||
|
_row(), "operation-1", collected=True, swept=False
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ppq_claim_rows_are_excluded_from_the_admin_transaction_list(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
from routstr.core.admin import get_transactions_api
|
||||||
|
|
||||||
|
await _seed_provider()
|
||||||
|
await _claim_ppq_topup(_row())
|
||||||
|
async with create_session() as session:
|
||||||
|
session.add(
|
||||||
|
CashuTransaction(
|
||||||
|
id="real-transaction",
|
||||||
|
token="cashuAreal",
|
||||||
|
amount=50,
|
||||||
|
unit="sat",
|
||||||
|
type="out",
|
||||||
|
source="x-cashu",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
result = await get_transactions_api()
|
||||||
|
|
||||||
|
ids = {t["id"] for t in result["transactions"]} # type: ignore[index,union-attr]
|
||||||
|
assert "real-transaction" in ids
|
||||||
|
assert _ppq_state_id_for_provider(1) not in ids
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ppq_payment_audit_row_is_visible_and_survives_next_claim(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
from routstr.core.admin import get_transactions_api
|
||||||
|
|
||||||
|
await _seed_provider()
|
||||||
|
operation_id = await _claim_ppq_topup(_row())
|
||||||
|
assert operation_id is not None
|
||||||
|
await _record_ppq_invoice(
|
||||||
|
_row(),
|
||||||
|
operation_id,
|
||||||
|
invoice="lnbc-secret-invoice",
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
quote_id="quote-1",
|
||||||
|
amount=102,
|
||||||
|
amount_usd=10,
|
||||||
|
unit="sat",
|
||||||
|
mint_url="https://mint.test",
|
||||||
|
)
|
||||||
|
assert await _set_ppq_state_terminal(
|
||||||
|
_row(), operation_id, collected=True, swept=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await get_transactions_api(source="ppq_auto_topup")
|
||||||
|
transactions = result["transactions"]
|
||||||
|
assert len(transactions) == 1
|
||||||
|
audit = transactions[0]
|
||||||
|
assert audit["id"] == _ppq_payment_id(operation_id)
|
||||||
|
assert audit["token"] == "ppq-invoice:invoice-1:usd:10"
|
||||||
|
assert audit["collected"] is True
|
||||||
|
assert "lnbc-secret-invoice" not in audit["token"]
|
||||||
|
|
||||||
|
# Reusing the deterministic claim lock must not overwrite history.
|
||||||
|
assert await _claim_ppq_topup(_row()) is not None
|
||||||
|
async with create_session() as session:
|
||||||
|
assert await session.get(CashuTransaction, audit["id"]) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_settles_a_recorded_invoice(patched_db_engine: Any) -> None:
|
||||||
|
from routstr.upstream.auto_topup import _reconcile_ppq_state
|
||||||
|
|
||||||
|
await _seed_claim(1, PPQ_PHASE_IN_FLIGHT, "invoice-1", int(time.time()) + 900)
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.check_topup_status = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
# Still suppresses this cycle, but the claim is now finished.
|
||||||
|
assert await _reconcile_ppq_state(_row(), provider) is True
|
||||||
|
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.collected is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stale_token_from_before_a_phase_change_cannot_release(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
"""The blocker scenario: admin reviews `claimed`, payment turns ambiguous.
|
||||||
|
|
||||||
|
The operation id is identical in both states, so an id-based fence would
|
||||||
|
let the stale confirmation land. The full state token must not.
|
||||||
|
"""
|
||||||
|
await _seed_provider()
|
||||||
|
operation_id = await _claim_ppq_topup(_row())
|
||||||
|
assert operation_id is not None
|
||||||
|
reviewed = await get_ppq_auto_topup_state(1)
|
||||||
|
assert reviewed["phase"] == PPQ_PHASE_CLAIMED
|
||||||
|
|
||||||
|
# Worker records the invoice: same operation, new phase, proofs committed.
|
||||||
|
await _record_ppq_invoice(
|
||||||
|
_row(),
|
||||||
|
operation_id,
|
||||||
|
invoice="lnbc-invoice",
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
quote_id="quote-1",
|
||||||
|
amount=102,
|
||||||
|
amount_usd=10,
|
||||||
|
unit="sat",
|
||||||
|
mint_url="https://mint.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = await release_ppq_auto_topup_state(
|
||||||
|
1, state_token=str(reviewed["state_token"])
|
||||||
|
)
|
||||||
|
assert outcome.released is False
|
||||||
|
assert outcome.reason == "stale_state"
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.swept is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_claims_only_one_wins(patched_db_engine: Any) -> None:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
await _seed_provider()
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_claim_ppq_topup(_row()) for _ in range(5)), return_exceptions=True
|
||||||
|
)
|
||||||
|
winners = [r for r in results if isinstance(r, str)]
|
||||||
|
assert len(winners) == 1
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
rows = (await session.exec(select(CashuTransaction))).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_releases_claim_when_mint_reports_unpaid(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
from routstr.upstream.auto_topup import _reconcile_ppq_state
|
||||||
|
|
||||||
|
# Lease expired, PPQ never credited: only the mint's own "unpaid" answer
|
||||||
|
# may hand the claim back.
|
||||||
|
await _seed_claim(1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) - 1)
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.check_topup_status = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.auto_topup.check_bolt11_payment_status",
|
||||||
|
AsyncMock(return_value="unpaid"),
|
||||||
|
) as status:
|
||||||
|
suppressed = await _reconcile_ppq_state(_row(), provider)
|
||||||
|
|
||||||
|
status.assert_awaited_once_with("https://mint.test", "sat", "quote-1")
|
||||||
|
assert suppressed is False
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.swept is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconcile_keeps_claim_when_mint_answer_is_not_final(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
from routstr.upstream.auto_topup import _reconcile_ppq_state
|
||||||
|
|
||||||
|
await _seed_claim(1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) - 1)
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.check_topup_status = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
for answer in ("paid", "pending", "unknown"):
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.auto_topup.check_bolt11_payment_status",
|
||||||
|
AsyncMock(return_value=answer),
|
||||||
|
):
|
||||||
|
assert await _reconcile_ppq_state(_row(), provider) is True
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.swept is False, answer
|
||||||
|
|
||||||
|
|
||||||
|
async def test_release_endpoint_maps_refusals_to_409(patched_db_engine: Any) -> None:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from routstr.core.admin import (
|
||||||
|
ReleasePPQAutoTopupRequest,
|
||||||
|
release_ppq_auto_topup_api,
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_row = MagicMock()
|
||||||
|
provider_row.provider_type = "ppqai"
|
||||||
|
|
||||||
|
token = await _seed_claim(
|
||||||
|
1, PPQ_PHASE_IN_FLIGHT, "invoice-1", int(time.time()) + 900
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.core.admin._require_ppq_provider",
|
||||||
|
AsyncMock(return_value=provider_row),
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await release_ppq_auto_topup_api(
|
||||||
|
1,
|
||||||
|
ReleasePPQAutoTopupRequest(
|
||||||
|
confirmed_safe_to_retry=True, state_token=token
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert excinfo.value.status_code == 409
|
||||||
|
assert "in flight" in excinfo.value.detail
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await release_ppq_auto_topup_api(
|
||||||
|
1,
|
||||||
|
ReleasePPQAutoTopupRequest(
|
||||||
|
confirmed_safe_to_retry=True, state_token="ppq:wrong"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert excinfo.value.status_code == 409
|
||||||
|
assert "changed since" in excinfo.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provider_delete_is_blocked_by_an_active_claim(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from routstr.core.admin import delete_upstream_provider
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
session.add(
|
||||||
|
UpstreamProviderRow(
|
||||||
|
id=1,
|
||||||
|
slug="ppq",
|
||||||
|
provider_type="ppqai",
|
||||||
|
base_url="https://api.ppq.ai",
|
||||||
|
api_key="secret",
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await _seed_claim(1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) + 900)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
await delete_upstream_provider("1")
|
||||||
|
assert excinfo.value.status_code == 409
|
||||||
|
|
||||||
|
# Provider must still exist.
|
||||||
|
async with create_session() as session:
|
||||||
|
assert await session.get(UpstreamProviderRow, 1) is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claim_is_refused_when_the_provider_row_is_gone(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
"""The worker's half of the delete race: no provider row, no claim."""
|
||||||
|
assert await _claim_ppq_topup(_row()) is None
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
rows = (await session.exec(select(CashuTransaction))).all()
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claim_is_refused_after_a_provider_type_change(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
|
||||||
|
await _seed_provider()
|
||||||
|
async with create_session() as session:
|
||||||
|
provider = await session.get(UpstreamProviderRow, 1)
|
||||||
|
assert provider is not None
|
||||||
|
provider.provider_type = "openai"
|
||||||
|
session.add(provider)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert await _claim_ppq_topup(_row()) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disabled_provider_with_claim_still_reconciles(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
"""A claim tracks committed money; eligibility must not stop reconciling."""
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
from routstr.upstream.auto_topup import _reconcile_all_ppq_claims
|
||||||
|
|
||||||
|
await _seed_provider()
|
||||||
|
async with create_session() as session:
|
||||||
|
provider = await session.get(UpstreamProviderRow, 1)
|
||||||
|
assert provider is not None
|
||||||
|
provider.enabled = False
|
||||||
|
session.add(provider)
|
||||||
|
await session.commit()
|
||||||
|
await _seed_claim(1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) + 900)
|
||||||
|
|
||||||
|
ppq = MagicMock()
|
||||||
|
ppq.check_topup_status = AsyncMock(return_value=True)
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=ppq,
|
||||||
|
):
|
||||||
|
await _reconcile_all_ppq_claims()
|
||||||
|
|
||||||
|
row = await _state_row()
|
||||||
|
assert row is not None and row.collected is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_claim_without_api_key_still_reconciles_via_the_mint(
|
||||||
|
patched_db_engine: Any,
|
||||||
|
) -> None:
|
||||||
|
from routstr.core.db import UpstreamProviderRow
|
||||||
|
from routstr.upstream.auto_topup import _reconcile_all_ppq_claims
|
||||||
|
|
||||||
|
await _seed_provider()
|
||||||
|
async with create_session() as session:
|
||||||
|
provider = await session.get(UpstreamProviderRow, 1)
|
||||||
|
assert provider is not None
|
||||||
|
provider.api_key = ""
|
||||||
|
session.add(provider)
|
||||||
|
await session.commit()
|
||||||
|
# Lease expired, so the mint may be consulted.
|
||||||
|
await _seed_claim(1, PPQ_PHASE_RECONCILE, "invoice-1", int(time.time()) - 1)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"routstr.upstream.auto_topup.check_bolt11_payment_status",
|
||||||
|
AsyncMock(return_value="unpaid"),
|
||||||
|
) as status:
|
||||||
|
await _reconcile_all_ppq_claims()
|
||||||
|
|
||||||
|
# No API key: PPQ was never polled, but the mint was, and its definitive
|
||||||
|
# "unpaid" released the claim.
|
||||||
|
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
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Restart reconciliation for ambiguous melts, against a real cashu wallet DB.
|
||||||
|
|
||||||
|
The ambiguous-melt path in ``execute_bolt11_payment`` re-reserves proofs with
|
||||||
|
``set_reserved_for_melt(..., quote_id=...)`` after cashu's ``melt()`` clears
|
||||||
|
both the reservation and the ``melt_id`` on a transport error. These tests
|
||||||
|
prove, on cashu's actual sqlite store rather than mocks, that the recovery
|
||||||
|
survives a process restart: a fresh wallet instance on the same database can
|
||||||
|
still find the proofs by ``melt_id`` — the lookup ``get_melt_quote()`` uses to
|
||||||
|
invalidate them on "paid" or release them on "unpaid".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cashu.core.base import Proof
|
||||||
|
from cashu.wallet import crud
|
||||||
|
from cashu.wallet.wallet import Wallet
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
QUOTE_ID = "quote-restart-1"
|
||||||
|
|
||||||
|
|
||||||
|
def _proof(secret: str, amount: int = 64) -> Proof:
|
||||||
|
return Proof(
|
||||||
|
id="009a1f293253e41e",
|
||||||
|
amount=amount,
|
||||||
|
secret=secret,
|
||||||
|
C="02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _wallet(db_dir: Path) -> Wallet:
|
||||||
|
# with_db builds the instance and runs migrations locally; nothing here
|
||||||
|
# talks to a mint.
|
||||||
|
return await Wallet.with_db("https://mint.test", str(db_dir))
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_ambiguous_melt(wallet: Wallet) -> list[Proof]:
|
||||||
|
"""Reproduce the exact sequence of an ambiguous melt failure.
|
||||||
|
|
||||||
|
1. Proofs exist and are selected for a melt.
|
||||||
|
2. cashu's melt() reserves them with the quote id, then hits a transport
|
||||||
|
error and rolls that back — reservation gone, melt_id gone.
|
||||||
|
3. Our recovery in execute_bolt11_payment re-reserves with the quote id.
|
||||||
|
"""
|
||||||
|
proofs = [_proof("secret-a"), _proof("secret-b", amount=32)]
|
||||||
|
for proof in proofs:
|
||||||
|
await crud.store_proof(proof, db=wallet.db)
|
||||||
|
|
||||||
|
await wallet.set_reserved_for_melt(proofs, reserved=True, quote_id=QUOTE_ID)
|
||||||
|
# cashu's `except` block in melt():
|
||||||
|
await wallet.set_reserved_for_melt(proofs, reserved=False, quote_id=None)
|
||||||
|
# our recovery:
|
||||||
|
await wallet.set_reserved_for_melt(proofs, reserved=True, quote_id=QUOTE_ID)
|
||||||
|
return proofs
|
||||||
|
|
||||||
|
|
||||||
|
async def test_melt_recovery_is_findable_by_quote_after_restart(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
wallet = await _wallet(tmp_path)
|
||||||
|
await _seed_ambiguous_melt(wallet)
|
||||||
|
|
||||||
|
# "Restart": a brand-new wallet on the same database file, as after a
|
||||||
|
# process crash between the melt and any reconciliation.
|
||||||
|
restarted = await _wallet(tmp_path)
|
||||||
|
found = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
|
||||||
|
|
||||||
|
# This is get_melt_quote()'s own lookup. If it comes back empty, a "paid"
|
||||||
|
# answer can never invalidate these proofs and an "unpaid" answer can
|
||||||
|
# never release them — the strand the send-style re-reserve caused.
|
||||||
|
assert sorted(p.secret for p in found) == ["secret-a", "secret-b"]
|
||||||
|
assert all(p.reserved for p in found)
|
||||||
|
assert all(p.melt_id == QUOTE_ID for p in found)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_send_style_reservation_would_not_be_reconcilable(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The defect the fix removed, demonstrated on the real store."""
|
||||||
|
wallet = await _wallet(tmp_path)
|
||||||
|
proofs = [_proof("secret-send")]
|
||||||
|
for proof in proofs:
|
||||||
|
await crud.store_proof(proof, db=wallet.db)
|
||||||
|
|
||||||
|
await wallet.set_reserved_for_melt(proofs, reserved=True, quote_id=QUOTE_ID)
|
||||||
|
await wallet.set_reserved_for_melt(proofs, reserved=False, quote_id=None)
|
||||||
|
# The old recovery: reserve as a send, no quote association.
|
||||||
|
await wallet.set_reserved_for_send(proofs, reserved=True)
|
||||||
|
|
||||||
|
restarted = await _wallet(tmp_path)
|
||||||
|
found = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
|
||||||
|
assert found == [] # reconciliation would never see these proofs
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unpaid_reconciliation_releases_recovered_proofs_after_restart(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""The full recovery arc: crash, restart, mint says unpaid, funds usable."""
|
||||||
|
wallet = await _wallet(tmp_path)
|
||||||
|
await _seed_ambiguous_melt(wallet)
|
||||||
|
|
||||||
|
restarted = await _wallet(tmp_path)
|
||||||
|
found = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
|
||||||
|
assert len(found) == 2
|
||||||
|
|
||||||
|
# What get_melt_quote() does on an "unpaid" answer.
|
||||||
|
await restarted.set_reserved_for_melt(found, reserved=False, quote_id=None)
|
||||||
|
|
||||||
|
released = await crud.get_proofs(db=restarted.db, melt_id=QUOTE_ID)
|
||||||
|
assert released == []
|
||||||
|
all_proofs = await crud.get_proofs(db=restarted.db)
|
||||||
|
assert len(all_proofs) == 2
|
||||||
|
assert all(not p.reserved for p in all_proofs) # spendable again
|
||||||
@@ -4,7 +4,29 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from routstr.core.db import CashuTransaction
|
from routstr.core.db import CashuTransaction
|
||||||
from routstr.upstream.auto_topup import _check_and_topup
|
from routstr.upstream.auto_topup import (
|
||||||
|
_check_and_topup,
|
||||||
|
_parse_ppq_request_id,
|
||||||
|
_run_auto_topup_cycle,
|
||||||
|
validate_ppq_auto_topup_settings,
|
||||||
|
)
|
||||||
|
from routstr.upstream.ppqai import PPQAIUpstreamProvider
|
||||||
|
from routstr.wallet import Bolt11PaymentAmbiguous, Bolt11PaymentNotAttempted
|
||||||
|
|
||||||
|
|
||||||
|
def test_ppq_claim_parser_rejects_invalid_expiry() -> None:
|
||||||
|
assert (
|
||||||
|
_parse_ppq_request_id("ppq:operation:not-a-timestamp:claimed:invoice:none")
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_balance_rejects_boolean_api_value() -> None:
|
||||||
|
provider = PPQAIUpstreamProvider("secret")
|
||||||
|
provider.check_balance = AsyncMock(return_value={"balance": False}) # type: ignore[method-assign]
|
||||||
|
|
||||||
|
assert await provider.get_balance() is None
|
||||||
|
|
||||||
|
|
||||||
def _row() -> MagicMock:
|
def _row() -> MagicMock:
|
||||||
@@ -12,6 +34,7 @@ def _row() -> MagicMock:
|
|||||||
row.id = "provider-1"
|
row.id = "provider-1"
|
||||||
row.base_url = "https://provider.test"
|
row.base_url = "https://provider.test"
|
||||||
row.api_key = "secret"
|
row.api_key = "secret"
|
||||||
|
row.provider_type = "routstr"
|
||||||
row.provider_settings = json.dumps(
|
row.provider_settings = json.dumps(
|
||||||
{
|
{
|
||||||
"auto_topup": True,
|
"auto_topup": True,
|
||||||
@@ -151,3 +174,563 @@ async def test_auto_topup_does_not_send_untracked_token() -> None:
|
|||||||
|
|
||||||
reclaim.assert_awaited_once_with("cashu-token")
|
reclaim.assert_awaited_once_with("cashu-token")
|
||||||
provider.topup.assert_not_awaited()
|
provider.topup.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def _ppq_row() -> MagicMock:
|
||||||
|
row = MagicMock()
|
||||||
|
row.id = "ppq-provider-1"
|
||||||
|
row.base_url = "https://api.ppq.ai"
|
||||||
|
row.api_key = "secret"
|
||||||
|
row.provider_type = "ppqai"
|
||||||
|
row.provider_settings = json.dumps(
|
||||||
|
{
|
||||||
|
"auto_topup": True,
|
||||||
|
"topup_threshold": 5.0,
|
||||||
|
"topup_amount_limit": 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_auto_topup_pays_invoice_and_confirms_settlement() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=2.5)
|
||||||
|
provider.initiate_topup = AsyncMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
payment_request="lnbc-invoice",
|
||||||
|
amount=10,
|
||||||
|
currency="USD",
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider.check_topup_status = AsyncMock(return_value=True)
|
||||||
|
plan = MagicMock()
|
||||||
|
plan.invoice_amount_sats = 100
|
||||||
|
plan.maximum_spend_sats = 102
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 2
|
||||||
|
plan.mint_url = "https://mint-rich.test"
|
||||||
|
plan.unit = "sat"
|
||||||
|
row = _ppq_row()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
||||||
|
AsyncMock(return_value="operation-1"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
||||||
|
AsyncMock(return_value=10_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
||||||
|
AsyncMock(return_value=0.0),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
||||||
|
AsyncMock(return_value=plan),
|
||||||
|
) as prepare,
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
||||||
|
AsyncMock(return_value=(101, "https://mint-rich.test", "sat")),
|
||||||
|
) as execute,
|
||||||
|
patch("routstr.upstream.auto_topup._record_ppq_invoice", AsyncMock()) as record,
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._record_ppq_payment_spent", AsyncMock()
|
||||||
|
) as record_spent,
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._set_ppq_state_terminal", AsyncMock()
|
||||||
|
) as terminal,
|
||||||
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
||||||
|
):
|
||||||
|
await _check_and_topup(row)
|
||||||
|
|
||||||
|
provider.initiate_topup.assert_awaited_once_with(10)
|
||||||
|
prepare.assert_awaited_once_with("lnbc-invoice")
|
||||||
|
execute.assert_awaited_once_with(plan)
|
||||||
|
record.assert_awaited_once()
|
||||||
|
record_spent.assert_awaited_once_with("operation-1", 101)
|
||||||
|
provider.check_topup_status.assert_awaited_once_with("invoice-1")
|
||||||
|
terminal.assert_awaited_once_with(row, "operation-1", collected=True, swept=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_ambiguous_melt_keeps_claim_and_emits_critical_alert() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=2.5)
|
||||||
|
provider.initiate_topup = AsyncMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
payment_request="lnbc-invoice",
|
||||||
|
amount=10,
|
||||||
|
currency="USD",
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plan = MagicMock(maximum_spend_sats=102, mint_url="https://mint.test", unit="sat")
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 2
|
||||||
|
row = _ppq_row()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
||||||
|
AsyncMock(return_value="operation-1"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
||||||
|
AsyncMock(return_value=10_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
||||||
|
AsyncMock(return_value=0.0),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
||||||
|
AsyncMock(return_value=plan),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
||||||
|
AsyncMock(side_effect=Bolt11PaymentAmbiguous("ambiguous melt")),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._record_ppq_invoice",
|
||||||
|
AsyncMock(return_value=2_000_000_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._mark_ppq_reconcile", AsyncMock()
|
||||||
|
) as reconcile_mark,
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._set_ppq_state_terminal", AsyncMock()
|
||||||
|
) as terminal,
|
||||||
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
||||||
|
patch("routstr.upstream.auto_topup.logger.critical") as critical,
|
||||||
|
):
|
||||||
|
with pytest.raises(Bolt11PaymentAmbiguous, match="ambiguous melt"):
|
||||||
|
await _check_and_topup(row)
|
||||||
|
|
||||||
|
# The claim is never released — it moves to reconcile for the admin.
|
||||||
|
terminal.assert_not_awaited()
|
||||||
|
reconcile_mark.assert_awaited_once()
|
||||||
|
critical.assert_called_once()
|
||||||
|
assert "admin reconciliation" in critical.call_args.args[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_payment_not_attempted_releases_claim_for_retry() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=2.5)
|
||||||
|
provider.initiate_topup = AsyncMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
payment_request="lnbc-invoice",
|
||||||
|
amount=10,
|
||||||
|
currency="USD",
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plan = MagicMock(maximum_spend_sats=102, mint_url="https://mint.test", unit="sat")
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 2
|
||||||
|
plan.quote.quote = "quote-1"
|
||||||
|
terminal = AsyncMock(return_value=True)
|
||||||
|
row = _ppq_row()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
||||||
|
AsyncMock(return_value=10_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
||||||
|
AsyncMock(return_value=0.0),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
||||||
|
AsyncMock(return_value="operation-1"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
||||||
|
AsyncMock(return_value=plan),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._record_ppq_invoice",
|
||||||
|
AsyncMock(return_value=2_000_000_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
||||||
|
AsyncMock(side_effect=Bolt11PaymentNotAttempted("unpaid")),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.auto_topup._set_ppq_state_terminal", terminal),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._mark_ppq_reconcile", AsyncMock()
|
||||||
|
) as reconcile,
|
||||||
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
||||||
|
pytest.raises(Bolt11PaymentNotAttempted, match="unpaid"),
|
||||||
|
):
|
||||||
|
await _check_and_topup(row)
|
||||||
|
|
||||||
|
terminal.assert_awaited_once_with(row, "operation-1", collected=False, swept=True)
|
||||||
|
reconcile.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_status_error_after_payment_marks_reconcile_and_alerts() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=2.5)
|
||||||
|
provider.initiate_topup = AsyncMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
payment_request="lnbc-invoice",
|
||||||
|
amount=10,
|
||||||
|
currency="USD",
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider.check_topup_status = AsyncMock(side_effect=RuntimeError("PPQ 502"))
|
||||||
|
plan = MagicMock(maximum_spend_sats=102, mint_url="https://mint.test", unit="sat")
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 2
|
||||||
|
plan.quote.quote = "quote-1"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
||||||
|
AsyncMock(return_value=10_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
||||||
|
AsyncMock(return_value=0.0),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
||||||
|
AsyncMock(return_value="operation-1"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
||||||
|
AsyncMock(return_value=plan),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._record_ppq_invoice",
|
||||||
|
AsyncMock(return_value=2_000_000_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
||||||
|
AsyncMock(return_value=(101, "https://mint.test", "sat")),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._record_ppq_payment_spent", AsyncMock()
|
||||||
|
) as spent,
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._mark_ppq_reconcile", AsyncMock()
|
||||||
|
) as reconcile,
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._set_ppq_state_terminal", AsyncMock()
|
||||||
|
) as terminal,
|
||||||
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
||||||
|
patch("routstr.upstream.auto_topup.logger.critical") as critical,
|
||||||
|
):
|
||||||
|
await _check_and_topup(_ppq_row())
|
||||||
|
|
||||||
|
spent.assert_awaited_once_with("operation-1", 101)
|
||||||
|
reconcile.assert_awaited_once()
|
||||||
|
terminal.assert_not_awaited()
|
||||||
|
assert "settlement polling failed" in critical.call_args.args[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_preflight_funding_check_happens_before_invoice_creation() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=2.5)
|
||||||
|
provider.initiate_topup = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
||||||
|
AsyncMock(return_value=1),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.auto_topup._claim_ppq_topup", AsyncMock()) as claim,
|
||||||
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
||||||
|
):
|
||||||
|
await _check_and_topup(_ppq_row())
|
||||||
|
|
||||||
|
provider.initiate_topup.assert_not_awaited()
|
||||||
|
claim.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_active_claim_at_cycle_start_suppresses_topup_for_whole_cycle() -> None:
|
||||||
|
row = _ppq_row()
|
||||||
|
row.id = 1
|
||||||
|
session = AsyncMock()
|
||||||
|
result = MagicMock()
|
||||||
|
result.all.return_value = [row]
|
||||||
|
session.exec.return_value = result
|
||||||
|
context = MagicMock()
|
||||||
|
context.__aenter__ = AsyncMock(return_value=session)
|
||||||
|
context.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_all_ppq_claims",
|
||||||
|
AsyncMock(return_value={1}),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.auto_topup.create_session", return_value=context),
|
||||||
|
patch("routstr.upstream.auto_topup._check_and_topup", AsyncMock()) as check,
|
||||||
|
):
|
||||||
|
await _run_auto_topup_cycle()
|
||||||
|
|
||||||
|
check.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_auto_topup_skips_when_balance_meets_threshold() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=5.0)
|
||||||
|
provider.initiate_topup = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _check_and_topup(_ppq_row())
|
||||||
|
|
||||||
|
provider.initiate_topup.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_auto_topup_skips_when_daily_spend_cap_reached() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=2.5)
|
||||||
|
provider.initiate_topup = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
||||||
|
AsyncMock(return_value=10_000_000),
|
||||||
|
),
|
||||||
|
# 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_usd",
|
||||||
|
AsyncMock(return_value=1000.0),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
||||||
|
AsyncMock(),
|
||||||
|
) as claim,
|
||||||
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
||||||
|
):
|
||||||
|
await _check_and_topup(_ppq_row())
|
||||||
|
|
||||||
|
claim.assert_not_awaited()
|
||||||
|
provider.initiate_topup.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_pending_attempt_suppresses_duplicate_topup() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=True),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _check_and_topup(_ppq_row())
|
||||||
|
|
||||||
|
provider.get_balance.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ppq_auto_topup_rejects_non_finite_balance() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=float("nan"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.auto_topup._claim_ppq_topup", AsyncMock()) as claim,
|
||||||
|
):
|
||||||
|
await _check_and_topup(_ppq_row())
|
||||||
|
|
||||||
|
claim.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settled_topup_alerts_when_its_claim_was_already_released() -> None:
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_balance = AsyncMock(return_value=2.5)
|
||||||
|
provider.initiate_topup = AsyncMock(
|
||||||
|
return_value=MagicMock(
|
||||||
|
invoice_id="invoice-1",
|
||||||
|
payment_request="lnbc-invoice",
|
||||||
|
amount=10,
|
||||||
|
currency="USD",
|
||||||
|
expires_at=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
provider.check_topup_status = AsyncMock(return_value=True)
|
||||||
|
plan = MagicMock()
|
||||||
|
plan.maximum_spend_sats = 102
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 2
|
||||||
|
plan.mint_url = "https://mint-rich.test"
|
||||||
|
plan.unit = "sat"
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
||||||
|
return_value=provider,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
||||||
|
AsyncMock(return_value="operation-1"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
||||||
|
AsyncMock(return_value=plan),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
||||||
|
AsyncMock(return_value=10_000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
||||||
|
AsyncMock(return_value=0.0),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
||||||
|
AsyncMock(return_value=(101, "https://mint-rich.test", "sat")),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.auto_topup._record_ppq_invoice", AsyncMock()),
|
||||||
|
patch("routstr.upstream.auto_topup._record_ppq_payment_spent", AsyncMock()),
|
||||||
|
patch(
|
||||||
|
"routstr.upstream.auto_topup._set_ppq_state_terminal",
|
||||||
|
AsyncMock(return_value=False),
|
||||||
|
),
|
||||||
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
||||||
|
patch("routstr.upstream.auto_topup.logger") as log,
|
||||||
|
):
|
||||||
|
await _check_and_topup(_ppq_row())
|
||||||
|
|
||||||
|
assert any(
|
||||||
|
"claim was already released" in call.args[0]
|
||||||
|
for call in log.critical.call_args_list
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("settings", "expected"),
|
||||||
|
[
|
||||||
|
({"auto_topup": False, "topup_threshold": -1}, None),
|
||||||
|
(
|
||||||
|
{"auto_topup": True, "topup_threshold": 5, "topup_amount_limit": 10},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"auto_topup": True, "topup_threshold": None, "topup_amount_limit": 10},
|
||||||
|
"threshold",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"auto_topup": True, "topup_threshold": 5, "topup_amount_limit": 0.5},
|
||||||
|
"whole number",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"auto_topup": True, "topup_threshold": 5, "topup_amount_limit": 5000},
|
||||||
|
"between",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"auto_topup": True, "topup_threshold": True, "topup_amount_limit": 10},
|
||||||
|
"threshold",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ppq_auto_topup_settings_validation(
|
||||||
|
settings: dict, expected: str | None
|
||||||
|
) -> None:
|
||||||
|
problem = validate_ppq_auto_topup_settings(settings)
|
||||||
|
if expected is None:
|
||||||
|
assert problem is None
|
||||||
|
else:
|
||||||
|
assert problem is not None and expected in problem
|
||||||
|
|
||||||
|
|
||||||
|
def test_ppq_auto_topup_settings_validation_survives_huge_json_integers() -> None:
|
||||||
|
# json.loads happily produces integers past float range; float() raises
|
||||||
|
# OverflowError there instead of returning inf.
|
||||||
|
problem = validate_ppq_auto_topup_settings(
|
||||||
|
{"auto_topup": True, "topup_threshold": 10**400, "topup_amount_limit": 10}
|
||||||
|
)
|
||||||
|
assert problem is not None and "threshold" in problem
|
||||||
|
|||||||
+223
-13
@@ -4,7 +4,7 @@ import json
|
|||||||
import socket
|
import socket
|
||||||
from collections.abc import AsyncIterator, Generator
|
from collections.abc import AsyncIterator, Generator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,13 +12,17 @@ from cashu.core.base import MeltQuoteState
|
|||||||
|
|
||||||
from routstr.core.db import ApiKey
|
from routstr.core.db import ApiKey
|
||||||
from routstr.wallet import (
|
from routstr.wallet import (
|
||||||
|
Bolt11PaymentAmbiguous,
|
||||||
|
Bolt11PaymentNotAttempted,
|
||||||
MintConnectionError,
|
MintConnectionError,
|
||||||
TokenConsumedError,
|
TokenConsumedError,
|
||||||
_is_mint_rate_limited,
|
_is_mint_rate_limited,
|
||||||
classify_redemption_error,
|
classify_redemption_error,
|
||||||
credit_balance,
|
credit_balance,
|
||||||
|
execute_bolt11_payment,
|
||||||
get_balance,
|
get_balance,
|
||||||
is_mint_connection_error,
|
is_mint_connection_error,
|
||||||
|
prepare_bolt11_payment,
|
||||||
recieve_token,
|
recieve_token,
|
||||||
send,
|
send,
|
||||||
send_token,
|
send_token,
|
||||||
@@ -239,9 +243,7 @@ async def test_recieve_token_uses_only_requested_destination_mint() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == (99, "sat", destination)
|
assert result == (99, "sat", destination)
|
||||||
swap.assert_awaited_once_with(
|
swap.assert_awaited_once_with(token, source_wallet, destination_mints=[destination])
|
||||||
token, source_wallet, destination_mints=[destination]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -492,9 +494,7 @@ async def test_send_refreshes_reservations_inside_wallet_guard() -> None:
|
|||||||
):
|
):
|
||||||
assert await send(1000, "sat", mint) == (1000, "token")
|
assert await send(1000, "sat", mint) == (1000, "token")
|
||||||
|
|
||||||
wallet.set_reserved_for_send.assert_awaited_once_with(
|
wallet.set_reserved_for_send.assert_awaited_once_with([proof], reserved=True)
|
||||||
[proof], reserved=True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -888,9 +888,7 @@ def _make_swap_mocks(
|
|||||||
quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee()
|
quote=f"melt_quote_{invoice}", amount=invoice, fee_reserve=_next_fee()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
mock_token_wallet.melt = AsyncMock(
|
mock_token_wallet.melt = AsyncMock(return_value=Mock(state=MeltQuoteState.paid))
|
||||||
return_value=Mock(state=MeltQuoteState.paid)
|
|
||||||
)
|
|
||||||
|
|
||||||
return mock_token, mock_token_wallet, mock_primary_wallet
|
return mock_token, mock_token_wallet, mock_primary_wallet
|
||||||
|
|
||||||
@@ -1804,6 +1802,220 @@ async def test_swap_melt_transport_error_is_never_reported_reusable() -> None:
|
|||||||
assert mock_token_wallet.melt.call_count == 1
|
assert mock_token_wallet.melt.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_bolt11_payment_rejects_unpaid_melt_state() -> None:
|
||||||
|
plan = MagicMock()
|
||||||
|
plan.proofs = [MagicMock(amount=110)]
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 10
|
||||||
|
plan.quote.quote = "quote-1"
|
||||||
|
plan.invoice = "lnbc-invoice"
|
||||||
|
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
|
||||||
|
plan.wallet.set_reserved_for_send = AsyncMock()
|
||||||
|
plan.wallet.melt = AsyncMock(return_value=MagicMock(state="UNPAID", change=[]))
|
||||||
|
|
||||||
|
with pytest.raises(Bolt11PaymentNotAttempted):
|
||||||
|
await execute_bolt11_payment(plan)
|
||||||
|
|
||||||
|
# An explicit unpaid answer means the proofs are ours again.
|
||||||
|
plan.wallet.set_reserved_for_send.assert_awaited_with(plan.proofs, reserved=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_bolt11_payment_accepts_legacy_paid_response() -> None:
|
||||||
|
plan = MagicMock()
|
||||||
|
plan.proofs = [MagicMock(amount=110)]
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 10
|
||||||
|
plan.quote.quote = "quote-1"
|
||||||
|
plan.invoice = "lnbc-invoice"
|
||||||
|
plan.mint_url = "https://mint.test"
|
||||||
|
plan.unit = "sat"
|
||||||
|
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
|
||||||
|
plan.wallet.set_reserved_for_send = AsyncMock()
|
||||||
|
plan.wallet.melt = AsyncMock(
|
||||||
|
return_value=MagicMock(state=None, paid=True, change=[])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await execute_bolt11_payment(plan) == (
|
||||||
|
110,
|
||||||
|
"https://mint.test",
|
||||||
|
"sat",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_bolt11_payment_keeps_proofs_reserved_when_melt_errors() -> None:
|
||||||
|
plan = MagicMock()
|
||||||
|
plan.proofs = [MagicMock(amount=110)]
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 10
|
||||||
|
plan.quote.quote = "quote-1"
|
||||||
|
plan.invoice = "lnbc-invoice"
|
||||||
|
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
|
||||||
|
plan.wallet.set_reserved_for_send = AsyncMock()
|
||||||
|
plan.wallet.set_reserved_for_melt = AsyncMock()
|
||||||
|
plan.wallet.melt = AsyncMock(side_effect=TimeoutError("no answer"))
|
||||||
|
|
||||||
|
with pytest.raises(Bolt11PaymentAmbiguous):
|
||||||
|
await execute_bolt11_payment(plan)
|
||||||
|
|
||||||
|
# The mint may still settle with these proofs. cashu's own melt()
|
||||||
|
# un-reserves them on a mint transport error, so the ambiguous path must
|
||||||
|
# re-reserve — and it must do so with the melt quote id, because
|
||||||
|
# get_melt_quote() finds the proofs to settle by melt_id.
|
||||||
|
plan.wallet.set_reserved_for_melt.assert_awaited_once_with(
|
||||||
|
plan.proofs, reserved=True, quote_id="quote-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_bolt11_payment_does_not_reserve_when_selection_fails() -> None:
|
||||||
|
plan = MagicMock()
|
||||||
|
plan.proofs = [MagicMock(amount=110)]
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 10
|
||||||
|
plan.wallet.select_to_send = AsyncMock(side_effect=ValueError("insufficient"))
|
||||||
|
plan.wallet.set_reserved_for_send = AsyncMock()
|
||||||
|
plan.wallet.melt = AsyncMock()
|
||||||
|
|
||||||
|
with pytest.raises(Bolt11PaymentNotAttempted):
|
||||||
|
await execute_bolt11_payment(plan)
|
||||||
|
|
||||||
|
plan.wallet.set_reserved_for_send.assert_not_awaited()
|
||||||
|
plan.wallet.melt.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prepare_bolt11_payment_counts_input_fees_in_sufficiency() -> None:
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.proofs = [MagicMock(amount=105)]
|
||||||
|
wallet.melt_quote = AsyncMock(
|
||||||
|
return_value=MagicMock(amount=100, fee_reserve=2, quote="quote-1")
|
||||||
|
)
|
||||||
|
# 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", **_: object) -> MagicMock:
|
||||||
|
if unit == "msat":
|
||||||
|
raise ValueError("unit unsupported")
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "cashu_mints", ["https://only.test"]),
|
||||||
|
patch.object(settings, "primary_mint", "https://only.test"),
|
||||||
|
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||||
|
side_effect=lambda wallet, *args, **kwargs: wallet.proofs,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.slow_filter_spend_proofs",
|
||||||
|
side_effect=lambda proofs, wallet: proofs,
|
||||||
|
),
|
||||||
|
pytest.raises(ValueError, match="enough balance"),
|
||||||
|
):
|
||||||
|
await prepare_bolt11_payment("lnbc-invoice")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prepare_bolt11_payment_does_not_spend_user_liabilities() -> None:
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.proofs = [MagicMock(amount=500)]
|
||||||
|
wallet.melt_quote = AsyncMock(
|
||||||
|
return_value=MagicMock(amount=100, fee_reserve=2, quote="quote-1")
|
||||||
|
)
|
||||||
|
wallet.get_fees_for_proofs = Mock(return_value=0)
|
||||||
|
|
||||||
|
async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock:
|
||||||
|
if unit == "msat":
|
||||||
|
raise ValueError("unit unsupported")
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "cashu_mints", ["https://only.test"]),
|
||||||
|
patch.object(settings, "primary_mint", "https://only.test"),
|
||||||
|
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||||
|
side_effect=lambda wallet, *args, **kwargs: wallet.proofs,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.slow_filter_spend_proofs",
|
||||||
|
side_effect=lambda proofs, wallet: proofs,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet._owner_balance_for_mint_and_unit",
|
||||||
|
AsyncMock(return_value=90),
|
||||||
|
),
|
||||||
|
pytest.raises(ValueError, match="user liabilities"),
|
||||||
|
):
|
||||||
|
await prepare_bolt11_payment("lnbc-invoice")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prepare_bolt11_payment_rounds_user_liability_up_to_whole_sats() -> None:
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
wallet = MagicMock()
|
||||||
|
wallet.proofs = [MagicMock(amount=100)]
|
||||||
|
wallet.melt_quote = AsyncMock(
|
||||||
|
return_value=MagicMock(amount=1, fee_reserve=0, quote="quote-1")
|
||||||
|
)
|
||||||
|
wallet.get_fees_for_proofs = Mock(return_value=0)
|
||||||
|
|
||||||
|
async def get_wallet(mint_url: str, unit: str = "sat", **_: object) -> MagicMock:
|
||||||
|
if unit == "msat":
|
||||||
|
raise ValueError("unit unsupported")
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "cashu_mints", ["https://only.test"]),
|
||||||
|
patch.object(settings, "primary_mint", "https://only.test"),
|
||||||
|
patch("routstr.wallet.get_wallet", side_effect=get_wallet),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||||
|
side_effect=lambda wallet, *args, **kwargs: wallet.proofs,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.slow_filter_spend_proofs",
|
||||||
|
side_effect=lambda proofs, wallet: proofs,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.db.total_user_liability",
|
||||||
|
AsyncMock(return_value=99_999),
|
||||||
|
),
|
||||||
|
pytest.raises(ValueError, match="user liabilities"),
|
||||||
|
):
|
||||||
|
await prepare_bolt11_payment("lnbc-invoice")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_bolt11_payment_rereserves_when_cancelled() -> None:
|
||||||
|
plan = MagicMock()
|
||||||
|
plan.proofs = [MagicMock(amount=110)]
|
||||||
|
plan.quote.amount = 100
|
||||||
|
plan.quote.fee_reserve = 10
|
||||||
|
plan.quote.quote = "quote-1"
|
||||||
|
plan.invoice = "lnbc-invoice"
|
||||||
|
plan.mint_url = "https://mint.test"
|
||||||
|
plan.wallet.select_to_send = AsyncMock(return_value=(plan.proofs, 0))
|
||||||
|
plan.wallet.set_reserved_for_send = AsyncMock()
|
||||||
|
plan.wallet.set_reserved_for_melt = AsyncMock()
|
||||||
|
plan.wallet.melt = AsyncMock(side_effect=asyncio.CancelledError())
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await execute_bolt11_payment(plan)
|
||||||
|
|
||||||
|
plan.wallet.set_reserved_for_melt.assert_awaited_once_with(
|
||||||
|
plan.proofs, reserved=True, quote_id="quote-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Per-mint adaptive guard + _mint_operation factory/retry
|
# Per-mint adaptive guard + _mint_operation factory/retry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -2014,9 +2226,7 @@ async def test_default_timeout_allows_retry_after_rate_limit_cooldown() -> None:
|
|||||||
response = httpx.Response(429, request=request)
|
response = httpx.Response(429, request=request)
|
||||||
operation = AsyncMock(
|
operation = AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
httpx.HTTPStatusError(
|
httpx.HTTPStatusError("rate limited", request=request, response=response),
|
||||||
"rate limited", request=request, response=response
|
|
||||||
),
|
|
||||||
"ok",
|
"ok",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AdminService } from '@/lib/api/services/admin';
|
||||||
|
import type { PPQAutoTopupState } from '@/lib/api/services/admin';
|
||||||
import type {
|
import type {
|
||||||
AdminModel,
|
AdminModel,
|
||||||
ProviderModels,
|
ProviderModels,
|
||||||
@@ -20,12 +22,16 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Key,
|
Key,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
AlertTriangle,
|
||||||
|
Unlock,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ProviderBalance } from '@/components/provider-balance';
|
import { ProviderBalance } from '@/components/provider-balance';
|
||||||
import { ProviderModelsPanel } from '@/components/provider-models-panel';
|
import { ProviderModelsPanel } from '@/components/provider-models-panel';
|
||||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||||
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
import { RoutstrProviderService } from '@/lib/api/services/routstr-provider';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { getErrorStatus } from '@/lib/api/client';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -36,6 +42,16 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface ProviderCardProps {
|
interface ProviderCardProps {
|
||||||
provider: UpstreamProvider;
|
provider: UpstreamProvider;
|
||||||
@@ -77,8 +93,71 @@ export function ProviderCard({
|
|||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
||||||
|
const [isReleaseDialogOpen, setIsReleaseDialogOpen] = useState(false);
|
||||||
|
// The claim as the query cache held it when the admin opened the dialog.
|
||||||
|
// The mutation sends this token rather than re-reading the query at submit
|
||||||
|
// time: a background refetch after the dialog opened must not swap in a
|
||||||
|
// state the admin never saw. The server rejects a stale token with a 409,
|
||||||
|
// which is the authoritative guard.
|
||||||
|
const [reviewedState, setReviewedState] = useState<PPQAutoTopupState | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
const hasDetails = Boolean(provider.api_version) || isExpanded;
|
||||||
const isRoutstr = provider.provider_type === 'routstr';
|
const isRoutstr = provider.provider_type === 'routstr';
|
||||||
|
const isPPQ = provider.provider_type === 'ppqai';
|
||||||
|
|
||||||
|
const { data: ppqAutoTopupState, isError: ppqStateFetchFailed } = useQuery({
|
||||||
|
queryKey: ['ppq-auto-topup-state', provider.id],
|
||||||
|
queryFn: () => AdminService.getPPQAutoTopupState(provider.id),
|
||||||
|
enabled: isPPQ,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A claim the server will not let us release: a worker is between reserving
|
||||||
|
// proofs and hearing back from the mint, and sweeping it would let the next
|
||||||
|
// cycle pay a second invoice.
|
||||||
|
const isPPQPaymentInFlight =
|
||||||
|
Boolean(ppqAutoTopupState?.active) &&
|
||||||
|
ppqAutoTopupState?.releasable === false;
|
||||||
|
|
||||||
|
const openReleaseDialog = () => {
|
||||||
|
setReviewedState(ppqAutoTopupState ?? null);
|
||||||
|
setIsReleaseDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const releasePPQMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
AdminService.releasePPQAutoTopup(
|
||||||
|
provider.id,
|
||||||
|
reviewedState?.state_token ?? null
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['ppq-auto-topup-state', provider.id],
|
||||||
|
});
|
||||||
|
setIsReleaseDialogOpen(false);
|
||||||
|
setReviewedState(null);
|
||||||
|
toast.success('PPQ auto top-up claim released');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['ppq-auto-topup-state', provider.id],
|
||||||
|
});
|
||||||
|
if (getErrorStatus(error) === 409) {
|
||||||
|
// The claim changed since it was reviewed; the stale snapshot is
|
||||||
|
// useless, so force a fresh review.
|
||||||
|
setIsReleaseDialogOpen(false);
|
||||||
|
setReviewedState(null);
|
||||||
|
toast.error(
|
||||||
|
'PPQ claim changed since it was reviewed; reopen to see the new state'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Transient failure: keep the dialog and the reviewed snapshot so the
|
||||||
|
// admin can retry without re-navigating.
|
||||||
|
toast.error(`Failed to release PPQ claim: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const refundMutation = useMutation({
|
const refundMutation = useMutation({
|
||||||
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
mutationFn: () => RoutstrProviderService.refundBalance(provider.id),
|
||||||
@@ -113,6 +192,35 @@ export function ProviderCard({
|
|||||||
>
|
>
|
||||||
{provider.enabled ? 'Enabled' : 'Disabled'}
|
{provider.enabled ? 'Enabled' : 'Disabled'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{ppqAutoTopupState?.active && (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className={cn(
|
||||||
|
'w-fit gap-1',
|
||||||
|
isPPQPaymentInFlight
|
||||||
|
? 'border-blue-500 text-blue-700 dark:text-blue-400'
|
||||||
|
: 'border-amber-500 text-amber-700 dark:text-amber-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPPQPaymentInFlight ? (
|
||||||
|
<Loader2 className='h-3 w-3 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className='h-3 w-3' />
|
||||||
|
)}
|
||||||
|
{isPPQPaymentInFlight
|
||||||
|
? 'Paying invoice'
|
||||||
|
: 'Auto top-up needs review'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isPPQ && ppqStateFetchFailed && (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className='border-destructive text-destructive w-fit gap-1'
|
||||||
|
>
|
||||||
|
<AlertTriangle className='h-3 w-3' />
|
||||||
|
Top-up status unavailable
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className='break-all'>
|
<CardDescription className='break-all'>
|
||||||
{provider.base_url}
|
{provider.base_url}
|
||||||
@@ -153,6 +261,19 @@ export function ProviderCard({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isPPQ && ppqAutoTopupState?.active && !isPPQPaymentInFlight && (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={openReleaseDialog}
|
||||||
|
className='justify-center gap-1.5 border-amber-500 text-amber-700 dark:text-amber-400'
|
||||||
|
title='Release only after manually verifying the Lightning payment outcome'
|
||||||
|
>
|
||||||
|
<Unlock className='h-4 w-4' />
|
||||||
|
<span>Release top-up</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isRoutstr && provider.api_key && (
|
{isRoutstr && provider.api_key && (
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
@@ -212,6 +333,42 @@ export function ProviderCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={isReleaseDialogOpen}
|
||||||
|
onOpenChange={setIsReleaseDialogOpen}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Release PPQ auto top-up?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Only continue after checking PPQ and the Cashu mint and confirming
|
||||||
|
the previous Lightning payment cannot later settle. Releasing an
|
||||||
|
ambiguous payment can allow a duplicate top-up.
|
||||||
|
{reviewedState?.invoice_id
|
||||||
|
? ` Invoice: ${reviewedState.invoice_id}`
|
||||||
|
: ''}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
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
|
||||||
|
? 'Releasing...'
|
||||||
|
: 'I verified it is safe to retry'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
<Dialog open={isKeyModalOpen} onOpenChange={setIsKeyModalOpen}>
|
||||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[500px]'>
|
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[500px]'>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ProviderFormFields } from '@/components/provider-form-fields';
|
import { ProviderFormFields } from '@/components/provider-form-fields';
|
||||||
|
import { ppqAutoTopupSettingsInvalid } from '@/components/providers/PPQAutoTopupSettings';
|
||||||
|
|
||||||
interface ProviderFormDialogContentProps {
|
interface ProviderFormDialogContentProps {
|
||||||
mode: 'create' | 'edit';
|
mode: 'create' | 'edit';
|
||||||
@@ -52,6 +53,11 @@ export function ProviderFormDialogContent({
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
availableMints,
|
availableMints,
|
||||||
}: ProviderFormDialogContentProps) {
|
}: ProviderFormDialogContentProps) {
|
||||||
|
// The server re-validates these bounds; this only stops submitting a form
|
||||||
|
// whose inline errors are already visible.
|
||||||
|
const hasInvalidSettings =
|
||||||
|
formData.provider_type === 'ppqai' &&
|
||||||
|
ppqAutoTopupSettingsInvalid(formData.provider_settings || {});
|
||||||
return (
|
return (
|
||||||
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[500px]'>
|
<DialogContent className='max-h-[90dvh] overflow-y-auto sm:max-w-[500px]'>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -80,7 +86,7 @@ export function ProviderFormDialogContent({
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={onSubmit}
|
onClick={onSubmit}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || hasInvalidSettings}
|
||||||
className='w-full sm:w-auto'
|
className='w-full sm:w-auto'
|
||||||
>
|
>
|
||||||
{isSubmitting ? submittingLabel : submitLabel}
|
{isSubmitting ? submittingLabel : submitLabel}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { PPQAutoTopupSettings } from '@/components/providers/PPQAutoTopupSettings';
|
||||||
import { RoutstrNodeSettings } from '@/components/providers/RoutstrNodeSettings';
|
import { RoutstrNodeSettings } from '@/components/providers/RoutstrNodeSettings';
|
||||||
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
import { RoutstrCreateKeySection } from '@/components/providers/RoutstrCreateKeySection';
|
||||||
|
|
||||||
@@ -78,6 +79,8 @@ export function ProviderFormFields({
|
|||||||
provider_type: value,
|
provider_type: value,
|
||||||
base_url: getDefaultBaseUrl(value),
|
base_url: getDefaultBaseUrl(value),
|
||||||
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
|
provider_fee: value === 'openrouter' ? 1.06 : 1.01,
|
||||||
|
provider_settings:
|
||||||
|
value === prev.provider_type ? prev.provider_settings : {},
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -118,6 +121,19 @@ export function ProviderFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{formData.provider_type === 'ppqai' && (
|
||||||
|
<PPQAutoTopupSettings
|
||||||
|
settings={formData.provider_settings || {}}
|
||||||
|
onSettingsChange={(settings) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
provider_settings: settings,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
idPrefix={mode === 'edit' ? 'edit' : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className='grid gap-2'>
|
<div className='grid gap-2'>
|
||||||
<Label htmlFor={`${idPrefix}slug`}>
|
<Label htmlFor={`${idPrefix}slug`}>
|
||||||
Slug {mode === 'create' ? '(optional, auto-generated)' : ''}
|
Slug {mode === 'create' ? '(optional, auto-generated)' : ''}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
interface ProviderSettings {
|
||||||
|
auto_topup?: boolean;
|
||||||
|
topup_threshold?: number;
|
||||||
|
topup_amount_limit?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PPQAutoTopupSettingsProps {
|
||||||
|
settings: ProviderSettings;
|
||||||
|
onSettingsChange: (settings: ProviderSettings) => void;
|
||||||
|
idPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
? 'Required when auto top-up is enabled'
|
||||||
|
: threshold <= 0
|
||||||
|
? 'Must be greater than 0'
|
||||||
|
: undefined,
|
||||||
|
amountError:
|
||||||
|
amount === undefined
|
||||||
|
? 'Required when auto top-up is enabled'
|
||||||
|
: amount < 1 || amount > 500
|
||||||
|
? 'Must be between 1 and 500 USD'
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ppqAutoTopupSettingsInvalid(
|
||||||
|
settings: ProviderSettings
|
||||||
|
): boolean {
|
||||||
|
const { thresholdError, amountError } = ppqAutoTopupSettingsErrors(settings);
|
||||||
|
return Boolean(thresholdError || amountError);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PPQAutoTopupSettings({
|
||||||
|
settings,
|
||||||
|
onSettingsChange,
|
||||||
|
idPrefix = '',
|
||||||
|
}: PPQAutoTopupSettingsProps) {
|
||||||
|
const prefix = idPrefix ? `${idPrefix}_` : '';
|
||||||
|
const update = (patch: Partial<ProviderSettings>) =>
|
||||||
|
onSettingsChange({ ...settings, ...patch });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clearing the field yields '' and parse* yields NaN, which JSON.stringify
|
||||||
|
* turns into null. Drop the key instead so the server rejects a missing
|
||||||
|
* value rather than storing a broken one.
|
||||||
|
*/
|
||||||
|
const updateNumber = (
|
||||||
|
key: 'topup_threshold' | 'topup_amount_limit',
|
||||||
|
raw: string,
|
||||||
|
parse: (value: string) => number
|
||||||
|
) => {
|
||||||
|
const next = { ...settings };
|
||||||
|
const parsed = parse(raw);
|
||||||
|
if (raw === '' || Number.isNaN(parsed)) {
|
||||||
|
delete next[key];
|
||||||
|
} else {
|
||||||
|
next[key] = parsed;
|
||||||
|
}
|
||||||
|
onSettingsChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { thresholdError, amountError } = ppqAutoTopupSettingsErrors(settings);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-muted/30 grid gap-4 rounded-lg border p-4'>
|
||||||
|
<Label className='text-sm font-semibold'>PPQ Auto Top-up</Label>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<Label htmlFor={`${prefix}ppq_auto_topup`} className='text-sm'>
|
||||||
|
Enable Auto Top-up
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id={`${prefix}ppq_auto_topup`}
|
||||||
|
checked={!!settings.auto_topup}
|
||||||
|
onCheckedChange={(checked) => update({ auto_topup: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.auto_topup && (
|
||||||
|
<div className='border-primary/20 grid gap-4 border-l-2 pt-2 pl-4'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${prefix}ppq_topup_threshold`}
|
||||||
|
className='text-xs font-medium'
|
||||||
|
>
|
||||||
|
When credits are below (USD)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}ppq_topup_threshold`}
|
||||||
|
type='number'
|
||||||
|
min='0.01'
|
||||||
|
step='0.01'
|
||||||
|
className='h-9'
|
||||||
|
placeholder='e.g. 5'
|
||||||
|
value={settings.topup_threshold ?? ''}
|
||||||
|
aria-invalid={Boolean(thresholdError)}
|
||||||
|
aria-describedby={
|
||||||
|
thresholdError
|
||||||
|
? `${prefix}ppq_topup_threshold_error`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateNumber('topup_threshold', e.target.value, parseFloat)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{thresholdError && (
|
||||||
|
<p
|
||||||
|
id={`${prefix}ppq_topup_threshold_error`}
|
||||||
|
className='text-destructive text-[10px]'
|
||||||
|
>
|
||||||
|
{thresholdError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${prefix}ppq_topup_amount_limit`}
|
||||||
|
className='text-xs font-medium'
|
||||||
|
>
|
||||||
|
Purchase this amount (USD)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${prefix}ppq_topup_amount_limit`}
|
||||||
|
type='number'
|
||||||
|
min='1'
|
||||||
|
max='500'
|
||||||
|
step='1'
|
||||||
|
className='h-9'
|
||||||
|
placeholder='e.g. 10'
|
||||||
|
value={settings.topup_amount_limit ?? ''}
|
||||||
|
aria-invalid={Boolean(amountError)}
|
||||||
|
aria-describedby={
|
||||||
|
amountError
|
||||||
|
? `${prefix}ppq_topup_amount_limit_error`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateNumber('topup_amount_limit', e.target.value, (v) =>
|
||||||
|
parseInt(v, 10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{amountError && (
|
||||||
|
<p
|
||||||
|
id={`${prefix}ppq_topup_amount_limit_error`}
|
||||||
|
className='text-destructive text-[10px]'
|
||||||
|
>
|
||||||
|
{amountError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='text-muted-foreground text-[10px]'>
|
||||||
|
Pays PPQ's Lightning invoice from the sufficiently funded Cashu
|
||||||
|
mint with the highest available balance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -144,3 +144,18 @@ export class ApiError extends Error {
|
|||||||
this.data = data;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1013,6 +1013,32 @@ export class AdminService {
|
|||||||
}>(`/admin/api/upstream-providers/${providerId}/balance`);
|
}>(`/admin/api/upstream-providers/${providerId}/balance`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async getPPQAutoTopupState(
|
||||||
|
providerId: number
|
||||||
|
): Promise<PPQAutoTopupState> {
|
||||||
|
return await apiClient.get<PPQAutoTopupState>(
|
||||||
|
`/admin/api/upstream-providers/${providerId}/ppq-auto-topup`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `stateToken` must be the `state_token` snapshotted when the admin opened
|
||||||
|
* the confirmation — not re-read at submit time. The server rejects a
|
||||||
|
* release whose claim changed in any way since that snapshot.
|
||||||
|
*/
|
||||||
|
static async releasePPQAutoTopup(
|
||||||
|
providerId: number,
|
||||||
|
stateToken: string | null
|
||||||
|
): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
released: boolean;
|
||||||
|
}> {
|
||||||
|
return await apiClient.post<{ ok: boolean; released: boolean }>(
|
||||||
|
`/admin/api/upstream-providers/${providerId}/ppq-auto-topup/release`,
|
||||||
|
{ confirmed_safe_to_retry: true, state_token: stateToken }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── CLI Tokens ──
|
// ── CLI Tokens ──
|
||||||
|
|
||||||
static async listCliTokens(): Promise<CliTokenListItem[]> {
|
static async listCliTokens(): Promise<CliTokenListItem[]> {
|
||||||
@@ -1244,6 +1270,30 @@ export interface TransactionsResponse {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PPQAutoTopupState {
|
||||||
|
ok: boolean;
|
||||||
|
active: boolean;
|
||||||
|
/**
|
||||||
|
* Opaque version of the claim as reviewed. Echo it back verbatim to
|
||||||
|
* release; the server rejects a release whose claim changed in any way
|
||||||
|
* (new attempt, phase change, renewed lease) since this was read.
|
||||||
|
*/
|
||||||
|
state_token?: string | null;
|
||||||
|
/** Identifies the attempt currently holding the claim. Informational. */
|
||||||
|
operation_id?: string | null;
|
||||||
|
/** 'claimed' | 'in_flight' | 'reconcile'. Null when the claim is malformed. */
|
||||||
|
phase?: string | null;
|
||||||
|
/** False while a payment is in flight — the server rejects a release then. */
|
||||||
|
releasable?: boolean;
|
||||||
|
expires_at?: number | null;
|
||||||
|
invoice_id?: string | null;
|
||||||
|
created_at?: number;
|
||||||
|
amount?: number;
|
||||||
|
unit?: string;
|
||||||
|
mint_url?: string | null;
|
||||||
|
malformed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LightningInvoice {
|
export interface LightningInvoice {
|
||||||
id: string;
|
id: string;
|
||||||
bolt11: string;
|
bolt11: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user