Spend velocity

A per-subject cap on how much value one account can move within a rolling window. The gate records the attempt and measures the window in one step, so a burst of concurrent operations can't slip past the limit.

Source src/trust.ts#L128riskSubjectsrc/trust.ts#L150attemptMinorsrc/economy.ts#L524screenRisksrc/engines/postgres.ts#L1450recordVelocity

A compromised or misbehaving account can try to move money faster than anyone reacts — draining a wallet or claiming payouts in a tight loop. Spend velocity is the gate that caps how much value one subject can move within a rolling window, checked before any money moves.

The idea

Every money-moving operation names a subject: the account whose recent activity is being limited. Before the operation posts, the gate records the attempt's amount and sums that subject's amounts over the recent window. If the total is over the limit, the operation comes back rejected with RISK_DENIED; otherwise it proceeds to post.

Amounts are summed in CREDIT minor units, and the limit and window come from the velocityLimitMinor and velocityWindowMs configuration. A spend that would push the buyer over the window never moves money:

const outcome = await economy.submit(spendOperation);
// → { status: "rejected", reason: "RISK_DENIED" }   when the window is over the limit

Why it exists

A naive limit reads the recent total, compares it, then records the new attempt — three steps a second operation can interleave. Two concurrent spends read the same stale total, both pass, and together they blow the limit. That read-then-write race is the failure the gate is built to close, so it records and measures in one indivisible step per subject.

Two consequences fall out of that design, and both matter for abuse:

  • Denied attempts still count. The amount is summed before the limit decision, so a flood of over-limit attempts keeps adding to the window. An attacker can't probe the limit for free, and a burst of rejections throttles itself.
  • The record outlives a rollback. The attempt is written on a connection independent of the money transaction, so an operation that rolls back still leaves its velocity mark. Tripping a rollback can't erase the footprint.

What counts as a subject

riskSubject decides which operations are gated and whose account is the subject. An operation that moves no tracked subject's funds returns null and is always allowed.

OperationSubjectAmount counted
spend, subscribethe buyerthe price
topUp, grantPromo, requestPayoutthe userthe amount
everything elsenot gated

How it's enforced

screenRisk runs inside the submit pipeline, before the money transaction opens. It hands the attempt to recordVelocity, which does the whole record-and-measure as one unit: it takes a per-subject lock, dedup-inserts the attempt keyed on the idempotencyKey so a retry never double-counts, sums the subject's amounts where the timestamp falls inside the window, and returns that total. screenRisk compares it to velocityLimitMinor and rejects when it's over.

What relies on it

See also