Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement rewards calculator and merge payout history #2482

Open
wants to merge 35 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f4e0537
Save changes on feature/rewardscalc
joeljkrb Jan 22, 2025
6723e20
feat: Migrate PayoutHistory to new Rewards Calculator page
joeljkrb Feb 3, 2025
5841df6
chore: rewards & payouts page merge
joeljkrb Feb 3, 2025
319cab2
chore: minor language updates
joeljkrb Feb 3, 2025
a934b20
chore: fixes
joeljkrb Feb 3, 2025
7f765b7
Merge branch 'main' into tabbed-rewards-calc
joeljkrb Feb 3, 2025
6b1627d
chore: fixes
joeljkrb Feb 4, 2025
e0f25b0
Merge branch 'tabbed-rewards-calc' of https://github.com/polkadot-clo…
joeljkrb Feb 4, 2025
be105ff
chore: ES update fix
joeljkrb Feb 4, 2025
403c0b9
chore: language updates
joeljkrb Feb 4, 2025
8ef27c1
Merge branch 'tabbed-rewards-calc' of https://github.com/polkadot-clo…
joeljkrb Feb 4, 2025
494e538
order locales
rossbulat Feb 4, 2025
e5bf291
fixing ross's breaking changes
rossbulat Feb 4, 2025
a766263
Merge branch 'main' into tabbed-rewards-calc
rossbulat Feb 6, 2025
3b4f8f3
remove `useTokenPrice` hook, use `useTokenPrices`
rossbulat Feb 6, 2025
fc61035
update copyright
rossbulat Feb 6, 2025
85fb460
Merge branch 'main' into tabbed-rewards-calc
rossbulat Feb 9, 2025
ecabfc6
fix breaking changes
rossbulat Feb 9, 2025
3fbc801
use pagination from `ListProvider`
rossbulat Feb 9, 2025
f952669
add `getStakedBalance` to `TransferOptionsProvider`
rossbulat Feb 9, 2025
d4a6e9e
use `getStakedBalance`, formalise Stat components
rossbulat Feb 9, 2025
5ec3b72
+ `Button` stat card, Reward Calculator stat, `StatButton core ui
rossbulat Feb 9, 2025
03e85a2
don't scale `Button` stat on hover
rossbulat Feb 9, 2025
a2afc12
amend styling
rossbulat Feb 9, 2025
4817363
fix: use child combinator
rossbulat Feb 9, 2025
0bc147b
style amendments
rossbulat Feb 9, 2025
6c121e4
fix: use locale
rossbulat Feb 9, 2025
e4c8e0e
Update packages/app/src/config/pages.ts
joeljkrb Feb 9, 2025
cf7c3c4
Update packages/app/src/pages/Rewards/Active/index.tsx
joeljkrb Feb 9, 2025
695778e
Update packages/app/src/pages/Rewards/Active/index.tsx
joeljkrb Feb 9, 2025
3649eb1
Merge branch 'main' into tabbed-rewards-calc
rossbulat Feb 12, 2025
41218d0
Merge branch 'main' into tabbed-rewards-calc
rossbulat Feb 13, 2025
eea134b
fix staking api
rossbulat Feb 13, 2025
5306ed2
resolve some feedack
rossbulat Feb 13, 2025
ae37f13
restructuring
rossbulat Feb 13, 2025
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
10 changes: 5 additions & 5 deletions packages/app/src/config/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { PageCategoryItems, PagesConfigItems } from 'common-types'
import { Community } from 'pages/Community'
import { Nominate } from 'pages/Nominate'
import { Overview } from 'pages/Overview'
import { Payouts } from 'pages/Payouts'
import { Pools } from 'pages/Pools'
import { Rewards } from 'pages/Rewards'
import { Validators } from 'pages/Validators'

const BASE_URL = import.meta.env.BASE_URL
Expand Down Expand Up @@ -52,10 +52,10 @@ export const PagesConfig: PagesConfigItems = [
},
{
category: 2,
key: 'payouts',
uri: `${BASE_URL}payouts`,
hash: '/payouts',
Entry: Payouts,
key: 'rewards_payouts',
joeljkrb marked this conversation as resolved.
Show resolved Hide resolved
uri: `${BASE_URL}rewards`,
hash: '/rewards',
Entry: Rewards,
lottie: 'analytics',
},
{
Expand Down
220 changes: 220 additions & 0 deletions packages/app/src/pages/Rewards/Active/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// Copyright 2025 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

import { planckToUnit } from '@w3ux/utils'
import { useActiveAccounts } from 'contexts/ActiveAccounts'
import { useBalances } from 'contexts/Balances'
import { useNetwork } from 'contexts/Network'
import { useActivePool } from 'contexts/Pools/ActivePool'
import { useTokenPrices } from 'contexts/TokenPrice'
import { AnimatePresence, motion } from 'framer-motion'
import { useAverageRewardRate } from 'hooks/useAverageRewardRate'
import { CardWrapper } from 'library/Card/Wrappers'
import { Text } from 'library/StatCards/Text'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ButtonPrimary } from 'ui-buttons'
import { CardHeader, PageRow, StatRow } from 'ui-core/base'
import { RewardText, RewardsGrid } from '../Wrappers'

export const Active = () => {
rossbulat marked this conversation as resolved.
Show resolved Hide resolved
const { t } = useTranslation('pages')
const { inPool } = useActivePool()
const { networkData } = useNetwork()
const { price: dotPrice } = useTokenPrices()
const { activeAccount } = useActiveAccounts()
const { getLedger, getPoolMembership } = useBalances()
const { getAverageRewardRate } = useAverageRewardRate()
const [manualStake, setManualStake] = useState<number | null>(null)
const [isCustomStake, setIsCustomStake] = useState(false)

const getCurrentStake = () => {
if (!activeAccount) {
return '0'
}

let rawAmount = '0'
if (inPool()) {
const membership = getPoolMembership(activeAccount)
rawAmount = membership?.points ?? '0'
} else {
rawAmount = getLedger({ stash: activeAccount }).active.toString() ?? '0'
}
return planckToUnit(rawAmount, networkData.units)
}

const { avgRateBeforeCommission } = getAverageRewardRate(false)
const rewardRate = avgRateBeforeCommission.toNumber()

const currentStake =
isCustomStake && manualStake !== null
? manualStake
: Number(getCurrentStake())
const annualReward = currentStake * (rewardRate / 100) || 0
const monthlyReward = annualReward / 12 || 0
const dailyReward = annualReward / 365 || 0

const handleToggleStake = () => {
if (isCustomStake) {
setManualStake(null)
}
setIsCustomStake(!isCustomStake)
}

return (
<>
<StatRow>
<RewardText>
<Text
label={t('rewards.averageRewardRate')}
value={`${rewardRate.toFixed(2)}%`}
helpKey="Average Reward Rate"
/>
</RewardText>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '1rem',
}}
>
<Text
label={
isCustomStake
? t('rewards.customBalance')
: t('rewards.currentStakedBalance')
}
value={`${currentStake.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} ${networkData.api.unit}`}
helpKey={isCustomStake ? 'Custom Balance' : 'Your Balance'}
/>
<ButtonPrimary
text={
isCustomStake
? t('rewards.useConnectedWallet')
: t('rewards.useCustomAmount')
}
onClick={handleToggleStake}
style={{ minWidth: 'fit-content', whiteSpace: 'nowrap' }}
/>
</div>
</StatRow>

<AnimatePresence>
{isCustomStake && (
<PageRow>
<motion.div
initial={{ opacity: 0, height: 0, marginTop: 0 }}
animate={{ opacity: 1, height: 'auto', marginTop: '1rem' }}
exit={{ opacity: 0, height: 0, marginTop: 0 }}
transition={{
duration: 0.3,
ease: 'easeInOut',
}}
style={{ overflow: 'hidden' }}
>
<CardWrapper>
<CardHeader>
<h4>{t('rewards.adjustStake')}</h4>
</CardHeader>

<div style={{ padding: '1rem' }}>
<label htmlFor="manual-stake">
{t('rewards.enterStakeAmount')} ({networkData.api.unit}):
</label>
<input
id="manual-stake"
type="number"
step="0.01"
value={manualStake ?? ''}
onChange={(e) =>
setManualStake(Number(e.target.value) || null)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can currently enter values less than 0. We can default to 0 on non-number & enforce a minimum of 0:

Suggested change
setManualStake(Number(e.target.value) || null)
const value = Number(e.target.value)
const newManualStake = isNaN(value) ? 0 : Math.max(0, value)
setManualStake(newManualStake)

}
placeholder={t('rewards.stakePlaceholder')}
style={{
width: '100%',
padding: '0.5rem',
marginTop: '0.5rem',
border: '1px solid var(--border-primary-color)',
borderRadius: '4px',
rossbulat marked this conversation as resolved.
Show resolved Hide resolved
}}
/>
</div>
</CardWrapper>
</motion.div>
</PageRow>
)}
</AnimatePresence>

<PageRow>
<CardWrapper>
<CardHeader>
<h4>{t('rewards.projectedRewards')}</h4>
joeljkrb marked this conversation as resolved.
Show resolved Hide resolved
</CardHeader>

<RewardsGrid>
<div className="header">
<span>{t('rewards.period')}</span>
<span>{networkData.api.unit}</span>
joeljkrb marked this conversation as resolved.
Show resolved Hide resolved
<span>USDT</span>
</div>

<div className="reward-row">
<span>{t('rewards.daily')}</span>
<span>
{dailyReward.toLocaleString('en-US', {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
})}
</span>
<span>
$
{(dailyReward * dotPrice).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>

<div className="reward-row">
<span>{t('rewards.monthly')}</span>
<span>
{monthlyReward.toLocaleString('en-US', {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
})}
</span>
<span>
$
{(monthlyReward * dotPrice).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>

<div className="reward-row">
<span>{t('rewards.annual')}</span>
<span>
{annualReward.toLocaleString('en-US', {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
})}
</span>
<span>
$
{(annualReward * dotPrice).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
</RewardsGrid>
</CardWrapper>
</PageRow>
</>
)
}
83 changes: 83 additions & 0 deletions packages/app/src/pages/Rewards/Graph/ActiveGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright 2025 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

import type { AnyApi } from 'common-types'
import { MaxPayoutDays } from 'consts'
import { useActiveAccounts } from 'contexts/ActiveAccounts'
import { useApi } from 'contexts/Api'
import { useNetwork } from 'contexts/Network'
import { getUnixTime } from 'date-fns'
import { AveragePayoutLine } from 'library/Graphs/AveragePayoutLine'
import { PayoutBar } from 'library/Graphs/PayoutBar'
import { removeNonZeroAmountAndSort } from 'library/Graphs/Utils'
import { usePoolRewards, useRewards } from 'plugin-staking-api'
import type { NominatorReward, RewardResults } from 'plugin-staking-api/types'
import { useEffect } from 'react'

interface Props {
nominating: boolean
inPool: boolean
setPayoutLists: (payouts: AnyApi[]) => void
}

export const ActiveGraph = ({ nominating, inPool, setPayoutLists }: Props) => {
const { activeEra } = useApi()
const { network } = useNetwork()
const { activeAccount } = useActiveAccounts()

const { data: nominatorRewardsData, loading: rewardsLoading } = useRewards({
chain: network,
who: activeAccount || '',
fromEra: Math.max(activeEra.index.minus(1).toNumber(), 0),
})

const fromDate = new Date()
fromDate.setDate(fromDate.getDate() - MaxPayoutDays)
fromDate.setHours(0, 0, 0, 0)

const { data: poolRewardsData, loading: poolRewardsLoading } = usePoolRewards(
{
chain: network,
who: activeAccount || '',
from: getUnixTime(fromDate),
}
)

const allRewards = nominatorRewardsData?.allRewards ?? []
const payouts =
allRewards.filter((reward: NominatorReward) => reward.claimed === true) ??
[]
const unclaimedPayouts =
allRewards.filter((reward: NominatorReward) => reward.claimed === false) ??
[]
const poolClaims = poolRewardsData?.poolRewards ?? []

useEffect(() => {
// filter zero rewards and order via timestamp, most recent first
const payoutsList = (allRewards as RewardResults).concat(
poolClaims
) as RewardResults
setPayoutLists(removeNonZeroAmountAndSort(payoutsList))
}, [JSON.stringify(payouts), JSON.stringify(poolClaims)])

return (
<>
<PayoutBar
days={MaxPayoutDays}
height="165px"
data={{ payouts, unclaimedPayouts, poolClaims }}
nominating={nominating}
inPool={inPool}
syncing={rewardsLoading || poolRewardsLoading}
/>
<AveragePayoutLine
days={MaxPayoutDays}
average={10}
height="65px"
data={{ payouts, unclaimedPayouts, poolClaims }}
nominating={nominating}
inPool={inPool}
/>
</>
)
}
27 changes: 27 additions & 0 deletions packages/app/src/pages/Rewards/Graph/InactiveGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2025 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

import { MaxPayoutDays } from 'consts'
import { AveragePayoutLine } from 'library/Graphs/AveragePayoutLine'
import { PayoutBar } from 'library/Graphs/PayoutBar'

export const InactiveGraph = () => (
<>
<PayoutBar
days={MaxPayoutDays}
height="165px"
data={{ payouts: [], unclaimedPayouts: [], poolClaims: [] }}
nominating={false}
inPool={false}
syncing={false}
/>
<AveragePayoutLine
days={MaxPayoutDays}
average={10}
height="65px"
data={{ payouts: [], unclaimedPayouts: [], poolClaims: [] }}
nominating={false}
inPool={false}
/>
</>
)
5 changes: 5 additions & 0 deletions packages/app/src/pages/Rewards/Graph/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Copyright 2025 @polkadot-cloud/polkadot-staking-dashboard authors & contributors
// SPDX-License-Identifier: GPL-3.0-only

export * from './ActiveGraph'
export * from './InactiveGraph'
Loading
Loading