From 92f8dec37c597b591f84b2021f40b44d9441bd47 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 21 Jan 2026 14:48:34 -0300 Subject: [PATCH 1/2] feat(gas-tracker): add gas tracker page with L2 support - Add GasPrices type and eth_feeHistory RPC method to NetworkAdapter - Create dedicated gas tracker page with Low/Avg/High tiers - Show transaction cost estimates for common tx types - Add gas price indicator to navbar with fuel pump icon - Update dashboard to show gas tiers with link to gas tracker - Support L2 networks (Optimism, Base) with Mwei/Kwei units - Extract formatGasPrice utility to reduce code duplication --- src/App.tsx | 2 + src/components/LazyComponents.tsx | 2 + .../navbar/NetworkBlockIndicator.tsx | 75 +++- src/components/navbar/index.tsx | 32 ++ src/components/pages/gastracker/index.tsx | 253 +++++++++++++ .../pages/network/DashboardStats.tsx | 60 ++- src/components/pages/network/index.tsx | 2 + src/hooks/useNetworkDashboard.ts | 10 +- .../ArbitrumAdapter/ArbitrumAdapter.ts | 6 +- .../adapters/BNBAdapter/BNBAdapter.ts | 6 +- .../adapters/BaseAdapter/BaseAdapter.ts | 6 +- .../adapters/EVMAdapter/EVMAdapter.ts | 4 + src/services/adapters/NetworkAdapter.ts | 93 ++++- .../OptimismAdapter/OptimismAdapter.ts | 6 +- .../adapters/PolygonAdapter/PolygonAdapter.ts | 7 +- src/styles/components.css | 343 +++++++++++++++++- src/types/index.ts | 8 + src/utils/formatUtils.ts | 58 +++ 18 files changed, 928 insertions(+), 45 deletions(-) create mode 100644 src/components/pages/gastracker/index.tsx create mode 100644 src/utils/formatUtils.ts diff --git a/src/App.tsx b/src/App.tsx index d674821..221fa6b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { LazyChain, LazyContact, LazyDevTools, + LazyGasTracker, LazyHome, LazyMempool, LazyProfile, @@ -114,6 +115,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/LazyComponents.tsx b/src/components/LazyComponents.tsx index 7fb655e..a68aa96 100644 --- a/src/components/LazyComponents.tsx +++ b/src/components/LazyComponents.tsx @@ -19,6 +19,7 @@ const Profile = lazy(() => import("./pages/profile")); const Supporters = lazy(() => import("./pages/supporters")); const Contact = lazy(() => import("./pages/contact")); const Search = lazy(() => import("./pages/search")); +const GasTracker = lazy(() => import("./pages/gastracker")); // Higher-order component to wrap lazy components with Suspense // biome-ignore lint/suspicious/noExplicitAny: @@ -51,5 +52,6 @@ export const LazyProfile = withSuspense(Profile); export const LazySupporters = withSuspense(Supporters); export const LazyContact = withSuspense(Contact); export const LazySearch = withSuspense(Search); +export const LazyGasTracker = withSuspense(GasTracker); // Default exports for backward compatibility export { Home }; diff --git a/src/components/navbar/NetworkBlockIndicator.tsx b/src/components/navbar/NetworkBlockIndicator.tsx index 598698a..162cd55 100644 --- a/src/components/navbar/NetworkBlockIndicator.tsx +++ b/src/components/navbar/NetworkBlockIndicator.tsx @@ -1,9 +1,10 @@ import { useContext, useEffect, useMemo, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { getRPCUrls } from "../../config/rpcConfig"; import { AppContext, useNetworks } from "../../context/AppContext"; import { RpcClient } from "@openscan/network-connectors"; -import { NetworkIcon } from "../common/NetworkIcon"; +import { useDataService } from "../../hooks/useDataService"; +import { formatGasPrice } from "../../utils/formatUtils"; interface NetworkBlockIndicatorProps { className?: string; @@ -11,9 +12,11 @@ interface NetworkBlockIndicatorProps { export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) { const location = useLocation(); + const navigate = useNavigate(); const { rpcUrls } = useContext(AppContext); const { getNetwork } = useNetworks(); const [blockNumber, setBlockNumber] = useState(null); + const [gasPrice, setGasPrice] = useState(null); const [isLoading, setIsLoading] = useState(false); // Extract networkId from the pathname (e.g., /1/blocks -> 1) @@ -25,17 +28,19 @@ export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) }, [location.pathname]); const network = networkId ? getNetwork(networkId) : undefined; + const dataService = useDataService(networkId || 1); useEffect(() => { if (!networkId) { setBlockNumber(null); + setGasPrice(null); return; } let isMounted = true; let intervalId: NodeJS.Timeout | null = null; - const fetchBlockNumber = async () => { + const fetchData = async () => { try { const urls = getRPCUrls(networkId, rpcUrls); const client = new RpcClient(urls[0] || ""); @@ -50,13 +55,25 @@ export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) setIsLoading(false); } } + + // Fetch gas price + if (dataService && rpcUrls[networkId]) { + try { + const gasPricesResult = await dataService.networkAdapter.getGasPrices(); + if (isMounted && gasPricesResult.data) { + setGasPrice(gasPricesResult.data.average); + } + } catch (error) { + console.error("Failed to fetch gas price:", error); + } + } }; setIsLoading(true); - fetchBlockNumber(); + fetchData(); - // Poll for new blocks every 12 seconds (Ethereum average block time) - intervalId = setInterval(fetchBlockNumber, 12000); + // Poll every 12 seconds + intervalId = setInterval(fetchData, 12000); return () => { isMounted = false; @@ -64,7 +81,7 @@ export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) clearInterval(intervalId); } }; - }, [networkId, rpcUrls]); + }, [networkId, rpcUrls, dataService]); if (!networkId || !network) return null; @@ -74,13 +91,49 @@ export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) style={{ "--network-color": network.color } as React.CSSProperties} title={network.name} > -
-
- -
+
{isLoading ? "..." : blockNumber !== null ? `#${blockNumber.toLocaleString()}` : "---"} +
navigate(`/${networkId}/gastracker`)} + > + + {gasPrice ? formatGasPrice(gasPrice).value : "..."} +
); } diff --git a/src/components/navbar/index.tsx b/src/components/navbar/index.tsx index 2be0b57..3ea2cf3 100644 --- a/src/components/navbar/index.tsx +++ b/src/components/navbar/index.tsx @@ -430,6 +430,38 @@ const Navbar = () => { Transactions + )} diff --git a/src/components/pages/gastracker/index.tsx b/src/components/pages/gastracker/index.tsx new file mode 100644 index 0000000..6087470 --- /dev/null +++ b/src/components/pages/gastracker/index.tsx @@ -0,0 +1,253 @@ +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { useParams } from "react-router-dom"; +import { AppContext, useNetwork } from "../../../context/AppContext"; +import { useDataService } from "../../../hooks/useDataService"; +import { getNativeTokenPrice } from "../../../services/PriceService"; +import type { GasPrices } from "../../../types"; +import { formatGasPrice } from "../../../utils/formatUtils"; +import Loader from "../../common/Loader"; + +const REFRESH_INTERVAL = 15000; // 15 seconds + +// Common transaction gas estimates +const TX_GAS_ESTIMATES = [ + { name: "ETH Transfer", gas: 21000, description: "Native token transfer" }, + { name: "ERC20 Transfer", gas: 65000, description: "Token transfer" }, + { name: "ERC20 Approve", gas: 46000, description: "Token approval" }, + { name: "NFT Transfer", gas: 85000, description: "ERC721 transfer" }, + { name: "Swap", gas: 150000, description: "DEX swap" }, + { name: "NFT Mint", gas: 120000, description: "Mint an NFT" }, + { name: "Contract Deploy (Simple)", gas: 300000, description: "Deploy basic contract" }, +]; + +interface GasTrackerData { + gasPrices: GasPrices | null; + price: number | null; + loading: boolean; + error: string | null; + lastUpdated: number | null; +} + +function formatUsd(value: number | null): string { + if (value === null || Number.isNaN(value)) return "—"; + if (value < 0.01) return "<$0.01"; + return `$${value.toFixed(2)}`; +} + +function calculateTxCost( + gasPrice: string, + gasLimit: number, + ethPrice: number | null, +): { eth: string; usd: string } { + try { + const gasPriceWei = BigInt(gasPrice); + const costWei = gasPriceWei * BigInt(gasLimit); + const costEth = Number(costWei) / 1e18; + + // Format ETH with appropriate decimals, max 6 decimals + let ethFormatted: string; + if (costEth >= 0.01) { + ethFormatted = costEth.toFixed(4); + } else if (costEth >= 0.0001) { + ethFormatted = costEth.toFixed(6); + } else { + ethFormatted = costEth.toFixed(8); + } + const usdValue = ethPrice ? costEth * ethPrice : null; + + return { + eth: ethFormatted, + usd: formatUsd(usdValue), + }; + } catch { + return { eth: "—", usd: "—" }; + } +} + +export default function GasTracker() { + const { networkId } = useParams<{ networkId?: string }>(); + const numericNetworkId = Number(networkId) || 1; + const networkConfig = useNetwork(numericNetworkId); + const { rpcUrls } = useContext(AppContext); + const dataService = useDataService(numericNetworkId); + const [data, setData] = useState({ + gasPrices: null, + price: null, + loading: true, + error: null, + lastUpdated: null, + }); + const isFetchingRef = useRef(false); + + const rpcUrl = rpcUrls[numericNetworkId]?.[0] || null; + const mainnetRpcUrl = rpcUrls[1]?.[0] || null; + const currency = networkConfig?.currency || "ETH"; + + const fetchGasData = useCallback(async () => { + if (!dataService || isFetchingRef.current) return; + + isFetchingRef.current = true; + + try { + const [gasPricesResult, priceResult] = await Promise.all([ + dataService.networkAdapter.getGasPrices(), + rpcUrl + ? getNativeTokenPrice(numericNetworkId, rpcUrl, mainnetRpcUrl || undefined) + : Promise.resolve(null), + ]); + + setData({ + gasPrices: gasPricesResult.data, + price: priceResult, + loading: false, + error: null, + lastUpdated: Date.now(), + }); + } catch (err) { + setData((prev) => ({ + ...prev, + loading: false, + error: err instanceof Error ? err.message : "Failed to fetch gas data", + })); + } finally { + isFetchingRef.current = false; + } + }, [dataService, numericNetworkId, rpcUrl, mainnetRpcUrl]); + + useEffect(() => { + fetchGasData(); + }, [fetchGasData]); + + useEffect(() => { + const intervalId = setInterval(fetchGasData, REFRESH_INTERVAL); + return () => clearInterval(intervalId); + }, [fetchGasData]); + + const { gasPrices, price, loading, error } = data; + + return ( +
+
+
+

