mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
fix: propagate Cashu transaction storage failures
This commit is contained in:
+10
-13
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)."
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user