From 72371db5f83b38a702d81c5716cbe31d4f4872e4 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 23 Jul 2024 15:59:18 -0400 Subject: [PATCH] test(ui): add unit tests for calculateValidatorPoolMetrics Also moves the function to `src/utils/contracts.ts` and the PoolData type to `src/interfaces/validator.ts` --- ui/src/api/contracts.ts | 33 +-------- ui/src/interfaces/validator.ts | 7 ++ ui/src/utils/contracts.spec.ts | 122 +++++++++++++++++++++++++++++++++ ui/src/utils/contracts.ts | 27 +++++++- 4 files changed, 157 insertions(+), 32 deletions(-) diff --git a/ui/src/api/contracts.ts b/ui/src/api/contracts.ts index 8f2b49df..272fa9cc 100644 --- a/ui/src/api/contracts.ts +++ b/ui/src/api/contracts.ts @@ -23,6 +23,7 @@ import { FindPoolForStakerResponse, MbrAmounts, NodePoolAssignmentConfig, + PoolData, PoolInfo, RawConstraints, RawNodePoolAssignmentConfig, @@ -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' @@ -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 { const poolBalance = await fetchAccountBalance(algosdk.getApplicationAddress(pool.poolAppId), true) if (poolBalance === 0) { @@ -107,30 +102,6 @@ async function processPool(pool: PoolInfo): Promise { } } -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 diff --git a/ui/src/interfaces/validator.ts b/ui/src/interfaces/validator.ts index a32140d7..4ca02dd8 100644 --- a/ui/src/interfaces/validator.ts +++ b/ui/src/interfaces/validator.ts @@ -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 +} diff --git a/ui/src/utils/contracts.spec.ts b/ui/src/utils/contracts.spec.ts index a8a59923..b7e391ec 100644 --- a/ui/src/utils/contracts.spec.ts +++ b/ui/src/utils/contracts.spec.ts @@ -2,6 +2,7 @@ import { calculateMaxStake, calculateRewardEligibility, calculateSaturationPercentage, + calculateValidatorPoolMetrics, getEpochLengthBlocks, isStakingDisabled, isUnstakingDisabled, @@ -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) + }) +}) diff --git a/ui/src/utils/contracts.ts b/ui/src/utils/contracts.ts index 54c905b4..ebd3f692 100644 --- a/ui/src/utils/contracts.ts +++ b/ui/src/utils/contracts.ts @@ -12,6 +12,7 @@ import { EntryGatingAssets, NodeInfo, NodePoolAssignmentConfig, + PoolData, PoolInfo, RawNodePoolAssignmentConfig, RawPoolsInfo, @@ -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 @@ -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 } +}