Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/agentic-chat/src/lib/stepUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
20 changes: 20 additions & 0 deletions apps/agentic-chat/src/utils/SimulationError.ts
Original file line number Diff line number Diff line change
@@ -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
}
63 changes: 63 additions & 0 deletions apps/agentic-chat/src/utils/__tests__/SimulationError.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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)
})
})
26 changes: 26 additions & 0 deletions apps/agentic-chat/src/utils/chains/evm/simulation.ts
Original file line number Diff line number Diff line change
@@ -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<bigint> {
// 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
}
23 changes: 21 additions & 2 deletions apps/agentic-chat/src/utils/chains/evm/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -38,20 +39,38 @@ export async function sendEvmTransaction(params: TransactionParams): Promise<str
const to = getAddress(params.to)
const value = BigInt(params.value)
const data = params.data as Hex
const gas = params.gasLimit ? BigInt(params.gasLimit) : undefined
let gas = params.gasLimit ? BigInt(params.gasLimit) : undefined

// Simulate before wallet interaction
try {
const { simulateEvmTransaction } = await import('./simulation')
const estimatedGas = await simulateEvmTransaction(publicClient, {
account: account as Hex,
to: to as Hex,
value,
data,
})
if (!params.gasLimit) gas = estimatedGas
} catch (error) {
if (error instanceof SimulationError) throw error
console.warn('[simulation] EVM simulation failed, proceeding without:', error)
}

const txParams = {
account,
to,
value,
data,
chain,
...(gas && { gas }),
...(gas !== undefined && { gas }),
}

const txHash = await walletClient.sendTransaction(txParams)
return txHash
} catch (error) {
if (error instanceof SimulationError) {
throw new Error(`Transaction will revert: ${error.message}`)
}
if (error instanceof Error) {
throw new Error(`EVM transaction failed: ${error.message}`)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'bun:test'

import { SimulationError } from '@/utils/SimulationError'

import { simulateSolanaTransaction } from '../simulation'

function mockConnection(result: { err: unknown; logs: string[] | null }) {
return {
simulateTransaction: async () => ({ 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))
}
})
})
23 changes: 23 additions & 0 deletions apps/agentic-chat/src/utils/chains/solana/simulation.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
16 changes: 15 additions & 1 deletion apps/agentic-chat/src/utils/chains/solana/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
VersionedTransaction,
} from '@solana/web3.js'

import { SimulationError } from '@/utils/SimulationError'

import type { TransactionParams } from '../types'

interface SolanaTransactionData {
Expand Down Expand Up @@ -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<VersionedTransaction>)(
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')
}
Expand Down
Loading