diff --git a/apps/agentic-chat/src/lib/stepUtils.ts b/apps/agentic-chat/src/lib/stepUtils.ts index 5b3ef4d0..d43a83da 100644 --- a/apps/agentic-chat/src/lib/stepUtils.ts +++ b/apps/agentic-chat/src/lib/stepUtils.ts @@ -13,6 +13,7 @@ export function getUserFriendlyError(rawError: string): string { if (lower.includes('user rejected') || lower.includes('user denied')) return 'Transaction was rejected in your wallet' if (lower.includes('insufficient funds')) return 'Insufficient funds to complete this transaction' if (lower.includes('failed to deploy safe')) return 'Failed to set up your vault. Please try again.' + if (lower.includes('transaction will revert')) return rawError return rawError.length > 120 ? `${rawError.slice(0, 120)}...` : rawError } diff --git a/apps/agentic-chat/src/utils/SimulationError.ts b/apps/agentic-chat/src/utils/SimulationError.ts new file mode 100644 index 00000000..c92c418b --- /dev/null +++ b/apps/agentic-chat/src/utils/SimulationError.ts @@ -0,0 +1,20 @@ +export class SimulationError extends Error { + constructor(reason: string) { + super(reason) + this.name = 'SimulationError' + } +} + +export function isRevertError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const msg = error.message.toLowerCase() + if (error.name === 'ContractFunctionRevertedError' || error.name === 'CallExecutionError') return true + return msg.includes('execution reverted') || msg.includes('transaction reverted') +} + +export function extractRevertReason(error: unknown): string { + if (!(error instanceof Error)) return 'Unknown revert' + const viemError = error as Error & { shortMessage?: string } + if (viemError.shortMessage) return viemError.shortMessage + return error.message +} diff --git a/apps/agentic-chat/src/utils/__tests__/SimulationError.test.ts b/apps/agentic-chat/src/utils/__tests__/SimulationError.test.ts new file mode 100644 index 00000000..1e319f9a --- /dev/null +++ b/apps/agentic-chat/src/utils/__tests__/SimulationError.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'bun:test' + +import { SimulationError, extractRevertReason, isRevertError } from '../SimulationError' + +describe('SimulationError', () => { + it('extends Error with name SimulationError', () => { + const err = new SimulationError('transfer amount exceeds balance') + expect(err).toBeInstanceOf(Error) + expect(err.name).toBe('SimulationError') + expect(err.message).toBe('transfer amount exceeds balance') + }) +}) + +describe('isRevertError', () => { + it('returns false for non-Error values', () => { + expect(isRevertError('string')).toBe(false) + expect(isRevertError(null)).toBe(false) + expect(isRevertError(42)).toBe(false) + }) + + it('returns true for ContractFunctionRevertedError', () => { + const err = new Error('some message') + err.name = 'ContractFunctionRevertedError' + expect(isRevertError(err)).toBe(true) + }) + + it('returns true for CallExecutionError', () => { + const err = new Error('some message') + err.name = 'CallExecutionError' + expect(isRevertError(err)).toBe(true) + }) + + it('returns true for message containing "execution reverted"', () => { + expect(isRevertError(new Error('execution reverted: ERC20: transfer amount exceeds balance'))).toBe(true) + }) + + it('returns true for message containing "transaction reverted"', () => { + expect(isRevertError(new Error('transaction reverted without a reason'))).toBe(true) + }) + + it('returns false for unrelated errors', () => { + expect(isRevertError(new Error('network timeout'))).toBe(false) + expect(isRevertError(new Error('fetch failed'))).toBe(false) + }) +}) + +describe('extractRevertReason', () => { + it('returns "Unknown revert" for non-Error values', () => { + expect(extractRevertReason('string')).toBe('Unknown revert') + expect(extractRevertReason(null)).toBe('Unknown revert') + }) + + it('prefers shortMessage when present', () => { + const err = new Error('long detailed message') as Error & { shortMessage: string } + err.shortMessage = 'transfer amount exceeds balance' + expect(extractRevertReason(err)).toBe('transfer amount exceeds balance') + }) + + it('falls back to message when no shortMessage', () => { + const err = new Error('execution reverted: insufficient funds') + expect(extractRevertReason(err)).toBe('execution reverted: insufficient funds') + }) +}) diff --git a/apps/agentic-chat/src/utils/chains/evm/__tests__/simulation.test.ts b/apps/agentic-chat/src/utils/chains/evm/__tests__/simulation.test.ts new file mode 100644 index 00000000..e9d4ead1 --- /dev/null +++ b/apps/agentic-chat/src/utils/chains/evm/__tests__/simulation.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'bun:test' + +import { SimulationError } from '@/utils/SimulationError' + +import { simulateEvmTransaction } from '../simulation' + +function mockPublicClient(overrides: { + callResult?: undefined + callError?: Error + gasEstimate?: bigint + gasError?: Error +}) { + return { + call: async () => { + if (overrides.callError) throw overrides.callError + return overrides.callResult + }, + estimateGas: async () => { + if (overrides.gasError) throw overrides.gasError + return overrides.gasEstimate ?? 21000n + }, + } as any +} + +const defaultParams = { + account: '0x1234567890abcdef1234567890abcdef12345678' as const, + to: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as const, + value: 0n, + data: '0x' as const, +} + +describe('simulateEvmTransaction', () => { + it('throws SimulationError when call reverts', async () => { + const revertErr = new Error('execution reverted: insufficient balance') as Error & { shortMessage: string } + revertErr.shortMessage = 'insufficient balance' + const client = mockPublicClient({ callError: revertErr }) + + expect(simulateEvmTransaction(client, defaultParams)).rejects.toBeInstanceOf(SimulationError) + }) + + it('includes revert reason in SimulationError message', async () => { + const revertErr = new Error('execution reverted: insufficient balance') as Error & { shortMessage: string } + revertErr.shortMessage = 'insufficient balance' + const client = mockPublicClient({ callError: revertErr }) + + try { + await simulateEvmTransaction(client, defaultParams) + expect(true).toBe(false) // should not reach + } catch (error) { + expect(error).toBeInstanceOf(SimulationError) + expect((error as Error).message).toBe('insufficient balance') + } + }) + + it('re-throws non-revert errors from call', async () => { + const networkErr = new Error('network timeout') + const client = mockPublicClient({ callError: networkErr }) + + expect(simulateEvmTransaction(client, defaultParams)).rejects.toThrow('network timeout') + try { + await simulateEvmTransaction(client, defaultParams) + } catch (error) { + expect(error).not.toBeInstanceOf(SimulationError) + } + }) + + it('returns gas estimate with 20% buffer on success', async () => { + const client = mockPublicClient({ gasEstimate: 100000n }) + const result = await simulateEvmTransaction(client, defaultParams) + expect(result).toBe(120000n) // 100000 + 20% + }) + + it('applies buffer correctly for non-round gas values', async () => { + const client = mockPublicClient({ gasEstimate: 21000n }) + const result = await simulateEvmTransaction(client, defaultParams) + // 21000 + (21000 * 20 / 100) = 21000 + 4200 = 25200 + expect(result).toBe(25200n) + }) +}) diff --git a/apps/agentic-chat/src/utils/chains/evm/simulation.ts b/apps/agentic-chat/src/utils/chains/evm/simulation.ts new file mode 100644 index 00000000..f599d2dc --- /dev/null +++ b/apps/agentic-chat/src/utils/chains/evm/simulation.ts @@ -0,0 +1,26 @@ +import type { Hex, PublicClient } from 'viem' + +import { extractRevertReason, isRevertError, SimulationError } from '@/utils/SimulationError' + +const GAS_BUFFER_PERCENT = 20n + +type SimulateParams = { + account: `0x${string}` + to: `0x${string}` + value: bigint + data: Hex +} + +export async function simulateEvmTransaction(publicClient: PublicClient, params: SimulateParams): Promise { + // Detect reverts via eth_call + try { + await publicClient.call(params) + } catch (error) { + if (isRevertError(error)) throw new SimulationError(extractRevertReason(error)) + throw error + } + + // Get accurate gas estimate + const gasEstimate = await publicClient.estimateGas(params) + return gasEstimate + (gasEstimate * GAS_BUFFER_PERCENT) / 100n +} diff --git a/apps/agentic-chat/src/utils/chains/evm/transaction.ts b/apps/agentic-chat/src/utils/chains/evm/transaction.ts index f74a0ac1..fb86ddee 100644 --- a/apps/agentic-chat/src/utils/chains/evm/transaction.ts +++ b/apps/agentic-chat/src/utils/chains/evm/transaction.ts @@ -5,6 +5,7 @@ import type { Hex } from 'viem' import { chainIdToChain } from '@/lib/chains' import { wagmiConfig } from '@/lib/wagmi-config' +import { SimulationError } from '@/utils/SimulationError' import type { TransactionParams } from '../types' @@ -38,7 +39,22 @@ export async function sendEvmTransaction(params: TransactionParams): Promise ({ value: result }), + } as any +} + +const tx = {} as any + +describe('simulateSolanaTransaction', () => { + it('returns void on successful simulation', async () => { + const connection = mockConnection({ err: null, logs: [] }) + const result = await simulateSolanaTransaction(tx, connection) + expect(result).toBeUndefined() + }) + + it('throws SimulationError with matching log line on error', async () => { + const connection = mockConnection({ + err: { InstructionError: [0, 'Custom'] }, + logs: ['Program log: some info', 'Program log: Error: insufficient funds', 'Program log: cleanup'], + }) + + try { + await simulateSolanaTransaction(tx, connection) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SimulationError) + expect((error as Error).message).toInclude('Error') + } + }) + + it('throws SimulationError with "failed" log line', async () => { + const connection = mockConnection({ + err: { InstructionError: [0, 'Custom'] }, + logs: ['Program log: transaction failed due to slippage'], + }) + + try { + await simulateSolanaTransaction(tx, connection) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SimulationError) + expect((error as Error).message).toInclude('failed') + } + }) + + it('falls back to JSON-stringified error when no matching log', async () => { + const errObj = { InstructionError: [0, 'Custom'] } + const connection = mockConnection({ + err: errObj, + logs: ['Program log: some benign info'], + }) + + try { + await simulateSolanaTransaction(tx, connection) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SimulationError) + expect((error as Error).message).toBe(JSON.stringify(errObj)) + } + }) + + it('falls back to JSON-stringified error when logs are null', async () => { + const errObj = { InstructionError: [0, 'Custom'] } + const connection = mockConnection({ err: errObj, logs: null }) + + try { + await simulateSolanaTransaction(tx, connection) + expect(true).toBe(false) + } catch (error) { + expect(error).toBeInstanceOf(SimulationError) + expect((error as Error).message).toBe(JSON.stringify(errObj)) + } + }) +}) diff --git a/apps/agentic-chat/src/utils/chains/solana/simulation.ts b/apps/agentic-chat/src/utils/chains/solana/simulation.ts new file mode 100644 index 00000000..e9087d70 --- /dev/null +++ b/apps/agentic-chat/src/utils/chains/solana/simulation.ts @@ -0,0 +1,23 @@ +import type { Connection, VersionedTransaction } from '@solana/web3.js' + +import { SimulationError } from '@/utils/SimulationError' + +export async function simulateSolanaTransaction( + transaction: VersionedTransaction, + connection: Connection +): Promise { + const result = await connection.simulateTransaction(transaction, { + sigVerify: false, + commitment: 'confirmed', + }) + + if (result.value.err) { + const logs = result.value.logs ?? [] + const errorLog = logs.find(l => { + const lower = l.toLowerCase() + return lower.includes('error') || lower.includes('failed') + }) + const reason = errorLog ?? JSON.stringify(result.value.err) + throw new SimulationError(reason) + } +} diff --git a/apps/agentic-chat/src/utils/chains/solana/transaction.ts b/apps/agentic-chat/src/utils/chains/solana/transaction.ts index 60ca0780..e13575dc 100644 --- a/apps/agentic-chat/src/utils/chains/solana/transaction.ts +++ b/apps/agentic-chat/src/utils/chains/solana/transaction.ts @@ -7,6 +7,8 @@ import { VersionedTransaction, } from '@solana/web3.js' +import { SimulationError } from '@/utils/SimulationError' + import type { TransactionParams } from '../types' interface SolanaTransactionData { @@ -81,17 +83,29 @@ export async function sendSolanaTransaction(params: TransactionParams): Promise< const transaction = new VersionedTransaction(messageV0) + // Simulate before wallet interaction + try { + const { simulateSolanaTransaction } = await import('./simulation') + await simulateSolanaTransaction(transaction, connection) + } catch (error) { + if (error instanceof SimulationError) throw error + console.warn('[simulation] Solana simulation failed, proceeding without:', error) + } + const signedTx = await (signer.signTransaction as (tx: VersionedTransaction) => Promise)( transaction ) const signature = await connection.sendRawTransaction(signedTx.serialize(), { - skipPreflight: false, + skipPreflight: true, preflightCommitment: 'confirmed', maxRetries: 3, }) return signature } catch (error) { + if (error instanceof SimulationError) { + throw new Error(`Transaction will revert: ${error.message}`) + } if (error instanceof SyntaxError) { throw new Error('Failed to parse Solana transaction data: Invalid JSON') }