Skip to content

Commit 0a4393b

Browse files
committed
Refactor abi loader to handle multiple abi in decoding
1 parent b51a320 commit 0a4393b

File tree

12 files changed

+313
-181
lines changed

12 files changed

+313
-181
lines changed

packages/transaction-decoder/src/abi-loader.ts

Lines changed: 104 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { Effect, Either, RequestResolver, Request, Array, pipe, Data, PrimaryKey, Schema, SchemaAST } from 'effect'
1+
import { Effect, Either, RequestResolver, Request, Array, Data, PrimaryKey, Schema, SchemaAST } from 'effect'
22
import { ContractABI } from './abi-strategy/request-model.js'
33
import { Abi } from 'viem'
44
import * as AbiStore from './abi-store.js'
55
import * as StrategyExecutorModule from './abi-strategy/strategy-executor.js'
66
import { SAFE_MULTISEND_SIGNATURE, SAFE_MULTISEND_ABI, AA_ABIS } from './decoding/constants.js'
7+
import { errorFunctionSignatures, solidityError, solidityPanic } from './helpers/error.js'
78

89
interface LoadParameters {
910
readonly chainID: number
@@ -34,7 +35,7 @@ export class EmptyCalldataError extends Data.TaggedError('DecodeError')<
3435
class SchemaAbi extends Schema.make<Abi>(SchemaAST.objectKeyword) {}
3536
class AbiLoader extends Schema.TaggedRequest<AbiLoader>()('AbiLoader', {
3637
failure: Schema.instanceOf(MissingABIError),
37-
success: SchemaAbi, // Abi
38+
success: Schema.Array(Schema.Struct({ abi: SchemaAbi, id: Schema.optional(Schema.String) })),
3839
payload: {
3940
chainID: Schema.Number,
4041
address: Schema.String,
@@ -78,20 +79,19 @@ const setValue = (key: AbiLoader, abi: ContractABI | null) =>
7879
event: key.event,
7980
signature: key.signature,
8081
},
81-
abi == null ? { status: 'not-found', result: null } : { status: 'success', result: abi },
82+
abi == null
83+
? {
84+
type: 'func' as const,
85+
abi: '',
86+
address: key.address,
87+
chainID: key.chainID,
88+
signature: key.signature || '',
89+
status: 'not-found' as const,
90+
}
91+
: { ...abi, status: 'success' as const },
8292
)
8393
})
8494

