Skip to content
markpaper

src/transport/address-budget.test.ts

v0.3.0 · 11.9 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  ADDRESS_BASE_REQUESTS,
  ADDRESS_BUDGET_MIN_POLL_MS,
  ADDRESS_BUDGET_POLL_MS,
  ADDRESS_EXHAUSTED_INTERVAL_MS,
  REQUEST_PRICE_USD,
  addressCancelCap,
  baseAddressRequestCap,
  budgetMode,
  computeAddressBudget,
  getAddressBudget,
  hoursToExhaustion,
  parseUserRateLimit,
  reserveRequestCostUsd,
  surplusAlreadyInCap,
} from './address-budget.js';
import type { InfoRequest, InfoRequester } from './types.js';

const ADDR_MIXED = '0x00000000000000000000000000000000000000Cd'; // privacy-allow: placeholder address
const ADDR_LOWER = '0x00000000000000000000000000000000000000cd'; // privacy-allow: placeholder address

function stubInfo(response: unknown) {
  const calls: InfoRequest[] = [];
  const info: InfoRequester = async <T>(body: InfoRequest) => {
    calls.push(body);
    return response as T;
  };
  return { info, calls };
}

describe('constants', () => {
  it('match the documented address limit', () => {
    expect(ADDRESS_BASE_REQUESTS).toBe(10_000);
    expect(REQUEST_PRICE_USD).toBe(0.0005);
    expect(ADDRESS_EXHAUSTED_INTERVAL_MS).toBe(10_000);
    expect(ADDRESS_BUDGET_POLL_MS).toBe(60_000);
    expect(ADDRESS_BUDGET_MIN_POLL_MS).toBe(5_000);
  });
});

describe('baseAddressRequestCap', () => {
  it('is 10 000 + 1 per whole $1 of volume (live: $0 -> 10 000, $10 -> 10 010)', () => {
    expect(baseAddressRequestCap('0')).toBe(10_000);
    expect(baseAddressRequestCap('0.0')).toBe(10_000);
    expect(baseAddressRequestCap('10')).toBe(10_010);
    expect(baseAddressRequestCap('10.999999')).toBe(10_010);
    expect(baseAddressRequestCap(10.5)).toBe(10_010);
    expect(baseAddressRequestCap('123456789.29')).toBe(123_466_789);
  });

  it('does not use float rounding on long decimals', () => {
    expect(baseAddressRequestCap('0.29')).toBe(10_000);
    expect(baseAddressRequestCap('99999999999.999999999999')).toBe(100_000_009_999);
  });

  it('rejects non-decimal input', () => {
    expect(() => baseAddressRequestCap('-1')).toThrow(TypeError);
    expect(() => baseAddressRequestCap('abc')).toThrow(TypeError);
    expect(() => baseAddressRequestCap(Number.NaN)).toThrow(TypeError);
  });
});

describe('addressCancelCap', () => {
  it('is min(limit + 100 000, limit * 2)', () => {
    expect(addressCancelCap(10_000)).toBe(20_000);
    expect(addressCancelCap(100_000)).toBe(200_000);
    expect(addressCancelCap(250_000)).toBe(350_000);
  });
});

describe('budgetMode', () => {
  it('freeze < 10%, economy < 30%, otherwise normal', () => {
    expect(budgetMode(9.99)).toBe('freeze');
    expect(budgetMode(10)).toBe('economy');
    expect(budgetMode(29.99)).toBe('economy');
    expect(budgetMode(30)).toBe('normal');
    expect(budgetMode(100)).toBe('normal');
    expect(budgetMode(50, 60, 80)).toBe('freeze');
  });

  it('fails closed on NaN', () => {
    expect(budgetMode(Number.NaN)).toBe('freeze');
  });
});

describe('computeAddressBudget', () => {
  it('derives remaining from cap, used and local placements', () => {
    const b = computeAddressBudget({ cumVlm: '10.0', nRequestsUsed: 77, nRequestsCap: 10_010, nRequestsSurplus: 0 });
    expect(b.remaining).toBe(9_933);
    expect(b.mode).toBe('normal');
    expect(b.exhausted).toBe(false);
    const after = computeAddressBudget({ cumVlm: '10.0', nRequestsUsed: 77, nRequestsCap: 10_010 }, { placesSinceRead: 33 });
    expect(after.remaining).toBe(9_900);
  });

  it('adds the bought surplus by default and can skip it to avoid double counting', () => {
    const rl = { cumVlm: '0', nRequestsUsed: 9_000, nRequestsCap: 10_000, nRequestsSurplus: 2_000 };
    expect(computeAddressBudget(rl).effectiveCap).toBe(12_000);
    expect(computeAddressBudget(rl).remaining).toBe(3_000);
    expect(computeAddressBudget(rl, { countSurplus: false }).remaining).toBe(1_000);
    expect(computeAddressBudget({ ...rl, nRequestsSurplus: -5 }).surplus).toBe(0);
  });

  it('reports economy, freeze and exhaustion', () => {
    expect(computeAddressBudget({ cumVlm: '0', nRequestsUsed: 7_500, nRequestsCap: 10_000 }).mode).toBe('economy');
    expect(computeAddressBudget({ cumVlm: '0', nRequestsUsed: 9_500, nRequestsCap: 10_000 }).mode).toBe('freeze');
    const dead = computeAddressBudget({ cumVlm: '0', nRequestsUsed: 12_000, nRequestsCap: 10_000 });
    expect(dead.remaining).toBe(0);
    expect(dead.exhausted).toBe(true);
    expect(dead.mode).toBe('freeze');
    const zeroCap = computeAddressBudget({ cumVlm: '0', nRequestsUsed: 0, nRequestsCap: 0 });
    expect(zeroCap.remainingPct).toBe(0);
    expect(zeroCap.exhausted).toBe(true);
  });
});

