> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Taxonomy

> Structured error classification for movements, executors, and adapters

# Error Taxonomy

> **Source of truth**: `src/qtg/domain/errors.py`
> **Error handling patterns**: `src/qtg/infrastructure/executors/cctp/burn.py`, `src/qtg/infrastructure/executors/observe/common.py`
> **CEX-specific errors**: `src/qtg/infrastructure/executors/cex/adapters/errors.py`

The QTG v3 error hierarchy classifies executor execution results into three categories: **retryable / fatal / ambiguous**. This classification determines node-state transitions and request-level judgment.

***

## Table of Contents

1. [Error class hierarchy](#error-class-hierarchy)
2. [MovementError — Base](#movementerror)
3. [TemporaryMovementError](#temporarymovementerror)
4. [FatalMovementError](#fatalmovementerror)
5. [AmbiguousMovementError](#ambiguousmovementerror)
6. [Signed-artifact guard error codes](#signed-artifact-guard-error-codes)
7. [Subclasses: specialized errors](#subclasses-specialized-errors)
8. [Error -> NodeState transition mapping](#error---nodestate-transition-mapping)
9. [Executor error-handling patterns](#executor-error-handling-patterns)
10. [Observe helper: fatal\_result() / temporary\_result()](#observe-helper)
11. [CEX adapter error system](#cex-adapter-error-system)
12. [Cross-References](#cross-references)

***

## Error class hierarchy

```mermaid theme={null}
flowchart TB
    Exception --> MovementError
    MovementError --> TemporaryMovementError["TemporaryMovementError<br/>Retryable transient failure"]
    MovementError --> FatalMovementError["FatalMovementError<br/>Deterministic failure (not retryable)"]
    MovementError --> AmbiguousMovementError["AmbiguousMovementError<br/>Unclear whether a side effect happened"]
    FatalMovementError --> MovementNotFoundError["MovementNotFoundError<br/>Resource not found"]
    FatalMovementError --> MovementConflictError["MovementConflictError<br/>State conflict"]
    FatalMovementError --> MovementValidationError["MovementValidationError<br/>Input validation failure"]
    FatalMovementError --> MakerCheckerError["MakerCheckerError<br/>Approver is also the creator"]
    FatalMovementError --> ConfigRefResolutionError["ConfigRefResolutionError<br/>Runtime $ref resolution failure"]
    MovementConflictError --> BalanceReservationError["BalanceReservationError<br/>Reservation cannot be granted"]
    MovementValidationError --> IllegalStateTransitionError["IllegalStateTransitionError<br/>Disallowed state transition"]
    MovementValidationError --> BindingResolutionError["BindingResolutionError<br/>No active registry match"]
    MovementValidationError --> AmbiguousBindingError["AmbiguousBindingError<br/>Multiple active registry matches"]
```

All errors carry `message` (str) and `detail` (object | None). `MovementError.__init__` also sets `self.code = message`, so every error exposes a `code` attribute (defaulting to the message string) that callers can read without unpacking `detail`.

***

## MovementError

```python theme={null}
class MovementError(Exception):
    """Base application/domain error."""

    def __init__(self, message: str, *, detail: object | None = None):
        super().__init__(message)
        self.code = message
        self.detail = detail
```

Base class for all domain/application errors. Do not raise it directly; use subclasses. Note that `self.code` defaults to the `message` string — distinct from the structured `detail["error_code"]` that executors attach (see the [signed-artifact guard error codes](#signed-artifact-guard-error-codes) section).

### detail field convention

`detail` is usually passed as a `dict`, and executors use it to carry error codes and additional information:

```python theme={null}
raise FatalMovementError(
    "missing evm endpoint",
    detail={"error_code": "MISSING_EVM_ENDPOINT"},
)
```

A common error-handling pattern is to treat `detail` as a `dict` and extract `error_code`:

```python theme={null}
detail = exc.detail if isinstance(exc.detail, dict) else {}
error_code = str(detail.get("error_code") or default_code)
```

***

## TemporaryMovementError

```python theme={null}
class TemporaryMovementError(MovementError):
    """Retryable or transient failure."""
```

### When to use

* transient RPC node failure (timeout, connection reset)
* exchange API rate limit
* transient network instability
* 503/429 responses from external services

### Runtime behavior

When `TemporaryMovementError` occurs, it is handled differently depending on the executor stage:

| Executor stage | Occurrence point  | Resulting NodeState | Meaning                                        |
| -------------- | ----------------- | ------------------- | ---------------------------------------------- |
| `preflight()`  | CCTP burn/mint    | `FAILED`            | safely fails during preflight (no side effect) |
| `submit()`     | CCTP burn/mint    | `UNKNOWN`           | unclear whether the transaction was submitted  |
| `observe()`    | Observe executors | `OBSERVING`         | waiting for retry (uses `temporary_result()`)  |

**Key point**: `TemporaryMovementError` during `submit()` transitions to `UNKNOWN`. A signed tx may already have been sent, so simple retry is not possible.

### Additional fields in detail

Fields that `temporary_result()` extracts from detail:

```python theme={null}
{
    "error_code": "RPC_TEMPORARY",
    "provider_state": "rpc_retry",
    "retry_after_seconds": 15,
}
```

***

## FatalMovementError

```python theme={null}
class FatalMovementError(MovementError):
    """Deterministic non-retryable failure."""
```

### When to use

* configuration error (RPC endpoint missing, probe unregistered)
* insufficient balance (on-chain)
* insufficient allowance
* address validation failure
* protocol-level deterministic error (tx reverted, nonce conflict)

### Runtime behavior

| Executor stage | Resulting NodeState | Meaning                                  |
| -------------- | ------------------- | ---------------------------------------- |
| `preflight()`  | `FAILED`            | blocked before execution starts          |
| `prepare()`    | `FAILED`            | failure during preparation               |
| `observe()`    | `FAILED`            | deterministic failure during observation |
| `recover()`    | `FAILED`            | recovery failure confirmed               |

`FatalMovementError` always transitions to `FAILED`. It is not retried.

### error\_code convention in detail

```python theme={null}
{
    "error_code": "MISSING_EVM_ENDPOINT",
}
```

Common error\_code values:

| error\_code                       | Meaning                                     |
| --------------------------------- | ------------------------------------------- |
| `MISSING_EVM_ENDPOINT`            | EVM RPC endpoint missing                    |
| `INSUFFICIENT_ALLOWANCE`          | insufficient ERC20 allowance                |
| `INSUFFICIENT_BALANCE`            | insufficient token balance                  |
| `MAX_FEE_EXCEEDED`                | fee cap exceeded                            |
| `FAST_ALLOWANCE_EXHAUSTED`        | CCTP fast transfer allowance exhausted      |
| `INVALID_FAST_ALLOWANCE_RESPONSE` | failed to parse fast allowance API response |
| `MISSING_PROBE_KEY`               | missing probe\_key in observe executor      |
| `PROBE_NOT_REGISTERED`            | probe not registered in the probe registry  |
| `CCTP_BURN_INVALID`               | CCTP burn validation failed                 |
| `CCTP_MINT_INVALID`               | CCTP mint validation failed                 |
| `TX_REVERTED`                     | on-chain transaction reverted               |
| `ALREADY_PROCESSED`               | message already processed (CCTP nonce)      |

***

## AmbiguousMovementError

```python theme={null}
class AmbiguousMovementError(MovementError):
    """Side-effect may have happened; manual or recovery path required."""
```

### When to use

* connection drops after transaction submission but before receipt confirmation
* timeout before receiving the response after calling an exchange withdrawal API
* impossible to tell whether the external system is processing or has failed

### Runtime behavior

`AmbiguousMovementError` is not directly caught in the current code, but in the domain model it expresses the meaning of `UNKNOWN` NodeState. Executor implementations usually use `TemporaryMovementError` and handle ambiguous situations by returning `UNKNOWN` as `next_state`.

**Design intent**: a type that explicitly distinguishes cases where it is impossible to decide whether a side effect occurred. It can be used later when executors perform more refined error classification.

***

## Signed-artifact guard error codes

> **Source of truth**: `src/qtg/infrastructure/executors/recipient_guard.py`, `src/qtg/infrastructure/executors/signed_evm_tx.py`

On-chain send executors enforce the **signed-recipient invariant**: the recipient/source/target re-derived from the *actual signed artifact* must equal the allowlist-validated authority. The check runs at prepare-time (intent-only resolvers) and again at submit/broadcast-time — the signed legacy tx is RLP-decoded and the Gateway burn-intent EIP-712 digest is re-hashed. Every guard failure raises a non-retryable `FatalMovementError` with `detail["error_code"]` set to one of the codes below. Because these are `FatalMovementError`, they always **fail closed to `FAILED`** (terminal, no retry) when raised in any executor stage.

| error\_code                 | Where raised                                                                              | Meaning                                                                                                 |
| --------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `SIGNED_RECIPIENT_MISMATCH` | `recipient_guard.py` — `assert_signed_address_equals` + ERC20 `recipient`/bytes32 helpers | The recipient decoded from the signed artifact does not match the validated destination authority       |
| `SIGNED_SOURCE_MISMATCH`    | `recipient_guard.py` — bytes32 source helper                                              | The source/depositor decoded from the signed artifact does not match the validated source               |
| `SIGNED_TARGET_MISMATCH`    | `signed_evm_tx.py` — selector/`to`/length checks on the signed tx body                    | The contract `to` address or call selector of the signed tx does not match the expected on-chain target |
| `SIGNED_TX_DECODE_FAILED`   | `signed_evm_tx.py` — `decode_signed_legacy_tx`                                            | The signed legacy tx could not be RLP-decoded (non-string input, malformed envelope, missing fields)    |
| `EIP712_DIGEST_MISMATCH`    | `gateway/intent.py`                                                                       | The re-hashed Gateway burn-intent EIP-712 digest does not match the digest that was signed              |
| `MISSING_PREPARED_PAYLOAD`  | `gateway/intent.py`                                                                       | The prepared payload needed to re-derive the EIP-712 digest is absent at submit time                    |

**Action-type scope** (`ONCHAIN_ACTION_TYPES` in `application/services/address_allowlist.py`):

* **Destination-only** (destination address validated; source is signer-bound): `stargate_send`, `usdt0_send`, `lighter_secure_withdraw`.
* **Strict source + destination**: `cctp_burn`, `cctp_mint`, `gateway_approve`, `gateway_deposit`, `gateway_intent`, `gateway_mint`, `ccip_send`, `evm_erc20_transfer`.

<Note>
  Submit-time guards close the "validated address != signed address" fund-outflow bypass. CCIP participates in the same signed-artifact guard set: the submit path decodes the signed `ccipSend` transaction and pins router, destination chain selector, receiver, token, amount, native fee, signer identity, and source chain id before sidecar broadcast. A mismatch fails closed as `ccip_signed_artifact_mismatch`.
</Note>

***

## Subclasses: specialized errors

### MovementNotFoundError

```python theme={null}
class MovementNotFoundError(FatalMovementError):
    def __init__(self, message: str = "not found", *, detail: object | None = None):
        super().__init__(message, detail=detail)
```

* Subclass of `FatalMovementError`
* Raised when a resource such as a request, template, or node cannot be found
* Default message: `"not found"`
* Mapped to HTTP 404 at the API layer

### MovementConflictError

```python theme={null}
class MovementConflictError(FatalMovementError):
    def __init__(self, message: str, *, detail: object | None = None):
        super().__init__(message, detail=detail)
```

* Raised on state conflicts (for example, trying to approve an already-approved request again)
* Mapped to HTTP 409 at the API layer

### MovementValidationError

```python theme={null}
class MovementValidationError(FatalMovementError):
    def __init__(self, message: str, *, detail: object | None = None):
        super().__init__(message, detail=detail)
```

* Raised when input validation fails (for example, required field missing, invalid format)
* Mapped to HTTP 422 at the API layer

### MakerCheckerError

```python theme={null}
class MakerCheckerError(FatalMovementError):
    def __init__(self, message: str = "approver must differ from creator", *,
                 detail: object | None = None):
        super().__init__(message, detail=detail)
```

* Raised by the approval path when `MG_REQUIRE_MAKER_CHECKER` is on and the authenticated approver key equals the key that created the movement
* `detail` carries `error_code = maker_checker_violation` plus `creator_key_id`
* Mapped to HTTP 403 at the API layer — the only domain error that maps to 403

### ConfigRefResolutionError

```python theme={null}
class ConfigRefResolutionError(FatalMovementError):
    """A $ref config reference could not be resolved at runtime."""
```

* Raised when the runtime `build_execution_context()` stage cannot resolve a top-level `$ref:node_key.field`
* Representative detail fields:
  * `config_key`
  * `ref_path`
  * `error_code = CONFIG_REF_UNRESOLVED`
* The dispatcher catches it and transitions the current node to `FAILED`

### BalanceReservationError

```python theme={null}
class BalanceReservationError(MovementConflictError):
    """Raised when balance reservation cannot be granted."""

    def __init__(self, *, message: str, reason: str, detail: dict) -> None:
        super().__init__(message, detail=detail)
        self.reason = reason
```

* Subclass of `MovementConflictError` (and thus `FatalMovementError`)
* Raised at approval/reservation time when a pool has no balance snapshot or insufficient withdrawable amount
* Carries an extra `reason` attribute on top of `message` / `detail`
* See the [CEX balance scope debug skill](/reference/executors/cex-lane) for the typical `no snapshot or null withdrawable` failure chain

### IllegalStateTransitionError

```python theme={null}
class IllegalStateTransitionError(MovementValidationError):
    def __init__(self, *, entity: str, old, new) -> None: ...
```

* Subclass of `MovementValidationError`
* Raised when `set_node_state` / `set_request_state` is given a transition not in `NODE_TRANSITIONS` / `REQUEST_TRANSITIONS` without `allow_transition_override=True`
* `detail` carries `error_code = ILLEGAL_STATE_TRANSITION` plus `entity`, `old`, `new`

### BindingResolutionError / AmbiguousBindingError

```python theme={null}
class BindingResolutionError(MovementValidationError):
    """No active registry entry matches the given selector."""

class AmbiguousBindingError(MovementValidationError):
    """Multiple active registry entries match the given selector."""
```

* Both subclass `MovementValidationError`
* `BindingResolutionError` — no active registry row resolves the executor/signer/venue selector
* `AmbiguousBindingError` — more than one active registry row matches and the selector cannot pick deterministically

### Summary: subclasses -> HTTP mapping

| Error class                   | HTTP Status        | Meaning                           |
| ----------------------------- | ------------------ | --------------------------------- |
| `MakerCheckerError`           | 403                | approver key equals creator key   |
| `MovementNotFoundError`       | 404                | resource not found                |
| `MovementConflictError`       | 409                | state conflict                    |
| `MovementValidationError`     | 422                | input validation failure          |
| `ConfigRefResolutionError`    | worker fail-closed | runtime `$ref` resolution failure |
| `FatalMovementError` (others) | 400/500            | deterministic failure             |
| `TemporaryMovementError`      | 503                | transient failure                 |

***

## Error -> NodeState transition mapping

Overall picture of how errors map to NodeState transitions:

```mermaid theme={null}
flowchart TB
    Call["Executor Method Call"]
    Call --> Fatal[FatalMovementError]
    Call --> Temp[TemporaryMovementError]
    Call --> Normal["Normal return<br/>ExecutionResult.next_state"]
    Fatal --> FailedA[FAILED]
    Temp --> Stage{stage-specific branch}
    Stage -->|preflight| FailedB[FAILED]
    Stage -->|submit| UnknownB[UNKNOWN]
    Stage -->|observe| ObservingB[OBSERVING]
    Normal --> Specified["(specified state)"]
```

### Detailed stage-by-stage mapping

**preflight()**

```python theme={null}
# CCTP burn executor — error handling in preflight
except TemporaryMovementError as exc:
    return ExecutionResult(next_state="FAILED", error_code=..., error_detail=str(exc))
except FatalMovementError as exc:
    return ExecutionResult(next_state="FAILED", error_code=..., error_detail=str(exc))
```

In preflight, even `TemporaryMovementError` is treated as `FAILED`. This is safe because there is no side effect at that stage.

**submit()**

```python theme={null}
# CCTP burn executor — error handling in submit
except TemporaryMovementError as exc:
    return ExecutionResult(next_state="UNKNOWN", error_code=..., error_detail=str(exc))
```

In submit, `TemporaryMovementError` is treated as `UNKNOWN`. The transaction may already have been sent.

**observe()**

```python theme={null}
# Observe executor — error handling in observe
except TemporaryMovementError as exc:
    return common.temporary_result(exc, provider_refs=...)  # next_state="OBSERVING"
except (FatalMovementError, KeyError) as exc:
    return common.fatal_result(exc, default_code=...)  # next_state="FAILED"
```

In observe, `TemporaryMovementError` is treated as `OBSERVING` (waiting for retry). `FatalMovementError` is treated as `FAILED`.

***

## Executor error-handling patterns

### Pattern 1: CCTP Action Executor (burn/mint)

The CCTP executor catches errors directly in each of the preflight/submit/observe/recover stages and converts them into `ExecutionResult`.

**preflight() pattern:**

```python theme={null}
async def preflight(self, ctx: ExecutionContext) -> ExecutionResult | None:
    try:
        # Validation logic (allowance, balance, fee, gas estimation, etc.)
        # Return ExecutionResult directly on validation failure
        if allowance_raw < amount_raw:
            return ExecutionResult(next_state="FAILED", error_code="INSUFFICIENT_ALLOWANCE")

        # All validation passed
        return None
    except TemporaryMovementError as exc:
        return ExecutionResult(
            next_state="FAILED",
            error_code=str(
                (exc.detail or {}).get("error_code")
                if isinstance(exc.detail, dict)
                else "PROTOCOL_TEMPORARY"
            ),
            error_detail=str(exc),
        )
    except FatalMovementError as exc:
        return ExecutionResult(
            next_state="FAILED",
            error_code=str(
                (exc.detail or {}).get("error_code")
                if isinstance(exc.detail, dict)
                else "CCTP_BURN_INVALID"
            ),
            error_detail=str(exc),
        )
```

**Key points:**

* In preflight, exceptions are **all mapped to FAILED** (no side effect)
* If `detail` is a dict, extract `error_code`; otherwise use the default code
* `return None` means preflight passed (proceed to the next stage)

**submit() pattern:**

```python theme={null}
async def submit(self, ctx, prepared_action, **kwargs) -> ExecutionResult:
    signed_tx = common.select_signed_tx(kwargs.get("sign_result"))
    try:
        tx_hash = await common.send_raw_transaction(
            self.rpc_client, endpoint=endpoint, signed_tx=signed_tx,
        )
    except TemporaryMovementError as exc:
        return ExecutionResult(
            next_state="UNKNOWN",        # <- UNKNOWN, not FAILED
            error_code=...,
            error_detail=str(exc),
        )
    return ExecutionResult(
        next_state="COMPLETED",
        provider_refs={"txid": tx_hash, ...},
        provider_state="submitted",
    )
```

**Key points:**

* In submit, `TemporaryMovementError` -> `UNKNOWN` (unclear whether the tx was sent)
* On success, store `txid` in `provider_refs` with `COMPLETED`
* `FatalMovementError` is not caught — fatal conditions should not happen in submit (they should be filtered in preflight)

**recover() pattern:**

```python theme={null}
async def recover(self, ctx: ExecutionContext) -> ExecutionResult:
    txid = ctx.provider_context.get("txid") or ctx.provider_context.get("burn_tx_hash")
    if txid:
        return ExecutionResult(next_state="COMPLETED", provider_refs=dict(ctx.provider_context))
    return ExecutionResult(next_state="FAILED", error_code="RECOVERY_FAILED")
```

* If `provider_context` contains a txid -> `COMPLETED` (transaction confirmed)
* If there is no txid -> `FAILED` (cannot recover)

### Pattern 2: CctpMintExecutor.recover() — advanced recovery

```python theme={null}
async def recover(self, ctx: ExecutionContext) -> ExecutionResult:
    txid = ctx.provider_context.get("mint_tx_hash") or ctx.provider_context.get("txid")
    if txid:
        receipt = await self.rpc_client.call(endpoint, "eth_getTransactionReceipt", [txid])
        if receipt is None:
            return ExecutionResult(next_state="UNKNOWN", error_code="MINT_STATUS_UNKNOWN")
        if common.receipt_reverted(receipt):
            return ExecutionResult(next_state="FAILED", error_code="TX_REVERTED")
        return ExecutionResult(next_state="COMPLETED", provider_refs=dict(ctx.provider_context))

    # Recovery attempt without txid — check whether it was already processed with eth_call
    try:
        await common.eth_call(...)
        # call succeeds = mint not performed yet (if already minted, it would revert)
        return ExecutionResult(next_state="FAILED", error_code="MINT_NOT_SUBMITTED")
    except FatalMovementError as exc:
        # match the "already processed" pattern
        if "already processed" in error_message:
            return ExecutionResult(next_state="COMPLETED", ...)
        return ExecutionResult(next_state="FAILED", ...)
```

This pattern performs multi-stage judgment during recovery:

1. txid exists -> inspect receipt -> 3-way branch: success/failure/unknown
2. no txid -> check mintability with eth\_call -> 3-way branch: already processed/not processed/error

***

## Observe helper

Shared helper for observe executors defined in `src/qtg/infrastructure/executors/observe/common.py`.

### fatal\_result()

```python theme={null}
def fatal_result(
    exc: FatalMovementError | KeyError,
    *,
    default_code: str,
    error_detail: str | None = None,
) -> ExecutionResult:
    if isinstance(exc, KeyError):
        return ExecutionResult(next_state="FAILED", error_code=default_code, error_detail=error_detail)
    detail = exc.detail if isinstance(exc.detail, dict) else {}
    return ExecutionResult(
        next_state="FAILED",
        error_code=str(detail.get("error_code") or default_code),
        error_detail=error_detail or str(exc),
    )
```

**Behavior:**

* Always returns `next_state="FAILED"`
* Supports `KeyError` handling (occurs when the probe registry is missing an entry)
* Uses `detail.error_code` from `FatalMovementError` if present, otherwise falls back to `default_code`

**Usage example:**

```python theme={null}
# DestinationChainReceiveObserveExecutor.observe()
except (FatalMovementError, KeyError) as exc:
    return common.fatal_result(exc, default_code="PROBE_NOT_REGISTERED")
```

### temporary\_result()

```python theme={null}
def temporary_result(exc: TemporaryMovementError, *, provider_refs: dict[str, Any]) -> ExecutionResult:
    detail = exc.detail if isinstance(exc.detail, dict) else {}
    return ExecutionResult(
        next_state="OBSERVING",
        provider_state=str(detail.get("provider_state") or "rpc_retry"),
        provider_refs=provider_refs,
        proof={},
        error_code=str(detail.get("error_code") or "RPC_TEMPORARY"),
        error_detail=str(exc),
        retry_after_seconds=int(detail.get("retry_after_seconds") or 15),
    )
```

**Behavior:**

* Always returns `next_state="OBSERVING"` (waiting for retry)
* Preserves `provider_refs` (keeps existing context)
* Default `retry_after_seconds` is 15 seconds
* Default `provider_state` is `"rpc_retry"`

**Usage example:**

```python theme={null}
# DestinationChainReceiveObserveExecutor.observe()
except TemporaryMovementError as exc:
    return common.temporary_result(exc, provider_refs=dict(ctx.provider_context))

# ProtocolObserveExecutor.observe()
except TemporaryMovementError as exc:
    return common.temporary_result(
        exc,
        provider_refs={"protocol_ref": common.resolve_protocol_ref(ctx) or ""},
    )
```

### Comparing fatal\_result vs temporary\_result

| Item             | `fatal_result()`                 | `temporary_result()`                      |
| ---------------- | -------------------------------- | ----------------------------------------- |
| next\_state      | `FAILED`                         | `OBSERVING`                               |
| Retry            | None                             | Retry after `retry_after_seconds` seconds |
| Exception types  | `FatalMovementError`, `KeyError` | `TemporaryMovementError`                  |
| provider\_refs   | Not preserved                    | Existing context preserved                |
| Final node state | Terminal                         | Continue observing                        |

***

## CEX adapter error system

The CEX executor (`CexWithdrawalActionExecutor`) uses a separate `TransferError` hierarchy instead of the domain error hierarchy. These errors are caught inside the executor and converted into `ExecutionResult`.

### TransferError hierarchy

```mermaid theme={null}
flowchart TB
    Exception --> TransferError
    TransferError --> AuthenticationError["AuthenticationError<br/>authentication failure"]
    TransferError --> RateLimitError["RateLimitError<br/>API rate limit"]
    TransferError --> InsufficientBalanceError["InsufficientBalanceError<br/>insufficient balance"]
    TransferError --> AddressNotWhitelistedError["AddressNotWhitelistedError<br/>address not registered"]
    TransferError --> WalletMaintenanceError["WalletMaintenanceError<br/>wallet under maintenance"]
    TransferError --> NetworkUnavailableError["NetworkUnavailableError<br/>network unavailable"]
```

```python theme={null}
class TransferError(Exception):
    def __init__(self, message, *, code="TRANSFER_ERROR", retryable=False,
                 exchange_code=None, exchange_message=None):
        self.code = code
        self.retryable = retryable
        self.exchange_code = exchange_code
        self.exchange_message = exchange_message
```

### CEX error -> ExecutionResult mapping

```python theme={null}
# CexWithdrawalActionExecutor.preflight()
except (AuthenticationError, AddressNotWhitelistedError) as exc:
    return ExecutionResult(next_state="FAILED", error_code=exc.code, error_detail=str(exc))
except (InsufficientBalanceError, WalletMaintenanceError, NetworkUnavailableError, TransferError) as exc:
    return ExecutionResult(next_state="FAILED", error_code=exc.code, error_detail=str(exc))

# CexWithdrawalActionExecutor.submit()
except (httpx.TimeoutException, httpx.ConnectError) as exc:
    return ExecutionResult(next_state="UNKNOWN", error_code="TIMEOUT", error_detail=str(exc))
except TransferError as exc:
    return ExecutionResult(next_state="FAILED", error_code=exc.code, error_detail=str(exc))
```

| CEX error                    | Stage     | NodeState   | error\_code                           |
| ---------------------------- | --------- | ----------- | ------------------------------------- |
| `AuthenticationError`        | preflight | FAILED      | `AUTHENTICATION_ERROR`                |
| `AddressNotWhitelistedError` | preflight | FAILED      | `ADDRESS_NOT_WHITELISTED`             |
| `InsufficientBalanceError`   | preflight | FAILED      | `INSUFFICIENT_BALANCE`                |
| `WalletMaintenanceError`     | preflight | FAILED      | `WALLET_MAINTENANCE`                  |
| `NetworkUnavailableError`    | preflight | FAILED      | `NETWORK_UNAVAILABLE`                 |
| `httpx.TimeoutException`     | submit    | **UNKNOWN** | `TIMEOUT`                             |
| `httpx.ConnectError`         | submit    | **UNKNOWN** | `TIMEOUT`                             |
| `TransferError`              | submit    | FAILED      | `TRANSFER_ERROR` (or a subclass code) |

**Key point**: In CEX submit, `httpx.TimeoutException` / `httpx.ConnectError` are treated as `UNKNOWN`. The exchange may already have received the withdrawal request.

***

## Summary of error-handling design principles

1. **In preflight, every error becomes FAILED**: safe to fail because there is no side effect
2. **In submit, uncertainty becomes UNKNOWN**: simple retry is not possible because external state may already have changed
3. **In observe, transient failures become OBSERVING**: retry observation while preserving `provider_refs`
4. **In recover, inability to confirm stays UNKNOWN**: reserve another recovery attempt
5. **side-effect node completed + another node failed = MANUAL\_INTERVENTION**: automatic judgment is impossible while funds are in transit
6. **detail.error\_code convention**: executors provide structured error codes to support operator tracking

***

## Cross-References

* [states-and-transitions.md](/reference/domain/states-and-transitions) — NodeState.UNKNOWN recovery and MANUAL\_INTERVENTION judgment
* [types-and-enums.md](/reference/domain/types-and-enums) — NodeKind action vs observe distinction
* [../executors/cctp-lane.md](/reference/executors/bridges/cctp-lane) — details of CCTP burn/mint executors
* [../executors/cex-lane.md](/reference/executors/cex-lane) — details of CEX withdrawal/observe executors
* [../executors/observe-probes.md](/reference/executors/observe-probes) — observe executor and probe architecture
* [../workers/runtime-workers.md](/reference/workers/runtime-workers) — error handling in dispatch/observe/recover workers
