diff --git a/packages/protocol-dashboard/src/components/EstimatedAnnualStat/EstimatedAnnualStat.module.css b/packages/protocol-dashboard/src/components/EstimatedAnnualStat/EstimatedAnnualStat.module.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/protocol-dashboard/src/components/EstimatedAnnualStat/EstimatedAnnualStat.tsx b/packages/protocol-dashboard/src/components/EstimatedAnnualStat/EstimatedAnnualStat.tsx new file mode 100644 index 00000000000..c90de652564 --- /dev/null +++ b/packages/protocol-dashboard/src/components/EstimatedAnnualStat/EstimatedAnnualStat.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +import RewardStat from 'components/RewardStat' +import { useAnnualRewardRate } from 'hooks/useRewardRate' +import { Status } from 'types' + +const messages = { + label: `ESTIMATED ANNUAL REWARD RATE` +} + +interface EstimatedAnnualStatProps { + className?: string +} + +const EstimatedAnnualStat: React.FC = ({ + className +}) => { + const claimRate = useAnnualRewardRate() + const value = + claimRate.status === Status.Success + ? `${claimRate.rate!.toFixed(0)}%` + : null + return +} + +export default EstimatedAnnualStat diff --git a/packages/protocol-dashboard/src/components/EstimatedAnnualStat/index.tsx b/packages/protocol-dashboard/src/components/EstimatedAnnualStat/index.tsx new file mode 100644 index 00000000000..6c563f171df --- /dev/null +++ b/packages/protocol-dashboard/src/components/EstimatedAnnualStat/index.tsx @@ -0,0 +1 @@ +export { default } from './EstimatedAnnualStat' diff --git a/packages/protocol-dashboard/src/components/EstimatedWeeklyStat/EstimatedWeeklyStat.tsx b/packages/protocol-dashboard/src/components/EstimatedWeeklyStat/EstimatedWeeklyStat.tsx new file mode 100644 index 00000000000..7be4c56ba44 --- /dev/null +++ b/packages/protocol-dashboard/src/components/EstimatedWeeklyStat/EstimatedWeeklyStat.tsx @@ -0,0 +1,28 @@ +import React from 'react' + +import RewardStat from 'components/RewardStat' +import { Status } from 'types' +import { useWeeklyRewardRate } from 'hooks/useRewardRate' + +const messages = { + label: `ESTIMATED WEEKLY REWARD RATE` +} + +interface EstimatedWeeklyStatProps { + className?: string +} + +const EstimatedWeeklyStat: React.FC = ({ + className +}) => { + const claimRate = useWeeklyRewardRate() + const value = + claimRate.status === Status.Success + ? `${claimRate.rate!.toFixed(3)}%` + : null + return ( + + ) +} + +export default EstimatedWeeklyStat diff --git a/packages/protocol-dashboard/src/components/EstimatedWeeklyStat/index.tsx b/packages/protocol-dashboard/src/components/EstimatedWeeklyStat/index.tsx new file mode 100644 index 00000000000..07fe75f4174 --- /dev/null +++ b/packages/protocol-dashboard/src/components/EstimatedWeeklyStat/index.tsx @@ -0,0 +1 @@ +export { default } from './EstimatedWeeklyStat' diff --git a/packages/protocol-dashboard/src/components/MyEstimatedRewards/MyEstimatedRewards.module.css b/packages/protocol-dashboard/src/components/MyEstimatedRewards/MyEstimatedRewards.module.css new file mode 100644 index 00000000000..47e33bc122c --- /dev/null +++ b/packages/protocol-dashboard/src/components/MyEstimatedRewards/MyEstimatedRewards.module.css @@ -0,0 +1,33 @@ +.container { + display: inline-flex; + flex-direction: column; + align-items: center; + padding: 0px; + width: 300px; + min-width: 300px; +} + +.rowContainer { + display: inline-flex; + width: 100%; + justify-content: space-between; + margin-bottom: 2px; +} + +.label { + font-weight: var(--font-bold); + font-size: var(--font-m); + letter-spacing: 0.01em; + text-transform: uppercase; + color: var(--neutral-light-4); +} + +.value { + font-weight: var(--font-bold); + font-size: var(--font-m); + + text-align: right; + letter-spacing: 0.01em; + text-transform: uppercase; + color: var(--neutral); +} \ No newline at end of file diff --git a/packages/protocol-dashboard/src/components/MyEstimatedRewards/MyEstimatedRewards.tsx b/packages/protocol-dashboard/src/components/MyEstimatedRewards/MyEstimatedRewards.tsx new file mode 100644 index 00000000000..cb9e2f95c97 --- /dev/null +++ b/packages/protocol-dashboard/src/components/MyEstimatedRewards/MyEstimatedRewards.tsx @@ -0,0 +1,71 @@ +import React, { ReactNode } from 'react' + +import Paper from 'components/Paper' +import styles from './MyEstimatedRewards.module.css' +import { TICKER } from 'utils/consts' +import { Address, Status } from 'types' +import { + useUserAnnualRewardRate, + useUserWeeklyRewards +} from 'store/cache/rewards/hooks' +import Loading from 'components/Loading' +import DisplayAudio from 'components/DisplayAudio' + +const messages = { + staked: `Staked ${TICKER}`, + estAnnualRewards: 'Est. Annual Reward', + estWeeklyRewards: 'Est. Weekly Reward' +} + +type RowStatProps = { + label: string + value: ReactNode +} +const RowStat: React.FC = ({ label, value }) => { + return ( +
+
{label}
+
{value}
+
+ ) +} + +type OwnProps = { + wallet: Address +} + +type MyEstimatedRewardsProps = OwnProps + +/** + * Shows stats about staking. Lives on the SP page + */ +const MyEstimatedRewards: React.FC = ({ wallet }) => { + const weeklyRewards = useUserWeeklyRewards({ wallet }) + const annualRewards = useUserAnnualRewardRate({ wallet }) + const isLoading = + weeklyRewards.status === Status.Loading || + annualRewards.status === Status.Loading + const annual = annualRewards.reward ? ( + + ) : null + + const weekly = + 'reward' in weeklyRewards ? ( + + ) : null + + return ( + + {isLoading ? ( + + ) : ( + <> + + + + )} + + ) +} + +export default MyEstimatedRewards diff --git a/packages/protocol-dashboard/src/components/MyEstimatedRewards/index.tsx b/packages/protocol-dashboard/src/components/MyEstimatedRewards/index.tsx new file mode 100644 index 00000000000..67a24042c11 --- /dev/null +++ b/packages/protocol-dashboard/src/components/MyEstimatedRewards/index.tsx @@ -0,0 +1 @@ +export { default } from './MyEstimatedRewards' diff --git a/packages/protocol-dashboard/src/components/RewardStat/RewardStat.module.css b/packages/protocol-dashboard/src/components/RewardStat/RewardStat.module.css new file mode 100644 index 00000000000..d8c146b5d0f --- /dev/null +++ b/packages/protocol-dashboard/src/components/RewardStat/RewardStat.module.css @@ -0,0 +1,43 @@ +/* Stats Container */ + +.container { + padding: 0px 16px; + display: inline-flex; + flex-direction: column; + justify-content: center; + min-width: 228px; + min-height: 150px; + box-sizing: border-box; + align-items: center; +} + +.stat { + font-family: var(--font-family); + font-weight: 900; + font-size: 64px; + height: 76px; + text-align: center; + display: inline; + background: -webkit-linear-gradient(315deg, #7652CC 2.04%, #B05CE6 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.label, +.description { + font-family: var(--font-family); + font-weight: var(--font-bold); + font-size: var(--font-l); + line-height: 22px; + text-align: center; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #BEC5E0; + user-select: none; +} + +.loadingContainer { + height: 62px; + display: flex; + align-items: center; +} diff --git a/packages/protocol-dashboard/src/components/RewardStat/RewardStat.tsx b/packages/protocol-dashboard/src/components/RewardStat/RewardStat.tsx new file mode 100644 index 00000000000..1a94cf76c65 --- /dev/null +++ b/packages/protocol-dashboard/src/components/RewardStat/RewardStat.tsx @@ -0,0 +1,42 @@ +import React, { ReactNode } from 'react' + +import Paper from 'components/Paper' +import styles from './RewardStat.module.css' +import Loading from 'components/Loading' +import Error from 'components/Error' +import clsx from 'clsx' + +type OwnProps = { + className?: string + stat: ReactNode + label: string + error?: boolean +} + +type RewardStatProps = OwnProps + +const RewardStat: React.FC = ({ + className, + stat, + label, + error +}) => { + return ( + + {error ? ( +
+ +
+ ) : stat !== null ? ( +
{stat}
+ ) : ( +
+ +
+ )} +
{label}
+
+ ) +} + +export default RewardStat diff --git a/packages/protocol-dashboard/src/components/RewardStat/index.tsx b/packages/protocol-dashboard/src/components/RewardStat/index.tsx new file mode 100644 index 00000000000..c9f3bcf46c0 --- /dev/null +++ b/packages/protocol-dashboard/src/components/RewardStat/index.tsx @@ -0,0 +1 @@ +export { default } from './RewardStat' diff --git a/packages/protocol-dashboard/src/components/StatsChip/StatsChip.module.css b/packages/protocol-dashboard/src/components/StatsChip/StatsChip.module.css index afc4b67264a..21c913f73bd 100644 --- a/packages/protocol-dashboard/src/components/StatsChip/StatsChip.module.css +++ b/packages/protocol-dashboard/src/components/StatsChip/StatsChip.module.css @@ -1,7 +1,8 @@ .statsChip { - padding: 24px 0px 16px; + padding: 24px 0px; display: inline-flex; flex-direction: column; + justify-content: center; min-width: 228px; box-sizing: border-box; } diff --git a/packages/protocol-dashboard/src/components/UserInfo/UserInfo.module.css b/packages/protocol-dashboard/src/components/UserInfo/UserInfo.module.css index 205bfd2c59c..d82425ae9be 100644 --- a/packages/protocol-dashboard/src/components/UserInfo/UserInfo.module.css +++ b/packages/protocol-dashboard/src/components/UserInfo/UserInfo.module.css @@ -2,7 +2,7 @@ .userInfo { position: relative; flex: 1; - padding: 24px 0px; + padding: 24px 0px 18px; display: inline-flex; flex-direction: column; align-items: center; @@ -55,17 +55,18 @@ text-align: center; color: #BEC5E0; - margin-bottom: 8px } .userName { font-size: var(--font-2xl); min-height: var(--font-2xl); + margin-bottom: 8px } .userWallet { font-size: var(--font-s); + margin-bottom: 24px } .buttonContainer { diff --git a/packages/protocol-dashboard/src/components/UserInfo/UserInfo.tsx b/packages/protocol-dashboard/src/components/UserInfo/UserInfo.tsx index 2a5a53d3093..20786f47aa5 100644 --- a/packages/protocol-dashboard/src/components/UserInfo/UserInfo.tsx +++ b/packages/protocol-dashboard/src/components/UserInfo/UserInfo.tsx @@ -18,6 +18,7 @@ import { useModalControls } from 'utils/hooks' import { TICKER } from 'utils/consts' import { formatAud } from 'utils/format' import Loading from 'components/Loading' +import MyEstimatedRewards from 'components/MyEstimatedRewards' import desktopStyles from './UserInfo.module.css' import mobileStyles from './UserInfoMobile.module.css' @@ -183,6 +184,7 @@ const UserInfo = ({ {'User
{name}
{wallet}
+ ) } diff --git a/packages/protocol-dashboard/src/containers/Home/Home.module.css b/packages/protocol-dashboard/src/containers/Home/Home.module.css index 2e40cdfeb0b..39938cae2a7 100644 --- a/packages/protocol-dashboard/src/containers/Home/Home.module.css +++ b/packages/protocol-dashboard/src/containers/Home/Home.module.css @@ -12,6 +12,20 @@ margin-right: 8px; } +.rewards { + margin-bottom: 16px; + margin-left: -8px; + margin-right: -8px; + display: flex; + align-items: stretch; +} + +.rewards > * { + flex-grow: 1; + margin-left: 8px; + margin-right: 8px; +} + .proposals { flex-direction: column; box-sizing: border-box; diff --git a/packages/protocol-dashboard/src/containers/Home/Home.tsx b/packages/protocol-dashboard/src/containers/Home/Home.tsx index f8105fd78f1..7ee168d12d5 100644 --- a/packages/protocol-dashboard/src/containers/Home/Home.tsx +++ b/packages/protocol-dashboard/src/containers/Home/Home.tsx @@ -14,6 +14,8 @@ import { GOVERNANCE } from 'utils/routes' import TotalStakedStat from 'components/TotalStakedStat' import ApiCallsStat from 'components/ApiCallsStat' import UniqueUsersStat from 'components/UniqueUsersStat' +import EstimatedWeeklyStat from 'components/EstimatedWeeklyStat' +import EstimatedAnnualStat from 'components/EstimatedAnnualStat' import desktopStyles from './Home.module.css' import mobileStyles from './HomeMobile.module.css' @@ -47,7 +49,10 @@ const Home: React.FC = (props: HomeProps) => { - +
+ + +
{isLoggedIn && } diff --git a/packages/protocol-dashboard/src/hooks/useRewardRate.ts b/packages/protocol-dashboard/src/hooks/useRewardRate.ts new file mode 100644 index 00000000000..67bda05e028 --- /dev/null +++ b/packages/protocol-dashboard/src/hooks/useRewardRate.ts @@ -0,0 +1,31 @@ +import AudiusClient from 'services/Audius' +import useTotalStaked from './useTotalStaked' +import { useFundsPerRound } from 'store/cache/claims/hooks' +import { Status } from 'types' + +export const useWeeklyRewardRate = () => { + const fundsPerRound = useFundsPerRound() + const totalActiveStake = useTotalStaked() + if ( + fundsPerRound.status === Status.Success && + totalActiveStake.status === Status.Success + ) { + const percentage = + AudiusClient.getBNPercentage( + fundsPerRound.amount!, + totalActiveStake.total!, + 4 + ) * 100 + return { status: Status.Success, rate: percentage } + } + return { status: Status.Loading } +} + +export const useAnnualRewardRate = () => { + const weeklyClaim = useWeeklyRewardRate() + if (weeklyClaim.status === Status.Success) { + const rate = weeklyClaim.rate! * 52 + return { status: Status.Success, rate } + } + return { status: Status.Loading } +} diff --git a/packages/protocol-dashboard/src/services/Audius/claim/claim.ts b/packages/protocol-dashboard/src/services/Audius/claim/claim.ts index b33144a9bde..0ccdf888155 100644 --- a/packages/protocol-dashboard/src/services/Audius/claim/claim.ts +++ b/packages/protocol-dashboard/src/services/Audius/claim/claim.ts @@ -34,8 +34,8 @@ export default class Claim { // Get the amount funded per round in wei async getFundsPerRound(): Promise { await this.aud.hasPermissions() - const info = await this.getContract().getFundsPerRound() - return info + const claimAmount = await this.getContract().getFundsPerRound() + return new BN(claimAmount) } // Get the total amount claimed in the current round diff --git a/packages/protocol-dashboard/src/services/Audius/helpers.ts b/packages/protocol-dashboard/src/services/Audius/helpers.ts index 0e55763dc81..faa68e49d35 100644 --- a/packages/protocol-dashboard/src/services/Audius/helpers.ts +++ b/packages/protocol-dashboard/src/services/Audius/helpers.ts @@ -87,11 +87,16 @@ export function toChecksumAddress(this: AudiusClient, wallet: string) { } // Static Helpers -export function getBNPercentage(n1: BigNumber, n2: BigNumber): number { +export function getBNPercentage( + n1: BigNumber, + n2: BigNumber, + decimals: number = 2 +): number { + const divisor = Math.pow(10, decimals + 1) if (n2.toString() === '0') return 0 - let num = n1.mul(Utils.toBN('1000')).div(n2) - if (num.gte(Utils.toBN('1000'))) return 1 - return num.toNumber() / 1000 + let num = n1.mul(Utils.toBN(divisor.toString())).div(n2) + if (num.gte(Utils.toBN(divisor.toString()))) return 1 + return num.toNumber() / divisor } export function displayShortAud(amount: BigNumber) { diff --git a/packages/protocol-dashboard/src/store/cache/claims/hooks.ts b/packages/protocol-dashboard/src/store/cache/claims/hooks.ts index fc987535432..b6bd2668830 100644 --- a/packages/protocol-dashboard/src/store/cache/claims/hooks.ts +++ b/packages/protocol-dashboard/src/store/cache/claims/hooks.ts @@ -7,12 +7,22 @@ import Audius from 'services/Audius' import { AppState } from 'store/types' import { useEffect } from 'react' -import { fetchClaim, setClaim } from 'store/cache/claims/slice' +import { + fetchClaim, + setClaim, + setClaimMetadata as setMetadata +} from 'store/cache/claims/slice' // -------------------------------- Selectors -------------------------------- export const getPendingClaim = (wallet: Address) => (state: AppState) => state.cache.claims.users[wallet] +export const getFundsPerRound = () => (state: AppState) => + state.cache.claims.metadata.fundsPerRound + +export const getLastFundedBlock = () => (state: AppState) => + state.cache.claims.metadata.lastFundedBlock + // -------------------------------- Thunk Actions -------------------------------- export function fetchPendingClaim( @@ -31,6 +41,41 @@ export function fetchPendingClaim( } } +export function setClaimMetadata(): ThunkAction< + void, + AppState, + Audius, + Action +> { + return async (dispatch, getState, aud) => { + await aud.awaitSetup() + try { + const [ + fundsPerRound, + lastFundedBlock, + fundingRoundBlockDiff, + totalClaimedInRound + ] = await Promise.all([ + aud.Claim.getFundsPerRound(), + aud.Claim.getLastFundedBlock(), + aud.Claim.getFundingRoundBlockDiff(), + aud.Claim.getTotalClaimedInRound() + ]) + dispatch( + setMetadata({ + fundsPerRound, + lastFundedBlock, + fundingRoundBlockDiff, + totalClaimedInRound + }) + ) + } catch (error) { + // TODO: Handle error case + console.log(error) + } + } +} + // -------------------------------- Hooks -------------------------------- export const usePendingClaim = (wallet: Address) => { @@ -44,3 +89,27 @@ export const usePendingClaim = (wallet: Address) => { if (!pendingClaim) return { status: Status.Loading, hasClaim: false } return pendingClaim } + +export const useFundsPerRound = () => { + const fundsPerRound = useSelector(getFundsPerRound()) + const dispatch = useDispatch() + useEffect(() => { + if (!fundsPerRound) { + dispatch(setClaimMetadata()) + } + }, [dispatch, fundsPerRound]) + if (!fundsPerRound) return { status: Status.Loading } + return { status: Status.Success, amount: fundsPerRound } +} + +export const useLastFundedBlock = () => { + const lastFundedBlock = useSelector(getLastFundedBlock()) + const dispatch = useDispatch() + useEffect(() => { + if (!lastFundedBlock) { + dispatch(setClaimMetadata()) + } + }, [dispatch, lastFundedBlock]) + if (!lastFundedBlock) return { status: Status.Loading } + return { status: Status.Success, blockNumber: lastFundedBlock } +} diff --git a/packages/protocol-dashboard/src/store/cache/claims/slice.ts b/packages/protocol-dashboard/src/store/cache/claims/slice.ts index 60f7717600f..4172e32fe0c 100644 --- a/packages/protocol-dashboard/src/store/cache/claims/slice.ts +++ b/packages/protocol-dashboard/src/store/cache/claims/slice.ts @@ -1,8 +1,16 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import BN from 'bn.js' import { Status } from 'types' export type State = { + metadata: { + fundsPerRound?: BN + lastFundedBlock?: number + totalClaimedInRound?: BN + recurringCommunityFundingAmount?: BN + fundingRoundBlockDiff?: number + } users: { [wallet: string]: { status: Status @@ -12,11 +20,19 @@ export type State = { } export const initialState: State = { + metadata: {}, users: {} } type FetchClaim = { wallet: string } type SetClaim = { wallet: string; hasClaim: boolean } +type SetClaimMetadata = { + fundsPerRound: BN + totalClaimedInRound: BN + lastFundedBlock: number + fundingRoundBlockDiff: number + recurringCommunityFundingAmount?: BN +} const slice = createSlice({ name: 'claim', @@ -33,10 +49,17 @@ const slice = createSlice({ status: Status.Success, hasClaim: action.payload.hasClaim } + }, + setClaimMetadata: (state, action: PayloadAction) => { + state.metadata.fundsPerRound = action.payload.fundsPerRound + state.metadata.lastFundedBlock = action.payload.lastFundedBlock + state.metadata.fundingRoundBlockDiff = + action.payload.fundingRoundBlockDiff + state.metadata.totalClaimedInRound = action.payload.totalClaimedInRound } } }) -export const { fetchClaim, setClaim } = slice.actions +export const { fetchClaim, setClaim, setClaimMetadata } = slice.actions export default slice.reducer diff --git a/packages/protocol-dashboard/src/store/cache/rewards/helpers.ts b/packages/protocol-dashboard/src/store/cache/rewards/helpers.ts new file mode 100644 index 00000000000..610e3aaefaa --- /dev/null +++ b/packages/protocol-dashboard/src/store/cache/rewards/helpers.ts @@ -0,0 +1,192 @@ +import BN from 'bn.js' +import AudiusClient from 'services/Audius' +import { User, Operator, Address } from 'types' + +const DEPLOYER_CUT_BASE = new BN('100') + +// Get the operator's active stake = total staked - pending decrease stake + total delegated to operator - operator's delegators' pending decrease stake +export const getOperatorTotalActiveStake = (user: Operator) => { + const userActiveStake = user.serviceProvider.deployerStake.sub( + user.pendingDecreaseStakeRequest?.amount ?? new BN('0') + ) + const userActiveDelegated = user.delegators.reduce((total, delegator) => { + return total.add(delegator.activeAmount) + }, new BN('0')) + const totalActiveStake = userActiveStake.add(userActiveDelegated) + return totalActiveStake +} + +// Get the amount locked - pending decrease stake, and the operator's delegator's pending decrease delegation +export const getOperatorTotalLocked = (user: Operator) => { + const lockedPendingDecrease = + (user as Operator).pendingDecreaseStakeRequest?.amount ?? new BN('0') + // Another way to get the locked delegation value from contract read + // const lockedDelegation await aud.Delegate.getTotalLockedDelegationForServiceProvider(user.wallet) + const lockedDelegation = user.delegators.reduce((totalLocked, delegate) => { + return totalLocked.add(delegate.amount.sub(delegate.activeAmount)) + }, new BN('0')) + const totalLocked = lockedPendingDecrease.add(lockedDelegation) + return totalLocked +} + +/** + * Calculates the net minted amount for a service operator prior to + * distribution among the service provider and their delegators + * Reference processClaim in the claims manager contract + * NOTE: minted amount is calculated using values at the init claim block + * @param {AudiusClient} aud Instance of the audius client + * @param {string} wallet The service operator's wallet address + * @param {BN} totalLocked The total token currently locked (decrease stake and delegation) + * @param {number} blockNumber The blocknumber of the claim to process + * @param {BN} fundingAmount The amount of total funds allocated per claim round + * @returns {BN} The net minted amount + */ +export const getMintedAmountAtBlock = async ({ + aud, + wallet, + totalLocked, + blockNumber, + fundingAmount +}: { + aud: AudiusClient + wallet: Address + fundingAmount: BN + totalLocked: BN + blockNumber: number +}) => { + const totalStakedAtFundBlockForClaimer = await aud.Staking.totalStakedForAt( + wallet, + blockNumber + ) + const totalStakedAtFundBlock = await aud.Staking.totalStakedAt(blockNumber) + const activeStake = totalStakedAtFundBlockForClaimer.sub(totalLocked) + const rewardsForClaimer = activeStake + .mul(fundingAmount) + .div(totalStakedAtFundBlock) + return rewardsForClaimer +} + +export const getOperatorRewards = ({ + user, + totalRewards, + deployerCutBase = DEPLOYER_CUT_BASE +}: { + user: Operator + totalRewards: BN + deployerCutBase?: BN +}) => { + const totalActiveStake = getOperatorTotalActiveStake(user) + const deployerCut = new BN(user.serviceProvider.deployerCut) + + const totalDelegatedRewards = user.delegators.reduce((total, delegate) => { + const delegateRewards = getDelegateRewards({ + delegateAmount: delegate.activeAmount, + totalRoundRewards: totalRewards, + totalActive: totalActiveStake, + deployerCut, + deployerCutBase + }) + return total.add(delegateRewards.delegatorCut) + }, new BN('0')) + + const operatorRewards = totalRewards.sub(totalDelegatedRewards) + return operatorRewards +} + +export const getDelegateRewards = ({ + delegateAmount, + totalRoundRewards, + totalActive, + deployerCut, + deployerCutBase = DEPLOYER_CUT_BASE +}: { + delegateAmount: BN + totalRoundRewards: BN + totalActive: BN + deployerCut: BN + deployerCutBase?: BN +}) => { + const rewardsPriorToSPCut = delegateAmount + .mul(totalRoundRewards) + .div(totalActive) + const spDeployerCut = delegateAmount + .mul(totalRoundRewards) + .mul(deployerCut) + .div(totalActive) + .div(deployerCutBase) + return { + spCut: spDeployerCut, + delegatorCut: rewardsPriorToSPCut.sub(spDeployerCut) + } +} + +/** + * Calculates the total rewards for a user from a claim given a blocknumber + * @param {string} wallet The user's wallet address + * @param {Object} users An object of all user wallets to their User details + * @param {BN} fundsPerRound The amount of rewards given out in a round + * @param {number} blockNumber The block number to process the claim event for + * @param {AudiusClient} aud Instance of the audius client for contract reads + * @returns {BN} expected rewards for the user at the claim block + */ +export const getRewardForClaimBlock = async ({ + wallet, + users, + fundsPerRound, + blockNumber, + aud +}: { + wallet: Address + users: (User | Operator)[] + fundsPerRound: BN + blockNumber: number + aud: AudiusClient +}) => { + const user = users.find(u => u.wallet === wallet) + let totalRewards = new BN('0') + + // If the user is a service provider, retrieve their expected rewards for staking + if ('serviceProvider' in user!) { + const lockedPendingDecrease = + (user as Operator).pendingDecreaseStakeRequest?.amount ?? new BN('0') + const lockedDelegation = await aud.Delegate.getTotalLockedDelegationForServiceProvider( + wallet + ) + const totalLocked = lockedPendingDecrease.add(lockedDelegation) + const mintedRewards = await getMintedAmountAtBlock({ + aud, + totalLocked, + fundingAmount: fundsPerRound, + wallet, + blockNumber + }) + const operatorRewards = getOperatorRewards({ + user: user as Operator, + totalRewards: mintedRewards + }) + totalRewards = totalRewards.add(operatorRewards) + } + + // For each service operator the user delegates to, calculate the expected rewards for delegating + for (let delegate of (user as User).delegates) { + const operator = users.find(u => u.wallet === delegate.wallet) as Operator + const deployerCut = new BN(operator.serviceProvider.deployerCut.toString()) + const operatorActiveStake = getOperatorTotalActiveStake(operator) + const operatorTotalLocked = getOperatorTotalLocked(operator) + const userMintedRewards = await getMintedAmountAtBlock({ + aud, + totalLocked: operatorTotalLocked, + fundingAmount: fundsPerRound, + wallet: delegate.wallet, + blockNumber + }) + const delegateRewards = getDelegateRewards({ + delegateAmount: delegate.activeAmount, + totalRoundRewards: userMintedRewards, + totalActive: operatorActiveStake, + deployerCut + }) + totalRewards = totalRewards.add(delegateRewards.delegatorCut) + } + return totalRewards +} diff --git a/packages/protocol-dashboard/src/store/cache/rewards/hooks.ts b/packages/protocol-dashboard/src/store/cache/rewards/hooks.ts new file mode 100644 index 00000000000..7d53a088f01 --- /dev/null +++ b/packages/protocol-dashboard/src/store/cache/rewards/hooks.ts @@ -0,0 +1,126 @@ +import { useSelector, useDispatch } from 'react-redux' +import { ThunkAction } from 'redux-thunk' +import { Action } from 'redux' +import BN from 'bn.js' + +import { Address, User, Operator, Status } from 'types' +import Audius from 'services/Audius' +import { AppState } from 'store/types' +import { useEffect } from 'react' + +import { + useFundsPerRound, + useLastFundedBlock, + usePendingClaim +} from 'store/cache/claims/hooks' +import { useEthBlockNumber } from 'store/cache/protocol/hooks' +import { useUsers } from 'store/cache/user/hooks' +import { fetchWeeklyRewards, setWeeklyRewards } from 'store/cache/rewards/slice' +import { getRewardForClaimBlock } from './helpers' + +// -------------------------------- Selectors -------------------------------- +export const getUserRewards = (wallet: Address) => (state: AppState) => + state.cache.rewards.users[wallet] + +export const getUserWeeklyRewards = (wallet: Address) => (state: AppState) => { + const userReward = state.cache.rewards.users[wallet] + return userReward?.status === Status.Success ? userReward.reward : undefined +} + +// -------------------------------- Thunk Actions -------------------------------- + +export function fetchRewards({ + wallet, + fundsPerRound, + users, + lastFundedBlock, + currentBlockNumber, + hasClaim +}: { + wallet: Address + currentBlockNumber: number + fundsPerRound: BN + users: (User | Operator)[] + lastFundedBlock: number + hasClaim: boolean +}): ThunkAction> { + return async (dispatch, getState, aud) => { + dispatch(fetchWeeklyRewards({ wallet })) + await aud.awaitSetup() + try { + // NOTE: If blocknumber is set to lastFundedBlock, then the reward will represent the pending claim amount + const blockNumber = currentBlockNumber + const reward = await getRewardForClaimBlock({ + wallet, + users, + fundsPerRound, + blockNumber, + aud + }) + dispatch(setWeeklyRewards({ wallet, reward })) + } catch (error) { + // TODO: Handle error case + console.log(error) + } + } +} + +// -------------------------------- Hooks -------------------------------- + +export const useUserWeeklyRewards = ({ wallet }: { wallet: Address }) => { + const weeklyRewards = useSelector(getUserRewards(wallet)) + const currentBlockNumber = useEthBlockNumber() + const { status: fundsStatus, amount: fundsPerRound } = useFundsPerRound() + const { + status: lastFundedStatus, + blockNumber: lastFundedBlock + } = useLastFundedBlock() + const { status: usersStatus, users } = useUsers() + const { status: claimStatus, hasClaim } = usePendingClaim(wallet) + + const dispatch = useDispatch() + useEffect(() => { + const hasInfo = [ + fundsStatus, + usersStatus, + lastFundedStatus, + claimStatus + ].every(status => status === Status.Success) + if (wallet && hasInfo && currentBlockNumber && !weeklyRewards) { + dispatch( + fetchRewards({ + wallet, + fundsPerRound: fundsPerRound!, + users, + lastFundedBlock: lastFundedBlock!, + currentBlockNumber, + hasClaim + }) + ) + } + }, [ + dispatch, + wallet, + weeklyRewards, + fundsStatus, + usersStatus, + lastFundedStatus, + claimStatus, + fundsPerRound, + users, + currentBlockNumber, + lastFundedBlock, + hasClaim + ]) + if (!weeklyRewards) return { status: Status.Loading } + return weeklyRewards +} + +export const useUserAnnualRewardRate = ({ wallet }: { wallet: Address }) => { + const weeklyRewards = useUserWeeklyRewards({ wallet }) + if (weeklyRewards.status === Status.Success && 'reward' in weeklyRewards) { + const amount = weeklyRewards.reward.mul(new BN('52')) + return { status: Status.Success, reward: amount } + } + return { status: Status.Loading } +} diff --git a/packages/protocol-dashboard/src/store/cache/rewards/slice.ts b/packages/protocol-dashboard/src/store/cache/rewards/slice.ts new file mode 100644 index 00000000000..ae8dad43bc3 --- /dev/null +++ b/packages/protocol-dashboard/src/store/cache/rewards/slice.ts @@ -0,0 +1,46 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' + +import BN from 'bn.js' +import { Status, Address } from 'types' + +export type State = { + users: { + [wallet: string]: + | { + status: Status.Loading | Status.Failure + } + | { + status: Status.Success + reward: BN + } + } +} + +export const initialState: State = { + users: {} +} + +type FetchWeeklyRewards = { wallet: Address } +type SetWeeklyRewards = { wallet: Address; reward: BN } + +const slice = createSlice({ + name: 'rewards', + initialState, + reducers: { + fetchWeeklyRewards: (state, action: PayloadAction) => { + state.users[action.payload.wallet] = { + status: Status.Loading + } + }, + setWeeklyRewards: (state, action: PayloadAction) => { + state.users[action.payload.wallet] = { + status: Status.Success, + reward: action.payload.reward + } + } + } +}) + +export const { fetchWeeklyRewards, setWeeklyRewards } = slice.actions + +export default slice.reducer diff --git a/packages/protocol-dashboard/src/store/index.ts b/packages/protocol-dashboard/src/store/index.ts index 1c4e9e63471..71a24aff7e7 100644 --- a/packages/protocol-dashboard/src/store/index.ts +++ b/packages/protocol-dashboard/src/store/index.ts @@ -16,6 +16,7 @@ import protocol from 'store/cache/protocol/slice' import user from 'store/cache/user/slice' import proposals from 'store/cache/proposals/slice' import votes from 'store/cache/votes/slice' +import rewards from 'store/cache/rewards/slice' import timeline from 'store/cache/timeline/slice' import claims from 'store/cache/claims/slice' import analytics from 'store/cache/analytics/slice' @@ -59,7 +60,8 @@ const getReducer = (history: History) => { timeline, claims, analytics, - music + music, + rewards }) }) } diff --git a/packages/protocol-dashboard/src/store/types.ts b/packages/protocol-dashboard/src/store/types.ts index fe5dbb0a6e5..e2beccc451b 100644 --- a/packages/protocol-dashboard/src/store/types.ts +++ b/packages/protocol-dashboard/src/store/types.ts @@ -11,6 +11,7 @@ import { State as PageHistoryState } from 'store/pageHistory/slice' import { State as ClaimsState } from 'store/cache/claims/slice' import { State as AnalyticsState } from 'store/cache/analytics/slice' import { State as MusicState } from 'store/cache/music/slice' +import { State as RewardsState } from 'store/cache/rewards/slice' export type AppState = { router: RouterState @@ -27,6 +28,7 @@ export type AppState = { claims: ClaimsState analytics: AnalyticsState music: MusicState + rewards: RewardsState } }