From 90b261a12db5b454e6eaa80fc3857bb7778b52ab Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 23 Jul 2024 16:12:53 -0400 Subject: [PATCH] feat(ui): rewards column in ValidatorTable is sortable (#249) * feat(ui): rewards column in ValidatorTable is sortable * fix(ui): remove unused argument from sortRewardsFn * feat(ui): update TrafficLight indicator UI * feat(ui): warning level for validator health * feat(ui): calculate avg APY only using pools with non-zero balance Closes TXN-1742 * 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 | 76 ++++++++--- ui/src/components/SaturationIndicator.tsx | 39 ------ ui/src/components/TrafficLight.tsx | 58 +++++++++ .../ValidatorDetails/PoolsChart.tsx | 4 +- ui/src/components/ValidatorRewards.tsx | 39 +++--- ui/src/components/ValidatorTable.tsx | 47 ++++++- ui/src/constants/indicator.ts | 13 ++ ui/src/constants/saturation.ts | 13 -- ui/src/interfaces/validator.ts | 9 ++ ui/src/utils/contracts.spec.ts | 122 ++++++++++++++++++ ui/src/utils/contracts.ts | 56 ++++++-- ui/src/utils/format.spec.ts | 66 ++++++++++ ui/src/utils/format.ts | 17 +++ 13 files changed, 460 insertions(+), 99 deletions(-) delete mode 100644 ui/src/components/SaturationIndicator.tsx create mode 100644 ui/src/components/TrafficLight.tsx create mode 100644 ui/src/constants/indicator.ts delete mode 100644 ui/src/constants/saturation.ts diff --git a/ui/src/api/contracts.ts b/ui/src/api/contracts.ts index 4bd23b33..272fa9cc 100644 --- a/ui/src/api/contracts.ts +++ b/ui/src/api/contracts.ts @@ -3,7 +3,7 @@ import { TransactionSignerAccount } from '@algorandfoundation/algokit-utils/type import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' import { QueryClient } from '@tanstack/react-query' import algosdk from 'algosdk' -import { fetchAsset, isOptedInToAsset } from '@/api/algod' +import { fetchAccountBalance, fetchAsset, isOptedInToAsset } from '@/api/algod' import { getSimulateStakingPoolClient, getSimulateValidatorClient, @@ -23,6 +23,7 @@ import { FindPoolForStakerResponse, MbrAmounts, NodePoolAssignmentConfig, + PoolData, PoolInfo, RawConstraints, RawNodePoolAssignmentConfig, @@ -38,6 +39,7 @@ import { makeEmptyTransactionSigner } from '@/lib/makeEmptyTransactionSigner' import { BalanceChecker } from '@/utils/balanceChecker' import { chunkBytes } from '@/utils/bytes' import { + calculateValidatorPoolMetrics, transformNodePoolAssignment, transformStakedInfo, transformValidatorData, @@ -80,9 +82,64 @@ export function callGetValidatorState( .simulate({ allowEmptySignatures: true, allowUnnamedResources: true }) } +async function processPool(pool: PoolInfo): Promise { + const poolBalance = await fetchAccountBalance(algosdk.getApplicationAddress(pool.poolAppId), true) + if (poolBalance === 0) { + return { balance: 0n } + } + + const stakingPoolClient = await getSimulateStakingPoolClient(pool.poolAppId) + const stakingPoolGS = await stakingPoolClient.getGlobalState() + + const lastPayout = stakingPoolGS.lastPayout?.asBigInt() + const ewmaBytes = stakingPoolGS.ewma?.asByteArray() + const apy = ewmaBytes ? Number(algosdk.bytesToBigInt(ewmaBytes)) / 10000 : undefined + + return { + balance: BigInt(poolBalance), + lastPayout, + apy, + } +} + +async function setValidatorPoolMetrics(validator: Validator, queryClient?: QueryClient) { + if (validator.pools.length === 0) return + + try { + const epochRoundLength = BigInt(validator.config.epochRoundLength) + const params = await ParamsCache.getSuggestedParams() + + const poolDataPromises = validator.pools.map((pool) => processPool(pool)) + const poolsData = await Promise.all(poolDataPromises) + + const { rewardsBalance, roundsSinceLastPayout, apy } = calculateValidatorPoolMetrics( + poolsData, + validator.state.totalAlgoStaked, + epochRoundLength, + BigInt(params.firstRound), + ) + + validator.rewardsBalance = rewardsBalance + validator.roundsSinceLastPayout = roundsSinceLastPayout + validator.apy = apy + + // Seed query cache + poolsData.forEach((data, index) => { + if (data.apy !== undefined) { + queryClient?.setQueryData(['pool-apy', validator.pools[index].poolAppId], data.apy) + } + }) + queryClient?.setQueryData(['available-rewards', validator.id], rewardsBalance) + queryClient?.setQueryData(['rounds-since-last-payout', validator.id], roundsSinceLastPayout) + queryClient?.setQueryData(['validator-apy', validator.id], apy) + } catch (error) { + console.error(error) + } +} + /** * Fetches the validator's configuration, state, pools info, node pool assignments, reward token - * (if one is configured), NFD for info, and APY for all pools. When this is called by the + * (if one is configured), NFD for info, and pool metrics. When this is called by the * `fetchValidators` function, the `queryClient` parameter is passed in to seed the query cache. * @param {string | number} validatorId - The validator's ID. * @param {QueryClient} queryClient - The query client to seed the query cache. @@ -119,6 +176,8 @@ export async function fetchValidator( rawNodePoolAssignment, ) + await setValidatorPoolMetrics(validator, queryClient) + if (validator.config.rewardTokenId > 0) { const rewardToken = await fetchAsset(validator.config.rewardTokenId) validator.rewardToken = rewardToken @@ -142,19 +201,6 @@ export async function fetchValidator( validator.nfd = nfd } - const poolApyPromises = validator.pools.map(async (pool) => { - const poolApy = await fetchPoolApy(pool.poolAppId) - // Seed the query cache with the pool APY data - queryClient?.setQueryData(['pool-apy', pool.poolAppId], poolApy) - - return poolApy - }) - - const poolApys = await Promise.all(poolApyPromises) - const avgApy = poolApys.reduce((acc, apy) => acc + apy, 0) / (poolApys.length || 1) - - validator.apy = avgApy - // Seed the query cache with the validator data queryClient?.setQueryData(['validator', String(validatorId)], validator) diff --git a/ui/src/components/SaturationIndicator.tsx b/ui/src/components/SaturationIndicator.tsx deleted file mode 100644 index c89024ca..00000000 --- a/ui/src/components/SaturationIndicator.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Tooltip } from '@/components/Tooltip' -import { SaturationLevel } from '@/constants/saturation' -import { Constraints, Validator } from '@/interfaces/validator' -import { calculateSaturationPercentage, calculateStakeSaturation } from '@/utils/contracts' -import { cn } from '@/utils/ui' - -interface SaturationIndicatorProps { - validator: Validator - constraints: Constraints -} - -export function SaturationIndicator({ validator, constraints }: SaturationIndicatorProps) { - const saturationLevel = calculateStakeSaturation(validator, constraints) - - const getClassName = () => { - switch (saturationLevel) { - case SaturationLevel.Error: - return 'bg-red-500' - case SaturationLevel.Normal: - return 'bg-green-500' - case SaturationLevel.Watch: - return 'bg-yellow-500' - case SaturationLevel.Warning: - return 'bg-orange-500' - case SaturationLevel.Max: - return 'bg-red-500' - } - } - - const saturationPercent = calculateSaturationPercentage(validator, constraints) - - return ( -
- -
- -
- ) -} diff --git a/ui/src/components/TrafficLight.tsx b/ui/src/components/TrafficLight.tsx new file mode 100644 index 00000000..f6662c11 --- /dev/null +++ b/ui/src/components/TrafficLight.tsx @@ -0,0 +1,58 @@ +import { Tooltip } from '@/components/Tooltip' +import { Indicator } from '@/constants/indicator' +import { cn } from '@/utils/ui' + +interface TrafficLightProps { + indicator: Indicator + tooltipContent?: string | Partial> + className?: string + showGreen?: boolean +} + +export function TrafficLight({ + indicator, + tooltipContent = '', + className = '', + showGreen = false, +}: TrafficLightProps) { + const getClassName = () => { + switch (indicator) { + case Indicator.Error: + return 'bg-red-500' + case Indicator.Normal: + return 'bg-green-500' + case Indicator.Watch: + return 'bg-yellow-500' + case Indicator.Warning: + return 'bg-orange-500' + case Indicator.Max: + return 'bg-red-500' + } + } + + const getTooltipContent = () => { + if (typeof tooltipContent === 'string') { + return tooltipContent + } + + return tooltipContent[indicator] + } + + const renderIndicator = () => { + return
+ } + + if (indicator === Indicator.Normal && !showGreen) { + return null + } + + return ( +
+ {tooltipContent ? ( + {renderIndicator()} + ) : ( + renderIndicator() + )} +
+ ) +} diff --git a/ui/src/components/ValidatorDetails/PoolsChart.tsx b/ui/src/components/ValidatorDetails/PoolsChart.tsx index 6b209b7d..0c46f5c8 100644 --- a/ui/src/components/ValidatorDetails/PoolsChart.tsx +++ b/ui/src/components/ValidatorDetails/PoolsChart.tsx @@ -1,13 +1,13 @@ import { DonutChart, EventProps } from '@tremor/react' import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount' -type PoolData = { +type PoolChartData = { name: string value: number } interface PoolsChartProps { - data: PoolData[] + data: PoolChartData[] onValueChange: (value: EventProps) => void className?: string } diff --git a/ui/src/components/ValidatorRewards.tsx b/ui/src/components/ValidatorRewards.tsx index a12e724c..c0881e35 100644 --- a/ui/src/components/ValidatorRewards.tsx +++ b/ui/src/components/ValidatorRewards.tsx @@ -1,9 +1,12 @@ -import { Validator } from '@/interfaces/validator' import { useQuery } from '@tanstack/react-query' -import { fetchAccountBalance } from '@/api/algod' import { getApplicationAddress } from 'algosdk' -import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount' +import { Validator } from '@/interfaces/validator' +import { fetchAccountBalance } from '@/api/algod' import { getSimulateStakingPoolClient } from '@/api/clients' +import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount' +import { TrafficLight } from '@/components/TrafficLight' +import { Indicator } from '@/constants/indicator' +import { calculateValidatorHealth } from '@/utils/contracts' import { ParamsCache } from '@/utils/paramsCache' /** @@ -54,7 +57,7 @@ async function epochPayoutFetch(validator: Validator) { return BigInt(params.firstRound) - oldestRound } catch (error) { console.error(error) - return 0 + return 0n } } @@ -64,35 +67,33 @@ interface ValidatorRewardsProps { export function ValidatorRewards({ validator }: ValidatorRewardsProps) { const totalBalancesQuery = useQuery({ - queryKey: ['valrewards', validator.id], + queryKey: ['available-rewards', validator.id], queryFn: () => fetchRewardBalances(validator), refetchInterval: 30000, }) + const epochPayoutsQuery = useQuery({ - queryKey: ['epochPayouts', validator.id], + queryKey: ['rounds-since-last-payout', validator.id], queryFn: () => epochPayoutFetch(validator), refetchInterval: 30000, }) - const dotColor = - epochPayoutsQuery.data !== undefined - ? epochPayoutsQuery.data < 21 - ? 'green' // 1 minute - : epochPayoutsQuery.data < 1200 - ? 'yellow' // 1 hour - : 'red' - : 'defaultColor' - if (totalBalancesQuery.isLoading) { - return Loading... + const healthStatus = calculateValidatorHealth(epochPayoutsQuery.data) + + const tooltipContent = { + [Indicator.Normal]: 'Fully operational', + [Indicator.Watch]: 'Payouts Lagging', + [Indicator.Warning]: 'Payouts Stopped', + [Indicator.Error]: 'Rewards not compounding', } + if (totalBalancesQuery.error || totalBalancesQuery.data == undefined) { return Error fetching balance } + return ( <> - - • - + ) diff --git a/ui/src/components/ValidatorTable.tsx b/ui/src/components/ValidatorTable.tsx index 4a91fa91..45fbecfc 100644 --- a/ui/src/components/ValidatorTable.tsx +++ b/ui/src/components/ValidatorTable.tsx @@ -4,6 +4,7 @@ import { Link, useRouter } from '@tanstack/react-router' import { ColumnDef, ColumnFiltersState, + SortingFn, SortingState, Updater, VisibilityState, @@ -25,8 +26,8 @@ import { DataTableColumnHeader } from '@/components/DataTableColumnHeader' import { DataTableViewOptions } from '@/components/DataTableViewOptions' import { DebouncedSearch } from '@/components/DebouncedSearch' import { NfdThumbnail } from '@/components/NfdThumbnail' -import { SaturationIndicator } from '@/components/SaturationIndicator' import { Tooltip } from '@/components/Tooltip' +import { TrafficLight } from '@/components/TrafficLight' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { @@ -54,6 +55,8 @@ import { Constraints, Validator } from '@/interfaces/validator' import { useAuthAddress } from '@/providers/AuthAddressProvider' import { calculateMaxStake, + calculateSaturationPercentage, + calculateStakeSaturation, canManageValidator, isAddingPoolDisabled, isStakingDisabled, @@ -103,6 +106,35 @@ export function ValidatorTable({ } } + const sortRewardsFn: SortingFn = (rowA, rowB) => { + const a = rowA.original + const b = rowB.original + + // Assign values for epoch payout status + const getStatus = (validator: Validator): number => { + if (validator.roundsSinceLastPayout === undefined) return 0 // red + if (validator.roundsSinceLastPayout < 21n) return 2 // green + if (validator.roundsSinceLastPayout < 1200n) return 1 // yellow + return 0 // red + } + + const statusA = getStatus(a) + const statusB = getStatus(b) + + // Compare status + if (statusA !== statusB) { + return statusA - statusB + } + + // If status is the same, compare rewardsBalance + if (a.rewardsBalance === undefined && b.rewardsBalance === undefined) return 0 + if (a.rewardsBalance === undefined) return 1 + if (b.rewardsBalance === undefined) return -1 + + // Compare rewardsBalance + return Number(a.rewardsBalance - b.rewardsBalance) + } + // Persistent column visibility state const [columnVisibility, setColumnVisibility] = useLocalStorage( 'validator-columns', @@ -234,11 +266,18 @@ export function ValidatorTable({ notation: 'compact', }).format(maxStakeAlgos) + const saturationLevel = calculateStakeSaturation(validator, constraints) + const saturationPercent = calculateSaturationPercentage(validator, constraints) + return ( {currentStakeCompact} / {maxStakeCompact} - + ) }, @@ -254,7 +293,9 @@ export function ValidatorTable({ }, { id: 'reward', - accessorFn: (row) => row.state.totalStakers, // @todo: fix this + accessorFn: (row) => row.rewardsBalance, + sortingFn: sortRewardsFn, + sortUndefined: -1, header: ({ column }) => , cell: ({ row }) => { const validator = row.original diff --git a/ui/src/constants/indicator.ts b/ui/src/constants/indicator.ts new file mode 100644 index 00000000..3bf7296b --- /dev/null +++ b/ui/src/constants/indicator.ts @@ -0,0 +1,13 @@ +export const INDICATOR_ERROR = 0 +export const INDICATOR_NORMAL = 1 +export const INDICATOR_WATCH = 2 +export const INDICATOR_WARNING = 3 +export const INDICATOR_MAX = 4 + +export enum Indicator { + Error = INDICATOR_ERROR, + Normal = INDICATOR_NORMAL, + Watch = INDICATOR_WATCH, + Warning = INDICATOR_WARNING, + Max = INDICATOR_MAX, +} diff --git a/ui/src/constants/saturation.ts b/ui/src/constants/saturation.ts deleted file mode 100644 index 5bf86bdd..00000000 --- a/ui/src/constants/saturation.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const SATURATION_ERROR = 0 -export const SATURATION_NORMAL = 1 -export const SATURATION_WATCH = 2 -export const SATURATION_WARNING = 3 -export const SATURATION_MAX = 4 - -export enum SaturationLevel { - Error = SATURATION_ERROR, - Normal = SATURATION_NORMAL, - Watch = SATURATION_WATCH, - Warning = SATURATION_WARNING, - Max = SATURATION_MAX, -} diff --git a/ui/src/interfaces/validator.ts b/ui/src/interfaces/validator.ts index a9fb9c73..4ca02dd8 100644 --- a/ui/src/interfaces/validator.ts +++ b/ui/src/interfaces/validator.ts @@ -86,6 +86,8 @@ export type Validator = { state: ValidatorState pools: PoolInfo[] nodePoolAssignment: NodePoolAssignmentConfig + rewardsBalance?: bigint + roundsSinceLastPayout?: bigint rewardToken?: Asset gatingAssets?: Asset[] nfd?: Nfd @@ -138,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 164342c3..ebd3f692 100644 --- a/ui/src/utils/contracts.ts +++ b/ui/src/utils/contracts.ts @@ -3,7 +3,7 @@ import algosdk from 'algosdk' import { fetchAccountAssetInformation, fetchAccountInformation } from '@/api/algod' import { fetchNfd, fetchNfdSearch } from '@/api/nfd' import { GatingType } from '@/constants/gating' -import { SaturationLevel } from '@/constants/saturation' +import { Indicator } from '@/constants/indicator' import { Asset, AssetHolding } from '@/interfaces/algod' import { NfdSearchV2Params } from '@/interfaces/nfd' import { StakedInfo, StakerValidatorData } from '@/interfaces/staking' @@ -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 @@ -676,9 +677,9 @@ export async function fetchRemainingRewardsBalance(validator: Validator): Promis export function calculateStakeSaturation( validator: Validator, constraints: Constraints, -): SaturationLevel { +): Indicator { if (!constraints) { - return SaturationLevel.Error + return Indicator.Error } const currentStake = validator.state.totalAlgoStaked @@ -688,13 +689,13 @@ export function calculateStakeSaturation( const nearSaturationThreshold = (saturatedAmount * BigInt(99)) / BigInt(100) if (currentStake >= maxStake) { - return SaturationLevel.Max + return Indicator.Max } else if (currentStake > saturatedAmount) { - return SaturationLevel.Warning + return Indicator.Warning } else if (currentStake >= nearSaturationThreshold) { - return SaturationLevel.Watch + return Indicator.Watch } else { - return SaturationLevel.Normal + return Indicator.Normal } } @@ -742,3 +743,42 @@ export function calculateSaturationPercentage( // Round to nearest whole number return Math.round(percentageAsNumber) } + +export function calculateValidatorHealth(roundsSinceLastPayout: bigint | undefined): Indicator { + if (!roundsSinceLastPayout || roundsSinceLastPayout >= 1200n) { + // 1 hour + return Indicator.Error + } else if (roundsSinceLastPayout >= 210n) { + // 10 minutes + return Indicator.Warning + } else if (roundsSinceLastPayout >= 21n) { + // 1 minute + return Indicator.Watch + } else { + 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 } +} diff --git a/ui/src/utils/format.spec.ts b/ui/src/utils/format.spec.ts index f1469e6b..d4605ec9 100644 --- a/ui/src/utils/format.spec.ts +++ b/ui/src/utils/format.spec.ts @@ -7,6 +7,7 @@ import { formatAmount, formatWithPrecision, roundToFirstNonZeroDecimal, + roundToWholeAlgos, } from '@/utils/format' describe('convertFromBaseUnits', () => { @@ -266,3 +267,68 @@ describe('roundToFirstNonZeroDecimal', () => { expect(roundToFirstNonZeroDecimal(1234.567)).toBe(1234.567) }) }) + +describe('roundToWholeAlgos', () => { + // Tests for number input + it('rounds 1500000 to 2000000', () => { + expect(roundToWholeAlgos(1500000)).toBe(2000000) + }) + + it('rounds 1499999 to 1000000', () => { + expect(roundToWholeAlgos(1499999)).toBe(1000000) + }) + + it('rounds 0 to 0', () => { + expect(roundToWholeAlgos(0)).toBe(0) + }) + + it('rounds negative numbers correctly', () => { + expect(roundToWholeAlgos(-1500000)).toBe(-2000000) + expect(roundToWholeAlgos(-1499999)).toBe(-1000000) + }) + + it('handles large numbers', () => { + expect(roundToWholeAlgos(1000000000000001)).toBe(1000000000000000) + }) + + // Tests for bigint input + it('rounds 1500000n to 2000000n', () => { + expect(roundToWholeAlgos(1500000n)).toBe(2000000n) + }) + + it('rounds 1499999n to 1000000n', () => { + expect(roundToWholeAlgos(1499999n)).toBe(1000000n) + }) + + it('rounds 0n to 0n', () => { + expect(roundToWholeAlgos(0n)).toBe(0n) + }) + + it('rounds negative bigints correctly', () => { + expect(roundToWholeAlgos(-1500000n)).toBe(-2000000n) + expect(roundToWholeAlgos(-1499999n)).toBe(-1000000n) + }) + + it('handles large bigints', () => { + expect(roundToWholeAlgos(1000000000000001n)).toBe(1000000000000000n) + }) + + // Edge cases + it('handles Number.MAX_SAFE_INTEGER', () => { + expect(roundToWholeAlgos(BigInt(Number.MAX_SAFE_INTEGER))).toBe(9007199255000000n) + }) + + it('handles very large bigints', () => { + const veryLargeBigInt = 2n ** 100n + 500000n + expect(roundToWholeAlgos(veryLargeBigInt)).toBe(1267650600228229401496704000000n) + }) + + // Type checking + it('returns the same type as input', () => { + const numberResult = roundToWholeAlgos(1500000) + expect(typeof numberResult).toBe('number') + + const bigintResult = roundToWholeAlgos(1500000n) + expect(typeof bigintResult).toBe('bigint') + }) +}) diff --git a/ui/src/utils/format.ts b/ui/src/utils/format.ts index 5facf9f5..9b292543 100644 --- a/ui/src/utils/format.ts +++ b/ui/src/utils/format.ts @@ -263,3 +263,20 @@ export function roundToFirstNonZeroDecimal(num: number): number { // Use toFixed to round to the first significant decimal place return Number(num.toFixed(decimalPlaces)) } + +/** + * Round a MicroAlgos amount to the nearest million (whole Algo amount) + * @param {number | bigint} microalgos - The number of MicroAlgos to round + * @returns {number | bigint} The rounded number + */ +export function roundToWholeAlgos(microalgos: T): T { + if (typeof microalgos === 'bigint') { + const sign = microalgos < 0n ? -1n : 1n + const abs = microalgos < 0n ? -microalgos : microalgos + return (((abs + 500000n) / 1000000n) * 1000000n * sign) as T + } else { + const sign = microalgos < 0 ? -1 : 1 + const abs = Math.abs(microalgos) + return (Math.round(abs / 1e6) * 1e6 * sign) as T + } +}