> ## 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.

# CEX Lane

> 3-node pipeline for transferring assets between centralized exchanges (CEX to CEX)

# CEX Lane (Exchange-to-Exchange Transfer)

## What is a CEX Lane?

A CEX (Centralized Exchange) Lane is a **3-step pipeline for sending coins from Exchange A to Exchange B**.

Think of it like a bank wire transfer:

1. **Withdrawal request**: "Please send \$1,000 from Bank A to Bank B" (withdrawal action)
2. **Withdrawal confirmation**: Bank A says "Transfer processed, reference TXN-1234" (withdrawal observe)
3. **Deposit confirmation**: Bank B confirms "\$1,000 received" (deposit observe)

The most battle-tested combination is the **Upbit ↔ other exchange** path.

***

## 3-Node Flow

```mermaid theme={null}
sequenceDiagram
    participant O as Orchestrator
    participant WA as Node 1<br/>Withdrawal Action
    participant SRC as Source Exchange<br/>(Upbit/Binance)
    participant WO as Node 2<br/>Withdrawal Observe
    participant DO as Node 3<br/>Deposit Observe
    participant DST as Destination Exchange<br/>(Upbit/Binance)

    Note over O,DST: === Node 1: Withdrawal Request ===
    O->>WA: preflight(ctx)
    WA->>SRC: get_whitelisted_addresses()
    SRC-->>WA: [addr1, addr2, ...]
    WA->>SRC: check_withdrawal_available()
    SRC-->>WA: {is_available, min, max, fee}
    WA->>SRC: check_wallet_service()
    SRC-->>WA: {is_maintenance: false}
    WA-->>O: None (pass)

    O->>WA: prepare(ctx)
    WA-->>O: PreparedAction(signing_required=false)

    O->>WA: submit(ctx, prepared_action)
    WA->>SRC: submit_withdrawal(asset, amount, address, ...)
    SRC-->>WA: {withdrawal_id: "abc-123", state: SUBMITTED}
    WA-->>O: COMPLETED + provider_refs {exchange_withdrawal_id: "abc-123"}

    Note over O,DST: === Node 2: Withdrawal Status Check ===
    Note right of O: propagate exchange_withdrawal_id<br/>via provider_context

    loop polling
        O->>WO: observe(ctx)
        WO->>SRC: get_withdrawal_status("abc-123")
        SRC-->>WO: {state: PROCESSING}
        WO-->>O: OBSERVING
    end

    WO->>SRC: get_withdrawal_status("abc-123")
    SRC-->>WO: {state: COMPLETED, txid: "0xabc..."}
    WO-->>O: COMPLETED + provider_refs {txid: "0xabc..."}

    Note over O,DST: === Node 3: Deposit Confirmation ===
    Note right of O: propagate txid<br/>via provider_context

    loop polling
        O->>DO: observe(ctx)
        DO->>DST: list_deposits(asset, limit=50)
        DST-->>DO: [deposit1, deposit2, ...]
        Note right of DO: find our deposit by txid matching
        DO-->>O: OBSERVING (not found yet)
    end

    DO->>DST: list_deposits(asset, limit=50)
    DST-->>DO: [..., {txid: "0xabc...", state: CREDITED}]
    DO-->>O: COMPLETED + provider_refs {exchange_deposit_id: "dep-456"}
```

### Data propagation between nodes (provider\_context)

Each node's output becomes the next node's input — like passing a relay baton:

```mermaid theme={null}
flowchart LR
    WA["Node 1: Withdrawal Action<br/>-----<br/>output: exchange_withdrawal_id"] -->|"provider_context"| WO["Node 2: Withdrawal Observe<br/>-----<br/>input: exchange_withdrawal_id<br/>output: txid"]
    WO -->|"provider_context"| DO["Node 3: Deposit Observe<br/>-----<br/>input: txid<br/>output: exchange_deposit_id"]

    style WA fill:#fff3e0
    style WO fill:#e3f2fd
    style DO fill:#e8f5e9
```

***

## Node 1: Withdrawal Action

The executor responsible for submitting a withdrawal request. Registered under the key `exec.cex.withdrawal_action`.

### What preflight checks

