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) await _refund_cache_set(bearer_value, result)
if "token" in result: if "token" in result:
try: await store_cashu_transaction(
await store_cashu_transaction( token=result["token"],
token=result["token"], amount=remaining_balance,
amount=remaining_balance, unit=key.refund_currency or "sat",
unit=key.refund_currency or "sat", mint_url=effective_refund_mint,
mint_url=effective_refund_mint, typ="out",
typ="out", collected=False,
collected=False, source="apikey",
source="apikey", api_key_hashed_key=key.hashed_key,
api_key_hashed_key=key.hashed_key, )
)
except Exception:
pass # store_cashu_transaction already logs
logger.info( logger.info(
"refund_wallet_endpoint: refund successful", "refund_wallet_endpoint: refund successful",
+9 -19
View File
@@ -455,25 +455,15 @@ async def withdraw(
status_code=400, detail="Insufficient wallet balance" status_code=400, detail="Insufficient wallet balance"
) from error ) from error
actual_mint = token_mint_url(token, effective_mint) actual_mint = token_mint_url(token, effective_mint)
try: await store_cashu_transaction(
await store_cashu_transaction( token=token,
token=token, amount=withdraw_request.amount,
amount=withdraw_request.amount, unit=withdraw_request.unit,
unit=withdraw_request.unit, mint_url=actual_mint,
mint_url=actual_mint, typ="out",
typ="out", collected=False,
collected=False, source="admin",
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,
},
)
return {"token": token, "mint_url": actual_mint} return {"token": token, "mint_url": actual_mint}
+67 -81
View File
@@ -3594,37 +3594,12 @@ class BaseUpstreamProvider:
max_retries = 3 max_retries = 3
last_exception = None last_exception = None
refund_token = None
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
refund_token = await send_token(amount, unit=unit, mint_url=mint) refund_token = await send_token(amount, unit=unit, mint_url=mint)
break
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
except Exception as e: except Exception as e:
last_exception = e last_exception = e
if attempt < max_retries - 1: if attempt < max_retries - 1:
@@ -3654,16 +3629,39 @@ class BaseUpstreamProvider:
}, },
) )
raise HTTPException( if refund_token is None:
status_code=401, raise HTTPException(
detail={ status_code=401,
"error": { detail={
"message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}", "error": {
"type": "invalid_request_error", "message": f"failed to create refund after {max_retries} attempts: {str(last_exception)}",
"code": "send_token_failed", "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( async def handle_x_cashu_streaming_response(
self, self,
@@ -3979,17 +3977,14 @@ class BaseUpstreamProvider:
emergency_refund = amount emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint) refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token response.headers["X-Cashu"] = refund_token
try: await store_cashu_transaction(
await store_cashu_transaction( token=refund_token,
token=refund_token, amount=emergency_refund,
amount=emergency_refund, unit=unit,
unit=unit, mint_url=token_mint_url(refund_token, mint),
mint_url=token_mint_url(refund_token, mint), typ="out",
typ="out", request_id=request_id,
request_id=request_id, )
)
except Exception:
pass
logger.warning( logger.warning(
"Emergency refund issued due to JSON parse error", "Emergency refund issued due to JSON parse error",
@@ -4345,18 +4340,15 @@ class BaseUpstreamProvider:
headers = self.prepare_headers(dict(request.headers)) headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None) request_id = getattr(request.state, "request_id", None)
try: await store_cashu_transaction(
await store_cashu_transaction( token=x_cashu_token,
token=x_cashu_token, amount=amount,
amount=amount, unit=unit,
unit=unit, mint_url=mint,
mint_url=mint, typ="in",
typ="in", request_id=request_id,
request_id=request_id, collected=True,
collected=True, )
)
except Exception:
pass
logger.info( logger.info(
"X-Cashu token redeemed for Responses API", "X-Cashu token redeemed for Responses API",
@@ -4960,17 +4952,14 @@ class BaseUpstreamProvider:
emergency_refund = amount emergency_refund = amount
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint) refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
response.headers["X-Cashu"] = refund_token response.headers["X-Cashu"] = refund_token
try: await store_cashu_transaction(
await store_cashu_transaction( token=refund_token,
token=refund_token, amount=emergency_refund,
amount=emergency_refund, unit=unit,
unit=unit, mint_url=token_mint_url(refund_token, mint),
mint_url=token_mint_url(refund_token, mint), typ="out",
typ="out", request_id=request_id,
request_id=request_id, )
)
except Exception:
pass
logger.warning( logger.warning(
"Emergency refund issued for Responses API due to JSON parse error", "Emergency refund issued for Responses API due to JSON parse error",
@@ -5034,18 +5023,15 @@ class BaseUpstreamProvider:
headers = self.prepare_headers(dict(request.headers)) headers = self.prepare_headers(dict(request.headers))
request_id = getattr(request.state, "request_id", None) request_id = getattr(request.state, "request_id", None)
try: await store_cashu_transaction(
await store_cashu_transaction( token=x_cashu_token,
token=x_cashu_token, amount=amount,
amount=amount, unit=unit,
unit=unit, mint_url=mint,
mint_url=mint, typ="in",
typ="in", request_id=request_id,
request_id=request_id, collected=True,
collected=True, )
)
except Exception:
pass
logger.info( logger.info(
"X-Cashu token redeemed successfully", "X-Cashu token redeemed successfully",
+17 -23
View File
@@ -749,17 +749,14 @@ async def send_cashu_refund(
) -> str: ) -> str:
"""Create a Cashu refund token and record the outgoing transaction.""" """Create a Cashu refund token and record the outgoing transaction."""
refund_token = await send_token(amount, unit=unit, mint_url=mint) refund_token = await send_token(amount, unit=unit, mint_url=mint)
try: await store_cashu_transaction(
await store_cashu_transaction( token=refund_token,
token=refund_token, amount=amount,
amount=amount, unit=unit,
unit=unit, mint_url=mint,
mint_url=mint, typ="out",
typ="out", request_id=request_id,
request_id=request_id, )
)
except Exception:
pass
return refund_token return refund_token
@@ -1014,18 +1011,15 @@ async def forward_ehbp_x_cashu_request(
try: try:
amount, unit, mint = await recieve_token(x_cashu_token) amount, unit, mint = await recieve_token(x_cashu_token)
redeemed = True redeemed = True
try: await store_cashu_transaction(
await store_cashu_transaction( token=x_cashu_token,
token=x_cashu_token, amount=amount,
amount=amount, unit=unit,
unit=unit, mint_url=mint,
mint_url=mint, typ="in",
typ="in", request_id=request_id,
request_id=request_id, collected=True,
collected=True, )
)
except Exception:
pass
headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined] headers = upstream.prepare_headers(dict(request.headers)) # type: ignore[attr-defined]
target = upstream.get_ehbp_forwarding_target(path, model_obj) # 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}, extra={"new_balance": key.balance},
) )
try: await store_cashu_transaction(
await store_cashu_transaction( token=cashu_token,
token=cashu_token, amount=original_amount,
amount=original_amount, unit=original_unit,
unit=original_unit, mint_url=mint_url,
mint_url=mint_url, typ="in",
typ="in", source="apikey",
source="apikey", api_key_hashed_key=key.hashed_key,
api_key_hashed_key=key.hashed_key, )
) logger.debug(
except Exception: "Cashu token successfully redeemed and stored",
pass extra={"amount": amount, "unit": unit, "mint_url": mint_url},
else: )
logger.debug(
"Cashu token successfully redeemed and stored",
extra={"amount": amount, "unit": unit, "mint_url": mint_url},
)
return amount return amount
except Exception as e: except Exception as e:
logger.error( logger.error(
+3 -7
View File
@@ -45,7 +45,7 @@ async def test_withdraw_uses_effective_mint_and_records_outgoing_transaction(
@pytest.mark.asyncio @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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
mint = "https://primary.example" mint = "https://primary.example"
@@ -58,14 +58,10 @@ async def test_withdraw_returns_issued_token_when_audit_storage_fails(
"store_cashu_transaction", "store_cashu_transaction",
AsyncMock(side_effect=RuntimeError("database unavailable")), AsyncMock(side_effect=RuntimeError("database unavailable")),
) )
critical = Mock()
monkeypatch.setattr(admin.logger, "critical", critical)
monkeypatch.setattr(admin.settings, "primary_mint", mint) monkeypatch.setattr(admin.settings, "primary_mint", mint)
result = await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75)) with pytest.raises(RuntimeError, match="database unavailable"):
await admin.withdraw(Mock(), admin.WithdrawRequest(amount=75))
assert result == {"token": token, "mint_url": mint}
critical.assert_called_once()
@pytest.mark.asyncio @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 from __future__ import annotations
import inspect import inspect
from unittest.mock import AsyncMock, patch import re
import pytest
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -65,8 +63,8 @@ def _source_contains_except_pass(source: str, anchor: str, window: int = 1000) -
if idx < 0: if idx < 0:
return False return False
section = source[idx : idx + window] section = source[idx : idx + window]
has_except = "except Exception:" in section or "except:" in section pattern = r"except(?:\s+Exception)?(?:\s+as\s+\w+)?\s*:\s*pass\b"
return has_except and "pass" in section 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: def test_except_pass_detector_window_is_wide_enough() -> None:
"""REGRESSION GUARD: The existing test_emergency_refund_no_try_except_pass """The detector must catch an except/pass block beyond 500 characters."""
inspects a 500-character window after "emergency_refund = amount". anchor = "emergency_refund = amount"
The except: pass block is ~530 chars after that anchor, so the 500-char source = anchor + (" " * 520) + "except Exception:\n pass"
window misses it entirely — producing a false green.
This test verifies that a 1000-char window (which we use in V-E2/V-E3) assert not _source_contains_except_pass(source, anchor, window=500)
correctly catches the live bug. If this test fails, someone shrank assert _source_contains_except_pass(source, anchor, window=1000)
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)."
)
+4 -7
View File
@@ -1510,11 +1510,8 @@ async def test_credit_balance_msat_unit_not_converted() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_credit_balance_survives_audit_store_failure() -> None: async def test_credit_balance_propagates_audit_store_failure_after_credit() -> None:
"""A failure writing the CashuTransaction history record must not undo the """A final transaction-history failure propagates after committing credit."""
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.)"""
mock_key = Mock() mock_key = Mock()
mock_key.balance = 0 mock_key.balance = 0
mock_key.hashed_key = "test_hash" 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", "routstr.wallet.store_cashu_transaction",
side_effect=Exception("history table locked"), 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 assert mock_session.commit.called