|
4 | 4 | from __future__ import annotations |
5 | 5 |
|
6 | 6 | import json |
7 | | -from typing import Any, Optional |
| 7 | +from contextlib import contextmanager |
| 8 | +from typing import Any, Generator, List, Optional, Tuple |
| 9 | + |
| 10 | +from stellar_sdk.exceptions import BaseHorizonError, SdkError |
8 | 11 |
|
9 | 12 | INVALID_REQUEST_STATUS_CODES = (400, 422) |
10 | 13 |
|
@@ -201,6 +204,225 @@ class NetworkError(ShadeError): |
201 | 204 | """Raised when the SDK cannot complete a network request.""" |
202 | 205 |
|
203 | 206 |
|
| 207 | +OPERATION_SUCCESS_CODE = "op_success" |
| 208 | + |
| 209 | +# Human-readable descriptions for the Stellar result codes this SDK is most |
| 210 | +# likely to surface. Codes absent from these tables fall back to the raw code, |
| 211 | +# so an unrecognised code still reaches the caller intact. |
| 212 | +TRANSACTION_RESULT_CODE_DESCRIPTIONS: dict[str, str] = { |
| 213 | + "tx_failed": "one or more operations in the transaction failed", |
| 214 | + "tx_too_early": "the ledger closed before the transaction's minimum time bound", |
| 215 | + "tx_too_late": "the ledger closed after the transaction's maximum time bound", |
| 216 | + "tx_missing_operation": "the transaction contained no operations", |
| 217 | + "tx_bad_seq": "the sequence number does not match the source account", |
| 218 | + "tx_bad_auth": "too few valid signatures, or the wrong network was used", |
| 219 | + "tx_bad_auth_extra": "the transaction carries signatures that were not needed", |
| 220 | + "tx_insufficient_balance": "the fee would drop the source account below its minimum reserve", |
| 221 | + "tx_insufficient_fee": "the fee offered is below the network minimum", |
| 222 | + "tx_no_source_account": "the source account does not exist on the network", |
| 223 | + "tx_internal_error": "Horizon reported an unknown internal error", |
| 224 | + "tx_not_supported": "the network does not support this transaction", |
| 225 | + "tx_fee_bump_inner_failed": "the inner transaction of the fee bump failed", |
| 226 | +} |
| 227 | + |
| 228 | +OPERATION_RESULT_CODE_DESCRIPTIONS: dict[str, str] = { |
| 229 | + "op_malformed": "the operation is malformed", |
| 230 | + "op_underfunded": "the source account does not hold enough of the asset", |
| 231 | + "op_src_no_trust": "the source account has no trustline for the asset", |
| 232 | + "op_src_not_authorized": "the source account is not authorized to send the asset", |
| 233 | + "op_no_destination": "the destination account does not exist on the network", |
| 234 | + "op_no_trust": "the destination account has no trustline for the asset", |
| 235 | + "op_not_authorized": "the destination account is not authorized to hold the asset", |
| 236 | + "op_line_full": "the transfer would exceed the destination account's trustline limit", |
| 237 | + "op_no_issuer": "the issuer of the asset does not exist", |
| 238 | + "op_low_reserve": "the resulting account would fall below its minimum reserve", |
| 239 | + "op_bad_auth": "the operation was not signed by enough authorized signers", |
| 240 | + "op_no_account": "the source account of the operation does not exist", |
| 241 | + "op_not_supported": "the network does not support this operation", |
| 242 | + "change_trust_no_issuer": "the issuer of the asset does not exist", |
| 243 | + "change_trust_invalid_limit": "the requested trustline limit is invalid", |
| 244 | + "change_trust_low_reserve": "the account cannot cover the reserve for a new trustline", |
| 245 | + "change_trust_self_not_allowed": "an account cannot create a trustline to itself", |
| 246 | +} |
| 247 | + |
| 248 | + |
| 249 | +class StellarError(ShadeError): |
| 250 | + """Raised when a Stellar/Horizon call fails. |
| 251 | +
|
| 252 | + Wraps the underlying ``stellar_sdk`` exception so callers keep access to the |
| 253 | + raw error while still handling a single SDK exception type. Build one from a |
| 254 | + caught ``stellar_sdk`` exception with :meth:`from_exception`, or let |
| 255 | + :func:`wrap_stellar_errors` do it for a whole block. |
| 256 | +
|
| 257 | + Attributes: |
| 258 | + stellar_result_code: The transaction-level Horizon result code (e.g. |
| 259 | + ``"tx_failed"``, ``"tx_insufficient_fee"``). Falls back to the first |
| 260 | + failing operation code when Horizon reported no transaction code, and |
| 261 | + is ``None`` when the failure carried no result codes at all. |
| 262 | + operation_result_codes: Per-operation result codes exactly as Horizon |
| 263 | + ordered them, including any ``"op_success"`` entries. Empty when the |
| 264 | + failure was not a rejected transaction. |
| 265 | + original_error: The raw ``stellar_sdk`` exception, or ``None`` when the |
| 266 | + error was constructed directly. |
| 267 | + """ |
| 268 | + |
| 269 | + def __init__( |
| 270 | + self, |
| 271 | + message: str, |
| 272 | + stellar_result_code: Optional[str] = None, |
| 273 | + original_error: Optional[Exception] = None, |
| 274 | + status_code: Optional[int] = None, |
| 275 | + response_body: Optional[str] = None, |
| 276 | + operation_result_codes: Optional[List[str]] = None, |
| 277 | + ) -> None: |
| 278 | + super().__init__(message, status_code, response_body) |
| 279 | + self.stellar_result_code = stellar_result_code |
| 280 | + self.original_error = original_error |
| 281 | + self.operation_result_codes: List[str] = list(operation_result_codes or []) |
| 282 | + |
| 283 | + @property |
| 284 | + def failed_operation_code(self) -> Optional[str]: |
| 285 | + """The first operation result code that is not ``"op_success"``. |
| 286 | +
|
| 287 | + Lets callers branch on the specific failure (``op_no_trust``, |
| 288 | + ``op_underfunded``, …) without walking |
| 289 | + :attr:`operation_result_codes` themselves. |
| 290 | + """ |
| 291 | + for code in self.operation_result_codes: |
| 292 | + if code != OPERATION_SUCCESS_CODE: |
| 293 | + return code |
| 294 | + return None |
| 295 | + |
| 296 | + def __str__(self) -> str: |
| 297 | + message = self.message |
| 298 | + if self.stellar_result_code: |
| 299 | + message = f"{message} (result code: {self.stellar_result_code})" |
| 300 | + if self.status_code is None: |
| 301 | + return message |
| 302 | + return f"{message} (status code: {self.status_code})" |
| 303 | + |
| 304 | + @classmethod |
| 305 | + def from_exception( |
| 306 | + cls, |
| 307 | + exc: Exception, |
| 308 | + message: Optional[str] = None, |
| 309 | + ) -> "StellarError": |
| 310 | + """Wrap a ``stellar_sdk`` exception, pulling out any Horizon result codes. |
| 311 | +
|
| 312 | + Args: |
| 313 | + exc: The caught ``stellar_sdk`` exception. |
| 314 | + message: Overrides the message derived from the result codes. Useful |
| 315 | + for adding operation context the exception cannot know about |
| 316 | + (e.g. "Failed to submit payout txn_123"). |
| 317 | + """ |
| 318 | + transaction_code, operation_codes = _stellar_result_codes(exc) |
| 319 | + status_code, response_body = _horizon_context(exc) |
| 320 | + return cls( |
| 321 | + message or _stellar_failure_message(exc, transaction_code, operation_codes), |
| 322 | + stellar_result_code=transaction_code |
| 323 | + or next((c for c in operation_codes if c != OPERATION_SUCCESS_CODE), None), |
| 324 | + original_error=exc, |
| 325 | + status_code=status_code, |
| 326 | + response_body=response_body, |
| 327 | + operation_result_codes=operation_codes, |
| 328 | + ) |
| 329 | + |
| 330 | + |
| 331 | +@contextmanager |
| 332 | +def wrap_stellar_errors(message: Optional[str] = None) -> Generator[None, None, None]: |
| 333 | + """Re-raise any ``stellar_sdk`` failure inside the block as :class:`StellarError`. |
| 334 | +
|
| 335 | + The Stellar integration layer wraps its Horizon and Soroban calls with this |
| 336 | + so callers only ever have to catch :class:`~shade.errors.ShadeError`:: |
| 337 | +
|
| 338 | + with wrap_stellar_errors("Failed to submit payment"): |
| 339 | + server.submit_transaction(transaction) |
| 340 | +
|
| 341 | + Args: |
| 342 | + message: Overrides the derived message on the raised ``StellarError``. |
| 343 | + The result codes and original exception are attached either way. |
| 344 | + """ |
| 345 | + try: |
| 346 | + yield |
| 347 | + except SdkError as exc: |
| 348 | + raise StellarError.from_exception(exc, message=message) from exc |
| 349 | + |
| 350 | + |
| 351 | +def _stellar_result_codes(exc: Exception) -> Tuple[Optional[str], List[str]]: |
| 352 | + """Return ``(transaction_code, operation_codes)`` from a Horizon error. |
| 353 | +
|
| 354 | + Horizon reports these under ``extras.result_codes``. Every level is |
| 355 | + type-checked rather than assumed, so a malformed or partial error body |
| 356 | + degrades to "no codes" instead of raising while building an exception. |
| 357 | + """ |
| 358 | + extras = getattr(exc, "extras", None) |
| 359 | + if not isinstance(extras, dict): |
| 360 | + return None, [] |
| 361 | + result_codes = extras.get("result_codes") |
| 362 | + if not isinstance(result_codes, dict): |
| 363 | + return None, [] |
| 364 | + |
| 365 | + transaction = result_codes.get("transaction") |
| 366 | + operations = result_codes.get("operations") |
| 367 | + return ( |
| 368 | + transaction if isinstance(transaction, str) else None, |
| 369 | + [code for code in operations if isinstance(code, str)] |
| 370 | + if isinstance(operations, list) |
| 371 | + else [], |
| 372 | + ) |
| 373 | + |
| 374 | + |
| 375 | +def _horizon_context(exc: Exception) -> Tuple[Optional[int], Optional[str]]: |
| 376 | + """Return ``(status_code, response_body)`` for a Horizon error, else ``(None, None)``.""" |
| 377 | + if not isinstance(exc, BaseHorizonError): |
| 378 | + return None, None |
| 379 | + status = getattr(exc, "status", None) |
| 380 | + body = getattr(exc, "message", None) |
| 381 | + return ( |
| 382 | + status if isinstance(status, int) else None, |
| 383 | + body if isinstance(body, str) else None, |
| 384 | + ) |
| 385 | + |
| 386 | + |
| 387 | +def _stellar_failure_message( |
| 388 | + exc: Exception, |
| 389 | + transaction_code: Optional[str], |
| 390 | + operation_codes: List[str], |
| 391 | +) -> str: |
| 392 | + """Build a human-readable message for a Stellar failure. |
| 393 | +
|
| 394 | + Prefers the most specific signal available: a failing operation code first |
| 395 | + (that is what actually went wrong), then the transaction code, then whatever |
| 396 | + Horizon or the exception itself described. |
| 397 | + """ |
| 398 | + operation_code = next( |
| 399 | + (code for code in operation_codes if code != OPERATION_SUCCESS_CODE), None |
| 400 | + ) |
| 401 | + if operation_code is not None: |
| 402 | + description = OPERATION_RESULT_CODE_DESCRIPTIONS.get(operation_code) |
| 403 | + if description: |
| 404 | + return f"Stellar transaction failed: {description} ({operation_code})" |
| 405 | + return f"Stellar transaction failed: {operation_code}" |
| 406 | + |
| 407 | + if transaction_code is not None: |
| 408 | + description = TRANSACTION_RESULT_CODE_DESCRIPTIONS.get(transaction_code) |
| 409 | + return f"Stellar transaction failed: {description or transaction_code}" |
| 410 | + |
| 411 | + account_id = getattr(exc, "account_id", None) |
| 412 | + if account_id: |
| 413 | + return f"Stellar account {account_id} does not exist on the network" |
| 414 | + |
| 415 | + title = getattr(exc, "title", None) |
| 416 | + detail = getattr(exc, "detail", None) |
| 417 | + if title and detail: |
| 418 | + return f"Stellar request failed: {title} - {detail}" |
| 419 | + if title or detail: |
| 420 | + return f"Stellar request failed: {title or detail}" |
| 421 | + |
| 422 | + text = str(exc).strip() |
| 423 | + return f"Stellar request failed: {text or type(exc).__name__}" |
| 424 | + |
| 425 | + |
204 | 426 | def raise_for_invalid_request( |
205 | 427 | status_code: int, |
206 | 428 | response_body: Optional[str] = None, |
|
0 commit comments