Skip to content

Commit

Permalink
test(ui): add unit tests for calculateValidatorPoolMetrics
Browse files Browse the repository at this point in the history
Also moves the function to `src/utils/contracts.ts` and the PoolData type to `src/interfaces/validator.ts`
  • Loading branch information
drichar committed Jul 23, 2024
1 parent 328b567 commit 72371db
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 32 deletions.
33 changes: 2 additions & 31 deletions ui/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
FindPoolForStakerResponse,
MbrAmounts,
NodePoolAssignmentConfig,
PoolData,
PoolInfo,
RawConstraints,
RawNodePoolAssignmentConfig,
Expand All @@ -38,11 +39,11 @@ import { makeEmptyTransactionSigner } from '@/lib/makeEmptyTransactionSigner'
import { BalanceChecker } from '@/utils/balanceChecker'
import { chunkBytes } from '@/utils/bytes'
import {
calculateValidatorPoolMetrics,
transformNodePoolAssignment,
transformStakedInfo,
transformValidatorData,
} from '@/utils/contracts'
import { roundToWholeAlgos } from '@/utils/format'
import { getAlgodConfigFromViteEnvironment } from '@/utils/network/getAlgoClientConfigs'
import { ParamsCache } from '@/utils/paramsCache'
import { encodeCallParams } from '@/utils/tests/abi'
Expand Down Expand Up @@ -81,12 +82,6 @@ export function callGetValidatorState(
.simulate({ allowEmptySignatures: true, allowUnnamedResources: true })
}

type PoolData = {
balance: bigint
lastPayout?: bigint
apy?: number
}

async function processPool(pool: PoolInfo): Promise<PoolData> {
const poolBalance = await fetchAccountBalance(algosdk.getApplicationAddress(pool.poolAppId), true)
if (poolBalance === 0) {
Expand All @@ -107,30 +102,6 @@ async function processPool(pool: PoolInfo): Promise<PoolData> {
}
}

function calculateValidatorPoolMetrics(
poolsData: PoolData[],
totalAlgoStaked: bigint,
epochRoundLength: bigint,
currentRound: bigint,
) {
const totalBalances = poolsData.reduce((sum, data) => sum + data.balance, 0n)
const oldestRound = poolsData.reduce((oldest, data) => {
if (!data.lastPayout) return oldest
const nextRound = data.lastPayout - (data.lastPayout % epochRoundLength) + epochRoundLength
return oldest === 0n || nextRound < oldest ? nextRound : oldest
}, 0n)

const rewardsBalance = roundToWholeAlgos(totalBalances - totalAlgoStaked)
const roundsSinceLastPayout = oldestRound ? currentRound - oldestRound : undefined

// Calculate APY only for pools with non-zero balance
const nonZeroBalancePools = poolsData.filter((data) => data.balance > 0n)
const totalApy = nonZeroBalancePools.reduce((sum, data) => sum + (data.apy || 0), 0)
const apy = nonZeroBalancePools.length > 0 ? totalApy / nonZeroBalancePools.length : 0

return { rewardsBalance, roundsSinceLastPayout, apy }
}

async function setValidatorPoolMetrics(validator: Validator, queryClient?: QueryClient) {
if (validator.pools.length === 0) return

Expand Down
7 changes: 7 additions & 0 deletions ui/src/interfaces/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,10 @@ export interface Constraints {
maxPoolsPerNode: number
maxStakersPerPool: number
}

// Used for calculating validator metrics
export type PoolData = {
balance: bigint
lastPayout?: bigint
apy?: number
}
122 changes: 122 additions & 0 deletions ui/src/utils/contracts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
calculateMaxStake,
calculateRewardEligibility,
calculateSaturationPercentage,
calculateValidatorPoolMetrics,
getEpochLengthBlocks,
isStakingDisabled,
isUnstakingDisabled,
Expand Down Expand Up @@ -465,3 +466,124 @@ describe('calculateSaturationPercentage', () => {
expect(calculateSaturationPercentage(validator, constraints)).toBe(0.0001)
})
})