```mermaid theme={null}
flowchart TD
    START([preflight start]) --> ADDR[address whitelist check]
    ADDR -->|"not whitelisted"| F1["FAILED: ADDRESS_NOT_WHITELISTED"]
    ADDR -->|OK| AVAIL[withdrawal availability check]
    AVAIL -->|"not available"| F2["FAILED: WITHDRAWAL_UNAVAILABLE"]
    AVAIL -->|OK| MIN[minimum amount check]
    MIN -->|"below min"| F3["FAILED: BELOW_MINIMUM"]
    MIN -->|OK| MAX[maximum amount check]
    MAX -->|"above max"| F4["FAILED: ABOVE_MAXIMUM"]
    MAX -->|OK| WALLET[wallet service status check]
    WALLET -->|"maintenance"| F5["FAILED: WALLET_MAINTENANCE"]
    WALLET -->|OK| TRAVEL[Travel Rule validation<br/>if supported]
    TRAVEL --> PASS([return None = pass])

    style F1 fill:#ffcdd2
    style F2 fill:#ffcdd2
    style F3 fill:#ffcdd2
    style F4 fill:#ffcdd2
    style F5 fill:#ffcdd2
    style PASS fill:#c8e6c9
```

<Tip>
  Why the whitelist check comes first: if the **destination address is wrong**, there is no way to recover the funds. That is why it is the strictest check and runs first. Both `destination_address` and `destination_memo` must match (when a memo is present).
</Tip>

In code:

```python theme={null}
whitelisted = await adapter.get_whitelisted_addresses(asset_symbol)
address_ok = any(
    item.address == destination_address
    and (destination_memo is None or item.memo == destination_memo)
    for item in whitelisted
)
```

### What submit does

Calls the exchange API to actually execute the withdrawal:

```python theme={null}
result = await adapter.submit_withdrawal(
    WithdrawalRequest(
        asset=..., network=..., amount=...,
        address=..., memo=...,
        destination_exchange=...,        # required for travel rule
        travel_rule_questionnaire=...,   # travel rule answers
    )
)
```

The `exchange_withdrawal_id` in the return value is the key piece of data — it gets propagated to the next node (Withdrawal Observe).

### Error handling

| Error                        | next\_state | Meaning                                   |
| ---------------------------- | ----------- | ----------------------------------------- |
| `AuthenticationError`        | FAILED      | API key is invalid or expired             |
| `AddressNotWhitelistedError` | FAILED      | Address is not on the whitelist           |
| `InsufficientBalanceError`   | FAILED      | Insufficient balance                      |
| `WalletMaintenanceError`     | FAILED      | Wallet is under maintenance               |
| `httpx.TimeoutException`     | **UNKNOWN** | Timeout — the call may have gone through! |
| `TransferError`              | FAILED      | Other transfer error                      |

<Warning>
  Timeout = UNKNOWN is the key point: if a withdrawal API call times out, the call may have been received by the exchange but the response was lost. In that case `UNKNOWN` is returned instead of `FAILED`. Later, `recover()` determines the real outcome by checking whether an `exchange_withdrawal_id` exists.
</Warning>

***

## Node 2: Withdrawal Observe

The executor that periodically checks the withdrawal status. Registered under the key `exec.cex.withdrawal_observe`.

### Preflight

Simple — just verifies that `exchange_withdrawal_id` is present in the provider\_context:

```python theme={null}
if common.withdrawal_id(ctx) is None:
    return ExecutionResult(next_state="FAILED", error_code="MISSING_WITHDRAWAL_ID")
```

### Observe (core logic)

Queries the exchange API for withdrawal status and branches based on the result:

```python theme={null}
status = await adapter.get_withdrawal_status(withdrawal_id, asset=asset)

if status.state == WithdrawalState.COMPLETED:
    return ExecutionResult(
        next_state="COMPLETED",
        provider_refs={**ctx.provider_context, "txid": status.txid},  # txid added!
    )
if status.state in (PROCESSING, WAITING_CONFIRMATION, SUBMITTED):
    return ExecutionResult(next_state="OBSERVING")  # keep waiting
```

State mapping:

| Exchange state   | WithdrawalState        | Observe result           |
| ---------------- | ---------------------- | ------------------------ |
| DONE / COMPLETED | `COMPLETED`            | COMPLETED + txid         |
| PROCESSING       | `PROCESSING`           | OBSERVING (keep waiting) |
| WAITING          | `WAITING_CONFIRMATION` | OBSERVING                |
| SUBMITTED        | `SUBMITTED`            | OBSERVING                |
| CANCELLED        | `CANCELLED`            | FAILED                   |
| REJECTED         | `REJECTED`             | FAILED                   |

<Info>
  Why txid only appears at COMPLETED: the blockchain transaction ID (txid) is not finalized until the exchange has actually completed the on-chain transfer. Before that point, the transaction is still being processed internally by the exchange, so no txid is available yet.
