mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
chore: trim redundant comments
This commit is contained in:
+1
-1
@@ -33,7 +33,7 @@ ROUTSTR_SECRET_KEY=
|
|||||||
# DATABASE_POOL_PRE_PING=false
|
# DATABASE_POOL_PRE_PING=false
|
||||||
# Warn when a checkout is held this many seconds.
|
# Warn when a checkout is held this many seconds.
|
||||||
# DATABASE_POOL_HOLD_WARN_SECONDS=10
|
# DATABASE_POOL_HOLD_WARN_SECONDS=10
|
||||||
# Seconds a file-backed SQLite writer waits for the write lock (default: 30).
|
# SQLite write-lock timeout, in seconds.
|
||||||
# DATABASE_BUSY_TIMEOUT=30
|
# DATABASE_BUSY_TIMEOUT=30
|
||||||
# SQLite serialises writes; increasing its pool can trade pool timeouts for
|
# SQLite serialises writes; increasing its pool can trade pool timeouts for
|
||||||
# "database is locked" errors rather than increasing write throughput.
|
# "database is locked" errors rather than increasing write throughput.
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# ADR-001: Cashu payment safety boundaries
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Accepted
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Cashu proofs are bearer instruments. Retrying quote creation, refund delivery, or a dispatched Lightning melt can duplicate side effects or spend proofs whose outcome is still unknown. Mint transport failures and concurrent workers also need one shared policy.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
- Treat account, invoice, quote, melt, token-delivery, and refund creation as non-idempotent unless an upstream idempotency key is available.
|
|
||||||
- A dispatched melt with an unknown outcome keeps a durable quote-linked proof reservation until later reconciliation confirms a terminal state. An immediate `unpaid` observation after transport loss is not terminal.
|
|
||||||
- Size melts from the quote amount, reserve, and exact proof input fees within the caller's gross budget; do not use recursive send selection for melt planning.
|
|
||||||
- Apply mint transport/rate cooldowns centrally and permit only explicit reconciliation probes during cooldown.
|
|
||||||
- Auto-topups require fresh threshold confirmation, durable per-provider claims/cooldown, atomic spend-cap checks, and owner-only funds.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
Transient failures can delay payouts/topups rather than risk duplicate payment. Operators may need to reconcile ambiguous claims. Tests must cover restart, concurrency, and partial-stream failures at these boundaries.
|
|
||||||
@@ -1367,9 +1367,7 @@ async def initiate_provider_topup(
|
|||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
# This POST creates a Cashu mint quote upstream. Without an
|
# Quote creation is unsafe to retry without idempotency.
|
||||||
# idempotency key, retrying a timeout or 5xx can create a
|
|
||||||
# second invoice while abandoning the first.
|
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
f"{clean_url}/v1/balance/lightning/invoice",
|
f"{clean_url}/v1/balance/lightning/invoice",
|
||||||
json=request_json,
|
json=request_json,
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ def create_db_engine(database_url: str = DATABASE_URL) -> AsyncEngine:
|
|||||||
options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping}
|
options: dict[str, int | float | bool] = {"pool_pre_ping": pool_pre_ping}
|
||||||
connect_args: dict[str, object] = {}
|
connect_args: dict[str, object] = {}
|
||||||
if is_sqlite and not is_memory_sqlite:
|
if is_sqlite and not is_memory_sqlite:
|
||||||
# SQLite's default busy_timeout is only 5s, and aiosqlite does not set
|
|
||||||
# one of its own. Without this, concurrent payment-settlement writes
|
|
||||||
# across the pooled engine wait just 5s, then raise
|
|
||||||
# sqlite3.OperationalError: database is locked. Give writers a real
|
|
||||||
# chance to acquire the single SQLite write lock.
|
|
||||||
connect_args["timeout"] = settings.database_busy_timeout
|
connect_args["timeout"] = settings.database_busy_timeout
|
||||||
if not is_memory_sqlite:
|
if not is_memory_sqlite:
|
||||||
options.update(
|
options.update(
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ async def http_exception_handler(request: Request, exc: Exception) -> JSONRespon
|
|||||||
path = request.url.path
|
path = request.url.path
|
||||||
|
|
||||||
# 4xx is client behaviour; the uvicorn access log already records it.
|
# 4xx is client behaviour; the uvicorn access log already records it.
|
||||||
# Retryable mint outages are expected dependency failures, not application
|
|
||||||
# faults, so keep them visible without flooding the error stream.
|
|
||||||
if status_code >= 500:
|
if status_code >= 500:
|
||||||
error_type = None
|
error_type = None
|
||||||
if isinstance(detail, dict):
|
if isinstance(detail, dict):
|
||||||
|
|||||||
@@ -148,10 +148,6 @@ class Settings(BaseSettings):
|
|||||||
database_pool_hold_warn_seconds: float = Field(
|
database_pool_hold_warn_seconds: float = Field(
|
||||||
default=10.0, gt=0, env="DATABASE_POOL_HOLD_WARN_SECONDS"
|
default=10.0, gt=0, env="DATABASE_POOL_HOLD_WARN_SECONDS"
|
||||||
)
|
)
|
||||||
# SQLite busy_timeout (seconds): how long a writer waits on a locked DB
|
|
||||||
# before raising "database is locked". Defaults to SQLite's 5s in stock
|
|
||||||
# aiosqlite; raise it so concurrent payment-settlement writes can queue
|
|
||||||
# instead of erroring. Referenced only by create_db_engine for SQLite.
|
|
||||||
database_busy_timeout: float = Field(
|
database_busy_timeout: float = Field(
|
||||||
default=30.0, gt=0, env="DATABASE_BUSY_TIMEOUT"
|
default=30.0, gt=0, env="DATABASE_BUSY_TIMEOUT"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -225,8 +225,7 @@ async def _request_mint_with_fallback(
|
|||||||
lambda: wallet.request_mint(amount_sats),
|
lambda: wallet.request_mint(amount_sats),
|
||||||
op_name="request_mint_invoice",
|
op_name="request_mint_invoice",
|
||||||
mint_url=mint_url,
|
mint_url=mint_url,
|
||||||
# Response loss may leave a valid quote at the mint. Creating a
|
# Quote creation is unsafe to retry without idempotency.
|
||||||
# second quote is not a safe retry without an idempotency key.
|
|
||||||
retry_timeouts=False,
|
retry_timeouts=False,
|
||||||
retry_on_rate_limit=False,
|
retry_on_rate_limit=False,
|
||||||
)
|
)
|
||||||
@@ -479,8 +478,6 @@ async def check_invoice_payment(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
mint_url = settlement.mint_url or settings.primary_mint
|
mint_url = settlement.mint_url or settings.primary_mint
|
||||||
# Quote status is remote state and does not inspect local proofs.
|
|
||||||
# _mint_invoice_quote loads proofs exactly when settlement needs them.
|
|
||||||
wallet = await get_wallet(mint_url, "sat", load_proofs=False)
|
wallet = await get_wallet(mint_url, "sat", load_proofs=False)
|
||||||
try:
|
try:
|
||||||
mint_status = await run_mint_operation(
|
mint_status = await run_mint_operation(
|
||||||
|
|||||||
@@ -239,7 +239,6 @@ def mint_cooldown_reason(mint_url: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def is_mint_transport_error(error: BaseException) -> bool:
|
def is_mint_transport_error(error: BaseException) -> bool:
|
||||||
"""Return whether an exception chain contains a mint transport failure."""
|
|
||||||
current: BaseException | None = error
|
current: BaseException | None = error
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
while current is not None and id(current) not in seen:
|
while current is not None and id(current) not in seen:
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ async def _fetch_lnurl_json(
|
|||||||
|
|
||||||
|
|
||||||
def _contains_mint_transport_error(error: BaseException) -> bool:
|
def _contains_mint_transport_error(error: BaseException) -> bool:
|
||||||
"""Detect transport failures wrapped by the Cashu wallet implementation."""
|
|
||||||
seen: set[int] = set()
|
seen: set[int] = set()
|
||||||
current: BaseException | None = error
|
current: BaseException | None = error
|
||||||
while current is not None and id(current) not in seen:
|
while current is not None and id(current) not in seen:
|
||||||
@@ -321,15 +320,10 @@ async def raw_send_to_lnurl(
|
|||||||
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
f"({min_sendable_sat} - {max_sendable_sat} {unit})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start at the caller's gross budget and converge downward from the mint's
|
|
||||||
# exact reserve plus NUT-02 input fees. Starting below the budget with a
|
|
||||||
# percentage heuristic silently underpays even when the exact fees are tiny.
|
|
||||||
final_amount = amount_msat
|
final_amount = amount_msat
|
||||||
|
|
||||||
selected_proofs: list[Proof] | None = None
|
selected_proofs: list[Proof] | None = None
|
||||||
# Fee reserves can change with the invoice amount. Each quote reduces the
|
# Find the largest amount covered by the budget after reserve and input fees.
|
||||||
# candidate by its exact shortfall, so this bounded fixed-point search keeps
|
|
||||||
# the largest amount the gross budget can fund without Cashu coin selection.
|
|
||||||
for _ in range(8):
|
for _ in range(8):
|
||||||
if final_amount < lnurl_data["min_sendable"]:
|
if final_amount < lnurl_data["min_sendable"]:
|
||||||
raise LNURLError("Cashu melt fees leave no payable LNURL amount")
|
raise LNURLError("Cashu melt fees leave no payable LNURL amount")
|
||||||
@@ -340,7 +334,7 @@ async def raw_send_to_lnurl(
|
|||||||
lambda: wallet.melt_quote(invoice=bolt11_invoice),
|
lambda: wallet.melt_quote(invoice=bolt11_invoice),
|
||||||
op_name="lnurl_melt_quote",
|
op_name="lnurl_melt_quote",
|
||||||
mint_url=str(wallet.url),
|
mint_url=str(wallet.url),
|
||||||
# Creating another quote after response loss only abandons the first.
|
# Quote creation is unsafe to retry without idempotency.
|
||||||
retry_timeouts=False,
|
retry_timeouts=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -392,10 +386,7 @@ async def raw_send_to_lnurl(
|
|||||||
raise
|
raise
|
||||||
if not _contains_mint_transport_error(error):
|
if not _contains_mint_transport_error(error):
|
||||||
raise
|
raise
|
||||||
# Cashu 0.20 clears melt reservations before wrapping transport errors
|
# Cashu clears reservations on transport errors despite an unknown outcome.
|
||||||
# in a plain Exception. Restore the durable melt association before
|
|
||||||
# asking for quote state so these proofs cannot be spent again while
|
|
||||||
# the Lightning outcome is unknown.
|
|
||||||
try:
|
try:
|
||||||
await wallet.set_reserved_for_melt(
|
await wallet.set_reserved_for_melt(
|
||||||
proofs, reserved=True, quote_id=melt_quote_resp.quote
|
proofs, reserved=True, quote_id=melt_quote_resp.quote
|
||||||
@@ -414,8 +405,6 @@ async def raw_send_to_lnurl(
|
|||||||
if melt_state == MeltQuoteState.paid:
|
if melt_state == MeltQuoteState.paid:
|
||||||
return final_amount
|
return final_amount
|
||||||
if melt_state == MeltQuoteState.unpaid:
|
if melt_state == MeltQuoteState.unpaid:
|
||||||
# A direct unpaid response is authoritative: the mint rejected the melt
|
|
||||||
# and Cashu has already cleared its quote-linked reservation.
|
|
||||||
await wallet.set_reserved_for_send(proofs, reserved=False)
|
await wallet.set_reserved_for_send(proofs, reserved=False)
|
||||||
raise LNURLError("Cashu mint confirmed that the melt was unpaid")
|
raise LNURLError("Cashu mint confirmed that the melt was unpaid")
|
||||||
|
|
||||||
@@ -425,8 +414,7 @@ async def raw_send_to_lnurl(
|
|||||||
op_name="reconcile_lnurl_melt_quote",
|
op_name="reconcile_lnurl_melt_quote",
|
||||||
mint_url=str(wallet.url),
|
mint_url=str(wallet.url),
|
||||||
retry_timeouts=False,
|
retry_timeouts=False,
|
||||||
# One direct state lookup is required to reconcile the just-dispatched
|
# Reconciliation must bypass the cooldown opened by this failure.
|
||||||
# melt even though its transport failure opened the mint cooldown.
|
|
||||||
allow_during_cooldown=True,
|
allow_during_cooldown=True,
|
||||||
)
|
)
|
||||||
except Exception as reconciliation_error:
|
except Exception as reconciliation_error:
|
||||||
@@ -438,10 +426,7 @@ async def raw_send_to_lnurl(
|
|||||||
if quote is not None and quote.state == MeltQuoteState.paid:
|
if quote is not None and quote.state == MeltQuoteState.paid:
|
||||||
return final_amount
|
return final_amount
|
||||||
if quote is not None and quote.state == MeltQuoteState.unpaid:
|
if quote is not None and quote.state == MeltQuoteState.unpaid:
|
||||||
# Reaching reconciliation means melt was dispatched and either lost its
|
# A just-dispatched quote can briefly report unpaid before transitioning.
|
||||||
# response or returned pending. A just-dispatched quote can briefly read
|
|
||||||
# UNPAID before transitioning. Cashu clears the reservation while
|
|
||||||
# refreshing that state, so restore it and require later reconciliation.
|
|
||||||
try:
|
try:
|
||||||
await wallet.set_reserved_for_melt(
|
await wallet.set_reserved_for_melt(
|
||||||
proofs, reserved=True, quote_id=melt_quote_resp.quote
|
proofs, reserved=True, quote_id=melt_quote_resp.quote
|
||||||
|
|||||||
@@ -366,9 +366,7 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with wallet_operation_guard():
|
async with wallet_operation_guard():
|
||||||
# The cap, owner-liability check, proof reservation, and outgoing
|
# Keep the spend cap and audit mutation in one wallet lock.
|
||||||
# audit row share one wallet mutation scope. The audit row must be
|
|
||||||
# durable before another worker can recheck the rolling cap.
|
|
||||||
spent_24h_sats = await _routstr_spent_last_24h_sats()
|
spent_24h_sats = await _routstr_spent_last_24h_sats()
|
||||||
if spent_24h_sats + amount > ROUTSTR_MAX_DAILY_TOPUP_SATS:
|
if spent_24h_sats + amount > ROUTSTR_MAX_DAILY_TOPUP_SATS:
|
||||||
raise ValueError("Routstr auto top-up daily spend cap reached")
|
raise ValueError("Routstr auto top-up daily spend cap reached")
|
||||||
@@ -420,9 +418,6 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None:
|
|||||||
await _release_routstr_claim(row, operation_id)
|
await _release_routstr_claim(row, operation_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
# The audit row and SENT claim committed together before this network call,
|
|
||||||
# so a worker crash cannot make reconciliation treat reserved proofs as an
|
|
||||||
# unspent CLAIMED attempt.
|
|
||||||
result = await provider.topup(token)
|
result = await provider.topup(token)
|
||||||
|
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
@@ -713,7 +708,6 @@ async def _persist_routstr_token_and_mark_sent(
|
|||||||
amount: int,
|
amount: int,
|
||||||
mint_url: str,
|
mint_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Commit the bearer-token audit row and SENT claim atomically."""
|
|
||||||
state_id = _routstr_state_id(row)
|
state_id = _routstr_state_id(row)
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
state = await session.get(CashuTransaction, state_id)
|
state = await session.get(CashuTransaction, state_id)
|
||||||
@@ -1123,8 +1117,6 @@ async def _set_ppq_state_terminal(
|
|||||||
.values(
|
.values(
|
||||||
collected=collected,
|
collected=collected,
|
||||||
swept=swept,
|
swept=swept,
|
||||||
# For successful payments this timestamps the durable cooldown,
|
|
||||||
# not merely when the original claim was created.
|
|
||||||
created_at=int(time.time()) if collected else CashuTransaction.created_at,
|
created_at=int(time.time()) if collected else CashuTransaction.created_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1513,8 +1505,7 @@ async def _check_and_topup_ppq(row: UpstreamProviderRow, settings: dict) -> None
|
|||||||
if balance >= threshold_usd:
|
if balance >= threshold_usd:
|
||||||
return
|
return
|
||||||
|
|
||||||
# A single stale/partial balance response must never create an invoice.
|
# Require two low-balance reads before creating an invoice.
|
||||||
# Read the uncached endpoint again and require independent agreement.
|
|
||||||
confirmed_balance = await provider.get_balance()
|
confirmed_balance = await provider.get_balance()
|
||||||
if (
|
if (
|
||||||
confirmed_balance is None
|
confirmed_balance is None
|
||||||
|
|||||||
@@ -1076,9 +1076,6 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Preserve the original stream exception. If the database
|
|
||||||
# cannot even be opened/read, stale-reservation cleanup is
|
|
||||||
# the only safe recovery path.
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Fallback stream billing recovery could not access the database",
|
"Fallback stream billing recovery could not access the database",
|
||||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
extra={"key_hash": key.hashed_key[:8] + "..."},
|
||||||
@@ -1551,9 +1548,6 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Preserve the original stream exception. If the database
|
|
||||||
# cannot even be opened/read, stale-reservation cleanup is
|
|
||||||
# the only safe recovery path.
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Fallback Responses billing recovery could not access the database",
|
"Fallback Responses billing recovery could not access the database",
|
||||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
extra={"key_hash": key.hashed_key[:8] + "..."},
|
||||||
@@ -3686,9 +3680,7 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# send_token may perform an irreversible Cashu swap to make exact
|
# Token creation may swap proofs, so it is unsafe to retry.
|
||||||
# denominations. A blanket retry after response loss can dispatch
|
|
||||||
# a second swap, so this call is intentionally single-attempt.
|
|
||||||
refund_token = await send_token(amount, unit=unit, mint_url=mint)
|
refund_token = await send_token(amount, unit=unit, mint_url=mint)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ _PPQ_CIRCUIT_COOLDOWN_SECONDS = 30.0
|
|||||||
|
|
||||||
|
|
||||||
class PPQCircuitOpenError(RuntimeError):
|
class PPQCircuitOpenError(RuntimeError):
|
||||||
"""PPQ safe reads are suppressed until one probe is allowed."""
|
pass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -51,12 +51,10 @@ async def _safe_read_request(
|
|||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
json: dict[str, object] | None = None,
|
json: dict[str, object] | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
"""Retry safe reads, then open one process-local circuit per PPQ origin."""
|
|
||||||
state = _ppq_circuits.setdefault(_ppq_origin(url), _PPQCircuitState())
|
state = _ppq_circuits.setdefault(_ppq_origin(url), _PPQCircuitState())
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
if state.loop is not loop:
|
if state.loop is not loop:
|
||||||
# Runtime uses one long-lived loop; pytest and some embedded hosts do
|
# Locks cannot be reused across event loops.
|
||||||
# not. Preserve circuit state while replacing a loop-bound lock.
|
|
||||||
state.lock = asyncio.Lock()
|
state.lock = asyncio.Lock()
|
||||||
state.loop = loop
|
state.loop = loop
|
||||||
async with state.lock:
|
async with state.lock:
|
||||||
@@ -323,8 +321,6 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
return models
|
return models
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# The base refresh handler preserves the last good model cache when
|
|
||||||
# fetching raises; [] would look like a valid empty catalog.
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def on_upstream_error_redirect(
|
async def on_upstream_error_redirect(
|
||||||
|
|||||||
+2
-19
@@ -154,7 +154,6 @@ class Wallet(_CashuWallet):
|
|||||||
*,
|
*,
|
||||||
force_refresh: bool = False,
|
force_refresh: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Load metadata once per mint URL, then hydrate unit wallets locally."""
|
|
||||||
mint_url = str(self.url)
|
mint_url = str(self.url)
|
||||||
lock = _mint_metadata_load_locks.setdefault(mint_url, asyncio.Lock())
|
lock = _mint_metadata_load_locks.setdefault(mint_url, asyncio.Lock())
|
||||||
async with lock:
|
async with lock:
|
||||||
@@ -171,8 +170,6 @@ class Wallet(_CashuWallet):
|
|||||||
await self.load_mint_info(reload=False)
|
await self.load_mint_info(reload=False)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
# An empty/stale local cache is not authoritative. Fall
|
|
||||||
# through to one remote refresh under the per-mint lock.
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await self.load_mint_keysets(force_old_keysets)
|
await self.load_mint_keysets(force_old_keysets)
|
||||||
@@ -593,7 +590,6 @@ async def send_token(amount: int, unit: str, mint_url: str | None = None) -> str
|
|||||||
async def send_token_from_owner_locked(
|
async def send_token_from_owner_locked(
|
||||||
amount: int, unit: str, mint_url: str | None = None
|
amount: int, unit: str, mint_url: str | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create an owner-funded token while the caller holds the wallet guard."""
|
|
||||||
_, token = await _send_locked(amount, unit, mint_url, owner_only=True)
|
_, token = await _send_locked(amount, unit, mint_url, owner_only=True)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
@@ -1866,10 +1862,7 @@ async def _credit_balance_locked(
|
|||||||
|
|
||||||
|
|
||||||
_wallets: dict[str, Wallet] = {}
|
_wallets: dict[str, Wallet] = {}
|
||||||
# Proofs are local SQLite state and need a short refresh window because another
|
# Proofs require a shorter refresh interval than remote mint metadata.
|
||||||
# worker process can reserve or spend them. Mint metadata is remote, shared by
|
|
||||||
# every operation on a wallet, and changes far less often; refreshing it on the
|
|
||||||
# proof cadence caused repeated /keysets, /keys, and /info requests.
|
|
||||||
_wallet_last_load: dict[str, float] = {}
|
_wallet_last_load: dict[str, float] = {}
|
||||||
_wallet_last_mint_load: dict[str, float] = {}
|
_wallet_last_mint_load: dict[str, float] = {}
|
||||||
_wallet_load_locks: dict[str, asyncio.Lock] = {}
|
_wallet_load_locks: dict[str, asyncio.Lock] = {}
|
||||||
@@ -1883,13 +1876,6 @@ async def get_wallet(
|
|||||||
force_reload: bool = False,
|
force_reload: bool = False,
|
||||||
load_proofs: bool = True,
|
load_proofs: bool = True,
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
"""Return a cached wallet, refreshing remote and local state independently.
|
|
||||||
|
|
||||||
``load=False`` remains the fully offline path. Quote-only callers can use
|
|
||||||
``load_proofs=False``: mint metadata is initialized when needed, but local
|
|
||||||
proofs are not re-read when the operation cannot spend or inspect them.
|
|
||||||
``force_reload`` still refreshes every requested layer immediately.
|
|
||||||
"""
|
|
||||||
global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks
|
global _wallets, _wallet_last_load, _wallet_last_mint_load, _wallet_load_locks
|
||||||
id = f"{mint_url}_{unit}"
|
id = f"{mint_url}_{unit}"
|
||||||
lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
|
lock = _wallet_load_locks.setdefault(id, asyncio.Lock())
|
||||||
@@ -1925,7 +1911,6 @@ async def get_wallet(
|
|||||||
or now - last_proof_load
|
or now - last_proof_load
|
||||||
>= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
|
>= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
|
||||||
):
|
):
|
||||||
# cashu's load_proofs is local SQLite I/O, not a mint call.
|
|
||||||
await run_mint_operation(
|
await run_mint_operation(
|
||||||
lambda: _wallets[id].load_proofs(reload=True),
|
lambda: _wallets[id].load_proofs(reload=True),
|
||||||
op_name="load_proofs",
|
op_name="load_proofs",
|
||||||
@@ -2005,9 +1990,7 @@ async def _get_supported_mint_units(mint_url: str) -> list[str]:
|
|||||||
if cached is not None and now < cached[0]:
|
if cached is not None and now < cached[0]:
|
||||||
return cached[1]
|
return cached[1]
|
||||||
|
|
||||||
# One full remote metadata load populates Cashu's shared SQLite keyset
|
# A metadata load populates Cashu's shared keyset cache for all units.
|
||||||
# cache. Discover all advertised units from that cache instead of issuing a
|
|
||||||
# separate /keysets request before each unit wallet loads.
|
|
||||||
wallet = await get_wallet(
|
wallet = await get_wallet(
|
||||||
mint_url,
|
mint_url,
|
||||||
settings.primary_mint_unit,
|
settings.primary_mint_unit,
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ from routstr.mint import MintRateGuard # noqa: E402
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def isolate_mint_rate_guards() -> Iterator[None]:
|
def isolate_mint_rate_guards() -> Iterator[None]:
|
||||||
"""Do not let one integration test's simulated outage poison the next."""
|
|
||||||
MintRateGuard._guards.clear()
|
MintRateGuard._guards.clear()
|
||||||
yield
|
yield
|
||||||
MintRateGuard._guards.clear()
|
MintRateGuard._guards.clear()
|
||||||
|
|||||||
@@ -313,8 +313,6 @@ async def test_ppq_payment_audit_row_is_visible_and_survives_next_claim(
|
|||||||
assert audit["collected"] is True
|
assert audit["collected"] is True
|
||||||
assert "lnbc-secret-invoice" not in audit["token"]
|
assert "lnbc-secret-invoice" not in audit["token"]
|
||||||
|
|
||||||
# The durable cooldown blocks an immediate duplicate, then the claim lock
|
|
||||||
# can be reused without overwriting audit history after it expires.
|
|
||||||
assert await _claim_ppq_topup(_row()) is None
|
assert await _claim_ppq_topup(_row()) is None
|
||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
state = await session.get(CashuTransaction, _ppq_state_id_for_provider(1))
|
state = await session.get(CashuTransaction, _ppq_state_id_for_provider(1))
|
||||||
|
|||||||
@@ -114,8 +114,6 @@ async def test_api_key_generation_invalid_token(
|
|||||||
async def test_duplicate_token_handling(
|
async def test_duplicate_token_handling(
|
||||||
integration_client: AsyncClient, testmint_wallet: Any, db_snapshot: Any
|
integration_client: AsyncClient, testmint_wallet: Any, db_snapshot: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Concurrent duplicate redemption credits once and deterministically replays."""
|
|
||||||
|
|
||||||
amount = 500
|
amount = 500
|
||||||
token = await testmint_wallet.mint_tokens(amount)
|
token = await testmint_wallet.mint_tokens(amount)
|
||||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||||
@@ -134,7 +132,6 @@ async def test_duplicate_token_handling(
|
|||||||
assert api_key1 == api_key2
|
assert api_key1 == api_key2
|
||||||
assert balance1 == balance2 == amount * 1000
|
assert balance1 == balance2 == amount * 1000
|
||||||
|
|
||||||
# The real DB contains one logical credit. A later replay is also mutation-free.
|
|
||||||
await db_snapshot.capture()
|
await db_snapshot.capture()
|
||||||
replay = await integration_client.get("/v1/wallet/info")
|
replay = await integration_client.get("/v1/wallet/info")
|
||||||
assert replay.status_code == 200
|
assert replay.status_code == 200
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ async def test_melt_recovery_is_findable_by_quote_after_restart(
|
|||||||
async def test_paid_reconciliation_invalidates_recovered_proofs_after_restart(
|
async def test_paid_reconciliation_invalidates_recovered_proofs_after_restart(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Actual Wallet.get_melt_quote consumes quote-linked proofs after restart."""
|
|
||||||
wallet = await _wallet(tmp_path)
|
wallet = await _wallet(tmp_path)
|
||||||
await _seed_ambiguous_melt(wallet)
|
await _seed_ambiguous_melt(wallet)
|
||||||
|
|
||||||
@@ -143,7 +142,6 @@ async def test_send_style_reservation_would_not_be_reconcilable(
|
|||||||
async def test_unpaid_reconciliation_releases_recovered_proofs_after_restart(
|
async def test_unpaid_reconciliation_releases_recovered_proofs_after_restart(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Actual Wallet.get_melt_quote releases proofs after an unpaid answer."""
|
|
||||||
wallet = await _wallet(tmp_path)
|
wallet = await _wallet(tmp_path)
|
||||||
await _seed_ambiguous_melt(wallet)
|
await _seed_ambiguous_melt(wallet)
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ LNURL_DATA = {
|
|||||||
"max_sendable": 100_000_000,
|
"max_sendable": 100_000_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
# The exact plan spends the 1000 sat gross budget as 999 sat + 1 sat reserve.
|
|
||||||
EXPECTED_QUOTE_SAT = 999
|
EXPECTED_QUOTE_SAT = 999
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +141,6 @@ async def test_raw_send_to_lnurl_accepts_exact_invoice() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
|
async def test_raw_send_to_lnurl_msat_unit_compares_in_wallet_unit() -> None:
|
||||||
# 1_000_000 msat gross minus the exact 1 msat reserve leaves 999_999 msat.
|
|
||||||
wallet, proofs = _wallet()
|
wallet, proofs = _wallet()
|
||||||
proofs[0].amount = 1_000_000
|
proofs[0].amount = 1_000_000
|
||||||
wallet.melt_quote = AsyncMock(
|
wallet.melt_quote = AsyncMock(
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ LNURL_DATA = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Exact planning pays 999 sat from a 1000 sat budget with a 1 sat reserve.
|
|
||||||
QUOTE_AMOUNT_SAT = 999
|
QUOTE_AMOUNT_SAT = 999
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"""Refund token issuance must not repeat an ambiguous Cashu swap."""
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|||||||
Reference in New Issue
Block a user