Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/spend-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,52 @@ describe("x402 onBeforePaymentCreation spend policy", () => {
});
});

describe("in-flight reservations under a live (non-frozen) clock", () => {
// Every other test in this file injects a FROZEN clock (`now: () => clock`, see
// createControl above): both clock reads inside a single check() return the same
// value, so a reservation is always counted and the race below cannot surface.
// Production uses Date.now(), which advances — when a millisecond ticks between
// the `now` check() captures at its top and the second `this.now()` the window
// helper used to read, the hourly/daily window silently dropped the pending
// total. This live clock (each read 1ms later) models that sub-ms advance so the
// concurrent-overspend path is actually exercised.
const liveClock = (startMs = 1_000_000_000_000) => {
let t = startMs;
return () => (t += 1);
};

it("counts an in-flight reservation against the hourly window while the clock advances mid-check", () => {
const control = new SpendControl({
storage: new InMemorySpendControlStorage(),
now: liveClock(),
});
control.setLimit("hourly", 1.0);

// Payment A is signed-in-flight: it cleared check() and reserved its cost but
// has not settled yet.
control.reserve(0.8);

// Payment B arrives concurrently. A's live $0.80 hold plus B's $0.80 is $1.60,
// over the $1.00/hr cap — B must be refused, or the two together overspend.
const result = control.check(0.8, {});
expect(result.allowed).toBe(false);
expect(result.blockedBy).toBe("hourly");
});

it("counts an in-flight reservation against the daily window while the clock advances mid-check", () => {
const control = new SpendControl({
storage: new InMemorySpendControlStorage(),
now: liveClock(),
});
control.setLimit("daily", 1.0);
control.reserve(0.8);

const result = control.check(0.8, {});
expect(result.allowed).toBe(false);
expect(result.blockedBy).toBe("daily");
});
});

describe("formatDuration", () => {
it("formats seconds", () => {
expect(formatDuration(30)).toBe("30s");
Expand Down
32 changes: 21 additions & 11 deletions src/spend-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ export class SpendControl {
}

if (this.limits.hourly !== undefined) {
const hourlySpent = this.getSpendingInWindow(now - HOUR_MS, now);
const hourlySpent = this.getSpendingInWindow(now - HOUR_MS, now, now);
const remaining = this.limits.hourly - hourlySpent;
if (estimatedCost > remaining) {
const oldestInWindow = this.history.find((r) => r.timestamp >= now - HOUR_MS);
Expand All @@ -479,7 +479,7 @@ export class SpendControl {
}

if (this.limits.daily !== undefined) {
const dailySpent = this.getSpendingInWindow(now - DAY_MS, now);
const dailySpent = this.getSpendingInWindow(now - DAY_MS, now, now);
const remaining = this.limits.daily - dailySpent;
if (estimatedCost > remaining) {
const oldestInWindow = this.history.find((r) => r.timestamp >= now - DAY_MS);
Expand Down Expand Up @@ -600,23 +600,33 @@ export class SpendControl {
}
}

private getSpendingInWindow(from: number, to: number): number {
// `now` is the single clock reading the caller already took to build the
// window; it must be passed in, not re-read here. In-flight reservations are
// "now" holds, so they count only against a window that reaches the present
// (`to >= now`). The bug this guards against: reading the clock a SECOND time
// inside this method (the old `to >= this.now()`) could land a millisecond
// after the caller's `now`, flip the guard false, and silently drop the
// pending total from the hourly/daily check — letting two concurrent payments
// both clear the same remaining budget. Threading the caller's `now` keeps the
// guard meaningful (a genuinely historical window with `to < now` still
// excludes live holds) without a second, racing read. Every existing test
// injects a frozen clock (`now: () => clock`), so the two reads always matched
// and the sub-ms window went uncaught; see the live-clock test in
// spend-control.test.ts.
private getSpendingInWindow(from: number, to: number, now: number): number {
const recorded = this.history
.filter((r) => r.timestamp >= from && r.timestamp <= to)
.reduce((sum, r) => sum + r.amount, 0);
// In-flight reservations count against every window they could land in.
// Both the hourly and daily windows end at `now`, so a live hold belongs
// to each of them.
return recorded + (to >= this.now() ? this.pendingTotal() : 0);
return recorded + (to >= now ? this.pendingTotal() : 0);
}

getSpending(window: "hourly" | "daily" | "session"): number {
const now = this.now();
switch (window) {
case "hourly":
return this.getSpendingInWindow(now - HOUR_MS, now);
return this.getSpendingInWindow(now - HOUR_MS, now, now);
case "daily":
return this.getSpendingInWindow(now - DAY_MS, now);
return this.getSpendingInWindow(now - DAY_MS, now, now);
case "session":
return this.sessionSpent + this.pendingTotal();
}
Expand All @@ -630,8 +640,8 @@ export class SpendControl {

getStatus(): SpendingStatus {
const now = this.now();
const hourlySpent = this.getSpendingInWindow(now - HOUR_MS, now);
const dailySpent = this.getSpendingInWindow(now - DAY_MS, now);
const hourlySpent = this.getSpendingInWindow(now - HOUR_MS, now, now);
const dailySpent = this.getSpendingInWindow(now - DAY_MS, now, now);

return {
limits: cloneLimits(this.limits),
Expand Down
Loading