import type { Positions as Position } from './__generated__/graphql.js'; import { toBigIntId } from './ids.js'; export interface SnatchablePosition { id: bigint; stakeShares: bigint; taxRate: number; taxRateIndex?: number; owner?: string | null; } export interface SnatchSelectionOptions { shortfallShares: bigint; maxTaxRate: number; includeOwned?: boolean; recipientAddress?: string | null; } export interface SnatchSelectionResult { selected: SnatchablePosition[]; coveredShares: bigint; remainingShortfall: bigint; maxSelectedTaxRate?: number; maxSelectedTaxRateIndex?: number; } const SHARE_SCALE = 1_000_000n; function normaliseAddress(value?: string | null): string { return (value ?? '').toLowerCase(); } export function minimumTaxRate(positions: T[], fallback: number = 0): number { if (!positions.length) return fallback; return positions.reduce((min, position) => (position.taxRate < min ? position.taxRate : min), Number.POSITIVE_INFINITY); } export function selectSnatchPositions(candidates: SnatchablePosition[], options: SnatchSelectionOptions): SnatchSelectionResult { const { shortfallShares, maxTaxRate, includeOwned = false, recipientAddress = null } = options; if (shortfallShares <= 0n) { return { selected: [], coveredShares: 0n, remainingShortfall: 0n, }; } const recipientNormalised = normaliseAddress(recipientAddress); const filtered = candidates.filter(candidate => { if (candidate.taxRate >= maxTaxRate) { return false; } if (!includeOwned && candidate.owner) { return normaliseAddress(candidate.owner) !== recipientNormalised; } return true; }); const sorted = filtered.slice().sort((a, b) => { if (a.taxRate === b.taxRate) { return Number(a.stakeShares - b.stakeShares); } return a.taxRate - b.taxRate; }); const selection: SnatchablePosition[] = []; let remaining = shortfallShares; let covered = 0n; let maxSelectedTaxRate: number | undefined; let maxSelectedTaxRateIndex: number | undefined; for (const candidate of sorted) { if (remaining <= 0n) break; if (candidate.stakeShares <= 0n) continue; selection.push(candidate); remaining -= candidate.stakeShares; if (remaining < 0n) { covered = shortfallShares; remaining = 0n; } else { covered = shortfallShares - remaining; } if (maxSelectedTaxRate === undefined || candidate.taxRate > maxSelectedTaxRate) { maxSelectedTaxRate = candidate.taxRate; if (typeof candidate.taxRateIndex === 'number') { maxSelectedTaxRateIndex = candidate.taxRateIndex; } } else if ( candidate.taxRate === maxSelectedTaxRate && typeof candidate.taxRateIndex === 'number' && candidate.taxRateIndex > (maxSelectedTaxRateIndex ?? -1) ) { maxSelectedTaxRateIndex = candidate.taxRateIndex; } } return { selected: selection, coveredShares: covered, remainingShortfall: remaining > 0n ? remaining : 0n, maxSelectedTaxRate, maxSelectedTaxRateIndex, }; } export function getSnatchList( positions: Position[], needed: bigint, taxRate: number, stakeTotalSupply: bigint ): bigint[] { if (stakeTotalSupply <= 0n) { throw new Error('stakeTotalSupply must be greater than zero'); } const candidates: SnatchablePosition[] = positions.map(position => { const shareRatio = Number(position.share); const scaledShares = BigInt(Math.round(shareRatio * Number(SHARE_SCALE))); return { id: toBigIntId(position.id), owner: position.owner, stakeShares: (stakeTotalSupply * scaledShares) / SHARE_SCALE, taxRate: Number(position.taxRate), }; }); const result = selectSnatchPositions(candidates, { shortfallShares: needed, maxTaxRate: taxRate, includeOwned: true, }); if (result.remainingShortfall > 0n) { throw new Error('Not enough capacity'); } return result.selected.map(candidate => candidate.id); }