diff --git a/components/Account/index.tsx b/components/Account/index.tsx index e295376b..28afdcc5 100644 --- a/components/Account/index.tsx +++ b/components/Account/index.tsx @@ -1,5 +1,5 @@ -import { formatAddress } from "@lib/utils"; import { Box, Flex, Link as A } from "@livepeer/design-system"; +import { formatAddress } from "@utils/web3"; import { useAccountAddress, useEnsData } from "hooks"; import Link from "next/link"; import { useRouter } from "next/router"; diff --git a/components/AccountCell/index.tsx b/components/AccountCell/index.tsx index 43f86c15..cd9659aa 100644 --- a/components/AccountCell/index.tsx +++ b/components/AccountCell/index.tsx @@ -1,5 +1,6 @@ -import { formatAddress, textTruncate } from "@lib/utils"; +import { textTruncate } from "@lib/utils"; import { Box, Flex } from "@livepeer/design-system"; +import { formatAddress } from "@utils/web3"; import { useEnsData } from "hooks"; import { QRCodeCanvas } from "qrcode.react"; diff --git a/components/Claim/index.tsx b/components/Claim/index.tsx index 016dbc52..31114f9e 100644 --- a/components/Claim/index.tsx +++ b/components/Claim/index.tsx @@ -1,9 +1,9 @@ import { LAYOUT_MAX_WIDTH } from "@layouts/constants"; import { l2Migrator } from "@lib/api/abis/bridge/L2Migrator"; import { getL2MigratorAddress } from "@lib/api/contracts"; -import { formatAddress } from "@lib/utils"; import { Box, Button, Container, Flex, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { formatAddress } from "@utils/web3"; import { constants, ethers } from "ethers"; import { useAccountAddress, useL1DelegatorData } from "hooks"; import { useHandleTransaction } from "hooks/useHandleTransaction"; diff --git a/components/DelegatingView/index.tsx b/components/DelegatingView/index.tsx index 0f32a1b9..8eec07a9 100644 --- a/components/DelegatingView/index.tsx +++ b/components/DelegatingView/index.tsx @@ -1,9 +1,10 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import Stat from "@components/Stat"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; -import { checkAddressEquality, formatAddress } from "@lib/utils"; import { Box, Button, Flex, Link as A, Text } from "@livepeer/design-system"; import { QuestionMarkCircledIcon } from "@modulz/radix-icons"; +import { formatETH, formatLPT, formatPercent } from "@utils/numberFormatters"; +import { checkAddressEquality, formatAddress } from "@utils/web3"; import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo"; import { useAccountAddress, @@ -14,7 +15,6 @@ import { useBondingManagerAddress } from "hooks/useContracts"; import { useHandleTransaction } from "hooks/useHandleTransaction"; import Link from "next/link"; import { useRouter } from "next/router"; -import numbro from "numbro"; import { useMemo } from "react"; import Masonry from "react-masonry-css"; import { Address } from "viem"; @@ -104,6 +104,22 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { [pendingFees] ); + const networkEquity = (() => { + if (!totalActiveStake) { + return 0; + } + + // If self-delegating, include total delegated stake + pending stake + if (delegator?.delegate?.id === delegator?.id) { + const delegateTotalStake = Math.abs( + +(delegator?.delegate?.totalStake ?? 0) + ); + return (delegateTotalStake + pendingStake) / totalActiveStake; + } + + return pendingStake / totalActiveStake; + })(); + if (!delegator?.bondedAmount) { if (isMyAccount) { return ( @@ -185,11 +201,9 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { fontSize: 26, }} > - {`${numbro(pendingStake).format( - pendingStake > 0 && pendingStake < 0.01 - ? { mantissa: 4, trimMantissa: true } - : { mantissa: 2, average: true, lowPrecision: false } - )} LPT`} + {`${formatLPT(pendingStake, { + precision: pendingStake > 0 && pendingStake < 0.01 ? 4 : 2, + })}`} ) : null } @@ -224,12 +238,7 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { {unbonded > 0 ? ( - {numbro(-unbonded).format({ - mantissa: 2, - average: true, - forceSign: true, - })}{" "} - LPT + {formatLPT(-unbonded, { forceSign: true, precision: 2 })} ) : ( @@ -258,12 +267,10 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { - {numbro(Math.abs(rewards)).format({ - mantissa: 2, - average: true, + {formatLPT(Math.abs(rewards), { forceSign: true, - })}{" "} - LPT + precision: 2, + })} @@ -280,10 +287,7 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { fontSize: 26, }} > - {numbro(pendingFees).format({ - mantissa: 3, - })}{" "} - ETH + {formatETH(pendingFees, { precision: 3 })} ) : null } @@ -316,11 +320,10 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { - {numbro(lifetimeEarnings || 0).format({ - mantissa: 3, - average: true, - })}{" "} - ETH + {formatETH(lifetimeEarnings || 0, { + abbreviate: true, + precision: 3, + })} { - {numbro(delegator?.withdrawnFees || 0).format({ - mantissa: 3, - average: true, - })}{" "} - ETH + {formatETH(delegator?.withdrawnFees, { + abbreviate: true, + precision: 3, + })} {isMyAccount && !withdrawButtonDisabled && delegator?.id && ( @@ -396,22 +398,7 @@ const Index = ({ delegator, transcoders, protocol, currentRound }: Props) => { } - value={ - - {numbro( - totalActiveStake === 0 - ? 0 - : delegator.delegate.id === delegator.id - ? (Math.abs(+delegator.delegate.totalStake) + - pendingStake) / - totalActiveStake - : pendingStake / totalActiveStake - ).format({ - output: "percent", - mantissa: 3, - })} - - } + value={{formatPercent(networkEquity, { precision: 3 })}} meta={ { > Account ( - {numbro( + {formatPercent( totalActiveStake === 0 ? 0 - : pendingStake / totalActiveStake - ).format({ - output: "percent", - mantissa: 2, - })} + : pendingStake / totalActiveStake, + { precision: 2 } + )} ) - {numbro(pendingStake).format({ - mantissa: 2, - average: true, - })}{" "} - LPT + {formatLPT(pendingStake, { precision: 2 })} { > Orchestrator ( - {numbro( + {formatPercent( totalActiveStake === 0 ? 0 : Math.abs(+delegator.delegate.totalStake) / - totalActiveStake - ).format({ - output: "percent", - mantissa: 2, - })} + totalActiveStake, + { precision: 2 } + )} ) - {numbro(Math.abs(+delegator.delegate.totalStake)).format({ - mantissa: 2, - average: true, - })}{" "} - LPT + {formatLPT(Math.abs(+delegator.delegate.totalStake), { + precision: 2, + abbreviate: true, + })} diff --git a/components/DelegatingWidget/Header.tsx b/components/DelegatingWidget/Header.tsx index b808c216..c9e071d1 100644 --- a/components/DelegatingWidget/Header.tsx +++ b/components/DelegatingWidget/Header.tsx @@ -1,6 +1,6 @@ import { EnsIdentity } from "@lib/api/types/get-ens"; -import { formatAddress } from "@lib/utils"; import { Box, Flex, Heading } from "@livepeer/design-system"; +import { formatAddress } from "@utils/web3"; import { QRCodeCanvas } from "qrcode.react"; import { TranscoderOrDelegateType } from "."; diff --git a/components/DelegatingWidget/Input.tsx b/components/DelegatingWidget/Input.tsx index b1fe3507..b7370224 100644 --- a/components/DelegatingWidget/Input.tsx +++ b/components/DelegatingWidget/Input.tsx @@ -1,5 +1,9 @@ import { calculateROI } from "@lib/roi"; import { Box } from "@livepeer/design-system"; +import { + PERCENTAGE_PRECISION_BILLION, + PERCENTAGE_PRECISION_MILLION, +} from "@utils/web3"; import { useExplorerStore } from "hooks"; import { useEffect, useMemo } from "react"; import { useWindowSize } from "react-use"; @@ -36,19 +40,20 @@ const Input = ({ }, feeParams: { ninetyDayVolumeETH: Number(transcoder.ninetyDayVolumeETH), - feeShare: Number(transcoder.feeShare) / 1000000, + feeShare: Number(transcoder.feeShare) / PERCENTAGE_PRECISION_MILLION, lptPriceEth: Number(protocol.lptPriceEth), }, rewardParams: { - inflation: Number(protocol.inflation) / 1000000000, + inflation: Number(protocol.inflation) / PERCENTAGE_PRECISION_BILLION, inflationChangePerRound: - Number(protocol.inflationChange) / 1000000000, + Number(protocol.inflationChange) / PERCENTAGE_PRECISION_BILLION, totalSupply: Number(protocol.totalSupply), totalActiveStake: Number(protocol.totalActiveStake), roundLength: Number(protocol.roundLength), rewardCallRatio, - rewardCut: Number(transcoder.rewardCut) / 1000000, + rewardCut: + Number(transcoder.rewardCut) / PERCENTAGE_PRECISION_MILLION, treasuryRewardCut: treasury.treasuryRewardCutRate, }, }), diff --git a/components/DelegatingWidget/InputBox.tsx b/components/DelegatingWidget/InputBox.tsx index 88c962e9..fe48fd28 100644 --- a/components/DelegatingWidget/InputBox.tsx +++ b/components/DelegatingWidget/InputBox.tsx @@ -1,8 +1,8 @@ import { TranscoderOrDelegateType } from "@components/DelegatingWidget"; import { ExplorerTooltip } from "@components/ExplorerTooltip"; import { EnsIdentity } from "@lib/api/types/get-ens"; -import { fromWei } from "@lib/utils"; import { Box, Card, Flex } from "@livepeer/design-system"; +import { fromWei } from "@utils/web3"; import { AccountQueryResult } from "apollo"; import { StakingAction, diff --git a/components/DelegatingWidget/ProjectionBox.tsx b/components/DelegatingWidget/ProjectionBox.tsx index 4850e6ed..17181df3 100644 --- a/components/DelegatingWidget/ProjectionBox.tsx +++ b/components/DelegatingWidget/ProjectionBox.tsx @@ -1,21 +1,20 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import { Box, Card, Flex, Text } from "@livepeer/design-system"; import { QuestionMarkCircledIcon } from "@modulz/radix-icons"; +import { formatETH, formatLPT, formatPercent } from "@utils/numberFormatters"; import { useExplorerStore } from "hooks"; -import numbro from "numbro"; -import { useMemo } from "react"; const ProjectionBox = ({ action }) => { const { yieldResults } = useExplorerStore(); - const formattedPrinciple = useMemo( - () => - numbro(Number(yieldResults?.principle) || 0).format({ - mantissa: 0, - average: true, - }), - [yieldResults] - ); + const formattedPrinciple = formatLPT(Number(yieldResults?.principle), { + precision: 0, + }); + + const roi = yieldResults.principle + ? (yieldResults.roiFeesLpt + yieldResults.roiRewards) / + +yieldResults.principle + : 0; return ( { {action === "delegate" && ( - {numbro( - yieldResults.principle - ? (yieldResults.roiFeesLpt + yieldResults.roiRewards) / - +yieldResults.principle - : 0 - ).format({ - mantissa: 1, - output: "percent", - })} + {formatPercent(roi, { precision: 1 })} )} @@ -110,10 +101,10 @@ const ProjectionBox = ({ action }) => { - {numbro(yieldResults.roiRewards).format({ - mantissa: 1, - })}{" "} - LPT + {formatLPT(yieldResults.roiRewards, { + precision: 1, + abbreviate: false, + })} @@ -137,10 +128,7 @@ const ProjectionBox = ({ action }) => { - {numbro(yieldResults.roiFees).format({ - mantissa: 3, - })}{" "} - ETH + {formatETH(yieldResults.roiFees, { precision: 3 })} diff --git a/components/DelegatingWidget/index.tsx b/components/DelegatingWidget/index.tsx index 813deaf6..df9b09b6 100644 --- a/components/DelegatingWidget/index.tsx +++ b/components/DelegatingWidget/index.tsx @@ -1,13 +1,13 @@ import { EnsIdentity } from "@lib/api/types/get-ens"; -import { fromWei } from "@lib/utils"; import { Box, Card, Flex, Text } from "@livepeer/design-system"; +import { formatLPT } from "@utils/numberFormatters"; +import { fromWei } from "@utils/web3"; import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo"; import { useEnsData, useExplorerStore, usePendingFeesAndStakeData, } from "hooks"; -import numbro from "numbro"; import { useMemo, useState } from "react"; import ArrowDown from "../../public/img/arrow-down.svg"; @@ -140,11 +140,10 @@ const Index = ({ {`This transaction will move your current delegated stake of `} - {numbro(currentPendingStake).format({ - mantissa: 1, - thousandSeparated: true, + {formatLPT(currentPendingStake, { + precision: 1, + abbreviate: false, })} - {` LPT`} {` from `} diff --git a/components/ExplorerChart/index.tsx b/components/ExplorerChart/index.tsx index fe52a5c0..0359e938 100644 --- a/components/ExplorerChart/index.tsx +++ b/components/ExplorerChart/index.tsx @@ -2,6 +2,12 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import dayjs from "@lib/dayjs"; import { Box, Button, Flex, Skeleton, Text } from "@livepeer/design-system"; import { QuestionMarkCircledIcon } from "@modulz/radix-icons"; +import { + formatETH, + formatNumber, + formatPercent, + formatUSD, +} from "@utils/numberFormatters"; import numbro from "numbro"; import { useCallback, useEffect, useMemo, useState } from "react"; import { @@ -98,38 +104,31 @@ const ExplorerChart = ({ .format("MMM D")}`, [grouping] ); + const formatSubtitle = useCallback( (value: number) => { - if (unit === "usd") { - return numbro(value).formatCurrency({ - mantissa: 0, - thousandSeparated: true, - }); + switch (unit) { + case "usd": + return formatUSD(value, { precision: 0 }); + case "eth": + return formatETH(value, { precision: 1 }); + case "percent": + return formatPercent(value, { precision: 1 }); + case "small-percent": + return formatPercent(value, { precision: 5 }); + case "minutes": + return ( + formatNumber(value, { precision: 0, abbreviate: true }) + " minutes" + ); + case "small-unitless": + return formatNumber(value, { precision: 1, abbreviate: true }); + default: + return formatNumber(value, { precision: 0, abbreviate: true }); } - return `${numbro(value).format( - unit === "eth" - ? { - mantissa: 1, - thousandSeparated: true, - } - : unit === "percent" - ? { - output: "percent", - mantissa: 1, - } - : unit === "small-percent" - ? { - output: "percent", - mantissa: 5, - } - : { - mantissa: 0, - thousandSeparated: true, - } - )}${unit === "minutes" ? " minutes" : unit === "eth" ? " ETH" : ""}`; }, [unit] ); + const defaultSubtitle = useMemo( () => formatSubtitle(base), [base, formatSubtitle] @@ -137,9 +136,8 @@ const ExplorerChart = ({ const defaultPercentChange = useMemo( () => basePercentChange !== 0 - ? numbro(basePercentChange / 100).format({ - output: "percent", - mantissa: 2, + ? formatPercent(basePercentChange / 100, { + precision: 2, forceSign: true, }) : "", @@ -181,38 +179,31 @@ const ExplorerChart = ({ fontWeight={400} fontSize="13px" > - {numbro(payload.value).format( - unit === "usd" - ? { - mantissa: 0, - currencySymbol: "$", - average: true, - } - : unit === "eth" - ? { - mantissa: 1, - } - : unit === "percent" - ? { - output: "percent", - mantissa: 0, - } - : unit === "small-percent" - ? { - output: "percent", - mantissa: 2, - } - : unit === "small-unitless" - ? { - mantissa: 1, - average: true, - } - : { - mantissa: 0, - average: true, - } - )} - {unit === "eth" ? " Ξ" : ""} + {(() => { + switch (unit) { + case "usd": + return formatUSD(payload.value, { + precision: 0, + abbreviate: true, + }); + case "eth": + return formatNumber(payload.value, { precision: 1 }) + " Ξ"; + case "percent": + return formatPercent(payload.value, { precision: 0 }); + case "small-percent": + return formatPercent(payload.value, { precision: 2 }); + case "small-unitless": + return formatNumber(payload.value, { + precision: 1, + abbreviate: true, + }); + default: + return formatNumber(payload.value, { + precision: 0, + abbreviate: true, + }); + } + })()} ); diff --git a/components/HistoryView/index.tsx b/components/HistoryView/index.tsx index cbc0ce70..d93736ba 100644 --- a/components/HistoryView/index.tsx +++ b/components/HistoryView/index.tsx @@ -4,7 +4,6 @@ import { Fm, parsePollIpfs } from "@lib/api/polls"; import { parseProposalText, Proposal } from "@lib/api/treasury"; import { POLL_VOTES, VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; import dayjs from "@lib/dayjs"; -import { formatAddress } from "@lib/utils"; import { Badge, Box, @@ -13,6 +12,9 @@ import { Link as A, styled, } from "@livepeer/design-system"; +import { formatETH, formatLPT, formatPercent, formatRound } from "@utils/numberFormatters"; +import { formatAddress } from "@utils/web3"; +import { PERCENTAGE_PRECISION_TEN_THOUSAND } from "@utils/web3"; import { TransactionsQuery, TreasuryVoteEvent, @@ -22,7 +24,6 @@ import { } from "apollo"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains"; import { useRouter } from "next/router"; -import numbro from "numbro"; import { useEffect, useMemo, useState } from "react"; import InfiniteScroll from "react-infinite-scroll-component"; import { catIpfsJson, IpfsPoll } from "utils/ipfs"; @@ -306,7 +307,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -315,13 +316,11 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.additionalAmount).format({ - mantissa: 1, - average: true, - })} - {" "} - LPT + {formatLPT(event.additionalAmount, { + precision: 1, + forceSign: true, + })} + @@ -356,16 +355,15 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} - Round # - {event.round.id} + Round {formatRound(event.round.id)} @@ -403,7 +401,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -412,13 +410,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.amount).format({ - mantissa: 1, - average: true, - })} - {" "} - LPT + {formatLPT(event.amount, { precision: 1, forceSign: true })} + @@ -455,7 +448,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -464,13 +457,8 @@ function renderSwitch(event, i: number) { {" "} - - - {numbro(event.amount).format({ - mantissa: 1, - average: true, - })} - {" "} - LPT + {formatLPT(-event.amount, { precision: 1, forceSign: true })} + @@ -507,7 +495,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -516,13 +504,11 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.rewardTokens).format({ - mantissa: 2, - average: true, - })} - {" "} - LPT + {formatLPT(event.rewardTokens, { + precision: 2, + forceSign: true, + })} + @@ -557,7 +543,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -566,15 +552,18 @@ function renderSwitch(event, i: number) { - {event.rewardCut / 10000}% R + {formatPercent( + event.rewardCut / PERCENTAGE_PRECISION_TEN_THOUSAND + )}{" "} + R {" "} - {(100 - event.feeShare / 10000) - .toFixed(2) - .replace(/[.,]00$/, "")} - % F + {formatPercent( + 1 - event.feeShare / PERCENTAGE_PRECISION_TEN_THOUSAND + )}{" "} + F {" "} @@ -611,7 +600,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -620,12 +609,8 @@ function renderSwitch(event, i: number) { {" "} - {numbro(event.amount).format({ - mantissa: 2, - average: true, - })} - {" "} - LPT + {formatLPT(event.amount, { precision: 2 })} + @@ -660,7 +645,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -669,12 +654,8 @@ function renderSwitch(event, i: number) { {" "} - {numbro(event.amount).format({ - mantissa: 3, - average: true, - })} - {" "} - ETH + {formatETH(event.amount, { precision: 3 })} + @@ -709,7 +690,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -718,13 +699,11 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.faceValue).format({ - mantissa: 3, - average: true, - })} - {" "} - ETH + {formatETH(event.faceValue, { + precision: 3, + forceSign: true, + })} + @@ -759,7 +738,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -768,13 +747,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.amount).format({ - mantissa: 2, - average: true, - })} - {" "} - ETH + +{formatETH(event.amount, { precision: 2 })} + @@ -814,7 +788,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -823,13 +797,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.amount).format({ - mantissa: 2, - average: true, - })} - {" "} - ETH + +{formatETH(event.amount, { precision: 2 })} + @@ -870,7 +839,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} @@ -939,7 +908,7 @@ function renderSwitch(event, i: number) { {dayjs .unix(event.transaction.timestamp) .format("MM/DD/YYYY h:mm:ss a")}{" "} - - Round #{event.round.id} + - Round {formatRound(event.round.id)} diff --git a/components/OrchestratingView/index.tsx b/components/OrchestratingView/index.tsx index f6fb506a..37b55278 100644 --- a/components/OrchestratingView/index.tsx +++ b/components/OrchestratingView/index.tsx @@ -2,6 +2,13 @@ import Stat from "@components/Stat"; import dayjs from "@lib/dayjs"; import { Box, Flex, Link as A, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon, CheckIcon, Cross1Icon } from "@modulz/radix-icons"; +import { + formatETH, + formatNumber, + formatPercent, + formatStakeAmount, +} from "@utils/numberFormatters"; +import { PERCENTAGE_PRECISION_MILLION } from "@utils/web3"; import { AccountQueryResult, OrderDirection, @@ -13,7 +20,6 @@ import { import { useScoreData } from "hooks"; import { useRegionsData } from "hooks/useSwr"; import Link from "next/link"; -import numbro from "numbro"; import { useMemo } from "react"; import Masonry from "react-masonry-css"; @@ -113,10 +119,9 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { const outputTrans = maxScore.transcoding?.score && maxScore.transcoding?.score > 0; const transcodingInfo = outputTrans - ? `${numbro(maxScore.transcoding?.score).divide(100).format({ - output: "percent", - mantissa: 1, - })} - ${maxScore.transcoding.region}` + ? `${formatPercent(maxScore.transcoding?.score)} - ${ + maxScore.transcoding.region + }` : ""; return outputTrans ? transcodingInfo : "N/A"; }, [maxScore]); @@ -128,11 +133,7 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { "N/A"; const aiInfo = outputAI ? ( <> - {numbro(maxScore.ai?.value).format({ - output: "percent", - mantissa: 1, - })}{" "} - - {region} + {formatPercent(maxScore.ai?.value)} - {region} ) : ( "" @@ -175,14 +176,7 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { tooltip={ "The total amount of stake delegated to this orchestrator (including their own self-stake)." } - value={ - transcoder - ? `${numbro(transcoder?.totalStake || 0).format({ - mantissa: 2, - average: true, - })} LPT` - : "N/A" - } + value={transcoder ? formatStakeAmount(transcoder?.totalStake) : "N/A"} /> { tooltip={ "The total amount of fees this orchestrator has earned (since the migration to Arbitrum One)." } - value={`${numbro(transcoder?.totalVolumeETH || 0).format({ - mantissa: 2, - average: true, - })} ETH`} + value={formatETH(transcoder?.totalVolumeETH)} /> {/* { tooltip={ "The number of delegators which have delegated stake to this orchestrator." } - value={`${numbro(transcoder?.delegators?.length || 0).format( - "0,0" - )}`} + value={`${formatNumber(transcoder?.delegators?.length, { + precision: 0, + })}`} /> */} { } value={ transcoder?.feeShare - ? numbro(1 - +(transcoder?.feeShare || 0) / 1000000).format({ - output: "percent", - mantissa: 0, - }) + ? formatPercent( + 1 - + +(transcoder?.feeShare || 0) / PERCENTAGE_PRECISION_MILLION + ) : "N/A" } /> @@ -271,12 +253,7 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { } value={ transcoder?.rewardCut - ? numbro(transcoder?.rewardCut || 0) - .divide(1000000) - .format({ - output: "percent", - mantissa: 0, - }) + ? formatPercent(+(transcoder?.rewardCut || 0) / 1000000) : "N/A" } /> @@ -367,7 +344,7 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { fontWeight: 500, }} > - / {govStats.eligible} Proposals + / {formatNumber(govStats.eligible, { precision: 0 })} Proposals ) : ( @@ -408,9 +385,8 @@ const Index = ({ currentRound, transcoder, isActive }: Props) => { size="2" css={{ color: "$neutral11", fontWeight: 600 }} > - {numbro(govStats.voted / govStats.eligible).format({ - output: "percent", - mantissa: 0, + {formatPercent(govStats.voted / govStats.eligible, { + precision: 0, })}{" "} Participation diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index e06ae1d7..68672f7a 100644 --- a/components/OrchestratorList/index.tsx +++ b/components/OrchestratorList/index.tsx @@ -11,7 +11,7 @@ import { ROIInflationChange, ROITimeHorizon, } from "@lib/roi"; -import { formatAddress, textTruncate } from "@lib/utils"; +import { textTruncate } from "@lib/utils"; import { Badge, Box, @@ -35,11 +35,21 @@ import { DotsHorizontalIcon, Pencil1Icon, } from "@radix-ui/react-icons"; +import { + formatETH, + formatLPT, + formatNumber, + formatPercent, +} from "@utils/numberFormatters"; +import { + formatAddress, + PERCENTAGE_PRECISION_BILLION, + PERCENTAGE_PRECISION_MILLION, +} from "@utils/web3"; import { OrchestratorsQueryResult, ProtocolQueryResult } from "apollo"; import { useEnsData } from "hooks"; import { useBondingManagerAddress } from "hooks/useContracts"; import Link from "next/link"; -import numbro from "numbro"; import { useCallback, useMemo, useState } from "react"; import { formatUnits } from "viem"; import { useReadContract } from "wagmi"; @@ -79,22 +89,22 @@ const OrchestratorList = ({ | NonNullable["transcoders"] | undefined; }) => { + // Derive protocol inflation data + const inflationRate = + Number(protocolData?.inflation || 0) / PERCENTAGE_PRECISION_BILLION; + const inflationChangeAmount = + Number(protocolData?.inflationChange || 0) / PERCENTAGE_PRECISION_BILLION; + const inflationChangeSign = + Number(protocolData?.inflationChange || 0) > 0 ? "+" : ""; + const formatPercentChange = useCallback( (change: ROIInflationChange) => change === "none" - ? `Fixed at ${numbro( - Number(protocolData?.inflation) / 1000000000 - ).format({ - mantissa: 3, - output: "percent", - })}` - : `${numbro(Number(protocolData?.inflationChange) / 1000000000).format({ - mantissa: 5, - output: "percent", - forceSign: true, + ? `Fixed at ${formatPercent(inflationRate, { precision: 3 })}` + : `${inflationChangeSign}${formatPercent(inflationChangeAmount, { + precision: 5, })} per round`, - - [protocolData?.inflation, protocolData?.inflationChange] + [inflationRate, inflationChangeAmount, inflationChangeSign] ); const [principle, setPrinciple] = useState(150); @@ -106,11 +116,10 @@ const OrchestratorList = ({ () => Math.floor(Number(protocolData?.totalSupply || 1e7)), [protocolData] ); - const formattedPrinciple = useMemo( - () => - numbro(Number(principle) || 150).format({ mantissa: 0, average: true }), - [principle] - ); + + const formattedPrinciple = formatLPT(Number(principle) || 150, { + precision: 0, + }); const { data: bondingManagerAddress } = useBondingManagerAddress(); const { data: treasuryRewardCutRate = BigInt(0.0) } = useReadContract({ query: { enabled: Boolean(bondingManagerAddress) }, @@ -156,39 +165,40 @@ const OrchestratorList = ({ }, feeParams: { ninetyDayVolumeETH: Number(row.ninetyDayVolumeETH), - feeShare: Number(row.feeShare) / 1000000, + feeShare: Number(row.feeShare) / PERCENTAGE_PRECISION_MILLION, lptPriceEth: Number(protocolData?.lptPriceEth), }, rewardParams: { - inflation: Number(protocolData?.inflation) / 1000000000, + inflation: + Number(protocolData?.inflation) / PERCENTAGE_PRECISION_BILLION, inflationChangePerRound: - Number(protocolData?.inflationChange) / 1000000000, + Number(protocolData?.inflationChange) / + PERCENTAGE_PRECISION_BILLION, totalSupply: Number(protocolData?.totalSupply), totalActiveStake: Number(protocolData?.totalActiveStake), roundLength: Number(protocolData?.roundLength), rewardCallRatio, - rewardCut: Number(row.rewardCut) / 1000000, + rewardCut: Number(row.rewardCut) / PERCENTAGE_PRECISION_MILLION, treasuryRewardCut: treasuryCutDecimal, }, }); // Pre-compute formatted values to avoid useMemo in Cell render functions - const formattedFeeCut = numbro( - 1 - Number(row.feeShare) / 1000000 - ).format({ mantissa: 0, output: "percent" }); - const formattedRewardCut = numbro( - Number(row.rewardCut) / 1000000 - ).format({ mantissa: 0, output: "percent" }); - const formattedRewardCalls = - pools.length > 0 - ? `${numbro(rewardCalls) - .divide(pools.length) - .format({ mantissa: 0, output: "percent" })}` - : "0%"; - const formattedTreasuryCut = numbro(treasuryCutDecimal).format({ - mantissa: 0, - output: "percent", + const formattedFeeCut = formatPercent( + 1 - Number(row.feeShare) / PERCENTAGE_PRECISION_MILLION, + { precision: 0 } + ); + const formattedRewardCut = formatPercent( + Number(row.rewardCut) / PERCENTAGE_PRECISION_MILLION, + { precision: 0 } + ); + const formattedRewardCalls = `${formatPercent( + pools.length ? rewardCalls / pools.length : 0, + { precision: 0 } + )}`; + const formattedTreasuryCut = formatPercent(treasuryCutDecimal, { + precision: 0, }); return { @@ -391,10 +401,11 @@ const OrchestratorList = ({ ) : ( <> - {numbro( + {formatPercent( row.values.earnings.roi.delegatorPercent.fees + - row.values.earnings.roi.delegatorPercent.rewards - ).format({ mantissa: 1, output: "percent" })} + row.values.earnings.roi.delegatorPercent.rewards, + { precision: 1 } + )} @@ -437,9 +448,10 @@ const OrchestratorList = ({ size="2" > Rewards ( - {numbro( - row.values.earnings.roi.delegatorPercent.rewards - ).format({ mantissa: 1, output: "percent" })} + {formatPercent( + row.values.earnings.roi.delegatorPercent.rewards, + { precision: 1 } + )} ): - {numbro( - row.values.earnings.roi.delegator.rewards - ).format({ mantissa: 1 })} - {" LPT"} + {formatLPT( + row.values.earnings.roi.delegator.rewards, + { precision: 1, abbreviate: false } + )} )} @@ -469,9 +481,10 @@ const OrchestratorList = ({ size="2" > Fees ( - {numbro( - row.values.earnings.roi.delegatorPercent.fees - ).format({ mantissa: 1, output: "percent" })} + {formatPercent( + row.values.earnings.roi.delegatorPercent.fees, + { precision: 1 } + )} ): - {numbro( - row.values.earnings.roi.delegator.fees - ).format({ mantissa: 3 })} - {" ETH"} + {formatETH(row.values.earnings.roi.delegator.fees, { + precision: 3, + })} )} @@ -625,10 +637,9 @@ const OrchestratorList = ({ }} size="2" > - {numbro( - row.values.earnings.ninetyDayVolumeETH - ).format({ mantissa: 3, average: true })} - {" ETH"} + {formatETH(row.values.earnings.ninetyDayVolumeETH, { + precision: 3, + })} @@ -651,11 +662,9 @@ const OrchestratorList = ({ }} size="2" > - {numbro(row.values.earnings.totalStake).format({ - mantissa: 1, - average: true, + {formatLPT(row.values.earnings.totalStake, { + precision: 1, })} - {" LPT"} @@ -678,7 +687,10 @@ const OrchestratorList = ({ }} size="2" > - {row?.original?.daysSinceChangeParams} days ago + {formatNumber(row?.original?.daysSinceChangeParams, { + precision: 0, + })}{" "} + days ago @@ -764,8 +776,8 @@ const OrchestratorList = ({ }} size="2" > - {numbro(AVERAGE_L1_BLOCK_TIME).format({ - mantissa: 0, + {formatNumber(AVERAGE_L1_BLOCK_TIME, { + precision: 0, })} {" seconds"} @@ -790,9 +802,10 @@ const OrchestratorList = ({ }} size="2" > - {numbro( - row.values.earnings.roi.params.roundsCount - ).format({ mantissa: 0 })} + {formatNumber( + row.values.earnings.roi.params.roundsCount, + { precision: 0 } + )} {" rounds"} @@ -816,11 +829,9 @@ const OrchestratorList = ({ }} size="2" > - {numbro(row.values.earnings.totalActiveStake).format({ - mantissa: 1, - average: true, + {formatLPT(row.values.earnings.totalActiveStake, { + precision: 1, })} - {" LPT"} @@ -867,11 +878,10 @@ const OrchestratorList = ({ }} size="2" > - {numbro(row.values.totalStake).format({ - mantissa: 0, - thousandSeparated: true, - })}{" "} - LPT + {formatLPT(row.values.totalStake, { + precision: 0, + abbreviate: false, + })} ), @@ -901,11 +911,7 @@ const OrchestratorList = ({ }} size="2" > - {numbro(row.values.ninetyDayVolumeETH).format({ - mantissa: 2, - average: true, - })}{" "} - ETH + {formatETH(row.values.ninetyDayVolumeETH, { precision: 2 })} ), @@ -1179,8 +1185,7 @@ const OrchestratorList = ({ fontWeight: 600, }} > - {numbro(principle).format({ mantissa: 1, average: true })} - {" LPT"} + {formatLPT(principle, { precision: 1 })} diff --git a/components/PerformanceList/index.tsx b/components/PerformanceList/index.tsx index 27959666..6145e5a4 100644 --- a/components/PerformanceList/index.tsx +++ b/components/PerformanceList/index.tsx @@ -4,13 +4,14 @@ import Table from "@components/Table"; import { Pipeline } from "@lib/api/types/get-available-pipelines"; import { AllPerformanceMetrics } from "@lib/api/types/get-performance"; import { Region } from "@lib/api/types/get-regions"; -import { formatAddress, textTruncate } from "@lib/utils"; +import { textTruncate } from "@lib/utils"; import { Badge, Box, Flex, Link as A, Skeleton } from "@livepeer/design-system"; import { QuestionMarkCircledIcon } from "@modulz/radix-icons"; +import { formatNumber, formatPercent } from "@utils/numberFormatters"; +import { formatAddress } from "@utils/web3"; import { OrchestratorsQueryResult } from "apollo"; import { useEnsData } from "hooks"; import Link from "next/link"; -import numbro from "numbro"; import { useMemo } from "react"; import { Column } from "react-table"; @@ -149,7 +150,7 @@ const PerformanceList = ({ }, }} > - {row.values.id.substring(0, 6)} + {formatAddress(row.values.id.substring(0, 6))} ) : ( @@ -170,8 +171,8 @@ const PerformanceList = ({ }, }} > - {numbro(row.values.scores).divide(10).format({ - mantissa: 2, + {formatNumber(row.values.scores / 10, { + precision: 2, })} ) : null} @@ -230,9 +231,7 @@ const PerformanceList = ({ {typeof value === "undefined" || value === null ? "---" - : numbro(value).divide(10).format({ - mantissa: 2, - })} + : formatNumber(Number(value) / 10, { precision: 2 })} ); }, @@ -268,10 +267,7 @@ const PerformanceList = ({ {typeof value === "undefined" || value === null ? "---" - : numbro(value).divide(100).format({ - output: "percent", - mantissa: 0, - })} + : formatPercent(Number(value) / 100, { precision: 0 })} ); }, @@ -308,9 +304,7 @@ const PerformanceList = ({ {typeof value === "undefined" || value === null ? "---" - : numbro(value).divide(10).format({ - mantissa: 2, - })} + : formatNumber(Number(value) / 10, { precision: 2 })} ); }, diff --git a/components/PollVotingWidget/index.tsx b/components/PollVotingWidget/index.tsx index 1bfeb429..b2537585 100644 --- a/components/PollVotingWidget/index.tsx +++ b/components/PollVotingWidget/index.tsx @@ -1,7 +1,6 @@ import VoteButton from "@components/VoteButton"; import { PollExtended } from "@lib/api/polls"; import dayjs from "@lib/dayjs"; -import { abbreviateNumber, formatAddress } from "@lib/utils"; import { Box, Button, @@ -19,11 +18,13 @@ import { Cross1Icon, CrossCircledIcon, } from "@radix-ui/react-icons"; +import { formatPercent, formatVotingPower } from "@utils/numberFormatters"; +import { formatAddress } from "@utils/web3"; import { AccountQuery, PollChoice, TranscoderStatus } from "apollo"; import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; import { useEffect, useMemo, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { formatPercent, getVotingPower } from "utils/voting"; +import { getVotingPower } from "utils/voting"; import Check from "../../public/img/check.svg"; import Copy from "../../public/img/copy.svg"; @@ -182,7 +183,7 @@ const Index = ({ data }: { data: Props }) => { textAlign: "right", }} > - {formatPercent(data.poll.percent.yes, 2)} + {formatPercent(data.poll.percent.yes)} @@ -239,7 +240,7 @@ const Index = ({ data }: { data: Props }) => { textAlign: "right", }} > - {formatPercent(data.poll.percent.no, 2)} + {formatPercent(data.poll.percent.no)} @@ -251,7 +252,7 @@ const Index = ({ data }: { data: Props }) => { ? "votes" : "vote" }`}{" "} - · {abbreviateNumber(data.poll.stake.voters, 4)} LPT ·{" "} + · {formatVotingPower(data.poll.stake.voters)} ·{" "} {data.poll.status !== "active" ? "Final Results" : dayjs @@ -326,14 +327,12 @@ const Index = ({ data }: { data: Props }) => { css={{ fontWeight: 500, color: "$hiContrast" }} > - {abbreviateNumber(votingPower, 4)} LPT ( - {( - (+votingPower / - (data.poll.stake.nonVoters + - data.poll.stake.voters)) * - 100 - ).toPrecision(2)} - %) + {formatVotingPower(votingPower)} ( + {formatPercent( + +votingPower / + (data.poll.stake.nonVoters + data.poll.stake.voters) + )} + ) diff --git a/components/Profile/index.tsx b/components/Profile/index.tsx index 233226d9..aa4c2e3d 100644 --- a/components/Profile/index.tsx +++ b/components/Profile/index.tsx @@ -1,7 +1,6 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import ShowMoreRichText from "@components/ShowMoreRichText"; import { EnsIdentity } from "@lib/api/types/get-ens"; -import { formatAddress } from "@lib/utils"; import { Box, Flex, Heading, Link as A, Text } from "@livepeer/design-system"; import { CheckIcon, @@ -10,6 +9,7 @@ import { GlobeIcon, TwitterLogoIcon, } from "@modulz/radix-icons"; +import { formatAddress } from "@utils/web3"; import { QRCodeCanvas } from "qrcode.react"; import { useEffect, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; diff --git a/components/RoundStatus/index.tsx b/components/RoundStatus/index.tsx index 2b67f71d..0f49fa4e 100644 --- a/components/RoundStatus/index.tsx +++ b/components/RoundStatus/index.tsx @@ -8,10 +8,14 @@ import { Cross1Icon, QuestionMarkCircledIcon, } from "@modulz/radix-icons"; +import { + formatETH, + formatNumber, + formatUSD, +} from "@utils/numberFormatters"; import { ProtocolQueryResult } from "apollo"; import { useCurrentRoundData } from "hooks"; import { useTheme } from "next-themes"; -import numbro from "numbro"; import { useMemo } from "react"; import { buildStyles } from "react-circular-progressbar"; @@ -78,6 +82,12 @@ const Index = ({ [protocol] ); + const rewards = `${formatNumber(rewardTokensClaimed, { + precision: 0, + })} / ${formatNumber(Number(protocol?.currentRound?.mintableTokens), { + precision: 0, + })} LPT`; + return ( The amount of fees that have been paid out in the current round. Equivalent to{" "} - {numbro( - protocol?.currentRound?.volumeUSD || 0 - ).formatCurrency({ - mantissa: 0, - average: true, + {formatUSD(protocol?.currentRound?.volumeUSD, { + precision: 0, + abbreviate: true, })}{" "} at recent prices of ETH. @@ -291,11 +299,9 @@ const Index = ({ color: "white", }} > - {numbro(protocol?.currentRound?.volumeETH || 0).format({ - mantissa: 2, - average: true, - })}{" "} - ETH + {formatETH(protocol?.currentRound?.volumeETH, { + precision: 2, + })} @@ -342,14 +348,7 @@ const Index = ({ color: "white", }} > - {numbro(rewardTokensClaimed).format({ - mantissa: 0, - })} - / - {numbro(protocol?.currentRound?.mintableTokens || 0).format({ - mantissa: 0, - })}{" "} - LPT + {rewards} diff --git a/components/Search/index.tsx b/components/Search/index.tsx index ad2f8d96..b9fb11a1 100644 --- a/components/Search/index.tsx +++ b/components/Search/index.tsx @@ -1,5 +1,4 @@ import Spinner from "@components/Spinner"; -import { formatAddress } from "@lib/utils"; import { Box, Dialog, @@ -12,6 +11,7 @@ import { TextField, } from "@livepeer/design-system"; import { ArrowRightIcon, MagnifyingGlassIcon } from "@modulz/radix-icons"; +import { formatAddress } from "@utils/web3"; import Fuse from "fuse.js"; import { useAllEnsData } from "hooks"; import { useMemo, useState } from "react"; diff --git a/components/StakeTransactions/index.tsx b/components/StakeTransactions/index.tsx index 85661fca..edf4fe66 100644 --- a/components/StakeTransactions/index.tsx +++ b/components/StakeTransactions/index.tsx @@ -1,14 +1,11 @@ import { Box, Card, Flex, Heading, Text } from "@livepeer/design-system"; +import { formatLPT } from "@utils/numberFormatters"; +import { formatAddress } from "@utils/web3"; import { UnbondingLock } from "apollo"; import { useMemo } from "react"; import { parseEther } from "viem"; -import { - abbreviateNumber, - formatAddress, - getHint, - simulateNewActiveSetOrder, -} from "../../lib/utils"; +import { getHint, simulateNewActiveSetOrder } from "../../lib/utils"; import Redelegate from "../Redelegate"; import RedelegateFromUndelegated from "../RedelegateFromUndelegated"; import WithdrawStake from "../WithdrawStake"; @@ -136,9 +133,8 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { }} > - {abbreviateNumber(lock.amount, 4)} - {" "} - LPT + {formatLPT(lock.amount)} + @@ -247,9 +243,8 @@ const Index = ({ delegator, transcoders, currentRound, isMyAccount }) => { }} > - {abbreviateNumber(lock.amount, 3)} - {" "} - LPT + {formatLPT(lock.amount)} + diff --git a/components/TransactionBadge/index.tsx b/components/TransactionBadge/index.tsx index cac3d7d0..518ac178 100644 --- a/components/TransactionBadge/index.tsx +++ b/components/TransactionBadge/index.tsx @@ -1,6 +1,6 @@ -import { formatTransactionHash } from "@lib/utils"; import { Badge, Box, Link as A } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { formatTransactionHash } from "@utils/web3"; interface TransactionBadgeProps { id: string | undefined; diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index b1b10d78..d1d45d0c 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -5,9 +5,18 @@ import { parseProposalText } from "@lib/api/treasury"; import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; import dayjs from "@lib/dayjs"; import { Badge, Box, Flex, Link as A, Text } from "@livepeer/design-system"; +import { + formatETH, + formatLPT, + formatPercent, + formatRound, +} from "@utils/numberFormatters"; +import { + PERCENTAGE_PRECISION_BILLION, + PERCENTAGE_PRECISION_MILLION, +} from "@utils/web3"; import { EventsQueryResult, TreasuryProposal } from "apollo"; import { sentenceCase } from "change-case"; -import numbro from "numbro"; import { useCallback, useMemo } from "react"; export const FILTERED_EVENT_TYPENAMES = [ @@ -16,43 +25,38 @@ export const FILTERED_EVENT_TYPENAMES = [ "EarningsClaimedEvent", ]; +const isTinyAmount = (amount: number) => amount > 0 && amount < 0.01; + const getLptAmount = (number: number | string | undefined) => { const amount = Number(number ?? 0) || 0; + const isTinyLPT = isTinyAmount(amount); return ( - {`${numbro(amount).format( - amount > 0 && amount < 0.01 - ? { mantissa: 4, trimMantissa: true } - : { mantissa: 2, average: true, lowPrecision: false } - )} LPT`} + + {formatLPT(amount, { + precision: isTinyLPT ? 4 : 2, + abbreviate: isTinyLPT ? false : true, + })} + ); }; const getEthAmount = (number?: number | string) => { const amount = Number(number ?? 0) || 0; + const isTinyETH = isTinyAmount(amount); return ( - {`${numbro(amount).format( - amount > 0 && amount < 0.01 - ? { mantissa: 4, trimMantissa: true } - : { mantissa: 2, average: true, lowPrecision: false } - )} ETH`} + {formatETH(amount, { + precision: isTinyETH ? 4 : 2, + abbreviate: isTinyETH ? false : true, + })} ); }; -const getRound = (number: number | string | undefined) => { - return `#${numbro(number || 0).format({ - mantissa: 0, - })}`; -}; - const getPercentAmount = (number: number | string | undefined) => { return ( - {numbro(number || 0).format({ - output: "percent", - mantissa: 0, - })} + {formatPercent(number, { precision: 0 })} ); }; @@ -216,9 +220,13 @@ const TransactionsList = ({ return ( {`Updated their reward/fee cut to `} - {getPercentAmount(Number(event?.rewardCut ?? 0) / 1000000)} + {getPercentAmount( + Number(event?.rewardCut ?? 0) / PERCENTAGE_PRECISION_MILLION + )} {` and `} - {getPercentAmount(1 - Number(event?.feeShare ?? 0) / 1000000)} + {getPercentAmount( + 1 - Number(event?.feeShare ?? 0) / PERCENTAGE_PRECISION_MILLION + )} ); case "RewardEvent": @@ -282,14 +290,14 @@ const TransactionsList = ({ return ( {`Starts orchestrating in round `} - {getRound(event?.activationRound)} + {formatRound(event?.activationRound)} ); case "TranscoderDeactivatedEvent": return ( {`Stops orchestrating in round `} - {getRound(event?.deactivationRound)} + {formatRound(event?.deactivationRound)} ); // case "EarningsClaimedEvent": @@ -325,12 +333,11 @@ const TransactionsList = ({ {`The inflation has been set to `} - {numbro(event?.currentInflation || 0) - .divide(1000000000) - .format({ - output: "percent", - mantissa: 4, - })} + {formatPercent( + Number(event?.currentInflation || 0) / + PERCENTAGE_PRECISION_BILLION, + { precision: 4 } + )} ); @@ -367,7 +374,7 @@ const TransactionsList = ({ {`Poll `} - {` has been created and will end on block ${getRound( + {` has been created and will end on block ${formatRound( event?.endBlock )}`} diff --git a/components/Treasury/TreasuryVoteTable/TreasuryVotePopover.tsx b/components/Treasury/TreasuryVoteTable/TreasuryVotePopover.tsx index b150e5c5..af25d6c9 100644 --- a/components/Treasury/TreasuryVoteTable/TreasuryVotePopover.tsx +++ b/components/Treasury/TreasuryVoteTable/TreasuryVotePopover.tsx @@ -2,6 +2,7 @@ import Spinner from "@components/Spinner"; import { TREASURY_VOTES } from "@lib/api/types/votes"; import { Badge, Box, Flex, Link, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@radix-ui/react-icons"; +import { formatNumber } from "@utils/numberFormatters"; import { TreasuryVoteEvent, TreasuryVoteSupport, @@ -95,7 +96,7 @@ const Index: React.FC = ({ Total: - {stats.total} + {formatNumber(stats.total, { precision: 0 })} )} @@ -123,7 +124,7 @@ const Index: React.FC = ({ Total: - {stats.total} + {formatNumber(stats.total, { precision: 0 })} = ({ as={TREASURY_VOTES.for.icon} css={{ width: 12, height: 12, flexShrink: 0 }} /> - For: {stats.for} + For: {formatNumber(stats.for, { precision: 0 })} = ({ as={TREASURY_VOTES.against.icon} css={{ width: 12, height: 12, flexShrink: 0 }} /> - Against: {stats.against} + Against: {formatNumber(stats.against, { precision: 0 })} = ({ as={TREASURY_VOTES.abstain.icon} css={{ width: 12, height: 12, flexShrink: 0 }} /> - Abstain: {stats.abstain} + Abstain: {formatNumber(stats.abstain, { precision: 0 })} )} diff --git a/components/Treasury/TreasuryVoteTable/Views/VoteItem.tsx b/components/Treasury/TreasuryVoteTable/Views/VoteItem.tsx index f94c508e..776e6467 100644 --- a/components/Treasury/TreasuryVoteTable/Views/VoteItem.tsx +++ b/components/Treasury/TreasuryVoteTable/Views/VoteItem.tsx @@ -1,7 +1,6 @@ import { ExplorerTooltip } from "@components/ExplorerTooltip"; import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; import dayjs from "@lib/dayjs"; -import { formatTransactionHash } from "@lib/utils"; import { Badge, Box, @@ -17,6 +16,7 @@ import { ChevronUpIcon, CounterClockwiseClockIcon, } from "@radix-ui/react-icons"; +import { formatTransactionHash } from "@utils/web3"; import { TreasuryVoteSupport } from "apollo/subgraph"; import { useState } from "react"; diff --git a/components/Treasury/TreasuryVoteTable/index.tsx b/components/Treasury/TreasuryVoteTable/index.tsx index 583ab35c..459caa9e 100644 --- a/components/Treasury/TreasuryVoteTable/index.tsx +++ b/components/Treasury/TreasuryVoteTable/index.tsx @@ -1,7 +1,8 @@ import Spinner from "@components/Spinner"; import { getEnsForVotes } from "@lib/api/ens"; -import { formatAddress, lptFormatter } from "@lib/utils"; import { Flex, Text } from "@livepeer/design-system"; +import { formatLPT, formatPercent } from "@utils/numberFormatters"; +import { formatAddress } from "@utils/web3"; import { useTreasuryVoteEventsQuery, useTreasuryVotesQuery } from "apollo"; import React, { useEffect, useMemo, useState } from "react"; import { useWindowSize } from "react-use"; @@ -126,9 +127,9 @@ const Index: React.FC = ({ proposalId }) => { const formatWeight = useMemo( () => (w: string) => - `${lptFormatter.format(parseFloat(w))} LPT (${ - totalWeight > 0 ? ((parseFloat(w) / totalWeight) * 100).toFixed(2) : "0" - }%)`, + `${formatLPT(parseFloat(w), { abbreviate: false })} (${ + totalWeight > 0 ? formatPercent(parseFloat(w) / totalWeight) : "0" + })`, [totalWeight] ); diff --git a/components/Treasury/TreasuryVotingWidget/index.tsx b/components/Treasury/TreasuryVotingWidget/index.tsx index bab547d0..596d0008 100644 --- a/components/Treasury/TreasuryVotingWidget/index.tsx +++ b/components/Treasury/TreasuryVotingWidget/index.tsx @@ -3,7 +3,6 @@ import VoteButton from "@components/VoteButton"; import { ProposalExtended } from "@lib/api/treasury"; import { ProposalVotingPower } from "@lib/api/types/get-treasury-proposal"; import dayjs from "@lib/dayjs"; -import { abbreviateNumber, formatAddress, fromWei } from "@lib/utils"; import { Box, Button, Flex, Link as A, Text } from "@livepeer/design-system"; import { CheckCircledIcon, @@ -11,10 +10,16 @@ import { InfoCircledIcon, MinusCircledIcon, } from "@radix-ui/react-icons"; +import { + formatLPT, + formatNumber, + formatPercent, + formatVotingPower, +} from "@utils/numberFormatters"; +import { formatAddress, fromWei } from "@utils/web3"; import { useAccountAddress } from "hooks"; import Link, { LinkProps } from "next/link"; import { useMemo, useState } from "react"; -import { formatPercent } from "utils/voting"; import { zeroAddress } from "viem"; import TreasuryVotingReason from "./TreasuryVotingReason"; @@ -25,9 +30,6 @@ type Props = { votesTabHref?: LinkProps["href"] | string; }; -const formatLPT = (lpt: string | undefined) => - abbreviateNumber(fromWei(lpt ?? "0"), 4); - const SectionLabel = ({ children }: { children: React.ReactNode }) => ( { textAlign: "right", }} > - {formatPercent(proposal.votes.percent.for, 2)} + {formatPercent(proposal.votes.percent.for)} @@ -190,7 +192,7 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { textAlign: "right", }} > - {formatPercent(proposal.votes.percent.against, 2)} + {formatPercent(proposal.votes.percent.against)} @@ -247,7 +249,7 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { textAlign: "right", }} > - {formatPercent(proposal.votes.percent.abstain, 2)} + {formatPercent(proposal.votes.percent.abstain)} @@ -255,7 +257,11 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { {/* Summary line */} - {abbreviateNumber(proposal.votes.total.voters, 4)} LPT voted ·{" "} + {formatNumber(proposal.votes.total.voters, { + precision: 0, + abbreviate: true, + })}{" "} + voted ·{" "} {proposal.state !== "Pending" && proposal.state !== "Active" ? "Final Results" : dayjs.duration(proposal.votes.voteEndTime.diff()).humanize() + @@ -354,7 +360,7 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { fontVariantNumeric: "tabular-nums", }} > - {vote?.self ? formatLPT(vote.self.votes) : "0"} LPT + {formatVotingPower(fromWei(vote?.self?.votes))} @@ -481,7 +487,7 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { marginBottom: "$3", }} > - You had 0 LPT staked on{" "} + You had {formatLPT(0)} staked on{" "} {proposal.votes.voteStartTime.format("MMM D, YYYY")} when this proposal was created. diff --git a/components/TxConfirmedDialog/index.tsx b/components/TxConfirmedDialog/index.tsx index 41414e55..46d0fb1b 100644 --- a/components/TxConfirmedDialog/index.tsx +++ b/components/TxConfirmedDialog/index.tsx @@ -14,6 +14,7 @@ import { Link as A, } from "@livepeer/design-system"; import { CheckIcon } from "@modulz/radix-icons"; +import { formatAddress, fromWei } from "@utils/web3"; import { TransactionStatus, useExplorerStore } from "hooks"; import { useBondingManagerAddress } from "hooks/useContracts"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains"; @@ -23,7 +24,7 @@ import { MdReceipt } from "react-icons/md"; import { Address } from "viem"; import { useReadContract } from "wagmi"; -import { formatAddress, fromWei, txMessages } from "../../lib/utils"; +import { txMessages } from "../../lib/utils"; const Index = () => { const router = useRouter(); diff --git a/components/TxStartedDialog/index.tsx b/components/TxStartedDialog/index.tsx index 46e86db7..9ecf123f 100644 --- a/components/TxStartedDialog/index.tsx +++ b/components/TxStartedDialog/index.tsx @@ -1,4 +1,4 @@ -import { formatAddress, fromWei, txMessages } from "@lib/utils"; +import { txMessages } from "@lib/utils"; import { Badge, Box, @@ -12,6 +12,7 @@ import { Link as A, } from "@livepeer/design-system"; import { ExternalLinkIcon } from "@modulz/radix-icons"; +import { formatAddress, fromWei } from "@utils/web3"; import { TransactionStatus, useAccountAddress, useExplorerStore } from "hooks"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains"; diff --git a/components/VotingWidget/index.tsx b/components/VotingWidget/index.tsx new file mode 100644 index 00000000..b772e1e1 --- /dev/null +++ b/components/VotingWidget/index.tsx @@ -0,0 +1,536 @@ +import { PollExtended } from "@lib/api/polls"; +import dayjs from "@lib/dayjs"; +import { + Box, + Button, + Dialog, + DialogClose, + DialogContent, + DialogTitle, + Flex, + Heading, + Text, + useSnackbar, +} from "@livepeer/design-system"; +import { Cross1Icon } from "@modulz/radix-icons"; +import { formatPercent, formatVotingPower } from "@utils/numberFormatters"; +import { formatAddress, fromWei } from "@utils/web3"; +import { AccountQuery, PollChoice, TranscoderStatus } from "apollo"; +import { useAccountAddress, usePendingFeesAndStakeData } from "hooks"; +import { useEffect, useMemo, useState } from "react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; + +import Check from "../../public/img/check.svg"; +import Copy from "../../public/img/copy.svg"; +import VoteButton from "../VoteButton"; + +type Props = { + poll: PollExtended; + delegateVote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + vote: + | { + __typename: "Vote"; + choiceID?: PollChoice; + voteStake: string; + nonVoteStake: string; + } + | undefined + | null; + myAccount: AccountQuery; +}; + +const Index = ({ data }: { data: Props }) => { + const accountAddress = useAccountAddress(); + const [copied, setCopied] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [openSnackbar] = useSnackbar(); + + useEffect(() => { + if (copied) { + setTimeout(() => { + setCopied(false); + }, 5000); + } + }, [copied]); + + const pendingFeesAndStake = usePendingFeesAndStakeData( + data?.myAccount?.delegator?.id + ); + + const votingPower = useMemo( + () => + getVotingPower( + accountAddress ?? "", + data?.myAccount, + data?.vote, + pendingFeesAndStake?.pendingStake + ? pendingFeesAndStake?.pendingStake + : "0" + ), + [accountAddress, data, pendingFeesAndStake] + ); + + let delegate: { + __typename: "Transcoder"; + id: string; + active: boolean; + status: TranscoderStatus; + totalStake: string; + } | null = null; + + if (data?.myAccount?.delegator?.delegate) { + delegate = data?.myAccount?.delegator?.delegate; + } + + return ( + + + + + Do you support LIP-{data?.poll?.attributes?.lip ?? "ERR"}? + + + + + + + + Yes + + + {formatPercent(data.poll.percent.yes)} + + + + + + No + + + {formatPercent(data.poll.percent.no)} + + + + + {data.poll.votes.length}{" "} + {`${ + data.poll.votes.length > 1 || data.poll.votes.length === 0 + ? "votes" + : "vote" + }`}{" "} + · {formatVotingPower(data.poll.stake.voters)} ·{" "} + {data.poll.status !== "active" + ? "Final Results" + : dayjs + .duration( + dayjs().unix() - data.poll.estimatedEndTime, + "seconds" + ) + .humanize() + " left"} + + + + {accountAddress ? ( + <> + + + + My Delegate Vote{" "} + {delegate && `(${formatAddress(delegate?.id)})`} + + + {data?.delegateVote?.choiceID + ? data?.delegateVote?.choiceID + : "N/A"} + + + + + My Vote ({formatAddress(accountAddress)}) + + + {data?.vote?.choiceID ? data?.vote?.choiceID : "N/A"} + + + {((!data?.vote?.choiceID && data.poll.status === "active") || + data?.vote?.choiceID) && ( + + + My Voting Power + + + + {formatVotingPower(votingPower)} ( + {( + (+votingPower / + (data.poll.stake.nonVoters + + data.poll.stake.voters)) * + 100 + ).toPrecision(2)} + %) + + + + )} + + {data.poll.status === "active" && + data && + renderVoteButton( + data?.myAccount, + data?.vote, + data?.poll, + pendingFeesAndStake?.pendingStake ?? "" + )} + + ) : ( + + + + Connect your wallet to vote. + + + )} + + + {data.poll.status === "active" && ( + + + Are you an orchestrator?{" "} + setModalOpen(true)} + css={{ color: "$primary11", cursor: "pointer" }} + > + Follow these instructions + {" "} + if you prefer to vote with the Livepeer CLI. + + + )} + + + + + + Livepeer CLI Voting Instructions + + + + + + + + + + + Run the Livepeer CLI and select the option to "Vote on a + poll". When prompted for a contract address, copy and paste + this poll's contract address: + + + {data.poll.id} + { + setCopied(true); + openSnackbar("Copied to clipboard"); + }} + > + + {copied ? ( + + ) : ( + + )} + + + + + + + The Livepeer CLI will prompt you for your vote. Enter 0 to vote + "Yes" or 1 to vote "No". + + + + + Once your vote is confirmed, check back here to see it reflected + in the UI. + + + + + + + ); +}; + +export default Index; + +function renderVoteButton( + myAccount: Props["myAccount"], + vote: Props["vote"], + poll: Props["poll"], + pendingStake: string +) { + switch (vote?.choiceID) { + case "Yes": + return ( + 0)} + css={{ marginTop: "$4", width: "100%" }} + variant="red" + size="4" + choiceId={1} + pollAddress={poll?.id} + > + Change Vote To No + + ); + case "No": + return ( + 0)} + css={{ marginTop: "$4", width: "100%" }} + size="4" + variant="primary" + choiceId={0} + pollAddress={poll?.id} + > + Change Vote To Yes + + ); + default: + return ( + + 0)} + variant="primary" + choiceId={0} + size="4" + pollAddress={poll?.id} + > + Yes + + 0)} + variant="red" + size="4" + choiceId={1} + pollAddress={poll?.id} + > + No + + + ); + } +} + +function getVotingPower( + accountAddress: string, + myAccount: Props["myAccount"], + vote: Props["vote"], + pendingStake?: string +) { + // if account is a delegate its voting power is its total stake minus its delegators' vote stake (nonVoteStake) + if (accountAddress === myAccount?.delegator?.delegate?.id) { + if (vote?.voteStake) { + return +vote.voteStake - +vote?.nonVoteStake; + } + return ( + +myAccount?.delegator?.delegate?.totalStake - + (vote?.nonVoteStake ? +vote?.nonVoteStake : 0) + ); + } + + return fromWei(pendingStake ? pendingStake : "0"); +} diff --git a/hooks/useSwr.tsx b/hooks/useSwr.tsx index 57492d30..8280b0e2 100644 --- a/hooks/useSwr.tsx +++ b/hooks/useSwr.tsx @@ -21,7 +21,7 @@ import { RegisteredToVote, VotingPower, } from "@lib/api/types/get-treasury-proposal"; -import { formatAddress } from "@lib/utils"; +import { formatAddress } from "@utils/web3"; import useSWR from "swr"; import { Address } from "viem"; diff --git a/layouts/account.tsx b/layouts/account.tsx index a70c0afd..aa98ba3c 100644 --- a/layouts/account.tsx +++ b/layouts/account.tsx @@ -6,7 +6,6 @@ import Profile from "@components/Profile"; import { LAYOUT_MAX_WIDTH } from "@layouts/constants"; import { getLayout } from "@layouts/main"; import { bondingManager } from "@lib/api/abis/main/BondingManager"; -import { checkAddressEquality } from "@lib/utils"; import { Box, Button, @@ -17,6 +16,7 @@ import { SheetContent, SheetTrigger, } from "@livepeer/design-system"; +import { checkAddressEquality } from "@utils/web3"; import { AccountQueryResult, OrchestratorsSortedQueryResult, diff --git a/layouts/main.tsx b/layouts/main.tsx index cff6bb08..adbd710f 100644 --- a/layouts/main.tsx +++ b/layouts/main.tsx @@ -11,7 +11,6 @@ import TxSummaryDialog from "@components/TxSummaryDialog"; import URLVerificationBanner from "@components/URLVerificationBanner"; import { IS_L2 } from "@lib/chains"; import { globalStyles } from "@lib/globalStyles"; -import { EMPTY_ADDRESS, formatAddress } from "@lib/utils"; import { Badge, Box, @@ -33,6 +32,7 @@ import { ChevronDownIcon, EyeOpenIcon, } from "@modulz/radix-icons"; +import { EMPTY_ADDRESS, formatAddress } from "@utils/web3"; import { usePollsQuery, useProtocolQuery, diff --git a/lib/api/ens.ts b/lib/api/ens.ts index 777fd7a8..e31d1388 100644 --- a/lib/api/ens.ts +++ b/lib/api/ens.ts @@ -1,5 +1,5 @@ import { l1Provider } from "@lib/chains"; -import { formatAddress } from "@lib/utils"; +import { formatAddress } from "@utils/web3"; import sanitizeHtml from "sanitize-html"; import { EnsIdentity } from "./types/get-ens"; diff --git a/lib/api/treasury.ts b/lib/api/treasury.ts index 17e4317d..3470f382 100644 --- a/lib/api/treasury.ts +++ b/lib/api/treasury.ts @@ -1,6 +1,6 @@ import { AVERAGE_L1_BLOCK_TIME } from "@lib/chains"; import dayjs from "@lib/dayjs"; -import { fromWei } from "@lib/utils"; +import { fromWei } from "@utils/web3"; import { ProtocolQuery, TreasuryProposalQuery } from "apollo"; import fm from "front-matter"; diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 02312d45..1681dcd5 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -1,20 +1,15 @@ +import { EMPTY_ADDRESS } from "@utils/web3"; import { AccountQueryResult } from "apollo"; import { StakingAction } from "hooks"; import { - abbreviateNumber, avg, - checkAddressEquality, - EMPTY_ADDRESS, - formatAddress, - fromWei, getDelegatorStatus, getHint, getPercentChange, isImageUrl, simulateNewActiveSetOrder, textTruncate, - toWei, } from "./utils"; describe("avg", () => { @@ -36,23 +31,6 @@ describe("avg", () => { }); }); -describe("abbreviateNumber", () => { - it("does not abbreviate numbers < 1000", () => { - expect(abbreviateNumber(500)).toBe("500"); - }); - - it("abbreviates thousands", () => { - expect(abbreviateNumber(1500)).toBe("1.50K"); - }); - - it("abbreviates millions", () => { - const res = abbreviateNumber(2_000_000); - expect(res.endsWith("M")).toBe(true); - expect(parseFloat(res.replace("M", ""))).toBeCloseTo(2, 3); - expect(res).toBe("2.00M"); - }); -}); - describe("getDelegatorStatus", () => { const makeDelegator = (overrides = {}) => ({ @@ -133,25 +111,6 @@ describe("textTruncate", () => { }); }); -describe("checkAddressEquality", () => { - it("returns false for invalid addresses", () => { - expect(checkAddressEquality("not-an-address", "0x123")).toBe(false); - }); - - it("compares valid addresses case-insensitively", () => { - const addrLower = "0x00a0000000000000000000000000000000000001"; - const addrUpper = - "0x00a0000000000000000000000000000000000001".toUpperCase(); - expect(checkAddressEquality(addrLower, addrUpper)).toBe(true); - }); - - it("returns false for different valid addresses", () => { - const addr1 = "0x0000000000000000000000000000000000000001"; - const addr2 = "0x0000000000000000000000000000000000000002"; - expect(checkAddressEquality(addr1, addr2)).toBe(false); - }); -}); - describe("getHint", () => { const transcoders = [{ id: "0xA" }, { id: "0xB" }, { id: "0xC" }]; @@ -303,25 +262,6 @@ describe("getPercentChange", () => { }); }); -describe("fromWei", () => { - it("converts string wei to ether string", () => { - const oneEthWei = "1000000000000000000"; - expect(fromWei(oneEthWei)).toBe("1"); - }); - - it("converts bigint wei to ether string", () => { - const twoEthWei = 2000000000000000000n; - expect(fromWei(twoEthWei)).toBe("2"); - }); -}); - -describe("toWei", () => { - it("converts ether number to bigint wei", () => { - expect(toWei(1)).toBe(1000000000000000000n); - expect(toWei(0.5)).toBe(500000000000000000n); - }); -}); - describe("isImageUrl", () => { it("returns true for common image extensions", () => { expect(isImageUrl("https://example.com/image.jpg")).toBe(true); @@ -335,19 +275,3 @@ describe("isImageUrl", () => { expect(isImageUrl("not-a-url")).toBe(false); }); }); - -describe("formatAddress", () => { - it("shortens a normal ethereum address", () => { - const addr = "0x1234567890abcdef1234567890abcdef12345678"; - const shortened = formatAddress(addr); - - // Implementation: replace address.slice(5, 39) with "…" - const expected = addr.slice(0, 6) + "…" + addr.slice(-4); - - expect(shortened).toBe(expected); - }); - - it("returns empty string for falsy address", () => { - expect(formatAddress(null)).toBe(""); - }); -}); diff --git a/lib/utils.tsx b/lib/utils.tsx index a045b29f..74e83175 100644 --- a/lib/utils.tsx +++ b/lib/utils.tsx @@ -1,8 +1,8 @@ +import { EMPTY_ADDRESS } from "@utils/web3"; import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo"; import { ethers } from "ethers"; import { StakingAction } from "hooks"; import { DEFAULT_CHAIN_ID, NETWORK_RPC_URLS } from "lib/chains"; -import { formatEther, getAddress, parseEther } from "viem"; export const provider = new ethers.providers.JsonRpcProvider( NETWORK_RPC_URLS[DEFAULT_CHAIN_ID][0] @@ -17,23 +17,6 @@ export function avg(obj, key) { return (arr?.reduce(sum)?.[key] ?? 0) / arr.length; } -export const EMPTY_ADDRESS = ethers.constants.AddressZero; - -export const abbreviateNumber = (value, precision = 3) => { - let newValue = value; - const suffixes = ["", "K", "M", "B", "T"]; - let suffixNum = 0; - while (newValue >= 1000) { - newValue /= 1000; - suffixNum++; - } - - newValue = Number.parseFloat(newValue).toPrecision(precision); - newValue += suffixes[suffixNum]; - - return newValue; -}; - export const getDelegatorStatus = ( delegator: NonNullable["delegator"], currentRound: @@ -81,17 +64,6 @@ export const textTruncate = (str, length, ending) => { } }; -export const checkAddressEquality = (address1: string, address2: string) => { - try { - const formattedAddress1 = getAddress(address1.toLowerCase()); - const formattedAddress2 = getAddress(address2.toLowerCase()); - - return formattedAddress1 === formattedAddress2; - } catch { - return false; - } -}; - export const txMessages = { approve: { pending: "Approving LPT", @@ -254,15 +226,6 @@ export const getPercentChange = (valueNow, value24HoursAgo) => { return adjustedPercentChange; }; -export const fromWei = (wei: bigint | string) => { - if (typeof wei === "string") { - return formatEther(BigInt(wei)); - } - return formatEther(wei); -}; - -export const toWei = (ether: number) => parseEther(ether.toString()); - /** * Check if a URL is an image URL. * @param url - The URL to check @@ -271,42 +234,3 @@ export const toWei = (ether: number) => parseEther(ether.toString()); export const isImageUrl = (url: string): boolean => { return /\.(jpg|jpeg|png|gif|webp)$/i.test(url); }; - -/** - * Shorten an Ethereum address for display. - * @param address - The address to shorten. - * @returns The shortened address. - */ -export const shortenAddress = (address: string) => - address?.replace(address.slice(5, 39), "…") ?? ""; - -export const lptFormatter = new Intl.NumberFormat("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -export const formatLpt = (w: string) => { - return `${lptFormatter.format(parseFloat(w) / 1e18)} LPT`; -}; - -export const formatAddress = ( - addr: string | null | undefined, - startLength = 6, - endLength = 4 -): string => { - if (!addr) return ""; - if (addr.endsWith(".xyz")) { - return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; - } - if (addr.endsWith(".eth") && addr.length < 21) { - return addr; - } - return addr.length > 21 - ? `${addr.slice(0, startLength)}…${addr.slice(-endLength)}` - : addr; -}; - -export const formatTransactionHash = (id: string | null | undefined) => { - if (!id) return ""; - return id.replace(id.slice(6, 62), "…"); -}; diff --git a/pages/_app.tsx b/pages/_app.tsx index 39e3a75e..cc74fdf9 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -8,7 +8,6 @@ import { DEFAULT_CHAIN, L1_CHAIN } from "lib/chains"; import dynamic from "next/dynamic"; import Head from "next/head"; import { useRouter } from "next/router"; -import numbro from "numbro"; import { Tooltip } from "radix-ui"; import { useMemo } from "react"; import { CookiesProvider } from "react-cookie"; @@ -17,8 +16,6 @@ import { SWRConfig } from "swr"; import { useApollo } from "../apollo"; import Layout from "../layouts/main"; -numbro.setDefaults({ spaceSeparated: false }); - const queryClient = new QueryClient(); const Web3Providers = dynamic(() => import("../components/Web3Providers"), { diff --git a/pages/api/l1-delegator/[address].tsx b/pages/api/l1-delegator/[address].tsx index d9a38666..2c3a85b3 100644 --- a/pages/api/l1-delegator/[address].tsx +++ b/pages/api/l1-delegator/[address].tsx @@ -5,7 +5,7 @@ import { roundsManager } from "@lib/api/abis/main/RoundsManager"; import { badRequest, internalError, methodNotAllowed } from "@lib/api/errors"; import { L1Delegator, UnbondingLock } from "@lib/api/types/get-l1-delegator"; import { CHAIN_INFO, L1_CHAIN_ID, l1PublicClient } from "@lib/chains"; -import { EMPTY_ADDRESS } from "@lib/utils"; +import { EMPTY_ADDRESS } from "@utils/web3"; import { keccak256, toUtf8Bytes } from "ethers/lib/utils"; import { NextApiRequest, NextApiResponse } from "next"; import { Address, isAddress } from "viem"; diff --git a/pages/api/score/[address].tsx b/pages/api/score/[address].tsx index 572e5144..0c5be1bc 100644 --- a/pages/api/score/[address].tsx +++ b/pages/api/score/[address].tsx @@ -11,7 +11,8 @@ import { } from "@lib/api/types/get-performance"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "@lib/chains"; import { fetchWithRetry } from "@lib/fetchWithRetry"; -import { avg, checkAddressEquality } from "@lib/utils"; +import { avg } from "@lib/utils"; +import { checkAddressEquality } from "@utils/web3"; import { NextApiRequest, NextApiResponse } from "next"; import { isAddress } from "viem"; diff --git a/pages/index.tsx b/pages/index.tsx index 828343e8..7d9b37e4 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -21,6 +21,7 @@ import { Link as A, } from "@livepeer/design-system"; import { ArrowRightIcon } from "@modulz/radix-icons"; +import { PERCENTAGE_PRECISION_BILLION } from "@utils/web3"; import { useChartData } from "hooks"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -110,7 +111,7 @@ const Charts = ({ chartData }: { chartData: HomeChartData | null }) => { () => getDaySeries( inflationGrouping, - (day) => Number(day?.inflation ?? 0) / 1000000000 + (day) => Number(day?.inflation ?? 0) / PERCENTAGE_PRECISION_BILLION ), [getDaySeries, inflationGrouping] ); @@ -178,7 +179,9 @@ const Charts = ({ chartData }: { chartData: HomeChartData | null }) => { { label={ Total Support ( - {formatPercent(+proposal.quota / 1000000)} + {formatPercent( + +proposal.quota / PERCENTAGE_PRECISION_MILLION + )}{" "} needed) } @@ -431,8 +437,9 @@ const Proposal = () => { - {abbreviateNumber(proposal.votes.total.for, 4)}{" "} - LPT + {formatLPT(proposal.votes.total.for, { + precision: 4, + })} { - {abbreviateNumber( - proposal.votes.total.against, - 4 - )}{" "} - LPT + {formatLPT(proposal.votes.total.against, { + precision: 4, + })} { - {abbreviateNumber( - proposal.votes.total.abstain, - 4 - )}{" "} - LPT + {formatLPT(proposal.votes.total.abstain, { + precision: 4, + })} @@ -509,7 +512,7 @@ const Proposal = () => { Total Participation ( {formatPercent( +proposal.quorum / +proposal.totalVoteSupply - )} + )}{" "} needed) } @@ -534,11 +537,9 @@ const Proposal = () => { - {abbreviateNumber( - proposal.votes.total.voters, - 4 - )}{" "} - LPT + {formatLPT(proposal.votes.total.voters, { + precision: 4, + })} @@ -555,11 +556,9 @@ const Proposal = () => { - {abbreviateNumber( - proposal.votes.total.nonVoters, - 4 - )}{" "} - LPT + {formatLPT(proposal.votes.total.nonVoters, { + precision: 4, + })} diff --git a/pages/treasury/create-proposal.tsx b/pages/treasury/create-proposal.tsx index 6b83038d..cddb5488 100644 --- a/pages/treasury/create-proposal.tsx +++ b/pages/treasury/create-proposal.tsx @@ -3,7 +3,6 @@ import Spinner from "@components/Spinner"; import { livepeerGovernor } from "@lib/api/abis/main/LivepeerGovernor"; import { livepeerToken } from "@lib/api/abis/main/LivepeerToken"; import { getLivepeerTokenAddress } from "@lib/api/contracts"; -import { abbreviateNumber, fromWei, toWei } from "@lib/utils"; import { Box, Button, @@ -18,6 +17,8 @@ import { TextField, } from "@livepeer/design-system"; import { Tab, TabList, TabPanel, TabPanels, Tabs } from "@reach/tabs"; +import { formatLPT } from "@utils/numberFormatters"; +import { fromWei, toWei } from "@utils/web3"; import { useAccountAddress, useAccountBalanceData, @@ -65,8 +66,6 @@ type Mutable = { -readonly [K in keyof T]: Mutable; }; -const formatLPT = (lpt: string) => abbreviateNumber(lpt, 6); - const CreateProposal = () => { const accountAddress = useAccountAddress(); const contractAddresses = useContractInfoData(); @@ -198,7 +197,7 @@ const CreateProposal = () => { > Treasury Balance:{" "} {treasuryBalance !== undefined && treasuryBalance !== null ? ( - <>{formatLPT(treasuryBalance)} LPT + <>{formatLPT(treasuryBalance)} ) : ( { {!sufficientStake ? ( Insufficient stake - you need at least{" "} - {fromWei(votingPower.proposalThreshold)} staked LPT to - create a proposal. + {formatLPT(fromWei(votingPower.proposalThreshold))} staked + to create a proposal. ) : ( <> diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index 90915f18..8375d0a2 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -18,6 +18,11 @@ import { Text, } from "@livepeer/design-system"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; +import { + formatLPT, + formatPercent, +} from "@utils/numberFormatters"; +import { PERCENTAGE_PRECISION_TEN_THOUSAND } from "@utils/web3"; import { AccountQuery, PollChoice, @@ -30,14 +35,12 @@ import Head from "next/head"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { useWindowSize } from "react-use"; -import { formatPercent } from "utils/voting"; import { useAccountAddress, useCurrentRoundData, useExplorerStore, } from "../../hooks"; -import { abbreviateNumber } from "../../lib/utils"; import FourZeroFour from "../404"; const Poll = () => { @@ -226,7 +229,7 @@ const Poll = () => { Total Support ({+pollData.quota / 10000}% needed) + Total Support ({+pollData.quota / PERCENTAGE_PRECISION_TEN_THOUSAND}% needed) } value={{formatPercent(pollData.percent.yes)}} meta={ @@ -248,8 +251,8 @@ const Poll = () => { For ({formatPercent(pollData.percent.yes)}) - - {abbreviateNumber(pollData.stake.yes, 4)} LPT + + {formatLPT(pollData.stake.yes, { precision: 4 })} { - {abbreviateNumber(pollData.stake.no, 4)} LPT + {formatLPT(pollData.stake.no, { precision: 4 })} @@ -280,7 +283,7 @@ const Poll = () => { css={{ flex: 1, mb: 0 }} label={ - Total Participation ({+pollData.quorum / 10000}% needed) + Total Participation ({+pollData.quorum / PERCENTAGE_PRECISION_TEN_THOUSAND}% needed) } value={{formatPercent(pollData.percent.voters)}} @@ -299,7 +302,7 @@ const Poll = () => { - {abbreviateNumber(pollData.stake.voters, 4)} LPT + {formatLPT(pollData.stake.voters, { precision: 4 })} @@ -316,7 +319,9 @@ const Poll = () => { - {abbreviateNumber(pollData.stake.nonVoters, 4)} LPT + {formatLPT(pollData.stake.nonVoters, { + precision: 4, + })} diff --git a/pages/voting/create-poll.tsx b/pages/voting/create-poll.tsx index e09cc681..7189a079 100644 --- a/pages/voting/create-poll.tsx +++ b/pages/voting/create-poll.tsx @@ -2,7 +2,6 @@ import ErrorComponent from "@components/Error"; import Spinner from "@components/Spinner"; import { pollCreator } from "@lib/api/abis/main/PollCreator"; import { getPollCreatorAddress } from "@lib/api/contracts"; -import { fromWei } from "@lib/utils"; import { Box, Button, @@ -14,6 +13,7 @@ import { RadioCardGroup, } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { fromWei } from "@utils/web3"; import { useAccountQuery } from "apollo"; import { createApolloFetch } from "apollo-fetch"; import { hexlify, toUtf8Bytes } from "ethers/lib/utils"; diff --git a/tsconfig.json b/tsconfig.json index d552295b..c98be223 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "@components/*": ["components/*"], "@layouts/*": ["layouts/*"], "@lib/*": ["lib/*"], - "@api/*": ["lib/api/*"] + "@api/*": ["lib/api/*"], + "@utils/*": ["utils/*"] }, "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], diff --git a/utils/numberFormatters.test.ts b/utils/numberFormatters.test.ts new file mode 100644 index 00000000..615fa28f --- /dev/null +++ b/utils/numberFormatters.test.ts @@ -0,0 +1,386 @@ +import { + formatETH, + formatLPT, + formatNumber, + formatPercent, + formatRound, + formatStakeAmount, + formatUSD, + formatVotingPower, +} from "./numberFormatters"; + +describe("formatLPT", () => { + describe("zero handling", () => { + it("formats zero as '0 LPT'", () => { + expect(formatLPT(0)).toBe("0 LPT"); + expect(formatLPT("0")).toBe("0 LPT"); + }); + + it("formats zero without symbol when showSymbol is false", () => { + expect(formatLPT(0, { showSymbol: false })).toBe("0"); + }); + }); + + describe("small value handling", () => { + it("shows '< 0.01 LPT' for values below threshold", () => { + expect(formatLPT(0.005)).toBe("< 0.01 LPT"); + expect(formatLPT(0.009)).toBe("< 0.01 LPT"); + expect(formatLPT(0.001)).toBe("< 0.01 LPT"); + }); + + it("shows exact value for values at or above threshold", () => { + expect(formatLPT(0.01)).toBe("0.01 LPT"); + expect(formatLPT(0.1)).toBe("0.1 LPT"); + expect(formatLPT(0.1)).toBe("0.1 LPT"); + }); + + it("handles negative small values", () => { + expect(formatLPT(-0.005)).toBe("> -0.01 LPT"); + }); + }); + + describe("standard formatting", () => { + it("formats with 2 decimal places by default, trimming zeros", () => { + expect(formatLPT(1.5)).toBe("1.5 LPT"); + expect(formatLPT(123.456)).toBe("123.46 LPT"); + expect(formatLPT(1234.56789)).toBe("1,234.57 LPT"); + }); + + it("includes thousand separators", () => { + expect(formatLPT(1234.56)).toBe("1,234.56 LPT"); + expect(formatLPT(9999.99)).toBe("9,999.99 LPT"); + }); + + it("handles negative values", () => { + expect(formatLPT(-123.45)).toBe("-123.45 LPT"); + expect(formatLPT(-1234.56)).toBe("-1,234.56 LPT"); + }); + }); + + describe("abbreviation (>= 10,000)", () => { + it("does not abbreviate below 10,000", () => { + expect(formatLPT(9999)).toBe("9,999 LPT"); + expect(formatLPT(9999.0)).toBe("9,999 LPT"); + }); + + it("abbreviates at 10,000 and above", () => { + expect(formatLPT(10000)).toBe("10K LPT"); + expect(formatLPT(15000)).toBe("15K LPT"); + expect(formatLPT(150000)).toBe("150K LPT"); + }); + + it("uses m for millions", () => { + expect(formatLPT(1500000)).toBe("1.5M LPT"); + expect(formatLPT(2000000)).toBe("2M LPT"); + }); + + it("uses b for billions", () => { + expect(formatLPT(1500000000)).toBe("1.5B LPT"); + }); + + it("can disable abbreviation", () => { + expect(formatLPT(15000, { abbreviate: false })).toBe("15,000 LPT"); + }); + }); + + describe("null/undefined handling", () => { + it("handles null as zero", () => { + expect(formatLPT(null)).toBe("0 LPT"); + }); + + it("handles undefined as zero", () => { + expect(formatLPT(undefined)).toBe("0 LPT"); + }); + }); + + describe("NaN handling", () => { + it("handles NaN as zero", () => { + expect(formatLPT(NaN)).toBe("0 LPT"); + expect(formatLPT("not a number")).toBe("0 LPT"); + }); + }); + + describe("options", () => { + it("respects showSymbol option", () => { + expect(formatLPT(123.45, { showSymbol: false })).toBe("123.45"); + expect(formatLPT(15000, { showSymbol: false })).toBe("15K"); + }); + + it("respects custom precision", () => { + expect(formatLPT(123.456789, { precision: 4 })).toBe("123.4568 LPT"); + }); + + it("respects thousandSeparated option", () => { + expect(formatLPT(1234.56, { thousandSeparated: false })).toBe( + "1234.56 LPT" + ); + }); + + it("respects trimZeros option", () => { + expect(formatLPT(1.5, { trimZeros: false })).toBe("1.50 LPT"); + expect(formatLPT(100, { trimZeros: false })).toBe("100.00 LPT"); + }); + }); + + describe("forceSign", () => { + it("shows + for positive numbers", () => { + expect(formatLPT(10, { forceSign: true })).toBe("+10 LPT"); + }); + + it("shows - for negative numbers", () => { + expect(formatLPT(-10, { forceSign: true })).toBe("-10 LPT"); + }); + }); +}); + +describe("formatETH", () => { + describe("zero handling", () => { + it("formats zero as '0 ETH'", () => { + expect(formatETH(0)).toBe("0 ETH"); + expect(formatETH("0")).toBe("0 ETH"); + }); + }); + + describe("small value handling", () => { + it("shows '< 0.0001 ETH' for values below threshold", () => { + expect(formatETH(0.00005)).toBe("< 0.0001 ETH"); + expect(formatETH(0.00009)).toBe("< 0.0001 ETH"); + }); + + it("shows exact value for values at or above threshold", () => { + expect(formatETH(0.0001)).toBe("0.0001 ETH"); + expect(formatETH(0.001)).toBe("0.001 ETH"); + }); + }); + + describe("standard formatting", () => { + it("formats with 4 decimal places by default, trimming zeros", () => { + expect(formatETH(1.23456789)).toBe("1.2346 ETH"); + expect(formatETH(100.5)).toBe("100.5 ETH"); + }); + + it("includes thousand separators", () => { + expect(formatETH(1234.5678)).toBe("1,234.5678 ETH"); + }); + }); + + describe("abbreviation (>= 10,000)", () => { + it("abbreviates when enabled", () => { + expect(formatETH(15000, { abbreviate: true })).toBe("15K ETH"); + expect(formatETH(1500000, { abbreviate: true })).toBe("1.5M ETH"); + }); + + it("does not abbreviate by default", () => { + expect(formatETH(15000)).toBe("15,000 ETH"); + }); + + it("forceSign works with abbreviation", () => { + expect(formatETH(15000, { abbreviate: true, forceSign: true })).toBe( + "+15K ETH" + ); + }); + }); + + describe("null/undefined handling", () => { + it("handles null as zero", () => { + expect(formatETH(null)).toBe("0 ETH"); + }); + + it("handles undefined as zero", () => { + expect(formatETH(undefined)).toBe("0 ETH"); + }); + }); + + describe("forceSign", () => { + it("shows + for positive numbers", () => { + expect(formatETH(10, { forceSign: true })).toBe("+10 ETH"); + }); + }); +}); + +describe("formatPercent", () => { + describe("zero handling", () => { + it("formats zero as '0%'", () => { + expect(formatPercent(0)).toBe("0%"); + expect(formatPercent("0")).toBe("0%"); + }); + }); + + describe("whole number percentages", () => { + it("shows no decimals for whole numbers", () => { + expect(formatPercent(0.5)).toBe("50%"); + expect(formatPercent(0.1)).toBe("10%"); + expect(formatPercent(1)).toBe("100%"); + }); + }); + + describe("fractional percentages", () => { + it("shows 2 decimals for fractional values", () => { + expect(formatPercent(0.12345)).toBe("12.35%"); + expect(formatPercent(0.001)).toBe("0.10%"); + expect(formatPercent(0.5555)).toBe("55.55%"); + }); + + it("respects explicit precision even if looks like whole number", () => { + // 0.0000005 * 100 = 0.00005 (looks whole by heuristic but isn't) + expect(formatPercent(0.0000005, { precision: 5 })).toBe("0.00005%"); + // 0.5 * 100 = 50 (is whole) + expect(formatPercent(0.5, { precision: 2 })).toBe("50.00%"); + }); + }); + + describe("null/undefined handling", () => { + it("handles null as zero", () => { + expect(formatPercent(null)).toBe("0%"); + }); + + it("handles undefined as zero", () => { + expect(formatPercent(undefined)).toBe("0%"); + }); + }); + + describe("forceSign", () => { + it("shows + for positive numbers", () => { + expect(formatPercent(0.1, { forceSign: true })).toBe("+10%"); + expect(formatPercent(0.5, { forceSign: true })).toBe("+50%"); + }); + + it("shows - for negative numbers", () => { + expect(formatPercent(-0.1, { forceSign: true })).toBe("-10%"); + }); + }); +}); + +describe("formatVotingPower", () => { + it("formats voting power with LPT rules", () => { + expect(formatVotingPower(0)).toBe("0 LPT"); + expect(formatVotingPower(0.005)).toBe("< 0.01 LPT"); + expect(formatVotingPower(1234.56)).toBe("1,234.56 LPT"); + expect(formatVotingPower(15000)).toBe("15K LPT"); + }); + + it("fixes Issue #442 - zero displays as '0 LPT' not '0.000'", () => { + expect(formatVotingPower(0)).toBe("0 LPT"); + expect(formatVotingPower("0")).toBe("0 LPT"); + }); +}); + +describe("formatStakeAmount", () => { + it("formats stake amounts with LPT rules", () => { + expect(formatStakeAmount(1234.56)).toBe("1,234.56 LPT"); + expect(formatStakeAmount(15000)).toBe("15K LPT"); + }); +}); + +describe("formatRound", () => { + describe("standard formatting", () => { + it("formats with # prefix and no decimals", () => { + expect(formatRound(12345)).toBe("#12,345"); + expect(formatRound(100)).toBe("#100"); + }); + + it("includes thousand separators", () => { + expect(formatRound(1234567)).toBe("#1,234,567"); + }); + + it("supports custom precision", () => { + expect(formatRound(12345.67, { precision: 2 })).toBe("#12,345.67"); + expect(formatRound(12345.67)).toBe("#12,346"); + }); + }); + + describe("zero handling", () => { + it("formats zero as '#0'", () => { + expect(formatRound(0)).toBe("#0"); + }); + }); + + describe("null/undefined handling", () => { + it("handles null as zero", () => { + expect(formatRound(null)).toBe("#0"); + }); + + it("handles undefined as zero", () => { + expect(formatRound(undefined)).toBe("#0"); + }); + }); +}); + +describe("formatNumber", () => { + describe("zero handling", () => { + it("formats zero as '0'", () => { + expect(formatNumber(0)).toBe("0"); + }); + }); + + describe("standard formatting", () => { + it("formats with 2 decimal places by default", () => { + expect(formatNumber(1234.56)).toBe("1,234.56"); + expect(formatNumber(123.456)).toBe("123.46"); + }); + + it("respects custom precision", () => { + expect(formatNumber(1234.56, { precision: 0 })).toBe("1,235"); + expect(formatNumber(1234.56789, { precision: 4 })).toBe("1,234.5679"); + }); + }); + + describe("abbreviation", () => { + it("does not abbreviate by default", () => { + expect(formatNumber(15000)).toBe("15,000"); + }); + + it("abbreviates when enabled", () => { + expect(formatNumber(15000, { abbreviate: true })).toBe("15K"); + expect(formatNumber(1500000, { abbreviate: true })).toBe("1.5M"); + }); + + it("only abbreviates at 10,000 threshold", () => { + expect(formatNumber(9999, { abbreviate: true })).toBe("9,999"); + expect(formatNumber(10000, { abbreviate: true })).toBe("10K"); + }); + }); + + describe("null/undefined handling", () => { + it("handles null as zero", () => { + expect(formatNumber(null)).toBe("0"); + }); + + it("handles undefined as zero", () => { + expect(formatNumber(undefined)).toBe("0"); + }); + }); + + describe("forceSign", () => { + it("shows + for positive numbers", () => { + expect(formatNumber(10, { forceSign: true })).toBe("+10"); + expect(formatNumber(15000, { abbreviate: true, forceSign: true })).toBe( + "+15K" + ); + }); + + it("shows - for negative numbers", () => { + expect(formatNumber(-10, { forceSign: true })).toBe("-10"); + }); + }); +}); + +describe("formatUSD", () => { + it("formats number with $ prefix", () => { + expect(formatUSD(1234.56)).toBe("$1,234.56"); + }); + + it("respects options", () => { + expect(formatUSD(15000, { abbreviate: true })).toBe("$15K"); + expect(formatUSD(1234.56, { precision: 0 })).toBe("$1,235"); + }); + + it("handles zero/null", () => { + expect(formatUSD(0)).toBe("$0"); + expect(formatUSD(null)).toBe("$0"); + }); + + it("supports forceSign", () => { + expect(formatUSD(10, { forceSign: true })).toBe("$+10"); + }); +}); diff --git a/utils/numberFormatters.ts b/utils/numberFormatters.ts new file mode 100644 index 00000000..54630ce3 --- /dev/null +++ b/utils/numberFormatters.ts @@ -0,0 +1,413 @@ +import numbro from "numbro"; + +/** + * Formatting options for number formatters + */ +export interface FormatOptions { + /** Whether to show the currency/unit symbol (e.g., "LPT", "ETH") */ + showSymbol?: boolean; + /** Whether to use K/M/B/T abbreviations for large numbers */ + abbreviate?: boolean; + /** Number of decimal places to show */ + precision?: number; + /** Whether to show thousand separators (commas) */ + thousandSeparated?: boolean; + /** Whether to trim trailing zeros (e.g. 1.5 instead of 1.50). Default is true (cleaner output). */ + trimZeros?: boolean; + /** Whether to force a sign (+/-) for positive numbers */ + forceSign?: boolean; +} + +/** + * Format LPT token amounts with consistent rules: + * - 2 decimal places by default + * - Show "< 0.01 LPT" for values below 0.01 + * - Use K/M/B/T abbreviations for values >= 10,000 + * - Show "0 LPT" for zero (not "0.00 LPT") + * + * @remarks + * **PREFER using domain-specific wrappers** (e.g. `formatVotingPower`, `formatStakeAmount`) + * when possible. Only use this generic function if no specific wrapper exists for your use case. + * + * @param value - The LPT amount to format (number or string) + * @param options - Optional formatting configuration + * @returns Formatted string (e.g., "1,234.56 LPT", "15K LPT", "< 0.01 LPT") + * + * @example + * formatLPT(0) // "0 LPT" + * formatLPT(0.005) // "< 0.01 LPT" + * formatLPT(1234.56) // "1,234.56 LPT" + * formatLPT(15000) // "15K LPT" + * formatLPT(1.5, { trimZeros: false }) // "1.50 LPT" + */ +export function formatLPT( + value: number | string | null | undefined, + options: FormatOptions = {} +): string { + const { + showSymbol = true, + abbreviate = true, + precision = 2, + thousandSeparated = true, + trimZeros = true, + forceSign = false, + } = options; + + // Handle null/undefined + if (value == null) { + return showSymbol ? "0 LPT" : "0"; + } + + const num = Number(value); + + // Handle NaN + if (isNaN(num)) { + return showSymbol ? "0 LPT" : "0"; + } + + // Handle zero explicitly + if (num === 0) { + return showSymbol ? "0 LPT" : "0"; + } + + // Handle very small positive values + const threshold = Math.pow(10, -precision); + if (num > 0 && num < threshold) { + const thresholdStr = threshold.toFixed(precision); + return showSymbol ? `< ${thresholdStr} LPT` : `< ${thresholdStr}`; + } + + // Handle negative small values (show as negative threshold) + if (num < 0 && num > -threshold) { + const thresholdStr = threshold.toFixed(precision); + return showSymbol ? `> -${thresholdStr} LPT` : `> -${thresholdStr}`; + } + + let formatted: string; + + // Use abbreviations for large numbers (>= 10,000) + if (abbreviate && Math.abs(num) >= 10000) { + formatted = numbro(num) + .format({ + average: true, + mantissa: 2, + trimMantissa: true, + thousandSeparated: false, + forceSign, + }) + .toUpperCase(); + } else { + // Standard formatting with specified precision + formatted = numbro(num).format({ + thousandSeparated, + mantissa: precision, + trimMantissa: trimZeros, + forceSign, + }); + } + + return showSymbol ? `${formatted} LPT` : formatted; +} + +/** + * Format ETH amounts with consistent rules: + * - 4 decimal places by default + * - Show "< 0.0001 ETH" for values below 0.0001 + * - No abbreviations (ETH amounts typically don't get that large) + * - Show "0 ETH" for zero + * + * @param value - The ETH amount to format (number or string) + * @param options - Optional formatting configuration + * @returns Formatted string (e.g., "1.2346 ETH", "< 0.0001 ETH") + * + * @example + * formatETH(0) // "0 ETH" + * formatETH(0.00005) // "< 0.0001 ETH" + * formatETH(1.23456789) // "1.2346 ETH" + * formatETH(100.5) // "100.5 ETH" + */ +export function formatETH( + value: number | string | null | undefined, + options: FormatOptions = {} +): string { + const { + showSymbol = true, + precision = 4, + thousandSeparated = true, + trimZeros = true, + forceSign = false, + abbreviate = false, + } = options; + + // Handle null/undefined + if (value == null) { + return showSymbol ? "0 ETH" : "0"; + } + + const num = Number(value); + + // Handle NaN + if (isNaN(num)) { + return showSymbol ? "0 ETH" : "0"; + } + + // Handle zero explicitly + if (num === 0) { + return showSymbol ? "0 ETH" : "0"; + } + + // Handle very small positive values + const threshold = Math.pow(10, -precision); + if (num > 0 && num < threshold) { + const thresholdStr = threshold.toFixed(precision); + return showSymbol ? `< ${thresholdStr} ETH` : `< ${thresholdStr}`; + } + + // Handle negative small values + if (num < 0 && num > -threshold) { + const thresholdStr = threshold.toFixed(precision); + return showSymbol ? `> -${thresholdStr} ETH` : `> -${thresholdStr}`; + } + + // Use abbreviations for large numbers (>= 10,000) + if (abbreviate && Math.abs(num) >= 10000) { + const abbreviated = numbro(num) + .format({ + average: true, + mantissa: 2, + trimMantissa: true, + thousandSeparated: false, + forceSign, + }) + .toUpperCase(); + return showSymbol ? `${abbreviated} ETH` : abbreviated; + } + + // Standard formatting + const formatted = numbro(num).format({ + thousandSeparated, + mantissa: precision, + trimMantissa: trimZeros, + forceSign, + }); + + return showSymbol ? `${formatted} ETH` : formatted; +} + +/** + * Format percentage values with consistent rules: + * - 2 decimal places for fractional percentages + * - 0 decimal places for whole number percentages + * - Always show % symbol + * + * @param value - The percentage as a decimal (0.5 = 50%) + * @param options - Optional formatting configuration + * @returns Formatted string (e.g., "50%", "12.35%") + * + * @example + * formatPercent(0.5) // "50%" + * formatPercent(0.12345) // "12.35%" + * formatPercent(0.1) // "10%" + * formatPercent(0.001) // "0.10%" + */ +export function formatPercent( + value: number | string | null | undefined, + options: FormatOptions = {} +): string { + const { precision = 2, forceSign = false } = options; + + // Handle null/undefined + if (value == null) { + return "0%"; + } + + const num = Number(value); + + // Handle NaN + if (isNaN(num)) { + return "0%"; + } + + // Handle zero + if (num === 0) { + return "0%"; + } + + // Determine if we need decimals + const percentValue = num * 100; + const isWholeNumber = Math.abs(percentValue % 1) < 0.001; // Account for floating point + + const formatted = numbro(num).format({ + output: "percent", + mantissa: + options.precision !== undefined + ? precision + : isWholeNumber + ? 0 + : precision, + trimMantissa: false, // Percentages should keep alignment usually + forceSign, + }); + + return formatted; +} + +/** + * Format voting power (specialized LPT formatter for voting contexts) + * Uses the same rules as formatLPT but optimized for voting displays + * + * @remarks + * **ALWAYS use this function** for voting power displays to ensure consistency across the app. + * Enforces standard rules: 2 decimals, abbreviation at 10k. + * + * @param value - The voting power amount + * @returns Formatted string (e.g., "1,234.56 LPT", "0 LPT") + * + * @example + * formatVotingPower(0) // "0 LPT" (fixes Issue #442) + * formatVotingPower(1234.56) // "1,234.56 LPT" + * formatVotingPower(15000) // "15k LPT" + */ +export function formatVotingPower( + value: number | string | null | undefined +): string { + return formatLPT(value, { precision: 2, abbreviate: true }); +} + +/** + * Format stake amounts (specialized LPT formatter for staking contexts) + * + * @remarks + * **ALWAYS use this function** for staking displays (delegations, active stake, etc.). + * Enforces standard rules: 2 decimals, abbreviation at 10k. + * + * @param value - The stake amount + * @returns Formatted string + */ +export function formatStakeAmount( + value: number | string | null | undefined +): string { + return formatLPT(value, { precision: 2, abbreviate: true }); +} + +/** + * Format round numbers with consistent rules: + * - No decimal places + * - Thousand separators + * - Prefix with # + * + * @param value - The round number + * @returns Formatted string (e.g., "#12,345") + * + * @example + * formatRound(12345) // "#12,345" + * formatRound(100) // "#100" + */ +export function formatRound( + value: number | string | null | undefined, + options: FormatOptions = {} +): string { + const { precision = 0 } = options; + + // Handle null/undefined + if (value == null) { + return "#0"; + } + + const num = Number(value); + + // Handle NaN + if (isNaN(num)) { + return "#0"; + } + + const formatted = numbro(num).format({ + thousandSeparated: true, + mantissa: precision, + }); + + return `#${formatted}`; +} + +/** + * Generic number formatter with flexible options + * + * @remarks + * **AVOID** using this directly for token amounts. Use `formatLPT` or `formatETH` instead. + * Use this for generic counters, stats, or non-currency values. + * + * @param value - The number to format + * @param options - Formatting options + * @returns Formatted string + * + * @example + * formatNumber(1234.56) // "1,234.56" + * formatNumber(1234.56, { precision: 0 }) // "1,235" + * formatNumber(15000, { abbreviate: true }) // "15K" + */ +export function formatNumber( + value: number | string | null | undefined, + options: FormatOptions = {} +): string { + const { + precision = 2, + abbreviate = false, + thousandSeparated = true, + trimZeros = true, + forceSign = false, + } = options; + + // Handle null/undefined + if (value == null) { + return "0"; + } + + const num = Number(value); + + // Handle NaN + if (isNaN(num)) { + return "0"; + } + + // Handle zero + if (num === 0) { + return "0"; + } + + // Use abbreviations for large numbers if requested + if (abbreviate && Math.abs(num) >= 10000) { + return numbro(num) + .format({ + average: true, + mantissa: 2, + trimMantissa: true, + thousandSeparated: false, + forceSign, + }) + .toUpperCase(); + } + + // Standard formatting + return numbro(num).format({ + thousandSeparated, + mantissa: precision, + trimMantissa: trimZeros, + forceSign, + }); +} + +/** + * Format currency values (USD) + * Wraps formatNumber and adds the currency symbol ($). + * + * @param value - The amount to format + * @param options - Formatting options + * @returns Formatted string (e.g., "$1,234.56", "$15K") + */ +export function formatUSD( + value: number | string | null | undefined, + options: FormatOptions = {} +): string { + const formatted = formatNumber(value, options); + return `$${formatted}`; +} diff --git a/utils/voting.ts b/utils/voting.ts index d3b6b353..d39650b8 100644 --- a/utils/voting.ts +++ b/utils/voting.ts @@ -1,7 +1,7 @@ import { PollExtended } from "@lib/api/polls"; -import { fromWei } from "@lib/utils"; +import { fromWei } from "@utils/web3"; import { AccountQuery, PollChoice } from "apollo"; -import numbro from "numbro"; + export type VotingResponse = { poll: PollExtended; delegateVote: @@ -25,9 +25,6 @@ export type VotingResponse = { myAccount: AccountQuery; }; -export const formatPercent = (percent: number, mantissa = 4) => - numbro(percent).format({ output: "percent", mantissa }); - export function getVotingPower( accountAddress: string, myAccount: VotingResponse["myAccount"], diff --git a/utils/web3.test.ts b/utils/web3.test.ts new file mode 100644 index 00000000..b2a9402b --- /dev/null +++ b/utils/web3.test.ts @@ -0,0 +1,65 @@ +import { checkAddressEquality, formatAddress, fromWei, toWei } from "./web3"; + +describe("checkAddressEquality", () => { + it("returns false for invalid addresses", () => { + expect(checkAddressEquality("not-an-address", "0x123")).toBe(false); + }); + + it("compares valid addresses case-insensitively", () => { + const addrLower = "0x00a0000000000000000000000000000000000001"; + const addrUpper = + "0x00a0000000000000000000000000000000000001".toUpperCase(); + expect(checkAddressEquality(addrLower, addrUpper)).toBe(true); + }); + + it("returns false for different valid addresses", () => { + const addr1 = "0x0000000000000000000000000000000000000001"; + const addr2 = "0x0000000000000000000000000000000000000002"; + expect(checkAddressEquality(addr1, addr2)).toBe(false); + }); +}); + +describe("fromWei", () => { + it("converts string wei to ether string", () => { + const oneEthWei = "1000000000000000000"; + expect(fromWei(oneEthWei)).toBe("1"); + }); + + it("converts bigint wei to ether string", () => { + const twoEthWei = 2000000000000000000n; + expect(fromWei(twoEthWei)).toBe("2"); + }); + + it("handles null/undefined gracefully", () => { + expect(fromWei(null)).toBe("0"); + expect(fromWei(undefined)).toBe("0"); + }); +}); + +describe("toWei", () => { + it("converts ether number to bigint wei", () => { + expect(toWei(1)).toBe(1000000000000000000n); + expect(toWei(0.5)).toBe(500000000000000000n); + }); +}); + +describe("formatAddress", () => { + it("shortens a normal ethereum address", () => { + const addr = "0x1234567890abcdef1234567890abcdef12345678"; + const shortened = formatAddress(addr); + + // Implementation: replace address.slice(5, 39) with "…" + // Note: slice(0,6) keeps 6 chars ("0x" + 4). slice(-4) keeps 4 chars. + // Length check > 21. + // 6 chars start + ... + 4 chars end. + + // Original implementation logic preserved in new file: + // `${addr.slice(0, 6)}…${addr.slice(-4)}` + + expect(shortened).toBe("0x1234…5678"); + }); + + it("returns empty string for falsy address", () => { + expect(formatAddress(null)).toBe(""); + }); +}); diff --git a/utils/web3.ts b/utils/web3.ts new file mode 100644 index 00000000..dd113ac9 --- /dev/null +++ b/utils/web3.ts @@ -0,0 +1,109 @@ +import { ethers } from "ethers"; +import { formatEther, getAddress, parseEther } from "viem"; + +/** + * Ethereum Address Zero (0x0...0) + */ +export const EMPTY_ADDRESS = ethers.constants.AddressZero; + +/** + * Protocol multiplier for percentage values stored with 9 degrees of precision. + * 1,000,000,000 = 100% + */ +export const PERCENTAGE_PRECISION_BILLION = 1000000000; + +/** + * Protocol multiplier for percentage values stored with 6 degrees of precision. + * 1,000,000 = 100% + */ +export const PERCENTAGE_PRECISION_MILLION = 1000000; + +/** + * Protocol multiplier for percentage values stored with 4 degrees of precision (BIPS). + * 10,000 = 100% + */ +export const PERCENTAGE_PRECISION_TEN_THOUSAND = 10000; + +/** + * Check if two addresses are equal, case-insensitive. + * @param address1 - First address + * @param address2 - Second address + * @returns true if addresses match + */ +export const checkAddressEquality = (address1: string, address2: string) => { + try { + const formattedAddress1 = getAddress(address1.toLowerCase()); + const formattedAddress2 = getAddress(address2.toLowerCase()); + + return formattedAddress1 === formattedAddress2; + } catch { + return false; + } +}; + +/** + * Convert Wei (bigint/string) to Ether string + * Safely handles null/undefined by returning "0". + * @param wei - The value in Wei + * @returns String representation in Ether + */ +export const fromWei = (wei: bigint | string | null | undefined) => { + if (wei == null) { + return "0"; + } + if (typeof wei === "string") { + return formatEther(BigInt(wei)); + } + return formatEther(wei); +}; + +/** + * Convert Ether amount (number) to Wei (bigint) + * @param ether - The value in Ether + * @returns BigInt representation in Wei + */ +export const toWei = (ether: number) => parseEther(ether.toString()); + +/** + * Shorten an Ethereum address for display. + * @deprecated Use formatAddress instead + * @param address - The address to shorten. + * @returns The shortened address. + */ +export const shortenAddress = (address: string) => + address?.replace(address.slice(5, 39), "…") ?? ""; + +/** + * Format an address for display (shortens it). + * Handles ENS names (.eth, .xyz) intelligently. + * @param addr - The address or ENS name + * @param startLength - Length of prefix to keep + * @param endLength - Length of suffix to keep + * @returns Formatted address string + */ +export const formatAddress = ( + addr: string | null | undefined, + startLength = 6, + endLength = 4 +): string => { + if (!addr) return ""; + if (addr.endsWith(".xyz")) { + return addr.length > 21 ? `${addr.slice(0, 6)}...${addr.slice(-6)}` : addr; + } + if (addr.endsWith(".eth") && addr.length < 21) { + return addr; + } + return addr.length > 21 + ? `${addr.slice(0, startLength)}…${addr.slice(-endLength)}` + : addr; +}; + +/** + * Format a transaction hash for display (shortens it). + * @param id - The transaction hash + * @returns Shortened hash + */ +export const formatTransactionHash = (id: string | null | undefined) => { + if (!id) return ""; + return id.replace(id.slice(6, 62), "…"); +};