Skip to main content

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-id stores chain_id = NULL, which matches every chain. Only cctp_burn and the CCIP lane check demand an exact chain_id match and reject NULL rows. Both enforce it at dispatcher preflight, by separate paths (ccip_send takes an explicit branch in the dispatch address guard; cctp_burn goes through ADDRESS_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.
Register with --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 not INSERT directly — a raw SQL row bypasses the audit log, actor attribution, and the usable_after cooling-period stamp.
  • --chain-family: evm or cex
  • --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
The dashboard offers the same mutations behind the writer HMAC boundary: POST /dashboard/allowed-addresses, .../{id}/revoke, .../{id}/reactivate (operator UI: /governance/allowlist).

Cooling period

When MG_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

Soft-delete approach. History is preserved.

Inspect what is registered

Read-only, goes through 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

  1. 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.
  2. 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.
  3. CEX withdrawal, exchange-side: the executor additionally compares the destination against the exchange’s own address book and fails with ADDRESS_NOT_WHITELISTED on 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:
  1. If MG_ALLOWLIST_COOLING_PERIOD_ENABLED=true, wait out usable_after (visible in qtg allowlist list) before the first fund-moving attempt.
  2. Confirm the registered chain_id matches the lane — cctp_burn and CCIP reject any-chain (NULL) rows at dispatch.
Note that step 1 does not apply to every venue: adapters with 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 to evm — a withdrawal to an EVM address matches chain_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_attempts is 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_CSV configuration
  • 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

  1. Immediately disable the key suspected of exposure (api_client_keys.status='revoked')
  2. Issue and roll out a new key
  3. old key revoke
  4. Consider rotating the callback secret as well

Checklist before switching to live

  1. Validate API/approval/callback wiring with MG_WORKERS_ENABLED=false (dry_run)
  2. Verify that the on-chain template intent addresses are registered in allowed_addresses
  3. Verify that MG_CALLBACK_ALLOWED_HOSTS_CSV and the callback receiver secret match
  4. When MG_AUTH_ENABLED=true, verify that every caller has the correct HMAC key
  5. Rehearse the first transfer with a small amount