Compare commits

..
Author SHA1 Message Date
9qeklajc a60b04aea0 fix: propagate Cashu transaction storage failures 2026-08-06 23:35:37 +02:00
thefux 667f9bf6bb test: money-path audit — 8 RED tests for live fund-loss vulnerabilities
Comprehensive audit of all money-moving code paths on current main.
Found 8 live vulnerabilities where users, providers, or node runners
can lose funds, plus 1 false-green in the existing emergency refund
test suite.

Live vulnerabilities (all RED — tests assert correct/safe behaviour):

V-E1  send_refund() swallows DB failure after minting a refund token
      base.py ~line 3625 — except Exception: pass
V-E2  Emergency refund (chat) — same except: pass
      base.py ~line 3992 (existing test is a false green — 500-char
      window too short)
V-E3  Emergency refund (responses API) — identical pattern
      base.py ~line 4972
V-E4  Balance refund endpoint swallows DB failure
      balance.py ~line 628
V-E5  credit_balance() swallows 'in' transaction DB failure
      wallet.py ~line 1715
V-E6  EHBP refund token — except: pass after store
      ehbp.py ~line 762
V-E7  EHBP 'in' transaction — except: pass after store
      ehbp.py ~line 1028
V-E8  Admin withdraw returns token even when DB store fails
      admin.py ~line 475

V-E9  Window regression guard (GREEN) — documents the false-green in
      the existing test_emergency_refund_no_try_except_pass

