diff --git a/routstr/core/admin.py b/routstr/core/admin.py index c6738efc..09f14f98 100644 --- a/routstr/core/admin.py +++ b/routstr/core/admin.py @@ -434,15 +434,25 @@ async def withdraw( token = await send_token( withdraw_request.amount, withdraw_request.unit, effective_mint ) - await store_cashu_transaction( - token=token, - amount=withdraw_request.amount, - unit=withdraw_request.unit, - mint_url=effective_mint, - typ="out", - collected=False, - source="admin", - ) + try: + await store_cashu_transaction( + token=token, + amount=withdraw_request.amount, + unit=withdraw_request.unit, + mint_url=effective_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": effective_mint, + }, + ) return {"token": token} diff --git a/routstr/core/db.py b/routstr/core/db.py index 5654a5cc..38b76f46 100644 --- a/routstr/core/db.py +++ b/routstr/core/db.py @@ -304,13 +304,14 @@ async def store_cashu_transaction( ) session.add(tx) await session.commit() - return True - except Exception as e: - logger.warning( - f"Failed to store cashu transaction: {e} (type={typ})", - extra={"error": str(e), "type": typ}, + except Exception: + logger.critical( + "Failed to store Cashu transaction", + extra={"type": typ, "request_id": request_id, "source": source}, + exc_info=True, ) - return False + raise + return True class UpstreamProviderRow(SQLModel, table=True): # type: ignore diff --git a/routstr/upstream/auto_topup.py b/routstr/upstream/auto_topup.py index 3517be7d..cb1ede96 100644 --- a/routstr/upstream/auto_topup.py +++ b/routstr/upstream/auto_topup.py @@ -142,16 +142,17 @@ async def _check_and_topup(row: UpstreamProviderRow) -> None: ) return - stored = await store_cashu_transaction( - token=token, - amount=amount, - unit="sat", - mint_url=mint_url, - typ="out", - collected=False, - source="auto_topup", - ) - if not stored: + try: + await store_cashu_transaction( + token=token, + amount=amount, + unit="sat", + mint_url=mint_url, + typ="out", + collected=False, + source="auto_topup", + ) + except Exception: logger.critical( "Aborting auto top-up because its cashu token could not be persisted", extra={"provider_id": row.id, "mint_url": mint_url}, diff --git a/routstr/wallet.py b/routstr/wallet.py index 03d45fb8..bd135b69 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -745,11 +745,11 @@ async def credit_balance( ) except Exception: pass - - logger.debug( - "Cashu token successfully redeemed and stored", - 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 except Exception as e: logger.error( diff --git a/tests/unit/test_admin_withdraw.py b/tests/unit/test_admin_withdraw.py index 1c3a1919..e54f7d01 100644 --- a/tests/unit/test_admin_withdraw.py +++ b/tests/unit/test_admin_withdraw.py @@ -49,3 +49,34 @@ async def test_withdraw_uses_effective_mint_and_records_outgoing_transaction( collected=False, source="admin", ) + + +@pytest.mark.asyncio +async def test_withdraw_returns_issued_token_when_audit_storage_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + mint = "https://primary.example" + proofs = [SimpleNamespace(amount=100)] + token = "cashuBrecoverable" + + monkeypatch.setattr(admin, "get_wallet", AsyncMock(return_value=object())) + monkeypatch.setattr( + admin, "get_proofs_per_mint_and_unit", Mock(return_value=proofs) + ) + monkeypatch.setattr( + admin, "slow_filter_spend_proofs", AsyncMock(return_value=proofs) + ) + monkeypatch.setattr(admin, "send_token", AsyncMock(return_value=token)) + monkeypatch.setattr( + admin, + "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} + critical.assert_called_once() diff --git a/tests/unit/test_auto_topup.py b/tests/unit/test_auto_topup.py index c05c85e5..2adb13e6 100644 --- a/tests/unit/test_auto_topup.py +++ b/tests/unit/test_auto_topup.py @@ -136,7 +136,7 @@ async def test_auto_topup_does_not_send_untracked_token() -> None: ), patch( "routstr.upstream.auto_topup.store_cashu_transaction", - AsyncMock(return_value=False), + AsyncMock(side_effect=RuntimeError("database unavailable")), ), ): await _check_and_topup(_row()) diff --git a/tests/unit/test_cashu_transaction_storage_errors.py b/tests/unit/test_cashu_transaction_storage_errors.py new file mode 100644 index 00000000..b26b4115 --- /dev/null +++ b/tests/unit/test_cashu_transaction_storage_errors.py @@ -0,0 +1,56 @@ +from unittest.mock import AsyncMock, patch + +import pytest + +from routstr.core.db import store_cashu_transaction + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "error", + [ + OSError("disk full"), + RuntimeError("connection lost"), + ConnectionRefusedError("database unavailable"), + ], +) +async def test_store_cashu_transaction_propagates_commit_errors( + error: Exception, +) -> None: + session = AsyncMock() + session.commit.side_effect = error + session.__aenter__.return_value = session + session.__aexit__.return_value = None + + with ( + patch("routstr.core.db.create_session", return_value=session), + patch("routstr.core.db.logger.critical") as critical, + ): + with pytest.raises(type(error), match=str(error)): + await store_cashu_transaction( + token="cashuAtest", + amount=1_000, + unit="sat", + mint_url="https://mint.example", + typ="out", + request_id="request-1", + ) + + critical.assert_called_once() + + +@pytest.mark.asyncio +async def test_store_cashu_transaction_returns_true_after_commit() -> None: + session = AsyncMock() + session.__aenter__.return_value = session + session.__aexit__.return_value = None + + with patch("routstr.core.db.create_session", return_value=session): + stored = await store_cashu_transaction( + token="cashuAtest", + amount=1_000, + unit="sat", + ) + + assert stored is True + session.commit.assert_awaited_once()