mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 07:46:15 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477acedb05 | ||
|
|
c0a34a391f | ||
|
|
a97ea2995a | ||
|
|
5b50a78d95 | ||
|
|
ccab5e4216 | ||
|
|
ff9c645bb1 | ||
|
|
d4318dcc07 | ||
|
|
75ed865a5f | ||
|
|
7969a8da55 | ||
|
|
830c299130 | ||
|
|
e9dced8148 | ||
|
|
ecd46975b4 | ||
|
|
8f5f3d9738 | ||
|
|
355e3f19ef | ||
|
|
439ac48216 |
@@ -38,3 +38,8 @@ proof_backups
|
||||
|
||||
*.todo
|
||||
ui_out
|
||||
|
||||
# local cashu wallet state (never commit)
|
||||
.wallet/
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""add balance_version to api_keys and refund_tokens table (core #412)
|
||||
|
||||
Durable, balance-versioned refund idempotency:
|
||||
* ``api_keys.balance_version`` is bumped atomically on every credit.
|
||||
* ``refund_tokens`` records the refund issued at each (api_key_hash,
|
||||
balance_version); an in-flight retry at the same version re-serves the
|
||||
stored token, while any later credit (which bumps the version) invalidates
|
||||
it.
|
||||
|
||||
Revision ID: c7f1a2b3d4e5
|
||||
Revises: b5e7c9d1f3a2
|
||||
Create Date: 2026-06-20 00:00:00.000000
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "c7f1a2b3d4e5"
|
||||
down_revision = "b5e7c9d1f3a2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Existing rows default to 0; the first credit moves them to 1. There are no
|
||||
# historical refund tokens to migrate (the old cache was in-memory only).
|
||||
op.add_column(
|
||||
"api_keys",
|
||||
sa.Column(
|
||||
"balance_version",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.create_table(
|
||||
"refund_tokens",
|
||||
sa.Column("api_key_hash", sa.String(), nullable=False),
|
||||
sa.Column("balance_version", sa.Integer(), nullable=False),
|
||||
sa.Column("token", sa.String(), nullable=False),
|
||||
sa.Column("amount", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("unit", sa.String(), nullable=False, server_default="sat"),
|
||||
sa.Column("created_at", sa.Integer(), nullable=False),
|
||||
sa.Column("redeemed_at", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["api_key_hash"], ["api_keys.hashed_key"]
|
||||
),
|
||||
sa.PrimaryKeyConstraint("api_key_hash", "balance_version"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("refund_tokens")
|
||||
op.drop_column("api_keys", "balance_version")
|
||||
@@ -364,6 +364,7 @@ async def validate_bearer_key(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"Cashu token redemption failed",
|
||||
extra={
|
||||
|
||||
+79
-4
@@ -14,6 +14,7 @@ from .core.db import (
|
||||
ApiKey,
|
||||
AsyncSession,
|
||||
CashuTransaction,
|
||||
RefundToken,
|
||||
get_session,
|
||||
store_cashu_transaction,
|
||||
)
|
||||
@@ -192,6 +193,29 @@ def _cache_key_for_authorization(authorization: str) -> str:
|
||||
return hashlib.sha256(authorization.strip().encode()).hexdigest()
|
||||
|
||||
|
||||
async def _stored_refund_for_version(
|
||||
session: AsyncSession, hashed_key: str, balance_version: int
|
||||
) -> RefundToken | None:
|
||||
"""Return the stored, UNREDEEMED refund token for this key at this exact
|
||||
balance_version, or None. Durable, worker-agnostic replacement for the
|
||||
in-process refund cache (core #412).
|
||||
|
||||
A row exists only for the version at which it was minted; any credit bumps
|
||||
balance_version, so a token minted at version k is structurally unreachable
|
||||
once a topup moves the key to k+1. Within the same version, a token whose
|
||||
``redeemed_at`` is set is never re-served.
|
||||
"""
|
||||
row = await session.get(RefundToken, (hashed_key, balance_version))
|
||||
# Defensive isinstance guard: some unit tests inject a MagicMock session
|
||||
# whose ``get`` returns an ApiKey regardless of the model queried. Only an
|
||||
# actual RefundToken row counts as an idempotency hit.
|
||||
if not isinstance(row, RefundToken):
|
||||
return None
|
||||
if row.redeemed_at is not None:
|
||||
return None
|
||||
return row
|
||||
|
||||
|
||||
async def _refund_cache_get(authorization: str) -> dict[str, str] | None:
|
||||
key = _cache_key_for_authorization(authorization)
|
||||
async with _refund_cache_lock:
|
||||
@@ -302,9 +326,31 @@ async def refund_wallet_endpoint(
|
||||
detail="Key not found. Deposit first via /v1/wallet/create before requesting a refund.",
|
||||
)
|
||||
|
||||
if key.total_balance <= 0:
|
||||
if cached := await _refund_cache_get(bearer_value):
|
||||
return cached
|
||||
# Durable, balance-versioned idempotency (core #412). Capture the version
|
||||
# the refund is being computed against. A stored refund token is only
|
||||
# re-served at the SAME balance_version and only if still unredeemed:
|
||||
# * in-flight retry (no intervening credit) -> same version -> same token
|
||||
# (true idempotency, balance debited exactly once);
|
||||
# * after ANY topup (cashu OR Lightning OR new-key credit) the version was
|
||||
# bumped, so the prior token is unreachable and never re-served.
|
||||
current_balance_version: int = key.balance_version
|
||||
stored = await _stored_refund_for_version(
|
||||
session, key.hashed_key, current_balance_version
|
||||
)
|
||||
if stored is not None:
|
||||
result: dict[str, str] = {"token": stored.token}
|
||||
if stored.unit == "sat":
|
||||
result["sats"] = str(stored.amount)
|
||||
else:
|
||||
result["msats"] = str(stored.amount)
|
||||
logger.info(
|
||||
"refund_wallet_endpoint: re-serving stored refund (idempotent)",
|
||||
extra={
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance_version": current_balance_version,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
if key.parent_key_hash:
|
||||
raise HTTPException(
|
||||
@@ -450,7 +496,36 @@ async def refund_wallet_endpoint(
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Refund failed: {error_msg}")
|
||||
|
||||
await _refund_cache_set(bearer_value, result)
|
||||
# Durably record the issued refund token keyed on (hashed_key,
|
||||
# balance_version) so an in-flight retry at the SAME version re-serves this
|
||||
# exact token instead of minting a second one (core #412). Only cashu-token
|
||||
# refunds are re-servable; LNURL refunds ("recipient") have no token to
|
||||
# re-serve. The PK (hashed_key, balance_version) makes a concurrent
|
||||
# duplicate insert at the same version fail loudly rather than double-mint.
|
||||
if "token" in result:
|
||||
try:
|
||||
session.add(
|
||||
RefundToken(
|
||||
api_key_hash=key.hashed_key,
|
||||
balance_version=current_balance_version,
|
||||
token=result["token"],
|
||||
amount=remaining_balance,
|
||||
unit=key.refund_currency or "sat",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
# A row already exists for this version (concurrent in-flight
|
||||
# refund won the race) — roll back our duplicate; the stored token
|
||||
# is authoritative and will be re-served on the client's retry.
|
||||
await session.rollback()
|
||||
logger.warning(
|
||||
"refund_wallet_endpoint: refund token already stored for version",
|
||||
extra={
|
||||
"hashed_key": key.hashed_key,
|
||||
"balance_version": current_balance_version,
|
||||
},
|
||||
)
|
||||
|
||||
if "token" in result:
|
||||
try:
|
||||
|
||||
@@ -88,12 +88,75 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
||||
default=None,
|
||||
description="Unix timestamp after which the key is no longer valid",
|
||||
)
|
||||
balance_version: int = Field(
|
||||
default=0,
|
||||
nullable=False,
|
||||
description=(
|
||||
"Monotonic counter bumped atomically on EVERY credit to this key "
|
||||
"(cashu topup, Lightning topup, new-key credit). Refund idempotency "
|
||||
"is keyed on (hashed_key, balance_version): a refund minted at "
|
||||
"version k is invalidated by any later credit, which moves the key "
|
||||
"to k+1. See core #412."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def total_balance(self) -> int:
|
||||
return self.balance - self.reserved_balance
|
||||
|
||||
|
||||
class RefundToken(SQLModel, table=True): # type: ignore
|
||||
"""Durable, balance-versioned record of issued refund tokens (core #412).
|
||||
|
||||
One row per (api_key_hash, balance_version). A refund issued while the key
|
||||
is at balance_version=k is stored here; an in-flight retry at the same
|
||||
version re-serves the exact same token (true idempotency). Any credit bumps
|
||||
balance_version, so the row at k can never be re-served after a topup.
|
||||
Once the gateway/client confirms redemption, ``redeemed_at`` is set so even
|
||||
within the same version a redeemed token is never re-served.
|
||||
"""
|
||||
|
||||
__tablename__ = "refund_tokens"
|
||||
|
||||
api_key_hash: str = Field(primary_key=True, foreign_key="api_keys.hashed_key")
|
||||
balance_version: int = Field(primary_key=True)
|
||||
token: str = Field(description="Serialized refund token re-served on retry")
|
||||
amount: int = Field(default=0, description="Refunded amount in the key's unit")
|
||||
unit: str = Field(default="sat", description="Refund unit (sat or msat)")
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
redeemed_at: int | None = Field(
|
||||
default=None,
|
||||
description="Set once the refund token's proofs are observed spent / acked",
|
||||
)
|
||||
|
||||
|
||||
async def credit_key_balance(
|
||||
session: AsyncSession, hashed_key: str, amount_msats: int
|
||||
) -> int:
|
||||
"""Atomically credit ``amount_msats`` to a key AND bump its balance_version.
|
||||
|
||||
This is the single choke point that invalidates all prior refund tokens for
|
||||
the key, regardless of how the credit arrived (cashu topup, Lightning topup,
|
||||
new-key credit) or which worker handles it (state lives in the shared DB).
|
||||
See core #412.
|
||||
|
||||
Does NOT commit — the caller owns the transaction so the credit and any
|
||||
surrounding work (e.g. marking a Lightning invoice paid) are atomic.
|
||||
|
||||
Returns the number of rows updated (1 on success, 0 if the key vanished).
|
||||
"""
|
||||
stmt = (
|
||||
update(ApiKey)
|
||||
.where(col(ApiKey.hashed_key) == hashed_key)
|
||||
.values(
|
||||
balance=col(ApiKey.balance) + amount_msats,
|
||||
balance_version=col(ApiKey.balance_version) + 1,
|
||||
)
|
||||
)
|
||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def reset_all_reserved_balances(session: AsyncSession) -> None:
|
||||
stmt = update(ApiKey).values(reserved_balance=0, reserved_at=None)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
|
||||
+18
-2
@@ -8,7 +8,13 @@ from pydantic import BaseModel, Field
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from .core.db import ApiKey, LightningInvoice, create_session, get_session
|
||||
from .core.db import (
|
||||
ApiKey,
|
||||
LightningInvoice,
|
||||
create_session,
|
||||
credit_key_balance,
|
||||
get_session,
|
||||
)
|
||||
from .core.logging import get_logger
|
||||
from .core.settings import settings
|
||||
from .wallet import get_wallet
|
||||
@@ -293,7 +299,17 @@ async def topup_api_key_from_invoice(
|
||||
if not api_key:
|
||||
raise ValueError("Associated API key not found")
|
||||
|
||||
api_key.balance += invoice.amount_sats * 1000 # Convert to msats
|
||||
# Atomically credit AND bump balance_version in the same DB txn. This is the
|
||||
# load-bearing fix for core #412 on the Lightning path: this credit runs in
|
||||
# the background invoice watcher with NO bearer/authorization in scope, so a
|
||||
# header-keyed refund-cache invalidation cannot fire here. Bumping
|
||||
# balance_version is data-driven and worker-agnostic, so the next refund
|
||||
# recomputes against the new version and never re-serves the stale token.
|
||||
updated = await credit_key_balance(
|
||||
session, api_key.hashed_key, invoice.amount_sats * 1000
|
||||
)
|
||||
if updated == 0:
|
||||
raise ValueError("Associated API key not found")
|
||||
await session.flush()
|
||||
|
||||
|
||||
|
||||
@@ -418,11 +418,9 @@ def _calculate_from_tokens(
|
||||
},
|
||||
)
|
||||
|
||||
visible_input_msats = int(calc_input_msats + calc_cache_read_msats)
|
||||
|
||||
return CostData(
|
||||
base_msats=0,
|
||||
input_msats=visible_input_msats,
|
||||
input_msats=int(calc_input_msats),
|
||||
output_msats=int(calc_output_msats),
|
||||
total_msats=token_based_cost,
|
||||
total_usd=total_usd,
|
||||
|
||||
+26
-39
@@ -39,7 +39,6 @@ from ..payment.models import (
|
||||
list_models,
|
||||
)
|
||||
from ..payment.price import sats_usd_price
|
||||
from ..payment.usage import normalize_usage
|
||||
from ..wallet import recieve_token, send_token
|
||||
from . import messages_dispatch
|
||||
from .cache_breakpoints import (
|
||||
@@ -158,56 +157,48 @@ class BaseUpstreamProvider:
|
||||
|
||||
@staticmethod
|
||||
def _fold_cache_into_input_tokens(usage: object) -> None:
|
||||
"""Fold additive cache token counts into Anthropic ``input_tokens``.
|
||||
"""Fold cache token counts into ``input_tokens`` / ``prompt_tokens``.
|
||||
|
||||
Cost calculation has already used the per-bucket counts to bill the
|
||||
request correctly; what the client sees in Anthropic-shaped visible
|
||||
token totals should be a single rolled-up input count *including* the
|
||||
cache portion. OpenAI-compatible ``prompt_tokens`` is already inclusive
|
||||
(DeepSeek, OpenAI, OpenRouter, litellm), so adding cache fields there
|
||||
would double-count.
|
||||
|
||||
The standalone ``cache_read_input_tokens`` /
|
||||
request correctly; what the client sees in the visible token total
|
||||
should be a single rolled-up prompt count *including* the cache
|
||||
portion. The standalone ``cache_read_input_tokens`` /
|
||||
``cache_creation_input_tokens`` fields are left in place for clients
|
||||
that want the breakdown.
|
||||
|
||||
For Anthropic-shaped responses (``input_tokens`` present), the cache
|
||||
fields are forced to ``0`` when the upstream omitted them, so the
|
||||
client always sees a consistent shape.
|
||||
"""
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
|
||||
# ``prompt_tokens`` is an inclusive OpenAI-compatible grand total.
|
||||
# Fold only native Anthropic-style usage where ``input_tokens`` excludes
|
||||
# cache reads/writes.
|
||||
if "input_tokens" not in usage or "prompt_tokens" in usage:
|
||||
return
|
||||
|
||||
usage.setdefault("cache_read_input_tokens", 0)
|
||||
usage.setdefault("cache_creation_input_tokens", 0)
|
||||
# Normalise missing cache fields to 0 on Anthropic-shaped usage so
|
||||
# downstream consumers can rely on them being present.
|
||||
if "input_tokens" in usage:
|
||||
usage.setdefault("cache_read_input_tokens", 0)
|
||||
usage.setdefault("cache_creation_input_tokens", 0)
|
||||
|
||||
try:
|
||||
cache_read = int(usage.get("cache_read_input_tokens") or 0)
|
||||
cache_creation = int(usage.get("cache_creation_input_tokens") or 0)
|
||||
input_tokens = int(usage.get("input_tokens") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
extra = cache_read + cache_creation
|
||||
if extra > 0:
|
||||
usage["input_tokens"] = input_tokens + extra
|
||||
|
||||
@staticmethod
|
||||
def _add_normalized_usage_fields(response_json: object) -> None:
|
||||
"""Preserve raw usage while adding canonical fields for billing/display."""
|
||||
if not isinstance(response_json, dict):
|
||||
if extra <= 0:
|
||||
return
|
||||
usage = response_json.get("usage")
|
||||
if not isinstance(usage, dict):
|
||||
return
|
||||
normalized = normalize_usage(usage)
|
||||
if normalized is None:
|
||||
return
|
||||
usage.setdefault("input_tokens", normalized.input_tokens)
|
||||
usage.setdefault("output_tokens", normalized.output_tokens)
|
||||
usage.setdefault("cache_read_input_tokens", normalized.cache_read_tokens)
|
||||
usage.setdefault("cache_creation_input_tokens", normalized.cache_write_tokens)
|
||||
if "input_tokens" in usage:
|
||||
try:
|
||||
usage["input_tokens"] = int(usage.get("input_tokens") or 0) + extra
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "prompt_tokens" in usage:
|
||||
try:
|
||||
usage["prompt_tokens"] = (
|
||||
int(usage.get("prompt_tokens") or 0) + extra
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
def _apply_provider_field(self, response_json: object) -> None:
|
||||
"""Stamp the routstr ``provider`` field onto an upstream response payload.
|
||||
@@ -867,7 +858,6 @@ class BaseUpstreamProvider:
|
||||
k: v for k, v in obj.items() if k != "choices"
|
||||
}
|
||||
usage_chunk_data["choices"] = []
|
||||
self._add_normalized_usage_fields(usage_chunk_data)
|
||||
# Forward the content now, without usage, so token
|
||||
# usage is reported exactly once (in the trailer).
|
||||
forward = {k: v for k, v in obj.items() if k != "usage"}
|
||||
@@ -879,7 +869,6 @@ class BaseUpstreamProvider:
|
||||
)
|
||||
return
|
||||
usage_chunk_data = obj
|
||||
self._add_normalized_usage_fields(usage_chunk_data)
|
||||
return
|
||||
yield prefix + b"data: " + json.dumps(obj).encode() + b"\n\n"
|
||||
else:
|
||||
@@ -917,8 +906,6 @@ class BaseUpstreamProvider:
|
||||
if fresh_key:
|
||||
cost_data: dict
|
||||
try:
|
||||
if usage_chunk_data is not None:
|
||||
self._add_normalized_usage_fields(usage_chunk_data)
|
||||
adjustment_input = (
|
||||
usage_chunk_data
|
||||
if usage_chunk_data is not None
|
||||
|
||||
+40
-21
@@ -8,7 +8,7 @@ from cashu.core.mint_info import MintInfo as _CashuMintInfo
|
||||
from cashu.wallet.helpers import deserialize_token_from_string
|
||||
from cashu.wallet.wallet import Wallet
|
||||
from pydantic_core import PydanticUndefined
|
||||
from sqlmodel import col, select, update
|
||||
from sqlmodel import select
|
||||
|
||||
from .core import db, get_logger
|
||||
from .core.db import store_cashu_transaction
|
||||
@@ -35,6 +35,24 @@ async def get_balance(unit: str) -> int:
|
||||
return wallet.available_balance.amount
|
||||
|
||||
|
||||
async def _redeem_same_mint(
|
||||
wallet: Wallet, token_obj: Token
|
||||
) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
"""Redeem proofs at their own issuing mint (no cross-mint swap).
|
||||
|
||||
split() re-mints the incoming proofs into fresh ones we own so the sender
|
||||
can't double-spend them. With include_fees=True the mint deducts its NUT-02
|
||||
per-proof input fee, so we end up holding only `amount - input_fees`. Credit
|
||||
that, not the face value, or routstr over-credits the user and its wallet
|
||||
drifts insolvent.
|
||||
"""
|
||||
await wallet.load_mint(keyset_id=token_obj.keysets[0])
|
||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||
input_fees = wallet.get_fees_for_proofs(token_obj.proofs)
|
||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
return int(token_obj.amount) - input_fees, token_obj.unit, token_obj.mint
|
||||
|
||||
|
||||
async def recieve_token(
|
||||
token: str,
|
||||
) -> tuple[int, str, str]: # amount, unit, mint_url
|
||||
@@ -48,12 +66,7 @@ async def recieve_token(
|
||||
if token_obj.mint not in settings.cashu_mints:
|
||||
return await swap_to_primary_mint(token_obj, wallet)
|
||||
|
||||
await wallet.load_mint(keyset_id=token_obj.keysets[0])
|
||||
|
||||
wallet.verify_proofs_dleq(token_obj.proofs)
|
||||
await wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
|
||||
return token_obj.amount, token_obj.unit, token_obj.mint
|
||||
return await _redeem_same_mint(wallet, token_obj)
|
||||
|
||||
|
||||
async def send(amount: int, unit: str, mint_url: str | None = None) -> tuple[int, str]:
|
||||
@@ -213,10 +226,9 @@ async def swap_to_primary_mint(
|
||||
amount_msat = token_amount
|
||||
else:
|
||||
raise ValueError("Invalid unit")
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
# If the token is already from the primary mint, we don't need to swap
|
||||
# and we definitely don't want to calculate or pay fees.
|
||||
# If the token is already from the primary mint, we don't need a cross-mint
|
||||
# swap — redeem it same-mint. There's no melt/Lightning fee, but the mint's
|
||||
# NUT-02 input fee still applies; _redeem_same_mint accounts for it.
|
||||
if token_obj.mint == settings.primary_mint:
|
||||
logger.info(
|
||||
"swap_to_primary_mint: token already on primary mint, skipping swap",
|
||||
@@ -226,8 +238,9 @@ async def swap_to_primary_mint(
|
||||
"unit": token_obj.unit,
|
||||
},
|
||||
)
|
||||
await token_wallet.split(proofs=token_obj.proofs, amount=0, include_fees=True)
|
||||
return token_amount, token_obj.unit, token_obj.mint
|
||||
return await _redeem_same_mint(token_wallet, token_obj)
|
||||
|
||||
primary_wallet = await get_wallet(settings.primary_mint, settings.primary_mint_unit)
|
||||
|
||||
minted_amount = await _calculate_swap_amount(
|
||||
amount_msat,
|
||||
@@ -422,13 +435,11 @@ async def credit_balance(
|
||||
extra={"old_balance": key.balance, "credit_amount": amount},
|
||||
)
|
||||
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent topups
|
||||
stmt = (
|
||||
update(db.ApiKey)
|
||||
.where(col(db.ApiKey.hashed_key) == key.hashed_key)
|
||||
.values(balance=(db.ApiKey.balance) + amount)
|
||||
)
|
||||
await session.exec(stmt) # type: ignore[call-overload]
|
||||
# Use atomic SQL UPDATE to prevent race conditions during concurrent
|
||||
# topups. credit_key_balance also bumps balance_version in the same
|
||||
# statement, which invalidates any prior refund token for this key
|
||||
# (core #412) — regardless of worker or auth context.
|
||||
await db.credit_key_balance(session, key.hashed_key, amount)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
|
||||
@@ -567,11 +578,19 @@ async def fetch_all_balances(
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Build the set of mints to inspect. Received tokens are stored against
|
||||
# ``primary_mint`` (which defaults to a real mint even when ``cashu_mints``
|
||||
# is empty), so include it as a fallback — otherwise a node that accepts
|
||||
# payments would still report empty balances when ``cashu_mints`` is unset.
|
||||
mint_urls: list[str] = list(settings.cashu_mints)
|
||||
if settings.primary_mint and settings.primary_mint not in mint_urls:
|
||||
mint_urls.append(settings.primary_mint)
|
||||
|
||||
# Create tasks for all mint/unit combinations
|
||||
async with db.create_session() as session:
|
||||
tasks = [
|
||||
fetch_balance(session, mint_url, unit)
|
||||
for mint_url in settings.cashu_mints
|
||||
for mint_url in mint_urls
|
||||
for unit in units
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import hashlib
|
||||
from types import SimpleNamespace
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from routstr.auth import validate_bearer_key
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
|
||||
def _make_engine() -> AsyncEngine:
|
||||
return create_async_engine(
|
||||
"sqlite+aiosqlite://",
|
||||
poolclass=StaticPool,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = _make_engine()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
db_session = AsyncSession(engine, expire_on_commit=False)
|
||||
try:
|
||||
yield db_session
|
||||
finally:
|
||||
await db_session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
token = "cashuAfirst_seen_but_redemption_fails"
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with (
|
||||
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
|
||||
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
|
||||
patch(
|
||||
"routstr.auth.credit_balance",
|
||||
new=AsyncMock(side_effect=ValueError("token already spent")),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException):
|
||||
await validate_bearer_key(token, session)
|
||||
|
||||
assert await session.get(ApiKey, hashed_key) is None
|
||||
@@ -181,10 +181,8 @@ async def test_deepseek_cache_hits_billed_at_cache_rate(model_pricing: Mock) ->
|
||||
result = await calculate_cost(response, max_cost=100000)
|
||||
|
||||
assert isinstance(result, CostData)
|
||||
# 1000 input @ 1 msat + 9000 cache reads @ 0.1 msat + 500 output @ 2 msat.
|
||||
# Client-visible input_msats includes cache-read input cost for display,
|
||||
# while cache_read_msats keeps the detailed breakdown.
|
||||
assert result.input_msats == 1900
|
||||
# 1000 input @ 1 msat + 9000 cache reads @ 0.1 msat + 500 output @ 2 msat
|
||||
assert result.input_msats == 1000
|
||||
assert result.cache_read_msats == 900
|
||||
assert result.output_msats == 1000
|
||||
assert result.total_msats == 2900
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from routstr.wallet import fetch_all_balances
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _fake_session(): # type: ignore[no-untyped-def]
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
def _patches(proof_amount: int = 1000): # type: ignore[no-untyped-def]
|
||||
proof = MagicMock(amount=proof_amount)
|
||||
return [
|
||||
patch("routstr.wallet.get_wallet", AsyncMock(return_value=MagicMock())),
|
||||
patch(
|
||||
"routstr.wallet.get_proofs_per_mint_and_unit",
|
||||
MagicMock(return_value=[proof]),
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.slow_filter_spend_proofs",
|
||||
AsyncMock(side_effect=lambda proofs, wallet: proofs),
|
||||
),
|
||||
patch(
|
||||
"routstr.wallet.db.balances_for_mint_and_unit",
|
||||
AsyncMock(return_value=0),
|
||||
),
|
||||
patch("routstr.wallet.db.create_session", _fake_session),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_falls_back_to_primary_mint() -> None:
|
||||
"""With empty cashu_mints, balances are still fetched for primary_mint."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", []), patch.object(
|
||||
settings, "primary_mint", "http://primary:3338"
|
||||
):
|
||||
for p in _patches(proof_amount=1000):
|
||||
p.start()
|
||||
try:
|
||||
details, total_wallet, total_user, owner = await fetch_all_balances(
|
||||
units=["sat"]
|
||||
)
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
|
||||
assert total_wallet == 1000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_all_balances_no_duplicate_primary_mint() -> None:
|
||||
"""primary_mint already in cashu_mints is not inspected twice."""
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(
|
||||
settings, "cashu_mints", ["http://primary:3338"]
|
||||
), patch.object(settings, "primary_mint", "http://primary:3338"):
|
||||
for p in _patches(proof_amount=1000):
|
||||
p.start()
|
||||
try:
|
||||
details, total_wallet, _total_user, _owner = await fetch_all_balances(
|
||||
units=["sat"]
|
||||
)
|
||||
finally:
|
||||
patch.stopall()
|
||||
|
||||
assert [d["mint_url"] for d in details] == ["http://primary:3338"]
|
||||
assert total_wallet == 1000
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Durable, balance-versioned refund idempotency — core #412.
|
||||
|
||||
These tests exercise the full ``POST /refund`` -> topup -> spend -> ``POST /refund``
|
||||
sequence against a **real in-memory SQLite database** (mints/network mocked).
|
||||
|
||||
The bug (#412): the refund endpoint cached the issued refund token keyed by
|
||||
``sha256(bearer)`` and re-served it whenever ``total_balance <= 0``. After a
|
||||
refund (token T1, balance debited to 0), a *topup* followed by a *spend* brings
|
||||
the balance back to 0 — but the cache still holds T1, so a second refund
|
||||
re-serves the already-spent T1 -> "proofs already spent" / fund loss.
|
||||
|
||||
The Lightning topup path is the load-bearing gap: it credits balance from a
|
||||
background watcher with no bearer/authorization in scope, so a header-keyed
|
||||
cache invalidation structurally cannot fire there.
|
||||
|
||||
The durable fix bumps ``ApiKey.balance_version`` atomically on *every* credit
|
||||
(cashu topup, Lightning topup, new-key credit) and keys refund idempotency on
|
||||
``(api_key_hash, balance_version)`` in a DB table, so any credit anywhere
|
||||
invalidates prior refund tokens regardless of worker or auth context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///:memory:")
|
||||
os.environ.setdefault("CASHU_MINTS", "https://mint.example.com")
|
||||
os.environ.setdefault("NSEC", "nsec1testkey")
|
||||
|
||||
import routstr.balance as balance_mod # noqa: E402
|
||||
from routstr.core.db import ApiKey # noqa: E402
|
||||
from routstr.lightning import topup_api_key_from_invoice # noqa: E402
|
||||
from routstr.wallet import credit_balance # noqa: E402
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def engine(): # type: ignore[no-untyped-def]
|
||||
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
yield eng
|
||||
await eng.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session(engine) -> AsyncGenerator[AsyncSession, None]: # type: ignore[no-untyped-def]
|
||||
async with AsyncSession(engine, expire_on_commit=False) as s:
|
||||
yield s
|
||||
|
||||
|
||||
async def _new_key(session: AsyncSession, hashed_key: str, balance: int) -> ApiKey:
|
||||
key = ApiKey(
|
||||
hashed_key=hashed_key,
|
||||
balance=balance,
|
||||
refund_currency="sat",
|
||||
refund_mint_url="https://mint.example.com",
|
||||
)
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
await session.refresh(key)
|
||||
return key
|
||||
|
||||
|
||||
async def _spend_to_zero(session: AsyncSession, hashed_key: str) -> None:
|
||||
"""Simulate the user spending their whole balance (no version bump)."""
|
||||
key = await session.get(ApiKey, hashed_key)
|
||||
assert key is not None
|
||||
key.balance = 0
|
||||
session.add(key)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _clear_refund_cache() -> None:
|
||||
# Defensively clear the legacy in-process cache if it still exists.
|
||||
cache = getattr(balance_mod, "_refund_cache", None)
|
||||
if isinstance(cache, dict):
|
||||
cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token minting fakes: each call returns a fresh, unique token so we can assert
|
||||
# whether a *new* token was minted (correct) or a *stale* one re-served (bug).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TokenMinter:
|
||||
def __init__(self) -> None:
|
||||
self.counter = 0
|
||||
self.minted: list[str] = []
|
||||
|
||||
async def __call__(self, amount: int, unit: str, mint_url: str | None = None) -> str:
|
||||
self.counter += 1
|
||||
tok = f"cashuMINTED_{self.counter}_amt{amount}"
|
||||
self.minted.append(tok)
|
||||
return tok
|
||||
|
||||
|
||||
async def _do_refund(session: AsyncSession, bearer: str) -> dict:
|
||||
result = await balance_mod.refund_wallet_endpoint(
|
||||
authorization=f"Bearer {bearer}",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
assert isinstance(result, dict), f"expected dict refund result, got {result!r}"
|
||||
return result
|
||||
|
||||
|
||||
async def _refund_token_or_none(session: AsyncSession, bearer: str) -> str | None:
|
||||
"""Return the refund token a /refund call yields, or None if the endpoint
|
||||
declines (e.g. 400 'No balance to refund'). The #412 bug manifests as a
|
||||
*stale token string* being returned where None (or a fresh token) is
|
||||
correct."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
result = await balance_mod.refund_wallet_endpoint(
|
||||
authorization=f"Bearer {bearer}",
|
||||
x_cashu=None,
|
||||
session=session,
|
||||
)
|
||||
except HTTPException:
|
||||
return None
|
||||
if isinstance(result, dict):
|
||||
return result.get("token")
|
||||
return None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 1. #412 across CASHU topup
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_412_cashu_topup_second_refund_is_not_stale_token(session: AsyncSession) -> None:
|
||||
_clear_refund_cache()
|
||||
hashed = "cashu412hash"
|
||||
bearer = f"sk-{hashed}"
|
||||
await _new_key(session, hashed, balance=1000) # 1000 msats
|
||||
|
||||
minter = TokenMinter()
|
||||
|
||||
async def fake_recieve(token: str) -> tuple[int, str, str]:
|
||||
# crediting 1 sat = 1000 msats
|
||||
return 1, "sat", "https://mint.example.com"
|
||||
|
||||
with (
|
||||
patch("routstr.balance.send_token", minter),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(side_effect=lambda k, s: k)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.wallet.recieve_token", AsyncMock(side_effect=fake_recieve)),
|
||||
patch("routstr.wallet.store_cashu_transaction", AsyncMock()),
|
||||
):
|
||||
# 1) First refund: mints T1, debits balance to 0, caches/stores T1.
|
||||
r1 = await _do_refund(session, bearer)
|
||||
t1 = r1["token"]
|
||||
assert t1 == "cashuMINTED_1_amt1"
|
||||
|
||||
# 2) Cashu topup: credit balance again (user adds funds & uses the key).
|
||||
key = await session.get(ApiKey, hashed)
|
||||
assert key is not None
|
||||
await credit_balance("cashuAtopup", key, session)
|
||||
|
||||
# 3) Spend the topped-up balance back to zero (normal usage).
|
||||
await _spend_to_zero(session, hashed)
|
||||
|
||||
# 4) Second refund while balance == 0. The legacy cache serves the
|
||||
# STALE T1 here (fund loss: T1's proofs were already spent/minted
|
||||
# against and the user may have already redeemed it). The durable
|
||||
# fix must NOT re-serve T1 — the topup bumped balance_version, so
|
||||
# the stored T1 is no longer valid at the current version (and with
|
||||
# balance 0 there is nothing to refund -> declines).
|
||||
t2 = await _refund_token_or_none(session, bearer)
|
||||
|
||||
assert t2 != t1, (
|
||||
f"#412 REGRESSION: second refund re-served stale token {t1!r} "
|
||||
f"after a cashu topup + spend. The topup must invalidate T1."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 2. #412 across LIGHTNING topup (the load-bearing gap)
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_412_lightning_topup_second_refund_is_not_stale_token(session: AsyncSession) -> None:
|
||||
_clear_refund_cache()
|
||||
hashed = "ln412hash"
|
||||
bearer = f"sk-{hashed}"
|
||||
await _new_key(session, hashed, balance=1000)
|
||||
|
||||
minter = TokenMinter()
|
||||
|
||||
# Fake the cashu wallet used by topup_api_key_from_invoice -> wallet.mint
|
||||
fake_wallet = AsyncMock()
|
||||
fake_wallet.mint = AsyncMock(return_value=None)
|
||||
|
||||
class _Invoice:
|
||||
amount_sats = 1
|
||||
api_key_hash = hashed
|
||||
id = "inv1"
|
||||
payment_hash = "ph1"
|
||||
|
||||
with (
|
||||
patch("routstr.balance.send_token", minter),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(side_effect=lambda k, s: k)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
patch("routstr.lightning.get_wallet", AsyncMock(return_value=fake_wallet)),
|
||||
):
|
||||
# 1) First refund: mint T1, debit to 0, cache/store T1.
|
||||
r1 = await _do_refund(session, bearer)
|
||||
t1 = r1["token"]
|
||||
|
||||
# 2) LIGHTNING topup via the background-watcher code path. No bearer in
|
||||
# scope here — a header-keyed cache invalidation cannot fire. The
|
||||
# durable fix must bump balance_version inside this DB txn.
|
||||
await topup_api_key_from_invoice(_Invoice(), session) # type: ignore[arg-type]
|
||||
await session.commit()
|
||||
|
||||
# 3) Spend the Lightning-topped-up balance back to zero.
|
||||
await _spend_to_zero(session, hashed)
|
||||
|
||||
# 4) Second refund at balance 0: must NOT re-serve the stale T1.
|
||||
t2 = await _refund_token_or_none(session, bearer)
|
||||
|
||||
assert t2 != t1, (
|
||||
f"#412 REGRESSION (Lightning): second refund re-served stale token "
|
||||
f"{t1!r} after a Lightning topup. The Lightning credit must bump "
|
||||
f"balance_version so the cached/stored refund token is invalidated."
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 3. In-flight idempotency: two refunds at the SAME balance_version (no
|
||||
# intervening credit) return the SAME token and debit the balance once.
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inflight_idempotency_same_version_same_token(session: AsyncSession) -> None:
|
||||
_clear_refund_cache()
|
||||
hashed = "inflighthash"
|
||||
bearer = f"sk-{hashed}"
|
||||
await _new_key(session, hashed, balance=2000)
|
||||
|
||||
minter = TokenMinter()
|
||||
|
||||
with (
|
||||
patch("routstr.balance.send_token", minter),
|
||||
patch("routstr.balance.get_billing_key", AsyncMock(side_effect=lambda k, s: k)),
|
||||
patch("routstr.balance.store_cashu_transaction", AsyncMock()),
|
||||
):
|
||||
r1 = await _do_refund(session, bearer)
|
||||
# Second refund with NO intervening credit -> same balance_version.
|
||||
# The balance is now 0; idempotency must re-serve the same token.
|
||||
r2 = await _do_refund(session, bearer)
|
||||
|
||||
assert r1["token"] == r2["token"], (
|
||||
"in-flight idempotency broken: two refunds at the same balance_version "
|
||||
"with no intervening credit must return the SAME token"
|
||||
)
|
||||
# Exactly one token minted -> balance debited exactly once.
|
||||
assert len(minter.minted) == 1, (
|
||||
f"idempotent retry must NOT mint a second token; minted={minter.minted}"
|
||||
)
|
||||
@@ -20,7 +20,6 @@ comment ever reaches the client. That invariant is exactly what the buggy
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -108,76 +107,6 @@ def _assert_clean(out: list[bytes]) -> list[dict]:
|
||||
return objs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deepseek_usage_chunk_is_normalized_before_billing() -> None:
|
||||
"""DeepSeek stream trailers keep raw fields and add canonical billing fields."""
|
||||
usage = {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"total_tokens": 10500,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
"prompt_cache_miss_tokens": 1000,
|
||||
}
|
||||
chunks = [
|
||||
b'data: {"id":"ds","model":"deepseek-chat","choices":[{"delta":{"content":"ok"}}]}\n\n',
|
||||
b"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"id": "ds",
|
||||
"model": "deepseek-chat",
|
||||
"choices": [],
|
||||
"usage": usage,
|
||||
}
|
||||
).encode()
|
||||
+ b"\n\n",
|
||||
b"data: [DONE]\n\n",
|
||||
]
|
||||
|
||||
await _drive(chunks)
|
||||
|
||||
adjust_mock = cast(AsyncMock, base.adjust_payment_for_tokens)
|
||||
adjustment_input = adjust_mock.call_args.args[1]
|
||||
billed_usage = adjustment_input["usage"]
|
||||
assert billed_usage["prompt_tokens"] == 10000
|
||||
assert billed_usage["prompt_cache_hit_tokens"] == 9000
|
||||
assert billed_usage["prompt_cache_miss_tokens"] == 1000
|
||||
assert billed_usage["input_tokens"] == 1000
|
||||
assert billed_usage["output_tokens"] == 500
|
||||
assert billed_usage["cache_read_input_tokens"] == 9000
|
||||
assert billed_usage["cache_creation_input_tokens"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fold_cache_tokens_does_not_double_count_inclusive_prompt_tokens() -> None:
|
||||
"""Visible usage mutation must not inflate OpenAI-compatible prompt totals."""
|
||||
usage = {
|
||||
"prompt_tokens": 10000,
|
||||
"completion_tokens": 500,
|
||||
"cache_read_input_tokens": 9000,
|
||||
"prompt_cache_hit_tokens": 9000,
|
||||
}
|
||||
|
||||
BaseUpstreamProvider._fold_cache_into_input_tokens(usage)
|
||||
|
||||
assert usage["prompt_tokens"] == 10000
|
||||
assert usage["cache_read_input_tokens"] == 9000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fold_cache_tokens_still_rolls_up_anthropic_input_tokens() -> None:
|
||||
"""Anthropic native input_tokens excludes cache and still needs rollup."""
|
||||
usage = {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 500,
|
||||
"cache_read_input_tokens": 9000,
|
||||
"cache_creation_input_tokens": 200,
|
||||
}
|
||||
|
||||
BaseUpstreamProvider._fold_cache_into_input_tokens(usage)
|
||||
|
||||
assert usage["input_tokens"] == 10200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_style_plain_stream() -> None:
|
||||
"""OpenAI / Groq / Fireworks / xAI / Perplexity: plain data + [DONE]."""
|
||||
|
||||
@@ -39,6 +39,8 @@ async def test_recieve_token_valid() -> None:
|
||||
|
||||
mock_wallet = Mock()
|
||||
mock_wallet.split = AsyncMock()
|
||||
# Fee-free trusted mint (e.g. Minibits): nothing deducted.
|
||||
mock_wallet.get_fees_for_proofs = Mock(return_value=0)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
@@ -61,6 +63,69 @@ async def test_recieve_token_valid() -> None:
|
||||
assert mint == "http://mint:3338"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recieve_token_trusted_mint_deducts_input_fee() -> None:
|
||||
"""A trusted mint that charges NUT-02 input fees.
|
||||
|
||||
The same-mint receive (`wallet.split(..., include_fees=True)`, a NUT-03 swap
|
||||
at the same mint — not swap_to_primary_mint) pays the mint's per-proof fee,
|
||||
so routstr only ends up with `face - input_fee` in fresh proofs. The credited
|
||||
amount must reflect that, otherwise routstr over-credits the user and its own
|
||||
wallet drifts toward insolvency.
|
||||
"""
|
||||
token_data = {
|
||||
"token": [
|
||||
{
|
||||
"mint": "http://mint:3338",
|
||||
"proofs": [
|
||||
{"amount": 1000, "id": "test", "secret": "secret", "C": "curve"}
|
||||
],
|
||||
}
|
||||
],
|
||||
"unit": "sat",
|
||||
}
|
||||
token_json = json.dumps(token_data)
|
||||
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
|
||||
token_str = f"cashuA{token_b64}"
|
||||
|
||||
mock_wallet = Mock()
|
||||
mock_wallet.split = AsyncMock()
|
||||
# Mock a 3-sat input fee from the Cashu wallet API.
|
||||
mock_wallet.get_fees_for_proofs = Mock(return_value=3)
|
||||
|
||||
from routstr.core.settings import settings
|
||||
|
||||
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
|
||||
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
|
||||
mock_token = Mock()
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.mint = "http://mint:3338"
|
||||
mock_token.unit = "sat"
|
||||
mock_token.amount = 1000
|
||||
mock_token.proofs = [{"amount": 1000}]
|
||||
mock_deserialize.return_value = mock_token
|
||||
|
||||
mock_wallet.load_mint = AsyncMock()
|
||||
mock_wallet.load_proofs = AsyncMock()
|
||||
# Patch get_wallet directly so the module-level `_wallets` cache
|
||||
# (keyed by mint URL) can't hand back a wallet from another test.
|
||||
with patch(
|
||||
"routstr.wallet.get_wallet",
|
||||
AsyncMock(return_value=mock_wallet),
|
||||
):
|
||||
amount, unit, mint = await recieve_token(token_str)
|
||||
assert amount == 997 # 1000 face - 3 sat input fee paid on swap
|
||||
assert unit == "sat"
|
||||
assert mint == "http://mint:3338"
|
||||
mock_wallet.get_fees_for_proofs.assert_called_once_with(
|
||||
mock_token.proofs
|
||||
)
|
||||
# DLEQ is verified before re-minting the incoming proofs.
|
||||
mock_wallet.verify_proofs_dleq.assert_called_once_with(
|
||||
mock_token.proofs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_token() -> None:
|
||||
mock_wallet = Mock()
|
||||
@@ -254,19 +319,31 @@ async def test_recieve_token_untrusted_mint() -> None:
|
||||
assert mint == "http://mint:3338"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_swap_to_primary_mint_already_on_primary() -> None:
|
||||
"""Same-mint shortcut: the token is already on the primary mint.
|
||||
|
||||
No cross-mint swap (no melt/mint), but the same-mint split(include_fees=True)
|
||||
still burns the mint's NUT-02 input fee, so the credited amount must be face
|
||||
minus the input fee — not full face value (the over-credit bug). DLEQ is
|
||||
verified too, matching the trusted same-mint receive path.
|
||||
"""
|
||||
from routstr.core.settings import settings
|
||||
from routstr.wallet import swap_to_primary_mint
|
||||
|
||||
mock_token = Mock()
|
||||
mock_token.mint = settings.primary_mint
|
||||
mock_token.keysets = ["keyset1"]
|
||||
mock_token.amount = 1000
|
||||
mock_token.unit = "sat"
|
||||
mock_token.proofs = []
|
||||
mock_token.proofs = [{"amount": 1000}]
|
||||
|
||||
mock_token_wallet = Mock()
|
||||
mock_token_wallet.load_mint = AsyncMock()
|
||||
mock_token_wallet.load_proofs = AsyncMock()
|
||||
mock_token_wallet.verify_proofs_dleq = Mock()
|
||||
# Mock a 3-sat input fee from the Cashu wallet API.
|
||||
mock_token_wallet.get_fees_for_proofs = Mock(return_value=3)
|
||||
mock_token_wallet.split = AsyncMock(return_value=None)
|
||||
mock_token_wallet.request_mint = AsyncMock()
|
||||
mock_token_wallet.melt_quote = AsyncMock()
|
||||
@@ -274,9 +351,11 @@ async def test_swap_to_primary_mint_already_on_primary() -> None:
|
||||
with patch("routstr.wallet.get_wallet", AsyncMock(return_value=mock_token_wallet)):
|
||||
amount, unit, mint = await swap_to_primary_mint(mock_token, mock_token_wallet)
|
||||
|
||||
assert amount == 1000
|
||||
assert amount == 997 # 1000 face - 3 sat input fee
|
||||
assert unit == "sat"
|
||||
assert mint == settings.primary_mint
|
||||
mock_token_wallet.verify_proofs_dleq.assert_called_once_with(mock_token.proofs)
|
||||
mock_token_wallet.get_fees_for_proofs.assert_called_once_with(mock_token.proofs)
|
||||
mock_token_wallet.split.assert_called_once()
|
||||
mock_token_wallet.request_mint.assert_not_called()
|
||||
mock_token_wallet.melt_quote.assert_not_called()
|
||||
|
||||
@@ -297,16 +297,13 @@ export default function ProvidersPage() {
|
||||
};
|
||||
|
||||
const toggleProviderExpansion = (providerId: number) => {
|
||||
const newExpanded = new Set(expandedProviders);
|
||||
if (newExpanded.has(providerId)) {
|
||||
newExpanded.delete(providerId);
|
||||
} else {
|
||||
newExpanded.add(providerId);
|
||||
}
|
||||
setExpandedProviders(newExpanded);
|
||||
if (!newExpanded.has(providerId)) {
|
||||
if (expandedProviders.has(providerId)) {
|
||||
setExpandedProviders(new Set());
|
||||
setViewingModels(null);
|
||||
} else {
|
||||
// Accordion: only one provider open at a time so switching to another
|
||||
// provider's models auto-collapses the previously expanded one.
|
||||
setExpandedProviders(new Set([providerId]));
|
||||
setViewingModels(providerId);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user