Compare commits

...
Author SHA1 Message Date
9qeklajc 379c319e0d fix test 2026-06-01 23:30:20 +02:00
9qeklajc 9633483fa4 prevent zero balance token 2026-06-01 22:47:09 +02:00
9qeklajcandGitHub 9aa3408905 Merge pull request #528 from bilthon/refactor/remove-refund-by-token-hash
Remove deprecated cashu-refund-by-hash endpoint
2026-05-31 16:39:22 +02:00
9qeklajcandGitHub 31ff1fd90c Merge pull request #519 from Routstr/sse-buffer-refactor
refactor: rewrite SSE parsing with buffered double-newline delimiter
2026-05-31 11:13:10 +02:00
Bilthon f2ef63da62 refactor(balance): remove unreachable cashu-refund-by-hash endpoint 2026-05-28 10:25:25 -05:00
9 changed files with 56 additions and 64 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ The response includes your change in the same header:
X-Cashu: cashuA7k2mNp4...
```
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated.
This is fully stateless—no session, no `/v1/balance/refund` call needed. However, **streaming does not work with `X-Cashu`** because the refund can only be calculated after the full response is generated. If you lose the `X-Cashu` response header before claiming your change, you can reclaim the refund via `POST /v1/wallet/refund` by supplying the original payment token in the `x-cashu` header.
## Response Headers
+5
View File
@@ -341,6 +341,11 @@ async def validate_bearer_key(
"Token redemption returned zero or negative amount",
extra={"msats": msats, "key_hash": hashed_key[:8] + "..."},
)
# Defense-in-depth: credit_balance now refuses to commit on a
# zero/negative redemption, but if a row was nonetheless
# persisted, drop it so we never leave an orphan zero-balance key.
await session.delete(new_key)
await session.commit()
raise Exception("Token redemption failed")
await session.refresh(new_key)
-20
View File
@@ -609,26 +609,6 @@ async def reset_child_key_spent(
return {"success": True, "message": "Child key balance reset successfully."}
@router.get("/cashu-refund/{payment_token_hash}")
async def get_cashu_refund(
payment_token_hash: str,
session: AsyncSession = Depends(get_session),
) -> dict:
"""Retrieve a stored Cashu refund token by the hash of the original payment token."""
result = await session.get(CashuTransaction, payment_token_hash)
if result is None:
raise HTTPException(status_code=404, detail="Refund not found")
if result.swept:
raise HTTPException(status_code=410, detail="Refund has been swept")
result.collected = True
session.add(result)
await session.commit()
return {
"refund_token": result.token,
"amount": result.amount,
"unit": result.unit,
}
@router.api_route(
"/{path:path}",
-35
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import traceback
import uuid
@@ -1891,7 +1890,6 @@ class BaseUpstreamProvider:
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> Response | StreamingResponse:
"""Dispatch /v1/messages via litellm for x-cashu payments.
@@ -1913,7 +1911,6 @@ class BaseUpstreamProvider:
max_cost_for_model,
requested_model,
mint,
payment_token_hash,
request_id,
)
@@ -1940,7 +1937,6 @@ class BaseUpstreamProvider:
refund_amount,
unit,
mint,
payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
@@ -2127,7 +2123,6 @@ class BaseUpstreamProvider:
max_cost_for_model: int,
requested_model: str | None,
mint: str | None,
payment_token_hash: str | None,
request_id: str | None,
) -> StreamingResponse:
"""Buffer a litellm stream end-to-end, compute cost, then replay.
@@ -2231,7 +2226,6 @@ class BaseUpstreamProvider:
refund_amount,
unit,
mint,
payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
@@ -2996,7 +2990,6 @@ class BaseUpstreamProvider:
amount: int,
unit: str,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> str:
"""Create and send a refund token to the user.
@@ -3005,7 +2998,6 @@ class BaseUpstreamProvider:
amount: Refund amount
unit: Unit of the refund (sat or msat)
mint: Optional mint URL for the refund token
payment_token_hash: Optional SHA-256 hash of the original payment token for storage
request_id: Optional HTTP request ID for tracking
Returns:
@@ -3097,7 +3089,6 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse:
"""Handle streaming response for X-Cashu payment, calculating refund if needed.
@@ -3108,7 +3099,6 @@ class BaseUpstreamProvider:
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
payment_token_hash: Optional hash of original payment token for refund storage
Returns:
StreamingResponse with refund token in header if applicable
@@ -3199,7 +3189,6 @@ class BaseUpstreamProvider:
refund_amount,
unit,
mint,
payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
@@ -3278,7 +3267,6 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> Response:
"""Handle non-streaming response for X-Cashu payment, calculating refund if needed.
@@ -3289,7 +3277,6 @@ class BaseUpstreamProvider:
amount: Payment amount received
unit: Payment unit (sat or msat)
max_cost_for_model: Maximum cost for the model
payment_token_hash: Optional hash of original payment token for refund storage
Returns:
Response with refund token in header if applicable
@@ -3359,7 +3346,6 @@ class BaseUpstreamProvider:
refund_amount,
unit,
mint,
payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
@@ -3431,7 +3417,6 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse | Response:
"""Handle chat completion response for X-Cashu payment, detecting streaming vs non-streaming.
@@ -3475,7 +3460,6 @@ class BaseUpstreamProvider:
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
else:
@@ -3486,7 +3470,6 @@ class BaseUpstreamProvider:
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
@@ -3516,7 +3499,6 @@ class BaseUpstreamProvider:
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
payment_token_hash: str | None = None,
) -> Response | StreamingResponse:
"""Forward request paid with X-Cashu token to upstream service.
@@ -3555,7 +3537,6 @@ class BaseUpstreamProvider:
max_cost_for_model=max_cost_for_model,
model_obj=model_obj,
mint=mint,
payment_token_hash=payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
@@ -3625,7 +3606,6 @@ class BaseUpstreamProvider:
amount,
unit,
mint,
payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
@@ -3675,7 +3655,6 @@ class BaseUpstreamProvider:
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
background_tasks = BackgroundTasks()
@@ -3751,7 +3730,6 @@ class BaseUpstreamProvider:
)
try:
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
headers = dict(request.headers)
amount, unit, mint = await recieve_token(x_cashu_token)
headers = self.prepare_headers(dict(request.headers))
@@ -3784,7 +3762,6 @@ class BaseUpstreamProvider:
max_cost_for_model,
model_obj,
mint,
payment_token_hash,
)
except Exception as e:
error_message = str(e)
@@ -3844,7 +3821,6 @@ class BaseUpstreamProvider:
max_cost_for_model: int,
model_obj: Model,
mint: str | None = None,
payment_token_hash: str | None = None,
) -> Response | StreamingResponse:
"""Forward Responses API request paid with X-Cashu token to upstream service.
@@ -3920,7 +3896,6 @@ class BaseUpstreamProvider:
amount,
unit,
mint,
payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
@@ -3965,7 +3940,6 @@ class BaseUpstreamProvider:
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=getattr(request.state, "request_id", None),
)
background_tasks = BackgroundTasks()
@@ -4016,7 +3990,6 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse | Response:
"""Handle Responses API completion response for X-Cashu payment.
@@ -4061,7 +4034,6 @@ class BaseUpstreamProvider:
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
else:
@@ -4072,7 +4044,6 @@ class BaseUpstreamProvider:
unit,
max_cost_for_model,
mint,
payment_token_hash,
request_id=request_id,
)
@@ -4100,7 +4071,6 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> StreamingResponse:
"""Handle streaming Responses API response for X-Cashu payment.
@@ -4187,7 +4157,6 @@ class BaseUpstreamProvider:
refund_amount,
unit,
mint,
payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
@@ -4266,7 +4235,6 @@ class BaseUpstreamProvider:
unit: str,
max_cost_for_model: int,
mint: str | None = None,
payment_token_hash: str | None = None,
request_id: str | None = None,
) -> Response:
"""Handle non-streaming Responses API response for X-Cashu payment."""
@@ -4335,7 +4303,6 @@ class BaseUpstreamProvider:
refund_amount,
unit,
mint,
payment_token_hash,
request_id=request_id,
)
response_headers["X-Cashu"] = refund_token
@@ -4432,7 +4399,6 @@ class BaseUpstreamProvider:
)
try:
payment_token_hash = hashlib.sha256(x_cashu_token.encode()).hexdigest()
headers = dict(request.headers)
amount, unit, mint = await recieve_token(x_cashu_token)
headers = self.prepare_headers(dict(request.headers))
@@ -4465,7 +4431,6 @@ class BaseUpstreamProvider:
max_cost_for_model,
model_obj,
mint,
payment_token_hash,
)
except Exception as e:
error_message = str(e)
+14
View File
@@ -403,6 +403,20 @@ async def credit_balance(
"credit_balance: Converted to msat", extra={"amount_msat": amount}
)
# Guard against zero/negative redemptions (empty or dust tokens, or
# swap-to-primary-mint amounts that net to <= 0 after fees). Raising here
# — before the UPDATE/commit below — leaves any freshly-created, still
# uncommitted ApiKey row to be rolled back when the request session
# closes, instead of persisting an orphan key with balance 0.
if amount <= 0:
logger.error(
"credit_balance: Redeemed amount is zero or negative; refusing to credit",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
)
raise ValueError(
f"Redeemed token amount must be positive, got {amount} msats"
)
logger.info(
"credit_balance: Updating balance",
extra={"old_balance": key.balance, "credit_amount": amount},
+3 -3
View File
@@ -436,9 +436,9 @@ async def test_topup_with_zero_amount_token( # type: ignore[no-untyped-def]
"/v1/wallet/topup", params={"cashu_token": token}
)
# Should succeed but add 0 msats
assert response.status_code == 200
assert response.json()["msats"] == 0
# Zero/negative redemptions are refused to avoid crediting empty
# or dust tokens (and to prevent orphan zero-balance keys).
assert response.status_code == 400
@pytest.mark.integration
@@ -693,7 +693,6 @@ async def test_x_cashu_non_streaming_dispatches_and_refunds_overpaid_amount() ->
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint.example",
payment_token_hash="hash123",
request_id="req-1",
)
@@ -972,7 +971,6 @@ async def test_forward_x_cashu_request_routes_messages_via_litellm() -> None:
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint",
payment_token_hash="h",
)
mock_helper.assert_awaited_once()
@@ -1041,7 +1039,6 @@ async def test_forward_x_cashu_request_handles_count_tokens_locally() -> None:
max_cost_for_model=10_000,
model_obj=model,
mint="https://mint",
payment_token_hash="h",
)
assert response.status_code == 200
+33
View File
@@ -108,6 +108,39 @@ async def test_credit_balance() -> None:
assert mock_session.refresh.called
@pytest.mark.asyncio
async def test_credit_balance_rejects_zero_amount() -> None:
"""A zero/dust redemption must raise BEFORE any commit, so no orphan
zero-balance key (balance 0, total_spent 0, total_requests 0) is persisted."""
token_data = {
"token": [{"mint": "http://mint:3338", "proofs": [{"amount": 0}]}],
"unit": "sat",
}
token_json = json.dumps(token_data)
token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode()
token_str = f"cashuA{token_b64}"
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
mock_session = AsyncMock()
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
return_value=(0, "sat", "http://mint:3338"),
):
with pytest.raises(ValueError, match="must be positive"):
await credit_balance(token_str, mock_key, mock_session)
# Critically: no balance UPDATE and no commit happened, so the caller's
# uncommitted key row rolls back instead of persisting as an orphan.
assert not mock_session.exec.called
assert not mock_session.commit.called
@pytest.mark.asyncio
async def test_swap_to_primary_mint_insufficient_for_fees() -> None:
"""Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""
-2
View File
@@ -64,7 +64,6 @@ async def test_non_streaming_includes_cost_sats() -> None:
unit="msat",
max_cost_for_model=10000,
mint=None,
payment_token_hash=None,
)
body = json.loads(response.body)
@@ -170,7 +169,6 @@ async def test_streaming_includes_cost_sats_in_usage_chunk() -> None:
unit="msat",
max_cost_for_model=10000,
mint=None,
payment_token_hash=None,
)
chunks = await _collect_streaming(response)