Skip to main content

Outflow Velocity Cap

Every other fund-outflow guard in QTG answers “is this one movement correct?” — the address allowlist (is the destination registered), the signed-recipient invariant (does the signed transaction pay the approved address), the memo gate, the SSRF pin. None of them answer “is the total volume leaving this instance normal?” A movement can be individually legitimate on every axis and still be one drop in an abnormal flood:
  • Runaway strategy code — a controller or strategy bug emits well-formed withdrawals at high frequency to allowlisted destinations. Every request passes every per-movement guard. Nothing stops the bleed until the funds are gone. (This is QTG’s stated #1 product threat: protect the team from its own runaway strategy code.)
  • Stolen API / admin credential — an attacker drains funds one valid request at a time.
  • Operator fat-finger — a single movement with one extra zero.
The outflow velocity cap is the first-line aggregate brake: a per-token, rolling-window ceiling on total outflow, enforced in-process at the approval/reservation chokepoint. When a token’s window fills, every further over-cap approval for that token is auto-rejected for the rest of the window — no operator action, no global flag.
This is a Free core-safety feature. It rides the existing BalanceReservation ledger — no new table, no oracle, no external dependency. Enabling it never breaks an existing flow: a token with no configured ceiling is uncapped.

It is a speed bump, not a wall

QTG is operator-owned infrastructure, not a custodian. When an altcoin is hacked or depegs, you must be able to emergency-withdraw everything right now, exceeding the cap. The velocity cap is therefore a speed bump with an operator override (see Operator override), not a hard wall. The override gating is the load-bearing design decision — it requires a capability the runaway-code / stolen-strategy-key adversary does not have.

What it protects (and what it does not)

The cap defends adversaries that sit on the API surface. It does not defend a direct DB-write attacker — the running counter lives in the DB, so anyone who can write the DB can route around it. That is the already-accepted “DB-write = game over” trust boundary; the cap’s job is to bound the realistic API-surface adversary for a self-hosted operator.

Coverage: CEX lanes today

The cap structurally covers CEX-balance outflow lanes — the live Upbit ↔ Bithumb (+ Binance) withdrawal path, which is exactly the runaway-strategy / stolen-credential surface it targets. On-chain bridge lanes (CCTP, CCIP, USDT0, Stargate) are out of scope by construction and are not counted or capped today.
The reason is structural, not an oversight: the cap derives its counter from the BalanceReservation ledger. A movement only contributes to the counter if its approval produces a balance reservation. CEX withdrawals snapshot a venue balance and hold against it → they produce a counted reservation. On-chain bridge outflows are signer-bound and have no CEX balance snapshot, so the reservation step blocks rather than holds → they never produce a counted reservation. On-chain bridge outflow accounting is a separate, deferred concern. A coverage-probe gate test (tests/qtg/test_outflow_cap_coverage_probe.py) pins this boundary so that any future change giving bridge lanes a real reservation context must consciously update the cap — a new outflow lane cannot silently escape it. Two lanes are deliberately excluded even though they touch the ledger:
  • Agent Wallet Top-Up has its own envelope caps and separate authority (“top-up only”); counting it again would double-cap. Excluded.
  • Capital transfers move funds between venues you already own (internal rebalancing), not out the door to an external recipient. They are not the external blast radius the cap targets, so they are filtered out of both the counter and enforcement, keeping the two symmetric.

Configuration

Two environment variables, both in the Free MG_* settings. Ceilings live in env, not the DB — the API-surface adversary cannot change them; changing a ceiling needs host access plus a restart, which is acceptable for a safety ceiling (it is not a hot-tuned knob).
Configuration is fail-closed: malformed JSON, a non-decimal ceiling, or a window < 1 causes the server to refuse to boot — consistent with the other boot_validate_* invariants. A typo never silently disables the brake.

How usage is counted

The window usage for a token is the sum of each qualifying movement’s reserved amount, counted once, over the last window_hours:
  • A movement counts its reserved amount once, even after it completes (the funds did leave).
  • A movement that was released (cancelled / failed / rolled back) counts zero — it never left.
  • capital_transfer reservations are excluded.
Because the counter is derived from the reservation ledger rather than a separate tally, it cannot drift from movement truth — it is self-healing.

Breach behaviour — token self-throttle

When approving a movement would push its token over the ceiling:
  1. The approval is rejected at the reservation chokepoint. A typed OutflowCapExceeded error is raised (HTTP 409 Conflict); no reservation commits and the movement stays in PENDING_APPROVAL. No new terminal state — the movement is not failed, just held.
  2. The token self-throttles. Once the window is full, every subsequent over-cap approval for that token is auto-rejected for the rest of the window, with no operator action and no global flag. Other tokens, each under their own ceiling, are unaffected — a flooded USDC window does not block a normal BTC withdrawal.
  3. A loud outflow_cap_breached audit event is emitted (token, window usage, ceiling, requested amount, movement id, actor) for forensics and future notification delivery.
The operator who sees the breach can, if they want a harder stop, cancel the affected movements (POST /v3/movements/{id}/actions/cancel). The cap deliberately does not build a global kill-switch — that would halt unrelated legitimate outflows.
Concurrency is safe. The check takes a transaction-scoped, per-asset advisory lock before reading window usage, held until the approving transaction commits. Two parallel over-cap approvals for the same token cannot both read stale usage and both slip through — they serialize, and the second sees the first’s committed reservation. This is proven by a red-green concurrency test (tests/qtg/test_outflow_cap_race.py). Uncapped tokens skip the lock entirely, so the common path never contends.

Operator override

The emergency path — a withdrawal that must exceed the cap — is a single flag on the authenticated approve action, not on movement creation:
When cap_override is set and the approving role is operator or admin, the velocity check is skipped for that approval, and a loud outflow_cap_overridden warning is always logged — so even a malicious operator-role override is noisy. The placement is the security crux:
  • The override rides the approve endpoint, whose route is already restricted to admin/operator HMAC roles — the same authenticated surface that carries approver today. It is not on POST /v3/movements create, where an agent fills the intent / input_params dicts.
  • An agent / strategy role can never override. A cap_override echoed into a movement’s intent or input_params is ignored — the check reads the flag only from the authenticated approval action. A stolen strategy key is not operator-role, so it cannot set an honored override.
  • Auto-approve and Agent Wallet Top-Up callers never send cap_override, so their over-cap movements hard-reject unconditionally.
This is why the cap survives exactly the adversary it targets: the runaway strategy code or stolen strategy key that creates the flood has no way to wave it through.
Do not hand an operator- or admin-role API key to automated strategy code. The override boundary is only as strong as your role assignment — an agent-role key for bots, operator/admin only for humans (and qtg-mcp agent bindings). See RBAC: Three Roles.

Relationship to the Pro budget ledger

There is a mechanically similar Pro feature — the budget ledger — that also does windowed amount caps. They are kept separate on purpose: The velocity cap is always called a cap / safety ceiling, never a “budget” — to keep the two from being confused. It shares no code with the Pro ledger.

Operator checklist

Before relying on the cap, confirm you can answer:
  • Which tokens have a ceiling, and is each ceiling above your normal daily volume but below a runaway flood?
  • Is the window length right for your strategy cadence?
  • Are all automated callers on agent-role keys (so they cannot override)?
  • Do you have an alert wired to the outflow_cap_breached event? (The structured event ships today; the delivery channel — Telegram/Slack — is a separate, later feature that subscribes to it.)
  • Does your on-call know that cap_override: true on the approve action is the emergency-withdraw path, and that it is audited?