Allowlist Operator QA — three-gate walkthrough (no-live)
A CEX withdrawal destination has to satisfy up to three independent gates. Only the first is an allowlist-registry row, and — the part most operators miss — the third does not exist on every exchange. This document does two things:- Maps the gates, including where each one is not enforced.
- Walks the registration semantics you can exercise safely — seed, list, duplicate, reactivate, revoke — against a database, with no movement, no dispatch, and no exchange write.
ADDRESS_NOT_ALLOWED or ADDRESS_NOT_WHITELISTED needs a movement created and
dispatched, which is outside this walkthrough’s no-live scope. Gates 2 and 3 are
documented here, not exercised.
Claims below were verified against the code on 2026-07-29 and cite the owning
symbol rather than a line number. If a claim and the code disagree, the code
wins — file the correction.
Before you start
This writes real rows. Seeding and revoking againstIf you want a throwaway database instead, note thatqtg_v3mutates your livemainnet.allowed_addressesand writes audit rows. Revoked rows are soft-deleted and stay in the table forever.
qtg init cannot give you
one: it hardcodes MG_DATABASE_URL to
postgresql+asyncpg://qtg:qtg@localhost:5432/qtg_v3 (init_cmds.py, overlay
builder) and has no arbitrary-database option. A real scratch lane means
creating and migrating a separate database yourself and provisioning an
admin CLI key in it — the exported MG_ADMIN_CLI_KEY_ID / MG_ADMIN_CLI_KEY_SECRET
are verified against a row in that database (_admin_cli_auth.py), so a fresh
database without the key fails authentication before the walkthrough starts.
Given that cost, the usual choice is to run against qtg_v3 with a throwaway
destination address. Note what you are left with: parts 1-A through 1-C end with
two active rows (the original, reactivated after revocation, plus the
case-variant row from 1-B). Revoke both when you finish if you do not want them
live.
Required posture:
The gate map
Gate 3 is not universal
withdrawal_action.py runs the exchange-whitelist preflight only when the
adapter reports supports_address_book_lookup (default True on the adapter
base). Adapters that set it to False skip the check entirely and log
“Skipping whitelist preflight”:
- Coinbase (
coinbase_transfer_adapter.py) - OKX (
okx_transfer_adapter.py) - Backpack (
backpack_transfer_adapter.py)
ADDRESS_NOT_WHITELISTED. Do not carry a mental model of
“QTG always double-checks the exchange whitelist” across venues.
The general-purpose venue command does not read the exchange address book.
get_whitelisted_addresses exists on the adapter protocol (adapters/base.py),
but qtg venue probe never calls it — the probe exercises
check_wallet_service, check_withdrawal_available, get_deposit_address,
list_deposits, and list_withdrawals (venue_probe.py). A green probe says
nothing about gate 3.
Specialised surfaces do read it, all of them requiring live venue credentials:
the withdrawal executor’s own preflight (withdrawal_action.py), the CEX submit
safety drill (cex_submit_safety_drill.py), and the opt-in read-only smoke tools
for Bybit and OKX (bybit_readonly_smoke.py with --include-whitelist,
okx_readonly_smoke.py, bybit_slice_g_live_probe.py). Outside those, gate 3 is
verified on the exchange site.
Gate 1 does not fire at movement creation for CEX-only templates
Creation-time allowlist validation runs only for nodes inONCHAIN_ACTION_TYPES
(movements.py). CEX_ADDRESS_CHECK_ACTION_TYPES is referenced only by
dispatch.py. A CEX-only movement is therefore created happily and fails at
dispatch. “Movement created” does not mean “destination approved.”
Part 1 — Registration semantics (safe to run)
1-A. Register normally
The family is a property of the template, not of the venue type. Dispatch reads it from the withdrawal node’s own config and falls back toevm:
cex_withdrawal node, only
seed_cex_to_cex_usdt_template.py sets "chain_family": "cex"; the other nine
omit it and therefore dispatch under evm. Guess wrong in either direction and
the row never matches — the withdrawal fails ADDRESS_NOT_ALLOWED at dispatch,
after the movement was created and approved. Read the template first:
There is no read CLI for this — GET /v3/plan-templates/{template_id} takes a
UUID, not a template key — so query it:
NULL in the chain_family column means the node omits it, which dispatch
reads as evm. An empty result means something different and worse — no
cex_withdrawal node matched, so you are querying the wrong schema or the
template is not seeded. Register the family dispatch will actually use:
{"status": "created", ...}. Confirm:
1-B. The normalization trap
The steps below register undercex to demonstrate non-evm normalization,
not because CEX destinations belong to that family — see 1-A. These are
throwaway QA rows.
normalize_address lowercases only the evm family; other families keep
their casing. (The seed service does strip surrounding whitespace first, so it is
“case-preserving”, not literally verbatim.)
Re-seed the same address with one character’s case flipped:
{"status": "created", ...} — a second row, not unchanged,
because the existing-row lookup matches on the normalized address exactly.
qtg allowlist list --chain-family cex now shows two rows for what a human reads
as one address.
Why it matters: the gate-2 pin comparison is a case-sensitive exact match after
strip (cex/common.py). A case variant can allowlist cleanly and still diverge
at the pin. Pick a canonical casing convention and hold to it.
Contrast: repeat with --chain-family evm and a mixed-case EVM address — that one
returns unchanged, because both forms normalise to the same lowercase string.
1-C. Duplicate and reactivate semantics
Re-run 1-A verbatim. Expected:{"status": "unchanged", ...} and no audit
row — the service returns before writing one (allowlist_admin.py). The
dashboard POST equivalent returns HTTP 409 instead.
Now revoke and re-seed:
{"status": "reactivated", ...} with a freshly computed usable_after.
The intent is anti-key-theft: a stolen key cannot revoke-then-re-add to skip the
time lock. This only bites when cooling is actually enabled —
_cooling_until() returns None otherwise, making reactivation immediate and
the anti-theft property nonexistent.
Check the effective value before you rely on it. The Settings fallback for
MG_ALLOWLIST_COOLING_PERIOD_ENABLED is false when the variable is unset, but
the shipped .env.example sets it to true and qtg init does not override
that — so a standard installation has cooling on. “Default” therefore depends
on whether you mean the code fallback or the generated env file.
1-D. chain_id scoping
Omitting--chain-id stores chain_id = NULL, which matches every chain of the
family. Two lanes demand an exact (address, chain_id) match and reject NULL
rows at dispatcher preflight, through two different code paths in
dispatch.py’s address guard:
ccip_sendtakes an explicit branch that derives both chain IDs from the persisted executor binding’s selectors and setsrequire_chain_match = True. An unmapped or empty selector fail-closes the node.cctp_burnreaches strict matching through_CCTP_CHAIN_MATCH_ACTION_TYPES, derived fromchain_match=TrueinADDRESS_GUARD_POSTURE.
movements.py), so a late-filled chain ID means
creation lets it through and dispatch is the gate that holds. CCIP’s creation-time
enforcement comes from _validate_ccip_registry_lanes, which resolves exact
source and destination chain IDs.
Register EVM rows chain-scoped and none of this can surprise you.
CEX withdrawals are the exception, and it runs the other way. Dispatch calls
the allowlist for a CEX destination without a chain_id at all:
chain_id=None and require_chain_match=False the lookup applies no
chain filter — any active row for that (chain_family, address) matches,
whatever chain_id it was stored with. So --chain-id does not narrow a CEX
registration; it only records intent. Do not treat a chain-scoped CEX row as a
control. If you need a CEX destination limited to one network, that limit has to
come from the template and the exchange-side whitelist, not from this column.
1-E. Cooling period
WithMG_ALLOWLIST_COOLING_PERIOD_ENABLED=true (on in a standard installation
— see 1-C), a freshly
created, reactivated, or chain-migrated row is unusable for fund movement until
usable_after (MG_ALLOWLIST_COOLING_PERIOD_HOURS, default 24).
Read paths are unaffected — qtg allowlist list does not filter on
usable_after, so the row is visible immediately. On a fund-moving path the
lookup first searches for a usable row; only if none exists and a cooling one
does will it raise ADDRESS_IN_COOLING_PERIOD. A usable matching row wins over a
cooling one.
Operational consequence when it is on: a registration lead time. A
destination decided today is not usable today, and any runbook that says “add the
address and withdraw” is wrong.
1-F. Revoke is not an emergency stop
Revoke blocks future dispatch. It does not touch a withdrawal already submitted to the exchange. Nor does anything else in QTG.cancel_movement changes QTG-side state only, and
while adapters define cancel_withdrawal, nothing calls it — the method has
definitions and no callers. There is no generic movement pause either; the only
pause_* operation in the tree is Hyperliquid strategy-scoped. Once a withdrawal
is submitted, stopping it means intervening at the venue itself.
Part 2 — Gate 2: the template literal pin (documented, not exercised)
The withdraw node’s destination must be an approval-pinned literal in the compiled plan.cex/common.py reads ctx.compiled_node_config["address"] — the
raw pre-resolution config covered by compute_compiled_plan_hash — and rejects:
- absent / empty / non-string →
CEX_ADDRESS_NOT_APPROVAL_PINNED - a
$input:/$ref:token, including whitespace-padded forms (the value is stripped before the token check) →CEX_ADDRESS_NOT_APPROVAL_PINNED - a literal diverging from a non-empty runtime
input_params["address"]→ADDRESS_INTENT_DIVERGENCE, compared case-sensitively after strip
destination_venue, not
destination addresses.
Part 3 — Audit verification
create, reactivate, and revoke each write an audit row in the same
transaction as the mutation. unchanged writes nothing.
Filter on entity_type = 'allowed_address' and confirm you can distinguish:
source:clivsdashboardactor_type:operatorvsadmin_cli— note the stored value isoperator, even though the constructor is namedlegacy_operator()
/v3/audit/events response (and so
qtg audit query) omits source and actor_type. The registry-audit
surface exposes both. If you cannot tell from the trail who registered a
destination and through which surface, that is a finding worth filing.
Allowlist changes also enqueue a security_allowlist_change notification
(severity warning) — but only when notifications are both enabled and
configured; an enabled-but-unconfigured channel is dropped without enqueue.
Part 4 — Policy decisions this QA should produce
These are operator decisions. This document deliberately does not answer them.- Label convention — what goes in
--labelso a row is identifiable a year later? - chain_id policy — are EVM rows always chain-scoped? Is an any-chain row ever acceptable?
- Casing convention for
cex-family addresses, given 1-B. - Cooling period — on or off, and how many hours? Check the effective value rather than assuming (1-C): the code fallback is off but a standard generated env turns it on. Off also disables the revoke-then-re-add anti-theft property. What lead time does enabling it impose on opening a route?
- Venues without gate 3 — do Coinbase / OKX / Backpack destinations need a compensating control, given QTG never checks their address book?
- Who may register — CLI only, or the dashboard writer key too? (The same question is blocking the allowlist CLI write half.)
MG_CLI_AUTH_REQUIRED=true— when does it become mandatory?- Address-change procedure across every gate, written down: exchange whitelist → allowlist row → new template version → re-approval.
Related
- Runbook — Address Allowlist Management — registration commands and the route-opening checklist
- CLI reference —
qtg allowlist list