Spend velocity
A per-subject cap on how much value one account can move within a rolling window.
A “rate limit” caps how fast something is allowed to happen — like a site locking you out after too many password tries, or a bank flagging a sudden run of card swipes. This page applies that idea to money: one account can only move so much in a short window, so a stolen or runaway one can't empty a wallet before anyone notices.
Source src/trust.ts#L136riskSubjectsrc/trust.ts#L172attemptMinorsrc/economy.ts#L763screenRisksrc/engines/postgres.ts#L1960recordVelocity
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", detail: { reason: "RISK_DENIED", … } } when the window is over the limit
Every risk-screened write counts, and each lands in one of two windows per user, because value flowing in and value flowing out are different threat models. The outflow window sums what leaves the wallet (spend, subscribe, requestPayout): a drained account fills this one. The inflow window sums what arrives (topUp, grantPromo): card testing — a run of small charges probing a stolen card — fills this one, so buying in past its limit is refused with the same RISK_DENIED as overspending. The table below lists every screened operation and its window. The rejection’s detail carries which window tripped (window: 'inflow' | 'outflow' | 'both') and the limitMinor ceiling it hit.
Why it exists
A compromised or misbehaving account can try to move money faster than anyone reacts — draining a wallet or claiming payouts in a tight loop. Each operation in the run is one the balance covers, so nothing about it looks wrong in isolation. What gives the drain away is the total moved in a short window, and that total is what the gate caps.
What counts as a subject
riskSubject decides which operations are gated, whose account is the subject, and which window the attempt fills — the recorded subject is <class>:<userId>, so each class accrues its own total. When an operation moves no tracked subject’s funds, riskSubject returns null and the operation is always allowed.
| Operation | Window | Amount counted |
|---|---|---|
spend, subscribe | the buyer’s out | the price |
requestPayout | the user’s out | the amount |
topUp, grantPromo | the user’s in | the amount |
| everything else | — | not gated |
One knob arms both ceilings: each class falls back to velocityLimitMinor unless its own velocityInflowLimitMinor / velocityOutflowLimitMinor is set, so a deployment that needs different in/out ceilings sets them apart and everyone else configures one figure.
How it’s enforced
A naive limit reads the total, compares, then records — three steps another operation can interleave, so two concurrent spends both pass and together blow the limit. It’s the same interleaving hazard concurrency handles for balances, and the gate closes it by recording and measuring in one indivisible step per subject.
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 the class’s limit and rejects when it’s over.
The insert comes before the decision, so denied attempts still count: a flood of over-limit attempts keeps adding to the window.
What relies on it
- The gated operations —
spend,topUp,requestPayout,grantPromo, andsubscribe— pass throughscreenRiskbefore any of them move money. RISK_DENIEDis thedetail.reasona caller sees on therejectedoutcome when the window is over the limit.
Try it
Arm the ceiling and cross it — the snippet runs the real engine on this page:
import {
createEconomy,
credits,
memoryPorts,
spend,
systemActor,
topUp,
userActor,
} from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';
// The velocity ceiling is construction-time config, so this block builds its own small
// economy with a 300-credit window. Inflow and outflow fill separate windows: the 200
// funded counts against inflow only, the first 160 spent fits the outflow window, and
// 160 more crosses it — declined as RISK_DENIED with the window's own figures.
export async function run(): Promise<SnippetReport> {
const economy = createEconomy(
memoryPorts({
signingKey: 'docs-signing-key',
config: { velocityLimitMinor: 30_000n }, // 300 credits per window, both classes
}),
);
await economy.submit(
topUp({
idempotencyKey: 'idem_fund',
actor: systemActor('docs'),
userId: 'usr_v',
amount: credits(200), // fills the inflow window, not the spend one
source: 'card',
}),
);
const PRICE = 160; // credits per spend — the second one crosses the 300 ceiling
const buy = (n: number) =>
spend({
idempotencyKey: `ord_v${n}`,
actor: userActor('usr_v'),
orderId: `ord_v${n}`,
buyerId: 'usr_v',
sku: 'Velocity Test Pass',
price: credits(PRICE),
recipients: [{ sellerId: 'usr_s', shareBps: 10_000 }],
});
const within = await economy.submit(buy(1));
const past = await economy.submit(buy(2));
await economy.close();
return {
lines: [
'ceiling armed at construction: 300 credits per window',
`spend of ${PRICE}: ${within.status} — ${PRICE} of 300 out this window`,
past.status === 'rejected' && past.detail.reason === 'RISK_DENIED'
? `${PRICE} more: ${past.status} (${past.detail.reason}) — the ` +
`${past.detail.window} window at its ${past.detail.limitMinor / 100n}-credit limit`
: `${PRICE} more: ${past.status}`,
],
consolePath: '/controls',
};
}The block above builds its own small economy — the ceiling is construction-time config, and a throwaway economy is the honest way to arm one. The same ceiling is a live knob on the console’s Controls page.
Recap
- The gate counts value moved per user inside two rolling windows — outflow (spends, subscriptions, payout requests) and inflow (top-ups, promo grants) — and every attempt counts, denied ones included.
- Crossing a ceiling declines as
RISK_DENIED, carrying which window tripped and thelimitMinorceiling it hit. - The ceilings are construction-time config:
velocityLimitMinorarms both, the per-class knobs override it, andvelocityWindowMssets the window. The window length is captured when the store is built, and the whole config object is frozen inside the engine — changing any knob means rebuilding the economy over the same store, never mutating (that is how the console’s live knob works).
To probe the gate yourself, edit the block above: shrink PRICE under the window and both
spends commit; raise the ceiling and the wallet becomes the next gate to answer. Every attempt,
denied ones included, stays in the window.