Skip to main content

CEX Lane Executors

Bithumb and Backpack are Pro CEX integrations. Lighter is a Pro on-chain venue, not a CEX adapter; it does not use this three-node CEX lane.
Source files
  • src/qtg/infrastructure/executors/cex/__init__.py
  • src/qtg/infrastructure/executors/cex/withdrawal_action.py
  • src/qtg/infrastructure/executors/cex/withdrawal_observe.py
  • src/qtg/infrastructure/executors/cex/deposit_observe.py
  • src/qtg/infrastructure/executors/cex/common.py
  • src/qtg/infrastructure/executors/cex/adapters/

1. CEX 3-Node Lane Overview

Transfers between CEX venues are a serial pipeline composed of three nodes.

Executor key mapping

Registration

register_builtin_cex_executors() registers all three executors wrapped in LocalExecutorAdapter:

2. Provider Refs Propagation Flow

Data is propagated between nodes through the provider_refs dict. The previous node’s provider_refs are passed to the next node as ctx.provider_context. Core propagated fields:

3. CexWithdrawalActionExecutor

File: cex/withdrawal_action.py Responsible for pre-validating and then submitting a withdrawal request.

3.1 preflight

This is the most complex preflight. It performs five validations in order:
  1. Whitelist address check: call adapter.get_whitelisted_addresses(asset) and verify the destination address and memo match
  2. Withdrawal availability: adapter.check_withdrawal_available(asset, network) -> verify is_available
  3. Minimum / maximum amount: validate against the chance.minimum_amount and chance.maximum_amount range
  4. Wallet service state: adapter.check_wallet_service(asset) -> verify is_maintenance
  5. Adapter-level validation (optional): adapter.validate_withdrawal_request(WithdrawalRequest(...)) -> called only when that method exists on the adapter
Returns None if all validations pass. Returns ExecutionResult with FAILED when validation fails. Error codes (preflight):

3.2 prepare

Packages the withdrawal request into PreparedAction.
action_type = "cex_withdrawal", signing_required = False (CEX withdrawals use API-key authentication, so no separate signer is required). PreparedAction.prepared_action_id is derived from the payload’s canonical JSON hash in the form cex_withdrawal:{sha256_prefix}.

3.3 submit

Calls adapter.submit_withdrawal(WithdrawalRequest(...)). WithdrawalRequest shape:
wallet_type and travel_rule_questionnaire are extracted from per-exchange provider options in ctx.input_params[exchange_name]. Returned on success:
  • next_state = "COMPLETED"
  • provider_refs: exchange_withdrawal_id, submitted_amount, asset, network, destination_exchange
Error codes (submit):

3.4 observe

The action node’s observe is a simple pass-through:

3.5 recover

Recover resolves an ambiguous submit through a three-path flow (cex/withdrawal_action.py):
  1. Fee-quote gate. Without complete gross fee-quote evidence the node returns UNKNOWN(CEX_FEE_APPROVAL_QUOTE_MISSING) with manual intervention — never a blind re-check.
  2. Fast path. exchange_withdrawal_id in provider_context (with submit-attempt markers) → COMPLETED with scrubbed refs.
  3. Reconciliation. Legacy UNKNOWN nodes without submit-attempt markers are gated to UNKNOWN(LEGACY_RECONCILIATION_GATED) + manual intervention. New-path reconciliation lists withdrawals within the recovery window (_RECOVERY_WINDOW_LOOKBACK, 2 minutes) and safely matches amount/address (approval-pinned)/memo, with candidate-ID claiming. A unique match → COMPLETED; no match → UNKNOWN(RECOVERY_MATCH_NOT_FOUND); multiple candidates → UNKNOWN(RECONCILIATION_AMBIGUOUS); auth failure → UNKNOWN(RECONCILIATION_AUTH_FAILED). RECOVERY_FAILED survives only as the fallback code for unstructured fatal errors.

4. CexWithdrawalObserveExecutor

File: cex/withdrawal_observe.py Polls exchange withdrawal status after a withdrawal has been submitted.

4.1 preflight

withdrawal_id is extracted from ctx.provider_context["exchange_withdrawal_id"].

