> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Allowlist Operator QA

> Three-gate walkthrough for qualifying a CEX withdrawal destination

# 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:

```bash theme={null}
export MG_WORKERS_ENABLED=false      # no dispatcher, no observer
export MG_ADMIN_CLI_KEY_ID=...       # secret in MG_ADMIN_CLI_KEY_SECRET
export MG_ADMIN_CLI_KEY_SECRET=...
```

***

## The gate map

| Gate                     | Where it lives                      | Failure code                                                   | When it fires           | Always on?         |
| ------------------------ | ----------------------------------- | -------------------------------------------------------------- | ----------------------- | ------------------ |
| 1. QTG allowlist         | `mainnet.allowed_addresses`         | `ADDRESS_NOT_ALLOWED`, `ADDRESS_IN_COOLING_PERIOD`             | dispatcher preflight    | yes                |
| 2. Template literal pin  | compiled plan node `config.address` | `CEX_ADDRESS_NOT_APPROVAL_PINNED`, `ADDRESS_INTENT_DIVERGENCE` | executor, before submit | yes                |
| 3. Exchange address book | the exchange's own whitelist        | `ADDRESS_NOT_WHITELISTED`                                      | executor preflight      | **no — see below** |

### 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`:

```python theme={null}
chain_family = context.node_config.get("chain_family", ChainFamily.evm.value)
```

**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:

```sql theme={null}
SELECT node_key, config ->> 'chain_family' AS chain_family
FROM <network_schema>.movement_plan_nodes
WHERE action_type = 'cex_withdrawal';
```

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:

```bash theme={null}
python -m qtg.interfaces.tools.seed_allowed_address \
  --chain-family <evm-or-cex-per-the-query> \
  --address <YOUR_TEST_ADDRESS> \
  --label qa_walkthrough \
  --reason "allowlist operator QA"
```

Expected: `{"status": "created", ...}`. Confirm:

```bash theme={null}
qtg allowlist list --chain-family evm
```

### 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:

```bash theme={null}
python -m qtg.interfaces.tools.seed_allowed_address \
  --chain-family cex \
  --address <SAME_ADDRESS_ONE_CHAR_CASE_FLIPPED> \
  --label qa_walkthrough_case_variant \
  --reason "allowlist QA normalization trap"
```

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:

```bash theme={null}
python -m qtg.interfaces.tools.revoke_allowed_address \
  --allowed-address-id <ID> --reason "allowlist QA revoke"
# then re-run the 1-A seed command
```

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:

```python theme={null}
is_address_allowed(session, chain_family, str(dest_addr), enforce_cooling_period=True)
```

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:

```bash theme={null}
curl -s "$QTG_API_URL/v3/plan-templates/<template_id>" | jq '.nodes[] | {node_key, action_type, config}'
```

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.

***

## Related

* [Runbook — Address Allowlist Management](/reference/runbook-v3) — registration commands and the route-opening checklist
* [CLI reference — `qtg allowlist list`](/reference/cli)
