Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions packages/app/src/hooks/useEnhancedRewardRate/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2025 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

import { useActiveAccounts } from 'contexts/ActiveAccounts'
import { useBalances } from 'contexts/Balances'
import { useValidators } from 'contexts/Validators/ValidatorEntries'
import { getEraRewardPoints, getValidatorEraPoints } from 'global-bus'
import { useMemo } from 'react'
import { useAverageRewardRate } from '../useAverageRewardRate'
import type { UseEnhancedRewardRate } from './types'

export const useEnhancedRewardRate = (): UseEnhancedRewardRate => {
const { activeAddress } = useActiveAccounts()
const { getNominations } = useBalances()
const { getValidators } = useValidators()
const { getAverageRewardRate } = useAverageRewardRate()

// Get the nominated validators for the active account - memoized
const nominatedValidators = useMemo(() => {
const nominations = getNominations(activeAddress)
const validators = getValidators()

return nominations
.map((address) => validators.find((v) => v.address === address))
.filter(
(validator): validator is NonNullable<typeof validator> =>
validator !== undefined,
)
}, [activeAddress, getNominations, getValidators])

// Calculate average commission rate of nominated validators - memoized
const actualCommissionRate = useMemo(() => {
if (nominatedValidators.length === 0) {
return 0
}

const totalCommission = nominatedValidators.reduce((sum, validator) => {
return sum + (validator?.prefs?.commission || 0)
}, 0)

return totalCommission / nominatedValidators.length
}, [nominatedValidators])

// Calculate era points performance factor for nominated validators - memoized
const eraPointsMultiplier = useMemo(() => {
const eraRewardPoints = getEraRewardPoints()

if (
nominatedValidators.length === 0 ||
eraRewardPoints.individual.length === 0
) {
return 1 // Default multiplier if no data
}

// Calculate average era points of nominated validators
const nominatedValidatorPoints = nominatedValidators.map((validator) =>
getValidatorEraPoints(validator.address),
)

const avgNominatedPoints =
nominatedValidatorPoints.reduce(
(sum: number, points: number) => sum + points,
0,
) / nominatedValidatorPoints.length

// Calculate network average era points
const totalNetworkPoints = eraRewardPoints.individual.reduce(
(sum: number, [, points]: [string, number]) => sum + points,
0,
)
const avgNetworkPoints =
totalNetworkPoints / eraRewardPoints.individual.length

// Return performance multiplier (how well nominated validators perform vs network average)
return avgNetworkPoints > 0 ? avgNominatedPoints / avgNetworkPoints : 1
}, [nominatedValidators])

// Getter functions for backwards compatibility
const getActualCommissionRate = (): number => actualCommissionRate
const getEraPointsMultiplier = (): number => eraPointsMultiplier
const getNominatedValidators = () => nominatedValidators

// Get enhanced reward rate that factors in validator performance and commission
const getEnhancedRewardRate = (
compounded: boolean = false,
conservative: boolean = true,
): number => {
const baseRewardRate = getAverageRewardRate(compounded)

// Apply era points performance factor
let adjustedRate = baseRewardRate * eraPointsMultiplier

// Apply conservative adjustment to match historical performance
// Network theoretical rates often overestimate by 15-20% due to:
// - Era timing variations, validator downtime, network effects, payout delays
if (conservative) {
const conservativeFactor = 0.85 // 15% reduction to match actual historical performance
adjustedRate *= conservativeFactor
}

return adjustedRate
}

// Calculate more accurate annual reward with actual commission deduction
const calculateAnnualRewardWithActualCommission = (
stakeAmount: number,
conservative: boolean = true,
): {
baseReward: number
afterCommission: number
commissionRate: number
eraPointsMultiplier: number
conservativeAdjustment: number
} => {
const baseRewardRate = getAverageRewardRate()

// Apply era points and conservative factors
let adjustedRate = baseRewardRate * eraPointsMultiplier
const conservativeAdjustment = conservative ? 0.85 : 1.0

if (conservative) {
adjustedRate *= conservativeAdjustment
}

const baseReward = stakeAmount * (adjustedRate / 100)
const afterCommission = baseReward * (1 - actualCommissionRate / 100)

return {
baseReward,
afterCommission,
commissionRate: actualCommissionRate,
eraPointsMultiplier,
conservativeAdjustment,
}
}

return {
getEnhancedRewardRate,
getActualCommissionRate,
getEraPointsMultiplier,
getNominatedValidators,
calculateAnnualRewardWithActualCommission,
}
}
24 changes: 24 additions & 0 deletions packages/app/src/hooks/useEnhancedRewardRate/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright 2025 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