Gas Tracker

+
+ + {loading && !gasPrices && } + + {error &&

Error: {error}

} + + {gasPrices && ( + <> +
+

+ Based on the last 20 blocks. Prices include base fee + priority fee. +

+ + {(() => { + const lowPrice = formatGasPrice(gasPrices.low); + const avgPrice = formatGasPrice(gasPrices.average); + const highPrice = formatGasPrice(gasPrices.high); + const baseFee = formatGasPrice(gasPrices.baseFee); + + return ( + <> +
+
+
Low
+
+ {lowPrice.value} {lowPrice.unit} +
+
~5+ min
+
+ +
+
Average
+
+ {avgPrice.value} {avgPrice.unit} +
+
~1-3 min
+
+ +
+
High
+
+ {highPrice.value} {highPrice.unit} +
+
~30 sec
+
+
+ +
+ Base Fee: + + {baseFee.value} {baseFee.unit} + +
+ + ); + })()} +
+ +
+

Transaction Cost Estimates

+

+ Estimated costs at current gas prices + {price && ` (${currency} @ $${price.toFixed(2)})`} +

+ +
+
+
Transaction Type
+
Gas
+
Low
+
Avg
+
High
+
+ + {TX_GAS_ESTIMATES.map((tx) => { + const lowCost = calculateTxCost(gasPrices.low, tx.gas, price); + const avgCost = calculateTxCost(gasPrices.average, tx.gas, price); + const highCost = calculateTxCost(gasPrices.high, tx.gas, price); + + return ( +
+
+ {tx.name} + {tx.description} +
+
{tx.gas.toLocaleString()}
+
+ + {lowCost.eth} {currency} + + {lowCost.usd} +
+
+ + {avgCost.eth} {currency} + + {avgCost.usd} +
+
+ + {highCost.eth} {currency} + + {highCost.usd} +
+
+ ); + })} +
+
+ + )} + + {data.lastUpdated && ( +
+ Last updated: {new Date(data.lastUpdated).toLocaleTimeString()} +
+ )} +
+
+ ); +} diff --git a/src/components/pages/network/DashboardStats.tsx b/src/components/pages/network/DashboardStats.tsx index 39d049b..f0c5101 100644 --- a/src/components/pages/network/DashboardStats.tsx +++ b/src/components/pages/network/DashboardStats.tsx @@ -1,26 +1,17 @@ import type React from "react"; +import { Link } from "react-router-dom"; +import type { GasPrices } from "../../../types"; import { formatPrice } from "../../../services/PriceService"; +import { formatGasPriceWithUnit } from "../../../utils/formatUtils"; interface DashboardStatsProps { price: number | null; gasPrice: string | null; + gasPrices: GasPrices | null; blockNumber: string | null; currency: string; loading: boolean; -} - -function formatGasPrice(gasPriceHex: string | null): string { - if (!gasPriceHex) return "—"; - try { - const gasPriceWei = BigInt(gasPriceHex); - const gasPriceGwei = Number(gasPriceWei) / 1e9; - if (gasPriceGwei < 1) { - return `${gasPriceGwei.toFixed(4)} Gwei`; - } - return `${gasPriceGwei.toFixed(2)} Gwei`; - } catch { - return "—"; - } + networkId: number; } function formatBlockNumber(blockNumberHex: string | null): string { @@ -36,10 +27,15 @@ function formatBlockNumber(blockNumberHex: string | null): string { const DashboardStats: React.FC = ({ price, gasPrice, + gasPrices, blockNumber, currency, loading, + networkId, }) => { + // Use gas tiers if available, otherwise fall back to single gas price + const hasGasTiers = gasPrices !== null; + return (
@@ -49,15 +45,39 @@ const DashboardStats: React.FC = ({
-
-
Gas Price
-
- {formatGasPrice(gasPrice)} + {hasGasTiers ? ( +
+ + Gas Price → + +
+
+ Low + {formatGasPriceWithUnit(gasPrices.low)} +
+
+ Avg + {formatGasPriceWithUnit(gasPrices.average)} +
+
+ High + {formatGasPriceWithUnit(gasPrices.high)} +
+
-
+ ) : ( +
+ + Gas Price → + +
+ {formatGasPriceWithUnit(gasPrice)} +
+
+ )}
-
Block
+
Latest Block
{formatBlockNumber(blockNumber)}
diff --git a/src/components/pages/network/index.tsx b/src/components/pages/network/index.tsx index 93e27fa..fde37ce 100644 --- a/src/components/pages/network/index.tsx +++ b/src/components/pages/network/index.tsx @@ -51,9 +51,11 @@ export default function Network() {
diff --git a/src/hooks/useNetworkDashboard.ts b/src/hooks/useNetworkDashboard.ts index 26bc3d5..716b0e8 100644 --- a/src/hooks/useNetworkDashboard.ts +++ b/src/hooks/useNetworkDashboard.ts @@ -6,7 +6,7 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { AppContext } from "../context/AppContext"; import { getNativeTokenPrice } from "../services/PriceService"; -import type { Block, NetworkStats, Transaction } from "../types"; +import type { Block, GasPrices, NetworkStats, Transaction } from "../types"; import { useDataService } from "./useDataService"; const REFRESH_INTERVAL = 10000; // 10 seconds @@ -16,6 +16,7 @@ const TXS_TO_FETCH = 10; export interface DashboardData { price: number | null; gasPrice: string | null; + gasPrices: GasPrices | null; blockNumber: string | null; latestBlocks: Block[]; latestTransactions: Transaction[]; @@ -27,6 +28,7 @@ export interface DashboardData { const initialState: DashboardData = { price: null, gasPrice: null, + gasPrices: null, blockNumber: null, latestBlocks: [], latestTransactions: [], @@ -53,10 +55,11 @@ export function useNetworkDashboard(networkId: number): DashboardData { isFetchingRef.current = true; try { - // Fetch network stats and price in parallel + // Fetch network stats, gas prices, and price in parallel // For L2s, price is fetched from mainnet pools - const [statsResult, priceResult] = await Promise.all([ + const [statsResult, gasPricesResult, priceResult] = await Promise.all([ dataService.networkAdapter.getNetworkStats(), + dataService.networkAdapter.getGasPrices().catch(() => null), rpcUrl ? getNativeTokenPrice(networkId, rpcUrl, mainnetRpcUrl || undefined) : Promise.resolve(null), @@ -106,6 +109,7 @@ export function useNetworkDashboard(networkId: number): DashboardData { setData({ price: priceResult, gasPrice: stats.currentGasPrice, + gasPrices: gasPricesResult?.data || null, blockNumber: stats.currentBlockNumber, latestBlocks: blocks, latestTransactions: transactions, diff --git a/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts b/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts index c3c747e..c4683c7 100644 --- a/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts +++ b/src/services/adapters/ArbitrumAdapter/ArbitrumAdapter.ts @@ -16,7 +16,7 @@ import { import { extractData } from "../shared/extractData"; import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { ArbitrumClient } from "@openscan/network-connectors"; +import type { ArbitrumClient, EthereumClient } from "@openscan/network-connectors"; /** * Arbitrum blockchain adapter @@ -32,6 +32,10 @@ export class ArbitrumAdapter extends NetworkAdapter { this.client = client; } + protected getClient(): EthereumClient { + return this.client as unknown as EthereumClient; + } + async getBlock(blockNumber: BlockNumberOrTag): Promise> { const normalizedBlockNumber = normalizeBlockNumber(blockNumber); const result = await this.client.getBlockByNumber(normalizedBlockNumber); diff --git a/src/services/adapters/BNBAdapter/BNBAdapter.ts b/src/services/adapters/BNBAdapter/BNBAdapter.ts index 3ff0774..e026c36 100644 --- a/src/services/adapters/BNBAdapter/BNBAdapter.ts +++ b/src/services/adapters/BNBAdapter/BNBAdapter.ts @@ -17,7 +17,7 @@ import { import { extractData } from "../shared/extractData"; import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { BNBClient } from "@openscan/network-connectors"; +import type { BNBClient, EthereumClient } from "@openscan/network-connectors"; /** * BNB Smart Chain (BSC) blockchain adapter @@ -32,6 +32,10 @@ export class BNBAdapter extends NetworkAdapter { this.client = client; } + protected getClient(): EthereumClient { + return this.client as unknown as EthereumClient; + } + async getBlock(blockNumber: BlockNumberOrTag): Promise> { const normalizedBlockNumber = normalizeBlockNumber(blockNumber); const result = await this.client.getBlockByNumber(normalizedBlockNumber); diff --git a/src/services/adapters/BaseAdapter/BaseAdapter.ts b/src/services/adapters/BaseAdapter/BaseAdapter.ts index 535e382..611d999 100644 --- a/src/services/adapters/BaseAdapter/BaseAdapter.ts +++ b/src/services/adapters/BaseAdapter/BaseAdapter.ts @@ -16,7 +16,7 @@ import { import { extractData } from "../shared/extractData"; import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { BaseClient } from "@openscan/network-connectors"; +import type { BaseClient, EthereumClient } from "@openscan/network-connectors"; /** * Base blockchain adapter @@ -31,6 +31,10 @@ export class BaseAdapter extends NetworkAdapter { this.client = client; } + protected getClient(): EthereumClient { + return this.client as unknown as EthereumClient; + } + async getBlock(blockNumber: BlockNumberOrTag): Promise> { const normalizedBlockNumber = normalizeBlockNumber(blockNumber); const result = await this.client.getBlockByNumber(normalizedBlockNumber); diff --git a/src/services/adapters/EVMAdapter/EVMAdapter.ts b/src/services/adapters/EVMAdapter/EVMAdapter.ts index b9009dd..3d3cf0f 100644 --- a/src/services/adapters/EVMAdapter/EVMAdapter.ts +++ b/src/services/adapters/EVMAdapter/EVMAdapter.ts @@ -33,6 +33,10 @@ export class EVMAdapter extends NetworkAdapter { this.client = client; this.txSearch = new AddressTransactionSearch(client); } + + protected getClient(): EthereumClient { + return this.client; + } async getBlock(blockNumber: BlockNumberOrTag): Promise> { const normalizedBlockNumber = normalizeBlockNumber(blockNumber); const result = await this.client.getBlockByNumber(normalizedBlockNumber); diff --git a/src/services/adapters/NetworkAdapter.ts b/src/services/adapters/NetworkAdapter.ts index 6a1d084..99a4bad 100644 --- a/src/services/adapters/NetworkAdapter.ts +++ b/src/services/adapters/NetworkAdapter.ts @@ -1,4 +1,4 @@ -import type { SupportedChainId } from "@openscan/network-connectors"; +import type { SupportedChainId, EthereumClient } from "@openscan/network-connectors"; import type { Block, Transaction, @@ -6,7 +6,9 @@ import type { NetworkStats, DataWithMetadata, AddressTransactionsResult, + GasPrices, } from "../../types"; +import { extractData } from "./shared/extractData"; export type BlockTag = "latest" | "earliest" | "pending" | "finalized" | "safe"; export type BlockNumberOrTag = number | string | BlockTag; @@ -49,6 +51,13 @@ export abstract class NetworkAdapter { this.networkId = networkId; this.isLocalHost = networkId === 31337; } + + /** + * Get the Ethereum client for RPC calls + * Each adapter must implement this to provide its client + */ + protected abstract getClient(): EthereumClient; + /** * Get block by number or tag * @param blockNumber - Block number (as number or hex string) or block tag @@ -107,6 +116,88 @@ export abstract class NetworkAdapter { */ abstract getNetworkStats(): Promise>; + /** + * Get gas prices with tiers (Low/Average/High) using eth_feeHistory + * @returns Gas price tiers and base fee + */ + async getGasPrices(): Promise> { + const client = this.getClient(); + + // Fetch fee history for last 20 blocks with 25th, 50th, 75th percentiles + const feeHistoryResult = await client.feeHistory("0x14", "latest", [25, 50, 75]); + const feeHistory = extractData<{ + baseFeePerGas: string[]; + gasUsedRatio: number[]; + oldestBlock: string; + reward?: string[][]; + }>(feeHistoryResult.data); + + if (!feeHistory || !feeHistory.reward || feeHistory.reward.length === 0) { + // Fallback to simple gas price if feeHistory not available + const gasPriceResult = await client.gasPrice(); + const gasPrice = extractData(gasPriceResult.data) || "0x0"; + const blockNumResult = await client.blockNumber(); + const blockNum = extractData(blockNumResult.data) || "0x0"; + + return { + data: { + low: gasPrice, + average: gasPrice, + high: gasPrice, + baseFee: gasPrice, + lastBlock: blockNum, + }, + metadata: gasPriceResult.metadata as DataWithMetadata["metadata"], + }; + } + + // Calculate average priority fees across all blocks for each percentile + const rewards = feeHistory.reward; + let lowSum = BigInt(0); + let avgSum = BigInt(0); + let highSum = BigInt(0); + let count = 0; + + for (const blockRewards of rewards) { + if (blockRewards && blockRewards.length >= 3) { + lowSum += BigInt(blockRewards[0] || "0x0"); + avgSum += BigInt(blockRewards[1] || "0x0"); + highSum += BigInt(blockRewards[2] || "0x0"); + count++; + } + } + + // Get the latest base fee (last element in baseFeePerGas array) + const baseFees = feeHistory.baseFeePerGas; + const latestBaseFee = baseFees[baseFees.length - 1] || "0x0"; + + // Calculate averages + const lowPriorityFee = count > 0 ? lowSum / BigInt(count) : BigInt(0); + const avgPriorityFee = count > 0 ? avgSum / BigInt(count) : BigInt(0); + const highPriorityFee = count > 0 ? highSum / BigInt(count) : BigInt(0); + + // Total gas price = base fee + priority fee + const baseFeeNum = BigInt(latestBaseFee); + const low = baseFeeNum + lowPriorityFee; + const average = baseFeeNum + avgPriorityFee; + const high = baseFeeNum + highPriorityFee; + + // Calculate last block number + const oldestBlock = BigInt(feeHistory.oldestBlock); + const lastBlock = oldestBlock + BigInt(baseFees.length - 1); + + return { + data: { + low: `0x${low.toString(16)}`, + average: `0x${average.toString(16)}`, + high: `0x${high.toString(16)}`, + baseFee: latestBaseFee, + lastBlock: `0x${lastBlock.toString(16)}`, + }, + metadata: feeHistoryResult.metadata as DataWithMetadata["metadata"], + }; + } + /** * Get latest N blocks * @param count - Number of blocks to retrieve diff --git a/src/services/adapters/OptimismAdapter/OptimismAdapter.ts b/src/services/adapters/OptimismAdapter/OptimismAdapter.ts index 27b9eb7..1735a01 100644 --- a/src/services/adapters/OptimismAdapter/OptimismAdapter.ts +++ b/src/services/adapters/OptimismAdapter/OptimismAdapter.ts @@ -16,7 +16,7 @@ import { import { extractData } from "../shared/extractData"; import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { OptimismClient } from "@openscan/network-connectors"; +import type { OptimismClient, EthereumClient } from "@openscan/network-connectors"; /** * Optimism (OP Stack) blockchain adapter @@ -31,6 +31,10 @@ export class OptimismAdapter extends NetworkAdapter { this.client = client; } + protected getClient(): EthereumClient { + return this.client as unknown as EthereumClient; + } + async getBlock(blockNumber: BlockNumberOrTag): Promise> { const normalizedBlockNumber = normalizeBlockNumber(blockNumber); const result = await this.client.getBlockByNumber(normalizedBlockNumber); diff --git a/src/services/adapters/PolygonAdapter/PolygonAdapter.ts b/src/services/adapters/PolygonAdapter/PolygonAdapter.ts index cd1a7a4..37f186d 100644 --- a/src/services/adapters/PolygonAdapter/PolygonAdapter.ts +++ b/src/services/adapters/PolygonAdapter/PolygonAdapter.ts @@ -17,7 +17,7 @@ import { import { extractData } from "../shared/extractData"; import { normalizeBlockNumber } from "../shared/normalizeBlockNumber"; import { mergeMetadata } from "../shared/mergeMetadata"; -import type { PolygonClient, SupportedChainId } from "@openscan/network-connectors"; +import type { PolygonClient, SupportedChainId, EthereumClient } from "@openscan/network-connectors"; /** * Polygon blockchain service @@ -30,6 +30,11 @@ export class PolygonAdapter extends NetworkAdapter { super(networkId); this.client = client; } + + protected getClient(): EthereumClient { + return this.client as unknown as EthereumClient; + } + async getBlock(blockNumber: BlockNumberOrTag): Promise> { const normalizedBlockNumber = normalizeBlockNumber(blockNumber); const result = await this.client.getBlockByNumber(normalizedBlockNumber); diff --git a/src/styles/components.css b/src/styles/components.css index 587ab14..b9b941a 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1968,11 +1968,6 @@ background: rgba(255, 255, 255, 0.05); border: 1px solid color-mix(in srgb, var(--network-color, #888) 25%, transparent); border-radius: 8px; - transition: all 0.2s ease; -} - -.network-block-indicator:hover { - background: rgba(255, 255, 255, 0.08); } .network-block-pulse { @@ -2033,6 +2028,15 @@ line-height: 1.2; } +.network-gas-tracker { + display: flex; + align-items: center; + gap: 4px; + padding-left: 8px; + border-left: 1px solid rgba(255, 255, 255, 0.1); + cursor: pointer; +} + /* Pagination */ .pagination-container { display: flex; @@ -5118,6 +5122,7 @@ text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; + text-align: center; } .dashboard-stat-value { @@ -5131,6 +5136,82 @@ opacity: 0.5; } +/* Clickable stat label */ +.dashboard-stat-label-link { + display: block; + font-size: 0.8rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; + text-align: center; + text-decoration: none; + transition: color 0.2s ease; +} + +.dashboard-stat-label-link:hover { + color: var(--color-primary); +} + +/* Gas Price Tiers */ +.dashboard-stat-card-gas { + padding: 16px 12px; +} + +.dashboard-gas-tiers { + display: flex; + justify-content: space-between; + gap: 8px; +} + +.gas-tier { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + padding: 6px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} + +.gas-tier-label { + font-size: 0.7rem; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 4px; +} + +.gas-tier-value { + font-family: "Outfit", sans-serif; + font-size: 0.95rem; + font-weight: 600; +} + +.gas-tier-low .gas-tier-label { + color: #4ade80; +} + +.gas-tier-low .gas-tier-value { + color: #4ade80; +} + +.gas-tier-avg .gas-tier-label { + color: #fbbf24; +} + +.gas-tier-avg .gas-tier-value { + color: #fbbf24; +} + +.gas-tier-high .gas-tier-label { + color: #f87171; +} + +.gas-tier-high .gas-tier-value { + color: #f87171; +} + /* Dashboard Tables Row */ .dashboard-tables-row { display: grid; @@ -5396,6 +5477,10 @@ font-size: 1.25rem; } + .gas-tier-value { + font-size: 0.85rem; + } + .dashboard-table-row { flex-wrap: wrap; gap: 8px; @@ -5598,3 +5683,251 @@ padding: 8px 12px; } } + +/* ============================================ + Gas Tracker Page Styles + ============================================ */ + +.gas-tracker-header { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.gas-tracker-back-link { + font-size: 0.9rem; + color: var(--color-primary-muted); + text-decoration: none; + transition: color 0.2s ease; +} + +.gas-tracker-back-link:hover { + color: var(--color-primary); +} + +.gas-tracker-section-title { + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text); + text-align: center; + margin: 0 0px 20px 0px; +} + +.gas-tracker-section-subtitle { + font-size: 0.85rem; + color: var(--color-text-muted); + margin-bottom: 20px; + text-align: center; + margin: 0 0px 20px 0px; +} + +/* Gas Price Cards */ +.gas-price-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 20px; +} + +.gas-price-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 24px; + text-align: center; +} + +.gas-price-tier-label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; +} + +.gas-price-tier-value { + font-family: "Outfit", sans-serif; + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 8px; +} + +.gas-price-tier-time { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.gas-price-low .gas-price-tier-label, +.gas-price-low .gas-price-tier-value { + color: #4ade80; +} + +.gas-price-avg .gas-price-tier-label, +.gas-price-avg .gas-price-tier-value { + color: #fbbf24; +} + +.gas-price-high .gas-price-tier-label, +.gas-price-high .gas-price-tier-value { + color: #f87171; +} + +.gas-base-fee { + display: flex; + justify-content: center; + gap: 8px; + padding: 12px; + background: rgba(255, 255, 255, 0.02); + border-radius: 8px; + margin-bottom: 10px; +} + +.gas-base-fee-label { + color: var(--color-text-muted); +} + +.gas-base-fee-value { + font-weight: 600; + color: var(--color-text); +} + +/* Transaction Cost Table */ +.tx-cost-table { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + overflow: hidden; +} + +.tx-cost-header { + display: grid; + grid-template-columns: 2fr 1fr 1.5fr 1.5fr 1.5fr; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.03); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); +} + +.tx-cost-row { + display: grid; + grid-template-columns: 2fr 1fr 1.5fr 1.5fr 1.5fr; + padding: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background 0.2s ease; +} + +.tx-cost-row:last-child { + border-bottom: none; +} + +.tx-cost-row:hover { + background: rgba(255, 255, 255, 0.02); +} + +.tx-cost-col-name { + display: flex; + flex-direction: column; + gap: 4px; +} + +.tx-cost-name { + font-weight: 600; + color: var(--color-text); +} + +.tx-cost-desc { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.tx-cost-col-gas { + font-family: "JetBrains Mono", monospace; + font-size: 0.85rem; + color: var(--color-text-muted); + display: flex; + align-items: center; +} + +.tx-cost-col-low, +.tx-cost-col-avg, +.tx-cost-col-high { + display: flex; + flex-direction: column; + gap: 2px; +} + +.tx-cost-eth { + font-family: "JetBrains Mono", monospace; + font-size: 0.85rem; + color: var(--color-text); +} + +.tx-cost-usd { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.tx-cost-col-low .tx-cost-eth { + color: #4ade80; +} + +.tx-cost-col-avg .tx-cost-eth { + color: #fbbf24; +} + +.tx-cost-col-high .tx-cost-eth { + color: #f87171; +} + +.gas-tracker-updated { + text-align: center; + font-size: 0.8rem; + color: var(--color-text-muted); + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +/* Gas Tracker Responsive */ +@media (max-width: 900px) { + .gas-price-cards { + grid-template-columns: repeat(3, 1fr); + } + + .tx-cost-header, + .tx-cost-row { + grid-template-columns: 2fr 1fr 1fr 1fr 1fr; + } +} + +@media (max-width: 640px) { + .gas-tracker-header { + flex-direction: column; + align-items: center; + gap: 12px; + } + + .gas-price-cards { + grid-template-columns: 1fr; + } + + .gas-price-card { + padding: 16px; + } + + .gas-price-tier-value { + font-size: 1.5rem; + } + + .tx-cost-table { + overflow-x: auto; + } + + .tx-cost-header, + .tx-cost-row { + min-width: 600px; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 3de9c6b..4333c33 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,6 +12,14 @@ export interface NetworkStats { metadata: any; } +export interface GasPrices { + low: string; // Wei hex - 25th percentile priority fee + average: string; // Wei hex - 50th percentile priority fee + high: string; // Wei hex - 75th percentile priority fee + baseFee: string; // Current base fee (EIP-1559) + lastBlock: string; // Block number hex +} + export interface Block { difficulty: string; extraData: string; diff --git a/src/utils/formatUtils.ts b/src/utils/formatUtils.ts new file mode 100644 index 0000000..67a687e --- /dev/null +++ b/src/utils/formatUtils.ts @@ -0,0 +1,58 @@ +/** + * Gas price formatting result with separate value and unit + */ +export interface FormattedGasPrice { + value: string; + unit: string; +} + +/** + * Formats a gas price from wei (hex string) to the most appropriate unit. + * Automatically selects Gwei, Mwei, Kwei, or wei based on the value. + * Handles L2 networks like Optimism that have very low gas prices. + * + * @param weiHex - Gas price in wei as a hex string (e.g., "0x3b9aca00") + * @returns Object with formatted value and unit, or combined string + */ +export function formatGasPrice(weiHex: string): FormattedGasPrice { + try { + const wei = BigInt(weiHex); + const weiNum = Number(wei); + const gwei = weiNum / 1e9; + + // Use Gwei for normal gas prices (>= 0.01 Gwei) + if (gwei >= 0.01) { + return { value: gwei.toFixed(2), unit: "Gwei" }; + } + + // Use Mwei for very low gas prices (L2s like Optimism) + const mwei = weiNum / 1e6; + if (mwei >= 0.01) { + return { value: mwei.toFixed(2), unit: "Mwei" }; + } + + // Use Kwei for extremely low gas prices + const kwei = weiNum / 1e3; + if (kwei >= 0.01) { + return { value: kwei.toFixed(2), unit: "Kwei" }; + } + + // Fallback to wei + return { value: weiNum.toFixed(0), unit: "wei" }; + } catch { + return { value: "—", unit: "Gwei" }; + } +} + +/** + * Formats a gas price from wei (hex string) to a combined string with unit. + * Convenience wrapper around formatGasPrice for cases where a single string is needed. + * + * @param weiHex - Gas price in wei as a hex string, or null + * @returns Formatted string like "1.23 Gwei" or "—" if invalid + */ +export function formatGasPriceWithUnit(weiHex: string | null): string { + if (!weiHex) return "—"; + const { value, unit } = formatGasPrice(weiHex); + return `${value} ${unit}`; +} From c13e890c6b74c906ecca482a69567c4c9164b1b5 Mon Sep 17 00:00:00 2001 From: Augusto Lemble Date: Wed, 21 Jan 2026 20:19:15 -0300 Subject: [PATCH 2/2] fix(gas-tracker): add accessibility attributes to gas tracker button Add role, tabIndex, and keyboard event handler for accessibility compliance. --- src/components/navbar/NetworkBlockIndicator.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/components/navbar/NetworkBlockIndicator.tsx b/src/components/navbar/NetworkBlockIndicator.tsx index 162cd55..c671a7d 100644 --- a/src/components/navbar/NetworkBlockIndicator.tsx +++ b/src/components/navbar/NetworkBlockIndicator.tsx @@ -95,11 +95,24 @@ export function NetworkBlockIndicator({ className }: NetworkBlockIndicatorProps) {isLoading ? "..." : blockNumber !== null ? `#${blockNumber.toLocaleString()}` : "---"} + {/* biome-ignore lint/a11y/useSemanticElements: using div for styling consistency */}
navigate(`/${networkId}/gastracker`)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + navigate(`/${networkId}/gastracker`); + } + }} >