Skip to content

Commit

Permalink
feat(ui): stake saturation indicator in ValidatorTable (#243)
Browse files Browse the repository at this point in the history
* chore: rename saturationThreshold to amtConsideredSaturated

* feat(ui): stake saturation indicator in ValidatorTable
  • Loading branch information
drichar authored Jul 18, 2024
1 parent c057f00 commit 9e03cc4
Show file tree
Hide file tree
Showing 8 changed files with 285 additions and 5 deletions.
4 changes: 2 additions & 2 deletions ui/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,7 @@ export async function fetchProtocolConstraints(
minEntryStake,
maxAlgoPerPool,
maxAlgoPerValidator,
saturationThreshold,
amtConsideredSaturated,
maxNodes,
maxPoolsPerNode,
maxStakersPerPool,
Expand All @@ -859,7 +859,7 @@ export async function fetchProtocolConstraints(
minEntryStake,
maxAlgoPerPool,
maxAlgoPerValidator,
saturationThreshold,
amtConsideredSaturated,
maxNodes: Number(maxNodes),
maxPoolsPerNode: Number(maxPoolsPerNode),
maxStakersPerPool: Number(maxStakersPerPool),
Expand Down
39 changes: 39 additions & 0 deletions ui/src/components/SaturationIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 (
<div className="inline-flex items-center justify-center">
<Tooltip content={`${saturationPercent}%`}>
<div className={cn('w-2.5 h-2.5 rounded-full ml-2', getClassName())} />
</Tooltip>
</div>
)
}
2 changes: 2 additions & 0 deletions ui/src/components/ValidatorTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ 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 { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
Expand Down Expand Up @@ -237,6 +238,7 @@ export function ValidatorTable({
<span className="whitespace-nowrap">
<AlgoSymbol />
{currentStakeCompact} / {maxStakeCompact}
<SaturationIndicator validator={validator} constraints={constraints} />
</span>
)
},
Expand Down
13 changes: 13 additions & 0 deletions ui/src/constants/saturation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
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,
}
2 changes: 1 addition & 1 deletion ui/src/interfaces/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export interface Constraints {
minEntryStake: bigint
maxAlgoPerPool: bigint
maxAlgoPerValidator: bigint
saturationThreshold: bigint
amtConsideredSaturated: bigint
maxNodes: number
maxPoolsPerNode: number
maxStakersPerPool: number
Expand Down
155 changes: 155 additions & 0 deletions ui/src/utils/contracts.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
calculateMaxStake,
calculateRewardEligibility,
calculateSaturationPercentage,
getEpochLengthBlocks,
isStakingDisabled,
isUnstakingDisabled,
Expand Down Expand Up @@ -310,3 +311,157 @@ describe('calculateRewardEligibility', () => {
expect(calculateRewardEligibility(epochRoundLength, lastPoolPayoutRound, entryRound)).toBe(0)
})
})

describe('calculateSaturationPercentage', () => {
it('should return 0% if the validator has no stake', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 0n,
},
}
expect(calculateSaturationPercentage(validator, MOCK_CONSTRAINTS)).toBe(0)
})

it('should calculate the correct saturation percentage', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 72000000000000n,
},
}

// 300000000000000n is the protocol maximum in MOCK_CONSTRAINTS
const result = calculateSaturationPercentage(validator, MOCK_CONSTRAINTS)
expect(result).toBe(24)
})

it('should round to the nearest whole number', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 71184768795601n,
},
}

const result = calculateSaturationPercentage(validator, MOCK_CONSTRAINTS)
expect(result).toBe(24) // 23.72825626 rounded to 24
})

it('should return 100% if the total stake exceeds the protocol maximum', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: MOCK_CONSTRAINTS.maxAlgoPerValidator + 1000n,
},
}
expect(calculateSaturationPercentage(validator, MOCK_CONSTRAINTS)).toBe(100)
})

it('should handle very small percentages correctly', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 50000000n,
},
}

const result = calculateSaturationPercentage(validator, MOCK_CONSTRAINTS)
expect(result).toBe(0.0001)
})

it('should handle extremely small percentages by returning 0.0001', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 1n,
},
}
const constraints = {
...MOCK_CONSTRAINTS,
maxAlgoPerValidator: 1000000000000n,
}

const result = calculateSaturationPercentage(validator, constraints)
expect(result).toBe(0.0001)
})

it('should round to first non-zero decimal for small percentages', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 5000000000n,
},
}

const result = calculateSaturationPercentage(validator, MOCK_CONSTRAINTS)
expect(result).toBe(0.002) // 0.00166666 rounded to 0.002
})