describe('parseUserRateLimit', () => {
  it('accepts the documented shape, with or without surplus', () => {
    expect(parseUserRateLimit({ cumVlm: '12.5', nRequestsUsed: 1, nRequestsCap: 10_012, nRequestsSurplus: 0 })).toEqual({
      cumVlm: '12.5',
      nRequestsUsed: 1,
      nRequestsCap: 10_012,
      nRequestsSurplus: 0,
    });
    expect(parseUserRateLimit({ cumVlm: '0', nRequestsUsed: 0, nRequestsCap: 10_000 })).toEqual({
      cumVlm: '0',
      nRequestsUsed: 0,
      nRequestsCap: 10_000,
    });
  });

  it('throws on malformed replies instead of inventing zeros', () => {
    expect(() => parseUserRateLimit(null)).toThrow(TypeError);
    expect(() => parseUserRateLimit([])).toThrow(TypeError);
    expect(() => parseUserRateLimit({ cumVlm: '0', nRequestsUsed: '5', nRequestsCap: 10_000 })).toThrow(TypeError);
    expect(() => parseUserRateLimit({ cumVlm: '0', nRequestsUsed: 5 })).toThrow(TypeError);
    expect(() => parseUserRateLimit({ cumVlm: 'NaN', nRequestsUsed: 5, nRequestsCap: 10_000 })).toThrow(TypeError);
    expect(() => parseUserRateLimit({ cumVlm: '0', nRequestsUsed: 5, nRequestsCap: 10_000, nRequestsSurplus: 'x' })).toThrow(TypeError);
  });
});

describe('getAddressBudget', () => {
  it('reads userRateLimit with a lower-cased user and derives the budget', async () => {
    const { info, calls } = stubInfo({ cumVlm: '10.0', nRequestsUsed: 4, nRequestsCap: 10_010, nRequestsSurplus: 0 });
    const b = await getAddressBudget(info, ADDR_MIXED, { placesSinceRead: 6 });
    expect(calls).toEqual([{ type: 'userRateLimit', user: ADDR_LOWER }]);
    expect(b.user).toBe(ADDR_LOWER);
    expect(b.remaining).toBe(10_000);
    expect(b.cap).toBe(10_010);
    expect(b.raw.cumVlm).toBe('10.0');
  });

  it('validates the address before spending weight', async () => {
    const { info, calls } = stubInfo({});
    await expect(getAddressBudget(info, '0xYOUR_ADDRESS')).rejects.toThrow(TypeError);
    await expect(getAddressBudget(info, '0x1234')).rejects.toThrow(TypeError);
    expect(calls).toHaveLength(0);
  });

  it('propagates transport errors and rejects malformed responses', async () => {
    const failing: InfoRequester = async () => {
      throw new Error('HTTP 429');
    };
    await expect(getAddressBudget(failing, ADDR_LOWER)).rejects.toThrow('429');
    await expect(getAddressBudget(stubInfo({ nope: true }).info, ADDR_LOWER)).rejects.toThrow(/malformed/);
  });

  it('forwards the abort signal', async () => {
    let seen: AbortSignal | undefined;
    const info: InfoRequester = async <T>(_b: InfoRequest, opts?: { signal?: AbortSignal }) => {
      seen = opts?.signal;
      return { cumVlm: '0', nRequestsUsed: 0, nRequestsCap: 10_000 } as T;
    };
    const ctl = new AbortController();
    await getAddressBudget(info, ADDR_LOWER, { signal: ctl.signal });
    expect(seen).toBe(ctl.signal);
  });
});

describe('hoursToExhaustion', () => {
  it('uses the net drain: placements minus $ volume per hour', () => {
    expect(hoursToExhaustion(1_000, 500, 100)).toBe(2.5);
    expect(hoursToExhaustion(0, 500, 100)).toBe(0);
  });

  it('is null when volume pays for the placements or remaining is unknown', () => {
    expect(hoursToExhaustion(1_000, 100, 100)).toBeNull();
    expect(hoursToExhaustion(1_000, 50, 100)).toBeNull();
    expect(hoursToExhaustion(Number.POSITIVE_INFINITY, 500, 0)).toBeNull();
    expect(hoursToExhaustion(1_000, Number.NaN, 0)).toBeNull();
  });
});

