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 0) have elapsed. Zero is a
pre-live/operator-QA no-delay default; set a positive duration explicitly before
live use. During a positive window, fund-moving paths 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.
Catalog readiness is a read-only snapshot
GET /v3/route-catalog?include_readiness=true and template detail with the
same opt-in report two separate aggregates. configuration_readiness covers
only persisted activation, graph/executability, bindings, static risk/fee/
constraint/approval definitions, and completion shape. It can be pass while
execution_readiness is unknown or fail because a concrete amount,
movement inputs, approval outcome, current fee, fresh health, or destination
evidence is missing. Neither aggregate creates authority or replaces movement
creation, approval, preflight, or dispatch checks.
The catalog is DB-only and freshness-bound. It never constructs a CEX adapter
or queries a provider. QTG allowlist coverage and venue whitelist membership
are separate cards: a venue declaration comes only from the static JEP-748
adapter-capability registry and remains unknown until an explicit provider
probe exists. For a CEX withdrawal, the reported lookup is the current dispatch
shape — node chain_family or runtime evm default, no chain-id filter, and
cooling enforced. A missing movement destination is unknown, not a pass based
on unrelated allowlist rows; a missing CEX raw config literal separately blocks
the current submit-time approval pin.
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 reads their address book.
This does not mean the venue lacks submit-time enforcement: OKX permits API
withdrawals only to addresses/accounts verified through Web/App.
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