85-
const getBestMatch = (abi: ContractABI | null) => {
86-
if (abi == null) return null
87-
88-
if (abi.type === 'address') {
89-
return JSON.parse(abi.abi) as Abi
90-
}
91-
92-
return JSON.parse(`[${abi.abi}]`) as Abi
93-
}
94-
9595
/**
9696
* Data loader for contracts abi
9797
*
@@ -146,27 +146,41 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
146146
const requestGroups = Array.groupBy(requests, makeRequestKey)
147147
const uniqueRequests = Object.values(requestGroups).map((group) => group[0])
148148

149-
const [remaining, cachedResults] = yield* pipe(
150-
getMany(uniqueRequests),
151-
Effect.map(
152-
Array.partitionMap((resp, i) => {
153-
return resp.status === 'empty'
154-
? Either.left(uniqueRequests[i])
155-
: Either.right([uniqueRequests[i], resp.result] as const)
156-
}),
157-
),
158-
Effect.orElseSucceed(() => [uniqueRequests, []] as const),
159-
)
149+
const allCachedData = yield* getMany(uniqueRequests)
150+
151+
// Create a map of invalid sources for each request
152+
const invalidSourcesMap = new Map<string, string[]>()
153+
allCachedData.forEach((abis, i) => {
154+
const request = uniqueRequests[i]
155+
const invalid = abis
156+
.filter((abi) => abi.status === 'invalid')
157+
.map((abi) => abi.source)
158+
.filter(Boolean) as string[]
159+
160+
invalidSourcesMap.set(makeRequestKey(request), invalid)
161+
})
162+
163+
const [remaining, cachedResults] = Array.partitionMap(allCachedData, (abis, i) => {
164+
// Filter out invalid/not-found ABIs and check if we have any valid ones
165+
const validAbis = abis.filter((abi) => abi.status === 'success')
166+
return validAbis.length === 0
167+
? Either.left(uniqueRequests[i])
168+
: Either.right([uniqueRequests[i], validAbis] as const)
169+
})
160170

161171
// Resolve ABI from the store
162172
yield* Effect.forEach(
163173
cachedResults,
164-
([request, abi]) => {
174+
([request, abis]) => {
165175
const group = requestGroups[makeRequestKey(request)]
166-
const bestMatch = getBestMatch(abi)
167-
const result = bestMatch ? Effect.succeed(bestMatch) : Effect.fail(new MissingABIError(request))
168-
169-
return Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true })
176+
const allMatches = abis.map((abi) => {
177+
const parsedAbi = abi.type === 'address' ? (JSON.parse(abi.abi) as Abi) : (JSON.parse(`[${abi.abi}]`) as Abi)
178+
return { abi: parsedAbi, id: abi.id }
179+
})
180+
181+
return Effect.forEach(group, (req) => Request.completeEffect(req, Effect.succeed(allMatches)), {
182+
discard: true,
183+
})
170184
},
171185
{
172186
discard: true,
@@ -191,9 +205,14 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
191205
const response = yield* Effect.forEach(
192206
remaining,
193207
(req) => {
194-
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? []).filter(
195-
(strategy) => strategy.type === 'address',
196-
)
208+
const invalidSources = invalidSourcesMap.get(makeRequestKey(req)) ?? []
209+
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? [])
210+
.filter((strategy) => strategy.type === 'address')
211+
.filter((strategy) => !invalidSources.includes(strategy.id))
212+
213+
if (allAvailableStrategies.length === 0) {
214+
return Effect.succeed(Either.right(req))
215+
}
197216

198217
return strategyExecutor
199218
.executeStrategiesSequentially(allAvailableStrategies, {
@@ -220,17 +239,22 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
220239
// NOTE: Secondly we request strategies to fetch fragments
221240
const fragmentStrategyResults = yield* Effect.forEach(
222241
notFound,
223-
({ chainID, address, event, signature }) => {
224-
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[chainID] ?? []).filter(
225-
(strategy) => strategy.type === 'fragment',
226-
)
242+
(req) => {
243+
const invalidSources = invalidSourcesMap.get(makeRequestKey(req)) ?? []
244+
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? [])
245+
.filter((strategy) => strategy.type === 'fragment')
246+
.filter((strategy) => !invalidSources.includes(strategy.id))
247+
248+
if (allAvailableStrategies.length === 0) {
249+
return Effect.succeed(null)
250+
}
227251

228252
return strategyExecutor
229253
.executeStrategiesSequentially(allAvailableStrategies, {
230-
address,
231-
chainId: chainID,
232-
event,
233-
signature,
254+
address: req.address,
255+
chainId: req.chainID,
256+
event: req.event,
257+
signature: req.signature,
234258
strategyId: 'fragment-batch',
235259
})
236260
.pipe(
@@ -244,20 +268,44 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
244268
},
245269
)
246270

247-
const strategyResults = Array.appendAll(addressStrategyResults, fragmentStrategyResults)
271+
// Create a map to track which requests got results from address strategies
272+
const addressResultsMap = new Map<string, ContractABI[]>()
273+
addressStrategyResults.forEach((abis, i) => {
274+
const request = remaining[i]
275+
if (abis && abis.length > 0) {
276+
addressResultsMap.set(makeRequestKey(request), abis)
277+
}
278+
})
279+
280+
// Create a map to track which requests got results from fragment strategies
281+
const fragmentResultsMap = new Map<string, ContractABI[]>()
282+
fragmentStrategyResults.forEach((abis, i) => {
283+
const request = notFound[i]
284+
if (abis && abis.length > 0) {
285+
fragmentResultsMap.set(makeRequestKey(request), abis)
286+
}
287+
})
248288

249-
// Store results and resolve pending requests
289+
// Resolve all remaining requests
250290
yield* Effect.forEach(
251-
strategyResults,
252-
(abis, i) => {
253-
const request = remaining[i]
254-
const abi = abis?.[0] ?? null
255-
const bestMatch = getBestMatch(abi)
256-
const result = bestMatch ? Effect.succeed(bestMatch) : Effect.fail(new MissingABIError(request))
291+
remaining,
292+
(request) => {
257293
const group = requestGroups[makeRequestKey(request)]
294+
const addressAbis = addressResultsMap.get(makeRequestKey(request)) || []
295+
const fragmentAbis = fragmentResultsMap.get(makeRequestKey(request)) || []
296+
const allAbis = [...addressAbis, ...fragmentAbis]
297+
const firstAbi = allAbis[0] || null
298+
299+
const allMatches = allAbis.map((abi) => {
300+
const parsedAbi = abi.type === 'address' ? (JSON.parse(abi.abi) as Abi) : (JSON.parse(`[${abi.abi}]`) as Abi)
301+
// TODO: We should figure out how to handle the db ID here, maybe we need to start providing the ids before inserting
302+
return { abi: parsedAbi, id: undefined }
303+
})
304+
305+
const result = allMatches.length > 0 ? Effect.succeed(allMatches) : Effect.fail(new MissingABIError(request))
258306

259307
return Effect.zipRight(
260-
setValue(request, abi),
308+
setValue(request, firstAbi),
261309
Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }),
262310
)
263311
},
@@ -283,12 +331,17 @@ export const getAndCacheAbi = (params: AbiStore.AbiParams) =>
283331
return yield* Effect.fail(new EmptyCalldataError(params))
284332
}
285333

334+
if (params.signature && errorFunctionSignatures.includes(params.signature)) {
335+
const errorAbis: Abi = [...solidityPanic, ...solidityError]
336+
return [{ abi: errorAbis, id: undefined }]
337+
}
338+
286339
if (params.signature && params.signature === SAFE_MULTISEND_SIGNATURE) {
287-
return yield* Effect.succeed(SAFE_MULTISEND_ABI)
340+
return [{ abi: SAFE_MULTISEND_ABI, id: undefined }]
288341
}
289342

290343
if (params.signature && AA_ABIS[params.signature]) {
291-
return yield* Effect.succeed(AA_ABIS[params.signature])
344+
return [{ abi: AA_ABIS[params.signature], id: undefined }]
292345
}
293346

294347
return yield* Effect.request(new AbiLoader(params), AbiLoaderRequestResolver)

packages/transaction-decoder/src/decoding/abi-decode.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
import { formatAbiItem } from 'viem/utils'
22
import type { DecodeResult, MostTypes, TreeNode } from '../types.js'
3-
import { Hex, Abi, decodeFunctionData, AbiParameter, AbiFunction, getAbiItem } from 'viem'
3+
import {
4+
Hex,
5+
Abi,
6+
decodeFunctionData,
7+
decodeEventLog as viemDecodeEventLog,
8+
AbiParameter,
9+
AbiFunction,
10+
getAbiItem,
11+
} from 'viem'
412
import { Data, Effect } from 'effect'
513
import { messageFromUnknown } from '../helpers/error.js'
14+
import * as AbiStore from '../abi-store.js'
15+
import { getAndCacheAbi } from '../abi-loader.js'
616

717
export class DecodeError extends Data.TaggedError('DecodeError')<{ message: string }> {
818
constructor(message: string, error?: unknown) {
@@ -93,3 +103,102 @@ export const decodeMethod = (data: Hex, abi: Abi): Effect.Effect<DecodeResult |
93103
}
94104
}
95105
})
106+
107+
/**
108+
* Validates and decodes data using multiple ABIs with fallback support.
109+
* Tries each ABI in sequence and marks failed ABIs as invalid in the store.
110+
* Uses Effect.firstSuccessOf to return the first successful decode result.
111+
*/
112+
export const validateAndDecodeWithABIs = (
113+
data: Hex,
114+
params: AbiStore.AbiParams,
115+
): Effect.Effect<DecodeResult, DecodeError, AbiStore.AbiStore> =>
116+
Effect.gen(function* () {
117+
const { updateStatus } = yield* AbiStore.AbiStore
118+
119+
// TODO: When abi is returned from external source, it does not have ids.
120+
// We could do a new db selct, change API of Store to return ids, or provide
121+
// ids instead of DB auto-generated ids.
122+
// Now it will fail only on the second call
123+
const abiWithIds = yield* getAndCacheAbi(params)
124+
125+
// Create validation effects for store ABIs
126+
const storeValidationEffects = abiWithIds.map(({ abi, id }) =>
127+
Effect.gen(function* () {
128+
const result = yield* decodeMethod(data, abi)
129+
130+
if (result == null) {
131+
return yield* Effect.fail(new DecodeError(`ABI ${abi} failed to decode`))
132+
}
133+
134+
return result
135+
}).pipe(
136+
Effect.catchAll((error: DecodeError) => {
137+
return Effect.gen(function* () {
138+
if (updateStatus && id != null) {
139+
// Mark this ABI as invalid when it fails
140+
yield* updateStatus(id, 'invalid').pipe(Effect.catchAll(() => Effect.void))
141+
}
142+
return yield* Effect.fail(error)
143+
})
144+
}),
145+
),
146+
)
147+
148+
return yield* Effect.firstSuccessOf(storeValidationEffects)
149+
})
150+
151+
/**
152+
* Validates and decodes event logs using multiple ABIs with fallback support.
153+
* Tries each ABI in sequence and returns the first successful decode result along with the ABI used.
154+
* Similar to validateAndDecodeWithABIs but specifically for event logs.
155+
*/
156+
export const validateAndDecodeEventWithABIs = (
157+
topics: readonly Hex[],
158+
data: Hex,
159+
params: AbiStore.AbiParams,
160+
): Effect.Effect<{ eventName: string; args: any; abiItem: Abi }, DecodeError, AbiStore.AbiStore> =>
161+
Effect.gen(function* () {
162+
const abiWithIds = yield* getAndCacheAbi(params)
163+
164+
const validationEffects = abiWithIds.map(({ abi, id }) =>
165+
Effect.gen(function* () {
166+
const result = yield* decodeEventLog(topics, data, abi as Abi)
167+
168+
if (result == null) {
169+
return yield* Effect.fail(new DecodeError(`ABI failed to decode event`))
170+
}
171+
172+
return { ...result, abiItem: abi as Abi }
173+
}).pipe(
174+
Effect.catchAll((error: DecodeError) => {
175+
return Effect.gen(function* () {
176+
// Note: We don't mark ABIs as invalid for event decoding failures
177+
// as the same ABI might work for other events on the same contract
178+
return yield* Effect.fail(error)
179+
})
180+
}),
181+
),
182+
)
183+
184+
return yield* Effect.firstSuccessOf(validationEffects)
185+
})
186+
187+
export const decodeEventLog = (
188+
topics: readonly Hex[],
189+
data: Hex,
190+
abi: Abi,
191+
): Effect.Effect<{ eventName: string; args: any } | undefined, DecodeError> =>
192+
Effect.gen(function* () {
193+
const { eventName, args = {} } = yield* Effect.try({
194+
try: () =>
195+
viemDecodeEventLog({ abi, topics: topics as [] | [`0x${string}`, ...`0x${string}`[]], data, strict: false }),
196+
catch: (error) => new DecodeError(`Could not decode event log`, error),
197+
})
198+
199+
if (eventName == null) {
200+
return undefined
201+
}
202+
203+
return { eventName, args }
204+
})

