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

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.

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