diff --git a/next.config.js b/next.config.js index b50cee1..efba211 100644 --- a/next.config.js +++ b/next.config.js @@ -26,7 +26,7 @@ const securityHeaders = [ key: 'Content-Security-Policy', value: `default-src 'self'; script-src 'self'${ isDev ? " 'unsafe-eval' 'unsafe-inline'" : '' - }; connect-src 'self' https://*.celo.org https://*.celoscan.io https://*.walletconnect.com wss://walletconnect.celo.org wss://*.walletconnect.com wss://*.walletconnect.org https://raw.githubusercontent.com; img-src 'self' data: https://raw.githubusercontent.com https://*.walletconnect.com; style-src 'self' 'unsafe-inline' https://*.googleapis.com; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-src 'self' https://*.walletconnect.com https://*.walletconnect.org;`, + }; connect-src 'self' https://*.celo.org https://*.celoscan.io https://*.walletconnect.com wss://walletconnect.celo.org wss://*.walletconnect.com wss://*.walletconnect.org https://raw.githubusercontent.com https://celo-mainnet.infura.io; img-src 'self' data: https://raw.githubusercontent.com https://*.walletconnect.com; style-src 'self' 'unsafe-inline' https://*.googleapis.com; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-src 'self' https://*.walletconnect.com https://*.walletconnect.org;`, }, ] diff --git a/src/app/staking/[address]/page.tsx b/src/app/staking/[address]/page.tsx index 376fb68..2378ab6 100644 --- a/src/app/staking/[address]/page.tsx +++ b/src/app/staking/[address]/page.tsx @@ -10,6 +10,7 @@ import { OutlineButton } from 'src/components/buttons/OutlineButton'; import { SolidButton } from 'src/components/buttons/SolidButton'; import { TabHeaderButton } from 'src/components/buttons/TabHeaderButton'; import { TextLink } from 'src/components/buttons/TextLink'; +import { HeatmapSquares } from 'src/components/charts/Heatmap'; import { ArrowIcon } from 'src/components/icons/Arrow'; import { Circle } from 'src/components/icons/Circle'; import { Identicon } from 'src/components/icons/Identicon'; @@ -17,18 +18,22 @@ import { Section } from 'src/components/layout/Section'; import { Twitter } from 'src/components/logos/Twitter'; import { Web } from 'src/components/logos/Web'; import { formatNumberString } from 'src/components/numbers/Amount'; -import { ZERO_ADDRESS } from 'src/config/consts'; +import { EPOCH_DURATION_MS, ZERO_ADDRESS } from 'src/config/consts'; import { VALIDATOR_GROUPS } from 'src/config/validators'; import { ValidatorGroupLogo } from 'src/features/validators/ValidatorGroupLogo'; import { ValidatorGroup, ValidatorStatus } from 'src/features/validators/types'; +import { useGroupRewardHistory } from 'src/features/validators/useGroupRewardHistory'; import { useValidatorGroups } from 'src/features/validators/useValidatorGroups'; import { useValidatorStakers } from 'src/features/validators/useValidatorStakers'; import { Color } from 'src/styles/Color'; import { useIsMobile } from 'src/styles/mediaQueries'; import { eqAddressSafe, shortenAddress } from 'src/utils/addresses'; import { fromWei, fromWeiRounded } from 'src/utils/amount'; +import { useCopyHandler } from 'src/utils/clipboard'; import { objLength } from 'src/utils/objects'; +const HEATMAP_SIZE = 100; + export const dynamicParams = true; export default function Page({ params: { address } }: { params: { address: Address } }) { @@ -47,8 +52,9 @@ export default function Page({ params: { address } }: { params: { address: Addre return (
-
+
+
@@ -60,6 +66,8 @@ function HeaderSection({ group }: { group?: ValidatorGroup }) { const webUrl = VALIDATOR_GROUPS[address]?.url; const twitterUrl = VALIDATOR_GROUPS[address]?.twitter; + const onClickAddress = useCopyHandler(group?.address); + return (
@@ -74,7 +82,7 @@ function HeaderSection({ group }: { group?: ValidatorGroup }) {

{group?.name || '...'}

- + {shortenAddress(address)} {webUrl && ( @@ -102,6 +110,41 @@ function HeaderSection({ group }: { group?: ValidatorGroup }) { ); } +function HeatmapSection({ group }: { group?: ValidatorGroup }) { + const { rewardHistory } = useGroupRewardHistory(group?.address, HEATMAP_SIZE); + + const data = useMemo(() => { + const hasReward = Array(HEATMAP_SIZE).fill(false); + if (!rewardHistory?.length) return hasReward; + const startTimestamp = Date.now() - EPOCH_DURATION_MS * HEATMAP_SIZE; + for (let i = 0; i < rewardHistory.length; i++) { + if (rewardHistory[i].timestamp < startTimestamp) continue; + const epochIndex = Math.floor( + (rewardHistory[i].timestamp - startTimestamp) / EPOCH_DURATION_MS, + ); + hasReward[epochIndex] = true; + } + return hasReward; + }, [rewardHistory]); + + return ( +
+

Reward payments (last 100 days)

+ +
+
+
+ +
+
+
+ +
+
+
+ ); +} + function DetailsSection({ group }: { group?: ValidatorGroup }) { const [tab, setTab] = useState<'members' | 'stakers'>('members'); diff --git a/src/components/charts/Heatmap.tsx b/src/components/charts/Heatmap.tsx new file mode 100644 index 0000000..920dc2b --- /dev/null +++ b/src/components/charts/Heatmap.tsx @@ -0,0 +1,32 @@ +import clsx from 'clsx'; + +// A simple grid of squares similar to Github's contribution graph +export function HeatmapSquares({ + rows, + columns, + data, +}: { + rows: number; + columns: number; + data: any[]; +}) { + return ( +
+ {data.map((value, index) => ( +
+ ))} +
+ ); +} diff --git a/src/components/nav/Footer.tsx b/src/components/nav/Footer.tsx index 230e4cb..85d16ab 100644 --- a/src/components/nav/Footer.tsx +++ b/src/components/nav/Footer.tsx @@ -1,4 +1,5 @@ import Image from 'next/image'; +import { ExternalLink } from 'src/components/buttons/ExternalLink'; import { links } from 'src/config/links'; import Discord from 'src/images/logos/discord.svg'; import Github from 'src/images/logos/github.svg'; @@ -13,7 +14,13 @@ export function Footer() {
- +
+
+ Powered by CeloScan and{' '} + Forno | +
+ +
); } diff --git a/src/config/config.ts b/src/config/config.ts index 5b66931..a32fc72 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -4,6 +4,7 @@ interface Config { walletConnectProjectId: string; fornoApiKey: string; celoscanApiKey: string; + infuraApiKey: string; } const isDevMode = process?.env?.NODE_ENV === 'development'; @@ -11,6 +12,7 @@ const version = process?.env?.NEXT_PUBLIC_VERSION ?? null; const walletConnectProjectId = process?.env?.NEXT_PUBLIC_WALLET_CONNECT_ID || ''; const fornoApiKey = process?.env?.NEXT_PUBLIC_FORNO_API_KEY || ''; const celoscanApiKey = process?.env?.NEXT_PUBLIC_CELOSCAN_API_KEY || ''; +const infuraApiKey = process?.env?.NEXT_PUBLIC_INFURA_API_KEY || ''; export const config: Config = Object.freeze({ debug: isDevMode, @@ -18,4 +20,5 @@ export const config: Config = Object.freeze({ walletConnectProjectId, fornoApiKey, celoscanApiKey, + infuraApiKey, }); diff --git a/src/config/consts.ts b/src/config/consts.ts index e512908..8c30949 100644 --- a/src/config/consts.ts +++ b/src/config/consts.ts @@ -1,6 +1,8 @@ export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; export const DEFAULT_DISPLAY_DECIMALS = 2; export const DEFAULT_TOKEN_DECIMALS = 18; +export const AVG_BLOCK_TIMES_MS = 5_000; // 5 seconds +export const EPOCH_DURATION_MS = 86_400_000; // 1 day // From the Election contract electableValidators config export const MAX_NUM_ELECTABLE_VALIDATORS = 110; diff --git a/src/config/links.ts b/src/config/links.ts index 00dfd94..3b4d242 100644 --- a/src/config/links.ts +++ b/src/config/links.ts @@ -1,10 +1,14 @@ export const links = { home: 'https://celostation.org', celo: 'https://celo.org', - blockscout: 'https://explorer.celo.org', - celoscan: 'https://celoscan.io', - celoscanApi: 'https://api.celoscan.io', discord: 'https://discord.gg/celo', github: 'https://github.com/jmrossy/celo-station', twitter: 'https://twitter.com/CeloOrg', + // RPCs + forno: 'https://forno.celo.org', + infura: 'https://celo-mainnet.infura.io/v3', + // Explorers + blockscout: 'https://explorer.celo.org', + celoscan: 'https://celoscan.io', + celoscanApi: 'https://api.celoscan.io', }; diff --git a/src/config/wagmi.tsx b/src/config/wagmi.tsx index aba81fa..ac1c17b 100644 --- a/src/config/wagmi.tsx +++ b/src/config/wagmi.tsx @@ -8,28 +8,33 @@ import { trustWallet, walletConnectWallet, } from '@rainbow-me/rainbowkit/wallets'; +import { infuraProvider } from '@wagmi/core/providers/infura'; import { jsonRpcProvider } from '@wagmi/core/providers/jsonRpc'; import 'react-toastify/dist/ReactToastify.css'; import { config } from 'src/config/config'; +import { links } from 'src/config/links'; import { Color } from 'src/styles/Color'; import { WagmiConfig, configureChains, createConfig } from 'wagmi'; -const fornoRpc = 'https://forno.celo.org?apikey=${config.fornoApiKey}'; -const infuraRpc = 'https://mainnet.infura.io/v3'; +export const fornoRpcUrl = `${links.forno}?apikey=${config.fornoApiKey}`; +export const infuraRpcUrl = `${links.infura}/${config.infuraApiKey}`; const { chains, publicClient } = configureChains( [ { ...Celo, rpcUrls: { - default: { http: [fornoRpc] }, - infura: { http: [infuraRpc] }, - public: { http: [fornoRpc] }, + default: { http: [fornoRpcUrl] }, + infura: { http: [links.infura] }, + public: { http: [fornoRpcUrl] }, }, }, ], - [jsonRpcProvider({ rpc: (chain) => ({ http: chain.rpcUrls.default.http[0] }) })], + [ + jsonRpcProvider({ rpc: (chain) => ({ http: chain.rpcUrls.default.http[0] }) }), + infuraProvider({ apiKey: config.infuraApiKey }), + ], + {}, ); - export const wagmiChains = chains; const connectorConfig = { @@ -65,7 +70,7 @@ export function WagmiContext({ children }: { children: React.ReactNode }) { return ( void; } +// TODO is a store needed? export const useStore = create()( persist( (set) => ({ diff --git a/src/features/validators/useGroupRewardHistory.ts b/src/features/validators/useGroupRewardHistory.ts new file mode 100644 index 0000000..ef475ca --- /dev/null +++ b/src/features/validators/useGroupRewardHistory.ts @@ -0,0 +1,115 @@ +import { electionABI } from '@celo/abis'; +import { useQuery } from '@tanstack/react-query'; +import { useToastError } from 'src/components/notifications/useToastError'; +import { EPOCH_DURATION_MS } from 'src/config/consts'; +import { Addresses } from 'src/config/contracts'; +import { links } from 'src/config/links'; +import { infuraRpcUrl, wagmiChains } from 'src/config/wagmi'; +import { queryCeloscan } from 'src/features/explorers/celoscan'; +import { logger } from 'src/utils/logger'; +import { createPublicClient, decodeEventLog, http, parseAbiItem } from 'viem'; +import { PublicClient, usePublicClient } from 'wagmi'; + +const REWARD_DISTRIBUTED_ABI_FRAGMENT = + 'event EpochRewardsDistributedToVoters(address indexed group, uint256 value)'; + +export function useGroupRewardHistory(group?: Address, epochs?: number) { + const publicClient = usePublicClient(); + const { isLoading, isError, error, data } = useQuery({ + queryKey: ['useGroupRewardHistory', group, epochs, publicClient], + queryFn: () => { + if (!group || !epochs) return null; + logger.debug(`Fetching reward history for group ${group}`); + return fetchValidatorGroupRewardHistory(group, epochs, publicClient); + }, + gcTime: Infinity, + staleTime: 60 * 60 * 1000, // 1 hour + }); + + useToastError(error, 'Error fetching group reward history'); + + return { + isLoading, + isError, + rewardHistory: data || undefined, + }; +} + +async function fetchValidatorGroupRewardHistory( + group: Address, + epochs: number, + _publicClient: PublicClient, +): Promise> { + // Get block number of epoch to start from + const startTimestamp = Math.floor((Date.now() - (epochs + 1) * EPOCH_DURATION_MS) / 1000); + const blockQueryUrl = `${links.celoscanApi}/api?module=block&action=getblocknobytime×tamp=${startTimestamp}&closest=before`; + const blockNumberStr = await queryCeloscan(blockQueryUrl); + const startingBlockNumber = parseInt(blockNumberStr); + + // NOTE(Rossy): I initially tried using celoscan and blockscout to fetch the reward + // logs but neither supplied them. It must be a bug related to something special about + // rewards on Celo. Forno can't provide logs for such a large window so instead I + // hack together a batch-enabled infra provider for this query. + // Batch is required to fetch the block timestamps en-masse quickly. + + // const topics = encodeEventTopics({ + // abi: electionABI, + // eventName: 'EpochRewardsDistributedToVoters', + // args: { group }, + // }); + // const topics = encodeEventTopics({ + // abi: validatorsABI, + // eventName: 'ValidatorEpochPaymentDistributed', + // args: { group }, + // }); + // const rewardLogsUrl = `${links.celoscanApi}/api?module=logs&action=getLogs&fromBlock=${startingBlockNumber}&toBlock=latest&address=${Addresses.Election}&topic0=${topics[0]}&topic1=${topics[1]}&topic0_1_opr=and`; + // const rewardLogs = await queryCeloscan(rewardLogsUrl); + + const infuraBatchTransport = http(infuraRpcUrl, { + batch: { wait: 100, batchSize: 100 }, + }); + const infuraBatchClient = createPublicClient({ + chain: wagmiChains[0], + transport: infuraBatchTransport, + }); + + const rewardLogs = await infuraBatchClient.getLogs({ + address: Addresses.Election, + fromBlock: BigInt(startingBlockNumber), + toBlock: 'latest', + event: parseAbiItem(REWARD_DISTRIBUTED_ABI_FRAGMENT), + args: { group }, + }); + + const rewards: Array<{ blockNumber: number; reward: bigint }> = []; + for (const log of rewardLogs) { + try { + if (!log.topics?.length || log.topics.length < 2) continue; + const { eventName, args } = decodeEventLog({ + abi: electionABI, + data: log.data, + // @ts-ignore https://github.com/wevm/viem/issues/381 + topics: log.topics, + strict: false, + }); + if (eventName !== 'EpochRewardsDistributedToVoters' || !args.value) continue; + rewards.push({ + blockNumber: Number(log.blockNumber), + reward: args.value, + }); + } catch (error) { + logger.warn('Error decoding event log', error, log); + } + } + + // GetBlock calls required to get the timestamps for each block :( + const blockDetails = await Promise.all( + rewards.map((r) => infuraBatchClient.getBlock({ blockNumber: BigInt(r.blockNumber) })), + ); + const rewardsWithTimestamps = rewards.map((r, i) => ({ + ...r, + timestamp: Number(blockDetails[i].timestamp) * 1000, + })); + + return rewardsWithTimestamps.sort((a, b) => a.blockNumber - b.blockNumber); +} diff --git a/src/features/validators/useValidatorStakers.ts b/src/features/validators/useValidatorStakers.ts index b1dcffb..60f4a15 100644 --- a/src/features/validators/useValidatorStakers.ts +++ b/src/features/validators/useValidatorStakers.ts @@ -79,14 +79,14 @@ function reduceLogs( logs: TransactionLog[], isAdd: boolean, ) { - for (const event of logs) { + for (const log of logs) { try { - if (!event.topics || event.topics.length < 3) continue; + if (!log.topics || log.topics.length < 3) continue; const { eventName, args } = decodeEventLog({ abi: electionABI, - data: event.data, + data: log.data, // @ts-ignore https://github.com/wevm/viem/issues/381 - topics: event.topics, + topics: log.topics, strict: false, }); if ( @@ -104,7 +104,7 @@ function reduceLogs( stakerToVotes[staker] = (stakerToVotes[staker] ?? 0) - value; } } catch (error) { - logger.warn('Error decoding event log', error, event); + logger.warn('Error decoding event log', error, log); } } } diff --git a/src/features/wallet/WalletDropdown.tsx b/src/features/wallet/WalletDropdown.tsx index 48a3265..594effb 100644 --- a/src/features/wallet/WalletDropdown.tsx +++ b/src/features/wallet/WalletDropdown.tsx @@ -1,6 +1,5 @@ import { useConnectModal } from '@rainbow-me/rainbowkit'; import Link from 'next/link'; -import { toast } from 'react-toastify'; import { OutlineButton } from 'src/components/buttons/OutlineButton'; import { SolidButton } from 'src/components/buttons/SolidButton'; import { Identicon } from 'src/components/icons/Identicon'; @@ -9,7 +8,7 @@ import { Amount } from 'src/components/numbers/Amount'; import { useStakingRewards } from 'src/features/staking/rewards/useStakingRewards'; import { useStakingBalances } from 'src/features/staking/useStakingBalances'; import { shortenAddress } from 'src/utils/addresses'; -import { tryClipboardSet } from 'src/utils/clipboard'; +import { useCopyHandler } from 'src/utils/clipboard'; import { useAccount, useDisconnect } from 'wagmi'; import { useBalance, useLockedBalance } from '../account/hooks'; @@ -41,6 +40,7 @@ export function WalletDropdown() { } function DropdownContent({ address, disconnect }: { address: Address; disconnect: () => void }) { + // TODO only run if content is open: https://github.com/saadeghi/daisyui/discussions/2697 // TODO update these hooks with a refetch interval after upgrading to wagmi v2 const { balance: walletBalance } = useBalance(address); const { balance: lockedBalance } = useLockedBalance(address); @@ -49,11 +49,7 @@ function DropdownContent({ address, disconnect }: { address: Address; disconnect const totalBalance = (walletBalance?.value || 0n) + (lockedBalance?.value || 0n); - const onClickCopy = async () => { - if (!address) return; - await tryClipboardSet(address); - toast.success('Address copied to clipboard', { autoClose: 1200 }); - }; + const onClickCopy = useCopyHandler(address); return (
diff --git a/src/styles/Color.ts b/src/styles/Color.ts index 5d3df45..3b928c0 100644 --- a/src/styles/Color.ts +++ b/src/styles/Color.ts @@ -12,7 +12,7 @@ export enum Color { Wood = '#655947', Lavender = '#B490FF', Fig = '#1E002B', - Green = '#56DF7C', + Jade = '#56DF7C', Forest = '#476520', Citrus = '#FF9A51', Lotus = '#FFA3EB', diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts index ef6e016..4902b5e 100644 --- a/src/utils/clipboard.ts +++ b/src/utils/clipboard.ts @@ -1,3 +1,5 @@ +import { useCallback } from 'react'; +import { toast } from 'react-toastify'; import { logger } from './logger'; export function isClipboardReadSupported() { @@ -24,3 +26,11 @@ export async function tryClipboardGet() { return null; } } + +export function useCopyHandler(value?: string) { + return useCallback(async () => { + if (!value) return; + const result = await tryClipboardSet(value); + if (result) toast.success('Copied to clipboard', { autoClose: 1200 }); + }, [value]); +} diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts new file mode 100644 index 0000000..b1190b0 --- /dev/null +++ b/src/utils/numbers.ts @@ -0,0 +1,9 @@ +import BigNumber from 'bignumber.js'; + +export function toDecimal(value: number | string | bigint): number { + return BigNumber(value.toString()).toNumber(); +} + +export function toHex(value: number | string | bigint): HexString { + return BigNumber(value.toString()).decimalPlaces(0).toString(16) as HexString; +} diff --git a/src/utils/time.ts b/src/utils/time.ts index e7683db..343a188 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,7 +1,3 @@ -export function isStale(lastUpdated: number | null, staleTime: number) { - return !lastUpdated || Date.now() - lastUpdated > staleTime; -} - export function areDatesSameDay(d1: Date, d2: Date) { return ( d1.getDate() === d2.getDate() && diff --git a/tailwind.config.js b/tailwind.config.js index 754794c..f55319a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -26,6 +26,7 @@ module.exports = { green: { 200: '#BEEAA9', 500: '#56DF7C', // Jade + 700: '#476520', // Forest }, taupe: { 100: '#FCF6F1', // Gypsum