|
| 1 | +import { Type } from '@sinclair/typebox' |
| 2 | +import { type Provider, ethers, formatEther } from 'ethers' |
| 3 | +import type { FastifyInstance } from 'fastify' |
| 4 | +import { logger } from '~/utils/logger' |
| 5 | + |
| 6 | +const CONTRACT_INFO_CACHE = new Map<string, { name: string }>() |
| 7 | + |
| 8 | +type DebugTraceTransactionParams = { chainId: string; txHash: string } |
| 9 | +type DebugTraceTransactionQuery = { rpcUrl: string } |
| 10 | +type RawTraceCall = { |
| 11 | + from: string |
| 12 | + gas: string |
| 13 | + gasUsed: string |
| 14 | + to: string |
| 15 | + input: string |
| 16 | + output?: string |
| 17 | + error?: string |
| 18 | + value: string |
| 19 | + type: string |
| 20 | + calls?: RawTraceCall[] |
| 21 | +} |
| 22 | + |
| 23 | +type DecodedSignature = { |
| 24 | + functionName: string |
| 25 | + functionSignature: string |
| 26 | + parameters: Array<{ name: string; type: string; value: string }> |
| 27 | +} |
| 28 | + |
| 29 | +type SimplifiedTraceCall = { |
| 30 | + type: string |
| 31 | + from: string |
| 32 | + fromContractName: string |
| 33 | + to: string |
| 34 | + toContractName: string |
| 35 | + functionSelector: string |
| 36 | + decodedFunctionSelector: DecodedSignature |
| 37 | + value: string |
| 38 | + valueInEther: string |
| 39 | + gasUsed: string |
| 40 | + reverted: boolean |
| 41 | + revertReason?: string |
| 42 | + calls: SimplifiedTraceCall[] |
| 43 | +} |
| 44 | + |
| 45 | +type DebugCheckIfRevertedResponse = { |
| 46 | + result: { |
| 47 | + hasRevertedCalls: boolean |
| 48 | + revertReasons: string[] |
| 49 | + revertedCalls: SimplifiedTraceCall[] |
| 50 | + summary?: string |
| 51 | + error?: string |
| 52 | + } |
| 53 | +} |
| 54 | + |
| 55 | +const decodedSignatureSchema = Type.Object({ |
| 56 | + functionName: Type.String(), |
| 57 | + functionSignature: Type.String(), |
| 58 | + parameters: Type.Array( |
| 59 | + Type.Object({ |
| 60 | + name: Type.String(), |
| 61 | + type: Type.String(), |
| 62 | + value: Type.String() |
| 63 | + }) |
| 64 | + ) |
| 65 | +}) |
| 66 | + |
| 67 | +const simplifiedTraceCallSchema = Type.Recursive((Self) => |
| 68 | + Type.Object({ |
| 69 | + type: Type.String(), |
| 70 | + from: Type.String(), |
| 71 | + fromContractName: Type.String(), |
| 72 | + to: Type.String(), |
| 73 | + toContractName: Type.String(), |
| 74 | + functionSelector: Type.String(), |
| 75 | + decodedFunctionSelector: decodedSignatureSchema, |
| 76 | + value: Type.String(), |
| 77 | + valueInEther: Type.String(), |
| 78 | + gasUsed: Type.String(), |
| 79 | + reverted: Type.Boolean(), |
| 80 | + revertReason: Type.Optional(Type.String()), |
| 81 | + calls: Type.Array(Self) |
| 82 | + }) |
| 83 | +) |
| 84 | + |
| 85 | +const debugCheckIfRevertedSchema = { |
| 86 | + description: |
| 87 | + 'Checks if a transaction has internal reverted calls, returning all possible function signatures, decodings, and resolved contract names.', |
| 88 | + tags: ['Debug', 'Contract'], |
| 89 | + params: Type.Object({ chainId: Type.String(), txHash: Type.String() }), |
| 90 | + querystring: Type.Object({ rpcUrl: Type.String() }), |
| 91 | + response: { |
| 92 | + 200: Type.Object({ |
| 93 | + result: Type.Object({ |
| 94 | + hasRevertedCalls: Type.Boolean(), |
| 95 | + revertReasons: Type.Array(Type.String()), |
| 96 | + summary: Type.Optional(Type.String()), |
| 97 | + revertedCalls: Type.Array(simplifiedTraceCallSchema), |
| 98 | + error: Type.Optional(Type.String()) |
| 99 | + }) |
| 100 | + }) |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +const getContractName = async ( |
| 105 | + address: string, |
| 106 | + provider: Provider |
| 107 | +): Promise<string> => { |
| 108 | + if (CONTRACT_INFO_CACHE.has(address)) { |
| 109 | + return CONTRACT_INFO_CACHE.get(address)!.name |
| 110 | + } |
| 111 | + try { |
| 112 | + // EOA (Externally Owned Account) check. Avoids unnecessary RPC calls. |
| 113 | + const code = await provider.getCode(address) |
| 114 | + if (code === '0x') { |
| 115 | + CONTRACT_INFO_CACHE.set(address, { name: 'EOA' }) |
| 116 | + return 'EOA' |
| 117 | + } |
| 118 | + |
| 119 | + const minimalAbi = ['function name() view returns (string)'] |
| 120 | + const contract = new ethers.Contract(address, minimalAbi, provider) |
| 121 | + const name = await Promise.race([ |
| 122 | + contract.name(), |
| 123 | + new Promise((_, reject) => |
| 124 | + setTimeout(() => reject(new Error('Timeout')), 2000) |
| 125 | + ) |
| 126 | + ]) |
| 127 | + |
| 128 | + if (typeof name === 'string' && name.length > 0) { |
| 129 | + CONTRACT_INFO_CACHE.set(address, { name }) |
| 130 | + return name |
| 131 | + } |
| 132 | + } catch (error) {} |
| 133 | + |
| 134 | + const fallbackName = 'Unknown Contract' |
| 135 | + CONTRACT_INFO_CACHE.set(address, { name: fallbackName }) |
| 136 | + return fallbackName |
| 137 | +} |
| 138 | + |
| 139 | +const fetchFunctionSignature = async ( |
| 140 | + selector: string |
| 141 | +): Promise<string | null> => { |
| 142 | + try { |
| 143 | + const response = await fetch( |
| 144 | + `https://www.4byte.directory/api/v1/signatures/?hex_signature=${selector}` |
| 145 | + ) |
| 146 | + if (!response.ok) return null |
| 147 | + const data = (await response.json()) as { |
| 148 | + results?: Array<{ |
| 149 | + id: number |
| 150 | + text_signature: string |
| 151 | + }> |
| 152 | + } |
| 153 | + if (data.results && data.results.length > 0) { |
| 154 | + const sortedResults = data.results.sort((a, b) => a.id - b.id) |
| 155 | + return sortedResults[0].text_signature |
| 156 | + } |
| 157 | + return null |
| 158 | + } catch (error) { |
| 159 | + logger.warn(`Failed to fetch signature for ${selector}: ${error}`) |
| 160 | + return null |
| 161 | + } |
| 162 | +} |
| 163 | + |
| 164 | +const decodeAndFetchSignatures = async ( |
| 165 | + input: string |
| 166 | +): Promise<{ decodings: DecodedSignature[]; signatures: string[] }> => { |
| 167 | + if (!input || input === '0x' || input.length < 10) |
| 168 | + return { decodings: [], signatures: [] } |
| 169 | + const selector = input.slice(0, 10) |
| 170 | + const signature = await fetchFunctionSignature(selector) |
| 171 | + if (!signature) return { decodings: [], signatures: [] } |
| 172 | + |
| 173 | + const decodings: DecodedSignature[] = [] |
| 174 | + try { |
| 175 | + const iface = new ethers.Interface([`function ${signature}`]) |
| 176 | + const decoded = iface.parseTransaction({ data: input }) |
| 177 | + if (decoded) { |
| 178 | + decodings.push({ |
| 179 | + functionName: decoded.name, |
| 180 | + functionSignature: signature, |
| 181 | + parameters: decoded.args.map((arg, i) => ({ |
| 182 | + name: decoded.fragment.inputs[i]?.name || `param${i}`, |
| 183 | + type: decoded.fragment.inputs[i]?.type, |
| 184 | + value: arg.toString() |
| 185 | + })) |
| 186 | + }) |
| 187 | + } |
| 188 | + } catch (e) { |
| 189 | + throw new Error( |
| 190 | + `Failed to decode function call with signature ${signature}: ${e}` |
| 191 | + ) |
| 192 | + } |
| 193 | + return { decodings, signatures: [signature] } |
| 194 | +} |
| 195 | + |
| 196 | +const getRevertReason = (output?: string): string | undefined => { |
| 197 | + if (!output || !output.startsWith('0x08c379a0')) |
| 198 | + return 'Execution reverted without a reason string' |
| 199 | + try { |
| 200 | + return new ethers.Interface(['function Error(string)']).decodeErrorResult( |
| 201 | + 'Error', |
| 202 | + output |
| 203 | + )[0] |
| 204 | + } catch (e) { |
| 205 | + return 'Execution reverted with unrecognized error format.' |
| 206 | + } |
| 207 | +} |
| 208 | + |
| 209 | +const transformTrace = async ( |
| 210 | + call: RawTraceCall, |
| 211 | + provider: Provider |
| 212 | +): Promise<SimplifiedTraceCall> => { |
| 213 | + const { decodings, signatures } = await decodeAndFetchSignatures(call.input) |
| 214 | + const reverted = !!call.error |
| 215 | + |
| 216 | + const [fromContractName, toContractName] = await Promise.all([ |
| 217 | + getContractName(call.from, provider), |
| 218 | + getContractName(call.to, provider) |
| 219 | + ]) |
| 220 | + |
| 221 | + return { |
| 222 | + type: call.type, |
| 223 | + from: call.from, |
| 224 | + fromContractName, |
| 225 | + to: call.to, |
| 226 | + toContractName, |
| 227 | + functionSelector: call.input.slice(0, 10), |
| 228 | + decodedFunctionSelector: decodings[0], |
| 229 | + value: call.value ? BigInt(call.value).toString() : '0', |
| 230 | + valueInEther: call.value ? formatEther(BigInt(call.value)) : '0', |
| 231 | + gasUsed: call.gasUsed ? BigInt(call.gasUsed).toString() : '0', |
| 232 | + reverted, |
| 233 | + revertReason: reverted |
| 234 | + ? call.error === 'execution reverted' |
| 235 | + ? getRevertReason(call.output) |
| 236 | + : call.error |
| 237 | + : undefined, |
| 238 | + calls: call.calls |
| 239 | + ? await Promise.all(call.calls.map((c) => transformTrace(c, provider))) |
| 240 | + : [] |
| 241 | + } |
| 242 | +} |
| 243 | + |
| 244 | +const findRevertedCalls = ( |
| 245 | + calls: SimplifiedTraceCall[] |
| 246 | +): SimplifiedTraceCall[] => { |
| 247 | + const reverted: SimplifiedTraceCall[] = [] |
| 248 | + const traverse = (callList: SimplifiedTraceCall[]) => { |
| 249 | + for (const call of callList) { |
| 250 | + if (call.reverted) reverted.push(call) |
| 251 | + if (call.calls?.length > 0) traverse(call.calls) |
| 252 | + } |
| 253 | + } |
| 254 | + traverse(calls) |
| 255 | + return reverted |
| 256 | +} |
| 257 | + |
| 258 | +const findDeepestRevertedCall = ( |
| 259 | + calls: SimplifiedTraceCall[] |
| 260 | +): SimplifiedTraceCall | null => { |
| 261 | + let deepestReverted: SimplifiedTraceCall | null = null |
| 262 | + let maxDepth = -1 |
| 263 | + |
| 264 | + const traverse = (callList: SimplifiedTraceCall[], depth = 0) => { |
| 265 | + for (const call of callList) { |
| 266 | + if (call.reverted && depth > maxDepth) { |
| 267 | + deepestReverted = call |
| 268 | + maxDepth = depth |
| 269 | + } |
| 270 | + if (call.calls?.length > 0) { |
| 271 | + traverse(call.calls, depth + 1) |
| 272 | + } |
| 273 | + } |
| 274 | + } |
| 275 | + |
| 276 | + traverse(calls) |
| 277 | + return deepestReverted |
| 278 | +} |
| 279 | + |
| 280 | +const generateSummary = ( |
| 281 | + txHash: string, |
| 282 | + deepestReverted: SimplifiedTraceCall | null |
| 283 | +): string => { |
| 284 | + if (!deepestReverted) { |
| 285 | + return `Transaction ${txHash} completed successfully` |
| 286 | + } |
| 287 | + |
| 288 | + const functionSignature = |
| 289 | + deepestReverted.decodedFunctionSelector?.functionSignature || |
| 290 | + 'Unknown Function' |
| 291 | + const revertReason = deepestReverted.revertReason || 'Unknown reason' |
| 292 | + |
| 293 | + return `Function ${functionSignature}() reverted with reason "${revertReason}". Parameters used: ${deepestReverted.decodedFunctionSelector?.parameters.map((p) => `${p.value}`).join(', ')}` |
| 294 | +} |
| 295 | + |
| 296 | +export async function checkForInternalReverts(fastify: FastifyInstance) { |
| 297 | + fastify.addHook('onRequest', (request, reply, done) => { |
| 298 | + CONTRACT_INFO_CACHE.clear() |
| 299 | + done() |
| 300 | + }) |
| 301 | + |
| 302 | + fastify.get<{ |
| 303 | + Params: DebugTraceTransactionParams |
| 304 | + Querystring: DebugTraceTransactionQuery |
| 305 | + Reply: DebugCheckIfRevertedResponse |
| 306 | + }>( |
| 307 | + '/debug/checkForInternalReverts/:chainId/:txHash', |
| 308 | + { schema: debugCheckIfRevertedSchema }, |
| 309 | + async (request, reply) => { |
| 310 | + try { |
| 311 | + const { chainId, txHash } = request.params |
| 312 | + const { rpcUrl } = request.query |
| 313 | + const chainIdNumber = Number.parseInt(chainId, 10) |
| 314 | + if (Number.isNaN(chainIdNumber)) |
| 315 | + return reply.code(400).send({ |
| 316 | + result: { |
| 317 | + hasRevertedCalls: false, |
| 318 | + revertReasons: [], |
| 319 | + revertedCalls: [], |
| 320 | + error: 'Invalid chain ID.' |
| 321 | + } |
| 322 | + }) |
| 323 | + if (!/^0x[a-fA-F0-9]{64}$/.test(txHash)) |
| 324 | + return reply.code(400).send({ |
| 325 | + result: { |
| 326 | + hasRevertedCalls: false, |
| 327 | + revertReasons: [], |
| 328 | + revertedCalls: [], |
| 329 | + error: 'Invalid transaction hash format.' |
| 330 | + } |
| 331 | + }) |
| 332 | + |
| 333 | + const provider = new ethers.JsonRpcProvider(rpcUrl, chainIdNumber) |
| 334 | + logger.info( |
| 335 | + `Checking for reverts in transaction ${txHash} on chain ${chainId}` |
| 336 | + ) |
| 337 | + const rawTrace = await provider.send('debug_traceTransaction', [ |
| 338 | + txHash, |
| 339 | + { tracer: 'callTracer' } |
| 340 | + ]) |
| 341 | + if (!rawTrace) |
| 342 | + return reply.code(200).send({ |
| 343 | + result: { |
| 344 | + hasRevertedCalls: false, |
| 345 | + revertReasons: [], |
| 346 | + revertedCalls: [] |
| 347 | + } |
| 348 | + }) |
| 349 | + |
| 350 | + const simplifiedTrace = await transformTrace(rawTrace, provider) |
| 351 | + const revertedCalls = findRevertedCalls([simplifiedTrace]) |
| 352 | + const revertReasons = Array.from( |
| 353 | + new Set( |
| 354 | + revertedCalls.map((c) => c.revertReason).filter(Boolean) as string[] |
| 355 | + ) |
| 356 | + ) |
| 357 | + const deepestReverted = findDeepestRevertedCall([simplifiedTrace]) |
| 358 | + const summary = generateSummary(txHash, deepestReverted) |
| 359 | + |
| 360 | + return reply.code(200).send({ |
| 361 | + result: { |
| 362 | + hasRevertedCalls: revertedCalls.length > 0, |
| 363 | + revertReasons, |
| 364 | + revertedCalls, |
| 365 | + summary |
| 366 | + } |
| 367 | + }) |
| 368 | + } catch (error: any) { |
| 369 | + logger.error(`Error checking for reverts: ${error}`) |
| 370 | + return reply.code(500).send({ |
| 371 | + result: { |
| 372 | + hasRevertedCalls: false, |
| 373 | + revertReasons: [], |
| 374 | + revertedCalls: [], |
| 375 | + error: error.message |
| 376 | + } |
| 377 | + }) |
| 378 | + } |
| 379 | + } |
| 380 | + ) |
| 381 | +} |
0 commit comments