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

If exchange_withdrawal_id exists in provider_context, return COMPLETED. Otherwise return FAILED(RECOVERY_FAILED).

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.

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.