import { describe, expect, test } from 'vitest'; import { getSnatchList, minimumTaxRate, selectSnatchPositions, type SnatchablePosition } from '../snatch.js'; import { uint256ToBytesLittleEndian } from '../subgraph.js'; import type { Positions as GraphPosition } from '../__generated__/graphql.js'; describe('minimumTaxRate', () => { test('finds the lowest tax from a list', () => { const rates = [{ taxRate: 0.12 }, { taxRate: 0.05 }, { taxRate: 0.08 }]; expect(minimumTaxRate(rates, 1)).toBeCloseTo(0.05); }); test('returns fallback for empty array', () => { expect(minimumTaxRate([], 0.42)).toBe(0.42); }); test('returns fallback of 0 by default for empty array', () => { expect(minimumTaxRate([])).toBe(0); }); test('works with a single element', () => { expect(minimumTaxRate([{ taxRate: 0.07 }])).toBeCloseTo(0.07); }); }); describe('selectSnatchPositions', () => { test('chooses cheapest positions first', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 30n, taxRate: 0.05, taxRateIndex: 5 }, { id: 2n, stakeShares: 40n, taxRate: 0.03, taxRateIndex: 3 }, { id: 3n, stakeShares: 50n, taxRate: 0.07, taxRateIndex: 7 }, ]; const result = selectSnatchPositions(candidates, { shortfallShares: 60n, maxTaxRate: 0.08, }); expect(result.selected.map(item => item.id)).toEqual([2n, 1n]); expect(result.remainingShortfall).toBe(0n); expect(result.coveredShares).toBe(60n); expect(result.maxSelectedTaxRateIndex).toBe(5); }); test('keeps track of remaining shortfall when not enough candidates', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 10n, taxRate: 0.01 }, { id: 2n, stakeShares: 10n, taxRate: 0.02 }, ]; const result = selectSnatchPositions(candidates, { shortfallShares: 50n, maxTaxRate: 0.05, }); expect(result.remainingShortfall).toBe(30n); }); test('returns empty result when shortfallShares is zero', () => { const candidates: SnatchablePosition[] = [{ id: 1n, stakeShares: 100n, taxRate: 0.05 }]; const result = selectSnatchPositions(candidates, { shortfallShares: 0n, maxTaxRate: 0.1, }); expect(result.selected).toEqual([]); expect(result.coveredShares).toBe(0n); expect(result.remainingShortfall).toBe(0n); }); test('filters out candidates at or above maxTaxRate', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 50n, taxRate: 0.1 }, // equal to maxTaxRate -> filtered { id: 2n, stakeShares: 50n, taxRate: 0.15 }, // above maxTaxRate -> filtered { id: 3n, stakeShares: 50n, taxRate: 0.05 }, // below maxTaxRate -> included ]; const result = selectSnatchPositions(candidates, { shortfallShares: 100n, maxTaxRate: 0.1, }); expect(result.selected.map(p => p.id)).toEqual([3n]); expect(result.remainingShortfall).toBe(50n); }); test('filters out positions owned by recipient when includeOwned is false', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 50n, taxRate: 0.05, owner: '0xABC' }, { id: 2n, stakeShares: 50n, taxRate: 0.04, owner: '0xDEF' }, ]; const result = selectSnatchPositions(candidates, { shortfallShares: 100n, maxTaxRate: 0.1, includeOwned: false, recipientAddress: '0xabc', // same as owner of id=1 (case-insensitive) }); // id=1 is owned by recipient -> filtered out expect(result.selected.map(p => p.id)).toEqual([2n]); }); test('includes owned positions when includeOwned is true', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 50n, taxRate: 0.05, owner: '0xABC' }, { id: 2n, stakeShares: 50n, taxRate: 0.04, owner: '0xDEF' }, ]; const result = selectSnatchPositions(candidates, { shortfallShares: 100n, maxTaxRate: 0.1, includeOwned: true, recipientAddress: '0xabc', }); expect(result.selected.map(p => p.id)).toEqual([2n, 1n]); expect(result.coveredShares).toBe(100n); }); test('skips candidates with zero stakeShares', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 0n, taxRate: 0.01 }, // zero shares -> skipped { id: 2n, stakeShares: 50n, taxRate: 0.02 }, ]; const result = selectSnatchPositions(candidates, { shortfallShares: 30n, maxTaxRate: 0.05, }); expect(result.selected.map(p => p.id)).toEqual([2n]); }); test('tracks maxSelectedTaxRateIndex for tie-breaking same tax rate', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 20n, taxRate: 0.05, taxRateIndex: 3 }, { id: 2n, stakeShares: 20n, taxRate: 0.05, taxRateIndex: 7 }, // higher index, same rate ]; const result = selectSnatchPositions(candidates, { shortfallShares: 40n, maxTaxRate: 0.1, }); // Both are selected; the one with higher taxRateIndex should win expect(result.maxSelectedTaxRateIndex).toBe(7); }); test('handles tie-breaking when first candidate has no taxRateIndex (covers ?? -1 fallback)', () => { const candidates: SnatchablePosition[] = [ { id: 1n, stakeShares: 20n, taxRate: 0.05 }, // no taxRateIndex { id: 2n, stakeShares: 20n, taxRate: 0.05, taxRateIndex: 5 }, // same rate, has index ]; const result = selectSnatchPositions(candidates, { shortfallShares: 40n, maxTaxRate: 0.1, }); expect(result.maxSelectedTaxRateIndex).toBe(5); }); }); describe('getSnatchList', () => { const makePosition = (id: bigint, share: number, taxRate: number, owner = '0xowner'): GraphPosition => ({ __typename: 'positions' as const, id: uint256ToBytesLittleEndian(id) as unknown as string, owner, share, creationTime: '0', lastTaxTime: '0', taxRate, status: 'Active', createdAt: 0, kraikenDeposit: '0', payout: '0', snatched: 0, stakeDeposit: '0', taxPaid: '0', totalSupplyInit: '0', }) as unknown as GraphPosition; test('converts subgraph positions and returns lowest-tax IDs', () => { const stakeTotalSupply = 1_000_000n * 10n ** 18n; const positions = [makePosition(1n, 0.0001, 0.02), makePosition(2n, 0.0002, 0.01)]; const result = getSnatchList(positions, 10n ** 18n, 0.05, stakeTotalSupply); expect(result).toEqual([2n]); }); test('throws when stakeTotalSupply is zero', () => { const positions = [makePosition(1n, 0.5, 0.02)]; expect(() => getSnatchList(positions, 10n, 0.05, 0n)).toThrow('stakeTotalSupply must be greater than zero'); }); test('throws when there is not enough capacity to cover needed shares', () => { // Only one small position, but need is larger than its shares const stakeTotalSupply = 100n; const positions = [makePosition(1n, 0.001, 0.01)]; // tiny share expect(() => getSnatchList(positions, 1000n, 0.05, stakeTotalSupply)).toThrow('Not enough capacity'); }); });