it('should return 0 when constraints is null or undefined', () => {
// @ts-expect-error constraints is null
expect(calculateSaturationPercentage(MOCK_VALIDATOR_1, null)).toBe(0)
// @ts-expect-error constraints is undefined
expect(calculateSaturationPercentage(MOCK_VALIDATOR_1, undefined)).toBe(0)
})

it('should return 0 when maxAlgoPerValidator is 0', () => {
const constraints = {
...MOCK_CONSTRAINTS,
maxAlgoPerValidator: 0n,
}
expect(calculateSaturationPercentage(MOCK_VALIDATOR_1, constraints)).toBe(0)
})

it('should calculate correctly for percentages just below 100%', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 99999999999999n,
},
}
const constraints = {
...MOCK_CONSTRAINTS,
maxAlgoPerValidator: 100000000000000n,
}
expect(calculateSaturationPercentage(validator, constraints)).toBe(99)
})

it('should round correctly at the 0.00005 threshold', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 150000n,
},
}
const constraints = {
...MOCK_CONSTRAINTS,
maxAlgoPerValidator: 300000000000000n,
}
expect(calculateSaturationPercentage(validator, constraints)).toBe(0.0001)
})

it('should round correctly just above the 0.00005 threshold', () => {
const validator = {
...MOCK_VALIDATOR_1,
state: {
...MOCK_VALIDATOR_1.state,
totalAlgoStaked: 151000n,
},
}
const constraints = {
...MOCK_CONSTRAINTS,
maxAlgoPerValidator: 300000000000000n,
}
expect(calculateSaturationPercentage(validator, constraints)).toBe(0.0001)
})
})
73 changes: 72 additions & 1 deletion ui/src/utils/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +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 { Asset, AssetHolding } from '@/interfaces/algod'
import { NfdSearchV2Params } from '@/interfaces/nfd'
import { StakedInfo, StakerValidatorData } from '@/interfaces/staking'
Expand All @@ -21,7 +22,7 @@ import {
ValidatorState,
} from '@/interfaces/validator'
import { dayjs } from '@/utils/dayjs'
import { convertToBaseUnits } from '@/utils/format'
import { convertToBaseUnits, roundToFirstNonZeroDecimal } from '@/utils/format'

/**
* Transform raw validator configuration data (from `callGetValidatorConfig`) into a structured object
Expand Down Expand Up @@ -671,3 +672,73 @@ export async function fetchRemainingRewardsBalance(validator: Validator): Promis

return remainingBalance
}

export function calculateStakeSaturation(
validator: Validator,
constraints: Constraints,
): SaturationLevel {
if (!constraints) {
return SaturationLevel.Error
}

const currentStake = validator.state.totalAlgoStaked
const maxStake = constraints.maxAlgoPerValidator
const saturatedAmount = constraints.amtConsideredSaturated

const nearSaturationThreshold = (saturatedAmount * BigInt(99)) / BigInt(100)

if (currentStake >= maxStake) {
return SaturationLevel.Max
} else if (currentStake > saturatedAmount) {
return SaturationLevel.Warning
} else if (currentStake >= nearSaturationThreshold) {
return SaturationLevel.Watch
} else {
return SaturationLevel.Normal
}
}

export function calculateSaturationPercentage(
validator: Validator,
constraints: Constraints,
): number {
if (!constraints) {
return 0
}

const currentStake = validator.state.totalAlgoStaked
const maxStake = constraints.maxAlgoPerValidator

if (maxStake === BigInt(0) || currentStake === BigInt(0)) {
return 0
}

// Calculate percentage as a BigInt scaled by 10000000000n (for precision)
const scaledPercentage = (currentStake * 10000000000n) / maxStake

// Convert percentage to a number for display
const percentageAsNumber = Number(scaledPercentage) / 100000000

// If the percentage is greater than or equal to 100, cap it at 100
if (percentageAsNumber >= 100) {
return 100
}

// If the percentage is between 99 and 100, round down to 99
if (percentageAsNumber >= 99) {
return 99
}

// If the percentage is less than 0.00005, round up to 0.0001
if (percentageAsNumber < 0.00005) {
return 0.0001
}

// If percentage is less than 1, round to first non-zero decimal
if (percentageAsNumber < 1) {
return roundToFirstNonZeroDecimal(percentageAsNumber)
}

// Round to nearest whole number
return Math.round(percentageAsNumber)
}
2 changes: 1 addition & 1 deletion ui/src/utils/tests/fixtures/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export const MOCK_CONSTRAINTS: Constraints = {
minEntryStake: 1000000n,
maxAlgoPerPool: 70000000000000n,
maxAlgoPerValidator: 300000000000000n,
saturationThreshold: 200000000000000n,
amtConsideredSaturated: 200000000000000n,
maxNodes: 8,
maxPoolsPerNode: 3,
maxStakersPerPool: 200,
Expand Down

0 comments on commit 9e03cc4

Please sign in to comment.