diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 8b094e1..1c3811b 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -8,6 +8,9 @@ import { TabHeaderButton } from 'src/components/buttons/TabHeaderButton'; import { Section } from 'src/components/layout/Section'; import { Amount, formatNumberString } from 'src/components/numbers/Amount'; import { useBalance } from 'src/features/account/hooks'; +import { DelegationsTable } from 'src/features/delegation/DelegationsTable'; +import { DelegationAmount } from 'src/features/delegation/types'; +import { useDelegationBalances } from 'src/features/delegation/useDelegationBalances'; import { LockActionType, LockedBalances } from 'src/features/locking/types'; import { useLockedStatus } from 'src/features/locking/useLockedStatus'; import { getTotalLockedCelo, getTotalUnlockedCelo } from 'src/features/locking/utils'; @@ -37,19 +40,23 @@ export default function Page() { const { balance: walletBalance } = useBalance(address); const { lockedBalances } = useLockedStatus(address); + const { delegations } = useDelegationBalances(address); const { stakeBalances, groupToStake, refetch: refetchStakes } = useStakingBalances(address); const { totalRewardsWei, groupToReward } = useStakingRewards(address, groupToStake); const { groupToIsActivatable, refetch: refetchActivations } = usePendingStakingActivations( address, groupToStake, ); + const { addressToGroup } = useValidatorGroups(); + const { activateStake } = useActivateStake(() => { refetchStakes(); refetchActivations(); }); - const { addressToGroup } = useValidatorGroups(); - const totalBalance = (walletBalance || 0n) + getTotalLockedCelo(lockedBalances); + const totalLocked = getTotalLockedCelo(lockedBalances); + const totalBalance = (walletBalance || 0n) + totalLocked; + const totalDelegated = (delegations?.percentDelegated || 0n) * totalLocked; return (
@@ -66,6 +73,7 @@ export default function Page() { lockedBalances={lockedBalances} stakeBalances={stakeBalances} totalRewards={totalRewardsWei} + totalDelegated={totalDelegated} />
@@ -117,11 +126,13 @@ function AccountStats({ lockedBalances, stakeBalances, totalRewards, + totalDelegated, }: { walletBalance?: bigint; lockedBalances?: LockedBalances; stakeBalances?: StakingBalances; totalRewards?: bigint; + totalDelegated?: bigint; }) { return (
@@ -129,7 +140,7 @@ function AccountStats({ title="Total locked" valueWei={lockedBalances?.locked} subtitle="Delegated" - subValueWei={0n} + subValueWei={totalDelegated} /> ; groupToReward?: AddressTo; groupToIsActivatable?: AddressTo; + delegateeToAmount?: AddressTo; activateStake: (g: Address) => void; }) { const [tab, setTab] = useState<'stakes' | 'rewards' | 'delegations'>('stakes'); @@ -211,7 +224,7 @@ function TableTabs({ {tab === 'rewards' && ( )} - {tab === 'delegations' &&
TODO
} + {tab === 'delegations' && }
); } diff --git a/src/config/consts.ts b/src/config/consts.ts index fdf1f2c..9873625 100644 --- a/src/config/consts.ts +++ b/src/config/consts.ts @@ -19,3 +19,6 @@ export const PROPOSAL_V1_MAX_ID = 110; // Proposals before this use old vote eve export const QUEUED_STAGE_EXPIRY_TIME = 2_419_200_000; // 4 weeks export const APPROVAL_STAGE_EXPIRY_TIME = 86_400_000; // 1 day export const EXECUTION_STAGE_EXPIRY_TIME = 259_200_000; // 3 days + +// Delegation +export const MAX_NUM_DELEGATEES = 10; diff --git a/src/features/delegation/DelegationsTable.tsx b/src/features/delegation/DelegationsTable.tsx new file mode 100644 index 0000000..6e49ac4 --- /dev/null +++ b/src/features/delegation/DelegationsTable.tsx @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; +import { FullWidthSpinner } from 'src/components/animation/Spinner'; +import { SolidButton } from 'src/components/buttons/SolidButton'; +import { PLACEHOLDER_BAR_CHART_ITEM, StackedBarChart } from 'src/components/charts/StackedBarChart'; +import { sortAndCombineChartData } from 'src/components/charts/chartData'; +import { HeaderAndSubheader } from 'src/components/layout/HeaderAndSubheader'; +import { formatNumberString } from 'src/components/numbers/Amount'; +import { DelegationAmount } from 'src/features/delegation/types'; +import { TransactionFlowType } from 'src/features/transactions/TransactionFlowType'; +import { useTransactionModal } from 'src/features/transactions/TransactionModal'; +import { ValidatorGroupLogoAndName } from 'src/features/validators/ValidatorGroupLogo'; +import { ValidatorGroup } from 'src/features/validators/types'; +import { tableClasses } from 'src/styles/common'; +import { fromWei } from 'src/utils/amount'; +import { bigIntSum, percent } from 'src/utils/math'; +import { objKeys, objLength } from 'src/utils/objects'; + +// TODO pass in addressToDelegatee data here +export function DelegationsTable({ + delegateeToAmount, + addressToDelegatee, +}: { + delegateeToAmount?: AddressTo; + addressToDelegatee?: AddressTo; +}) { + const showTxModal = useTransactionModal(TransactionFlowType.Delegate); + + const { chartData, tableData } = useMemo(() => { + if (!delegateeToAmount || !objLength(delegateeToAmount)) { + return { tableData: [], chartData: [PLACEHOLDER_BAR_CHART_ITEM] }; + } + + const total = fromWei( + bigIntSum(Object.values(delegateeToAmount).map((amount) => amount.expected)), + ); + + const tableData = objKeys(delegateeToAmount) + .map((address) => { + const amount = fromWei(delegateeToAmount[address].expected); + const percentage = total ? percent(amount, total) : 0; + const name = addressToDelegatee?.[address]?.name || 'Unknown Delegatee'; + return { address, name, amount, percentage }; + }) + .sort((a, b) => b.amount - a.amount); + + const chartData = sortAndCombineChartData( + tableData.map(({ address, amount, percentage }) => ({ + label: addressToDelegatee?.[address]?.name || 'Unknown Delegatee', + value: amount, + percentage, + })), + ); + return { chartData, tableData }; + }, [delegateeToAmount, addressToDelegatee]); + + if (!delegateeToAmount) { + return Loading delegation data; + } + + if (!objLength(delegateeToAmount)) { + return ( + + showTxModal()}>Delegate CELO + + ); + } + + return ( +
+ + + + + + + + + + + {tableData.map(({ address, name, amount, percentage }) => ( + + + + + + ))} + +
NameAmount%
+ + {formatNumberString(amount, 2) + ' CELO'}{percentage + '%'}
+
+ ); +} diff --git a/src/features/delegation/types.ts b/src/features/delegation/types.ts new file mode 100644 index 0000000..16f2de0 --- /dev/null +++ b/src/features/delegation/types.ts @@ -0,0 +1,9 @@ +export interface DelegationAmount { + expected: bigint; + real: bigint; +} + +export interface DelegationBalances { + percentDelegated: bigint; + delegateeToAmount: AddressTo; +} diff --git a/src/features/delegation/useDelegationBalances.ts b/src/features/delegation/useDelegationBalances.ts new file mode 100644 index 0000000..63a2d4e --- /dev/null +++ b/src/features/delegation/useDelegationBalances.ts @@ -0,0 +1,88 @@ +import { lockedGoldABI } from '@celo/abis'; +import { useQuery } from '@tanstack/react-query'; +import { useToastError } from 'src/components/notifications/useToastError'; +import { Addresses } from 'src/config/contracts'; +import { DelegationBalances } from 'src/features/delegation/types'; +import { logger } from 'src/utils/logger'; +import { MulticallReturnType, PublicClient } from 'viem'; +import { usePublicClient } from 'wagmi'; + +export function useDelegationBalances(address?: Address) { + const publicClient = usePublicClient(); + + const { isLoading, isError, error, data, refetch } = useQuery({ + queryKey: ['useDelegationBalances', publicClient, address], + queryFn: async () => { + if (!address || !publicClient) return null; + logger.debug('Fetching delegation balances'); + return fetchDelegationBalances(publicClient, address); + }, + gcTime: 10 * 60 * 1000, // 10 minutes + staleTime: 1 * 60 * 1000, // 1 minute + }); + + useToastError(error, 'Error fetching delegation balances'); + + return { + isLoading, + isError, + delegations: data, + refetch, + }; +} + +async function fetchDelegationBalances( + publicClient: PublicClient, + address: Address, +): Promise { + const result: DelegationBalances = { + percentDelegated: 0n, + delegateeToAmount: {}, + }; + + // First fetch the list of delegatees addresses + const delegateeAddresses = await publicClient.readContract({ + address: Addresses.LockedGold, + abi: lockedGoldABI, + functionName: 'getDelegateesOfDelegator', + args: [address], + }); + + // If there are none, stop here + if (!delegateeAddresses.length) return result; + + // Fetch the fraction delegated + const delegatorPercent = await publicClient.readContract({ + address: Addresses.LockedGold, + abi: lockedGoldABI, + functionName: 'getAccountTotalDelegatedFraction', + args: [address], + }); + result.percentDelegated = delegatorPercent; + + // Prepare a list of account, delegatee tuples + const accountAndDelegatee = delegateeAddresses.map((a) => [address, a]); + + // @ts-ignore TODO Bug with viem 2.0 multicall types + const delegatedAmounts: MulticallReturnType = await publicClient.multicall({ + contracts: accountAndDelegatee.map(([acc, del]) => ({ + address: Addresses.LockedGold, + abi: lockedGoldABI, + functionName: 'getDelegatorDelegateeExpectedAndRealAmount', + args: [acc, del], + })), + }); + + for (let i = 0; i < delegateeAddresses.length; i++) { + const delegateeAddress = delegateeAddresses[i]; + const amounts = delegatedAmounts[i]; + if (amounts.status !== 'success') throw new Error('Delegated amount call failed'); + const [expected, real] = amounts.result as [bigint, bigint]; + result.delegateeToAmount[delegateeAddress] = { + expected, + real, + }; + } + + return result; +} diff --git a/src/features/staking/ActiveStakesTable.tsx b/src/features/staking/ActiveStakesTable.tsx index cdda498..22f8033 100644 --- a/src/features/staking/ActiveStakesTable.tsx +++ b/src/features/staking/ActiveStakesTable.tsx @@ -48,13 +48,14 @@ export function ActiveStakesTable({ .map((address) => { const stake = fromWei(groupToStake[address].active + groupToStake[address].pending); const percentage = percent(stake, total); - return { address, stake, percentage }; + const name = addressToGroup?.[address]?.name; + return { address, name, stake, percentage }; }) .sort((a, b) => b.stake - a.stake); const chartData = sortAndCombineChartData( tableData.map(({ address, stake, percentage }) => ({ - label: addressToGroup[address]?.name || 'Unknown', + label: addressToGroup[address]?.name || 'Unknown Group', value: stake, percentage, })), @@ -91,14 +92,11 @@ export function ActiveStakesTable({ - {tableData.map(({ address, stake, percentage }) => ( + {tableData.map(({ address, name, stake, percentage }) => (
- + {groupToIsActivatable?.[address] && ( Pending diff --git a/src/features/staking/rewards/RewardsTable.tsx b/src/features/staking/rewards/RewardsTable.tsx index 0a0cfc8..ff42823 100644 --- a/src/features/staking/rewards/RewardsTable.tsx +++ b/src/features/staking/rewards/RewardsTable.tsx @@ -33,13 +33,14 @@ export function RewardsTable({ .map((address) => { const reward = groupToReward[address]; const percentage = total ? percent(reward, total) : 0; - return { address, reward, percentage }; + const name = addressToGroup?.[address]?.name; + return { address, name, reward, percentage }; }) .sort((a, b) => b.reward - a.reward); const chartData = sortAndCombineChartData( tableData.map(({ address, reward, percentage }) => ({ - label: addressToGroup[address]?.name || 'Unknown', + label: addressToGroup[address]?.name || 'Unknown Group', value: reward, percentage, })), @@ -75,13 +76,10 @@ export function RewardsTable({ - {tableData.map(({ address, reward, percentage }) => ( + {tableData.map(({ address, name, reward, percentage }) => ( - + {formatNumberString(reward, 2) + ' CELO'} {percentage + '%'} diff --git a/src/features/staking/useStakingBalances.ts b/src/features/staking/useStakingBalances.ts index a94a405..93dc5db 100644 --- a/src/features/staking/useStakingBalances.ts +++ b/src/features/staking/useStakingBalances.ts @@ -45,7 +45,7 @@ export function useStakingBalances(address?: Address) { }; } -export async function fetchStakingBalances(publicClient: PublicClient, address: Address) { +async function fetchStakingBalances(publicClient: PublicClient, address: Address) { const groupAddrs = await publicClient.readContract({ address: Addresses.Election, abi: electionABI, diff --git a/src/features/validators/ValidatorGroupLogo.tsx b/src/features/validators/ValidatorGroupLogo.tsx index 951405a..e52b07e 100644 --- a/src/features/validators/ValidatorGroupLogo.tsx +++ b/src/features/validators/ValidatorGroupLogo.tsx @@ -40,7 +40,7 @@ export function ValidatorGroupLogoAndName({
- {name || 'Unknown'} + {name || 'Unknown Group'} {shortenAddress(address)}