fix: reservation leak, spoofable delivery envelopes, and Decimal wei rendering
Seven bugs found by auditing the billing and PSS paths. 1. Reservations were never released on ack/delivery timeout. Both failure paths in gateway/context.ts marked the GatewayJob terminal *before* calling releaseReservation, and releaseReservation bailed out on exactly those statuses — so every failed job permanently inflated the tenant's reservedXbzzWei and shrank their available balance. Replace the status check with GatewayJob.reservationReleasedAt as an explicit compare-and-set marker, which makes the release exactly-once regardless of ordering, and give settleXbzz the same guard so a job that was released locally and claimed on-chain anyway can't decrement the reservation twice. The migration returns the xBZZ already stuck on existing accounts. 2. job_ack / job_deliver were only checked for a valid signature, never for *whose* signature. Anyone who learned a routing id could sign their own job_deliver, pointing at a chunk encrypted to the gateway's (published) PSS key, and hand the tenant an arbitrary completion. Drop any envelope whose `from` isn't the provider the job was posted to. 3. BigInt(decimal.toString()) throws on any wei value >= 1e21 (1000 xBZZ), because decimal.js switches to exponential notation there. That took out GET /accounts/me and, worse, wedged the claim watcher's settle loop. Route every Decimal→string conversion through toFixed(). 4. A cancelJob failure at the ACK deadline left the job with no timer at all, so the chat request hung forever holding a semaphore slot and a reservation. Fall through to the delivery deadline instead. 5. `?limit=abc` reached Prisma as `take: NaN` (500). Clamp and fall back. 6. A fractional max_tokens made BigInt() throw; a negative one budgeted a negative reservation. Validate at the schema and defend in the estimator. 7. model.<entity>.delete() announced the deletion on the queue before performing it, so a failed delete still told consumers the row was gone. Verified against a real Postgres: the reservation lifecycle (release-after- terminal, settle-after-release, concurrent release/settle, idempotent settle) and the migration's repair of leaked reservations on pre-fix data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mfkh741kNCZeBdk4xyM9qR
This commit is contained in:
15 files changed
+332
-62
No files matched your search
@@ -0,0 +1,27 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "GatewayJob" ADD COLUMN "reservationReleasedAt" TIMESTAMP(3);
|
||||
|
||||
-- Data repair: before this migration, `releaseReservation` bailed out whenever
|
||||
-- the GatewayJob row was already in a terminal status — and both failure paths
|
||||
-- in `gateway/context.ts` marked the row `cancelled` / `timed_out` *before*
|
||||
-- calling it. So every job that failed to ACK or to deliver left its
|
||||
-- `Account.reservedXbzzWei` permanently inflated, shrinking the tenant's
|
||||
-- available balance for good. Give that stuck xBZZ back, flooring at 0 so a
|
||||
-- partially-repaired database can never go negative.
|
||||
UPDATE "Account" a
|
||||
SET "reservedXbzzWei" = GREATEST(a."reservedXbzzWei" - leaked.total, 0)
|
||||
FROM (
|
||||
SELECT j."userId" AS "userId", SUM(j."estimatedMaxXbzzWei") AS total
|
||||
FROM "GatewayJob" j
|
||||
WHERE j."status" IN ('cancelled', 'timed_out')
|
||||
AND j."reservationReleasedAt" IS NULL
|
||||
GROUP BY j."userId"
|
||||
) AS leaked
|
||||
WHERE a."userId" = leaked."userId";
|
||||
|
||||
-- Backfill the marker for every job whose reservation is no longer outstanding
|
||||
-- (either repaired above, or already settled by `settleXbzz` on JobClaimed) so
|
||||
-- a late JobClaimed event can't decrement the same reservation twice.
|
||||
UPDATE "GatewayJob"
|
||||
SET "reservationReleasedAt" = COALESCE("claimedAt", "updatedAt")
|
||||
WHERE "status" IN ('claimed', 'cancelled', 'timed_out', 'failed');
|
||||
@@ -176,6 +176,13 @@ model GatewayJob {
|
||||
ackDeadline DateTime?
|
||||
deliveryDeadline DateTime?
|
||||
|
||||
// Set the first time this job's off-chain reservation is released — by
|
||||
// `settleXbzz` on JobClaimed, or by `releaseReservation` on cancel / timeout
|
||||
// / dispatch failure. It is the compare-and-set marker that makes the
|
||||
// release exactly-once: `status` cannot serve that role because a job can be
|
||||
// marked terminal and released by different code paths in either order.
|
||||
reservationReleasedAt DateTime?
|
||||
|
||||
// Token accounting (from JobClaimed actualPayment)
|
||||
promptTokens Int?
|
||||
completionTokens Int?
|
||||
|
||||
Reference in new issue
Block a user