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
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { renderHook, waitFor } from '@testing-library/react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAccount, useConfig } from 'wagmi';
import { useOnchainKit } from '@/useOnchainKit';
import { useTransactionContext } from '../components/TransactionProvider';
import { useTransactionSimulation } from './useTransactionSimulation';

vi.mock('wagmi', () => ({
useAccount: vi.fn(),
useConfig: vi.fn(),
}));

vi.mock('@/useOnchainKit', () => ({
useOnchainKit: vi.fn(),
}));

vi.mock('../components/TransactionProvider', () => ({
useTransactionContext: vi.fn(),
}));

vi.mock('../utils/simulateTransaction', () => ({
simulateTransaction: vi.fn(),
}));

import { simulateTransaction } from '../utils/simulateTransaction';

describe('useTransactionSimulation', () => {
const mockConfig = { chains: [{ id: 8453 }] };
const mockAddress = '0x1234567890123456789012345678901234567890';
const mockChain = { id: 8453 };

beforeEach(() => {
vi.resetAllMocks();
});

it('should return null when disabled', () => {
(useAccount as Mock).mockReturnValue({ address: mockAddress });
(useConfig as Mock).mockReturnValue(mockConfig);
(useOnchainKit as Mock).mockReturnValue({ chain: mockChain });
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0xCONTRACT', data: '0x1234' }],
});

const { result } = renderHook(() => useTransactionSimulation({ enabled: false }));

expect(result.current.simulation).toBeNull();
expect(result.current.isSimulating).toBe(false);
});

it('should return null when no calls', () => {
(useAccount as Mock).mockReturnValue({ address: mockAddress });
(useConfig as Mock).mockReturnValue(mockConfig);
(useOnchainKit as Mock).mockReturnValue({ chain: mockChain });
(useTransactionContext as Mock).mockReturnValue({ calls: [] });

const { result } = renderHook(() => useTransactionSimulation());

expect(result.current.simulation).toBeNull();
});

it('should simulate successful transaction', async () => {
const mockSimulation = {
success: true,
gasUsed: 21000n,
returnData: '0x000000000000000000000000000000000000000000000000000000000000002a',
warnings: [],
};

(useAccount as Mock).mockReturnValue({ address: mockAddress });
(useConfig as Mock).mockReturnValue(mockConfig);
(useOnchainKit as Mock).mockReturnValue({ chain: mockChain });
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0xCONTRACT', data: '0x1234' }],
});
(simulateTransaction as Mock).mockResolvedValue(mockSimulation);

const { result } = renderHook(() => useTransactionSimulation());

await waitFor(() => expect(result.current.isSimulating).toBe(false));

expect(result.current.simulation?.success).toBe(true);
expect(result.current.willFail).toBe(false);
expect(result.current.gasEstimate).toBe(21000n);
});

it('should detect failing transaction', async () => {
const mockSimulation = {
success: false,
gasUsed: 0n,
errorMessage: 'Transaction will revert — check parameters',
warnings: ['Transaction will revert — check parameters'],
};

(useAccount as Mock).mockReturnValue({ address: mockAddress });
(useConfig as Mock).mockReturnValue(mockConfig);
(useOnchainKit as Mock).mockReturnValue({ chain: mockChain });
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0xCONTRACT', data: '0x1234' }],
});
(simulateTransaction as Mock).mockResolvedValue(mockSimulation);

const { result } = renderHook(() => useTransactionSimulation());

await waitFor(() => expect(result.current.isSimulating).toBe(false));

expect(result.current.willFail).toBe(true);
expect(result.current.warnings).toContain('Transaction will revert — check parameters');
});