4.2 prepare

Builds the observe action with action_type = "cex_withdrawal_status".

4.3 submit

Returns SUBMITTED immediately. Actual polling happens in observe.

4.4 observe

Core polling logic:
Branching by WithdrawalState: When COMPLETED, provider_refs gains txid. That txid is used by the next node (deposit_observe) to match the deposit.

4.5 recover

Re-calls observe directly. Error codes:

5. CexDepositObserveExecutor

File: cex/deposit_observe.py Confirms settlement of the deposit on the destination exchange.

5.1 preflight

txid is extracted from ctx.provider_context["txid"].

5.2 prepare

Builds the observe action with action_type = "cex_deposit_status".

5.3 submit

Returns SUBMITTED immediately.

5.4 observe

Core deposit matching logic:
Key characteristics:
  • compares txid case-insensitively (normalize_txid applies lower() + strip())
  • searches the most recent 50 deposits for a matching txid
  • if the matched deposit is in CREDITED or ACCEPTED, transitions to COMPLETED

5.5 recover

Re-calls observe directly. Error codes:

6. Common Helpers

File: cex/common.py Shared context extraction functions and utilities used by all CEX executors.

6.1 Context extraction functions

Each function extracts a specific value from ExecutionContext. They use fallback priority.

6.2 build_provider_request_action

Serializes payload as canonical JSON, computes its SHA-256 hash, and creates PreparedAction.
  • payload_format = "provider_request"
  • signing_required = False
  • prepared_action_id = "{action_type}:{sha256_prefix_12}" (first 12 chars of the hash)

6.3 normalize_txid

Normalizes to lowercase because exchanges may vary in txid casing.

6.4 get_exchange_adapter

Returns an adapter instance with configured credentials via runtime.py:get_configured_adapter.

7. CEX Adapter Abstraction

7.1 TransferAdapterBase

File: cex/adapters/base.py ABC that every exchange adapter must implement:

7.2 Type definitions

File: cex/adapters/types.py

7.3 Error hierarchy

File: cex/adapters/errors.py All errors carry code, retryable, exchange_code, and exchange_message.

7.4 Adapter registry

File: cex/adapters/registry.py Registers adapter factories with a decorator pattern:

7.5 Built-in adapter list

The Free distribution ships five adapters: upbit, binance, bybit, coinbase, okx. Bithumb and Backpack are the additional Pro CEX adapters. Lighter is a Pro on-chain venue outside this CEX lane. Each __init__.py registers its adapter with the register_adapter("name")(AdapterClass) pattern.

7.6 Runtime adapter creation

File: cex/adapters/runtime.py
Creates the adapter by reading credentials from settings according to the exchange name. Binance applies base_url and recv_window_ms; Bybit applies base_url and recv_window_ms; Coinbase uses CDP API key (PEM) and Advanced Trade API; OKX applies base_url and also requires passphrase. Raises ValueError when credentials are missing.

7.7 Live withdrawal capacity

GET /v3/withdrawal-capacities is a read-only companion to executor preflight. Adapters implementing WithdrawalCapabilityProvider return chain-specific deposit/withdraw status, min/max/precision, fee policy, and typed provider quota evidence. The application service combines that with the configured account’s live withdrawable balance and the canonical SPOT reservation pool. This contract deliberately does not overload WithdrawalChance:
  • WithdrawalChance remains Q1 (can this provider accept a withdrawal now?) plus authoritative asset-unit min/max used by preflight.
  • remaining_daily_limit is deprecated diagnostic shape and is not a capacity authority.
  • Fiat/quote/global quotas retain their provider unit and never participate in asset arithmetic without a separately reviewed conversion contract.
  • Unknown minimum, maximum, precision, or fee evidence leaves effective capacity unknown; it is never reinterpreted as zero or unlimited.
  • Capacity is point-in-time advice, not a quota reservation or submit approval.
Bybit remainAmount is deprecated and ignored. OKX quota is rolling-24h USD and its fee policy uses current fee/feeCcy plus burningFeeRate fields rather than deprecated minFee/maxFee; Upbit uses remaining_daily_fiat; Bithumb’s asset-unit onetime/daily fields are pinned by redacted TIA/SEI/BLAST authenticated GET fixtures.

