diff --git a/src/shade/__init__.py b/src/shade/__init__.py index 6c5a209..4b03c18 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -15,6 +15,8 @@ RateLimitError, ShadeError, SignatureVerificationError, + StellarError, + wrap_stellar_errors, ) from .models import AssetBalance, Balance, Merchant, ShadeObject, Transfer, TransferStatus @@ -40,6 +42,7 @@ "ShadeError", "SignatureVerificationError", "ShadeObject", + "StellarError", "SyncHTTPClient", "Transfer", "TransferStatus", @@ -48,6 +51,7 @@ "environment", "max_retries", "timeout", + "wrap_stellar_errors", ] class _ShadeModule(ModuleType): diff --git a/src/shade/errors.py b/src/shade/errors.py index c9feff5..eb9bf07 100644 --- a/src/shade/errors.py +++ b/src/shade/errors.py @@ -4,7 +4,10 @@ from __future__ import annotations import json -from typing import Any, Optional +from contextlib import contextmanager +from typing import Any, Generator, List, Optional, Tuple + +from stellar_sdk.exceptions import BaseHorizonError, SdkError INVALID_REQUEST_STATUS_CODES = (400, 422) @@ -201,6 +204,225 @@ class NetworkError(ShadeError): """Raised when the SDK cannot complete a network request.""" +OPERATION_SUCCESS_CODE = "op_success" + +# Human-readable descriptions for the Stellar result codes this SDK is most +# likely to surface. Codes absent from these tables fall back to the raw code, +# so an unrecognised code still reaches the caller intact. +TRANSACTION_RESULT_CODE_DESCRIPTIONS: dict[str, str] = { + "tx_failed": "one or more operations in the transaction failed", + "tx_too_early": "the ledger closed before the transaction's minimum time bound", + "tx_too_late": "the ledger closed after the transaction's maximum time bound", + "tx_missing_operation": "the transaction contained no operations", + "tx_bad_seq": "the sequence number does not match the source account", + "tx_bad_auth": "too few valid signatures, or the wrong network was used", + "tx_bad_auth_extra": "the transaction carries signatures that were not needed", + "tx_insufficient_balance": "the fee would drop the source account below its minimum reserve", + "tx_insufficient_fee": "the fee offered is below the network minimum", + "tx_no_source_account": "the source account does not exist on the network", + "tx_internal_error": "Horizon reported an unknown internal error", + "tx_not_supported": "the network does not support this transaction", + "tx_fee_bump_inner_failed": "the inner transaction of the fee bump failed", +} + +OPERATION_RESULT_CODE_DESCRIPTIONS: dict[str, str] = { + "op_malformed": "the operation is malformed", + "op_underfunded": "the source account does not hold enough of the asset", + "op_src_no_trust": "the source account has no trustline for the asset", + "op_src_not_authorized": "the source account is not authorized to send the asset", + "op_no_destination": "the destination account does not exist on the network", + "op_no_trust": "the destination account has no trustline for the asset", + "op_not_authorized": "the destination account is not authorized to hold the asset", + "op_line_full": "the transfer would exceed the destination account's trustline limit", + "op_no_issuer": "the issuer of the asset does not exist", + "op_low_reserve": "the resulting account would fall below its minimum reserve", + "op_bad_auth": "the operation was not signed by enough authorized signers", + "op_no_account": "the source account of the operation does not exist", + "op_not_supported": "the network does not support this operation", + "change_trust_no_issuer": "the issuer of the asset does not exist", + "change_trust_invalid_limit": "the requested trustline limit is invalid", + "change_trust_low_reserve": "the account cannot cover the reserve for a new trustline", + "change_trust_self_not_allowed": "an account cannot create a trustline to itself", +} + + +class StellarError(ShadeError): + """Raised when a Stellar/Horizon call fails. + + Wraps the underlying ``stellar_sdk`` exception so callers keep access to the + raw error while still handling a single SDK exception type. Build one from a + caught ``stellar_sdk`` exception with :meth:`from_exception`, or let + :func:`wrap_stellar_errors` do it for a whole block. + + Attributes: + stellar_result_code: The transaction-level Horizon result code (e.g. + ``"tx_failed"``, ``"tx_insufficient_fee"``). Falls back to the first + failing operation code when Horizon reported no transaction code, and + is ``None`` when the failure carried no result codes at all. + operation_result_codes: Per-operation result codes exactly as Horizon + ordered them, including any ``"op_success"`` entries. Empty when the + failure was not a rejected transaction. + original_error: The raw ``stellar_sdk`` exception, or ``None`` when the + error was constructed directly. + """ + + def __init__( + self, + message: str, + stellar_result_code: Optional[str] = None, + original_error: Optional[Exception] = None, + status_code: Optional[int] = None, + response_body: Optional[str] = None, + operation_result_codes: Optional[List[str]] = None, + ) -> None: + super().__init__(message, status_code, response_body) + self.stellar_result_code = stellar_result_code + self.original_error = original_error + self.operation_result_codes: List[str] = list(operation_result_codes or []) + + @property + def failed_operation_code(self) -> Optional[str]: + """The first operation result code that is not ``"op_success"``. + + Lets callers branch on the specific failure (``op_no_trust``, + ``op_underfunded``, …) without walking + :attr:`operation_result_codes` themselves. + """ + for code in self.operation_result_codes: + if code != OPERATION_SUCCESS_CODE: + return code + return None + + def __str__(self) -> str: + message = self.message + if self.stellar_result_code: + message = f"{message} (result code: {self.stellar_result_code})" + if self.status_code is None: + return message + return f"{message} (status code: {self.status_code})" + + @classmethod + def from_exception( + cls, + exc: Exception, + message: Optional[str] = None, + ) -> "StellarError": + """Wrap a ``stellar_sdk`` exception, pulling out any Horizon result codes. + + Args: + exc: The caught ``stellar_sdk`` exception. + message: Overrides the message derived from the result codes. Useful + for adding operation context the exception cannot know about + (e.g. "Failed to submit payout txn_123"). + """ + transaction_code, operation_codes = _stellar_result_codes(exc) + status_code, response_body = _horizon_context(exc) + return cls( + message or _stellar_failure_message(exc, transaction_code, operation_codes), + stellar_result_code=transaction_code + or next((c for c in operation_codes if c != OPERATION_SUCCESS_CODE), None), + original_error=exc, + status_code=status_code, + response_body=response_body, + operation_result_codes=operation_codes, + ) + + +@contextmanager +def wrap_stellar_errors(message: Optional[str] = None) -> Generator[None, None, None]: + """Re-raise any ``stellar_sdk`` failure inside the block as :class:`StellarError`. + + The Stellar integration layer wraps its Horizon and Soroban calls with this + so callers only ever have to catch :class:`~shade.errors.ShadeError`:: + + with wrap_stellar_errors("Failed to submit payment"): + server.submit_transaction(transaction) + + Args: + message: Overrides the derived message on the raised ``StellarError``. + The result codes and original exception are attached either way. + """ + try: + yield + except SdkError as exc: + raise StellarError.from_exception(exc, message=message) from exc + + +def _stellar_result_codes(exc: Exception) -> Tuple[Optional[str], List[str]]: + """Return ``(transaction_code, operation_codes)`` from a Horizon error. + + Horizon reports these under ``extras.result_codes``. Every level is + type-checked rather than assumed, so a malformed or partial error body + degrades to "no codes" instead of raising while building an exception. + """ + extras = getattr(exc, "extras", None) + if not isinstance(extras, dict): + return None, [] + result_codes = extras.get("result_codes") + if not isinstance(result_codes, dict): + return None, [] + + transaction = result_codes.get("transaction") + operations = result_codes.get("operations") + return ( + transaction if isinstance(transaction, str) else None, + [code for code in operations if isinstance(code, str)] + if isinstance(operations, list) + else [], + ) + + +def _horizon_context(exc: Exception) -> Tuple[Optional[int], Optional[str]]: + """Return ``(status_code, response_body)`` for a Horizon error, else ``(None, None)``.""" + if not isinstance(exc, BaseHorizonError): + return None, None + status = getattr(exc, "status", None) + body = getattr(exc, "message", None) + return ( + status if isinstance(status, int) else None, + body if isinstance(body, str) else None, + ) + + +def _stellar_failure_message( + exc: Exception, + transaction_code: Optional[str], + operation_codes: List[str], +) -> str: + """Build a human-readable message for a Stellar failure. + + Prefers the most specific signal available: a failing operation code first + (that is what actually went wrong), then the transaction code, then whatever + Horizon or the exception itself described. + """ + operation_code = next( + (code for code in operation_codes if code != OPERATION_SUCCESS_CODE), None + ) + if operation_code is not None: + description = OPERATION_RESULT_CODE_DESCRIPTIONS.get(operation_code) + if description: + return f"Stellar transaction failed: {description} ({operation_code})" + return f"Stellar transaction failed: {operation_code}" + + if transaction_code is not None: + description = TRANSACTION_RESULT_CODE_DESCRIPTIONS.get(transaction_code) + return f"Stellar transaction failed: {description or transaction_code}" + + account_id = getattr(exc, "account_id", None) + if account_id: + return f"Stellar account {account_id} does not exist on the network" + + title = getattr(exc, "title", None) + detail = getattr(exc, "detail", None) + if title and detail: + return f"Stellar request failed: {title} - {detail}" + if title or detail: + return f"Stellar request failed: {title or detail}" + + text = str(exc).strip() + return f"Stellar request failed: {text or type(exc).__name__}" + + def raise_for_invalid_request( status_code: int, response_body: Optional[str] = None, diff --git a/tests/test_stellar_error.py b/tests/test_stellar_error.py new file mode 100644 index 0000000..a6c9a3e --- /dev/null +++ b/tests/test_stellar_error.py @@ -0,0 +1,347 @@ +""" +Tests for StellarError and the wrap_stellar_errors helper (issue #22). + +Acceptance criteria covered: +* A rejected Stellar transaction raises StellarError carrying the Horizon + result code. +* ``error.original_error`` exposes the raw ``stellar_sdk`` exception. +* A missing trustline surfaces as StellarError with a descriptive message. +""" +from __future__ import annotations + +import httpx +import pytest +from stellar_sdk.exceptions import ( + AccountNotFoundException, + BadRequestError, + Ed25519PublicKeyInvalidError, + NotFoundError as HorizonNotFoundError, +) + +import shade +from shade.errors import ShadeError, StellarError, wrap_stellar_errors + +DESTINATION = "GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ" + + +def _horizon_error( + error_cls=BadRequestError, + *, + status: int = 400, + result_codes: dict | None = None, + extras: dict | None = None, + title: str | None = "Transaction Failed", + detail: str | None = "The transaction failed when submitted to the Stellar network.", +): + """Build a real stellar_sdk Horizon exception from a synthetic response.""" + body: dict = { + "type": "https://stellar.org/horizon-errors/transaction_failed", + "title": title, + "status": status, + "detail": detail, + } + if extras is not None: + body["extras"] = extras + elif result_codes is not None: + body["extras"] = {"result_codes": result_codes} + return error_cls(httpx.Response(status_code=status, json=body)) + + +# --------------------------------------------------------------------------- +# Rejected transactions +# --------------------------------------------------------------------------- + +class TestRejectedTransaction: + def test_exposes_horizon_transaction_result_code(self): + exc = _horizon_error( + result_codes={"transaction": "tx_insufficient_fee", "operations": []} + ) + + error = StellarError.from_exception(exc) + + assert error.stellar_result_code == "tx_insufficient_fee" + assert error.message == ( + "Stellar transaction failed: the fee offered is below the network minimum" + ) + + def test_exposes_operation_result_codes(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_underfunded"]} + ) + + error = StellarError.from_exception(exc) + + assert error.stellar_result_code == "tx_failed" + assert error.operation_result_codes == ["op_underfunded"] + assert error.failed_operation_code == "op_underfunded" + + def test_failed_operation_code_skips_successful_operations(self): + exc = _horizon_error( + result_codes={ + "transaction": "tx_failed", + "operations": ["op_success", "op_success", "op_line_full"], + } + ) + + error = StellarError.from_exception(exc) + + assert error.failed_operation_code == "op_line_full" + assert error.operation_result_codes == ["op_success", "op_success", "op_line_full"] + + def test_carries_horizon_status_and_raw_body(self): + exc = _horizon_error( + result_codes={"transaction": "tx_bad_seq", "operations": []} + ) + + error = StellarError.from_exception(exc) + + assert error.status_code == 400 + assert "tx_bad_seq" in error.response_body + + def test_str_includes_result_code_and_status(self): + exc = _horizon_error( + result_codes={"transaction": "tx_bad_auth", "operations": []} + ) + + error = StellarError.from_exception(exc) + + assert str(error) == ( + "Stellar transaction failed: too few valid signatures, or the wrong " + "network was used (result code: tx_bad_auth) (status code: 400)" + ) + + def test_falls_back_to_operation_code_when_transaction_code_absent(self): + exc = _horizon_error(result_codes={"operations": ["op_no_destination"]}) + + error = StellarError.from_exception(exc) + + assert error.stellar_result_code == "op_no_destination" + + +# --------------------------------------------------------------------------- +# Descriptive messages +# --------------------------------------------------------------------------- + +class TestDescriptiveMessages: + def test_missing_trustline_is_described(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_no_trust"]} + ) + + error = StellarError.from_exception(exc) + + assert error.message == ( + "Stellar transaction failed: the destination account has no trustline " + "for the asset (op_no_trust)" + ) + assert error.failed_operation_code == "op_no_trust" + + def test_source_missing_trustline_is_described(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_src_no_trust"]} + ) + + error = StellarError.from_exception(exc) + + assert "source account has no trustline" in error.message + + def test_operation_failure_takes_precedence_over_transaction_code(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_underfunded"]} + ) + + error = StellarError.from_exception(exc) + + assert "does not hold enough of the asset" in error.message + assert "one or more operations" not in error.message + + def test_unrecognised_operation_code_is_passed_through_raw(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_brand_new"]} + ) + + error = StellarError.from_exception(exc) + + assert error.message == "Stellar transaction failed: op_brand_new" + assert error.failed_operation_code == "op_brand_new" + + def test_unrecognised_transaction_code_is_passed_through_raw(self): + exc = _horizon_error(result_codes={"transaction": "tx_brand_new"}) + + error = StellarError.from_exception(exc) + + assert error.message == "Stellar transaction failed: tx_brand_new" + + def test_explicit_message_overrides_derived_one(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_no_trust"]} + ) + + error = StellarError.from_exception(exc, message="Failed to submit payout txn_1") + + assert error.message == "Failed to submit payout txn_1" + assert error.stellar_result_code == "tx_failed" + assert error.failed_operation_code == "op_no_trust" + + def test_horizon_error_without_result_codes_uses_title_and_detail(self): + exc = _horizon_error( + HorizonNotFoundError, + status=404, + title="Resource Missing", + detail="The resource at the url requested was not found.", + ) + + error = StellarError.from_exception(exc) + + assert error.message == ( + "Stellar request failed: Resource Missing - The resource at the url " + "requested was not found." + ) + assert error.stellar_result_code is None + assert error.status_code == 404 + + def test_account_not_found_names_the_account(self): + exc = AccountNotFoundException(DESTINATION) + + error = StellarError.from_exception(exc) + + assert error.message == ( + f"Stellar account {DESTINATION} does not exist on the network" + ) + + def test_non_horizon_sdk_error_uses_its_own_text(self): + exc = Ed25519PublicKeyInvalidError("Invalid Ed25519 Public Key: GBAD") + + error = StellarError.from_exception(exc) + + assert error.message == ( + "Stellar request failed: Invalid Ed25519 Public Key: GBAD" + ) + assert error.stellar_result_code is None + assert error.status_code is None + assert error.response_body is None + + +# --------------------------------------------------------------------------- +# Access to the underlying exception +# --------------------------------------------------------------------------- + +class TestOriginalError: + def test_original_error_is_the_raw_stellar_exception(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_no_trust"]} + ) + + error = StellarError.from_exception(exc) + + assert error.original_error is exc + assert isinstance(error.original_error, BadRequestError) + assert error.original_error.extras["result_codes"]["operations"] == ["op_no_trust"] + + def test_original_error_is_none_when_constructed_directly(self): + error = StellarError("contract call reverted") + + assert error.original_error is None + assert error.stellar_result_code is None + assert error.operation_result_codes == [] + assert error.failed_operation_code is None + assert str(error) == "contract call reverted" + + +# --------------------------------------------------------------------------- +# Malformed Horizon payloads +# --------------------------------------------------------------------------- + +class TestMalformedPayloads: + def test_missing_extras_yields_no_result_codes(self): + exc = _horizon_error() + + error = StellarError.from_exception(exc) + + assert error.stellar_result_code is None + assert error.operation_result_codes == [] + + def test_non_dict_result_codes_are_ignored(self): + exc = _horizon_error(extras={"result_codes": "tx_failed"}) + + error = StellarError.from_exception(exc) + + assert error.stellar_result_code is None + assert error.operation_result_codes == [] + + def test_non_list_operations_are_ignored(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": "op_no_trust"} + ) + + error = StellarError.from_exception(exc) + + assert error.stellar_result_code == "tx_failed" + assert error.operation_result_codes == [] + + def test_non_string_operation_entries_are_dropped(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": [None, "op_no_trust"]} + ) + + error = StellarError.from_exception(exc) + + assert error.operation_result_codes == ["op_no_trust"] + + +# --------------------------------------------------------------------------- +# wrap_stellar_errors +# --------------------------------------------------------------------------- + +class TestWrapStellarErrors: + def test_converts_stellar_exception_and_chains_it(self): + exc = _horizon_error( + result_codes={"transaction": "tx_failed", "operations": ["op_no_trust"]} + ) + + with pytest.raises(StellarError) as excinfo: + with wrap_stellar_errors(): + raise exc + + error = excinfo.value + assert error.original_error is exc + assert error.__cause__ is exc + assert error.stellar_result_code == "tx_failed" + + def test_message_argument_overrides_derived_message(self): + exc = _horizon_error(result_codes={"transaction": "tx_bad_seq"}) + + with pytest.raises(StellarError) as excinfo: + with wrap_stellar_errors("Failed to submit transfer"): + raise exc + + assert excinfo.value.message == "Failed to submit transfer" + assert excinfo.value.stellar_result_code == "tx_bad_seq" + + def test_non_stellar_exceptions_pass_through_untouched(self): + with pytest.raises(RuntimeError, match="boom"): + with wrap_stellar_errors(): + raise RuntimeError("boom") + + def test_block_without_errors_is_transparent(self): + with wrap_stellar_errors(): + result = "submitted" + + assert result == "submitted" + + +# --------------------------------------------------------------------------- +# Integration with the rest of the SDK +# --------------------------------------------------------------------------- + +class TestSdkIntegration: + def test_stellar_error_is_a_shade_error(self): + exc = _horizon_error(result_codes={"transaction": "tx_failed"}) + + with pytest.raises(ShadeError): + with wrap_stellar_errors(): + raise exc + + def test_exported_from_the_top_level_package(self): + assert shade.StellarError is StellarError + assert shade.wrap_stellar_errors is wrap_stellar_errors