Runbook (v3 / qtg)
Address Allowlist Management
Fund-moving executors are protected by a DB-level address allowlist (allowed_addresses). This covers the on-chain executors (Gateway, CCTP, CCIP,
Stargate, USDT0, plain ERC-20 transfer) and CEX withdrawals.
What the allowlist does and does not scope
A row is(chain_family, address, chain_id?) — there is no token column, no
direction column, and no amount column. Consequences worth internalizing
before you register anything:
- An allowlisted address accepts any token. Registering an address “for USDC” does not restrict it to USDC.
- Omitting
--chain-idstoreschain_id = NULL, which matches every chain. Onlycctp_burnand the CCIP lane check demand an exactchain_idmatch and rejectNULLrows. Both enforce it at dispatcher preflight, by separate paths (ccip_sendtakes an explicit branch in the dispatch address guard;cctp_burngoes throughADDRESS_GUARD_POSTURE). The CCTP strict creation check is best-effort and is skipped when the static chain IDs are missing or malformed, so dispatch is the gate that always holds. - Amount caps live in the auto-approve policies, not here.
--chain-id unless you deliberately want an any-chain row.
Why there is no token axis
The allowlist answers “may funds leave to this address”, not “which token”. Token scoping is not missing from the system — it lives one layer up, in the template and in the per-lane registries. Which of those applies depends on the lane:
The Stargate (Pro) and USDT0 lanes are not in this table: their templates are
materialized from a deployment/pool registry rather than hand-authored, so the
token question there is a registry question. Check the registry entry, not the
node config.
So a caller can choose the token on some lanes. What a caller cannot do is
displace a token the template already pinned: runtime
input_params.node_config
is merged under the template’s config — supplement_node_config_from_input_params
returns {**caller_supplied, **template_config} — so caller input fills only the
keys a template left out.
That is the operationally important qualifier: nothing forces a template to pin
its token. A node authored without its token field accepts a caller-supplied one
(the QA multi-hop drill seed’s cctp_burn node is shaped exactly that way — it
carries asset and chain IDs but no burn_token_address). A token column on
allowed_addresses would not close that; the fix belongs at template-authoring
time; that is tracked as separate work.
Practical reading: registering an address “for USDC” does not restrict it to
USDC, and the allowlist is not where you should look for that restriction. Review
the template’s token fields — and, for CCIP/Stargate/USDT0, the registry entry —
with the same care you give the allowlist row.
Register an address
Use the admin CLI (audited, sets the cooling period, records actor attribution). Do notINSERT directly — a raw SQL row bypasses the audit log, actor
attribution, and the usable_after cooling-period stamp.
--chain-family:evmorcex--address: EVM addresses are normalized to lowercase automatically--chain-id: omit only for a deliberate any-chain row (see above)--label: operator reference (for example,my_base_wallet,treasury)- The unique constraint is on
(chain_family, address, chain_id)— the same address may be registered once per chain - Re-running against an inactive row reactivates it
POST /dashboard/allowed-addresses, .../{id}/revoke, .../{id}/reactivate
(operator UI: /governance/allowlist).
Cooling period
WhenMG_ALLOWLIST_COOLING_PERIOD_ENABLED=true (default: false), a newly
registered, reactivated, or chain-migrated address is unusable for fund movement
until MG_ALLOWLIST_COOLING_PERIOD_HOURS (default 24) have elapsed. Fund-moving
paths then fail with ADDRESS_IN_COOLING_PERIOD; read-only paths (policy
preview, status) are not blocked.
Disable an address
Inspect what is registered
GET /dashboard/allowed-addresses, and needs the
normal QTG_* CLI env (see CLI reference). The direct-DB
equivalent — usable without a running server — is
python -m qtg.interfaces.tools.list_allowed_addresses --include-inactive,
which additionally offers --null-chain-id to isolate any-chain rows.
There is no qtg allowlist add / revoke. Mutations stay on the audited
surfaces above; whether a convenience CLI mutation should exist is an open
security decision.
Validation points
- At movement creation: on-chain intent addresses are checked; a non-allowlisted address returns HTTP 422. A CEX-only template has no allowlist gate at this point.
- Dispatcher preflight: re-checked immediately before execution, for
on-chain nodes and
cex_withdrawal, so an allowlist change between creation and execution is honored. This is where CEX withdrawal destinations are enforced. - CEX withdrawal, exchange-side: the executor additionally compares the
destination against the exchange’s own address book and fails with
ADDRESS_NOT_WHITELISTEDon a mismatch.
Opening a route — registration checklist
A destination is only usable once it is registered in every place that gates it. Missing one is the usual cause of a route that passes creation and then fails at dispatch.
Step 3 has no shortcut.
qtg template promote is a mutation, not a check, and
the route catalog only carries venue-level metadata (destination_venue), not
destination addresses — neither one can tell you the template points at the
address you just allowlisted. Read the template detail, or the proposal payload
before promoting.
Order matters only for step 1 — exchange whitelists often carry their own
waiting period, so start there. Then:
- If
MG_ALLOWLIST_COOLING_PERIOD_ENABLED=true, wait outusable_after(visible inqtg allowlist list) before the first fund-moving attempt. - Confirm the registered
chain_idmatches the lane —cctp_burnand CCIP reject any-chain (NULL) rows at dispatch.
supports_address_book_lookup=False (Coinbase, OKX, Backpack) skip the
exchange-whitelist preflight entirely, so QTG never checks their address book.
Before the first route opens against real capital, read
Allowlist Operator QA — it maps where each
gate is and is not enforced, and walks the registration semantics safely.
Notes
- A CEX withdrawal therefore needs the destination registered in two places:
the exchange’s own whitelist (registered by a human on the exchange site) and
QTG’s
allowed_addresses. QTG has no surface for the former. - CEX withdrawal nodes match against the node’s
chain_family, which defaults toevm— a withdrawal to an EVM address matcheschain_family = 'evm'rows. - The zero address (
0x0000...) is naturally blocked unless it is registered - For self-transfer-only operation, register only your own wallet addresses
Callback
Callback receiver failure response
- Callbacks are outbox-based and retry with exponential backoff (
2^min(attempts, 6)seconds) - When
max_attemptsis exceeded, the callback moves to the DLQ (Dead Letter Queue) - If the callback URL host is not in the allowlist, it goes directly to the DLQ
- On retry, only the header timestamp is refreshed; the payload and nonce remain unchanged
Callback checks
- Whether the callback receiver secret (
MG_CALLBACK_HMAC_SECRET) matches - Verify the
MG_CALLBACK_ALLOWED_HOSTS_CSVconfiguration - On the receiver side, verify the signature against the raw body (do not re-serialize after JSON parsing)
- Signature schema: see
docs/callback-verification-contract.md
Response by state
Operator actions
- retry:
POST /v3/movements/{id}/actions/retry— returns FAILED/UNKNOWN nodes to READY - resume:
POST /v3/movements/{id}/actions/resume— resumes after manual intervention - cancel:
POST /v3/movements/{id}/actions/cancel— immediate for PENDING_APPROVAL/APPROVED; in-progress execution requires an on_cancel edge
Security key response
- Immediately disable the key suspected of exposure (
api_client_keys.status='revoked') - Issue and roll out a new key
- old key revoke
- Consider rotating the callback secret as well
Checklist before switching to live
- Validate API/approval/callback wiring with
MG_WORKERS_ENABLED=false(dry_run) - Verify that the on-chain template intent addresses are registered in
allowed_addresses - Verify that
MG_CALLBACK_ALLOWED_HOSTS_CSVand the callback receiver secret match - When
MG_AUTH_ENABLED=true, verify that every caller has the correct HMAC key - Rehearse the first transfer with a small amount