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,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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user