Extract snatch selection into reusable composable (#30)

## Summary

  - add a useSnatchSelection composable that centralises snatch shortfall calculations, position filtering, and RPC memoisation
  - refactor StakeHolder.vue to consume the composable instead of reimplementing the flow inline
  - introduce Vitest config and first composable tests (useSnatchSelection.spec.ts) to cover empty/partial fills and ownership edge cases
  - wire up project tooling updates so the new tests run (jsdom dep, updated package metadata)

  ## Testing

  - cd web-app && npm install
  - npm test

resolves #24

Co-authored-by: openhands <openhands@all-hands.dev>
Reviewed-on: https://codeberg.org/johba/harb/pulls/30
This commit is contained in:
johba 2025-09-30 20:35:47 +02:00
parent 76d84341de
commit 26a8771848
6 changed files with 1202 additions and 311 deletions

View file

@ -0,0 +1,274 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { useSnatchSelection } from '../useSnatchSelection'
import { usePositions } from '../usePositions'
import { useStake } from '../useStake'
import { useWallet } from '../useWallet'
import { useStatCollection } from '../useStatCollection'
import { useAdjustTaxRate } from '../useAdjustTaxRates'
// Mock all composables
vi.mock('../usePositions', () => ({
usePositions: vi.fn()
}))
vi.mock('../useStake', () => ({
useStake: vi.fn()
}))
vi.mock('../useWallet', () => ({
useWallet: vi.fn()
}))
vi.mock('../useStatCollection', () => ({
useStatCollection: vi.fn()
}))
vi.mock('../useAdjustTaxRates', () => ({
useAdjustTaxRate: vi.fn()
}))
vi.mock('kraiken-lib', () => ({
calculateSnatchShortfall: vi.fn((outstandingStake, stakingShares, stakeTotalSupply) => {
return stakingShares > outstandingStake ? 0n : outstandingStake - stakingShares
}),
selectSnatchPositions: vi.fn((candidates, options) => {
if (candidates.length === 0) {
return { selected: [], remainingShortfall: options.shortfallShares }
}
return {
selected: candidates,
remainingShortfall: 0n,
maxSelectedTaxRateIndex: candidates[candidates.length - 1].taxRateIndex
}
}),
minimumTaxRate: vi.fn(() => 0.01)
}))
describe('useSnatchSelection', () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
// Setup default mock values with proper refs
vi.mocked(usePositions).mockReturnValue({
activePositions: ref([])
} as any)
vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 0n,
taxRate: 1.0
} as any)
vi.mocked(useWallet).mockReturnValue({
account: { address: '0x123' }
} as any)
// Mock with realistic values for local computation
// stakeTotalSupply is typically 10^(18+7) = 10^25 from contract
// kraikenTotalSupply is dynamic but starts around 10^25
vi.mocked(useStatCollection).mockReturnValue({
stakeTotalSupply: 10000000000000000000000000n, // 10^25
kraikenTotalSupply: 10000000000000000000000000n, // 10^25
outstandingStake: 500n
} as any)
vi.mocked(useAdjustTaxRate).mockReturnValue({
taxRates: [{ year: 1 }]
} as any)
})
it('should initialize with empty snatchable positions', () => {
const { snatchablePositions } = useSnatchSelection()
expect(snatchablePositions.value).toEqual([])
})
it('should handle no active positions', () => {
const { snatchablePositions, floorTax } = useSnatchSelection()
expect(snatchablePositions.value).toEqual([])
expect(floorTax.value).toBe(1)
})
it('should handle no shortfall', () => {
vi.mocked(useStatCollection).mockReturnValue({
stakeTotalSupply: 1000n,
outstandingStake: 100n
})
vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 900n,
taxRate: 1.0
})
const { snatchablePositions, openPositionsAvailable } = useSnatchSelection()
expect(snatchablePositions.value).toEqual([])
expect(openPositionsAvailable.value).toBe(true)
})
it('should filter out positions with higher tax rate', async () => {
vi.mocked(usePositions).mockReturnValue({
activePositions: ref([
{
positionId: 1n,
owner: '0x456',
harbDeposit: 100n,
taxRate: 2.0,
taxRateIndex: 1,
iAmOwner: false
}
])
} as any)
vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 100n,
taxRate: 1.0
} as any)
const { snatchablePositions } = useSnatchSelection()
// Wait for watchEffect to run
await new Promise(resolve => setTimeout(resolve, 0))
expect(snatchablePositions.value).toEqual([])
})
it('should filter out owned positions by default', async () => {
vi.mocked(usePositions).mockReturnValue({
activePositions: ref([
{
positionId: 1n,
owner: '0x123',
harbDeposit: 100n,
taxRate: 0.5,
taxRateIndex: 1,
iAmOwner: true
}
])
} as any)
const { snatchablePositions } = useSnatchSelection()
await new Promise(resolve => setTimeout(resolve, 0))
expect(snatchablePositions.value).toEqual([])
})
it('should include owned positions when demo mode is enabled', async () => {
const position = {
positionId: 1n,
owner: '0x123',
harbDeposit: 100n,
taxRate: 0.005, // 0.5% tax rate (less than maxTaxRate)
taxRateIndex: 1,
iAmOwner: true
}
vi.mocked(usePositions).mockReturnValue({
activePositions: ref([position])
} as any)
vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 100n,
taxRate: 1.0 // Will be converted to 0.01 (1%) decimal
} as any)
// Need outstandingStake > stakingAmountShares to create shortfall
vi.mocked(useStatCollection).mockReturnValue({
stakeTotalSupply: 10000000000000000000000000n,
kraikenTotalSupply: 10000000000000000000000000n,
outstandingStake: 500n
} as any)
const { snatchablePositions } = useSnatchSelection(true)
// Wait for watchEffect to run (no longer async)
await nextTick()
expect(snatchablePositions.value).toContainEqual(position)
})
it('should handle partial fills', async () => {
const position1 = {
positionId: 1n,
owner: '0x456',
harbDeposit: 100n,
taxRate: 0.005, // 0.5% tax rate
taxRateIndex: 1,
iAmOwner: false
}
const position2 = {
positionId: 2n,
owner: '0x789',
harbDeposit: 200n,
taxRate: 0.006, // 0.6% tax rate
taxRateIndex: 2,
iAmOwner: false
}
vi.mocked(usePositions).mockReturnValue({
activePositions: ref([position1, position2])
} as any)
vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 150n,
taxRate: 1.0 // Will be converted to 0.01 (1%) decimal
} as any)
// Need outstandingStake > stakingAmountShares to create shortfall
vi.mocked(useStatCollection).mockReturnValue({
stakeTotalSupply: 10000000000000000000000000n,
kraikenTotalSupply: 10000000000000000000000000n,
outstandingStake: 500n
} as any)
const { snatchablePositions } = useSnatchSelection()
// Wait for watchEffect to run
await nextTick()
expect(snatchablePositions.value).toEqual([position1, position2])
})
it('should update floor tax based on selected positions', async () => {
const position = {
positionId: 1n,
owner: '0x456',
harbDeposit: 100n,
taxRate: 0.005, // 0.5% tax rate
taxRateIndex: 1,
iAmOwner: false
}
vi.mocked(usePositions).mockReturnValue({
activePositions: ref([position])
} as any)
vi.mocked(useStake).mockReturnValue({
stakingAmountShares: 100n,
taxRate: 1.0 // Will be converted to 0.01 (1%) decimal
} as any)
// Need outstandingStake > stakingAmountShares to create shortfall
vi.mocked(useStatCollection).mockReturnValue({
stakeTotalSupply: 10000000000000000000000000n,
kraikenTotalSupply: 10000000000000000000000000n,
outstandingStake: 500n
} as any)
vi.mocked(useAdjustTaxRate).mockReturnValue({
taxRates: [
{ year: 1 },
{ year: 2 },
{ year: 3 }
]
} as any)
const { floorTax } = useSnatchSelection()
// Wait for watchEffect to run
await nextTick()
// Floor tax should be taxRates[maxSelectedTaxRateIndex + 1]
// Position has taxRateIndex: 1, so nextIndex = 2, taxRates[2] = { year: 3 }
expect(floorTax.value).toBe(3)
})
})

View file

@ -0,0 +1,180 @@
import { ref, watchEffect, computed } 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,
selectSnatchPositions,
minimumTaxRate,
type SnatchablePosition,
} from 'kraiken-lib'
/**
* 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) {
const { activePositions } = usePositions()
const stake = useStake()
const wallet = useWallet()
const statCollection = useStatCollection()
const adjustTaxRate = useAdjustTaxRate()
const snatchablePositions = ref<Position[]>([])
const shortfallShares = ref<bigint>(0n)
const floorTax = ref(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 maxTaxRateDecimal = (stake.taxRate ?? 0) / 100
const includeOwned = demo
const recipient = wallet.account.address ?? null
const eligiblePositions = activePositions.value.filter((position: Position) => {
if (position.taxRate >= maxTaxRateDecimal) {
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<bigint, Position>()
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,
}
}