describe('calculateValidatorPoolMetrics', () => {
const epochRoundLength = 1000n
const currentRound = 5000n

it('calculates metrics correctly with multiple non-zero balance pools', () => {
const poolsData = [
{ balance: 1000000n, lastPayout: 4000n, apy: 5 },
{ balance: 2000000n, lastPayout: 3500n, apy: 7 },
{ balance: 3000000n, lastPayout: 4500n, apy: 6 },
]
const totalAlgoStaked = 5500000n

const result = calculateValidatorPoolMetrics(
poolsData,
totalAlgoStaked,
epochRoundLength,
currentRound,
)

expect(result.rewardsBalance).toBe(1000000n) // Rounded to nearest whole ALGO
expect(result.roundsSinceLastPayout).toBe(1000n)
expect(result.apy).toBe(6)
})

it('handles pools with zero balance', () => {
const poolsData = [
{ balance: 1000000n, lastPayout: 4000n, apy: 5 },
{ balance: 0n, lastPayout: 3500n, apy: 0 },
{ balance: 3000000n, lastPayout: 4500n, apy: 6 },
]
const totalAlgoStaked = 3900000n

const result = calculateValidatorPoolMetrics(
poolsData,
totalAlgoStaked,
epochRoundLength,
currentRound,
)

expect(result.rewardsBalance).toBe(0n)
expect(result.roundsSinceLastPayout).toBe(1000n)
expect(result.apy).toBe(5.5)
})

it('returns zero APY when all pools have zero balance', () => {
const poolsData = [
{ balance: 0n, lastPayout: 4000n, apy: 0 },
{ balance: 0n, lastPayout: 3500n, apy: 0 },
]
const totalAlgoStaked = 0n

const result = calculateValidatorPoolMetrics(
poolsData,
totalAlgoStaked,
epochRoundLength,
currentRound,
)

expect(result.rewardsBalance).toBe(0n)
expect(result.roundsSinceLastPayout).toBe(1000n)
expect(result.apy).toBe(0)
})

it('handles undefined lastPayout', () => {
const poolsData = [
{ balance: 1000000n, lastPayout: undefined, apy: 5 },
{ balance: 2000000n, lastPayout: 3500n, apy: 7 },
]
const totalAlgoStaked = 2900000n

const result = calculateValidatorPoolMetrics(
poolsData,
totalAlgoStaked,
epochRoundLength,
currentRound,
)

expect(result.rewardsBalance).toBe(0n)
expect(result.roundsSinceLastPayout).toBe(1000n)
expect(result.apy).toBe(6)
})

it('returns undefined roundsSinceLastPayout when no valid lastPayout', () => {
const poolsData = [
{ balance: 1000000n, lastPayout: undefined, apy: 5 },
{ balance: 2000000n, lastPayout: undefined, apy: 7 },
]
const totalAlgoStaked = 2900000n

const result = calculateValidatorPoolMetrics(
poolsData,
totalAlgoStaked,
epochRoundLength,
currentRound,
)

expect(result.rewardsBalance).toBe(0n)
expect(result.roundsSinceLastPayout).toBeUndefined()
expect(result.apy).toBe(6)
})

it('handles negative rewards balance', () => {
const poolsData = [
{ balance: 1000000n, lastPayout: 4000n, apy: 5 },
{ balance: 2000000n, lastPayout: 3500n, apy: 7 },
]
const totalAlgoStaked = 3100000n

const result = calculateValidatorPoolMetrics(
poolsData,
totalAlgoStaked,
epochRoundLength,
currentRound,
)

expect(result.rewardsBalance).toBe(0n)
expect(result.roundsSinceLastPayout).toBe(1000n)
expect(result.apy).toBe(6)
})
})
27 changes: 26 additions & 1 deletion ui/src/utils/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
EntryGatingAssets,
NodeInfo,
NodePoolAssignmentConfig,
PoolData,
PoolInfo,
RawNodePoolAssignmentConfig,
RawPoolsInfo,
Expand All @@ -22,7 +23,7 @@ import {
ValidatorState,
} from '@/interfaces/validator'
import { dayjs } from '@/utils/dayjs'
import { convertToBaseUnits, roundToFirstNonZeroDecimal } from '@/utils/format'
import { convertToBaseUnits, roundToFirstNonZeroDecimal, roundToWholeAlgos } from '@/utils/format'

/**
* Transform raw validator configuration data (from `callGetValidatorConfig`) into a structured object
Expand Down Expand Up @@ -757,3 +758,27 @@ export function calculateValidatorHealth(roundsSinceLastPayout: bigint | undefin
return Indicator.Normal
}
}

export function calculateValidatorPoolMetrics(
poolsData: PoolData[],
totalAlgoStaked: bigint,
epochRoundLength: bigint,
currentRound: bigint,
) {
const totalBalances = poolsData.reduce((sum, data) => sum + data.balance, 0n)
const oldestRound = poolsData.reduce((oldest, data) => {
if (!data.lastPayout) return oldest
const nextRound = data.lastPayout - (data.lastPayout % epochRoundLength) + epochRoundLength
return oldest === 0n || nextRound < oldest ? nextRound : oldest
}, 0n)

const rewardsBalance = roundToWholeAlgos(totalBalances - totalAlgoStaked)
const roundsSinceLastPayout = oldestRound ? currentRound - oldestRound : undefined

// Calculate APY only for pools with non-zero balance
const nonZeroBalancePools = poolsData.filter((data) => data.balance > 0n)
const totalApy = nonZeroBalancePools.reduce((sum, data) => sum + (data.apy || 0), 0)
const apy = nonZeroBalancePools.length > 0 ? totalApy / nonZeroBalancePools.length : 0

return { rewardsBalance, roundsSinceLastPayout, apy }
}

0 comments on commit 72371db

Please sign in to comment.