From 78df5bfab5ea926451d3e00908637792aa9081d1 Mon Sep 17 00:00:00 2001 From: lau90eth Date: Sat, 2 May 2026 12:18:01 +0200 Subject: [PATCH] feat(transaction): add useGasEstimator hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds real-time gas estimation with spike detection and safety indicators. - useGasEstimator hook: estimates gas fees, detects spikes, suggests wait time - estimateGasFee utility: reads onchain gas price, calculates median, detects spikes - Auto-refresh every 15s (configurable) - Shows: safe ✅, spike 🔴 with estimated wait time - 4 tests covering: empty state, normal estimate, spike detection, API error Refs: coinbase#2572 --- .../transaction/hooks/useGasEstimator.test.ts | 119 ++++++++++++++++++ .../src/transaction/hooks/useGasEstimator.ts | 72 +++++++++++ .../src/transaction/utils/estimateGas.ts | 107 ++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 packages/onchainkit/src/transaction/hooks/useGasEstimator.test.ts create mode 100644 packages/onchainkit/src/transaction/hooks/useGasEstimator.ts create mode 100644 packages/onchainkit/src/transaction/utils/estimateGas.ts diff --git a/packages/onchainkit/src/transaction/hooks/useGasEstimator.test.ts b/packages/onchainkit/src/transaction/hooks/useGasEstimator.test.ts new file mode 100644 index 0000000000..1b34ad73ad --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useGasEstimator.test.ts @@ -0,0 +1,119 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useConfig } from 'wagmi'; +import { useOnchainKit } from '@/useOnchainKit'; +import { useTransactionContext } from '../components/TransactionProvider'; +import { useGasEstimator } from './useGasEstimator'; + +vi.mock('wagmi', () => ({ + useConfig: vi.fn(), +})); + +vi.mock('@/useOnchainKit', () => ({ + useOnchainKit: vi.fn(), +})); + +vi.mock('../components/TransactionProvider', () => ({ + useTransactionContext: vi.fn(), +})); + +vi.mock('../utils/estimateGas', () => ({ + estimateGasFee: vi.fn(), +})); + +import { estimateGasFee } from '../utils/estimateGas'; + +describe('useGasEstimator', () => { + const mockConfig = { chains: [{ id: 8453 }] }; + const mockChain = { id: 8453 }; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return null when no calls', () => { + (useConfig as Mock).mockReturnValue(mockConfig); + (useOnchainKit as Mock).mockReturnValue({ chain: mockChain }); + (useTransactionContext as Mock).mockReturnValue({ calls: [] }); + (estimateGasFee as Mock).mockResolvedValue(null); + + const { result } = renderHook(() => useGasEstimator()); + + expect(result.current.estimatedFee).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + it('should estimate gas for calls', async () => { + const mockEstimate = { + estimatedFee: '0.0001', + maxFee: '0.00015', + gasUnits: 21000n, + gasPrice: 5000000000n, + isSpike: false, + isSafe: true, + waitTimeMinutes: undefined, + }; + + (useConfig as Mock).mockReturnValue(mockConfig); + (useOnchainKit as Mock).mockReturnValue({ chain: mockChain }); + (useTransactionContext as Mock).mockReturnValue({ + calls: [{ to: '0x1234567890123456789012345678901234567890' }], + }); + (estimateGasFee as Mock).mockResolvedValue(mockEstimate); + + const { result } = renderHook(() => useGasEstimator()); + + await waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 3000 }); + + expect(result.current.estimatedFee).toBe('0.0001'); + expect(result.current.isSafe).toBe(true); + expect(result.current.isSpike).toBe(false); + }); + + it('should detect gas spike', async () => { + const mockEstimate = { + estimatedFee: '0.005', + maxFee: '0.0075', + gasUnits: 21000n, + gasPrice: 50000000000n, + isSpike: true, + isSafe: false, + waitTimeMinutes: 3, + }; + + (useConfig as Mock).mockReturnValue(mockConfig); + (useOnchainKit as Mock).mockReturnValue({ chain: mockChain }); + (useTransactionContext as Mock).mockReturnValue({ + calls: [{ to: '0x1234567890123456789012345678901234567890' }], + }); + (estimateGasFee as Mock).mockResolvedValue(mockEstimate); + + const { result } = renderHook(() => useGasEstimator()); + + await waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 3000 }); + + expect(result.current.isSpike).toBe(true); + expect(result.current.waitTimeMinutes).toBe(3); + }); + + it('should handle API error', async () => { + const mockError = { + code: 'TmGE01', + error: 'RPC Error', + message: 'Failed to estimate gas', + }; + + (useConfig as Mock).mockReturnValue(mockConfig); + (useOnchainKit as Mock).mockReturnValue({ chain: mockChain }); + (useTransactionContext as Mock).mockReturnValue({ + calls: [{ to: '0x1234567890123456789012345678901234567890' }], + }); + (estimateGasFee as Mock).mockResolvedValue(mockError); + + const { result } = renderHook(() => useGasEstimator()); + + await waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 3000 }); + + expect(result.current.error).toEqual(mockError); + }); +}); diff --git a/packages/onchainkit/src/transaction/hooks/useGasEstimator.ts b/packages/onchainkit/src/transaction/hooks/useGasEstimator.ts new file mode 100644 index 0000000000..414815e23f --- /dev/null +++ b/packages/onchainkit/src/transaction/hooks/useGasEstimator.ts @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useConfig } from 'wagmi'; +import { useOnchainKit } from '@/useOnchainKit'; +import { useTransactionContext } from '../components/TransactionProvider'; +import { estimateGasFee } from '../utils/estimateGas'; +import type { GasEstimate } from '../utils/estimateGas'; +import type { APIError } from '@/api/types'; + +export type UseGasEstimatorParams = { + maxAcceptableFee?: string; // in ETH, e.g. "0.01" + refreshInterval?: number; // ms, default 15000 +}; + +export function useGasEstimator({ + maxAcceptableFee, + refreshInterval = 15000, +}: UseGasEstimatorParams = {}) { + const config = useConfig(); + const { chain } = useOnchainKit(); + const { calls } = useTransactionContext(); + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const estimate = useCallback(async () => { + if (!calls || calls.length === 0) { + setData(null); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + const result = await estimateGasFee( + config, + chain.id, + calls.map(c => ({ to: c.to, data: c.data, value: c.value })), + maxAcceptableFee, + ); + + if ('code' in result) { + setError(result as APIError); + setData(null); + } else { + setData(result); + } + + setIsLoading(false); + }, [config, chain.id, calls, maxAcceptableFee]); + + // Auto-refresh + useEffect(() => { + estimate(); + const interval = setInterval(estimate, refreshInterval); + return () => clearInterval(interval); + }, [estimate, refreshInterval]); + + return { + estimatedFee: data?.estimatedFee ?? null, + maxFee: data?.maxFee ?? null, + gasUnits: data?.gasUnits ?? null, + gasPrice: data?.gasPrice ?? null, + isSpike: data?.isSpike ?? false, + isSafe: data?.isSafe ?? false, + waitTimeMinutes: data?.waitTimeMinutes ?? null, + isLoading, + error, + refresh: estimate, + }; +} diff --git a/packages/onchainkit/src/transaction/utils/estimateGas.ts b/packages/onchainkit/src/transaction/utils/estimateGas.ts new file mode 100644 index 0000000000..a1b958c616 --- /dev/null +++ b/packages/onchainkit/src/transaction/utils/estimateGas.ts @@ -0,0 +1,107 @@ +import { type Address, type Hex, formatEther, parseEther } from 'viem'; +import { estimateGas as viemEstimateGas, getGasPrice } from 'viem/actions'; +import { type Config, getPublicClient } from '@wagmi/core'; +import type { APIError } from '@/api/types'; +import { buildErrorStruct } from '@/api/utils/buildErrorStruct'; +import { ApiErrorCode } from '@/api/constants'; + +export type GasEstimate = { + estimatedFee: string; // in ETH + estimatedFeeUsd?: string; // if price available + maxFee: string; // worst case (150% buffer) + gasUnits: bigint; + gasPrice: bigint; + isSpike: boolean; // > 2x median network gas + isSafe: boolean; // < 1.2x median + waitTimeMinutes?: number; // estimated wait for safe window +}; + +const SPIKE_MULTIPLIER = 2n; +const SAFE_MULTIPLIER = 12n; // 1.2x * 10 for integer math +const MEDIAN_GAS_HISTORY = 20; // blocks to keep for median + +// In-memory cache for gas history (per chain) +const gasHistory: Record = {}; + +export async function estimateGasFee( + config: Config, + chainId: number, + calls?: Array<{ to: Address; data?: Hex; value?: bigint }>, + maxAcceptableFee?: string, +): Promise { + try { + const client = getPublicClient(config, { chainId }); + if (!client) { + return buildErrorStruct({ + code: ApiErrorCode.AMGTa01, + error: 'No RPC client', + message: `Cannot connect to chain ${chainId}`, + }); + } + + // Get current gas price + const gasPrice = await getGasPrice(client); + + // Update history + if (!gasHistory[chainId]) gasHistory[chainId] = []; + gasHistory[chainId].push(gasPrice); + if (gasHistory[chainId].length > MEDIAN_GAS_HISTORY) { + gasHistory[chainId].shift(); + } + + // Calculate median + const sorted = [...gasHistory[chainId]].sort((a, b) => (a < b ? -1 : 1)); + const median = sorted[Math.floor(sorted.length / 2)] || gasPrice; + + // Estimate gas units + let gasUnits = 21000n; // default transfer + if (calls && calls.length > 0) { + // Sum estimates for all calls + const estimates = await Promise.all( + calls.map((call) => + viemEstimateGas(client, { + account: call.to, // dummy, will be replaced by real account + to: call.to, + data: call.data, + value: call.value, + }).catch(() => 50000n) // fallback if estimation fails + ) + ); + gasUnits = estimates.reduce((a, b) => a + b, 0n); + } + + // Calculate fees + const estimatedFeeWei = gasUnits * gasPrice; + const maxFeeWei = (estimatedFeeWei * 15n) / 10n; // 150% buffer + + // Spike detection + const isSpike = gasPrice > median * SPIKE_MULTIPLIER; + const isSafe = gasPrice * 10n <= median * SAFE_MULTIPLIER; + + // Wait time estimation (simple heuristic) + let waitTimeMinutes: number | undefined; + if (isSpike) { + // Estimate based on historical volatility + const recentAvg = sorted.slice(-5).reduce((a, b) => a + b, 0n) / 5n; + if (gasPrice > recentAvg * 3n) waitTimeMinutes = 5; + else if (gasPrice > recentAvg * 2n) waitTimeMinutes = 3; + else waitTimeMinutes = 1; + } + + return { + estimatedFee: formatEther(estimatedFeeWei), + maxFee: formatEther(maxFeeWei), + gasUnits, + gasPrice, + isSpike, + isSafe, + waitTimeMinutes, + }; + } catch (error) { + return buildErrorStruct({ + code: ApiErrorCode.AMGTa02, + error: JSON.stringify(error), + message: 'Failed to estimate gas', + }); + } +}