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
Recover resolves an ambiguous submit through a three-path flow (cex/withdrawal_action.py):
- 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. - Fast path.
exchange_withdrawal_idinprovider_context(with submit-attempt markers) →COMPLETEDwith scrubbed refs. - Reconciliation. Legacy
UNKNOWNnodes without submit-attempt markers are gated toUNKNOWN(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_FAILEDsurvives 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 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.
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:
WithdrawalChanceremains Q1 (can this provider accept a withdrawal now?) plus authoritative asset-unit min/max used by preflight.remaining_daily_limitis 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.
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 asapproved_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 preservesprovider_definedplus the canonical policy snapshot and authorization basis in evidence, and is rechecked before submit.unknown: approval always fails closed.
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 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)