mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 02:54:37 +00:00
fix: return sanitized, specific errors when redeeming an incoming Cashu token fails
This commit is contained in:
+58
-3
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user