Skip to content

fix(spend-control): count in-flight reservations against hourly/daily caps under a live clock - #286

Merged
VickyXAI merged 1 commit into
BlockRunAI:mainfrom
erhnysr:fix/spend-control-concurrent-window-race
Sep 1, 2026
Merged

fix(spend-control): count in-flight reservations against hourly/daily caps under a live clock#286
VickyXAI merged 1 commit into
BlockRunAI:mainfrom
erhnysr:fix/spend-control-concurrent-window-race

Conversation

@erhnysr

@erhnysr erhnysr commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What

SpendControl.getSpendingInWindow() decided whether in-flight reservations (pendingTotal()) count toward the hourly/daily spend by reading the clock a second time, and comparing it against the window bound the caller had already computed from a first read.

check() captures now once at its top and passes it as the window's to:

const now = this.now();                                        // read #1
const hourlySpent = this.getSpendingInWindow(now - HOUR_MS, now);
// inside getSpendingInWindow, before the fix:
return recorded + (to >= this.now() ? this.pendingTotal() : 0); // read #2

On the production clock (() => Date.now()), when a millisecond ticks between read #1 and read #2, this.now() > to, the guard goes false, and the in-flight reservation total is silently dropped from the hourly/daily figure.

Why that's a money-path bug

Reservations are the concurrency-safety mechanism. The x402 pre-sign hook does check-then-reserve() synchronously, on purpose:

// Reserve synchronously — no await between check() and reserve() — so two
// concurrent payments cannot both clear the same remaining budget.
return control.reserve(estimatedCost);

When payment B's hourly/daily check drops payment A's live reservation, both clear the same remaining budget and the operator's cap is exceeded. The session window was unaffected — it reads sessionSpent + pendingTotal() directly, never through this guard — which is why the leak was narrow and silent.

Severity is bounded: the racing gap is sub-millisecond per check, so this is intermittent under concurrent paid load, not every request. No security impact — it's an accounting/limit-enforcement correctness bug in the payment path.

Why the existing suite didn't catch it

Every test in spend-control.test.ts builds its SpendControl through a helper that injects a frozen clock:

let clock = nowMs;
const control = new SpendControl({ storage, now: () => clock });

With a frozen clock, read #1 and read #2 return the identical value, to >= this.now() is always true, the reservation is always counted, and the race cannot surface. The bug only appears when the clock actually advances between the two reads — which the frozen clock structurally prevents.

Fix (threaded now)

Thread the caller's single clock reading into the helper and gate on it, instead of taking a second racing read:

private getSpendingInWindow(from: number, to: number, now: number): number {
  const recorded = /* history in [from, to] */;
  return recorded + (to >= now ? this.pendingTotal() : 0);
}

All six call sites (2 in check(), 2 in getSpending(), 2 in getStatus()) already had now in scope and now pass it through. This keeps the guard meaningful — a genuinely historical window (to < now) still correctly excludes live "now" holds — while removing the second, racing read. (I deliberately avoided the terser to >= from, which is always true for these callers and would leave the guard vacuous in a file reviewers scrutinize closely.)

Tests (failing-first)

New describe("in-flight reservations under a live (non-frozen) clock") with an advancing clock (each read 1ms later), covering the hourly and daily windows. A comment on the block states explicitly why the frozen-clock suite missed this.

  • Stashing only the source fix and keeping the new tests: 2 failed on the pristine tree (expected true to be false).
  • With the fix: 2 passed; full spend-control.test.ts suite 71 passed — every existing frozen-clock test unchanged and green.
  • tsc --noEmit, prettier --check, eslint: clean.
  • Full repo suite: 792 passed. The 2 unrelated failures on the tree (router/brand-numbers, router/free-model-liveness) are pre-existing catalog/brand-snapshot drift — confirmed by reproducing them with this change stashed. Zero new failures.

Scope

Touches only src/spend-control.ts and src/spend-control.test.ts. Independent of the unrelated /stats?days fix.

Summary by CodeRabbit

  • Bug Fixes
    • Improved hourly and daily spending-limit checks to accurately account for pending reservations.
    • Prevented concurrent spending from bypassing limits when checks occur as time advances.
    • Updated spending totals and status information to remain consistent during in-flight transactions.

… caps under a live clock

check() captures `now` once, then getSpendingInWindow re-read the clock via a second this.now() to decide whether in-flight reservations (pendingTotal) count. On a real advancing clock (Date.now()) the second read can land a millisecond after the captured now, flipping `to >= this.now()` false and silently dropping the pending total from the hourly/daily window — so two concurrent payments could both clear the same remaining budget and overspend the operator's cap. The session window was unaffected (it reads sessionSpent + pendingTotal directly).

Thread the caller's single `now` into getSpendingInWindow(from, to, now) and gate on `to >= now`. This removes the racing second read while keeping the guard meaningful: a genuinely historical window (to < now) still excludes live holds.

Every existing spend-control test injects a frozen clock (now: () => clock), so its two reads always matched and the sub-ms window was masked — which is why the suite was green. Add a live-clock (advancing) test covering the hourly and daily windows; it fails on the pre-fix code and passes after.
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 01c7ebda-1bb5-461a-99eb-6f42793c859c

📥 Commits

Reviewing files that changed from the base of the PR and between 7267014 and f9859fa.

📒 Files selected for processing (2)
  • src/spend-control.test.ts
  • src/spend-control.ts

Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

Spending-window calculations now reuse the timestamp captured by the caller. New tests verify that pending reservations remain counted when the clock advances during a single check() call.

Changes

Spending-window timestamp consistency

Layer / File(s) Summary
Reuse captured timestamps
src/spend-control.ts
Hourly, daily, and status spending calculations pass one captured timestamp to getSpendingInWindow, removing the second clock read.
Validate in-flight reservations
src/spend-control.test.ts
Live-clock tests verify that concurrent reservations block hourly and daily checks.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to f9859

The PR makes hourly and daily spend checks use one captured timestamp so active reservations are not intermittently omitted. No actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: twzrd-sol

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: counting in-flight reservations against hourly and daily caps when the clock advances during a check.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2 files.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@VickyXAI
VickyXAI merged commit 26c885f into BlockRunAI:main Sep 1, 2026
4 checks passed
VickyXAI pushed a commit that referenced this pull request Sep 1, 2026
…lear the same cap

Three fixes, all in the same family: a value read twice, or read without a guard,
changing behaviour behind the caller's back.

- spend-control: getSpendingInWindow re-read the clock and compared it against a
  bound the caller had already computed from an earlier read. A millisecond tick
  between the two flipped the guard false and silently dropped in-flight
  reservations from the hourly/daily figure, letting two concurrent payments both
  clear the same remaining budget. The caller's single `now` is threaded through.
- /stats?days=: non-numeric became NaN and reported zero usage; negative dropped
  the newest day and mislabelled the response. One resolveStatsDays() guard.
- logs --days: the same input class on the local path, where `parseInt || 1` let
  a negative through to a slice that trims from the end. Guarded at the sink.

809 tests green, typecheck and prettier clean, dist rebuilt (smoke check passed).

Thanks to @erhnysr for #286 and #285.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants