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.
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 reason is structural, not an oversight: the cap derives its counter from theBalanceReservation 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 FreeMG_* 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).
How usage is counted
The window usage for a token is the sum of each qualifying movement’s reserved amount, counted once, over the lastwindow_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_transferreservations are excluded.
Breach behaviour — token self-throttle
When approving a movement would push its token over the ceiling:- The approval is rejected at the reservation chokepoint. A typed
OutflowCapExceedederror is raised (HTTP409 Conflict); no reservation commits and the movement stays inPENDING_APPROVAL. No new terminal state — the movement is not failed, just held. - 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
USDCwindow does not block a normalBTCwithdrawal. - A loud
outflow_cap_breachedaudit event is emitted (token, window usage, ceiling, requested amount, movement id, actor) for forensics and future notification delivery.
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: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
approvertoday. It is not onPOST /v3/movementscreate, where an agent fills theintent/input_paramsdicts. - An agent / strategy role can never override. A
cap_overrideechoed into a movement’sintentorinput_paramsis 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.
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_breachedevent? (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: trueon the approve action is the emergency-withdraw path, and that it is audited?
Related pages
- Security Model — where the cap sits in defense-in-depth
- Approval/Risk Policy Guide — per-movement and per-strategy policy layers
- Approval/Risk Policy Plane
- Movement Lifecycle — the
PENDING_APPROVALgate the cap holds at