When to Use
Use this skill when an automated system can move crypto out of a hot wallet without a human approving each transfer — an exchange withdrawal gateway, a treasury disbursement queue, or a prime-brokerage settlement job. It is the amount and rate layer of that gate: it answers "is this much, this fast, unusual for this account or for the hot wallet as a whole?" and returns a decision carrying the risk flags that produced it.
It exists because the controls that survive a stolen API key are the ones that bound aggregate damage. An attacker who passes authentication still has to move value, and value moved per unit time is the one signal a compromised credential cannot forge.
The engine enforces:
- Rolling per-account 1h and 24h USD caps, measured over continuous windows against a trusted clock.
- A global hot-wallet 1h cap that trips a latching circuit breaker — once tripped it stays tripped until
reset_hot_wallet_freeze(authorized_by=...). - A per-account size baseline (
Z = (A_req - mu) / sigma) that fails closed when the history is too thin for sigma to mean anything. - A cooling period on the destination address record, after first verifying the record actually binds to this request's account and address.
When NOT to Use
- As the allowlist itself. This engine does not own address registration, network scoping, memo binding, revocation, or address canonicalisation. It verifies the record it is handed and measures its age. Use
exchange-withdrawal-whitelist-enforcementfor the allowlist. - As the enforcer of last resort. This runs in your infrastructure in front of your signer. An attacker holding your custodian API credentials calls the custodian directly and never executes this code. Configure the equivalent policy at the custodian too — the local gate is the second layer, not the first.
- As AML transaction monitoring. Velocity caps here are a security control against credential compromise. Suspicious-activity reporting, sanctions screening, and Travel Rule obligations are a separate regime with separate thresholds — see
sanctions-screening-for-counterparties-and-instrumentsandkyc-aml-considerations-for-algo-trading-entities. - For a population of brand-new accounts. A Z-score needs a baseline. With a thin profile the engine reports the check as not-run and holds; if most of your accounts are new, the anomaly layer holds nearly everything and you need a different segmentation.
- As a durable ledger. State is in memory and is not thread-safe. A restart resets every rolling window to zero — precisely the gap an attacker wants.
Prerequisites
- Python 3.10+ and the standard library only (
datetime,dataclasses,enum,math,typing,logging). - A trusted clock for
evaluation_timestamp. Pass it explicitly for reproducible audits; never let the request supply it. - A USD-equivalent valuation for every request, from a price source you control. The caps are denominated in USD, so the oracle sits inside the trust boundary.
- Per-account
AccountHistoricalProfile(mean_withdrawal_usd,std_dev_usd,historical_count) computed point-in-time over a stated lookback. - Allowlist records carrying the
account_idandaddressthey belong to, so the binding check can run. - Durable storage behind
withdrawal_ledger, and serialisation around the evaluate-then-submit sequence. - An operator path for both exits:
release_held_withdrawalfor an approved hold,cancel_held_withdrawalfor a rejected one.
Workflow
- Evaluate Against a Clock the Request Cannot Influence: Pass
evaluation_timestamp. Every window bound and every address age is measured from it.WithdrawalRequest.timestampis client-asserted, recorded for the audit trail, and skew-checked intodecision.warnings— never used as the clock. A velocity window you can slide by writing a number into the request is not a limit. - Replay Before Scoring: A gateway timeout means unknown, not failed. The engine caches the decision per
request_idand replays it on retry. Without this, the retry of an approved withdrawal is scored a second time against a ledger the first attempt already updated — it double-counts velocity and can return a different decision than the one already acted on. - Check the Latch, Then the Global Cap: If the breaker is already latched, reject immediately. Otherwise test the global 1h hot-wallet total; breaching it latches the freeze and returns
REJECTED_FREEZE. A breaker that re-arms itself when the rolling window decays is not a breaker — the attacker waits an hour and resumes. - Score Per-Account Velocity, Then Size: Test the rolling 1h and 24h account totals. Then compute
Z = (A_req - mu) / sigma, but only whenhistorical_count >= min_profile_observationsandsigma > 0. When it cannot run, recordanomaly_zscore=Noneand flagINSUFFICIENT_PROFILE_HISTORY— "did not run" is not "passed". - Bind the Address Record Before Trusting Its Age: Verify
whitelist_record.account_idand.addressmatch the request. A record is evidence about one (account, address) pair; measuring the age of a record fetched for some other address approves a withdrawal to an address nobody allowlisted. A record that does not bind is discarded as evidence, flaggedWHITELIST_RECORD_MISMATCH, and held — never auto-released. - Hold, or Approve and Consume Capacity: Any flag produces
TIMELOCK_HOLDand parks the request awaiting review. An approved request enters the ledger stamped with the trusted clock. - Account for Released Holds: A held withdrawal consumed no capacity because it moved no funds. When review releases one, call
release_held_withdrawal(request_id, authorized_by=...)so it enters the ledger. Skip this and anyone who can get holds released has an unmetered channel straight through every rolling cap.
Full procedure: see
references/workflows.md. Sourced thresholds and vendor mapping: seereferences/standards.md. Printable checklist: seeassets/checklist.md.
Common Pitfalls
- Measuring the Window From the Request's Own Timestamp: If velocity is computed from
request.timestamp, anything that can build a request escapes every cap by claiming to be a week in the future. Use a clock the request cannot influence. - Leaving the Rolling Window Unbounded Above: A window written as
entry.timestamp >= cutoffwith no upper bound counts a single future-dated entry toward every window until real time overtakes it — silently inflating velocity and eventually tripping the global breaker on legitimate flow. The window is[now - T, now], closed at both ends. - Resetting Velocity at a Calendar Boundary: A limit that resets at midnight UTC lets an attacker take 100% of the daily cap at 23:59 and 100% again at 00:01. Use continuous rolling windows.
- Trusting a Whitelist Record Without Binding It to the Request: Checking
record.is_whitelistedandrecord.added_timestampwhile never comparingrecord.addresstorequest.destination_addressapproves a transfer to an arbitrary address whenever the caller's lookup is sloppy. Bind account and address, then measure age. - Letting NaN Decide:
float('nan') >= 3.0isFalse, so a NaN mean or sigma does not raise — it silently answers "not anomalous" and switches the anomaly control off while every dashboard still shows it enabled. Validate finite, and raise. - Treating a Thin Profile as a Pass: Skipping the Z-score when
historical_countis low fails open on exactly the accounts with no track record. Report the check as not-run and hold. - Setting a 3-Sigma Rule on a Handful of Observations: With
nobservations and sigma estimated from them, no in-sample point can score above(n-1)/sqrt(n)— the Grubbs bound: 1.79 at n=5, 2.85 at n=10. AZ >= 3.0rule on a 5-observation profile was never calibrated against anything the account actually did; 3.0 first becomes attainable at n=11. The engine refuses that configuration at construction. - Assuming Withdrawal Amounts Are Normal: Sizes are right-skewed and heavy-tailed, so a Z threshold does not deliver its Gaussian false-positive rate. Calibrate against your realised alert volume, or score
log(amount). Treat Z as a ranked outlier score, not a probability. - A Circuit Breaker That Self-Resets: If the freeze lifts when the rolling window decays, the attacker pauses and continues. Latch it and require a named human to reset.
- Forgetting That a Released Hold Moves Funds: Holds sit outside the velocity ledger because they have not been disbursed. If a manual release does not feed back into the ledger, the released amount consumes no capacity.
- Capping in Crypto Units Instead of USD: A 10 BTC cap is a different amount of risk each week. Denominate in USD equivalent — and remember the price oracle then becomes a security dependency.
- Losing the Ledger on Restart: In-memory velocity state means a process restart zeroes every window, so a crash loop becomes a withdrawal window. Persist it.
Verification
- Hand the engine a valid whitelist record for
0xSAFEwhile the request's destination is0xATTACKER, and confirmTIMELOCK_HOLDwithWHITELIST_RECORD_MISMATCH— notAPPROVED. Repeat with a record whoseaccount_idbelongs to another account. - Submit a profile with
mean=float('nan')orsigma=float('nan')and confirmVelocityEngineError, not an approval. Same foramount_usd=float('nan'),0.0, and a blank destination address. - Submit
historical_count=4and confirmTIMELOCK_HOLDwithINSUFFICIENT_PROFILE_HISTORYandanomaly_zscore is None. Repeat withstd_dev_usd=0.0. - Construct with
anomaly_zscore_threshold=3.0, min_profile_observations=5and confirmVelocityEngineError; confirmmin_profile_observations=11is accepted, and thatmax_attainable_in_sample_zscore(5) == 1.7888543819998317. - Trip the global cap, then submit a small, whitelisted, in-baseline request 48 hours later — after every rolling window has decayed — and confirm it is still
REJECTED_FREEZE. Callreset_hot_wallet_freeze("soc@example.com")and confirm the next request isAPPROVED; confirm an empty authoriser raises. - Evaluate the same
request_idtwice and confirm the identical decision object comes back, the ledger holds one entry, and velocity counted the amount once. - Approve a request, then submit a second whose
timestampclaims to be 10 days ahead, and confirm the earlier amount still counts toward the 1h window and a clock-skew entry appears inwarnings. Confirm the ledger entry is stamped with the trusted clock, not the claimed time. - Release a held withdrawal via
release_held_withdrawaland confirm the amount then counts toward the rolling window and constrains the next request; confirm a cancelled hold can never be released. - Run
python -m unittest discover -s skills/withdrawal-velocity-limits-and-anomaly-detection/scriptsand confirm a 100% pass rate.
Related Skills
exchange-withdrawal-whitelist-enforcementmulti-signature-approval-for-large-transfershot-cold-wallet-split-for-trading-botstest-transaction-verification-before-large-transferson-chain-transaction-monitoring-for-anomaliessegregation-of-duties-for-custody-operationsapi-key-least-privilege-audit-toolkill-switch-and-drawdown-circuit-breakers