Skip to content

Commit a064c72

Browse files
Add ability to try the decoder over multiple ABIs (#239)
* Change store API and add migrations * Refactor abi loader to handle multiple abi in decoding * Update documentation * Update migrations * Strategy executor returns abi with strategy id * Add default ABIs as fallback * Use strict decode to ensure params --------- Co-authored-by: Anastasia Rodionova <[email protected]>
1 parent 6a0d1bb commit a064c72

29 files changed

+842
-351
lines changed

.changeset/old-signs-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@3loop/transaction-decoder': minor
3+
---
4+
5+
BREAKING! This version changes the public API for ABI store. If you use built in stores evrything should work out of the box. When using SQL store ensure that migrations complete on start. Additionally the AbiLoader will now also return an array of ABIs when accessing the cached data. These changes allows us to run over multiple ABIs when decoding a transaction, instead of failing when the cached ABI is wrong.
Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
```ts
22
export interface AbiStore {
3+
/**
4+
* Set of resolver strategies grouped by chain id and a `default` bucket.
5+
*/
36
readonly strategies: Record<ChainOrDefault, readonly ContractAbiResolverStrategy[]>
4-
readonly set: (key: AbiParams, value: ContractAbiResult) => Effect.Effect<void, never>
7+
/**
8+
* Persist a resolved ABI or a terminal state for a lookup key.
9+
*/
10+
readonly set: (key: AbiParams, value: CacheContractABIParam) => Effect.Effect<void, never>
11+
/**
12+
* Fetch all cached ABIs for the lookup key. Implementations may return multiple entries.
13+
*/
514
readonly get: (arg: AbiParams) => Effect.Effect<ContractAbiResult, never>
15+
/** Optional batched variant of `get` for performance. */
616
readonly getMany?: (arg: Array<AbiParams>) => Effect.Effect<Array<ContractAbiResult>, never>
17+
/** Optional helper for marking an existing cached ABI as invalid or changing its status. */
18+
readonly updateStatus?: (
19+
id: string | number,
20+
status: 'success' | 'invalid' | 'not-found',
21+
) => Effect.Effect<void, never>
722
}
823
```

apps/docs/src/content/components/vanilla-abi-store-interface.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
export interface VanillaAbiStore {
33
strategies?: readonly ContractAbiResolverStrategy[]
44
get: (key: AbiParams) => Promise<ContractAbiResult>
5-
set: (key: AbiParams, val: ContractAbiResult) => Promise<void>
5+
set: (key: AbiParams, val: ContractABI) => Promise<void>
66
}
77
```

apps/docs/src/content/docs/reference/data-loaders.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import { Tabs, TabItem } from '@astrojs/starlight/components'
1010
Data Loaders are mechanisms for retrieving the necessary ABI and Contract Metadata for transaction decoding. They are responsible for:
1111

1212
- Resolving ABIs and Contract Metadata using specified strategies and third-party APIs
13-
- Automatically batching requests when processing logs and traces in parallel
14-
- Caching request results in the [Data Store](/reference/data-store)
13+
- Automatically batching requests and deduplicating identical lookups during parallel log/trace processing
14+
- Caching results and negative lookups in the [Data Store](/reference/data-store)
15+
- Applying rate limits, circuit breaking, and adaptive concurrency via a shared request pool
1516

16-
Loop Decoder implements optimizations to minimize API requests to third-party services. For example, when a contract’s ABI is resolved via Etherscan, it is cached in the store. If the ABI is requested again, the store provides the cached version, avoiding redundant API calls.
17+
Loop Decoder implements optimizations to minimize API requests to third-party services. For example, when a contract’s ABI is resolved via Etherscan, it is cached in the store. If the ABI is requested again, the store provides the cached version, avoiding redundant API calls. When a source returns no result, a `not-found` entry can be stored to skip repeated attempts temporarily.
1718

1819
## ABI Strategies
1920

apps/docs/src/content/docs/reference/data-store.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ The full interface of ABI store is:
6767

6868
<EffectABI />
6969

70-
ABI Store Interface requires 2 methods: `set` and `get` to store and retrieve the ABI of a contract. Optionally, you can provide a batch get method `getMany` for further optimization. Because our API supports ABI fragments, the get method will receive both the contract address and the fragment signature.
70+
ABI Store Interface requires `set` and `get` to store and retrieve ABIs. Optionally, you can provide a batch get method `getMany` and an `updateStatus` helper used by the decoder to mark ABIs invalid after failed decode attempts. Because our API supports ABI fragments, the get method will receive both the contract address and the fragment signature.
7171

7272
```typescript
7373
interface AbiParams {

apps/docs/src/content/docs/welcome/overview.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ The Transaction Decoder package transforms raw transactions, including calldata,
2626

2727
See the Decoded Transaction section in our [playground](https://loop-decoder-web.vercel.app/) for transaction examples.
2828

29-
The minimal configuration for the Transaction Decoder requires an RPC URL, ABIs, and contract metadata stores, which can be in-memory (see the [Data Store](/reference/data-store/) section). We also provide data loaders for popular APIs like Etherscan (see the full list in the [ABI Strategies](/reference/data-loaders/) section). These loaders request contract metadata and cache it in your specified store. The decoder supports RPCs with both Debug (Geth tracers) and Trace (OpenEthereum/Parity and Erigon tracers) APIs.
29+
The minimal configuration for the Transaction Decoder requires an RPC URL, ABIs, and contract metadata stores, which can be in-memory (see the [Data Store](/reference/data-store/) section). We also provide data loaders for popular APIs like Etherscan (see the full list in the [ABI Strategies](/reference/data-loaders/) section). These loaders fetch ABIs/metadata, cache results and negative lookups in your store, and avoid querying sources previously marked invalid for a key. The decoder supports RPCs with both Debug (Geth tracers) and Trace (OpenEthereum/Parity and Erigon tracers) APIs.
3030

3131
### Transaction Interpreter
3232

apps/web/src/lib/decode.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,7 @@ import { SqlAbiStore, SqlContractMetaStore } from '@3loop/transaction-decoder/sq
1919
import { Hex } from 'viem'
2020
import { DatabaseLive } from './database'
2121

22-
const LogLevelLive = Layer.unwrapEffect(
23-
Effect.gen(function* () {
24-
const level = LogLevel.Warning
25-
return Logger.minimumLogLevel(level)
26-
}),
27-
)
22+
const LogLevelLive = Logger.minimumLogLevel(LogLevel.Warning)
2823

2924
const AbiStoreLive = Layer.unwrapEffect(
3025
Effect.gen(function* () {
@@ -85,7 +80,7 @@ export async function decodeTransaction({
8580
return { decoded: result }
8681
} catch (error: unknown) {
8782
const endTime = performance.now()
88-
const message = error instanceof Error ? JSON.parse(error.message) : 'Failed to decode transaction'
83+
const message = error instanceof Error ? error.message : 'Failed to decode transaction'
8984
console.log(message)
9085
console.log(`Failed decode transaction took ${endTime - startTime}ms`)
9186
return {

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

Lines changed: 123 additions & 57 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'
3-
import { Abi } from 'viem'
3+
import { Abi, erc20Abi, erc721Abi } 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,
@@ -68,7 +69,7 @@ const getMany = (requests: Array<AbiStore.AbiParams>) =>
6869
}
6970
})
7071

71-
const setValue = (key: AbiLoader, abi: ContractABI | null) =>
72+
const setValue = (key: AbiLoader, abi: (ContractABI & { strategyId: string }) | null) =>
7273
Effect.gen(function* () {
7374
const { set } = yield* AbiStore.AbiStore
7475
yield* set(
@@ -78,20 +79,23 @@ 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+
: {
92+
...abi,
93+
source: abi.strategyId,
94+
status: 'success' as const,
95+
},
8296
)
8397
})
8498

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-
9599
/**
96100
* Data loader for contracts abi
97101
*
@@ -136,6 +140,7 @@ const getBestMatch = (abi: ContractABI | null) => {
136140
* - Request pooling with back-pressure handling
137141
*/
138142

143+
// TODO: there is an overfetching, find why
139144
export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array<AbiLoader>) =>
140145
Effect.gen(function* () {
141146
if (requests.length === 0) return
@@ -146,27 +151,41 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
146151
const requestGroups = Array.groupBy(requests, makeRequestKey)
147152
const uniqueRequests = Object.values(requestGroups).map((group) => group[0])
148153

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-
)
154+
const allCachedData = yield* getMany(uniqueRequests)
155+
156+
// Create a map of invalid sources for each request
157+
const invalidSourcesMap = new Map<string, string[]>()
158+
allCachedData.forEach((abis, i) => {
159+
const request = uniqueRequests[i]
160+
const invalid = abis
161+
.filter((abi) => abi.status === 'invalid')
162+
.map((abi) => abi.source)
163+
.filter(Boolean) as string[]
164+
165+
invalidSourcesMap.set(makeRequestKey(request), invalid)
166+
})
167+
168+
const [remaining, cachedResults] = Array.partitionMap(allCachedData, (abis, i) => {
169+
// Filter out invalid/not-found ABIs and check if we have any valid ones
170+
const validAbis = abis.filter((abi) => abi.status === 'success')
171+
return validAbis.length === 0
172+
? Either.left(uniqueRequests[i])
173+
: Either.right([uniqueRequests[i], validAbis] as const)
174+
})
160175

161176
// Resolve ABI from the store
162177
yield* Effect.forEach(
163178
cachedResults,
164-
([request, abi]) => {
179+
([request, abis]) => {
165180
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 })
181+
const allMatches = abis.map((abi) => {
182+
const parsedAbi = abi.type === 'address' ? (JSON.parse(abi.abi) as Abi) : (JSON.parse(`[${abi.abi}]`) as Abi)
183+
return { abi: parsedAbi, id: abi.id }
184+
})
185+
186+
return Effect.forEach(group, (req) => Request.completeEffect(req, Effect.succeed(allMatches)), {
187+
discard: true,
188+
})
170189
},
171190
{
172191
discard: true,
@@ -191,15 +210,19 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
191210
const response = yield* Effect.forEach(
192211
remaining,
193212
(req) => {
194-
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? []).filter(
195-
(strategy) => strategy.type === 'address',
196-
)
213+
const invalidSources = invalidSourcesMap.get(makeRequestKey(req)) ?? []
214+
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? [])
215+
.filter((strategy) => strategy.type === 'address')
216+
.filter((strategy) => !invalidSources.includes(strategy.id))
217+
218+
if (allAvailableStrategies.length === 0) {
219+
return Effect.succeed(Either.right(req))
220+
}
197221

198222
return strategyExecutor
199223
.executeStrategiesSequentially(allAvailableStrategies, {
200224
address: req.address,
201225
chainId: req.chainID,
202-
strategyId: 'address-batch',
203226
})
204227
.pipe(
205228
Effect.tapError(Effect.logDebug),
@@ -220,18 +243,22 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
220243
// NOTE: Secondly we request strategies to fetch fragments
221244
const fragmentStrategyResults = yield* Effect.forEach(
222245
notFound,
223-
({ chainID, address, event, signature }) => {
224-
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[chainID] ?? []).filter(
225-
(strategy) => strategy.type === 'fragment',
226-
)
246+
(req) => {
247+
const invalidSources = invalidSourcesMap.get(makeRequestKey(req)) ?? []
248+
const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? [])
249+
.filter((strategy) => strategy.type === 'fragment')
250+
.filter((strategy) => !invalidSources.includes(strategy.id))
251+
252+
if (allAvailableStrategies.length === 0) {
253+
return Effect.succeed(null)
254+
}
227255

228256
return strategyExecutor
229257
.executeStrategiesSequentially(allAvailableStrategies, {
230-
address,
231-
chainId: chainID,
232-
event,
233-
signature,
234-
strategyId: 'fragment-batch',
258+
address: req.address,
259+
chainId: req.chainID,
260+
event: req.event,
261+
signature: req.signature,
235262
})
236263
.pipe(
237264
Effect.tapError(Effect.logDebug),
@@ -244,21 +271,50 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
244271
},
245272
)
246273

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

249-
// Store results and resolve pending requests
292+
// Resolve all remaining requests
250293
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))
294+
remaining,
295+
(request) => {
257296
const group = requestGroups[makeRequestKey(request)]
297+
const addressAbis = addressResultsMap.get(makeRequestKey(request)) || []
298+
const fragmentAbis = fragmentResultsMap.get(makeRequestKey(request)) || []
299+
const allAbis = [...addressAbis, ...fragmentAbis]
300+
301+
const allMatches = allAbis.map((abi) => {
302+
const parsedAbi = abi.type === 'address' ? (JSON.parse(abi.abi) as Abi) : (JSON.parse(`[${abi.abi}]`) as Abi)
303+
// TODO: We should figure out how to handle the db ID here, maybe we need to start providing the ids before inserting
304+
return { abi: parsedAbi, id: undefined }
305+
})
306+
307+
const cacheEffect =
308+
allAbis.length > 0
309+
? Effect.forEach(allAbis, (abi) => setValue(request, abi), {
310+
discard: true,
311+
concurrency: 'unbounded',
312+
})
313+
: Effect.void
258314

259315
return Effect.zipRight(
260-
setValue(request, abi),
261-
Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }),
316+
cacheEffect,
317+
Effect.forEach(group, (req) => Request.completeEffect(req, Effect.succeed(allMatches)), { discard: true }),
262318
)
263319
},
264320
{
@@ -277,21 +333,31 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A
277333
// in a missing Fragment. We treat this issue as a minor one for now, as we expect it to occur rarely on contracts that
278334
// are not verified and with a non standard events structure.
279335

336+
const errorAbis: Abi = [...solidityPanic, ...solidityError]
337+
280338
export const getAndCacheAbi = (params: AbiStore.AbiParams) =>
281339
Effect.gen(function* () {
282340
if (params.event === '0x' || params.signature === '0x') {
283341
return yield* Effect.fail(new EmptyCalldataError(params))
284342
}
285343

344+
if (params.signature && errorFunctionSignatures.includes(params.signature)) {
345+
return [{ abi: errorAbis, id: undefined }]
346+
}
347+
286348
if (params.signature && params.signature === SAFE_MULTISEND_SIGNATURE) {
287-
return yield* Effect.succeed(SAFE_MULTISEND_ABI)
349+
return [{ abi: SAFE_MULTISEND_ABI, id: undefined }]
288350
}
289351

290352
if (params.signature && AA_ABIS[params.signature]) {
291-
return yield* Effect.succeed(AA_ABIS[params.signature])
353+
return [{ abi: AA_ABIS[params.signature], id: undefined }]
292354
}
293355

294-
return yield* Effect.request(new AbiLoader(params), AbiLoaderRequestResolver)
356+
const abis = yield* Effect.request(new AbiLoader(params), AbiLoaderRequestResolver)
357+
return Array.appendAll(abis, [
358+
{ abi: erc20Abi, id: undefined },
359+
{ abi: erc721Abi, id: undefined },
360+
])
295361
}).pipe(
296362
Effect.withSpan('AbiLoader.GetAndCacheAbi', {
297363
attributes: {

0 commit comments

Comments
 (0)