diff --git a/src/app/app.tsx b/src/app/app.tsx index 3173998..b0487d5 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -10,6 +10,7 @@ import { Header } from 'src/components/nav/Header'; import { WagmiContext } from 'src/config/wagmi'; import { useIsSsr } from 'src/utils/ssr'; import 'src/vendor/inpage-metamask'; +import 'src/vendor/polyfill'; function SafeHydrate({ children }: PropsWithChildren) { // Disable app SSR for now as it's not needed and diff --git a/src/app/staking/page.tsx b/src/app/staking/page.tsx index 61cfefc..f18291e 100644 --- a/src/app/staking/page.tsx +++ b/src/app/staking/page.tsx @@ -1,19 +1,38 @@ 'use client'; +import { useMemo } from 'react'; import { SolidButton } from 'src/components/buttons/SolidButton'; import { Card } from 'src/components/layout/Card'; import { Section } from 'src/components/layout/Section'; +import { Amount } from 'src/components/numbers/Amount'; import { useValidatorGroups } from 'src/features/validators/hooks'; +import { ValidatorGroup, ValidatorStatus } from 'src/features/validators/types'; +import { bigIntMin } from 'src/utils/math'; export default function Index() { + const { groups, totalVotes } = useValidatorGroups(); return (
- - + +
); } -function HeroSection() { +function HeroSection({ totalVotes, groups }: { totalVotes?: bigint; groups?: ValidatorGroup[] }) { + const minVotes = useMemo(() => { + if (!groups?.length) return 0n; + let min = BigInt(1e40); + for (const g of groups) { + const numElectedMembers = Object.values(g.members).filter( + (m) => m.status === ValidatorStatus.Elected, + ).length; + if (!numElectedMembers) continue; + const votesPerMember = g.votes / BigInt(numElectedMembers); + min = bigIntMin(min, votesPerMember); + } + return min; + }, [groups]); + return (
@@ -23,27 +42,37 @@ function HeroSection() { {`Stake and earn up to 6%`}
- - - - + + + +
); } -function HeroStat({ label, value }: { label: string; value: string }) { +function HeroStat({ + label, + text, + amount, +}: { + label: string; + text?: string | number; + amount?: bigint; +}) { return (
-
{value}
+ {!!text &&
{text}
} + {!!amount && ( + + )}
); } -function ListSection() { - const { groups } = useValidatorGroups(); +function ListSection({ groups }: { groups?: ValidatorGroup[] }) { return (
diff --git a/src/components/numbers/Amount.tsx b/src/components/numbers/Amount.tsx index fa2a8ca..02784ff 100644 --- a/src/components/numbers/Amount.tsx +++ b/src/components/numbers/Amount.tsx @@ -14,21 +14,31 @@ export function Amount({ tokenId, tokenAddress, className, + decimals = 2, + showSymbol = true, }: { value?: BigNumber.Value | bigint; - valueWei?: string; + valueWei?: BigNumber.Value | bigint; tokenId?: TokenId; tokenAddress?: Address; className?: string; + decimals?: number; + showSymbol?: boolean; }) { if (valueWei) { value = fromWei(valueWei); } - const valueFormatted = BigNumber(value?.toString() || '0').toFormat(NUMBER_FORMAT); + const valueFormatted = BigNumber(value?.toString() || '0') + .decimalPlaces(decimals) + .toFormat(NUMBER_FORMAT); const token = (tokenId ? getTokenById(tokenId) : tokenAddress ? getTokenByAddress(tokenAddress) : null) || CELO; - return {`${valueFormatted} ${token.symbol}`}; + return ( + {`${valueFormatted} ${ + showSymbol ? token.symbol : '' + }`} + ); } diff --git a/src/config/consts.ts b/src/config/consts.ts index 7895008..71d9ac6 100644 --- a/src/config/consts.ts +++ b/src/config/consts.ts @@ -1 +1,4 @@ export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +// From the Election contract electableValidators config +export const MAX_NUM_ELECTABLE_VALIDATORS = 110; diff --git a/src/features/validators/hooks.ts b/src/features/validators/hooks.ts index 949f2b9..9a2d7ff 100644 --- a/src/features/validators/hooks.ts +++ b/src/features/validators/hooks.ts @@ -1,11 +1,13 @@ -import { accountsABI, electionABI, validatorsABI } from '@celo/abis'; +import { accountsABI, electionABI, lockedGoldABI, validatorsABI } from '@celo/abis'; import { useQuery } from '@tanstack/react-query'; import { Addresses } from 'src/config/contracts'; // import { getContract } from 'viem'; +import BigNumber from 'bignumber.js'; import { useEffect } from 'react'; import { toast } from 'react-toastify'; -import { ZERO_ADDRESS } from 'src/config/consts'; +import { MAX_NUM_ELECTABLE_VALIDATORS, ZERO_ADDRESS } from 'src/config/consts'; import { logger } from 'src/utils/logger'; +import { bigIntSum } from 'src/utils/math'; import { PublicClient, usePublicClient } from 'wagmi'; import { Validator, ValidatorGroup, ValidatorStatus } from './types'; @@ -16,49 +18,104 @@ export function useValidatorGroups() { queryKey: ['useValidatorGroups', publicClient], queryFn: async () => { logger.debug('Fetching validator groups'); - const groups = await fetchValidatorGroupInfo(publicClient); - return groups; + return await fetchValidatorGroupInfo(publicClient); }, gcTime: Infinity, - staleTime: Infinity, + staleTime: 60 * 60 * 1000, // 1 hour }); useEffect(() => { - if (error) { - logger.error(error); - toast.error('Error fetching validator groups'); - } + if (!error) return; + logger.error(error); + toast.error('Error fetching validator groups'); }, [error]); return { isLoading, isError, - groups: data, + groups: data?.groups, + totalLocked: data?.totalLocked, + totalVotes: data?.totalVotes, }; } async function fetchValidatorGroupInfo(publicClient: PublicClient) { - // Get contracts - // const cAccounts = getContract({ - // address: Addresses.Accounts, - // abi: accountsABI, - // publicClient, - // }); - // const cValidators = getContract({ - // address: Addresses.Validators, - // abi: validatorsABI, - // publicClient, - // }); - // const cElections = getContract({ - // address: Addresses.Election, - // abi: electionABI, - // publicClient, - // }); + const { validatorAddrs, electedSignersSet } = await fetchValidatorAddresses(publicClient); + + const validatorDetails = await fetchValidatorDetails(publicClient, validatorAddrs); + const validatorNames = await fetchNamesForAccounts(publicClient, validatorAddrs); + + if ( + validatorAddrs.length !== validatorDetails.length || + validatorAddrs.length !== validatorNames.length + ) { + throw new Error('Validator list / details size mismatch'); + } - // Fetch list of validators and list of elected signers - // const validatorAddrsP = cValidators.read.getRegisteredValidators() - // const electedSignersP = cElections.read.getCurrentValidatorSigners() - // const [validatorAddrs, electedSigners] = await Promise.all([validatorAddrsP, electedSignersP]) + // Process validator lists to create list of validator groups + const groups: Record = {}; + for (let i = 0; i < validatorAddrs.length; i++) { + const valAddr = validatorAddrs[i]; + const valDetails = validatorDetails[i]; + const valName = validatorNames[i].result || ''; + const groupAddr = valDetails.affiliation; + // Create new group if there isn't one yet + if (!groups[groupAddr]) { + groups[groupAddr] = { + address: groupAddr, + name: '', + url: '', + members: {}, + eligible: false, + capacity: 0n, + votes: 0n, + }; + } + // Create new validator group member + const validatorStatus = electedSignersSet.has(valDetails.signer) + ? ValidatorStatus.Elected + : ValidatorStatus.NotElected; + const validator: Validator = { + address: valAddr, + name: valName, + score: valDetails.score, + signer: valDetails.signer, + status: validatorStatus, + }; + groups[groupAddr].members[valAddr] = validator; + } + + // // Remove 'null' group with unaffiliated validators + if (groups[ZERO_ADDRESS]) { + delete groups[ZERO_ADDRESS]; + } + + // Fetch details about the validator groups + const groupAddrs = Object.keys(groups) as Address[]; + const groupNames = await fetchNamesForAccounts(publicClient, groupAddrs); + for (let i = 0; i < groupAddrs.length; i++) { + const groupAddr = groupAddrs[i]; + groups[groupAddr].name = groupNames[i].result || groupAddr.substring(0, 10) + '...'; + } + + // Fetch vote-related details about the validator groups + const { eligibleGroups, groupVotes, totalLocked, totalVotes } = + await fetchVotesAndTotalLocked(publicClient); + + // Process vote-related details about the validator groups + for (let i = 0; i < eligibleGroups.length; i++) { + const groupAddr = eligibleGroups[i]; + const numVotes = groupVotes[i]; + const group = groups[groupAddr]; + group.eligible = true; + group.votes = numVotes; + group.capacity = getValidatorGroupCapacity(group, validatorAddrs.length, totalLocked); + } + + return { groups: Object.values(groups), totalLocked, totalVotes }; +} + +async function fetchValidatorAddresses(publicClient: PublicClient) { const [validatorAddrsResp, electedSignersResp] = await publicClient.multicall({ contracts: [ { @@ -84,10 +141,13 @@ async function fetchValidatorGroupInfo(publicClient: PublicClient) { logger.debug( `Found ${validatorAddrs.length} validators and ${electedSignersSet.size} elected signers`, ); + return { validatorAddrs, electedSignersSet }; +} +async function fetchValidatorDetails(publicClient: PublicClient, addresses: readonly Address[]) { // Fetch validator details, needed for their scores and signers const validatorDetailsRaw = await publicClient.multicall({ - contracts: validatorAddrs.map((addr) => ({ + contracts: addresses.map((addr) => ({ address: Addresses.Validators, abi: validatorsABI, functionName: 'getValidator', @@ -96,7 +156,7 @@ async function fetchValidatorGroupInfo(publicClient: PublicClient) { }); // https://viem.sh/docs/faq.html#why-is-a-contract-function-return-type-returning-an-array-instead-of-an-object - const validatorDetails = validatorDetailsRaw.map((d, i) => { + return validatorDetailsRaw.map((d, i) => { if (!d.result) throw new Error(`Validator details missing for index ${i}`); return { ecdsaPublicKey: d.result[0], @@ -106,10 +166,11 @@ async function fetchValidatorGroupInfo(publicClient: PublicClient) { signer: d.result[4], }; }); - console.log(validatorDetails); +} - const validatorNames = await publicClient.multicall({ - contracts: validatorAddrs.map((addr) => ({ +function fetchNamesForAccounts(publicClient: PublicClient, addresses: readonly Address[]) { + return publicClient.multicall({ + contracts: addresses.map((addr) => ({ address: Addresses.Accounts, abi: accountsABI, functionName: 'getName', @@ -117,140 +178,48 @@ async function fetchValidatorGroupInfo(publicClient: PublicClient) { })), allowFailure: true, }); +} - if ( - validatorAddrs.length !== validatorDetails.length || - validatorAddrs.length !== validatorNames.length - ) { - throw new Error('Validator list / details size mismatch'); - } - - // Process validator lists to create list of validator groups - const groups: Record = {}; - for (let i = 0; i < validatorAddrs.length; i++) { - const valAddr = validatorAddrs[i]; - const valDetails = validatorDetails[i]; - const valName = validatorNames[i].result || ''; - const groupAddr = valDetails.affiliation; - // Create new group if there isn't one yet - if (!groups[groupAddr]) { - groups[groupAddr] = { - address: groupAddr, - name: '', - url: '', - members: {}, - eligible: false, - capacity: '0', - votes: '0', - }; - } - // Create new validator group member - const validatorStatus = electedSignersSet.has(valDetails.signer) - ? ValidatorStatus.Elected - : ValidatorStatus.NotElected; - const validator: Validator = { - address: valAddr, - name: valName, - score: valDetails.score.toString(), - signer: valDetails.signer, - status: validatorStatus, - }; - groups[groupAddr].members[valAddr] = validator; - } - - // // Remove 'null' group with unaffiliated validators - if (groups[ZERO_ADDRESS]) { - delete groups[ZERO_ADDRESS]; - } - - // Fetch details about the validator groups - const groupAddrs = Object.keys(groups) as Address[]; - const groupNames = await publicClient.multicall({ - contracts: groupAddrs.map((addr) => ({ - address: Addresses.Accounts, - abi: accountsABI, - functionName: 'getName', - args: [addr], - })), - allowFailure: true, +async function fetchVotesAndTotalLocked(publicClient: PublicClient) { + const [votes, locked] = await publicClient.multicall({ + contracts: [ + { + address: Addresses.Election, + abi: electionABI, + functionName: 'getTotalVotesForEligibleValidatorGroups', + }, + { + address: Addresses.LockedGold, + abi: lockedGoldABI, + functionName: 'getTotalLockedGold', + }, + ], }); - // Process details about the validator groups - for (let i = 0; i < groupAddrs.length; i++) { - const groupAddr = groupAddrs[i]; - groups[groupAddr].name = groupNames[i].result || groupAddr.substring(0, 10) + '...'; + if (votes.status !== 'success' || !votes.result?.length) { + throw new Error('Error fetching group votes'); + } + if (locked.status !== 'success' || !locked.result) { + throw new Error('Error total locked CELO'); } - // // Fetch vote-related details about the validator groups - // const { eligibleGroups, groupVotes, totalLocked } = await fetchVotesAndTotalLocked() - - // // Process vote-related details about the validator groups - // for (let i = 0; i < eligibleGroups.length; i++) { - // const groupAddr = eligibleGroups[i] - // const numVotes = groupVotes[i] - // const group = groups[groupAddr] - // group.eligible = true - // group.capacity = getValidatorGroupCapacity(group, validatorAddrs.length, totalLocked) - // group.votes = numVotes.toString() - // } - - return Object.values(groups); + const eligibleGroups = votes.result[0]; + const groupVotes = votes.result[1]; + const totalVotes = bigIntSum(groupVotes); + const totalLocked = locked.result; + return { eligibleGroups, groupVotes, totalLocked, totalVotes }; } -// Just fetch latest vote counts, not the entire groups + validators info set -// async function fetchValidatorGroupVotes(groups: ValidatorGroup[]) { -// let totalValidators = groups.reduce((total, g) => total + Object.keys(g.members).length, 0) -// // Only bother to fetch actual num validators on the off chance there are fewer members than MAX -// if (totalValidators < MAX_NUM_ELECTABLE_VALIDATORS) { -// const validators = getContract(CeloContract.Validators) -// const validatorAddrs: string[] = await validators.getRegisteredValidators() -// totalValidators = validatorAddrs.length -// } - -// // Fetch vote-related details about the validator groups -// const { eligibleGroups, groupVotes, totalLocked } = await fetchVotesAndTotalLocked() - -// // Create map from list provided -// const groupsMap: Record = {} -// for (const group of groups) { -// groupsMap[group.address] = { ...group } -// } - -// // Process vote-related details about the validator groups -// for (let i = 0; i < eligibleGroups.length; i++) { -// const groupAddr = eligibleGroups[i] -// const numVotes = groupVotes[i] -// const group = groupsMap[groupAddr] -// if (!group) { -// logger.warn('No group found matching votes, group list must be stale') -// continue -// } -// group.eligible = true -// group.capacity = getValidatorGroupCapacity(group, totalValidators, totalLocked) -// group.votes = numVotes.toString() -// } -// return Object.values(groupsMap) -// } - -// async function fetchVotesAndTotalLocked() { -// const lockedGold = getContract(CeloContract.LockedGold) -// const election = getContract(CeloContract.Election) -// const votesP: Promise = election.getTotalVotesForEligibleValidatorGroups() -// const totalLockedP: Promise = lockedGold.getTotalLockedGold() -// const [votes, totalLocked] = await Promise.all([votesP, totalLockedP]) -// const eligibleGroups = votes[0] -// const groupVotes = votes[1] -// return { eligibleGroups, groupVotes, totalLocked } -// } - -// function getValidatorGroupCapacity( -// group: ValidatorGroup, -// totalValidators: number, -// totalLocked: BigNumberish -// ) { -// const numMembers = Object.keys(group.members).length -// return BigNumber.from(totalLocked) -// .mul(numMembers + 1) -// .div(Math.min(MAX_NUM_ELECTABLE_VALIDATORS, totalValidators)) -// .toString() -// } +function getValidatorGroupCapacity( + group: ValidatorGroup, + totalValidators: number, + totalLocked: bigint, +): bigint { + const numMembers = Object.keys(group.members).length; + return BigInt( + BigNumber(totalLocked.toString()) + .times(numMembers + 1) + .div(Math.min(MAX_NUM_ELECTABLE_VALIDATORS, totalValidators)) + .toFixed(0), + ); +} diff --git a/src/features/validators/types.ts b/src/features/validators/types.ts index eb5a23d..2c21174 100644 --- a/src/features/validators/types.ts +++ b/src/features/validators/types.ts @@ -5,8 +5,8 @@ export interface ValidatorGroup { name: string; url: string; eligible: boolean; - capacity: string; - votes: string; + capacity: bigint; + votes: bigint; members: Record; } @@ -18,7 +18,7 @@ export enum ValidatorStatus { export interface Validator { address: Address; name: string; - score: string; + score: bigint; signer: Address; status: ValidatorStatus; } @@ -30,19 +30,6 @@ export enum ValidatorGroupStatus { Good = 2, } -export interface ValidatorGroupTableRow { - id: string; - address: Address; - name: string; - url: string; - members: Record; - numElected: number; - numMembers: number; - votes: number; - percent: number; - status: ValidatorGroupStatus; -} - export enum StakeActionType { Vote = 'vote', Activate = 'activate', diff --git a/src/utils/amount.ts b/src/utils/amount.ts index 336d434..926b70e 100644 --- a/src/utils/amount.ts +++ b/src/utils/amount.ts @@ -10,7 +10,7 @@ const DEFAULT_TOKEN_DECIMALS = 18; * @returns Converted value in string type. */ export function fromWei( - value: BigNumber.Value | null | undefined, + value: BigNumber.Value | bigint | null | undefined, decimals = DEFAULT_TOKEN_DECIMALS, ): string { if (!value) return (0).toString(); @@ -27,12 +27,12 @@ export function fromWei( * @returns Converted value in string type. */ export function fromWeiRounded( - value: BigNumber.Value | null | undefined, + value: BigNumber.Value | bigint | null | undefined, decimals = DEFAULT_TOKEN_DECIMALS, displayDecimals?: number, ): string { if (!value) return '0'; - const flooredValue = BigNumber(value).toFixed(0, BigNumber.ROUND_FLOOR); + const flooredValue = BigNumber(value.toString()).toFixed(0, BigNumber.ROUND_FLOOR); const amount = BigNumber(formatUnits(BigInt(flooredValue), decimals)); if (amount.isZero()) return '0'; displayDecimals ??= amount.gte(10000) ? 2 : DEFAULT_DISPLAY_DECIMALS; diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 0000000..94eb088 --- /dev/null +++ b/src/utils/math.ts @@ -0,0 +1,21 @@ +import BigNumber from 'bignumber.js'; + +export function sum(a: readonly number[]): number { + return a.reduce((acc, val) => acc + val); +} + +export function mean(a: readonly number[]): number { + return Number(sum(a)) / a.length; +} + +export function bigIntSum(a: readonly number[] | readonly bigint[]): bigint { + return BigInt(a.reduce((acc, val) => acc.plus(val.toString()), new BigNumber(0)).toFixed(0)); +} + +export function bigIntMax(...args: bigint[]): bigint { + return args.reduce((m, e) => (e > m ? e : m)); +} + +export function bigIntMin(...args: bigint[]): bigint { + return args.reduce((m, e) => (e < m ? e : m)); +} diff --git a/src/utils/objects.ts b/src/utils/objects.ts index 618ae60..bc0ef2a 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -37,6 +37,10 @@ export function objFilter( >; } +export function objLength(obj: Record) { + return Object.keys(obj).length; +} + // promiseObjectAll :: {k: Promise a} -> Promise {k: a} export function promiseObjAll(obj: { [key in K]: Promise; diff --git a/src/vendor/polyfill.js b/src/vendor/polyfill.js new file mode 100644 index 0000000..2f626bc --- /dev/null +++ b/src/vendor/polyfill.js @@ -0,0 +1,3 @@ +BigInt.prototype.toJSON = function () { + return this.toString(); +}; \ No newline at end of file