</Info>

### Recover

`recover()` simply calls `observe()` again:

```python theme={null}
async def recover(self, ctx):
    return await self.observe(ctx)
```

Querying withdrawal status is idempotent — calling it multiple times causes no issues.

***

## Node 3: Deposit Observe

The executor that confirms the deposit has been credited at the destination exchange. Registered under the key `exec.cex.deposit_observe`.

### Why txid matching is necessary

<Warning>
  Multiple parties deposit to an exchange simultaneously: the destination exchange's deposit list contains deposits from other users mixed in with yours. That is why **exact txid matching** is required to confirm "this is the deposit we sent."
</Warning>

```python theme={null}
async def observe(self, ctx):
    current_txid = common.normalize_txid(common.txid(ctx))  # normalize to lowercase
    deposits = await adapter.list_deposits(asset=asset, limit=50)  # fetch last 50
    matched = next(
        (item for item in deposits if common.normalize_txid(item.txid) == current_txid),
        None,
    )
    if matched and matched.state in (DepositState.CREDITED, DepositState.ACCEPTED):
        return ExecutionResult(next_state="COMPLETED", ...)
    return ExecutionResult(next_state="OBSERVING")  # not arrived yet, keep waiting
```

<Tip>
  Why normalize\_txid is needed: different exchanges may return txids in different formats. One exchange might return lowercase while another returns uppercase. Normalizing with `strip().lower()` ensures accurate matching.
</Tip>

### Deposit states

| DepositState | Meaning                            | Observe result |
| ------------ | ---------------------------------- | -------------- |
| `CREDITED`   | Deposit complete (balance updated) | **COMPLETED**  |
| `ACCEPTED`   | Deposit accepted                   | **COMPLETED**  |
| `PENDING`    | Processing                         | OBSERVING      |

<Info>
  CREDITED vs ACCEPTED: different exchanges use different names for a completed deposit (`CREDITED`, `ACCEPTED`, `DEPOSIT_ACCEPTED`, etc.). The code handles all of these variants because exchange response formats differ.
</Info>

***

## Error Handling and Route Pause

### Error scenario overview

```mermaid theme={null}
flowchart TD
    ERR{error type?}
    ERR -->|"address not allowed"| F1[FAILED<br/>ADDRESS_NOT_WHITELISTED]
    ERR -->|"insufficient balance"| F2[FAILED<br/>INSUFFICIENT_BALANCE]
    ERR -->|"under maintenance"| F3[FAILED<br/>WALLET_MAINTENANCE]
    ERR -->|"API timeout"| U[UNKNOWN<br/>TIMEOUT]
    ERR -->|"withdrawal rejected"| F4[FAILED<br/>CANCELLED/REJECTED]

    F1 --> RP{Route Auto-Pause?}
    F3 --> RP
    F2 --> NO[retryable<br/>replenish balance]
    U --> REC[recover() called]
    F4 --> LOG[write audit log]

    RP -->|"repeated failure"| PAUSE[route paused]
    PAUSE --> NOTIFY[notify operator via callback]

    style U fill:#fff9c4
    style PAUSE fill:#ffcdd2
```

<Warning>
  When Route Auto-Pause triggers: when certain errors (wallet maintenance, network unavailable, etc.) recur, the State Machine automatically pauses the affected route. The reason is straightforward — **repeating the same failure is pointless and risky**.

  A paused route requires an operator to explicitly resume it. There is no automatic recovery — a human must assess the situation and make a decision.
</Warning>

### CEX Lane full error flow

```mermaid theme={null}
sequenceDiagram
    participant O as Orchestrator
    participant E as Executor
    participant EX as Exchange

    Note over O,EX: normal flow
    O->>E: submit()
    E->>EX: API call
    EX-->>E: success
    E-->>O: COMPLETED

    Note over O,EX: timeout scenario
    O->>E: submit()
    E->>EX: API call
    EX--xE: Timeout!
    E-->>O: UNKNOWN

    O->>E: recover()
    E->>EX: status check
    alt withdrawal_id exists
        EX-->>E: executed
        E-->>O: COMPLETED
    else withdrawal_id not found
        E-->>O: FAILED: RECOVERY_FAILED
    end
```

***

## Related Docs

* [Executor Protocol](/concepts/executor-protocol) — The common interface all Executors implement
* [Bridge Lane](/concepts/bridge-lane) — On-chain bridge family (CCTP, CCIP, LayerZero)
* [State Machine](/concepts/state-machine) — State transitions and route pause logic
