Skip to content

Commit

Permalink
feat(ui): rewards column in ValidatorTable is sortable (#249)
Browse files Browse the repository at this point in the history
* 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`
  • Loading branch information
drichar authored Jul 23, 2024
1 parent 74ece97 commit 90b261a
Show file tree
Hide file tree
Showing 13 changed files with 460 additions and 99 deletions.
76 changes: 61 additions & 15 deletions ui/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +23,7 @@ import {
FindPoolForStakerResponse,
MbrAmounts,
NodePoolAssignmentConfig,
PoolData,
PoolInfo,
RawConstraints,
RawNodePoolAssignmentConfig,
Expand All @@ -38,6 +39,7 @@ import { makeEmptyTransactionSigner } from '@/lib/makeEmptyTransactionSigner'
import { BalanceChecker } from '@/utils/balanceChecker'
import { chunkBytes } from '@/utils/bytes'
import {
calculateValidatorPoolMetrics,
transformNodePoolAssignment,
transformStakedInfo,
transformValidatorData,
Expand Down Expand Up @@ -80,9 +82,64 @@ export function callGetValidatorState(
.simulate({ allowEmptySignatures: true, allowUnnamedResources: true })
}

async function processPool(pool: PoolInfo): Promise<PoolData> {
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.
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
39 changes: 0 additions & 39 deletions ui/src/components/SaturationIndicator.tsx

This file was deleted.

58 changes: 58 additions & 0 deletions ui/src/components/TrafficLight.tsx
Original file line number Diff line number Diff line change
@@ -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<Record<Indicator, string>>
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 <div className={cn('w-2.5 h-2.5 rounded-full', getClassName(), className)} />
}

if (indicator === Indicator.Normal && !showGreen) {
return null
}

return (
<div className="inline-flex items-center justify-center">
{tooltipContent ? (
<Tooltip content={getTooltipContent()}>{renderIndicator()}</Tooltip>
) : (
renderIndicator()
)}
</div>
)
}
4 changes: 2 additions & 2 deletions ui/src/components/ValidatorDetails/PoolsChart.tsx
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
39 changes: 20 additions & 19 deletions ui/src/components/ValidatorRewards.tsx
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -54,7 +57,7 @@ async function epochPayoutFetch(validator: Validator) {
return BigInt(params.firstRound) - oldestRound
} catch (error) {
console.error(error)
return 0
return 0n
}
}

Expand All @@ -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 <span>Loading...</span>
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 <span className="text-sm text-red-500">Error fetching balance</span>
}

return (
<>
<span className="text-2xl" style={{ color: dotColor }}>
&bull;
</span>
<TrafficLight indicator={healthStatus} tooltipContent={tooltipContent} className="mr-2" />
<AlgoDisplayAmount amount={totalBalancesQuery.data} microalgos />
</>
)
Expand Down
Loading

0 comments on commit 90b261a

Please sign in to comment.