Skip to content
markpaper

src/risk/builder-fee.test.ts

v0.3.0 · 5.1 KB

Download file
import { describe, expect, it } from 'vitest';
import {
  PERP_BUILDER_FEE_CAP,
  builderFeeToBps,
  builderFeeToMaxFeeRate,
  builderFeeToPercent,
  builderFeeToRate,
  builderFeeUsd,
  parseMaxFeeRate,
  resolveBuilderFee,
} from './builder-fee.js';

const BUILDER_MIXED = '0x00000000000000000000000000000000000000Ab'; // privacy-allow: placeholder address
const BUILDER_LOWER = '0x00000000000000000000000000000000000000ab'; // privacy-allow: placeholder address

describe('builder fee units (tenths of a bp)', () => {
  it('converts per the table 10/25/50/100', () => {
    expect([10, 25, 50, 100].map(builderFeeToBps)).toEqual([1, 2.5, 5, 10]);
    expect([10, 25, 50, 100].map(builderFeeToPercent)).toEqual([0.01, 0.025, 0.05, 0.1]);
    expect([10, 25, 50, 100].map(builderFeeToRate)).toEqual([0.0001, 0.00025, 0.0005, 0.001]);
    expect(PERP_BUILDER_FEE_CAP).toBe(100);
  });

  it('computes the amount as notional × f / 100000', () => {
    expect(builderFeeUsd(10_000, 25)).toBe(2.5);
    expect(builderFeeUsd('1234.56', 50)).toBe(0.61728);
    expect(builderFeeUsd(1000, 0)).toBe(0);
  });

  it('rejects fractional or negative f', () => {
    expect(() => builderFeeToBps(2.5)).toThrow(RangeError);
    expect(() => builderFeeUsd(1, -1)).toThrow(RangeError);
  });
});

describe('maxFeeRate string <-> tenths', () => {
  it('formats exactly what the user signs', () => {
    expect(builderFeeToMaxFeeRate(50)).toBe('0.05%');
    expect(builderFeeToMaxFeeRate(25)).toBe('0.025%');
    expect(builderFeeToMaxFeeRate(10)).toBe('0.01%');
    expect(builderFeeToMaxFeeRate(100)).toBe('0.1%');
    expect(builderFeeToMaxFeeRate(0)).toBe('0%');
  });

  it('round-trips one-to-one', () => {
    for (const f of [1, 10, 25, 50, 99, 100]) expect(parseMaxFeeRate(builderFeeToMaxFeeRate(f))).toBe(f);
    expect(parseMaxFeeRate(' 0.050% ')).toBe(50);
  });

  it('rejects non-percent and sub-tenth strings', () => {
    expect(() => parseMaxFeeRate('0.05')).toThrow(RangeError);
    expect(() => parseMaxFeeRate('0.0125%')).toThrow(RangeError);
    expect(() => parseMaxFeeRate('-0.05%')).toThrow(RangeError);
  });
});

describe('resolveBuilderFee', () => {
  it('uses f = min(cap, approved, target) and lowercases the address', () => {
    expect(resolveBuilderFee({ builder: BUILDER_MIXED, approved: 30, target: 30 })).toEqual({ b: BUILDER_LOWER, f: 30 });
    // an older, lower approval (0.015%) keeps trading at 15 without re-signing
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 15, target: 30, minAccepted: 15 })).toEqual({
      b: BUILDER_LOWER,
      f: 15,
    });
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 100, target: 30 })?.f).toBe(30);
  });

  it('never exceeds the perp cap of 100', () => {
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 500, target: 500 })?.f).toBe(100);
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 500, target: 500, cap: 1000 })?.f).toBe(100);
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 500, target: 500, cap: 40 })?.f).toBe(40);
  });

  it('omits the field without approval, below the minimum, or when the read failed', () => {
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 0, target: 30 })).toBeUndefined();
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 10, target: 30, minAccepted: 15 })).toBeUndefined();
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: null, target: 30 })).toBeUndefined();
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: Number.NaN, target: 30 })).toBeUndefined();
  });

  it('omits the field on close retries', () => {
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 30, target: 30, isCloseRetry: true })).toBeUndefined();
  });

  it('omits the field when the feature is off or the target is 0', () => {
    expect(resolveBuilderFee({ builder: undefined, approved: 30, target: 30 })).toBeUndefined();
    expect(resolveBuilderFee({ builder: '', approved: 30, target: 30 })).toBeUndefined();
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 30, target: 0 })).toBeUndefined();
  });

  it('throws on a malformed builder address', () => {
    expect(() => resolveBuilderFee({ builder: '0x1234', approved: 30, target: 30 })).toThrow(RangeError);
  });

  it('never throws on a close retry, even with a misconfigured address (exits are never blocked)', () => {
    expect(resolveBuilderFee({ builder: '0x1234', approved: 30, target: 30, isCloseRetry: true })).toBeUndefined();
  });

  it('a NaN minimum does not open the gate', () => {
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 10, target: 30, minAccepted: Number.NaN })).toBeUndefined();
  });

  it('floors a fractional approval and never exceeds it', () => {
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 29.9, target: 30 })?.f).toBe(29);
    expect(resolveBuilderFee({ builder: BUILDER_LOWER, approved: 30, target: 30, cap: Number.NaN })).toBeUndefined();
  });

  it('builderFeeUsd is exact where floats drift (float 452.613 × 30 / 1e5 = 0.13578389999999999)', () => {
    expect(builderFeeUsd('452.613', 30)).toBe(0.1357839);
  });
});
All files