Test results: 8 failed, 1 passed.
2026-08-06 20:58:06 +00:00
10 changed files with 471 additions and 261 deletions
+6 -46
View File
@@ -1,53 +1,13 @@
# Git and repository metadata
.env
.venv
.git
.gitignore
.dockerignore
compose.yml
compose.testing.yml
.todo
.github
.vscode
.DS_Store
.todo
# Local configuration and secrets
**/.env
**/.env.*
**/routstr_secret.key
# Python environments, caches, and build artifacts
**/.venv
**/__pycache__
**/*.py[cod]
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.coverage
**/htmlcov
**/*.egg-info
**/build
**/dist
# Runtime state must never be baked into the image
logs
logs.*
**/*.log
**/.wallet*
**/.cashu
**/*.db
**/*.db-*
**/*.sqlite3
**/*.sqlite3-*
**/keys
**/proof_backups
**/relay-data
# The UI output is built by its own service and mounted at runtime
ui_out
ui/.next
ui/out
**/node_modules
# Compose and local development files
compose.yml
compose.testing.yml
compose.override.yml
plans
.worktrees
ui/.next
+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
-48
View File
@@ -1,48 +0,0 @@
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_backend_docker_context_excludes_generated_and_runtime_state() -> None:
patterns = {
line.strip()
for line in (REPO_ROOT / ".dockerignore").read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
required_patterns = {
"**/.env",
"**/.env.*",
"**/routstr_secret.key",
"**/.venv",
"**/__pycache__",
"**/*.py[cod]",
"**/.pytest_cache",
"**/.mypy_cache",
"**/.ruff_cache",
"**/.coverage",
"**/htmlcov",
"**/*.egg-info",
"**/build",
"**/dist",
"logs",
"logs.*",
"**/*.log",
"**/.wallet*",
"**/.cashu",
"**/*.db",
"**/*.db-*",
"**/*.sqlite3",
"**/*.sqlite3-*",
"**/keys",
"**/proof_backups",
"**/relay-data",
"ui_out",
"ui/.next",
"ui/out",
}
assert required_patterns <= patterns, (
"The backend Docker context must exclude local build artifacts and "
f"runtime state; missing patterns: {sorted(required_patterns - patterns)}"
)
+342
View File
@@ -0,0 +1,342 @@
"""RED tests for live money-loss vulnerabilities in routstr-core.
Every test in this file asserts the CORRECT (safe) behaviour for a money
path where a user, provider, or node runner can lose funds. Each test
FAILS against current ``main`` because the code is buggy — they are the
"RED" phase of TDD. Once the underlying bugs are fixed, they go green.
== Vulnerability summary (all LIVE on main as of 2026-08-06) ==
V-E1 send_refund() swallows DB failure after minting a refund token
base.py ~line 3625 — except Exception: pass
Impact: refund token is minted at the Cashu mint but never recorded
in the DB. The user receives the token in the X-Cashu header, but
if they lose it (or it never arrives) the refund endpoint cannot
look it up → permanently unrecoverable funds.
V-E2 Emergency refund (chat) — same except: pass, false-green in the
existing test_emergency_refund_no_try_except_pass because the
500-char inspection window is too short to reach the except block.
base.py ~line 3992
V-E3 Emergency refund (responses API) — identical pattern.
base.py ~line 4972
V-E4 Balance refund endpoint swallows DB failure.
balance.py ~line 628 — except Exception: pass
V-E5 credit_balance() swallows "in" transaction DB failure.
wallet.py ~line 1715 — except Exception: pass
Impact: token is redeemed and balance credited, but no "in" audit
row is stored. The refund endpoint matches "in""out" by
request_id; a missing "in" row breaks that linkage.
V-E6 EHBP refund token — except: pass after store.
ehbp.py ~line 762
V-E7 EHBP "in" transaction — except: pass after store.
ehbp.py ~line 1028
V-E8 Admin withdraw returns the token to the caller even when the DB
store fails. The token is delivered but there is no audit trail.
admin.py ~line 475
V-E9 Existing emergency-refund tests use a 500-char source window that
is too short to reach the except: pass block, producing a false
green. This test verifies the window is wide enough.
"""
from __future__ import annotations
import inspect
import re
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _source_contains_except_pass(source: str, anchor: str, window: int = 1000) -> bool:
"""Return True if an ``except Exception: pass`` (or bare ``except: pass``)
appears within ``window`` characters after ``anchor`` in ``source``."""
idx = source.find(anchor)
if idx < 0:
return False
section = source[idx : idx + window]
pattern = r"except(?:\s+Exception)?(?:\s+as\s+\w+)?\s*:\s*pass\b"
return re.search(pattern, section) is not None
# ===========================================================================
# V-E1: send_refund() must not silently swallow DB write failure
# ===========================================================================
def test_send_refund_no_except_pass_after_store() -> None:
"""FIX REQUIRED: send_refund() mints a refund token then uses
try/except/pass around store_cashu_transaction.
If the DB write fails the token exists at the mint but is never
recorded. The refund endpoint cannot find it and the user's funds
are permanently lost.
Correct behaviour: let the exception propagate, or at minimum log
at CRITICAL with the full token string so an operator can manually
recover it. Never silently pass.
"""
from routstr.upstream.base import BaseUpstreamProvider
src = inspect.getsource(BaseUpstreamProvider.send_refund)
assert not _source_contains_except_pass(
src, "store_cashu_transaction"
), (
"FIX REQUIRED: send_refund() uses try/except/pass around "
"store_cashu_transaction (base.py ~line 3625). A failed DB write "
"after the token is minted permanently loses the refund token. "
"Fix: propagate the exception or log CRITICAL with the full token."
)
# ===========================================================================
# V-E2: Emergency refund (chat) — except: pass is LIVE (existing test is
# a false green because its 500-char window is too short)
# ===========================================================================
def test_emergency_refund_chat_no_silent_db_failure() -> None:
"""FIX REQUIRED: The chat emergency refund path (JSON parse error)
uses try/except/pass around store_cashu_transaction after minting a
refund token via send_token().
The existing test_emergency_refund_no_try_except_pass passes because
it only inspects a 500-char window — too short to reach the except
block. This test uses a wider window and correctly fails.
"""
from routstr.upstream.base import BaseUpstreamProvider
src = inspect.getsource(
BaseUpstreamProvider.handle_x_cashu_non_streaming_response
)
assert not _source_contains_except_pass(
src, "emergency_refund = amount", window=1000
), (
"FIX REQUIRED: Emergency refund (chat, base.py ~line 3992) uses "
"try/except/pass around store_cashu_transaction after minting a "
"refund token. A failed DB write permanently loses the token. "
"Fix: propagate the exception or log CRITICAL with the full token."
)
# ===========================================================================
# V-E3: Emergency refund (responses API) — identical pattern
# ===========================================================================
def test_emergency_refund_responses_no_silent_db_failure() -> None:
"""FIX REQUIRED: Same except: pass pattern in the Responses API
emergency refund path (base.py ~line 4972)."""
from routstr.upstream.base import BaseUpstreamProvider
src = inspect.getsource(
BaseUpstreamProvider.handle_x_cashu_non_streaming_responses_response
)
assert not _source_contains_except_pass(
src, "emergency_refund = amount", window=1000
), (
"FIX REQUIRED: Emergency refund (responses API, base.py ~line 4972) "
"uses try/except/pass around store_cashu_transaction after minting "
"a refund token. Same fund-loss vulnerability as the chat path."
)
# ===========================================================================
# V-E4: Balance refund endpoint — except: pass
# ===========================================================================
def test_balance_refund_endpoint_no_silent_db_failure() -> None:
"""FIX REQUIRED: The /v1/wallet/refund endpoint mints a refund token
via send_token() then uses try/except/pass around
store_cashu_transaction (balance.py ~line 628).
A failed DB write means the token is delivered to the user but never
recorded — the refund sweep cannot reclaim it and the audit trail
is broken.
"""
from routstr import balance
src = inspect.getsource(balance.refund_wallet_endpoint)
assert not _source_contains_except_pass(
src, "store_cashu_transaction"
), (
"FIX REQUIRED: refund_wallet_endpoint (balance.py ~line 628) uses "
"try/except/pass around store_cashu_transaction. A failed DB write "
"loses the audit record for the minted refund token."
)
# ===========================================================================
# V-E5: credit_balance() — "in" transaction except: pass
# ===========================================================================
def test_credit_balance_no_silent_db_failure_for_in_tx() -> None:
"""FIX REQUIRED: credit_balance() redeems a Cashu token and credits
the user's balance, then uses try/except/pass around
store_cashu_transaction for the "in" record (wallet.py ~line 1715).
The token is already spent at the mint. If the "in" DB record is
not stored, the refund endpoint cannot match "in""out" by
request_id. The funds are credited but the audit/reconciliation
chain is broken.
"""
from routstr import wallet
# credit_balance delegates to _credit_balance_locked
src = inspect.getsource(wallet._credit_balance_locked)
assert not _source_contains_except_pass(
src, "store_cashu_transaction"
), (
"FIX REQUIRED: _credit_balance_locked (wallet.py ~line 1715) uses "
"try/except/pass around store_cashu_transaction for the 'in' "
"record. A failed DB write breaks the in→out refund linkage."
)
# ===========================================================================
# V-E6: EHBP refund token — except: pass
# ===========================================================================
def test_ehbp_refund_no_silent_db_failure() -> None:
"""FIX REQUIRED: The EHBP refund helper mints a refund token via
send_token() then uses try/except/pass around
store_cashu_transaction (ehbp.py ~line 762)."""
from routstr.upstream import ehbp
# Find the function that creates a refund token
refund_fn = None
for name in dir(ehbp):
obj = getattr(ehbp, name)
if inspect.iscoroutinefunction(obj) and hasattr(obj, "__code__"):
try:
src = inspect.getsource(obj)
if "send_token" in src and "store_cashu_transaction" in src and "typ=\"out\"" in src:
refund_fn = obj
break
except (OSError, TypeError):
continue
assert refund_fn is not None, "Could not locate EHBP refund function"
src = inspect.getsource(refund_fn)
assert not _source_contains_except_pass(
src, "store_cashu_transaction"
), (
"FIX REQUIRED: EHBP refund helper (ehbp.py ~line 762) uses "
"try/except/pass around store_cashu_transaction after minting a "
"refund token. Same fund-loss vulnerability as base.py paths."
)
# ===========================================================================
# V-E7: EHBP "in" transaction — except: pass
# ===========================================================================
def test_ehbp_in_transaction_no_silent_db_failure() -> None:
"""FIX REQUIRED: The EHBP receive path redeems a token then uses
try/except/pass around store_cashu_transaction for the "in" record
(ehbp.py ~line 1028)."""
from routstr.upstream import ehbp
receive_fn = None
for name in dir(ehbp):
obj = getattr(ehbp, name)
if inspect.iscoroutinefunction(obj) and hasattr(obj, "__code__"):
try:
src = inspect.getsource(obj)
if "recieve_token" in src and "store_cashu_transaction" in src and "typ=\"in\"" in src:
receive_fn = obj
break
except (OSError, TypeError):
continue
assert receive_fn is not None, "Could not locate EHBP receive function"
src = inspect.getsource(receive_fn)
assert not _source_contains_except_pass(
src, "store_cashu_transaction"
), (
"FIX REQUIRED: EHBP receive path (ehbp.py ~line 1028) uses "
"try/except/pass around store_cashu_transaction for the 'in' "
"record. A failed DB write breaks the audit trail."
)
# ===========================================================================
# V-E8: Admin withdraw must not return token when DB store fails
# ===========================================================================
def test_admin_withdraw_must_not_return_token_on_db_failure() -> None:
"""FIX REQUIRED: The admin withdraw endpoint mints a token via
send_token(), then tries to store it. If the store fails it logs
CRITICAL but STILL RETURNS THE TOKEN to the caller (admin.py ~line
475).
The token is delivered (admin gets their money) but there is no
audit trail — if the admin later loses the token, there is no DB
record to reclaim it from. Worse, the "out" row is missing so
reconciliation is impossible.
Correct behaviour: if the DB store fails, the token should NOT be
returned to the caller. Instead, raise an error so the admin knows
the withdrawal failed and can retry.
"""
import inspect
from routstr.core import admin
src = inspect.getsource(admin.withdraw)
# Find the store_cashu_transaction section
store_idx = src.find("store_cashu_transaction")
assert store_idx > 0, "withdraw endpoint must store the token"
# The section from store_cashu_transaction to the return statement
# must NOT contain "return" before the except block — i.e. the
# function must not return the token if the store raised.
# Currently the code does:
# try:
# await store_cashu_transaction(...)
# except Exception:
# logger.critical(...)
# return {"token": token, ...}
#
# The return is AFTER the except, meaning the token is returned even
# when the store failed. The fix: re-raise or return an error
# response inside the except block, before the return.
section = src[store_idx:]
# Check that there is a "return" after the except block (the bug)
return_after_except = (
"except Exception:" in section and "return" in section.split("except Exception:")[-1]
)
assert not return_after_except, (
"FIX REQUIRED: admin.withdraw (admin.py ~line 475) returns the "
"token to the caller even when store_cashu_transaction fails. "
"A failed DB write means the token is delivered but has no audit "
"trail. Fix: re-raise or return an error response inside the "
"except block — do not return the token."
)
# ===========================================================================
# V-E9: Existing emergency refund test window is too short (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"
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