import type { Validator } from 'types'

export interface UseEnhancedRewardRate {
getEnhancedRewardRate: (
compounded?: boolean,
conservative?: boolean,
) => number
getActualCommissionRate: () => number
getEraPointsMultiplier: () => number
getNominatedValidators: () => NonNullable<Validator>[]
calculateAnnualRewardWithActualCommission: (
stakeAmount: number,
conservative?: boolean,
) => {
baseReward: number
afterCommission: number
commissionRate: number
eraPointsMultiplier: number
conservativeAdjustment: number
}
}
185 changes: 147 additions & 38 deletions packages/app/src/modals/RewardCalculator/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
// Copyright 2025 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

import { faToggleOff, faToggleOn } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { getChainIcons } from 'assets'
import { getStakingChainData } from 'consts/util'
import { useNetwork } from 'contexts/Network'
import { useValidators } from 'contexts/Validators/ValidatorEntries'
import { useAverageRewardRate } from 'hooks/useAverageRewardRate'
import { useEnhancedRewardRate } from 'hooks/useEnhancedRewardRate'
import { Balance } from 'library/Balance'
import { Title } from 'library/Modal/Title'
import type { ChangeEvent } from 'react'
Expand All @@ -27,6 +26,8 @@ export const RewardCalculator = () => {
const { config } = useOverlay().modal
const { avgCommission } = useValidators()
const { getAverageRewardRate } = useAverageRewardRate()
const { calculateAnnualRewardWithActualCommission, getActualCommissionRate } =
useEnhancedRewardRate()

const { unit } = getStakingChainData(network)
const Token = getChainIcons(network).token
Expand All @@ -35,26 +36,68 @@ export const RewardCalculator = () => {
// Store token amount to stake
const [stakeAmount, setStakeAmount] = useState<number>(DEFAULT_TOKEN_INPUT)

// Whether to show base or commission-adjusted rewards
const [showAdjusted, setShowCommissionAdjusted] = useState<boolean>(false)
// Calculation mode: 'simple', 'enhanced', 'conservative'
const [calculationMode, setCalculationMode] = useState<
'simple' | 'enhanced' | 'conservative'
>('conservative')

const annualRewardBase = stakeAmount * (getAverageRewardRate() / 100) || 0
// Calculate rewards based on selected mode
const getRewardCalculations = () => {
const actualCommissionRate = getActualCommissionRate()

const annualRewardAfterCommission =
annualRewardBase * (1 - avgCommission / 100)
const monthlyRewardAfterCommission = annualRewardAfterCommission / 12
const dailyRewardAfterCommission = annualRewardAfterCommission / 365

const annualReward = showAdjusted
? annualRewardAfterCommission
: annualRewardBase
switch (calculationMode) {
case 'simple': {
// Original simple calculation
const annualRewardBase =
stakeAmount * (getAverageRewardRate() / 100) || 0
const annualRewardAfterCommission =
annualRewardBase * (1 - avgCommission / 100)
return {
annual: annualRewardAfterCommission,
monthly: annualRewardAfterCommission / 12,
daily: annualRewardAfterCommission / 365,
commissionRate: avgCommission,
description: t('simpleCalculationDesc', { ns: 'pages' }),
}
}
case 'enhanced': {
// Enhanced with era points but optimistic
const enhancedRewards = calculateAnnualRewardWithActualCommission(
stakeAmount,
false,
)
return {
annual: enhancedRewards.afterCommission,
monthly: enhancedRewards.afterCommission / 12,
daily: enhancedRewards.afterCommission / 365,
commissionRate: actualCommissionRate,
description: t('enhancedCalculationDesc', { ns: 'pages' }),
}
}
case 'conservative':
default: {
// Enhanced with conservative adjustment (most accurate)
const enhancedRewards = calculateAnnualRewardWithActualCommission(
stakeAmount,
true,
)
return {
annual: enhancedRewards.afterCommission,
monthly: enhancedRewards.afterCommission / 12,
daily: enhancedRewards.afterCommission / 365,
commissionRate: actualCommissionRate,
description: t('conservativeCalculationDesc', { ns: 'pages' }),
}
}
}
}

const monthlyReward = showAdjusted
? monthlyRewardAfterCommission
: annualRewardBase / 12
const dailyReward = showAdjusted
? dailyRewardAfterCommission
: annualRewardBase / 365
const calculations = getRewardCalculations()
const {
annual: annualReward,
monthly: monthlyReward,
daily: dailyReward,
} = calculations

const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const isNumber = !isNaN(Number(e.target.value))
Expand All @@ -81,27 +124,93 @@ export const RewardCalculator = () => {
value={String(stakeAmount || 0)}
marginY
/>{' '}
<h3 style={{ padding: '0 0.5rem' }}>
<button
type="button"
onClick={() => setShowCommissionAdjusted(!showAdjusted)}
>
<FontAwesomeIcon
icon={showAdjusted ? faToggleOn : faToggleOff}
<div style={{ padding: '0.5rem' }}>
<h4 style={{ marginBottom: '0.75rem' }}>
{t('calculationMethod', { ns: 'pages' })}
</h4>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
onClick={() => setCalculationMode('simple')}
style={{
color: showAdjusted
? 'var(--accent-color-primary)'
: 'var(--text-color-tertiary)',
marginRight: '0.8rem',
padding: '0.5rem 1rem',
border: `2px solid ${calculationMode === 'simple' ? 'var(--accent-color-primary)' : 'var(--border-primary-color)'}`,
backgroundColor:
calculationMode === 'simple'
? 'var(--accent-color-primary)'
: 'transparent',
color:
calculationMode === 'simple'
? 'white'
: 'var(--text-color-primary)',
borderRadius: '0.5rem',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: '500',
}}
transform={'grow-6'}
/>
{t('deductAvgCommissionOf', {
ns: 'pages',
commission: avgCommission,
})}
</button>
</h3>
>
{t('basicEstimate', { ns: 'pages' })}
</button>
<button
type="button"
onClick={() => setCalculationMode('enhanced')}
style={{
padding: '0.5rem 1rem',
border: `2px solid ${calculationMode === 'enhanced' ? 'var(--accent-color-primary)' : 'var(--border-primary-color)'}`,
backgroundColor:
calculationMode === 'enhanced'
? 'var(--accent-color-primary)'
: 'transparent',
color:
calculationMode === 'enhanced'
? 'white'
: 'var(--text-color-primary)',
borderRadius: '0.5rem',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: '500',
}}
>
{t('detailedEstimate', { ns: 'pages' })}
</button>
<button
type="button"
onClick={() => setCalculationMode('conservative')}
style={{
padding: '0.5rem 1rem',
border: `2px solid ${calculationMode === 'conservative' ? 'var(--accent-color-primary)' : 'var(--border-primary-color)'}`,
backgroundColor:
calculationMode === 'conservative'
? 'var(--accent-color-primary)'
: 'transparent',
color:
calculationMode === 'conservative'
? 'white'
: 'var(--text-color-primary)',
borderRadius: '0.5rem',
cursor: 'pointer',
fontSize: '0.9rem',
fontWeight: '500',
}}
>
{t('realisticEstimate', { ns: 'pages' })}
<span style={{ fontSize: '0.75rem', opacity: 0.8 }}>
{' '}
{t('recommended', { ns: 'pages' })}
</span>
</button>
</div>
<p
style={{
fontSize: '0.85rem',
color: 'var(--text-color-secondary)',
marginTop: '0.75rem',
lineHeight: '1.4',
}}
>
{calculations.description}
</p>
</div>
<Separator lg />
<CardHeader>
<h4>
Expand Down
Loading