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
95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|