From e727b53e4f0dae77bb7b12912a5601cff7a25e72 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Wed, 4 Feb 2026 12:20:43 -0500 Subject: [PATCH 1/7] feat: add numberFormatter and web3 utils --- components/TreasuryVotingWidget/index.tsx | 17 +- lib/utils.test.ts | 58 ---- lib/utils.tsx | 66 ++-- tsconfig.json | 3 +- utils/numberFormatters.test.ts | 303 ++++++++++++++++++ utils/numberFormatters.ts | 361 ++++++++++++++++++++++ utils/web3.test.ts | 65 ++++ utils/web3.ts | 91 ++++++ 8 files changed, 845 insertions(+), 119 deletions(-) create mode 100644 utils/numberFormatters.test.ts create mode 100644 utils/numberFormatters.ts create mode 100644 utils/web3.test.ts create mode 100644 utils/web3.ts diff --git a/components/TreasuryVotingWidget/index.tsx b/components/TreasuryVotingWidget/index.tsx index 311406bd..8920c851 100644 --- a/components/TreasuryVotingWidget/index.tsx +++ b/components/TreasuryVotingWidget/index.tsx @@ -3,11 +3,11 @@ import TreasuryVotingReason from "@components/TreasuryVotingReason"; 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, Text } from "@livepeer/design-system"; import { InfoCircledIcon } from "@modulz/radix-icons"; +import { formatPercent, formatVotingPower } from "@utils/numberFormatters"; +import { formatAddress, fromWei } from "@utils/web3"; import { useAccountAddress } from "hooks"; -import numbro from "numbro"; import { useMemo, useState } from "react"; import { zeroAddress } from "viem"; @@ -18,15 +18,6 @@ type Props = { vote: ProposalVotingPower | undefined | null; }; -const formatPercent = (percent: number) => - numbro(percent).format({ - output: "percent", - mantissa: 2, - }); - -const formatLPT = (lpt: string | undefined) => - abbreviateNumber(fromWei(lpt ?? "0"), 4); - const SectionLabel = ({ children }: { children: React.ReactNode }) => ( { {/* Summary line */} - {abbreviateNumber(proposal.votes.total.voters, 4)} LPT voted ·{" "} + {formatVotingPower(proposal.votes.total.voters)} voted ·{" "} {proposal.state !== "Pending" && proposal.state !== "Active" ? "Final Results" : dayjs.duration(proposal.votes.voteEndTime.diff()).humanize() + @@ -304,7 +295,7 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { fontVariantNumeric: "tabular-nums", }} > - {vote?.self ? formatLPT(vote.self.votes) : "0"} LPT + {formatVotingPower(fromWei(vote?.self?.votes))} diff --git a/lib/utils.test.ts b/lib/utils.test.ts index 02312d45..949cefab 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -4,17 +4,13 @@ import { StakingAction } from "hooks"; import { abbreviateNumber, avg, - checkAddressEquality, EMPTY_ADDRESS, - formatAddress, - fromWei, getDelegatorStatus, getHint, getPercentChange, isImageUrl, simulateNewActiveSetOrder, textTruncate, - toWei, } from "./utils"; describe("avg", () => { @@ -133,25 +129,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 +280,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 +293,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..0d2ad64b 100644 --- a/lib/utils.tsx +++ b/lib/utils.tsx @@ -1,8 +1,26 @@ +import { + checkAddressEquality, + EMPTY_ADDRESS, + formatAddress, + formatTransactionHash, + fromWei, + shortenAddress, + toWei, +} 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 { + checkAddressEquality, + EMPTY_ADDRESS, + formatAddress, + formatTransactionHash, + fromWei, + shortenAddress, + toWei, +}; export const provider = new ethers.providers.JsonRpcProvider( NETWORK_RPC_URLS[DEFAULT_CHAIN_ID][0] @@ -17,8 +35,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"]; @@ -81,17 +97,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 +259,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 @@ -277,8 +273,6 @@ export const isImageUrl = (url: string): boolean => { * @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, @@ -288,25 +282,3 @@ export const lptFormatter = new Intl.NumberFormat("en-US", { 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/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..9316441b --- /dev/null +++ b/utils/numberFormatters.test.ts @@ -0,0 +1,303 @@ +import { + formatETH, + formatLPT, + formatNumber, + formatPercent, + formatRound, + formatStakeAmount, + 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("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("no abbreviation", () => { + it("does not abbreviate large values", () => { + expect(formatETH(15000)).toBe("15,000 ETH"); + expect(formatETH(1000000)).toBe("1,000,000 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("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%"); + }); + }); + + 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("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"); + }); + }); + + 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"); + }); + }); +}); diff --git a/utils/numberFormatters.ts b/utils/numberFormatters.ts new file mode 100644 index 00000000..cfb1b034 --- /dev/null +++ b/utils/numberFormatters.ts @@ -0,0 +1,361 @@ +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; +} + +/** + * 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, + } = 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, + }) + .toUpperCase(); + } else { + // Standard formatting with specified precision + formatted = numbro(num).format({ + thousandSeparated, + mantissa: precision, + trimMantissa: trimZeros, + }); + } + + 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, + } = 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}`; + } + + // Standard formatting (no abbreviations for ETH) + const formatted = numbro(num).format({ + thousandSeparated, + mantissa: precision, + trimMantissa: trimZeros, + }); + + 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 } = 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: isWholeNumber ? 0 : precision, + trimMantissa: false, // Percentages should keep alignment usually + }); + + 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): string { + // 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: 0, + }); + + 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, + } = 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, + }) + .toUpperCase(); + } + + // Standard formatting + return numbro(num).format({ + thousandSeparated, + mantissa: precision, + trimMantissa: trimZeros, + }); +} 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..d3fda50e --- /dev/null +++ b/utils/web3.ts @@ -0,0 +1,91 @@ +import { ethers } from "ethers"; +import { formatEther, getAddress, parseEther } from "viem"; + +/** + * Ethereum Address Zero (0x0...0) + */ +export const EMPTY_ADDRESS = ethers.constants.AddressZero; + +/** + * 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), "…"); +}; From 15a878062c64be358ff5abd8f4aef3382e1d6f0e Mon Sep 17 00:00:00 2001 From: roaring30s Date: Wed, 4 Feb 2026 20:25:26 -0500 Subject: [PATCH 2/7] feat: add formatting --- components/Account/index.tsx | 2 +- components/AccountCell/index.tsx | 3 +- components/Claim/index.tsx | 2 +- components/DelegatingView/index.tsx | 118 +++++------- components/DelegatingWidget/Header.tsx | 2 +- components/DelegatingWidget/Input.tsx | 13 +- components/DelegatingWidget/InputBox.tsx | 2 +- components/DelegatingWidget/ProjectionBox.tsx | 42 ++--- components/DelegatingWidget/index.tsx | 11 +- components/ExplorerChart/index.tsx | 113 ++++++------ components/HistoryView/index.tsx | 97 +++------- components/OrchestratingView/index.tsx | 67 +++---- components/OrchestratorList/index.tsx | 170 +++++++++--------- components/PerformanceList/index.tsx | 22 +-- components/Profile/index.tsx | 2 +- components/RoundStatus/index.tsx | 37 ++-- components/Search/index.tsx | 2 +- components/StakeTransactions/index.tsx | 19 +- components/TransactionsList/index.tsx | 73 ++++---- components/TxConfirmedDialog/index.tsx | 3 +- components/TxStartedDialog/index.tsx | 3 +- components/VotingWidget/index.tsx | 14 +- hooks/useSwr.tsx | 2 +- layouts/account.tsx | 2 +- layouts/main.tsx | 2 +- lib/api/ens.ts | 2 +- lib/api/treasury.ts | 2 +- lib/utils.test.ts | 20 +-- lib/utils.tsx | 50 +----- next-env.d.ts | 2 +- pages/_app.tsx | 3 - pages/api/l1-delegator/[address].tsx | 2 +- pages/api/score/[address].tsx | 3 +- pages/index.tsx | 7 +- pages/migrate/broadcaster.tsx | 2 +- pages/migrate/delegator/index.tsx | 2 +- pages/migrate/orchestrator.tsx | 2 +- pages/treasury/[proposal].tsx | 39 ++-- pages/treasury/create-proposal.tsx | 11 +- pages/voting/[poll].tsx | 17 +- pages/voting/create-poll.tsx | 2 +- utils/numberFormatters.test.ts | 89 ++++++++- utils/numberFormatters.ts | 62 ++++++- utils/web3.ts | 18 ++ 44 files changed, 566 insertions(+), 592 deletions(-) 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 1bfd9c97..a14faf74 100644 --- a/components/HistoryView/index.tsx +++ b/components/HistoryView/index.tsx @@ -1,6 +1,5 @@ import Spinner from "@components/Spinner"; import dayjs from "@lib/dayjs"; -import { formatAddress, formatTransactionHash } from "@lib/utils"; import { Box, Card as CardBase, @@ -9,10 +8,12 @@ import { styled, } from "@livepeer/design-system"; import { ExternalLinkIcon } from "@modulz/radix-icons"; +import { formatETH, formatLPT, formatPercent } from "@utils/numberFormatters"; +import { formatAddress, formatTransactionHash } from "@utils/web3"; +import { PERCENTAGE_PRECISION_TEN_THOUSAND } from "@utils/web3"; import { useTransactionsQuery } from "apollo"; import { CHAIN_INFO, DEFAULT_CHAIN_ID } from "lib/chains"; import { useRouter } from "next/router"; -import numbro from "numbro"; import { useMemo } from "react"; import InfiniteScroll from "react-infinite-scroll-component"; @@ -220,13 +221,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.additionalAmount).format({ - mantissa: 1, - average: true, - })} - {" "} - LPT + +{formatLPT(event.additionalAmount, { precision: 1 })} + @@ -337,13 +333,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.amount).format({ - mantissa: 1, - average: true, - })} - {" "} - LPT + +{formatLPT(event.amount, { precision: 1 })} + @@ -399,13 +390,8 @@ function renderSwitch(event, i: number) { {" "} - - - {numbro(event.amount).format({ - mantissa: 1, - average: true, - })} - {" "} - LPT + -{formatLPT(event.amount, { precision: 1 })} + @@ -461,13 +447,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.rewardTokens).format({ - mantissa: 2, - average: true, - })} - {" "} - LPT + +{formatLPT(event.rewardTokens, { precision: 2 })} + @@ -521,15 +502,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 {" "} @@ -585,12 +569,8 @@ function renderSwitch(event, i: number) { {" "} - {numbro(event.amount).format({ - mantissa: 2, - average: true, - })} - {" "} - LPT + {formatLPT(event.amount, { precision: 2 })} + @@ -644,12 +624,8 @@ function renderSwitch(event, i: number) { {" "} - {numbro(event.amount).format({ - mantissa: 3, - average: true, - })} - {" "} - ETH + {formatETH(event.amount, { precision: 3 })} + @@ -703,13 +679,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.faceValue).format({ - mantissa: 3, - average: true, - })} - {" "} - ETH + +{formatETH(event.faceValue, { precision: 3 })} + @@ -763,13 +734,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.amount).format({ - mantissa: 2, - average: true, - })} - {" "} - ETH + +{formatETH(event.amount, { precision: 2 })} + @@ -828,13 +794,8 @@ function renderSwitch(event, i: number) { {" "} - + - {numbro(event.amount).format({ - mantissa: 2, - average: true, - })} - {" "} - ETH + +{formatETH(event.amount, { precision: 2 })} + diff --git a/components/OrchestratingView/index.tsx b/components/OrchestratingView/index.tsx index df2cf494..9a1081ed 100644 --- a/components/OrchestratingView/index.tsx +++ b/components/OrchestratingView/index.tsx @@ -2,10 +2,16 @@ import Stat from "@components/Stat"; import dayjs from "@lib/dayjs"; import { Box, Flex } from "@livepeer/design-system"; import { CheckIcon, Cross1Icon } from "@modulz/radix-icons"; +import { + formatETH, + formatNumber, + formatPercent, + formatStakeAmount, +} from "@utils/numberFormatters"; +import { PERCENTAGE_PRECISION_MILLION } from "@utils/web3"; import { AccountQueryResult } from "apollo"; import { useScoreData } from "hooks"; import { useRegionsData } from "hooks/useSwr"; -import numbro from "numbro"; import { useMemo } from "react"; import Masonry from "react-masonry-css"; @@ -63,10 +69,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]); @@ -78,11 +83,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} ) : ( "" @@ -125,14 +126,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" } /> @@ -221,12 +203,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" } /> diff --git a/components/OrchestratorList/index.tsx b/components/OrchestratorList/index.tsx index e06ae1d7..0aa11f39 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"} @@ -764,8 +773,8 @@ const OrchestratorList = ({ }} size="2" > - {numbro(AVERAGE_L1_BLOCK_TIME).format({ - mantissa: 0, + {formatNumber(AVERAGE_L1_BLOCK_TIME, { + precision: 0, })} {" seconds"} @@ -790,9 +799,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 +826,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 +875,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 +908,7 @@ const OrchestratorList = ({ }} size="2" > - {numbro(row.values.ninetyDayVolumeETH).format({ - mantissa: 2, - average: true, - })}{" "} - ETH + {formatETH(row.values.ninetyDayVolumeETH, { precision: 2 })} ), @@ -1179,8 +1182,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..340d746c 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"; @@ -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/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/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index f887da8a..fd171402 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -1,13 +1,22 @@ import Table from "@components/Table"; import dayjs from "@lib/dayjs"; -import { formatTransactionHash } from "@lib/utils"; import { Badge, Box, Flex, Link as A, Text } from "@livepeer/design-system"; import { ArrowTopRightIcon } from "@modulz/radix-icons"; +import { + formatETH, + formatLPT, + formatPercent, + formatRound, +} from "@utils/numberFormatters"; +import { formatTransactionHash } from "@utils/web3"; +import { + PERCENTAGE_PRECISION_BILLION, + PERCENTAGE_PRECISION_MILLION, +} from "@utils/web3"; import { EventsQueryResult } from "apollo"; import { sentenceCase } from "change-case"; import { useEnsData } from "hooks"; import Link from "next/link"; -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 })} ); }; @@ -246,9 +250,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": @@ -312,14 +320,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": @@ -355,12 +363,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 } + )} ); @@ -393,7 +400,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/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 6b8df896..5ef9bd88 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 index 73f07e59..b772e1e1 100644 --- a/components/VotingWidget/index.tsx +++ b/components/VotingWidget/index.tsx @@ -1,6 +1,5 @@ import { PollExtended } from "@lib/api/polls"; import dayjs from "@lib/dayjs"; -import { abbreviateNumber, formatAddress, fromWei } from "@lib/utils"; import { Box, Button, @@ -14,9 +13,10 @@ import { 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 numbro from "numbro"; import { useEffect, useMemo, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; @@ -47,12 +47,6 @@ type Props = { myAccount: AccountQuery; }; -const formatPercent = (percent: number) => - numbro(percent).format({ - output: "percent", - mantissa: 4, - }); - const Index = ({ data }: { data: Props }) => { const accountAddress = useAccountAddress(); const [copied, setCopied] = useState(false); @@ -215,7 +209,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 @@ -280,7 +274,7 @@ const Index = ({ data }: { data: Props }) => { css={{ fontWeight: 500, color: "$hiContrast" }} > - {abbreviateNumber(votingPower, 4)} LPT ( + {formatVotingPower(votingPower)} ( {( (+votingPower / (data.poll.stake.nonVoters + 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 04f6db31..165c537a 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 { Button, Container, @@ -16,6 +15,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 949cefab..1681dcd5 100644 --- a/lib/utils.test.ts +++ b/lib/utils.test.ts @@ -1,10 +1,9 @@ +import { EMPTY_ADDRESS } from "@utils/web3"; import { AccountQueryResult } from "apollo"; import { StakingAction } from "hooks"; import { - abbreviateNumber, avg, - EMPTY_ADDRESS, getDelegatorStatus, getHint, getPercentChange, @@ -32,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 = {}) => ({ diff --git a/lib/utils.tsx b/lib/utils.tsx index 0d2ad64b..74e83175 100644 --- a/lib/utils.tsx +++ b/lib/utils.tsx @@ -1,27 +1,9 @@ -import { - checkAddressEquality, - EMPTY_ADDRESS, - formatAddress, - formatTransactionHash, - fromWei, - shortenAddress, - toWei, -} from "@utils/web3"; +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"; -export { - checkAddressEquality, - EMPTY_ADDRESS, - formatAddress, - formatTransactionHash, - fromWei, - shortenAddress, - toWei, -}; - export const provider = new ethers.providers.JsonRpcProvider( NETWORK_RPC_URLS[DEFAULT_CHAIN_ID][0] ); @@ -35,21 +17,6 @@ export function avg(obj, key) { return (arr?.reduce(sum)?.[key] ?? 0) / arr.length; } -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: @@ -267,18 +234,3 @@ export const getPercentChange = (valueNow, value24HoursAgo) => { 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 lptFormatter = new Intl.NumberFormat("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); - -export const formatLpt = (w: string) => { - return `${lptFormatter.format(parseFloat(w) / 1e18)} LPT`; -}; diff --git a/next-env.d.ts b/next-env.d.ts index 7996d352..19709046 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 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 }) => { - numbro(percent).format({ mantissa: 4, output: "percent" }); - const blockExplorerLink = (address: string) => `${CHAIN_INFO[DEFAULT_CHAIN_ID].explorer}address/${address}`; @@ -257,7 +262,10 @@ const Proposal = () => { `} label={ - Total Support ({formatPercent(+proposal.quota / 1000000)} + Total Support ( + {formatPercent( + +proposal.quota / PERCENTAGE_PRECISION_MILLION + )} needed) } @@ -285,7 +293,7 @@ const Proposal = () => { - {abbreviateNumber(proposal.votes.total.for, 4)} LPT + {formatVotingPower(proposal.votes.total.for)} { - {abbreviateNumber(proposal.votes.total.against, 4)}{" "} - LPT + {formatVotingPower(proposal.votes.total.against)} { - {abbreviateNumber(proposal.votes.total.abstain, 4)}{" "} - LPT + {formatVotingPower(proposal.votes.total.abstain)} @@ -363,8 +369,7 @@ const Proposal = () => { - {abbreviateNumber(proposal.votes.total.voters, 4)}{" "} - LPT + {formatVotingPower(proposal.votes.total.voters)} @@ -381,11 +386,7 @@ const Proposal = () => { - {abbreviateNumber( - proposal.votes.total.nonVoters, - 4 - )}{" "} - LPT + {formatVotingPower(proposal.votes.total.nonVoters)} @@ -478,7 +479,7 @@ const Proposal = () => { }} size="2" > - {fromWei(action.lptTransfer.amount)} LPT + {formatLPT(fromWei(action.lptTransfer.amount))} 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 5a2e5197..d507d0ac 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -17,6 +17,7 @@ import { Heading, Text, } from "@livepeer/design-system"; +import { formatPercent, formatVotingPower } from "@utils/numberFormatters"; import { AccountQuery, PollChoice, @@ -27,7 +28,6 @@ import { import { sentenceCase } from "change-case"; import Head from "next/head"; import { useRouter } from "next/router"; -import numbro from "numbro"; import { useEffect, useState } from "react"; import { useWindowSize } from "react-use"; @@ -36,15 +36,8 @@ import { useCurrentRoundData, useExplorerStore, } from "../../hooks"; -import { abbreviateNumber } from "../../lib/utils"; import FourZeroFour from "../404"; -const formatPercent = (percent: number) => - numbro(percent).format({ - output: "percent", - mantissa: 4, - }); - const Poll = () => { const router = useRouter(); const accountAddress = useAccountAddress(); @@ -248,7 +241,7 @@ const Poll = () => { Yes ({formatPercent(pollData.percent.yes)}) - {abbreviateNumber(pollData.stake.yes, 4)} LPT + {formatVotingPower(pollData.stake.yes)} { No ({formatPercent(pollData.percent.no)}) - {abbreviateNumber(pollData.stake.no, 4)} LPT + {formatVotingPower(pollData.stake.no)} @@ -292,7 +285,7 @@ const Poll = () => { - {abbreviateNumber(pollData.stake.voters, 4)} LPT + {formatVotingPower(pollData.stake.voters)} @@ -309,7 +302,7 @@ const Poll = () => { - {abbreviateNumber(pollData.stake.nonVoters, 4)} LPT + {formatVotingPower(pollData.stake.nonVoters)} 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/utils/numberFormatters.test.ts b/utils/numberFormatters.test.ts index 9316441b..615fa28f 100644 --- a/utils/numberFormatters.test.ts +++ b/utils/numberFormatters.test.ts @@ -5,6 +5,7 @@ import { formatPercent, formatRound, formatStakeAmount, + formatUSD, formatVotingPower, } from "./numberFormatters"; @@ -120,6 +121,16 @@ describe("formatLPT", () => { 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", () => { @@ -153,10 +164,20 @@ describe("formatETH", () => { }); }); - describe("no abbreviation", () => { - it("does not abbreviate large values", () => { + 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"); - expect(formatETH(1000000)).toBe("1,000,000 ETH"); + }); + + it("forceSign works with abbreviation", () => { + expect(formatETH(15000, { abbreviate: true, forceSign: true })).toBe( + "+15K ETH" + ); }); }); @@ -169,6 +190,12 @@ describe("formatETH", () => { expect(formatETH(undefined)).toBe("0 ETH"); }); }); + + describe("forceSign", () => { + it("shows + for positive numbers", () => { + expect(formatETH(10, { forceSign: true })).toBe("+10 ETH"); + }); + }); }); describe("formatPercent", () => { @@ -193,6 +220,13 @@ describe("formatPercent", () => { 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", () => { @@ -204,6 +238,17 @@ describe("formatPercent", () => { 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", () => { @@ -237,6 +282,11 @@ describe("formatRound", () => { 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", () => { @@ -300,4 +350,37 @@ describe("formatNumber", () => { 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 index cfb1b034..54630ce3 100644 --- a/utils/numberFormatters.ts +++ b/utils/numberFormatters.ts @@ -14,6 +14,8 @@ export interface FormatOptions { 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; } /** @@ -48,6 +50,7 @@ export function formatLPT( precision = 2, thousandSeparated = true, trimZeros = true, + forceSign = false, } = options; // Handle null/undefined @@ -90,6 +93,7 @@ export function formatLPT( mantissa: 2, trimMantissa: true, thousandSeparated: false, + forceSign, }) .toUpperCase(); } else { @@ -98,6 +102,7 @@ export function formatLPT( thousandSeparated, mantissa: precision, trimMantissa: trimZeros, + forceSign, }); } @@ -130,6 +135,8 @@ export function formatETH( precision = 4, thousandSeparated = true, trimZeros = true, + forceSign = false, + abbreviate = false, } = options; // Handle null/undefined @@ -162,11 +169,26 @@ export function formatETH( return showSymbol ? `> -${thresholdStr} ETH` : `> -${thresholdStr}`; } - // Standard formatting (no abbreviations for ETH) + // 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; @@ -192,7 +214,7 @@ export function formatPercent( value: number | string | null | undefined, options: FormatOptions = {} ): string { - const { precision = 2 } = options; + const { precision = 2, forceSign = false } = options; // Handle null/undefined if (value == null) { @@ -217,8 +239,14 @@ export function formatPercent( const formatted = numbro(num).format({ output: "percent", - mantissa: isWholeNumber ? 0 : precision, + mantissa: + options.precision !== undefined + ? precision + : isWholeNumber + ? 0 + : precision, trimMantissa: false, // Percentages should keep alignment usually + forceSign, }); return formatted; @@ -275,7 +303,12 @@ export function formatStakeAmount( * formatRound(12345) // "#12,345" * formatRound(100) // "#100" */ -export function formatRound(value: number | string | null | undefined): string { +export function formatRound( + value: number | string | null | undefined, + options: FormatOptions = {} +): string { + const { precision = 0 } = options; + // Handle null/undefined if (value == null) { return "#0"; @@ -290,7 +323,7 @@ export function formatRound(value: number | string | null | undefined): string { const formatted = numbro(num).format({ thousandSeparated: true, - mantissa: 0, + mantissa: precision, }); return `#${formatted}`; @@ -321,6 +354,7 @@ export function formatNumber( abbreviate = false, thousandSeparated = true, trimZeros = true, + forceSign = false, } = options; // Handle null/undefined @@ -348,6 +382,7 @@ export function formatNumber( mantissa: 2, trimMantissa: true, thousandSeparated: false, + forceSign, }) .toUpperCase(); } @@ -357,5 +392,22 @@ export function formatNumber( 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/web3.ts b/utils/web3.ts index d3fda50e..dd113ac9 100644 --- a/utils/web3.ts +++ b/utils/web3.ts @@ -6,6 +6,24 @@ import { formatEther, getAddress, parseEther } from "viem"; */ 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 From 4ffe2ea2d305e0ca7d59c10c5433ba72d88f89e1 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Thu, 5 Feb 2026 10:49:15 -0500 Subject: [PATCH 3/7] fix: fix bug --- pages/voting/[poll].tsx | 2 -- utils/voting.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index ec64ed5d..d93702f4 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -20,7 +20,6 @@ import { import { formatLPT, formatPercent, - formatVotingPower, } from "@utils/numberFormatters"; import { PERCENTAGE_PRECISION_TEN_THOUSAND } from "@utils/web3"; import { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; @@ -43,7 +42,6 @@ import { } from "../../hooks"; import FourZeroFour from "../404"; - const Poll = () => { const router = useRouter(); const accountAddress = useAccountAddress(); diff --git a/utils/voting.ts b/utils/voting.ts index d3b6b353..7cd9dfcf 100644 --- a/utils/voting.ts +++ b/utils/voting.ts @@ -1,5 +1,5 @@ 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 = { From 3ad0919a69fb6890d778ad26c0f92619b1bdc21b Mon Sep 17 00:00:00 2001 From: roaring30s Date: Thu, 5 Feb 2026 10:52:41 -0500 Subject: [PATCH 4/7] fix: remove extra formatPercent --- utils/voting.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/utils/voting.ts b/utils/voting.ts index 7cd9dfcf..d39650b8 100644 --- a/utils/voting.ts +++ b/utils/voting.ts @@ -1,7 +1,7 @@ import { PollExtended } from "@lib/api/polls"; 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"], From 88118ae6a3bacc50ee3d5a1a0dc3c6445cca313c Mon Sep 17 00:00:00 2001 From: roaring30s Date: Thu, 5 Feb 2026 11:43:33 -0500 Subject: [PATCH 5/7] fix: fix UI bugs --- .../Treasury/TreasuryVotingWidget/index.tsx | 5 +- pages/treasury/[proposal].tsx | 52 +++++++++---------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/components/Treasury/TreasuryVotingWidget/index.tsx b/components/Treasury/TreasuryVotingWidget/index.tsx index 25c42a86..596d0008 100644 --- a/components/Treasury/TreasuryVotingWidget/index.tsx +++ b/components/Treasury/TreasuryVotingWidget/index.tsx @@ -257,7 +257,10 @@ const TreasuryVotingWidget = ({ proposal, vote, ...props }: Props) => { {/* Summary line */} - {formatNumber(proposal.votes.total.voters, { precision: 0 })}{" "} + {formatNumber(proposal.votes.total.voters, { + precision: 0, + abbreviate: true, + })}{" "} voted ·{" "} {proposal.state !== "Pending" && proposal.state !== "Active" ? "Final Results" diff --git a/pages/treasury/[proposal].tsx b/pages/treasury/[proposal].tsx index f5026a74..186e0090 100644 --- a/pages/treasury/[proposal].tsx +++ b/pages/treasury/[proposal].tsx @@ -25,20 +25,16 @@ import { Text, } from "@livepeer/design-system"; import { - formatLPT, - formatPercent, - formatVotingPower, -} from "@utils/numberFormatters"; + CheckCircledIcon, + CrossCircledIcon, + MinusCircledIcon, +} from "@radix-ui/react-icons"; +import { formatLPT, formatPercent } from "@utils/numberFormatters"; import { formatAddress, fromWei, PERCENTAGE_PRECISION_MILLION, } from "@utils/web3"; -import { - CheckCircledIcon, - CrossCircledIcon, - MinusCircledIcon, -} from "@radix-ui/react-icons"; import { useProtocolQuery, useTreasuryProposalQuery, @@ -402,7 +398,9 @@ const Proposal = () => { label={ Total Support ( - {formatPercent(+proposal.quota / PERCENTAGE_PRECISION_MILLION)} + {formatPercent( + +proposal.quota / PERCENTAGE_PRECISION_MILLION + )}{" "} needed) } @@ -439,7 +437,9 @@ const Proposal = () => { - {formatLPT(proposal.votes.total.for, { precision: 4 })} + {formatLPT(proposal.votes.total.for, { + precision: 4, + })} { - {formatLPT( - proposal.votes.total.against, - { precision: 4 } - )} + {formatLPT(proposal.votes.total.against, { + precision: 4, + })} { - {formatLPT( - proposal.votes.total.abstain, - { precision: 4 } - )} + {formatLPT(proposal.votes.total.abstain, { + precision: 4, + })} @@ -514,7 +512,7 @@ const Proposal = () => { Total Participation ( {formatPercent( +proposal.quorum / +proposal.totalVoteSupply - )} + )}{" "} needed) } @@ -539,10 +537,9 @@ const Proposal = () => { - {formatLPT( - proposal.votes.total.voters, - { precision: 4 } - )} + {formatLPT(proposal.votes.total.voters, { + precision: 4, + })} @@ -559,10 +556,9 @@ const Proposal = () => { - {formatLPT( - proposal.votes.total.nonVoters, - { precision: 4 } - )} + {formatLPT(proposal.votes.total.nonVoters, { + precision: 4, + })} From 6c56b1dfeff8fcf3e0cd5b0a00fb00d0d69768fa Mon Sep 17 00:00:00 2001 From: roaring30s Date: Thu, 5 Feb 2026 12:29:17 -0500 Subject: [PATCH 6/7] fix: add abbreviation --- components/Treasury/TreasuryVoteTable/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Treasury/TreasuryVoteTable/index.tsx b/components/Treasury/TreasuryVoteTable/index.tsx index b674aa6b..459caa9e 100644 --- a/components/Treasury/TreasuryVoteTable/index.tsx +++ b/components/Treasury/TreasuryVoteTable/index.tsx @@ -127,7 +127,7 @@ const Index: React.FC = ({ proposalId }) => { const formatWeight = useMemo( () => (w: string) => - `${formatLPT(parseFloat(w))} (${ + `${formatLPT(parseFloat(w), { abbreviate: false })} (${ totalWeight > 0 ? formatPercent(parseFloat(w) / totalWeight) : "0" })`, [totalWeight] From 73d4b188a3b5c401ba656327225d20c570d7b503 Mon Sep 17 00:00:00 2001 From: roaring30s Date: Thu, 5 Feb 2026 13:08:35 -0500 Subject: [PATCH 7/7] fix: remove linting errors --- components/OrchestratingView/index.tsx | 4 ++-- components/TransactionsList/index.tsx | 3 +-- pages/voting/[poll].tsx | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/components/OrchestratingView/index.tsx b/components/OrchestratingView/index.tsx index 022fabfa..37b55278 100644 --- a/components/OrchestratingView/index.tsx +++ b/components/OrchestratingView/index.tsx @@ -1,5 +1,7 @@ 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, @@ -7,8 +9,6 @@ import { formatStakeAmount, } from "@utils/numberFormatters"; import { PERCENTAGE_PRECISION_MILLION } from "@utils/web3"; -import { Box, Flex, Link as A, Text } from "@livepeer/design-system"; -import { ArrowTopRightIcon, CheckIcon, Cross1Icon } from "@modulz/radix-icons"; import { AccountQueryResult, OrderDirection, diff --git a/components/TransactionsList/index.tsx b/components/TransactionsList/index.tsx index 8b0d8abc..d1d45d0c 100644 --- a/components/TransactionsList/index.tsx +++ b/components/TransactionsList/index.tsx @@ -4,8 +4,7 @@ import TransactionBadge from "@components/TransactionBadge"; import { parseProposalText } from "@lib/api/treasury"; import { VOTING_SUPPORT_MAP } from "@lib/api/types/votes"; import dayjs from "@lib/dayjs"; -import { Badge, Box, Flex, Text, Link as A } from "@livepeer/design-system"; - +import { Badge, Box, Flex, Link as A, Text } from "@livepeer/design-system"; import { formatETH, formatLPT, diff --git a/pages/voting/[poll].tsx b/pages/voting/[poll].tsx index d93702f4..8375d0a2 100644 --- a/pages/voting/[poll].tsx +++ b/pages/voting/[poll].tsx @@ -17,12 +17,12 @@ import { Heading, 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 { CheckCircledIcon, CrossCircledIcon } from "@radix-ui/react-icons"; import { AccountQuery, PollChoice, @@ -35,6 +35,7 @@ import Head from "next/head"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { useWindowSize } from "react-use"; + import { useAccountAddress, useCurrentRoundData,