import { ref, watchEffect, computed, type Ref } from 'vue'; import { usePositions, type Position } from './usePositions'; import { useStake } from './useStake'; import { useWallet } from './useWallet'; import { useStatCollection } from './useStatCollection'; import { useAdjustTaxRate } from './useAdjustTaxRates'; import { calculateSnatchShortfall } from 'kraiken-lib/staking'; import { selectSnatchPositions, minimumTaxRate, type SnatchablePosition } from 'kraiken-lib/snatch'; import { DEFAULT_CHAIN_ID } from '@/config'; /** * Converts Kraiken token assets to shares using the same formula as Stake.sol: * shares = (assets * stakeTotalSupply) / kraikenTotalSupply * * @param assets - Amount of Kraiken tokens * @param kraikenTotalSupply - Total supply of Kraiken tokens * @param stakeTotalSupply - Total supply of stake shares (constant from contract) * @returns Number of shares corresponding to the assets */ function assetsToSharesLocal(assets: bigint, kraikenTotalSupply: bigint, stakeTotalSupply: bigint): bigint { if (kraikenTotalSupply === 0n) { return 0n; } // Equivalent to: assets.mulDiv(stakeTotalSupply, kraikenTotalSupply, Math.Rounding.Down) return (assets * stakeTotalSupply) / kraikenTotalSupply; } export function useSnatchSelection(demo = false, taxRateIndex?: Ref, chainId?: number) { const wallet = useWallet(); const resolvedChainId = chainId ?? wallet.account.chainId ?? DEFAULT_CHAIN_ID; const { activePositions } = usePositions(resolvedChainId); const stake = useStake(); const statCollection = useStatCollection(resolvedChainId); const adjustTaxRate = useAdjustTaxRate(); const snatchablePositions = ref([]); const shortfallShares = ref(0n); const floorTax = ref(adjustTaxRate.taxRates[0]?.year ?? 1); let selectionRun = 0; const openPositionsAvailable = computed(() => shortfallShares.value <= 0n); function getMinFloorTax() { const minRate = minimumTaxRate(activePositions.value, 0); return Math.round(minRate * 100); } watchEffect(onCleanup => { const runId = ++selectionRun; let cancelled = false; onCleanup(() => { cancelled = true; }); // No longer async since we compute shares locally const compute = () => { if (statCollection.stakeTotalSupply === 0n) { shortfallShares.value = 0n; if (!cancelled && runId === selectionRun) { snatchablePositions.value = []; floorTax.value = getMinFloorTax(); } return; } const stakingShares = stake.stakingAmountShares ?? 0n; const shortfall = calculateSnatchShortfall(statCollection.outstandingStake, stakingShares, statCollection.stakeTotalSupply, 2n, 10n); shortfallShares.value = shortfall; if (shortfall <= 0n) { if (!cancelled && runId === selectionRun) { snatchablePositions.value = []; floorTax.value = getMinFloorTax(); } return; } const stakeTaxRateIndex = (stake as { taxRateIndex?: number }).taxRateIndex; const selectedTaxRateIndex = taxRateIndex?.value ?? stakeTaxRateIndex; const maxTaxRateDecimal = typeof selectedTaxRateIndex === 'number' && Number.isInteger(selectedTaxRateIndex) ? (adjustTaxRate.taxRates[selectedTaxRateIndex]?.decimal ?? Number.POSITIVE_INFINITY) : Number.POSITIVE_INFINITY; const includeOwned = demo; const recipient = wallet.account.address ?? null; const eligiblePositions = activePositions.value.filter((position: Position) => { // Filter by tax rate index instead of decimal to avoid floating-point issues const posIndex = position.taxRateIndex; if (typeof posIndex !== 'number' || (typeof selectedTaxRateIndex === 'number' && posIndex >= selectedTaxRateIndex)) { return false; } if (!includeOwned && position.iAmOwner) { return false; } return true; }); if (eligiblePositions.length === 0) { if (!cancelled && runId === selectionRun) { snatchablePositions.value = []; floorTax.value = getMinFloorTax(); } return; } const candidates: SnatchablePosition[] = []; // Compute shares locally using the same formula as Stake.sol for (const position of eligiblePositions) { const shares = assetsToSharesLocal(position.harbDeposit, statCollection.kraikenTotalSupply, statCollection.stakeTotalSupply); candidates.push({ id: position.positionId, owner: position.owner, stakeShares: shares, taxRate: position.taxRate, taxRateIndex: position.taxRateIndex, }); } const selection = selectSnatchPositions(candidates, { shortfallShares: shortfall, maxTaxRate: maxTaxRateDecimal, includeOwned, recipientAddress: recipient, }); if (cancelled || runId !== selectionRun) { return; } if (selection.remainingShortfall > 0n) { snatchablePositions.value = []; floorTax.value = getMinFloorTax(); return; } const positionById = new Map(); for (const position of activePositions.value) { positionById.set(position.positionId, position); } const selectedPositions = selection.selected .map(candidate => positionById.get(candidate.id)) .filter((value): value is Position => Boolean(value)); snatchablePositions.value = selectedPositions; if (selection.maxSelectedTaxRateIndex !== undefined) { const nextIndex = selection.maxSelectedTaxRateIndex + 1; const option = adjustTaxRate.taxRates[nextIndex] ?? adjustTaxRate.taxRates[selection.maxSelectedTaxRateIndex]; floorTax.value = option ? option.year : getMinFloorTax(); } else { floorTax.value = getMinFloorTax(); } }; compute(); }); return { snatchablePositions, shortfallShares, floorTax, openPositionsAvailable, }; }