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:
Claude committed 2026-08-07 07:28:09 +00:00
1 parent 07602838cd
commit 5b2888aade
15 files changed
+332 -62

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
/**
* Pure-function regressions for the wei/query coercion helpers. No Postgres,
* Redis, Bee or RPC — run with `bun run test:unit`.
*/
import { describe, expect, it } from 'bun:test';
import { Prisma } from '@prisma/client';
import { weiString, weiToBigInt } from '../../src/lib/decimal';
import { parseLimit } from '../../src/lib/query';
// `src/pricing/estimate` pulls in the zod-validated env module, which exits the
// process when required vars are missing. Fill in the minimum before importing.
process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/test';
process.env.JWT_SECRET_KEY ??= 'test-secret-at-least-16-chars';
process.env.OPERATOR_PRIVATE_KEY ??= '0x' + '11'.repeat(32);
process.env.GNOSIS_RPC_URL ??= 'http://localhost:8545';
process.env.REGISTRY_ADDRESS ??= '0x' + '22'.repeat(20);
process.env.ESCROW_ADDRESS ??= '0x' + '33'.repeat(20);
process.env.XBZZ_ADDRESS ??= '0x' + '44'.repeat(20);
process.env.BEE_API_URL ??= 'http://localhost:1633';
process.env.MARKUP_BPS ??= '2000';
const { estimateMaxXbzz } = await import('../../src/pricing/estimate');
const OFFERING = {
inputPricePerMillionTokens: 10n ** 18n,
outputPricePerMillionTokens: 2n * 10n ** 18n,
maxContextTokens: 0n,
};
describe('wei rendering', () => {
it('keeps large balances in plain notation', () => {
// decimal.js' toString() flips to exponential at 1e21 — i.e. at 1000 xBZZ,
// which is an ordinary balance. "1e+21" is not a valid BigInt literal.
const thousandXbzz = new Prisma.Decimal('1000000000000000000000');
expect(thousandXbzz.toString()).toBe('1e+21'); // the trap being guarded
expect(weiString(thousandXbzz)).toBe('1000000000000000000000');
expect(weiToBigInt(thousandXbzz)).toBe(1000000000000000000000n);
});
it('round-trips a full uint256', () => {
const max = (2n ** 256n - 1n).toString();
expect(weiToBigInt(new Prisma.Decimal(max))).toBe(2n ** 256n - 1n);
});
it('handles negative deltas and zero', () => {
expect(weiString(new Prisma.Decimal('-5000000000000000000000'))).toBe('-5000000000000000000000');
expect(weiString(new Prisma.Decimal(0))).toBe('0');
});
});
describe('parseLimit', () => {
it('clamps to the allowed range', () => {
expect(parseLimit('10')).toBe(10);
expect(parseLimit('1000')).toBe(200);
expect(parseLimit('-5')).toBe(1);
expect(parseLimit('0')).toBe(1);
});
it('falls back instead of handing Prisma a NaN take', () => {
expect(parseLimit('abc')).toBe(50);
expect(parseLimit(undefined)).toBe(50);
expect(parseLimit('')).toBe(50); // Number('') is 0, but empty means "unset"
});
it('truncates fractional input', () => {
expect(parseLimit('12.9')).toBe(12);
});
});
describe('estimateMaxXbzz', () => {
it('never returns a negative reservation', () => {
for (const max_tokens of [-1000, -1, 0]) {
const { rawWei, withMarkupWei } = estimateMaxXbzz({ model: 'm', max_tokens }, OFFERING);
expect(rawWei > 0n).toBe(true);
expect(withMarkupWei >= rawWei).toBe(true);
}
});
it('does not throw on a fractional max_tokens', () => {
const { rawWei } = estimateMaxXbzz({ model: 'm', max_tokens: 100.5 }, OFFERING);
const { rawWei: dflt } = estimateMaxXbzz({ model: 'm' }, OFFERING);
expect(rawWei).toBe(dflt);
});
it('scales with max_tokens', () => {
const small = estimateMaxXbzz({ model: 'm', max_tokens: 1 }, OFFERING).rawWei;
const large = estimateMaxXbzz({ model: 'm', max_tokens: 100_000 }, OFFERING).rawWei;
expect(large > small).toBe(true);
});
it('applies the markup on top of the on-chain amount', () => {
const { rawWei, withMarkupWei } = estimateMaxXbzz({ model: 'm', max_tokens: 1024 }, OFFERING);
expect(withMarkupWei).toBe((rawWei * 12_000n) / 10_000n);
});
});