Skip to main content

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:
  1. Maps the gates, including where each one is not enforced.
  2. 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.
What it does not do: it does not make the gates fail. Driving a real 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 against qtg_v3 mutates your live mainnet.allowed_addresses and writes audit rows. Revoked rows are soft-deleted and stay in the table forever.
If you want a throwaway database instead, note that 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)
For those venues there are effectively two gates, not three. Whatever the exchange enforces server-side still applies, but QTG will not have checked it and will not fail with 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 in ONCHAIN_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 to evm:
It varies across the committed templates, which is exactly why you cannot infer it. Of the ten seeders that build a 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:
A 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:
Expected: {"status": "created", ...}. Confirm:

1-B. The normalization trap

The steps below register under cex 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:
Expected: {"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:
Expected: {"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_send takes an explicit branch that derives both chain IDs from the persisted executor binding’s selectors and sets require_chain_match = True. An unmapped or empty selector fail-closes the node.
  • cctp_burn reaches strict matching through _CCTP_CHAIN_MATCH_ACTION_TYPES, derived from chain_match=True in ADDRESS_GUARD_POSTURE.
Both also have creation-time enforcement, with one caveat worth knowing: the CCTP strict creation check is best-effort and is skipped when the static chain IDs are missing or malformed (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:
With 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

With MG_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
The consequence operators miss: changing a withdrawal destination is a new template version, not just a new allowlist row. Allowlisting a new address does nothing on its own — the plan still pins the old literal. To read the pinned value without mutating anything:
The route catalog will not tell you this — it carries 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: cli vs dashboard
  • actor_type: operator vs admin_cli — note the stored value is operator, even though the constructor is named legacy_operator()
Where to read them matters: the ordinary /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.
  1. Label convention — what goes in --label so a row is identifiable a year later?
  2. chain_id policy — are EVM rows always chain-scoped? Is an any-chain row ever acceptable?
  3. Casing convention for cex-family addresses, given 1-B.
  4. 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?
  5. Venues without gate 3 — do Coinbase / OKX / Backpack destinations need a compensating control, given QTG never checks their address book?
  6. Who may register — CLI only, or the dashboard writer key too? (The same question is blocking the allowlist CLI write half.)
  7. MG_CLI_AUTH_REQUIRED=true — when does it become mandatory?
  8. Address-change procedure across every gate, written down: exchange whitelist → allowlist row → new template version → re-approval.