7.8 Fee-aware approval and reservation

A CEX withdrawal approval performs an authenticated, read-only fee quote before it creates the balance hold. QTG preserves the requested destination amount as approved_amount; approved_source_debit, the balance reservation, rolling outflow cap, and Pro budget ledger use gross same-asset source debit:
  • on_top: requested amount plus the adapter’s exact fee quote;
  • deducted_from_amount: requested amount;
  • provider_defined: approval fails closed unless the typed policy contains only a fixed zero fee and the amount-specific quote is also exactly zero; the adapter must also opt in to the reviewed exact-zero contract, where a missing fee is an error rather than a fallback zero. This arithmetic-neutral exception preserves provider_defined plus the canonical policy snapshot and authorization basis in evidence, and is rechecked before submit.
  • unknown: approval always fails closed.
The withdrawal node stores the approved net amount, fee, gross source debit, charge mode, unit, provider network, and observation time. Immediately before provider submit, the executor obtains another fresh quote and requires exact equality with both the approval evidence and the active reservation while holding the reservation-pool row lock. Fee drift, missing evidence, wrong units, or a net-only reservation terminalizes before provider I/O. Existing submitted or recovery nodes do not gain retry authority. Positive fee-on-top manual approval requires explicit operator max_source_debit; the dashboard and both live CEX CLIs expose that ceiling. During rollout, any unknown-gross legacy CEX approval quarantines new approvals for the same asset across transports until the largest active cap/policy window expires or an exact backfill is reviewed. Legacy in-flight rows park for manual intervention and retain their hold. Request/node locks prevent approval, rejection, or cancellation from racing an in-flight submit marker. OKX fixed-fee withdrawals are on_top, pinned by the JEP-714 live result (10 USDT credited, 1.5 USDT fee, 11.5 USDT source debit). A nonzero OKX burningFeeRate remains provider_defined until its distinct arithmetic is modeled, so it cannot pass fee-aware approval by inference. Upbit remains provider_defined; QTG does not infer its nonzero-fee charge semantics. An authenticated USDT/TRX GET on 2026-08-24 returned a fixed zero fee, which may use the exact-zero exception while it remains zero on both the approval and pre-submit reads. Any nonzero or structurally ambiguous Upbit fee still fails closed before provider POST.

8. Node Config Requirements

CEX executors read most settings from input_params and intent rather than node_config.

9. CI test coverage

CEX lane regressions are caught at three levels: Both drill harness files run in the per-PR CI subset (.github/workflows/test.yml) against the postgres:16 service container; combined wall-clock < 12s. They catch the “drill harness silently broken by unrelated merges” class — see Drill Harness CI Guard.

10. Drill Harness CI Guard

Background: between 2026-05-13 (Bybit drill #5) and 2026-05-28 (OKX drill #1), run_cex_live_drill was silently broken by 5 separate unrelated merges (network-class isolation, balance-fetcher account_type, dispatch invariants, V12 state-transition guard). Each broken merge was only detected at operator-driven live-drill time — 15-day undetected drift, 5+1 latent bugs accumulated. The drill harness CI guard closes this gap: every PR now exercises the same orchestration path that drill harness uses, with fake adapters substituted at the common.get_exchange_adapter chokepoint. Fake substitution patches all four import sites used by CEX executors:
Balance fetcher fakes are registered into balance_fetcher_registry via in-place dict mutation (not setattr) because qtg.application.services.balance imports the dict at module load time — the smoke must mutate the shared dict object so the service-layer binding sees the fakes. Bootstrap’s register_balance_fetcher is monkeypatched to a no-op so live credential-backed fetchers can’t overwrite the fakes during run_drill(). Out of scope for the smoke: wire-byte adapter correctness (already covered by unit tests), Pro/Bithumb path (Pro test suite is separate), live HTTP behavior.

11. Observe intervals

From application/services/observe.py:DEFAULT_OBSERVE_INTERVALS: If an executor returns retry_after_seconds, that value takes precedence.