packages/transaction-decoder/src/decoding/calldata-decode.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Effect } from 'effect'
22
import { Hex, Address, encodeFunctionData, isAddress, getAddress } from 'viem'
3-
import { getAndCacheAbi, MissingABIError } from '../abi-loader.js'
3+
import { MissingABIError } from '../abi-loader.js'
44
import * as AbiDecoder from './abi-decode.js'
5+
import { validateAndDecodeWithABIs } from './abi-decode.js'
56
import { TreeNode } from '../types.js'
67
import { PublicClient, RPCFetchError, UnknownNetwork } from '../public-client.js'
78
import { SAFE_MULTISEND_SIGNATURE, SAFE_MULTISEND_NESTED_ABI } from './constants.js'
@@ -166,16 +167,11 @@ export const decodeMethod = ({
166167
}
167168
}
168169

169-
const abi = yield* getAndCacheAbi({
170+
const decoded = yield* validateAndDecodeWithABIs(data, {
170171
address: implementationAddress ?? contractAddress,
171172
signature,
172173
chainID,
173174
})
174-
const decoded = yield* AbiDecoder.decodeMethod(data, abi)
175-
176-
if (decoded == null) {
177-
return yield* new AbiDecoder.DecodeError(`Failed to decode method: ${data}`)
178-
}
179175

180176
//MULTISEND: decode nested params for the multisend of the safe smart account
181177
if (

0 commit comments

Comments
 (0)