mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: make payouts resilient to mint proof limits
This commit is contained in:
@@ -213,6 +213,7 @@ Use environment variables for:
|
|||||||
| `MINT_RETRY_MAX_ATTEMPTS` | Retries after a timeout or HTTP 429 (`0` disables retries) | `3` |
|
| `MINT_RETRY_MAX_ATTEMPTS` | Retries after a timeout or HTTP 429 (`0` disables retries) | `3` |
|
||||||
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
| `RECEIVE_LN_ADDRESS` | Lightning address for withdrawals | — |
|
||||||
| `MIN_PAYOUT_SAT` | Min payout balance in sats (applies to all mints) | `210` |
|
| `MIN_PAYOUT_SAT` | Min payout balance in sats (applies to all mints) | `210` |
|
||||||
|
| `MAX_PAYOUT_SAT` | Maximum gross budget per periodic payout in sats, including fees (all mints) | `250000` |
|
||||||
| `PAYOUT_INTERVAL_SECONDS` | Payout loop interval (seconds) | `900` |
|
| `PAYOUT_INTERVAL_SECONDS` | Payout loop interval (seconds) | `900` |
|
||||||
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
| `TOR_PROXY_URL` | SOCKS5 proxy for Tor | `socks5://127.0.0.1:9050` |
|
||||||
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
| `CORS_ORIGINS` | Allowed CORS origins | `*` |
|
||||||
@@ -225,6 +226,26 @@ Routstr's wallet mutation lock fail fast during that cooldown instead of waiting
|
|||||||
while blocking every other wallet mutation. Callers receive an error and may retry
|
while blocking every other wallet mutation. Callers receive an error and may retry
|
||||||
later; the current response does not include the cooldown duration.
|
later; the current response does not include the cooldown duration.
|
||||||
|
|
||||||
|
Read-only `/v1/checkstate` requests start at the SDK request-model limit
|
||||||
|
(currently 1,000 proofs) and adapt downward on HTTP 413 or 500, down to one
|
||||||
|
proof. A 500 is a size hypothesis, not a confirmed limit. Successful reduced
|
||||||
|
sizes are cached per mint within each worker for 24 hours (and refreshed while
|
||||||
|
in use). HTTP 429 never reduces the batch
|
||||||
|
size. Scan deadline expiry opens a transport cooldown without shortening any
|
||||||
|
existing rate-limit cooldown. Invalid, incomplete, or failed scans do
|
||||||
|
not produce a partial spendable balance. Only explicit UNSPENT proofs qualify;
|
||||||
|
PENDING proofs are retained but excluded from payouts.
|
||||||
|
|
||||||
|
Each scan is bounded by a fixed 60-second deadline and a 128-request budget;
|
||||||
|
exhausting either aborts that scan safely. Automatic splitting applies only to
|
||||||
|
state checks, **not swaps or melts**. Their limits are independent, and ambiguous
|
||||||
|
mutation outcomes must be reconciled rather than retried with different inputs.
|
||||||
|
Periodic payouts reload local proofs without forcing a keyset refresh, skip
|
||||||
|
state checks at/below `MIN_PAYOUT_SAT`, and cap each gross payout budget at
|
||||||
|
`MAX_PAYOUT_SAT`. Oversized inputs receive enough change outputs to return the
|
||||||
|
excess; they are not automatically swapped. The cap is not a proof-count limit
|
||||||
|
or a guarantee of Lightning payment success.
|
||||||
|
|
||||||
### Priority
|
### Priority
|
||||||
|
|
||||||
Environment variables are read on startup. Dashboard settings override them and persist in the database. Once you change a setting in the dashboard, the env var is ignored for that setting.
|
Environment variables are read on startup. Dashboard settings override them and persist in the database. Once you change a setting in the dashboard, the env var is ignored for that setting.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Bounded adaptive batching for read-only NUT-07 requests, never mutations."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from cashu.core.base import Proof, ProofSpentState, ProofState
|
||||||
|
from cashu.core.models import PostCheckStateRequest
|
||||||
|
from cashu.wallet.wallet import Wallet
|
||||||
|
|
||||||
|
from .core.logging import get_logger
|
||||||
|
from .mint import MINT_TRANSPORT_COOLDOWN_SECONDS, MintRateGuard, run_mint_operation
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
_SDK_BATCH_LIMIT = PostCheckStateRequest.model_json_schema()["properties"]["Ys"][
|
||||||
|
"maxItems"
|
||||||
|
]
|
||||||
|
_LEARNED_TTL = 24 * 60 * 60
|
||||||
|
# Fixed scan bounds: adaptive halving makes the start size near irrelevant, and
|
||||||
|
# the deadline/request budget are safety limits, not tuning knobs.
|
||||||
|
_DEFAULT_BATCH_SIZE = _SDK_BATCH_LIMIT
|
||||||
|
_SCAN_TIMEOUT_SECONDS = 60
|
||||||
|
_MAX_REQUESTS = 128
|
||||||
|
_learned_sizes: dict[str, tuple[int, float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def filter_unspent_proofs(
|
||||||
|
proofs: list[Proof], wallet: Wallet, *, retry_on_rate_limit: bool = True
|
||||||
|
) -> list[Proof]:
|
||||||
|
if not proofs:
|
||||||
|
return []
|
||||||
|
mint_url = str(wallet.url)
|
||||||
|
key = mint_url.rstrip("/")
|
||||||
|
configured = _DEFAULT_BATCH_SIZE
|
||||||
|
learned, expires = _learned_sizes.get(key, (configured, 0.0))
|
||||||
|
batch_size = min(configured, learned) if expires > time.monotonic() else configured
|
||||||
|
unspent: list[Proof] = []
|
||||||
|
spent: list[Proof] = []
|
||||||
|
offset = 0
|
||||||
|
requests = 0
|
||||||
|
|
||||||
|
async def check_batch() -> tuple[list[Proof], list[ProofState]]:
|
||||||
|
nonlocal batch_size, requests
|
||||||
|
# Size fallback stays inside the rate guard's operation. A recoverable
|
||||||
|
# 500 during a cooldown probe must not open another cooldown first.
|
||||||
|
while True:
|
||||||
|
batch = proofs[offset : offset + batch_size]
|
||||||
|
if requests >= _MAX_REQUESTS:
|
||||||
|
raise ValueError("Proof-state request budget exhausted")
|
||||||
|
requests += 1
|
||||||
|
try:
|
||||||
|
response = await wallet.check_proof_state(batch)
|
||||||
|
except httpx.HTTPStatusError as error:
|
||||||
|
# A proxy 500 can mean a body limit (#761), but is not proof of
|
||||||
|
# one. Diagnostic retries are safe here because this is a read.
|
||||||
|
if error.response.status_code not in {413, 500} or len(batch) == 1:
|
||||||
|
logger.warning(
|
||||||
|
"Proof-state request failed; scan aborted",
|
||||||
|
extra={
|
||||||
|
"mint_url": mint_url,
|
||||||
|
"endpoint": "/v1/checkstate",
|
||||||
|
"status": error.response.status_code,
|
||||||
|
"content_type": error.response.headers.get("content-type"),
|
||||||
|
"request_bytes": error.request.headers.get(
|
||||||
|
"content-length"
|
||||||
|
),
|
||||||
|
"proof_count": len(batch),
|
||||||
|
"requests": requests,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
batch_size = max(1, len(batch) // 2)
|
||||||
|
logger.warning(
|
||||||
|
"Retrying proof-state check with a smaller batch",
|
||||||
|
extra={
|
||||||
|
"mint_url": mint_url,
|
||||||
|
"endpoint": "/v1/checkstate",
|
||||||
|
"status": error.response.status_code,
|
||||||
|
"content_type": error.response.headers.get("content-type"),
|
||||||
|
"request_bytes": error.request.headers.get("content-length"),
|
||||||
|
"proof_count": len(batch),
|
||||||
|
"next_batch_size": batch_size,
|
||||||
|
"requests": requests,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
states = response.states
|
||||||
|
if len(states) != len(batch) or any(
|
||||||
|
state.Y != proof.Y for proof, state in zip(batch, states)
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid proof-state response: count or Y mismatch")
|
||||||
|
if any(state.state not in set(ProofSpentState) for state in states):
|
||||||
|
raise ValueError("Invalid proof-state response: unknown state")
|
||||||
|
return batch, states
|
||||||
|
|
||||||
|
# Bound the entire scan, including retries and cooldown waits.
|
||||||
|
deadline = asyncio.timeout(_SCAN_TIMEOUT_SECONDS)
|
||||||
|
try:
|
||||||
|
async with deadline:
|
||||||
|
while offset < len(proofs):
|
||||||
|
batch, states = await run_mint_operation(
|
||||||
|
check_batch,
|
||||||
|
op_name="check_proof_state",
|
||||||
|
mint_url=mint_url,
|
||||||
|
retry_on_rate_limit=retry_on_rate_limit,
|
||||||
|
)
|
||||||
|
if batch_size < configured:
|
||||||
|
_learned_sizes[key] = (batch_size, time.monotonic() + _LEARNED_TTL)
|
||||||
|
for proof, state in zip(batch, states):
|
||||||
|
if state.state == ProofSpentState.unspent:
|
||||||
|
unspent.append(proof)
|
||||||
|
elif state.state == ProofSpentState.spent:
|
||||||
|
spent.append(proof)
|
||||||
|
# Retain PENDING proofs without making them spendable.
|
||||||
|
offset += len(batch)
|
||||||
|
if spent:
|
||||||
|
await wallet.set_reserved_for_send(spent, reserved=True)
|
||||||
|
except TimeoutError:
|
||||||
|
if deadline.expired():
|
||||||
|
MintRateGuard.get(mint_url).apply_cooldown(
|
||||||
|
MINT_TRANSPORT_COOLDOWN_SECONDS, reason="transport"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
return unspent
|
||||||
@@ -62,6 +62,8 @@ class Settings(BaseSettings):
|
|||||||
# Minimum available balance (in satoshis) before profit is paid out over
|
# Minimum available balance (in satoshis) before profit is paid out over
|
||||||
# Lightning
|
# Lightning
|
||||||
min_payout_sat: int = Field(default=210, gt=0, env="MIN_PAYOUT_SAT")
|
min_payout_sat: int = Field(default=210, gt=0, env="MIN_PAYOUT_SAT")
|
||||||
|
# Gross payout budget in sats, including fees.
|
||||||
|
max_payout_sat: int = Field(default=250_000, gt=0, env="MAX_PAYOUT_SAT")
|
||||||
# Interval (seconds) between periodic payout attempts. Must be positive.
|
# Interval (seconds) between periodic payout attempts. Must be positive.
|
||||||
payout_interval_seconds: int = Field(
|
payout_interval_seconds: int = Field(
|
||||||
default=900, gt=0, env="PAYOUT_INTERVAL_SECONDS"
|
default=900, gt=0, env="PAYOUT_INTERVAL_SECONDS"
|
||||||
|
|||||||
@@ -417,6 +417,8 @@ async def raw_send_to_lnurl(
|
|||||||
|
|
||||||
assert selected_proofs is not None
|
assert selected_proofs is not None
|
||||||
proofs = selected_proofs
|
proofs = selected_proofs
|
||||||
|
# Cashu uses this argument only to size blank outputs, not set mint fees.
|
||||||
|
change_budget = sum(proof.amount for proof in proofs) - quoted_amount
|
||||||
await wallet.set_reserved_for_send(proofs, reserved=True)
|
await wallet.set_reserved_for_send(proofs, reserved=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -424,7 +426,7 @@ async def raw_send_to_lnurl(
|
|||||||
lambda: wallet.melt(
|
lambda: wallet.melt(
|
||||||
proofs=proofs,
|
proofs=proofs,
|
||||||
invoice=bolt11_invoice,
|
invoice=bolt11_invoice,
|
||||||
fee_reserve_sat=melt_quote_resp.fee_reserve,
|
fee_reserve_sat=change_budget,
|
||||||
quote_id=melt_quote_resp.quote,
|
quote_id=melt_quote_resp.quote,
|
||||||
),
|
),
|
||||||
op_name="lnurl_melt",
|
op_name="lnurl_melt",
|
||||||
|
|||||||
+27
-32
@@ -21,6 +21,7 @@ from pydantic_core import PydanticUndefined
|
|||||||
from sqlmodel import col, select, update
|
from sqlmodel import col, select, update
|
||||||
|
|
||||||
from .cashu_compat import install_cashu_httpx_shim
|
from .cashu_compat import install_cashu_httpx_shim
|
||||||
|
from .checkstate import filter_unspent_proofs
|
||||||
from .core import db, get_logger
|
from .core import db, get_logger
|
||||||
from .core.db import store_cashu_transaction_with_retry as store_cashu_transaction
|
from .core.db import store_cashu_transaction_with_retry as store_cashu_transaction
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
@@ -143,6 +144,12 @@ class Wallet(_CashuWallet):
|
|||||||
request=resp.request,
|
request=resp.request,
|
||||||
response=resp,
|
response=resp,
|
||||||
)
|
)
|
||||||
|
if resp.status_code in {413, 500} and resp.request.url.path.endswith(
|
||||||
|
"/v1/checkstate"
|
||||||
|
):
|
||||||
|
# Preserve size/HTTP diagnostics even when a proxy or mint returns
|
||||||
|
# JSON with a detail field. Mutation error handling stays unchanged.
|
||||||
|
resp.raise_for_status()
|
||||||
try:
|
try:
|
||||||
response_data = resp.json()
|
response_data = resp.json()
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -1165,6 +1172,7 @@ async def get_wallet(
|
|||||||
retry_on_rate_limit: bool = True,
|
retry_on_rate_limit: bool = True,
|
||||||
force_reload: bool = False,
|
force_reload: bool = False,
|
||||||
load_proofs: bool = True,
|
load_proofs: bool = True,
|
||||||
|
force_reload_proofs: bool = False,
|
||||||
) -> Wallet:
|
) -> Wallet:
|
||||||
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}"
|
||||||
@@ -1197,6 +1205,7 @@ async def get_wallet(
|
|||||||
last_proof_load = _wallet_last_load.get(id)
|
last_proof_load = _wallet_last_load.get(id)
|
||||||
if (
|
if (
|
||||||
force_reload
|
force_reload
|
||||||
|
or force_reload_proofs
|
||||||
or last_proof_load is None
|
or last_proof_load is None
|
||||||
or now - last_proof_load
|
or now - last_proof_load
|
||||||
>= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
|
>= _WALLET_PROOF_RELOAD_MIN_INTERVAL_SECONDS
|
||||||
@@ -1231,29 +1240,9 @@ async def slow_filter_spend_proofs(
|
|||||||
*,
|
*,
|
||||||
retry_on_rate_limit: bool = True,
|
retry_on_rate_limit: bool = True,
|
||||||
) -> list[Proof]:
|
) -> list[Proof]:
|
||||||
if not proofs:
|
return await filter_unspent_proofs(
|
||||||
return []
|
proofs, wallet, retry_on_rate_limit=retry_on_rate_limit
|
||||||
_proofs = []
|
)
|
||||||
_spent_proofs = []
|
|
||||||
# Keep proof-state checks in large batches. Mint quotas count HTTP requests,
|
|
||||||
# so smaller batches make balance reads slower and more likely to hit 429s.
|
|
||||||
batch_size = 1000
|
|
||||||
for i in range(0, len(proofs), batch_size):
|
|
||||||
pb = proofs[i : i + batch_size]
|
|
||||||
proof_states = await run_mint_operation(
|
|
||||||
lambda: wallet.check_proof_state(pb),
|
|
||||||
op_name="check_proof_state",
|
|
||||||
mint_url=str(wallet.url),
|
|
||||||
retry_on_rate_limit=retry_on_rate_limit,
|
|
||||||
)
|
|
||||||
for proof, state in zip(pb, proof_states.states):
|
|
||||||
if str(state.state) != "spent":
|
|
||||||
_proofs.append(proof)
|
|
||||||
else:
|
|
||||||
_spent_proofs.append(proof)
|
|
||||||
if _spent_proofs:
|
|
||||||
await wallet.set_reserved_for_send(_spent_proofs, reserved=True)
|
|
||||||
return _proofs
|
|
||||||
|
|
||||||
|
|
||||||
class BalanceDetail(TypedDict, total=False):
|
class BalanceDetail(TypedDict, total=False):
|
||||||
@@ -1545,12 +1534,16 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
|
|||||||
try:
|
try:
|
||||||
# Runs under wallet_operation_guard; a cached wallet may carry a proof
|
# Runs under wallet_operation_guard; a cached wallet may carry a proof
|
||||||
# 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 fresh local proofs, not a
|
||||||
wallet = await get_wallet(mint_url, unit, force_reload=True)
|
# forced network refresh of every keyset.
|
||||||
|
wallet = await get_wallet(mint_url, unit, force_reload_proofs=True)
|
||||||
proofs = get_proofs_per_mint_and_unit(wallet, mint_url, unit, not_reserved=True)
|
proofs = get_proofs_per_mint_and_unit(wallet, mint_url, unit, not_reserved=True)
|
||||||
if not proofs:
|
min_amount = (
|
||||||
# Nothing to pay out, so skip the settle delay rather than hold the
|
settings.min_payout_sat
|
||||||
# cross-process guard (and block credits) for a wallet with no funds.
|
if unit == "sat"
|
||||||
|
else _sats_to_msats(settings.min_payout_sat)
|
||||||
|
)
|
||||||
|
if sum(proof.amount for proof in proofs) <= min_amount:
|
||||||
return
|
return
|
||||||
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
proofs = await slow_filter_spend_proofs(proofs, wallet)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
@@ -1582,18 +1575,19 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
|
|||||||
user_balance = _msats_to_sats_ceil(user_balance)
|
user_balance = _msats_to_sats_ceil(user_balance)
|
||||||
proofs_balance = sum(proof.amount for proof in proofs)
|
proofs_balance = sum(proof.amount for proof in proofs)
|
||||||
available_balance = proofs_balance - user_balance
|
available_balance = proofs_balance - user_balance
|
||||||
min_amount = (
|
max_amount = (
|
||||||
settings.min_payout_sat
|
settings.max_payout_sat
|
||||||
if unit == "sat"
|
if unit == "sat"
|
||||||
else _sats_to_msats(settings.min_payout_sat)
|
else _sats_to_msats(settings.max_payout_sat)
|
||||||
)
|
)
|
||||||
if available_balance > min_amount:
|
if available_balance > min_amount:
|
||||||
|
payout_amount = min(available_balance, max_amount)
|
||||||
amount_received = await raw_send_to_lnurl(
|
amount_received = await raw_send_to_lnurl(
|
||||||
wallet,
|
wallet,
|
||||||
proofs,
|
proofs,
|
||||||
settings.receive_ln_address,
|
settings.receive_ln_address,
|
||||||
unit,
|
unit,
|
||||||
amount=available_balance,
|
amount=payout_amount,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Payout sent successfully",
|
"Payout sent successfully",
|
||||||
@@ -1601,6 +1595,7 @@ async def _payout_mint_and_unit(mint_url: str, unit: str) -> None:
|
|||||||
"mint_url": mint_url,
|
"mint_url": mint_url,
|
||||||
"unit": unit,
|
"unit": unit,
|
||||||
"balance": available_balance,
|
"balance": available_balance,
|
||||||
|
"amount": payout_amount,
|
||||||
"amount_received": amount_received,
|
"amount_received": amount_received,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from cashu.core.base import ProofSpentState
|
||||||
|
|
||||||
|
from routstr import checkstate
|
||||||
|
from routstr.checkstate import _learned_sizes, filter_unspent_proofs
|
||||||
|
from routstr.mint import MintRateGuard, fail_fast_mint_operations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate():
|
||||||
|
_learned_sizes.clear()
|
||||||
|
MintRateGuard._guards.clear()
|
||||||
|
yield
|
||||||
|
_learned_sizes.clear()
|
||||||
|
MintRateGuard._guards.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def proofs(count):
|
||||||
|
return [Mock(Y=str(i)) for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
def response(batch):
|
||||||
|
return SimpleNamespace(
|
||||||
|
states=[SimpleNamespace(Y=p.Y, state=ProofSpentState.unspent) for p in batch]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rejection(status):
|
||||||
|
request = httpx.Request("POST", "https://mint.test/v1/checkstate")
|
||||||
|
return httpx.HTTPStatusError(
|
||||||
|
"rejected",
|
||||||
|
request=request,
|
||||||
|
response=httpx.Response(
|
||||||
|
status,
|
||||||
|
request=request,
|
||||||
|
headers={"content-type": "text/html", "retry-after": "120"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wallet(check):
|
||||||
|
return Mock(
|
||||||
|
url="https://mint.test",
|
||||||
|
check_proof_state=AsyncMock(side_effect=check),
|
||||||
|
set_reserved_for_send=AsyncMock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("status", [413, 500])
|
||||||
|
async def test_adapts_and_reuses_size_without_skipping_proofs(status):
|
||||||
|
checked = []
|
||||||
|
|
||||||
|
async def check(batch):
|
||||||
|
if len(batch) > 120:
|
||||||
|
raise rejection(status)
|
||||||
|
checked.extend(batch)
|
||||||
|
return response(batch)
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
ps = proofs(1001)
|
||||||
|
assert await filter_unspent_proofs(ps, w) == ps
|
||||||
|
assert checked == ps
|
||||||
|
sizes = [len(c.args[0]) for c in w.check_proof_state.await_args_list]
|
||||||
|
assert sizes[:5] == [1000, 500, 250, 125, 62]
|
||||||
|
w.check_proof_state.reset_mock()
|
||||||
|
assert await filter_unspent_proofs(ps, w) == ps
|
||||||
|
assert max(len(c.args[0]) for c in w.check_proof_state.await_args_list) == 62
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_size_fallback_works_inside_cooldown_probe_under_wallet_guard():
|
||||||
|
async def check(batch):
|
||||||
|
if len(batch) > 2:
|
||||||
|
raise rejection(500)
|
||||||
|
return response(batch)
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
MintRateGuard.get(w.url).apply_cooldown(0, reason="transport")
|
||||||
|
async with fail_fast_mint_operations():
|
||||||
|
ps = proofs(8)
|
||||||
|
assert await filter_unspent_proofs(ps, w) == ps
|
||||||
|
assert MintRateGuard.get(w.url).cooldown_remaining() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_429_is_not_a_size_signal():
|
||||||
|
w = wallet(Mock(side_effect=rejection(429)))
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await filter_unspent_proofs(proofs(1000), w, retry_on_rate_limit=False)
|
||||||
|
assert w.check_proof_state.await_count == 1
|
||||||
|
assert not _learned_sizes
|
||||||
|
assert MintRateGuard.get(w.url).cooldown_remaining() > 100
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("status", [400, 401, 422, 503])
|
||||||
|
async def test_other_http_errors_are_not_split(status):
|
||||||
|
w = wallet(Mock(side_effect=rejection(status)))
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await filter_unspent_proofs(proofs(10), w)
|
||||||
|
assert w.check_proof_state.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_singleton_failure_is_bounded_and_does_not_poison_cache():
|
||||||
|
w = wallet(Mock(side_effect=rejection(500)))
|
||||||
|
with pytest.raises(httpx.HTTPStatusError):
|
||||||
|
await filter_unspent_proofs(proofs(1000), w)
|
||||||
|
assert [len(c.args[0]) for c in w.check_proof_state.await_args_list] == [
|
||||||
|
1000,
|
||||||
|
500,
|
||||||
|
250,
|
||||||
|
125,
|
||||||
|
62,
|
||||||
|
31,
|
||||||
|
15,
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
]
|
||||||
|
assert not _learned_sizes
|
||||||
|
w.set_reserved_for_send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_request_budget_counts_successes_and_failures():
|
||||||
|
w = wallet(response)
|
||||||
|
with (
|
||||||
|
patch.object(checkstate, "_DEFAULT_BATCH_SIZE", 1),
|
||||||
|
patch.object(checkstate, "_MAX_REQUESTS", 2),
|
||||||
|
pytest.raises(ValueError, match="budget"),
|
||||||
|
):
|
||||||
|
await filter_unspent_proofs(proofs(3), w)
|
||||||
|
assert w.check_proof_state.await_count == 2
|
||||||
|
w.set_reserved_for_send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_total_deadline_cancels_slow_check():
|
||||||
|
async def check(batch):
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
with (
|
||||||
|
patch.object(checkstate, "_SCAN_TIMEOUT_SECONDS", 0.01),
|
||||||
|
pytest.raises(TimeoutError),
|
||||||
|
):
|
||||||
|
await filter_unspent_proofs(proofs(1), w)
|
||||||
|
assert w.check_proof_state.await_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("malformation", ["missing", "reordered", "unknown"])
|
||||||
|
async def test_invalid_response_fails_closed(malformation):
|
||||||
|
def check(batch):
|
||||||
|
result = response(batch)
|
||||||
|
if malformation == "missing":
|
||||||
|
result.states.pop()
|
||||||
|
elif malformation == "reordered":
|
||||||
|
result.states.reverse()
|
||||||
|
else:
|
||||||
|
result.states[0].state = "UNKNOWN"
|
||||||
|
return result
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
with pytest.raises(ValueError, match="Invalid proof-state"):
|
||||||
|
await filter_unspent_proofs(proofs(3), w)
|
||||||
|
w.set_reserved_for_send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_only_unspent_proofs_are_spendable():
|
||||||
|
ps = proofs(3)
|
||||||
|
states = [ProofSpentState.unspent, ProofSpentState.pending, ProofSpentState.spent]
|
||||||
|
w = wallet(
|
||||||
|
lambda batch: SimpleNamespace(
|
||||||
|
states=[SimpleNamespace(Y=p.Y, state=s) for p, s in zip(batch, states)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert await filter_unspent_proofs(ps, w) == ps[:1]
|
||||||
|
w.set_reserved_for_send.assert_awaited_once_with(ps[2:], reserved=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_learned_size_is_per_mint_and_expires():
|
||||||
|
w = wallet(response)
|
||||||
|
ps = proofs(5)
|
||||||
|
_learned_sizes[w.url] = (1, 0)
|
||||||
|
other = wallet(response)
|
||||||
|
other.url = "https://other.test"
|
||||||
|
_learned_sizes[other.url] = (1, float("inf"))
|
||||||
|
with patch.object(checkstate, "_DEFAULT_BATCH_SIZE", 2):
|
||||||
|
assert await filter_unspent_proofs(ps, w) == ps
|
||||||
|
assert [len(c.args[0]) for c in w.check_proof_state.await_args_list] == [
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
]
|
||||||
|
assert await filter_unspent_proofs(ps, other) == ps
|
||||||
|
assert [len(c.args[0]) for c in other.check_proof_state.await_args_list] == [
|
||||||
|
1
|
||||||
|
] * 5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_smaller_later_batch_failure_does_not_skip_or_return_partial():
|
||||||
|
ps = proofs(9)
|
||||||
|
checked = []
|
||||||
|
|
||||||
|
def check(batch):
|
||||||
|
if batch[0] is not ps[0] and len(batch) > 1:
|
||||||
|
raise rejection(500)
|
||||||
|
checked.extend(batch)
|
||||||
|
return response(batch)
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
with patch.object(checkstate, "_DEFAULT_BATCH_SIZE", 4):
|
||||||
|
assert await filter_unspent_proofs(ps, w) == ps
|
||||||
|
assert checked == ps
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", [413, 500])
|
||||||
|
@pytest.mark.parametrize("body", [{"detail": "too big"}, "<html>error</html>"])
|
||||||
|
def test_wallet_adapter_preserves_checkstate_http_status(status, body):
|
||||||
|
from routstr.wallet import Wallet
|
||||||
|
|
||||||
|
request = httpx.Request("POST", "https://mint.test/v1/checkstate")
|
||||||
|
reply = (
|
||||||
|
httpx.Response(status, request=request, json=body)
|
||||||
|
if isinstance(body, dict)
|
||||||
|
else httpx.Response(status, request=request, text=body)
|
||||||
|
)
|
||||||
|
with pytest.raises(httpx.HTTPStatusError) as error:
|
||||||
|
Wallet.raise_on_error_request(reply)
|
||||||
|
assert error.value.response is reply
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_batch_fits_real_sdk_model():
|
||||||
|
from cashu.core.base import Proof
|
||||||
|
from cashu.core.models import PostCheckStateRequest
|
||||||
|
|
||||||
|
limit = PostCheckStateRequest.model_json_schema()["properties"]["Ys"]["maxItems"]
|
||||||
|
ps = [
|
||||||
|
Proof(id="00", amount=1, secret=f"sdk-{i}", C="02" + "00" * 32)
|
||||||
|
for i in range(limit + 1)
|
||||||
|
]
|
||||||
|
sizes = []
|
||||||
|
|
||||||
|
def check(batch):
|
||||||
|
payload = PostCheckStateRequest(Ys=[p.Y for p in batch])
|
||||||
|
sizes.append(len(payload.Ys))
|
||||||
|
return response(batch)
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
assert await filter_unspent_proofs(ps, w) == ps
|
||||||
|
assert sizes == [limit, 1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_deadline_opens_cooldown_for_next_guarded_scan():
|
||||||
|
from routstr.mint import MintCooldownError
|
||||||
|
|
||||||
|
async def check(batch):
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
with patch.object(checkstate, "_SCAN_TIMEOUT_SECONDS", 0.01):
|
||||||
|
async with fail_fast_mint_operations():
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
await filter_unspent_proofs(proofs(1), w)
|
||||||
|
with pytest.raises(MintCooldownError):
|
||||||
|
await filter_unspent_proofs(proofs(1), w)
|
||||||
|
assert w.check_proof_state.await_count == 1
|
||||||
|
assert MintRateGuard.get(w.url).cooldown_reason() == "transport"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_external_cancellation_does_not_open_cooldown():
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def check(batch):
|
||||||
|
started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
w = wallet(check)
|
||||||
|
task = asyncio.create_task(filter_unspent_proofs(proofs(1), w))
|
||||||
|
await started.wait()
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert MintRateGuard.get(w.url).cooldown_remaining() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_deadline_preserves_longer_rate_limit_cooldown():
|
||||||
|
w = wallet(response)
|
||||||
|
guard = MintRateGuard.get(w.url)
|
||||||
|
guard.apply_rate_limit_cooldown(120)
|
||||||
|
until = guard._cooldown_until
|
||||||
|
with patch.object(checkstate, "_SCAN_TIMEOUT_SECONDS", 0.01):
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
await filter_unspent_proofs(proofs(1), w)
|
||||||
|
assert guard._cooldown_until == until
|
||||||
|
assert guard.cooldown_reason() == "rate_limited"
|
||||||
|
w.check_proof_state.assert_not_awaited()
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cashu.core.base import BlindedSignature, Proof, Unit
|
||||||
|
from cashu.core.crypto import b_dhke
|
||||||
|
from cashu.core.models import PostMeltQuoteResponse
|
||||||
|
from cashu.wallet.v1_api import LedgerAPI
|
||||||
|
from cashu.wallet.wallet import Wallet as CashuWallet
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
from routstr.mint import MintRateGuard
|
||||||
|
from routstr.wallet import _payout_mint_and_unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("unit,scale", [("sat", 1), ("msat", 1000)])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"liability,input_fee,reserve,actual_fee",
|
||||||
|
[(0, 0, 0, 0), (0, 7, 10, 3), (300000, 7, 10, 3)],
|
||||||
|
)
|
||||||
|
async def test_capped_payout_recovers_all_change_with_real_cashu_sdk(
|
||||||
|
unit, scale, liability, input_fee, reserve, actual_fee
|
||||||
|
):
|
||||||
|
MintRateGuard._guards.clear()
|
||||||
|
private_key = b_dhke.PrivateKey()
|
||||||
|
proof = Proof(
|
||||||
|
id="00",
|
||||||
|
amount=524288 * scale,
|
||||||
|
secret="input-proof",
|
||||||
|
C=private_key.public_key.format().hex(),
|
||||||
|
)
|
||||||
|
w = CashuWallet.__new__(CashuWallet)
|
||||||
|
w.url = "https://mint.test"
|
||||||
|
w.unit = Unit[unit]
|
||||||
|
w.keyset_id = "00"
|
||||||
|
w.keysets = {
|
||||||
|
"00": SimpleNamespace(
|
||||||
|
public_keys={2**i: private_key.public_key for i in range(40)}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
w.proofs = [proof]
|
||||||
|
w.db = Mock()
|
||||||
|
w.get_fees_for_proofs = Mock(return_value=input_fee * scale)
|
||||||
|
w.set_reserved_for_send = AsyncMock()
|
||||||
|
w.set_reserved_for_melt = AsyncMock()
|
||||||
|
w.sign_proofs_inplace_melt = Mock(side_effect=lambda ps, outputs, quote: ps)
|
||||||
|
w._store_proofs = AsyncMock()
|
||||||
|
|
||||||
|
async def invalidate(ps):
|
||||||
|
w.proofs = [p for p in w.proofs if p not in ps]
|
||||||
|
|
||||||
|
w.invalidate = AsyncMock(side_effect=invalidate)
|
||||||
|
w.generate_n_secrets = AsyncMock(
|
||||||
|
side_effect=lambda n: (
|
||||||
|
[f"change-{i}" for i in range(n)],
|
||||||
|
[],
|
||||||
|
[f"path-{i}" for i in range(n)],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
quotes = {}
|
||||||
|
|
||||||
|
async def quote(invoice):
|
||||||
|
amount_msat = int(invoice)
|
||||||
|
amount = amount_msat // 1000 if unit == "sat" else amount_msat
|
||||||
|
q = PostMeltQuoteResponse(
|
||||||
|
quote=str(amount),
|
||||||
|
amount=amount,
|
||||||
|
unit=unit,
|
||||||
|
request=invoice,
|
||||||
|
fee_reserve=reserve * scale,
|
||||||
|
state="UNPAID",
|
||||||
|
expiry=None,
|
||||||
|
)
|
||||||
|
quotes[q.quote] = q
|
||||||
|
return q
|
||||||
|
|
||||||
|
w.melt_quote = AsyncMock(side_effect=quote)
|
||||||
|
selected_total = 0
|
||||||
|
returned_change = 0
|
||||||
|
blank_count = 0
|
||||||
|
paid_amount = 0
|
||||||
|
|
||||||
|
async def mint_melt(quote_id, inputs, outputs):
|
||||||
|
nonlocal selected_total, returned_change, blank_count, paid_amount
|
||||||
|
q = quotes[quote_id]
|
||||||
|
selected_total = sum(p.amount for p in inputs)
|
||||||
|
paid_amount = q.amount
|
||||||
|
blank_count = len(outputs)
|
||||||
|
assert q.fee_reserve == reserve * scale
|
||||||
|
change = selected_total - q.amount - (input_fee + actual_fee) * scale
|
||||||
|
amounts = [2**i for i in range(change.bit_length()) if change & (2**i)]
|
||||||
|
signatures = []
|
||||||
|
for amount, output in zip(amounts, outputs):
|
||||||
|
blinded, _, _ = b_dhke.step2_bob(
|
||||||
|
b_dhke.PublicKey(bytes.fromhex(output.B_)), private_key
|
||||||
|
)
|
||||||
|
signatures.append(
|
||||||
|
BlindedSignature(id="00", amount=amount, C_=blinded.format().hex())
|
||||||
|
)
|
||||||
|
returned_change = sum(s.amount for s in signatures)
|
||||||
|
assert returned_change == change
|
||||||
|
return q.model_copy(update={"state": "PAID", "change": signatures})
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session():
|
||||||
|
yield Mock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "max_payout_sat", 250000),
|
||||||
|
patch.object(settings, "min_payout_sat", 210),
|
||||||
|
patch("routstr.wallet.get_wallet", AsyncMock(return_value=w)),
|
||||||
|
patch("routstr.wallet.get_proofs_per_mint_and_unit", return_value=[proof]),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.slow_filter_spend_proofs", AsyncMock(return_value=[proof])
|
||||||
|
),
|
||||||
|
patch("routstr.wallet.asyncio.sleep", AsyncMock()),
|
||||||
|
patch("routstr.wallet.db.create_session", session),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.db.total_user_liability",
|
||||||
|
AsyncMock(return_value=liability * 1000),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.payment.lnurl.get_lnurl_data",
|
||||||
|
AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"callback_url": "https://ln.test/cb",
|
||||||
|
"min_sendable": 1000,
|
||||||
|
"max_sendable": 10**12,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.payment.lnurl.get_lnurl_invoice",
|
||||||
|
AsyncMock(side_effect=lambda callback, amount: (str(amount), {})),
|
||||||
|
),
|
||||||
|
patch.object(LedgerAPI, "melt", AsyncMock(side_effect=mint_melt)) as transport,
|
||||||
|
patch("cashu.wallet.wallet.update_bolt11_melt_quote", AsyncMock()),
|
||||||
|
):
|
||||||
|
await _payout_mint_and_unit(w.url, unit)
|
||||||
|
|
||||||
|
transport.assert_awaited_once()
|
||||||
|
assert selected_total == 524288 * scale
|
||||||
|
assert blank_count > 0
|
||||||
|
assert sum(p.amount for p in w.proofs) == returned_change
|
||||||
|
assert all(
|
||||||
|
b_dhke.verify(private_key, b_dhke.PublicKey(bytes.fromhex(p.C)), p.secret)
|
||||||
|
for p in w.proofs
|
||||||
|
)
|
||||||
|
net_debit = selected_total - returned_change
|
||||||
|
assert net_debit == paid_amount + (input_fee + actual_fee) * scale
|
||||||
|
assert net_debit <= min(250000, 524288 - liability) * scale
|
||||||
|
assert returned_change >= liability * scale
|
||||||
|
if liability == input_fee == reserve == actual_fee == 0:
|
||||||
|
assert returned_change == 274288 * scale
|
||||||
|
assert net_debit == 250000 * scale
|
||||||
|
w._store_proofs.assert_awaited_once()
|
||||||
|
MintRateGuard._guards.clear()
|
||||||
@@ -65,6 +65,33 @@ def _lnurl_patches() -> tuple[Any, Any]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("outcome", ["timeout", "pending"])
|
||||||
|
async def test_oversized_proof_change_budget_preserves_ambiguous_melt(
|
||||||
|
outcome: str,
|
||||||
|
) -> None:
|
||||||
|
wallet, proofs = _wallet()
|
||||||
|
proofs[0].amount = 524288
|
||||||
|
if outcome == "timeout":
|
||||||
|
wallet.melt.side_effect = httpx.ReadTimeout("response lost")
|
||||||
|
else:
|
||||||
|
wallet.melt.return_value = MagicMock(state=MeltQuoteState.pending)
|
||||||
|
wallet.get_melt_quote = AsyncMock(
|
||||||
|
return_value=MagicMock(state=MeltQuoteState.pending)
|
||||||
|
)
|
||||||
|
data_patch, invoice_patch = _lnurl_patches()
|
||||||
|
with data_patch, invoice_patch, pytest.raises(MeltOutcomeAmbiguousError):
|
||||||
|
await raw_send_to_lnurl(wallet, proofs, "owner@ln.tld", "sat", amount=1000)
|
||||||
|
wallet.melt.assert_awaited_once()
|
||||||
|
assert wallet.melt.await_args.kwargs["fee_reserve_sat"] == 524288 - QUOTE_AMOUNT_SAT
|
||||||
|
wallet.set_reserved_for_send.assert_awaited_once_with(proofs, reserved=True)
|
||||||
|
if outcome == "timeout":
|
||||||
|
wallet.set_reserved_for_melt.assert_awaited_once_with(
|
||||||
|
proofs, reserved=True, quote_id="q"
|
||||||
|
)
|
||||||
|
wallet.get_melt_quote.assert_awaited_once_with("q")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_raw_send_to_lnurl_direct_unpaid_is_retry_safe() -> None:
|
async def test_raw_send_to_lnurl_direct_unpaid_is_retry_safe() -> None:
|
||||||
wallet, proofs = _wallet()
|
wallet, proofs = _wallet()
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
from routstr.wallet import _payout_mint_and_unit
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session():
|
||||||
|
yield Mock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("unit,scale", [("sat", 1), ("msat", 1000)])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"balance,liability,expected",
|
||||||
|
[(1000, 0, 100), (80, 30000, 50), (20, 20000, None), (0, 0, None), (10, 0, None)],
|
||||||
|
)
|
||||||
|
async def test_payout_limits_and_proof_refresh(
|
||||||
|
unit, scale, balance, liability, expected
|
||||||
|
):
|
||||||
|
send = AsyncMock()
|
||||||
|
get_wallet = AsyncMock()
|
||||||
|
check = AsyncMock(side_effect=lambda ps, w: ps)
|
||||||
|
sleep = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch.object(settings, "min_payout_sat", 10),
|
||||||
|
patch.object(settings, "max_payout_sat", 100),
|
||||||
|
patch("routstr.wallet.get_wallet", get_wallet),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||||
|
return_value=[Mock(amount=balance * scale)],
|
||||||
|
),
|
||||||
|
patch("routstr.wallet.slow_filter_spend_proofs", check),
|
||||||
|
patch("routstr.wallet.db.create_session", session),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.db.total_user_liability", AsyncMock(return_value=liability)
|
||||||
|
),
|
||||||
|
patch("routstr.wallet.asyncio.sleep", sleep),
|
||||||
|
patch("routstr.wallet.raw_send_to_lnurl", send),
|
||||||
|
):
|
||||||
|
await _payout_mint_and_unit("https://mint.test", unit)
|
||||||
|
get_wallet.assert_awaited_once_with(
|
||||||
|
"https://mint.test", unit, force_reload_proofs=True
|
||||||
|
)
|
||||||
|
if expected is None:
|
||||||
|
send.assert_not_awaited()
|
||||||
|
else:
|
||||||
|
assert send.await_args.kwargs["amount"] == expected * scale
|
||||||
|
if balance <= 10:
|
||||||
|
check.assert_not_awaited()
|
||||||
|
sleep.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_failed_proof_check_never_pays_partial_balance():
|
||||||
|
send = AsyncMock()
|
||||||
|
with (
|
||||||
|
patch("routstr.wallet.get_wallet", AsyncMock()),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||||
|
return_value=[Mock(amount=1_000_000)],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.slow_filter_spend_proofs",
|
||||||
|
AsyncMock(side_effect=ValueError("Invalid proof-state response")),
|
||||||
|
),
|
||||||
|
patch("routstr.wallet.raw_send_to_lnurl", send),
|
||||||
|
):
|
||||||
|
await _payout_mint_and_unit("https://mint.test", "sat")
|
||||||
|
send.assert_not_awaited()
|
||||||
@@ -12,6 +12,7 @@ Covers two regressions from the auto-payout / primary-mint audit
|
|||||||
|
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
@@ -19,6 +20,14 @@ import pytest
|
|||||||
|
|
||||||
from routstr.wallet import _payout_units, periodic_payout
|
from routstr.wallet import _payout_units, periodic_payout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_wallet_lock(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routstr.wallet._WALLET_OPERATION_LOCK", tmp_path / "wallet.lock"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Sentinel interval used to break the otherwise-infinite payout loop after
|
# Sentinel interval used to break the otherwise-infinite payout loop after
|
||||||
# exactly one full cycle.
|
# exactly one full cycle.
|
||||||
_INTERVAL = 987
|
_INTERVAL = 987
|
||||||
@@ -156,7 +165,7 @@ async def test_periodic_payout_isolates_failing_mint() -> None:
|
|||||||
from routstr.core.settings import settings
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
async def _get_wallet(
|
async def _get_wallet(
|
||||||
mint_url: str, unit: str, force_reload: bool = False
|
mint_url: str, unit: str, force_reload_proofs: bool = False
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
if mint_url == "http://bad:3338":
|
if mint_url == "http://bad:3338":
|
||||||
raise RuntimeError("mint unreachable")
|
raise RuntimeError("mint unreachable")
|
||||||
@@ -219,6 +228,7 @@ async def test_periodic_payout_handles_session_creation_failure() -> None:
|
|||||||
patch.object(settings, "payout_interval_seconds", _INTERVAL),
|
patch.object(settings, "payout_interval_seconds", _INTERVAL),
|
||||||
patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()),
|
patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()),
|
||||||
patch("routstr.wallet.db.create_session", create_session),
|
patch("routstr.wallet.db.create_session", create_session),
|
||||||
|
patch.object(settings, "min_payout_sat", 10),
|
||||||
patch(
|
patch(
|
||||||
"routstr.wallet._get_supported_mint_units",
|
"routstr.wallet._get_supported_mint_units",
|
||||||
AsyncMock(return_value=["sat", "msat"]),
|
AsyncMock(return_value=["sat", "msat"]),
|
||||||
@@ -255,3 +265,45 @@ async def test_payout_units_excludes_units_the_sender_cannot_pay() -> None:
|
|||||||
AsyncMock(return_value=["usd", "sat", "eur", "msat"]),
|
AsyncMock(return_value=["usd", "sat", "eur", "msat"]),
|
||||||
):
|
):
|
||||||
assert await _payout_units("http://mint:3338") == ["sat", "msat"]
|
assert await _payout_units("http://mint:3338") == ["sat", "msat"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_periodic_payout_caps_amount_at_max_payout_sat() -> None:
|
||||||
|
"""Available balance above max_payout_sat is capped for a single payout."""
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
raw_send = AsyncMock(return_value=1000)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||||
|
patch.object(settings, "primary_mint", "http://mint:3338"),
|
||||||
|
patch.object(settings, "receive_ln_address", "owner@ln.tld"),
|
||||||
|
patch.object(settings, "payout_interval_seconds", _INTERVAL),
|
||||||
|
patch.object(settings, "min_payout_sat", 10),
|
||||||
|
patch.object(settings, "max_payout_sat", 250_000),
|
||||||
|
patch("routstr.wallet.asyncio.sleep", _one_cycle_sleep()),
|
||||||
|
patch("routstr.wallet.db.create_session", _fake_session),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet._get_supported_mint_units",
|
||||||
|
AsyncMock(return_value=["sat"]),
|
||||||
|
),
|
||||||
|
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||||
|
MagicMock(return_value=[MagicMock(amount=1_000_000)]),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.slow_filter_spend_proofs",
|
||||||
|
AsyncMock(side_effect=lambda proofs, wallet: proofs),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"routstr.wallet.db.total_user_liability",
|
||||||
|
AsyncMock(return_value=0),
|
||||||
|
),
|
||||||
|
patch("routstr.wallet.raw_send_to_lnurl", raw_send),
|
||||||
|
):
|
||||||
|
with pytest.raises(_LoopBreak):
|
||||||
|
await periodic_payout()
|
||||||
|
|
||||||
|
assert raw_send.await_count >= 1
|
||||||
|
assert raw_send.await_args_list[0].kwargs["amount"] == 250_000
|
||||||
|
|||||||
@@ -207,9 +207,7 @@ async def test_settings_initialize_discards_unknown_keys() -> None:
|
|||||||
|
|
||||||
# Simulate older persisted key name and an unknown key.
|
# Simulate older persisted key name and an unknown key.
|
||||||
await session.exec( # type: ignore
|
await session.exec( # type: ignore
|
||||||
text(
|
text("UPDATE settings SET data = :data WHERE id = 1").bindparams(
|
||||||
"UPDATE settings SET data = :data WHERE id = 1"
|
|
||||||
).bindparams(
|
|
||||||
data='{"name":"LegacyNode","nostr_analytics_enabled":false,"unknown_key":123}'
|
data='{"name":"LegacyNode","nostr_analytics_enabled":false,"unknown_key":123}'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -279,7 +277,9 @@ async def test_upstream_api_key_survives_persistence(
|
|||||||
await SettingsService.initialize(session)
|
await SettingsService.initialize(session)
|
||||||
await session.exec( # type: ignore
|
await session.exec( # type: ignore
|
||||||
text("UPDATE settings SET data = :d WHERE id = 1").bindparams(
|
text("UPDATE settings SET data = :d WHERE id = 1").bindparams(
|
||||||
d=json.dumps({"name": "LegacyNode", "upstream_api_key": "sk-only-in-db"})
|
d=json.dumps(
|
||||||
|
{"name": "LegacyNode", "upstream_api_key": "sk-only-in-db"}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -4,6 +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 pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -31,11 +32,16 @@ from routstr.wallet import (
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def isolate_wallet_runtime_state() -> Generator[None, None, None]:
|
def isolate_wallet_runtime_state(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
"""Keep production limiter/wallet caches from leaking across unit tests."""
|
"""Keep production limiter/wallet caches from leaking across unit tests."""
|
||||||
from routstr import wallet as wallet_module
|
from routstr import wallet as wallet_module
|
||||||
from routstr.core.settings import settings
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wallet_module, "_WALLET_OPERATION_LOCK", tmp_path / "wallet.lock"
|
||||||
|
)
|
||||||
original_concurrency = settings.mint_max_concurrency
|
original_concurrency = settings.mint_max_concurrency
|
||||||
settings.mint_max_concurrency = 0
|
settings.mint_max_concurrency = 0
|
||||||
wallet_module._MintRateGuard._guards.clear()
|
wallet_module._MintRateGuard._guards.clear()
|
||||||
@@ -143,6 +149,19 @@ async def test_get_wallet_force_reload_bypasses_reload_interval() -> None:
|
|||||||
assert mock_wallet.load_proofs.await_count == 2
|
assert mock_wallet.load_proofs.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_wallet_force_reload_proofs_keeps_cached_keysets() -> None:
|
||||||
|
from routstr.wallet import get_wallet
|
||||||
|
|
||||||
|
mock_wallet = Mock(load_mint=AsyncMock(), load_proofs=AsyncMock())
|
||||||
|
with patch("routstr.wallet.Wallet.with_db", AsyncMock(return_value=mock_wallet)):
|
||||||
|
await get_wallet("http://mint:3338", "sat")
|
||||||
|
await get_wallet("http://mint:3338", "sat", force_reload_proofs=True)
|
||||||
|
|
||||||
|
assert mock_wallet.load_mint.await_count == 1
|
||||||
|
assert mock_wallet.load_proofs.await_count == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_public_recieve_token_holds_wallet_operation_guard() -> None:
|
async def test_public_recieve_token_holds_wallet_operation_guard() -> None:
|
||||||
inside_guard = False
|
inside_guard = False
|
||||||
@@ -1301,10 +1320,12 @@ async def test_execute_bolt11_payment_rereserves_when_cancelled() -> None:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_balance_proof_check_uses_large_batches_to_avoid_rate_limit() -> None:
|
async def test_balance_proof_check_uses_large_batches_to_avoid_rate_limit() -> None:
|
||||||
"""Balance reads must not turn a few hundred proofs into many mint requests."""
|
"""Balance reads must not turn a few hundred proofs into many mint requests."""
|
||||||
|
from cashu.core.base import ProofSpentState
|
||||||
|
|
||||||
from routstr.wallet import slow_filter_spend_proofs
|
from routstr.wallet import slow_filter_spend_proofs
|
||||||
|
|
||||||
proofs = [Mock() for _ in range(250)]
|
proofs = [Mock(Y=str(i)) for i in range(250)]
|
||||||
states = [Mock(state="UNSPENT") for _ in proofs]
|
states = [Mock(Y=proof.Y, state=ProofSpentState.unspent) for proof in proofs]
|
||||||
wallet = Mock()
|
wallet = Mock()
|
||||||
wallet.url = "http://mint:3338"
|
wallet.url = "http://mint:3338"
|
||||||
wallet.check_proof_state = AsyncMock(return_value=Mock(states=states))
|
wallet.check_proof_state = AsyncMock(return_value=Mock(states=states))
|
||||||
@@ -2017,7 +2038,7 @@ async def test_payout_reloads_wallet_snapshot_under_guard() -> None:
|
|||||||
await _payout_mint_and_unit("https://mint.example.com", "sat")
|
await _payout_mint_and_unit("https://mint.example.com", "sat")
|
||||||
|
|
||||||
mock_get_wallet.assert_awaited_once_with(
|
mock_get_wallet.assert_awaited_once_with(
|
||||||
"https://mint.example.com", "sat", force_reload=True
|
"https://mint.example.com", "sat", force_reload_proofs=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user