Skip to main content

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

Data propagation between nodes (provider_context)

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

Node 1: Withdrawal Action

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

What preflight checks

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).
In code:

What submit does

Calls the exchange API to actually execute the withdrawal:
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

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.

Node 2: Withdrawal Observe

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

Preflight

First gates on complete gross fee-quote evidence, then verifies that exchange_withdrawal_id is present in the provider_context:

Observe (core logic)

Queries the exchange API for withdrawal status and branches based on the result:
State mapping:
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.

Recover

recover() simply calls observe() again:
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

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

Deposit states

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.

Live capacity reads

Before creating a movement, callers can query GET /v3/withdrawal-capacities for one configured venue, asset, and provider-native network. The response keeps three concerns separate:
  • current chain deposit/withdraw status and provider policy;
  • the configured account’s live withdrawable balance;
  • provider quotas and QTG’s pending reservation overlay.
Quota units are never flattened. For example, an OKX USD rolling quota or an Upbit KRW daily quota cannot be subtracted from a coin balance, so QTG reports the asset-only bound and leaves the effective amount unknown. The endpoint is always live, no-store, and read-only. It creates no movement and grants no approval to withdraw. The same fail-closed rule applies to missing provider policy: an unknown minimum, maximum, precision, or fee never becomes zero or unlimited. OKX fee evidence comes from current fee/feeCcy and burningFeeRate fields, not the deprecated minFee/maxFee pair.

Fee-aware balance holds

Approval separates the net destination amount from the gross same-asset source debit. An exact on_top quote reserves amount plus fee; an exact deducted_from_amount quote reserves the requested amount. Unknown or provider-defined charge semantics cannot approve a CEX movement. The quote is stored on the withdrawal node and used by the existing outflow cap and balance-reservation ledger; approved_source_debit stores gross separately from net approved_amount. Positive on-top manual approval requires operator max_source_debit, while auto-approval rechecks and reserves its policy budget with gross. Immediately before submit, QTG reads the fee again and requires the fee, gross debit, identity fields, and active reservation to match the approval evidence exactly while the pool row is locked. A mismatch fails before provider I/O; it is never permission to retry an earlier submit. Unknown-gross legacy CEX approvals quarantine the same asset across transports for every active cap/policy window. Legacy in-flight rows park for manual review, and cancellation cannot release a hold while a side effect may be in flight.

Error Handling and Route Availability

Error scenario overview

Recurring errors (wallet maintenance, network unavailable, etc.) do not auto-pause a route. The node repeatedly fails or lands in UNKNOWN, and recovery keeps re-attempting on its interval; the CCIP lane (Pro) is the one lane with automatic drift handling — it quarantines a token lane on detected registry drift. Route availability is surfaced to operators through the route-readiness projection (/v3 route catalog), and repeated failures end up as manual-attention alarms.Resolving a persistently failing route is always a human decision — there is no automatic return to healthy operation.

CEX Lane full error flow