Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions packages/onchainkit/src/transaction/hooks/useGasEstimator.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
72 changes: 72 additions & 0 deletions packages/onchainkit/src/transaction/hooks/useGasEstimator.ts
Original file line number Diff line number Diff line change
@@ -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<GasEstimate | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<APIError | null>(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,
};
}
107 changes: 107 additions & 0 deletions packages/onchainkit/src/transaction/utils/estimateGas.ts
Original file line number Diff line number Diff line change
@@ -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<number, bigint[]> = {};

export async function estimateGasFee(
config: Config,
chainId: number,
calls?: Array<{ to: Address; data?: Hex; value?: bigint }>,
maxAcceptableFee?: string,
): Promise<GasEstimate | APIError> {
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',
});
}
}