Skip to content

Commit 2e816cf

Browse files
authored
Feat/debugging (#53)
* add more debugging endpoints using raw traces * linting fix * fix relayer receipt route * change param name * linting
1 parent c0898e4 commit 2e816cf

File tree

6 files changed

+962
-3
lines changed

6 files changed

+962
-3
lines changed
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)