fix: propagate Cashu transaction storage failures

This commit is contained in:
9qeklajc
2026-08-06 23:35:37 +02:00
parent 667f9bf6bb
commit a60b04aea0
8 changed files with 132 additions and 196 deletions
+10 -13
View File
@@ -613,19 +613,16 @@ async def refund_wallet_endpoint(
await _refund_cache_set(bearer_value, result)
if "token" in result:
try:
await store_cashu_transaction(
token=result["token"],
amount=remaining_balance,
unit=key.refund_currency or "sat",
mint_url=effective_refund_mint,
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass # store_cashu_transaction already logs
await store_cashu_transaction(
token=result["token"],
amount=remaining_balance,
unit=key.refund_currency or "sat",
mint_url=effective_refund_mint,
typ="out",
collected=False,
source="apikey",
api_key_hashed_key=key.hashed_key,
)
logger.info(
"refund_wallet_endpoint: refund successful",
+9 -19
View File
@@ -455,25 +455,15 @@ async def withdraw(
status_code=400, detail="Insufficient wallet balance"
) from error
actual_mint = token_mint_url(token, effective_mint)
try:
await store_cashu_transaction(
token=token,
amount=withdraw_request.amount,
unit=withdraw_request.unit,
mint_url=actual_mint,
typ="out",
collected=False,
source="admin",
)
except Exception:
logger.critical(
"Admin withdrawal token issued without a persisted audit record",
extra={
"amount": withdraw_request.amount,
"unit": withdraw_request.unit,
"mint_url": actual_mint,
},
)
await store_cashu_transaction(
token=token,
amount=withdraw_request.amount,
unit=withdraw_request.unit,
mint_url=actual_mint,
typ="out",
collected=False,
source="admin",
)
return {"token": token, "mint_url": actual_mint}
+67 -81
View File
@@ -3594,37 +3594,12 @@ class BaseUpstreamProvider:
max_retries = 3
last_exception = None
refund_token = None
for attempt in range(max_retries):
try:
refund_token = await send_token(amount, unit=unit, mint_url=mint)
logger.info(
"Refund token created successfully",
extra={
"amount": amount,
"unit": unit,
"mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
try:
await store_cashu_transaction(
token=refund_token,
amount=amount,
unit=unit,
mint_url=token_mint_url(refund_token, mint),
typ="out",
request_id=request_id,
)
except Exception:
pass # store_cashu_transaction already logs
return refund_token
break
except Exception as e:
last_exception = e
if attempt < max_retries - 1:
@@ -3654,16 +3629,39 @@ class BaseUpstreamProvider:
},
)
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
"type": "invalid_request_error",
"code": "send_token_failed",
}
if refund_token is None:
raise HTTPException(
status_code=401,
detail={
"error": {
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
"type": "invalid_request_error",
"code": "send_token_failed",
}
},
)
logger.info(
"Refund token created successfully",
extra={
"amount": amount,
"unit": unit,
"mint": mint,
"attempt": attempt + 1,
"token_preview": refund_token[:20] + "..."
if len(refund_token) > 20
else refund_token,
},
)
await store_cashu_transaction(
token=refund_token,
amount=amount,
unit=unit,
mint_url=token_mint_url(refund_token, mint),
typ="out",
request_id=request_id,
)
return refund_token
async def handle_x_cashu_streaming_response(
self,
@@ -3979,17 +3977,14 @@ class BaseUpstreamProvider:
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=token_mint_url(refund_token, mint),
typ="out",
request_id=request_id,
)
except Exception:
pass
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=token_mint_url(refund_token, mint),
typ="out",
request_id=request_id,
)
logger.warning(
"Emergency refund issued due to JSON parse error",
@@ -4345,18 +4340,15 @@ class BaseUpstreamProvider:
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
logger.info(
"X-Cashu token redeemed for Responses API",
@@ -4960,17 +4952,14 @@ class BaseUpstreamProvider:
emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token
try:
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=token_mint_url(refund_token, mint),
typ="out",
request_id=request_id,
)
except Exception:
pass
await store_cashu_transaction(
token=refund_token,
amount=emergency_refund,
unit=unit,
mint_url=token_mint_url(refund_token, mint),
typ="out",
request_id=request_id,
)
logger.warning(
"Emergency refund issued for Responses API due to JSON parse error",
@@ -5034,18 +5023,15 @@ class BaseUpstreamProvider:
headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None)
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
logger.info(
"X-Cashu token redeemed successfully",
+17 -23
View File
@@ -749,17 +749,14 @@ async def send_cashu_refund(
) -> str:
"""Create a Cashu refund token and record the outgoing transaction."""
refund_token = await send_token(amount, unit=unit, mint_url=mint)
try:
await store_cashu_transaction(
token=refund_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
except Exception:
pass
await store_cashu_transaction(
token=refund_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="out",
request_id=request_id,
)
return refund_token
@@ -1014,18 +1011,15 @@ async def forward_ehbp_x_cashu_request(
try:
amount, unit, mint = await recieve_token(x_cashu_token)
redeemed = True
try:
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
except Exception:
pass
await store_cashu_transaction(
token=x_cashu_token,
amount=amount,
unit=unit,
mint_url=mint,
typ="in",
request_id=request_id,
collected=True,
)
headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined]
target = upstream.get_ehbp_forwarding_target(path, model_obj) # type: ignore[attr-defined]
+13 -17
View File
@@ -1703,23 +1703,19 @@ async def _credit_balance_locked(
extra={"new_balance": key.balance},
)
try:
await store_cashu_transaction(
token=cashu_token,
amount=original_amount,
unit=original_unit,
mint_url=mint_url,
typ="in",
source="apikey",
api_key_hashed_key=key.hashed_key,
)
except Exception:
pass
else:
logger.debug(
"Cashu token successfully redeemed and stored",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
)
await store_cashu_transaction(
token=cashu_token,
amount=original_amount,
unit=original_unit,
mint_url=mint_url,
typ="in",
source="apikey",
api_key_hashed_key=key.hashed_key,
)
logger.debug(
"Cashu token successfully redeemed and stored",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
)
return amount
except Exception as e:
logger.error(
+3 -7
View File
@@ -45,7 +45,7 @@ async def test_withdraw_uses_effective_mint_and_records_outgoing_transaction(
@pytest.mark.asyncio
async def test_withdraw_returns_issued_token_when_audit_storage_fails(
async def test_withdraw_propagates_audit_storage_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
mint = "https://primary.example"
@@ -58,14 +58,10 @@ async def test_withdraw_returns_issued_token_when_audit_storage_fails(
"store_cashu_transaction",
AsyncMock(side_effect=RuntimeError("database unavailable")),
)
critical = Mock()
monkeypatch.setattr(admin.logger, "critical", critical)
monkeypatch.setattr(admin.settings, "primary_mint", mint)
result = await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75))
assert result == {"token": token, "mint_url": mint}
critical.assert_called_once()
with pytest.raises(RuntimeError, match="database unavailable"):
await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75))
@pytest.mark.asyncio
+9 -29
View File
@@ -49,9 +49,7 @@ V-E9 Existing emergency-refund tests use a 500-char source window that
from __future__ import annotations
import inspect
from unittest.mock import AsyncMock, patch
import pytest
import re
# ---------------------------------------------------------------------------
# Helpers
@@ -65,8 +63,8 @@ def _source_contains_except_pass(source: str, anchor: str, window: int = 1000) -
if idx < 0:
return False
section = source[idx : idx + window]
has_except = "except Exception:" in section or "except:" in section
return has_except and "pass" in section
pattern = r"except(?:\s+Exception)?(?:\s+as\s+\w+)?\s*:\s*pass\b"
return re.search(pattern, section) is not None
# ===========================================================================
@@ -335,28 +333,10 @@ def test_admin_withdraw_must_not_return_token_on_db_failure() -> None:
# ===========================================================================
def test_existing_emergency_refund_test_window_is_wide_enough() -> None:
"""REGRESSION GUARD: The existing test_emergency_refund_no_try_except_pass
inspects a 500-character window after "emergency_refund = amount".
The except: pass block is ~530 chars after that anchor, so the 500-char
window misses it entirely producing a false green.
def test_except_pass_detector_window_is_wide_enough() -> None:
"""The detector must catch an except/pass block beyond 500 characters."""
anchor = "emergency_refund = amount"
source = anchor + (" " * 520) + "except Exception:\n pass"
This test verifies that a 1000-char window (which we use in V-E2/V-E3)
correctly catches the live bug. If this test fails, someone shrank
the window back to 500 or removed the wider-window tests.
"""
from routstr.upstream.base import BaseUpstreamProvider
src = inspect.getsource(
BaseUpstreamProvider.handle_x_cashu_non_streaming_response
)
emergency_start = src.find("emergency_refund = amount")
assert emergency_start > 0, "Emergency refund path must exist"
# The 1000-char window MUST see the except: pass (currently live bug)
wide_section = src[emergency_start : emergency_start + 1000]
assert "except Exception:" in wide_section and "pass" in wide_section, (
"The 1000-char window must catch the live except: pass bug. "
"If this fails, either the bug was fixed (good!) or the window "
"logic changed (bad — re-check V-E2)."
)
assert not _source_contains_except_pass(source, anchor, window=500)
assert _source_contains_except_pass(source, anchor, window=1000)
+4 -7
View File
@@ -1510,11 +1510,8 @@ async def test_credit_balance_msat_unit_not_converted() -> None:
@pytest.mark.asyncio
async def test_credit_balance_survives_audit_store_failure() -> None:
"""A failure writing the CashuTransaction history record must not undo the
already-committed balance credit. (The silent swallow is a known
audit-trail gap slated for its own fix this test pins the financial
invariant that the user keeps their credit, not the swallow itself.)"""
async def test_credit_balance_propagates_audit_store_failure_after_credit() -> None:
"""A final transaction-history failure propagates after committing credit."""
mock_key = Mock()
mock_key.balance = 0
mock_key.hashed_key = "test_hash"
@@ -1531,9 +1528,9 @@ async def test_credit_balance_survives_audit_store_failure() -> None:
"routstr.wallet.store_cashu_transaction",
side_effect=Exception("history table locked"),
):
amount = await credit_balance("cashuAtest", mock_key, mock_session)
with pytest.raises(Exception, match="history table locked"):
await credit_balance("cashuAtest", mock_key, mock_session)
assert amount == 1_000_000
assert mock_session.commit.called