describe('reserveRequestCostUsd', () => {
  it('prices requests at 0.0005 USDC (2 000 = $1, 10 000 = $5)', () => {
    expect(reserveRequestCostUsd(2_000)).toBe(1);
    expect(reserveRequestCostUsd(10_000)).toBe(5);
    expect(reserveRequestCostUsd(100)).toBe(0.05);
    expect(reserveRequestCostUsd(1)).toBe(0.0005);
    expect(reserveRequestCostUsd(0)).toBe(0);
    expect(reserveRequestCostUsd(-10)).toBe(0);
  });
});

describe('surplusAlreadyInCap (experimental)', () => {
  const before = { cumVlm: '0', nRequestsUsed: 100, nRequestsCap: 10_000, nRequestsSurplus: 0 };

  it('detects a cap that grew by the chunk while surplus also grew', () => {
    const after = { ...before, nRequestsCap: 12_000, nRequestsSurplus: 2_000 };
    expect(surplusAlreadyInCap(before, after, 2_000)).toBe(true);
    const nearly = { ...before, nRequestsCap: 11_850, nRequestsSurplus: 2_000 };
    expect(surplusAlreadyInCap(before, nearly, 2_000)).toBe(true);
  });

  it('is false when only one of the fields moved or chunk is invalid', () => {
    expect(surplusAlreadyInCap(before, { ...before, nRequestsSurplus: 2_000 }, 2_000)).toBe(false);
    expect(surplusAlreadyInCap(before, { ...before, nRequestsCap: 12_000 }, 2_000)).toBe(false);
    expect(surplusAlreadyInCap(before, { ...before, nRequestsCap: 12_000, nRequestsSurplus: 2_000 }, 0)).toBe(false);
  });
});

describe('address budget edge cases', () => {
  it('floors numeric cumVlm without float rounding up (10.9999999 must not become 10 011)', () => {
    expect(baseAddressRequestCap(10.9999999)).toBe(10_010);
    expect(baseAddressRequestCap(0.1 + 0.2)).toBe(10_000);
    expect(baseAddressRequestCap(0.29 * 100)).toBe(10_028); // 28.999999999999996 -> 28
    expect(baseAddressRequestCap(0)).toBe(10_000);
    expect(() => baseAddressRequestCap(-0.5)).toThrow(TypeError);
    expect(() => baseAddressRequestCap(Number.POSITIVE_INFINITY)).toThrow(TypeError);
  });

  it('clamps absurd volumes to a safe integer', () => {
    expect(baseAddressRequestCap('123456789012345678901234.5')).toBe(Number.MAX_SAFE_INTEGER);
    expect(baseAddressRequestCap(1e300)).toBe(Number.MAX_SAFE_INTEGER);
  });

  it('local placements beyond the remainder clamp to zero and freeze', () => {
    const b = computeAddressBudget({ cumVlm: '0', nRequestsUsed: 9_990, nRequestsCap: 10_000 }, { placesSinceRead: 50 });
    expect(b.remaining).toBe(0);
    expect(b.exhausted).toBe(true);
    expect(b.mode).toBe('freeze');
    expect(computeAddressBudget({ cumVlm: '0', nRequestsUsed: 0, nRequestsCap: 10_000 }, { placesSinceRead: -5 }).remaining).toBe(10_000);
  });

  it('mode thresholds are strict: exactly 10% is economy, exactly 30% is normal', () => {
    expect(computeAddressBudget({ cumVlm: '0', nRequestsUsed: 9_000, nRequestsCap: 10_000 }).mode).toBe('economy');
    expect(computeAddressBudget({ cumVlm: '0', nRequestsUsed: 7_000, nRequestsCap: 10_000 }).mode).toBe('normal');
  });

  it('accepts a null surplus and rejects negative counters', () => {
    expect(parseUserRateLimit({ cumVlm: '1.5', nRequestsUsed: 2, nRequestsCap: 10_001, nRequestsSurplus: null })).toEqual({
      cumVlm: '1.5',
      nRequestsUsed: 2,
      nRequestsCap: 10_001,
    });
    expect(() => parseUserRateLimit({ cumVlm: '1', nRequestsUsed: -1, nRequestsCap: 10_001 })).toThrow(TypeError);
    expect(() => parseUserRateLimit({ cumVlm: '-1', nRequestsUsed: 1, nRequestsCap: 10_001 })).toThrow(TypeError);
  });

  it('hoursToExhaustion treats negative remaining as zero and zero net as never', () => {
    expect(hoursToExhaustion(-10, 100, 0)).toBe(0);
    expect(hoursToExhaustion(1000, 100, 100)).toBeNull();
    expect(hoursToExhaustion(Number.NaN, 100, 0)).toBeNull();
  });

  it('surplus check at the 0.9 x chunk boundary', () => {
    const before = { cumVlm: '0', nRequestsUsed: 0, nRequestsCap: 10_000, nRequestsSurplus: 0 };
    expect(surplusAlreadyInCap(before, { ...before, nRequestsCap: 11_800, nRequestsSurplus: 2_000 }, 2_000)).toBe(true);
    expect(surplusAlreadyInCap(before, { ...before, nRequestsCap: 11_799, nRequestsSurplus: 2_000 }, 2_000)).toBe(false);
  });
});
All files