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__.pysrc/qtg/infrastructure/executors/cex/withdrawal_action.pysrc/qtg/infrastructure/executors/cex/withdrawal_observe.pysrc/qtg/infrastructure/executors/cex/deposit_observe.pysrc/qtg/infrastructure/executors/cex/common.pysrc/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 theprovider_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:- Whitelist address check: call
adapter.get_whitelisted_addresses(asset)and verify the destination address and memo match - Withdrawal availability:
adapter.check_withdrawal_available(asset, network)-> verifyis_available - Minimum / maximum amount: validate against the
chance.minimum_amountandchance.maximum_amountrange - Wallet service state:
adapter.check_wallet_service(asset)-> verifyis_maintenance - Adapter-level validation (optional):
adapter.validate_withdrawal_request(WithdrawalRequest(...))-> called only when that method exists on the adapter
None if all validations pass. Returns ExecutionResult with FAILED when validation fails.
Error codes (preflight):
3.2 prepare
Packages the withdrawal request intoPreparedAction.
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
Callsadapter.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
3.4 observe
The action node’sobserve is a simple pass-through:
3.5 recover
Ifexchange_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 withaction_type = "cex_withdrawal_status".
4.3 submit
ReturnsSUBMITTED immediately. Actual polling happens in observe.
4.4 observe
Core polling logic: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-callsobserve 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 withaction_type = "cex_deposit_status".
5.3 submit
ReturnsSUBMITTED immediately.
5.4 observe
Core deposit matching logic:- compares
txidcase-insensitively (normalize_txidapplieslower()+strip()) - searches the most recent 50 deposits for a matching
txid - if the matched deposit is in
CREDITEDorACCEPTED, transitions toCOMPLETED
5.5 recover
Re-callsobserve 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 fromExecutionContext. They use fallback priority.
6.2 build_provider_request_action
payload as canonical JSON, computes its SHA-256 hash, and creates PreparedAction.
payload_format = "provider_request"signing_required = Falseprepared_action_id = "{action_type}:{sha256_prefix_12}"(first 12 chars of the hash)
6.3 normalize_txid
txid casing.
6.4 get_exchange_adapter
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
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 frominput_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_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
Fromapplication/services/observe.py:DEFAULT_OBSERVE_INTERVALS:
If an executor returns
retry_after_seconds, that value takes precedence.
Related documents
- Executor Overview
- Observe Probes (for on-chain confirmation after
deposit_observeif needed) - CCTP Lane
- Venue Smoke Test (operator-driven live smoke; complementary to the PR-time fake-adapter smoke above)