it('should handle API error', async () => {
const mockError = {
code: 'TmTS01',
error: 'RPC Error',
message: 'Simulation failed',
};

(useAccount as Mock).mockReturnValue({ address: mockAddress });
(useConfig as Mock).mockReturnValue(mockConfig);
(useOnchainKit as Mock).mockReturnValue({ chain: mockChain });
(useTransactionContext as Mock).mockReturnValue({
calls: [{ to: '0xCONTRACT', data: '0x1234' }],
});
(simulateTransaction as Mock).mockResolvedValue(mockError);

const { result } = renderHook(() => useTransactionSimulation());

await waitFor(() => expect(result.current.isSimulating).toBe(false));

expect(result.current.error).toEqual(mockError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useState } from 'react';
import { useAccount, useConfig } from 'wagmi';
import { useOnchainKit } from '@/useOnchainKit';
import { useTransactionContext } from '../components/TransactionProvider';
import { simulateTransaction } from '../utils/simulateTransaction';
import type { SimulationResult } from '../utils/simulateTransaction';
import type { APIError } from '@/api/types';

export type UseTransactionSimulationParams = {
enabled?: boolean; // default: true
};

export function useTransactionSimulation({
enabled = true,
}: UseTransactionSimulationParams = {}) {
const config = useConfig();
const { address: from } = useAccount();
const { chain } = useOnchainKit();
const { calls } = useTransactionContext();

const [simulation, setSimulation] = useState<SimulationResult | null>(null);
const [isSimulating, setIsSimulating] = useState(false);
const [error, setError] = useState<APIError | null>(null);

const runSimulation = useCallback(async () => {
if (!enabled || !from || !calls || calls.length === 0) {
setSimulation(null);
setError(null);
return;
}

setIsSimulating(true);
setError(null);

// Simula solo la prima call per semplicità
// (in produzione: simula tutte le calls in sequenza)
const call = calls[0];
if (!call.to) {
setIsSimulating(false);
return;
}

const result = await simulateTransaction({
config,
chainId: chain.id,
from,
to: call.to,
data: call.data || '0x',
value: call.value,
});

if ('code' in result) {
setError(result as APIError);
setSimulation(null);
} else {
setSimulation(result);
}

setIsSimulating(false);
}, [enabled, from, calls, config, chain.id]);

useEffect(() => {
runSimulation();
}, [runSimulation]);

return {
simulation,
isSimulating,
error,
willFail: simulation ? !simulation.success : false,
expectedOutput: simulation?.decodedOutput,
gasEstimate: simulation?.gasUsed,
warnings: simulation?.warnings ?? [],
refresh: runSimulation,
};
}
86 changes: 86 additions & 0 deletions packages/onchainkit/src/transaction/utils/simulateTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { type Address, type Hex, decodeFunctionResult, encodeFunctionData } from 'viem';
import { call as viemCall } 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 SimulationResult = {
success: boolean;
gasUsed: bigint;
returnData?: Hex;
decodedOutput?: unknown;
errorMessage?: string;
warnings: string[];
};

export type SimulateTransactionParams = {
config: Config;
chainId: number;
from: Address;
to: Address;
data: Hex;
value?: bigint;
};

export async function simulateTransaction({
config,
chainId,
from,
to,
data,
value,
}: SimulateTransactionParams): Promise<SimulationResult | APIError> {
try {
const client = getPublicClient(config, { chainId });
if (!client) {
return buildErrorStruct({
code: ApiErrorCode.AMGTa01,
error: 'No RPC client',
message: `Cannot connect to chain ${chainId}`,
});
}

const result = await viemCall(client, {
account: from,
to,
data,
value,
});

// Analisi del risultato
const warnings: string[] = [];

// Se è una swap, decodifica l'output per slippage
// (semplificato — in produzione userebbe l'ABI specifico)
if (result.data && result.data !== '0x') {
// Qui potremmo decodificare il risultato
// Per ora, segnaliamo solo che c'è un output
}

return {
success: true,
gasUsed: result.gasUsed || 0n,
returnData: result.data,
warnings,
};
} catch (error: any) {
// Decodifica l'errore per messaggi utili
let errorMessage = 'Transaction simulation failed';

if (error.message?.includes('insufficient funds')) {
errorMessage = 'Insufficient funds for gas + value';
} else if (error.message?.includes('execution reverted')) {
errorMessage = 'Transaction will revert — check parameters';
} else if (error.message?.includes('gas required exceeds')) {
errorMessage = 'Gas limit exceeded — transaction too complex';
}

return {
success: false,
gasUsed: 0n,
errorMessage,
warnings: [errorMessage],
};
}
}