fix: return sanitized, specific errors when redeeming an incoming Cashu token fails

This commit is contained in:
9qeklajc
2026-07-02 00:47:56 +02:00
parent b7fcf000af
commit 949dc433f1
2 changed files with 157 additions and 3 deletions
+58 -3
View File
@@ -78,6 +78,51 @@ async def check_and_reset_limit(key: ApiKey, session: AsyncSession) -> bool:
return False
def redemption_error_to_http_exception(error: Exception) -> HTTPException:
"""Map a Cashu token redemption failure to a sanitized client-facing error.
Known redemption failure patterns (from the wallet or the mint) and expected
wallet errors (ValueError) map to 400/401 with a stable message under a
single "token_redemption_failed" code. Anything else is an internal fault
and maps to a generic 500. Raw error text never reaches the client — it
stays in server logs.
"""
lowered = str(error).lower()
status_code = 400
if "already spent" in lowered:
message = "Cashu token already spent"
elif "insufficient" in lowered or "melt fee" in lowered:
message = "Token value is too small to cover swap fees"
elif "failed to melt" in lowered:
message = "Failed to swap token from foreign mint"
elif "invalid" in lowered or "decode" in lowered:
message = "Invalid Cashu token"
status_code = 401
elif isinstance(error, ValueError):
message = "Failed to redeem Cashu token"
else:
return HTTPException(
status_code=500,
detail={
"error": {
"message": "Internal error during token redemption",
"type": "api_error",
"code": "internal_error",
}
},
)
return HTTPException(
status_code=status_code,
detail={
"error": {
"message": message,
"type": "invalid_request_error",
"code": "token_redemption_failed",
}
},
)
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
@@ -334,7 +379,8 @@ async def validate_bearer_key(
"error_type": type(credit_error).__name__,
},
)
raise credit_error
await session.rollback()
raise redemption_error_to_http_exception(credit_error) from credit_error
if msats <= 0:
logger.error(
@@ -346,7 +392,16 @@ async def validate_bearer_key(
# persisted, drop it so we never leave an orphan zero-balance key.
await session.delete(new_key)
await session.commit()
raise Exception("Token redemption failed")
raise HTTPException(
status_code=400,
detail={
"error": {
"message": "Failed to redeem Cashu token: token yielded no value",
"type": "invalid_request_error",
"code": "token_redemption_failed",
}
},
)
await session.refresh(new_key)
await session.commit()
@@ -379,7 +434,7 @@ async def validate_bearer_key(
status_code=401,
detail={
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"message": "Invalid or expired Cashu key",
"type": "invalid_request_error",
"code": "invalid_api_key",
}
+99
View File
@@ -57,3 +57,102 @@ async def test_failed_first_cashu_redemption_rolls_back_empty_api_key(
await validate_bearer_key(token, session)
assert await session.get(ApiKey, hashed_key) is None
@pytest.mark.parametrize(
("error", "expected_status", "expected_message"),
[
(
ValueError("Mint Error: Token already spent. (Code: 11001)"),
400,
"Cashu token already spent",
),
(
ValueError(
"Token amount (5 sat) is insufficient to cover melt fees. "
"Needed: 7 sat (amount: 5 + fee: 1 + input_fees: 1)"
),
400,
"Token value is too small to cover swap fees",
),
(
ValueError(
"Failed to melt token from foreign mint http://foreign:3338: boom"
),
400,
"Failed to swap token from foreign mint",
),
(
ValueError("could not decode token"),
401,
"Invalid Cashu token",
),
(
ValueError("some unexpected wallet condition"),
400,
"Failed to redeem Cashu token",
),
],
)
@pytest.mark.asyncio
async def test_redemption_failure_returns_sanitized_error(
session: AsyncSession,
error: Exception,
expected_status: int,
expected_message: str,
) -> None:
"""Redemption failures share one error code, expose stable sanitized
messages (no raw exception text), and leave no orphan ApiKey row."""
token = "cashuAredemption_fails_with_specific_error"
hashed_key = hashlib.sha256(token.encode()).hexdigest()
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
from routstr.core.settings import settings
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
patch(
"routstr.auth.credit_balance",
new=AsyncMock(side_effect=error),
),
):
with pytest.raises(HTTPException) as exc_info:
await validate_bearer_key(token, session)
assert exc_info.value.status_code == expected_status
error_detail = exc_info.value.detail["error"]
assert error_detail["code"] == "token_redemption_failed"
assert error_detail["message"] == expected_message
assert str(error) not in error_detail["message"]
assert await session.get(ApiKey, hashed_key) is None
@pytest.mark.asyncio
async def test_unexpected_redemption_error_returns_internal_error(
session: AsyncSession,
) -> None:
"""Unexpected (non-wallet) failures surface as generic 500s without
leaking internal details, instead of masquerading as token errors."""
token = "cashuAredemption_fails_with_internal_error"
hashed_key = hashlib.sha256(token.encode()).hexdigest()
token_obj = SimpleNamespace(mint="http://mint:3338", unit="sat")
from routstr.core.settings import settings
with (
patch.object(settings, "cashu_mints", ["http://mint:3338"]),
patch("routstr.auth.deserialize_token_from_string", return_value=token_obj),
patch(
"routstr.auth.credit_balance",
new=AsyncMock(side_effect=RuntimeError("db exploded at /var/lib/secret")),
),
):
with pytest.raises(HTTPException) as exc_info:
await validate_bearer_key(token, session)
assert exc_info.value.status_code == 500
error_detail = exc_info.value.detail["error"]
assert error_detail["code"] == "internal_error"
assert "/var/lib/secret" not in error_detail["message"]
assert await session.get(ApiKey, hashed_key) is None