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.
+
+
+ )}
+
+
+ );
+};
+
+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), "…");
+};