From b51a32024364cebcc232a3855a98ccb78553cd0e Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sat, 16 Aug 2025 15:48:58 +0200 Subject: [PATCH 01/16] Change store API and add migrations --- .changeset/old-signs-lay.md | 5 + packages/transaction-decoder/src/abi-store.ts | 28 ++- .../src/in-memory/abi-store.ts | 64 +++--- .../transaction-decoder/src/sql/abi-store.ts | 206 ++++++++++-------- .../transaction-decoder/src/sql/migrations.ts | 65 ++++++ .../interpreters/moxie.ts | 5 +- 6 files changed, 243 insertions(+), 130 deletions(-) create mode 100644 .changeset/old-signs-lay.md create mode 100644 packages/transaction-decoder/src/sql/migrations.ts diff --git a/.changeset/old-signs-lay.md b/.changeset/old-signs-lay.md new file mode 100644 index 00000000..56e86024 --- /dev/null +++ b/.changeset/old-signs-lay.md @@ -0,0 +1,5 @@ +--- +'@3loop/transaction-decoder': minor +--- + +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. diff --git a/packages/transaction-decoder/src/abi-store.ts b/packages/transaction-decoder/src/abi-store.ts index d3bb45d8..543f631d 100644 --- a/packages/transaction-decoder/src/abi-store.ts +++ b/packages/transaction-decoder/src/abi-store.ts @@ -9,30 +9,34 @@ export interface AbiParams { signature?: string | undefined } -export interface ContractAbiSuccess { - status: 'success' - result: ContractABI -} +export type CachedContractABIStatus = 'success' | 'invalid' | 'not-found' -export interface ContractAbiNotFound { - status: 'not-found' - result: null +export type CachedContractABI = ContractABI & { + id: string + source?: string + status: CachedContractABIStatus + timestamp?: string } -export interface ContractAbiEmpty { - status: 'empty' - result: null +export type CacheContractABIParam = ContractABI & { + source?: string + status: CachedContractABIStatus + timestamp?: string } -export type ContractAbiResult = ContractAbiSuccess | ContractAbiNotFound | ContractAbiEmpty +export type ContractAbiResult = CachedContractABI[] type ChainOrDefault = number | 'default' export interface AbiStore { readonly strategies: Record - readonly set: (key: AbiParams, value: ContractAbiResult) => Effect.Effect + readonly set: (key: AbiParams, value: CacheContractABIParam) => Effect.Effect readonly get: (arg: AbiParams) => Effect.Effect readonly getMany?: (arg: Array) => Effect.Effect, never> + readonly updateStatus?: ( + id: string | number, + status: 'success' | 'invalid' | 'not-found', + ) => Effect.Effect readonly circuitBreaker: CircuitBreaker.CircuitBreaker readonly requestPool: RequestPool.RequestPool } diff --git a/packages/transaction-decoder/src/in-memory/abi-store.ts b/packages/transaction-decoder/src/in-memory/abi-store.ts index 122ccffa..40ab44ba 100644 --- a/packages/transaction-decoder/src/in-memory/abi-store.ts +++ b/packages/transaction-decoder/src/in-memory/abi-store.ts @@ -2,6 +2,7 @@ import { Effect, Layer } from 'effect' import * as AbiStore from '../abi-store.js' import { ContractABI } from '../abi-strategy/request-model.js' +// Keyed by composite: kind|key|source to allow per-strategy replacement const abiCache = new Map() export const make = (strategies: AbiStore.AbiStore['strategies']) => @@ -9,44 +10,51 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => AbiStore.AbiStore, AbiStore.make({ strategies, - set: (_key, value) => + set: (_key, abi) => Effect.sync(() => { - if (value.status === 'success') { - if (value.result.type === 'address') { - abiCache.set(value.result.address, value.result) - } else if (value.result.type === 'event') { - abiCache.set(value.result.event, value.result) - } else if (value.result.type === 'func') { - abiCache.set(value.result.signature, value.result) - } + const source = abi.source ?? 'unknown' + if (abi.type === 'address') { + abiCache.set(`addr|${abi.address}|${source}`.toLowerCase(), abi) + } else if (abi.type === 'event') { + abiCache.set(`event|${abi.event}|${source}`, abi) + } else if (abi.type === 'func') { + abiCache.set(`sig|${abi.signature}|${source}`, abi) } }), get: (key) => Effect.sync(() => { - if (abiCache.has(key.address)) { - return { - status: 'success', - result: abiCache.get(key.address)!, - } - } + const results: ContractABI[] = [] - if (key.event && abiCache.has(key.event)) { - return { - status: 'success', - result: abiCache.get(key.event)!, - } - } + // If a specific strategy is requested via mark on keys in future, we return union of all strategies for that key + const prefixAddr = `addr|${key.address}|`.toLowerCase() + const prefixSig = key.signature ? `sig|${key.signature}|` : undefined + const prefixEvt = key.event ? `event|${key.event}|` : undefined - if (key.signature && abiCache.has(key.signature)) { - return { - status: 'success', - result: abiCache.get(key.signature)!, + for (const [k, v] of abiCache.entries()) { + if ( + k.startsWith(prefixAddr) || + (prefixSig && k.startsWith(prefixSig)) || + (prefixEvt && k.startsWith(prefixEvt)) + ) { + results.push(v) } } - return { - status: 'empty', - result: null, + return results + }), + updateStatus: (id, status) => + Effect.sync(() => { + // For in-memory store, we need to find the ABI by ID and update its status + // Since we don't have ID-based lookup in memory, we'll iterate through cache + for (const [key, abi] of abiCache.entries()) { + if (abi.id === id) { + // Create a new ABI object with updated status + // Note: For in-memory, we can't actually change the status of the result + // since it's used in ContractAbiResult. This is a limitation of the in-memory approach. + // In practice, you'd want to remove invalid ABIs from cache or mark them differently. + abiCache.delete(key) + break + } } }), }), diff --git a/packages/transaction-decoder/src/sql/abi-store.ts b/packages/transaction-decoder/src/sql/abi-store.ts index ffd19279..199a13f9 100644 --- a/packages/transaction-decoder/src/sql/abi-store.ts +++ b/packages/transaction-decoder/src/sql/abi-store.ts @@ -1,6 +1,7 @@ import * as AbiStore from '../abi-store.js' import { Effect, Layer } from 'effect' import { SqlClient } from '@effect/sql' +import { runMigrations, migration } from './migrations.js' // Utility function to build query conditions for a single key const buildQueryForKey = ( @@ -21,39 +22,20 @@ const buildQueryForKey = ( : sql.or([addressQuery, signatureQuery, eventQuery].filter(Boolean)) } -// Convert database items to result format +// Convert database items to result format - returns all ABIs with their individual status const createResult = (items: readonly any[], address: string, chainID: number): AbiStore.ContractAbiResult => { - const successItems = items.filter((item) => item.status === 'success') - - const item = - successItems.find((item) => { - // Prioritize address over fragments - return item.type === 'address' - }) ?? successItems[0] - - if (item != null) { - return { - status: 'success', - result: { - type: item.type, - event: item.event, - signature: item.signature, - address, - chainID, - abi: item.abi, - }, - } as AbiStore.ContractAbiResult - } else if (items[0] != null && items[0].status === 'not-found') { - return { - status: 'not-found', - result: null, - } - } - - return { - status: 'empty', - result: null, - } + return items.map((item) => ({ + type: item.type, + event: item.event, + signature: item.signature, + address, + chainID, + abi: item.abi, + id: item.id, + source: item.source || 'unknown', + status: item.status as 'success' | 'invalid' | 'not-found', + timestamp: item.timestamp, + })) } // Build single lookup map with prefixed keys @@ -94,7 +76,8 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient - const table = sql('_loop_decoder_contract_abi_v2') + const tableV3 = sql('_loop_decoder_contract_abi_v3') + const tableV2 = sql('_loop_decoder_contract_abi_v2') const id = sql.onDialectOrElse({ sqlite: () => sql`id INTEGER PRIMARY KEY AUTOINCREMENT,`, pg: () => sql`id SERIAL PRIMARY KEY,`, @@ -102,69 +85,104 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => orElse: () => sql``, }) - // TODO; add timestamp to the table - yield* sql` - CREATE TABLE IF NOT EXISTS ${table} ( - ${id} - type TEXT NOT NULL, - address TEXT, - event TEXT, - signature TEXT, - chain INTEGER, - abi TEXT, - status TEXT NOT NULL, - timestamp TEXT DEFAULT CURRENT_TIMESTAMP - ) - `.pipe( - Effect.tapError(Effect.logError), - Effect.catchAll(() => Effect.dieMessage('Failed to create contractAbi table')), - ) + // TODO: Allow skipping migrations if users want to apply it manually + // Run structured migrations (idempotent, transactional) + yield* runMigrations([ + migration('001_create_contract_abi_v3', (q) => + Effect.gen(function* () { + yield* q`CREATE TABLE IF NOT EXISTS ${tableV3} ( + ${id} + type TEXT NOT NULL, + address TEXT, + event TEXT, + signature TEXT, + chain INTEGER, + abi TEXT, + status TEXT NOT NULL, + timestamp TEXT DEFAULT CURRENT_TIMESTAMP, + source TEXT DEFAULT 'unknown' + )` + + const tsCoalesce = q.onDialectOrElse({ + sqlite: () => q`COALESCE(timestamp, CURRENT_TIMESTAMP)`, + pg: () => q`COALESCE(timestamp, CURRENT_TIMESTAMP)`, + mysql: () => q`IFNULL(timestamp, CURRENT_TIMESTAMP)`, + orElse: () => q`CURRENT_TIMESTAMP`, + }) + + yield* q` + INSERT INTO ${tableV3} (type, address, chain, abi, status, timestamp, source) + SELECT 'address' as type, v.address, v.chain, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source + FROM ${tableV2} as v + WHERE v.type = 'address' + AND v.address IS NOT NULL AND v.chain IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM ${tableV3} t + WHERE t.type = 'address' AND t.address = v.address AND t.chain = v.chain + ) + `.pipe(Effect.catchAll(Effect.logError)) + + yield* q` + INSERT INTO ${tableV3} (type, signature, abi, status, timestamp, source) + SELECT 'func' as type, v.signature, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source + FROM ${tableV2} as v + WHERE v.type = 'func' AND v.signature IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM ${tableV3} t + WHERE t.type = 'func' AND t.signature = v.signature + ) + `.pipe(Effect.catchAll(Effect.logError)) + + yield* q` + INSERT INTO ${tableV3} (type, event, abi, status, timestamp, source) + SELECT 'event' as type, v.event, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source + FROM ${tableV2} as v + WHERE v.type = 'event' AND v.event IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM ${tableV3} t + WHERE t.type = 'event' AND t.event = v.event + ) + `.pipe(Effect.catchAll(Effect.logError)) + }), + ), + ]) + + const table = tableV3 return yield* AbiStore.make({ strategies, - set: (key, value) => + set: (key, abi) => Effect.gen(function* () { const normalizedAddress = key.address.toLowerCase() - if (value.status === 'success' && value.result.type === 'address') { - const result = value.result - yield* sql` - INSERT INTO ${table} - ${sql.insert([ - { - type: result.type, - address: normalizedAddress, - chain: key.chainID, - abi: result.abi, - status: 'success', - }, - ])} - ` - } else if (value.status === 'success') { - const result = value.result + + if (abi.type === 'address') { yield* sql` - INSERT INTO ${table} - ${sql.insert([ - { - type: result.type, - event: 'event' in result ? result.event : null, - signature: 'signature' in result ? result.signature : null, - abi: result.abi, - status: 'success', - }, - ])} - ` + INSERT INTO ${table} + ${sql.insert([ + { + type: abi.type, + address: normalizedAddress, + chain: key.chainID, + abi: abi.abi, + status: abi.status, + source: abi.source || 'unknown', + }, + ])} + ` } else { yield* sql` - INSERT INTO ${table} - ${sql.insert([ - { - type: 'address', - address: normalizedAddress, - chain: key.chainID, - status: 'not-found', - }, - ])} - ` + INSERT INTO ${table} + ${sql.insert([ + { + type: abi.type, + event: 'event' in abi ? abi.event : null, + signature: 'signature' in abi ? abi.signature : null, + abi: abi.abi, + status: abi.status, + source: abi.source || 'unknown', + }, + ])} + ` } }).pipe( Effect.tapError(Effect.logError), @@ -226,6 +244,18 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => return createResult(keyItems, address, chainID) }) }), + + updateStatus: (id, status) => + Effect.gen(function* () { + yield* sql` + UPDATE ${table} + SET status = ${status} + WHERE id = ${id} + `.pipe( + Effect.tapError(Effect.logError), + Effect.catchAll(() => Effect.succeed(null)), + ) + }), }) }), ) diff --git a/packages/transaction-decoder/src/sql/migrations.ts b/packages/transaction-decoder/src/sql/migrations.ts new file mode 100644 index 00000000..8cedca1d --- /dev/null +++ b/packages/transaction-decoder/src/sql/migrations.ts @@ -0,0 +1,65 @@ +import { Effect } from 'effect' +import { SqlClient } from '@effect/sql' + +// _loop_decoder_migrations ( +// id TEXT PRIMARY KEY, -- usually a timestamped name like 001_init +// created_at TEXT NOT NULL -- iso timestamp when applied +// ) + +export type Migration = { + id: string + up: (sql: SqlClient.SqlClient) => Effect.Effect +} + +const MIGRATIONS_TABLE = '_loop_decoder_migrations' + +export const ensureMigrationsTable = (sql: SqlClient.SqlClient) => + Effect.gen(function* () { + const table = sql(MIGRATIONS_TABLE) + + // Create migrations table if it doesn't exist + yield* sql` + CREATE TABLE IF NOT EXISTS ${table} ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + ` + }) + +const getLatestAppliedMigration = (sql: SqlClient.SqlClient) => + Effect.gen(function* () { + try { + const rows = yield* sql`SELECT id FROM ${sql(MIGRATIONS_TABLE)} ORDER BY id DESC LIMIT 1` + const id = rows?.[0]?.id as string | undefined + return id ?? null + } catch { + return null + } + }) + +export const runMigrations = (migrations: readonly Migration[]) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + + yield* ensureMigrationsTable(sql) + + // compute latest applied (lexicographic order). Expect zero-padded ids like 001_... + const latest = yield* getLatestAppliedMigration(sql) + + // filter only migrations with id greater than latest + const pending = latest == null ? migrations : migrations.filter((m) => m.id > latest) + + for (const m of pending) { + // Run each migration in a transaction when supported + yield* sql.withTransaction( + Effect.gen(function* () { + const table = sql(MIGRATIONS_TABLE) + // record as applied + yield* sql`INSERT INTO ${table} (id) VALUES (${m.id})` + }), + ) + } + }) + +// Helpers to define migrations succinctly +export const migration = (id: string, up: Migration['up']): Migration => ({ id, up }) diff --git a/packages/transaction-interpreter/interpreters/moxie.ts b/packages/transaction-interpreter/interpreters/moxie.ts index 469c2007..1bcdeba4 100644 --- a/packages/transaction-interpreter/interpreters/moxie.ts +++ b/packages/transaction-interpreter/interpreters/moxie.ts @@ -48,8 +48,9 @@ export function transformEvent(event: DecodedTransaction): InterpretedTransactio return { ...newEvent, type: 'stake-token', - action: `Bought and Locked ${formatNumber(bougt[0].amount)} Fan Tokens of ${bougt[0].asset - ?.name} for ${displayAsset(sold[0])}`, + action: `Bought and Locked ${formatNumber(bougt[0].amount)} Fan Tokens of ${ + bougt[0].asset?.name + } for ${displayAsset(sold[0])}`, } } From 0a4393b3dbc20ad98df5feab071c682146d73095 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sat, 16 Aug 2025 21:07:48 +0200 Subject: [PATCH 02/16] Refactor abi loader to handle multiple abi in decoding --- .../transaction-decoder/src/abi-loader.ts | 155 ++++++++++++------ .../src/decoding/abi-decode.ts | 111 ++++++++++++- .../src/decoding/calldata-decode.ts | 10 +- .../src/decoding/log-decode.ts | 48 ++---- .../src/decoding/trace-decode.ts | 28 +--- .../src/in-memory/abi-store.ts | 19 ++- packages/transaction-decoder/src/vanilla.ts | 3 +- .../test/mocks/abi-loader-mock.ts | 83 +++++----- .../test/mocks/abi-to-signature.ts | 2 +- .../test/mocks/json-rpc-mock.ts | 2 +- .../test/transaction-decoder.test.ts | 2 +- .../transaction-decoder/test/vanilla.test.ts | 31 ++-- 12 files changed, 313 insertions(+), 181 deletions(-) diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index efa92bf6..82c25e0d 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -1,9 +1,10 @@ -import { Effect, Either, RequestResolver, Request, Array, pipe, Data, PrimaryKey, Schema, SchemaAST } from 'effect' +import { Effect, Either, RequestResolver, Request, Array, Data, PrimaryKey, Schema, SchemaAST } from 'effect' import { ContractABI } from './abi-strategy/request-model.js' import { Abi } from 'viem' import * as AbiStore from './abi-store.js' import * as StrategyExecutorModule from './abi-strategy/strategy-executor.js' import { SAFE_MULTISEND_SIGNATURE, SAFE_MULTISEND_ABI, AA_ABIS } from './decoding/constants.js' +import { errorFunctionSignatures, solidityError, solidityPanic } from './helpers/error.js' interface LoadParameters { readonly chainID: number @@ -34,7 +35,7 @@ export class EmptyCalldataError extends Data.TaggedError('DecodeError')< class SchemaAbi extends Schema.make(SchemaAST.objectKeyword) {} class AbiLoader extends Schema.TaggedRequest()('AbiLoader', { failure: Schema.instanceOf(MissingABIError), - success: SchemaAbi, // Abi + success: Schema.Array(Schema.Struct({ abi: SchemaAbi, id: Schema.optional(Schema.String) })), payload: { chainID: Schema.Number, address: Schema.String, @@ -78,20 +79,19 @@ const setValue = (key: AbiLoader, abi: ContractABI | null) => event: key.event, signature: key.signature, }, - abi == null ? { status: 'not-found', result: null } : { status: 'success', result: abi }, + abi == null + ? { + type: 'func' as const, + abi: '', + address: key.address, + chainID: key.chainID, + signature: key.signature || '', + status: 'not-found' as const, + } + : { ...abi, status: 'success' as const }, ) }) -const getBestMatch = (abi: ContractABI | null) => { - if (abi == null) return null - - if (abi.type === 'address') { - return JSON.parse(abi.abi) as Abi - } - - return JSON.parse(`[${abi.abi}]`) as Abi -} - /** * Data loader for contracts abi * @@ -146,27 +146,41 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A const requestGroups = Array.groupBy(requests, makeRequestKey) const uniqueRequests = Object.values(requestGroups).map((group) => group[0]) - const [remaining, cachedResults] = yield* pipe( - getMany(uniqueRequests), - Effect.map( - Array.partitionMap((resp, i) => { - return resp.status === 'empty' - ? Either.left(uniqueRequests[i]) - : Either.right([uniqueRequests[i], resp.result] as const) - }), - ), - Effect.orElseSucceed(() => [uniqueRequests, []] as const), - ) + const allCachedData = yield* getMany(uniqueRequests) + + // Create a map of invalid sources for each request + const invalidSourcesMap = new Map() + allCachedData.forEach((abis, i) => { + const request = uniqueRequests[i] + const invalid = abis + .filter((abi) => abi.status === 'invalid') + .map((abi) => abi.source) + .filter(Boolean) as string[] + + invalidSourcesMap.set(makeRequestKey(request), invalid) + }) + + const [remaining, cachedResults] = Array.partitionMap(allCachedData, (abis, i) => { + // Filter out invalid/not-found ABIs and check if we have any valid ones + const validAbis = abis.filter((abi) => abi.status === 'success') + return validAbis.length === 0 + ? Either.left(uniqueRequests[i]) + : Either.right([uniqueRequests[i], validAbis] as const) + }) // Resolve ABI from the store yield* Effect.forEach( cachedResults, - ([request, abi]) => { + ([request, abis]) => { const group = requestGroups[makeRequestKey(request)] - const bestMatch = getBestMatch(abi) - const result = bestMatch ? Effect.succeed(bestMatch) : Effect.fail(new MissingABIError(request)) - - return Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }) + const allMatches = abis.map((abi) => { + const parsedAbi = abi.type === 'address' ? (JSON.parse(abi.abi) as Abi) : (JSON.parse(`[${abi.abi}]`) as Abi) + return { abi: parsedAbi, id: abi.id } + }) + + return Effect.forEach(group, (req) => Request.completeEffect(req, Effect.succeed(allMatches)), { + discard: true, + }) }, { discard: true, @@ -191,9 +205,14 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A const response = yield* Effect.forEach( remaining, (req) => { - const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? []).filter( - (strategy) => strategy.type === 'address', - ) + const invalidSources = invalidSourcesMap.get(makeRequestKey(req)) ?? [] + const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? []) + .filter((strategy) => strategy.type === 'address') + .filter((strategy) => !invalidSources.includes(strategy.id)) + + if (allAvailableStrategies.length === 0) { + return Effect.succeed(Either.right(req)) + } return strategyExecutor .executeStrategiesSequentially(allAvailableStrategies, { @@ -220,17 +239,22 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A // NOTE: Secondly we request strategies to fetch fragments const fragmentStrategyResults = yield* Effect.forEach( notFound, - ({ chainID, address, event, signature }) => { - const allAvailableStrategies = Array.prependAll(strategies.default, strategies[chainID] ?? []).filter( - (strategy) => strategy.type === 'fragment', - ) + (req) => { + const invalidSources = invalidSourcesMap.get(makeRequestKey(req)) ?? [] + const allAvailableStrategies = Array.prependAll(strategies.default, strategies[req.chainID] ?? []) + .filter((strategy) => strategy.type === 'fragment') + .filter((strategy) => !invalidSources.includes(strategy.id)) + + if (allAvailableStrategies.length === 0) { + return Effect.succeed(null) + } return strategyExecutor .executeStrategiesSequentially(allAvailableStrategies, { - address, - chainId: chainID, - event, - signature, + address: req.address, + chainId: req.chainID, + event: req.event, + signature: req.signature, strategyId: 'fragment-batch', }) .pipe( @@ -244,20 +268,44 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A }, ) - const strategyResults = Array.appendAll(addressStrategyResults, fragmentStrategyResults) + // Create a map to track which requests got results from address strategies + const addressResultsMap = new Map() + addressStrategyResults.forEach((abis, i) => { + const request = remaining[i] + if (abis && abis.length > 0) { + addressResultsMap.set(makeRequestKey(request), abis) + } + }) + + // Create a map to track which requests got results from fragment strategies + const fragmentResultsMap = new Map() + fragmentStrategyResults.forEach((abis, i) => { + const request = notFound[i] + if (abis && abis.length > 0) { + fragmentResultsMap.set(makeRequestKey(request), abis) + } + }) - // Store results and resolve pending requests + // Resolve all remaining requests yield* Effect.forEach( - strategyResults, - (abis, i) => { - const request = remaining[i] - const abi = abis?.[0] ?? null - const bestMatch = getBestMatch(abi) - const result = bestMatch ? Effect.succeed(bestMatch) : Effect.fail(new MissingABIError(request)) + remaining, + (request) => { const group = requestGroups[makeRequestKey(request)] + const addressAbis = addressResultsMap.get(makeRequestKey(request)) || [] + const fragmentAbis = fragmentResultsMap.get(makeRequestKey(request)) || [] + const allAbis = [...addressAbis, ...fragmentAbis] + const firstAbi = allAbis[0] || null + + const allMatches = allAbis.map((abi) => { + const parsedAbi = abi.type === 'address' ? (JSON.parse(abi.abi) as Abi) : (JSON.parse(`[${abi.abi}]`) as Abi) + // TODO: We should figure out how to handle the db ID here, maybe we need to start providing the ids before inserting + return { abi: parsedAbi, id: undefined } + }) + + const result = allMatches.length > 0 ? Effect.succeed(allMatches) : Effect.fail(new MissingABIError(request)) return Effect.zipRight( - setValue(request, abi), + setValue(request, firstAbi), Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }), ) }, @@ -283,12 +331,17 @@ export const getAndCacheAbi = (params: AbiStore.AbiParams) => return yield* Effect.fail(new EmptyCalldataError(params)) } + if (params.signature && errorFunctionSignatures.includes(params.signature)) { + const errorAbis: Abi = [...solidityPanic, ...solidityError] + return [{ abi: errorAbis, id: undefined }] + } + if (params.signature && params.signature === SAFE_MULTISEND_SIGNATURE) { - return yield* Effect.succeed(SAFE_MULTISEND_ABI) + return [{ abi: SAFE_MULTISEND_ABI, id: undefined }] } if (params.signature && AA_ABIS[params.signature]) { - return yield* Effect.succeed(AA_ABIS[params.signature]) + return [{ abi: AA_ABIS[params.signature], id: undefined }] } return yield* Effect.request(new AbiLoader(params), AbiLoaderRequestResolver) diff --git a/packages/transaction-decoder/src/decoding/abi-decode.ts b/packages/transaction-decoder/src/decoding/abi-decode.ts index 781ff20f..a1e37f6a 100644 --- a/packages/transaction-decoder/src/decoding/abi-decode.ts +++ b/packages/transaction-decoder/src/decoding/abi-decode.ts @@ -1,8 +1,18 @@ import { formatAbiItem } from 'viem/utils' import type { DecodeResult, MostTypes, TreeNode } from '../types.js' -import { Hex, Abi, decodeFunctionData, AbiParameter, AbiFunction, getAbiItem } from 'viem' +import { + Hex, + Abi, + decodeFunctionData, + decodeEventLog as viemDecodeEventLog, + AbiParameter, + AbiFunction, + getAbiItem, +} from 'viem' import { Data, Effect } from 'effect' import { messageFromUnknown } from '../helpers/error.js' +import * as AbiStore from '../abi-store.js' +import { getAndCacheAbi } from '../abi-loader.js' export class DecodeError extends Data.TaggedError('DecodeError')<{ message: string }> { constructor(message: string, error?: unknown) { @@ -93,3 +103,102 @@ export const decodeMethod = (data: Hex, abi: Abi): Effect.Effect => + Effect.gen(function* () { + const { updateStatus } = yield* AbiStore.AbiStore + + // TODO: When abi is returned from external source, it does not have ids. + // We could do a new db selct, change API of Store to return ids, or provide + // ids instead of DB auto-generated ids. + // Now it will fail only on the second call + const abiWithIds = yield* getAndCacheAbi(params) + + // Create validation effects for store ABIs + const storeValidationEffects = abiWithIds.map(({ abi, id }) => + Effect.gen(function* () { + const result = yield* decodeMethod(data, abi) + + if (result == null) { + return yield* Effect.fail(new DecodeError(`ABI ${abi} failed to decode`)) + } + + return result + }).pipe( + Effect.catchAll((error: DecodeError) => { + return Effect.gen(function* () { + if (updateStatus && id != null) { + // Mark this ABI as invalid when it fails + yield* updateStatus(id, 'invalid').pipe(Effect.catchAll(() => Effect.void)) + } + return yield* Effect.fail(error) + }) + }), + ), + ) + + return yield* Effect.firstSuccessOf(storeValidationEffects) + }) + +/** + * Validates and decodes event logs using multiple ABIs with fallback support. + * Tries each ABI in sequence and returns the first successful decode result along with the ABI used. + * Similar to validateAndDecodeWithABIs but specifically for event logs. + */ +export const validateAndDecodeEventWithABIs = ( + topics: readonly Hex[], + data: Hex, + params: AbiStore.AbiParams, +): Effect.Effect<{ eventName: string; args: any; abiItem: Abi }, DecodeError, AbiStore.AbiStore> => + Effect.gen(function* () { + const abiWithIds = yield* getAndCacheAbi(params) + + const validationEffects = abiWithIds.map(({ abi, id }) => + Effect.gen(function* () { + const result = yield* decodeEventLog(topics, data, abi as Abi) + + if (result == null) { + return yield* Effect.fail(new DecodeError(`ABI failed to decode event`)) + } + + return { ...result, abiItem: abi as Abi } + }).pipe( + Effect.catchAll((error: DecodeError) => { + return Effect.gen(function* () { + // Note: We don't mark ABIs as invalid for event decoding failures + // as the same ABI might work for other events on the same contract + return yield* Effect.fail(error) + }) + }), + ), + ) + + return yield* Effect.firstSuccessOf(validationEffects) + }) + +export const decodeEventLog = ( + topics: readonly Hex[], + data: Hex, + abi: Abi, +): Effect.Effect<{ eventName: string; args: any } | undefined, DecodeError> => + Effect.gen(function* () { + const { eventName, args = {} } = yield* Effect.try({ + try: () => + viemDecodeEventLog({ abi, topics: topics as [] | [`0x${string}`, ...`0x${string}`[]], data, strict: false }), + catch: (error) => new DecodeError(`Could not decode event log`, error), + }) + + if (eventName == null) { + return undefined + } + + return { eventName, args } + }) diff --git a/packages/transaction-decoder/src/decoding/calldata-decode.ts b/packages/transaction-decoder/src/decoding/calldata-decode.ts index 1829d9c6..2e74ad76 100644 --- a/packages/transaction-decoder/src/decoding/calldata-decode.ts +++ b/packages/transaction-decoder/src/decoding/calldata-decode.ts @@ -1,7 +1,8 @@ import { Effect } from 'effect' import { Hex, Address, encodeFunctionData, isAddress, getAddress } from 'viem' -import { getAndCacheAbi, MissingABIError } from '../abi-loader.js' +import { MissingABIError } from '../abi-loader.js' import * as AbiDecoder from './abi-decode.js' +import { validateAndDecodeWithABIs } from './abi-decode.js' import { TreeNode } from '../types.js' import { PublicClient, RPCFetchError, UnknownNetwork } from '../public-client.js' import { SAFE_MULTISEND_SIGNATURE, SAFE_MULTISEND_NESTED_ABI } from './constants.js' @@ -166,16 +167,11 @@ export const decodeMethod = ({ } } - const abi = yield* getAndCacheAbi({ + const decoded = yield* validateAndDecodeWithABIs(data, { address: implementationAddress ?? contractAddress, signature, chainID, }) - const decoded = yield* AbiDecoder.decodeMethod(data, abi) - - if (decoded == null) { - return yield* new AbiDecoder.DecodeError(`Failed to decode method: ${data}`) - } //MULTISEND: decode nested params for the multisend of the safe smart account if ( diff --git a/packages/transaction-decoder/src/decoding/log-decode.ts b/packages/transaction-decoder/src/decoding/log-decode.ts index 3dd67700..4e57987e 100644 --- a/packages/transaction-decoder/src/decoding/log-decode.ts +++ b/packages/transaction-decoder/src/decoding/log-decode.ts @@ -1,10 +1,11 @@ -import { type GetTransactionReturnType, type Log, decodeEventLog, getAbiItem, getAddress } from 'viem' +import { type GetTransactionReturnType, type Log, getAbiItem, getAddress } from 'viem' import { Effect } from 'effect' import type { DecodedLogEvent, Interaction, RawDecodedLog } from '../types.js' import { getProxyImplementation } from './proxies.js' -import { getAndCacheAbi } from '../abi-loader.js' + import { getAndCacheContractMeta } from '../contract-meta-loader.js' import * as AbiDecoder from './abi-decode.js' + import { stringify } from '../helpers/stringify.js' import { formatAbiItem } from 'viem/utils' @@ -25,38 +26,21 @@ const decodedLog = (transaction: GetTransactionReturnType, logItem: Log) => const implementation = yield* getProxyImplementation({ address, chainID }) const abiAddress = implementation?.address ?? address - const [abiItem, contractData] = yield* Effect.all( - [ - getAndCacheAbi({ - address: abiAddress, - event: logItem.topics[0], - chainID, - }), - getAndCacheContractMeta({ - address, - chainID: Number(transaction.chainId), - }), - ], - { - concurrency: 'unbounded', - batching: true, - }, - ) - - const { eventName, args: args_ } = yield* Effect.try({ - try: () => - decodeEventLog({ - abi: abiItem, - topics: logItem.topics, - data: logItem.data, - strict: false, - }), - catch: (err) => new AbiDecoder.DecodeError(`Could not decode log ${abiAddress}`, err), + const contractData = yield* getAndCacheContractMeta({ + address, + chainID: Number(transaction.chainId), }) - if (eventName == null) { - return yield* new AbiDecoder.DecodeError(`Could not decode log ${abiAddress}`) - } + // Try to decode with all available ABIs for this event + const { + eventName, + args: args_, + abiItem, + } = yield* AbiDecoder.validateAndDecodeEventWithABIs(logItem.topics, logItem.data, { + address: abiAddress, + event: logItem.topics[0], + chainID, + }) const args = args_ as any diff --git a/packages/transaction-decoder/src/decoding/trace-decode.ts b/packages/transaction-decoder/src/decoding/trace-decode.ts index 906cab47..16599818 100644 --- a/packages/transaction-decoder/src/decoding/trace-decode.ts +++ b/packages/transaction-decoder/src/decoding/trace-decode.ts @@ -2,10 +2,12 @@ import { Effect } from 'effect' import type { DecodeTraceResult, Interaction, InteractionEvent } from '../types.js' import type { CallTraceLog, TraceLog } from '../schema/trace.js' import { DecodeError, decodeMethod } from './abi-decode.js' -import { getAndCacheAbi } from '../abi-loader.js' + +import { validateAndDecodeWithABIs } from './abi-decode.js' import { type Hex, type GetTransactionReturnType, Abi, getAddress } from 'viem' import { stringify } from '../helpers/stringify.js' import { errorFunctionSignatures, panicReasons, solidityError, solidityPanic } from '../helpers/error.js' +import { e } from 'vitest/dist/types-63abf2e0.js' //because some transactions are multicalls, we need to get the second level calls //to decode the actual method calls @@ -29,14 +31,12 @@ const decodeTraceLog = (call: TraceLog, transaction: GetTransactionReturnType) = const signature = call.action.input.slice(0, 10) const contractAddress = to - const abi = yield* getAndCacheAbi({ + const method = yield* validateAndDecodeWithABIs(input as Hex, { address: contractAddress, signature, chainID, }) - const method = yield* decodeMethod(input as Hex, abi) - return { ...method, from, @@ -53,21 +53,11 @@ const decodeTraceLogOutput = (call: TraceLog, chainID: number) => const data = call.result.output as Hex const signature = data.slice(0, 10) - //standart error functions - let abi: Abi = [...solidityPanic, ...solidityError] - - //custom error function - if (!errorFunctionSignatures.includes(signature)) { - const abi_ = yield* getAndCacheAbi({ - address: '', - signature, - chainID, - }) - - abi = [...abi, ...abi_] - } - - return yield* decodeMethod(data as Hex, abi) + return yield* validateAndDecodeWithABIs(data as Hex, { + address: '', + signature, + chainID, + }) } }) diff --git a/packages/transaction-decoder/src/in-memory/abi-store.ts b/packages/transaction-decoder/src/in-memory/abi-store.ts index 40ab44ba..d6ab7c93 100644 --- a/packages/transaction-decoder/src/in-memory/abi-store.ts +++ b/packages/transaction-decoder/src/in-memory/abi-store.ts @@ -1,6 +1,7 @@ import { Effect, Layer } from 'effect' import * as AbiStore from '../abi-store.js' import { ContractABI } from '../abi-strategy/request-model.js' +import { CachedContractABI } from '../abi-store.js' // Keyed by composite: kind|key|source to allow per-strategy replacement const abiCache = new Map() @@ -23,7 +24,7 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => }), get: (key) => Effect.sync(() => { - const results: ContractABI[] = [] + const results: CachedContractABI[] = [] // If a specific strategy is requested via mark on keys in future, we return union of all strategies for that key const prefixAddr = `addr|${key.address}|`.toLowerCase() @@ -36,7 +37,15 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => (prefixSig && k.startsWith(prefixSig)) || (prefixEvt && k.startsWith(prefixEvt)) ) { - results.push(v) + // Convert ContractABI to CachedContractABI + const cachedAbi: CachedContractABI = { + ...v, + id: k, // Use cache key as ID for in-memory storage + status: 'success', + source: k.split('|')[2] || undefined, + timestamp: undefined, + } + results.push(cachedAbi) } } @@ -45,9 +54,9 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => updateStatus: (id, status) => Effect.sync(() => { // For in-memory store, we need to find the ABI by ID and update its status - // Since we don't have ID-based lookup in memory, we'll iterate through cache - for (const [key, abi] of abiCache.entries()) { - if (abi.id === id) { + // Since we use cache key as ID, we can find it directly + for (const [key] of abiCache.entries()) { + if (key === id) { // Create a new ABI object with updated status // Note: For in-memory, we can't actually change the status of the result // since it's used in ContractAbiResult. This is a limitation of the in-memory approach. diff --git a/packages/transaction-decoder/src/vanilla.ts b/packages/transaction-decoder/src/vanilla.ts index c088516a..c619f099 100644 --- a/packages/transaction-decoder/src/vanilla.ts +++ b/packages/transaction-decoder/src/vanilla.ts @@ -4,6 +4,7 @@ import { decodeTransactionByHash, decodeCalldata } from './transaction-decoder.j import * as EffectAbiStore from './abi-store.js' import * as EffectContractMetaStore from './contract-meta-store.js' import type { ContractAbiResolverStrategy } from './abi-strategy/index.js' +import type { ContractABI } from './abi-strategy/request-model.js' import type { Hex } from 'viem' import type { ContractMetaResolverStrategy } from './meta-strategy/request-model.js' @@ -19,7 +20,7 @@ export interface TransactionDecoderOptions { export interface VanillaAbiStore { strategies?: readonly ContractAbiResolverStrategy[] get: (key: EffectAbiStore.AbiParams) => Promise - set: (key: EffectAbiStore.AbiParams, val: EffectAbiStore.ContractAbiResult) => Promise + set: (key: EffectAbiStore.AbiParams, val: ContractABI) => Promise } type VanillaContractMetaStategy = (client: PublicClient) => ContractMetaResolverStrategy diff --git a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts index 405b92b8..c13ff0af 100644 --- a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts +++ b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts @@ -1,6 +1,6 @@ /* eslint-disable turbo/no-undeclared-env-vars */ import { Effect, Match } from 'effect' -import fs from 'node:fs' +import * as fs from 'node:fs' import * as AbiStore from '../../src/abi-store.js' import { FourByteStrategyResolver } from '../../src/index.js' import { EtherscanStrategyResolver } from '../../src/abi-strategy/index.js' @@ -20,18 +20,18 @@ export const MockedAbiStoreLive = AbiStore.layer({ Effect.gen(function* () { if (response.status !== 'success') return - const { key, value } = Match.value(response.result).pipe( - Match.when({ type: 'address' } as const, (value) => ({ - key: value.address.toLowerCase(), - value: value.abi, + const { key, value } = Match.value(response).pipe( + Match.when({ type: 'address' }, (response) => ({ + key: response.address.toLowerCase(), + value: response.abi, })), - Match.when({ type: 'func' } as const, (value) => ({ - key: value.signature.toLowerCase(), - value: value.abi, + Match.when({ type: 'func' }, (response) => ({ + key: response.signature.toLowerCase(), + value: response.abi, })), - Match.when({ type: 'event' } as const, (value) => ({ - key: value.event.toLowerCase(), - value: value.abi, + Match.when({ type: 'event' }, (response) => ({ + key: response.event.toLowerCase(), + value: response.abi, })), Match.exhaustive, ) @@ -40,22 +40,22 @@ export const MockedAbiStoreLive = AbiStore.layer({ }), get: ({ address, signature, event }) => Effect.gen(function* () { + const results: AbiStore.ContractAbiResult = [] + const addressExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${address.toLowerCase()}.json`)) if (addressExists) { - const abi = yield* Effect.sync( - () => fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), + const abi = yield* Effect.sync(() => + fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), ) - return { + results.push({ + type: 'address', + abi, + address, + chainID: 1, status: 'success', - result: { - type: 'address', - abi, - address, - chainID: 1, - }, - } + }) } const sig = signature ?? event @@ -64,39 +64,32 @@ export const MockedAbiStoreLive = AbiStore.layer({ const signatureExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)) if (signatureExists) { - const signatureAbi = yield* Effect.sync( - () => fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), + const signatureAbi = yield* Effect.sync(() => + fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), ) if (signature) { - return { + results.push({ + type: 'func', + abi: signatureAbi, + address, + chainID: 1, + signature, status: 'success', - result: { - type: 'func', - abi: signatureAbi, - address, - chainID: 1, - signature, - }, - } + }) } else if (event) { - return { + results.push({ + type: 'event', + abi: signatureAbi, + address, + chainID: 1, + event, status: 'success', - result: { - type: 'event', - abi: signatureAbi, - address, - chainID: 1, - event, - }, - } + }) } } } - return { - status: 'empty', - result: null, - } + return results }), }) diff --git a/packages/transaction-decoder/test/mocks/abi-to-signature.ts b/packages/transaction-decoder/test/mocks/abi-to-signature.ts index 59400f1c..d91755f4 100644 --- a/packages/transaction-decoder/test/mocks/abi-to-signature.ts +++ b/packages/transaction-decoder/test/mocks/abi-to-signature.ts @@ -1,4 +1,4 @@ -import fs from 'fs' +import * as fs from 'fs' import { convertAbiToFragments } from '../../src/helpers/abi.js' export async function main(abiPath: string) { diff --git a/packages/transaction-decoder/test/mocks/json-rpc-mock.ts b/packages/transaction-decoder/test/mocks/json-rpc-mock.ts index 4db409cd..5e93001a 100644 --- a/packages/transaction-decoder/test/mocks/json-rpc-mock.ts +++ b/packages/transaction-decoder/test/mocks/json-rpc-mock.ts @@ -1,4 +1,4 @@ -import fs from 'node:fs' +import * as fs from 'node:fs' import { PublicClient, PublicClientObject, UnknownNetwork } from '../../src/index.js' import { Effect } from 'effect' import { createPublicClient, custom } from 'viem' diff --git a/packages/transaction-decoder/test/transaction-decoder.test.ts b/packages/transaction-decoder/test/transaction-decoder.test.ts index bb5ea561..9c76673a 100644 --- a/packages/transaction-decoder/test/transaction-decoder.test.ts +++ b/packages/transaction-decoder/test/transaction-decoder.test.ts @@ -6,7 +6,7 @@ import { MockedRPCProvider, MockedTransaction } from './mocks/json-rpc-mock.js' import { CALLDATA_TRANSACTIONS, FAILED_TRANSACTIONS, TEST_TRANSACTIONS } from './constants.js' import { MockedAbiStoreLive } from './mocks/abi-loader-mock.js' import { MockedMetaStoreLive } from './mocks/meta-loader-mock.js' -import fs from 'fs' +import * as fs from 'fs' describe('Transaction Decoder', () => { test.each(TEST_TRANSACTIONS)('Resolve and decode transaction %', async ({ hash, chainID }) => { diff --git a/packages/transaction-decoder/test/vanilla.test.ts b/packages/transaction-decoder/test/vanilla.test.ts index dc6925ef..42f7fb1d 100644 --- a/packages/transaction-decoder/test/vanilla.test.ts +++ b/packages/transaction-decoder/test/vanilla.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest' import { MockedTransaction, mockedTransport } from './mocks/json-rpc-mock.js' import { TransactionDecoder } from '../src/vanilla.js' -import fs from 'fs' +import * as fs from 'fs' import { createPublicClient } from 'viem' import { goerli } from 'viem/chains' import { ERC20RPCStrategyResolver } from '../src/index.js' @@ -26,15 +26,15 @@ describe('Transaction Decoder', () => { const addressExists = fs.existsSync(`./test/mocks/abi/${address.toLowerCase()}.json`) if (addressExists) { - return { - status: 'success', - result: { + return [ + { type: 'address', abi: fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), address, chainID: 5, + status: 'success', }, - } + ] } const sig = signature ?? event @@ -42,34 +42,31 @@ describe('Transaction Decoder', () => { const signatureAbi = fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString() if (signature) { - return { - status: 'success', - result: { + return [ + { type: 'func', abi: signatureAbi, address, chainID: 1, signature, + status: 'success', }, - } + ] } else if (event) { - return { - status: 'success', - result: { + return [ + { type: 'event', abi: signatureAbi, address, chainID: 1, event, + status: 'success', }, - } + ] } } - return { - status: 'empty', - result: null, - } + return [] }, set: async () => { console.debug('Not implemented') From 50b1a4e2a01f5b50266d12165a93681a307a3344 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 17 Aug 2025 10:04:59 +0200 Subject: [PATCH 03/16] Update documentation --- .../components/effect-abi-store-interface.md | 17 ++++++++++++++++- .../components/vanilla-abi-store-interface.md | 2 +- .../src/content/docs/reference/data-loaders.mdx | 7 ++++--- .../src/content/docs/reference/data-store.mdx | 2 +- apps/docs/src/content/docs/welcome/overview.md | 2 +- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/docs/src/content/components/effect-abi-store-interface.md b/apps/docs/src/content/components/effect-abi-store-interface.md index 6b4cc9ef..d2df9b4c 100644 --- a/apps/docs/src/content/components/effect-abi-store-interface.md +++ b/apps/docs/src/content/components/effect-abi-store-interface.md @@ -1,8 +1,23 @@ ```ts export interface AbiStore { + /** + * Set of resolver strategies grouped by chain id and a `default` bucket. + */ readonly strategies: Record - readonly set: (key: AbiParams, value: ContractAbiResult) => Effect.Effect + /** + * Persist a resolved ABI or a terminal state for a lookup key. + */ + readonly set: (key: AbiParams, value: CacheContractABIParam) => Effect.Effect + /** + * Fetch all cached ABIs for the lookup key. Implementations may return multiple entries. + */ readonly get: (arg: AbiParams) => Effect.Effect + /** Optional batched variant of `get` for performance. */ readonly getMany?: (arg: Array) => Effect.Effect, never> + /** Optional helper for marking an existing cached ABI as invalid or changing its status. */ + readonly updateStatus?: ( + id: string | number, + status: 'success' | 'invalid' | 'not-found', + ) => Effect.Effect } ``` diff --git a/apps/docs/src/content/components/vanilla-abi-store-interface.md b/apps/docs/src/content/components/vanilla-abi-store-interface.md index 7af32b9e..e0e67031 100644 --- a/apps/docs/src/content/components/vanilla-abi-store-interface.md +++ b/apps/docs/src/content/components/vanilla-abi-store-interface.md @@ -2,6 +2,6 @@ export interface VanillaAbiStore { strategies?: readonly ContractAbiResolverStrategy[] get: (key: AbiParams) => Promise - set: (key: AbiParams, val: ContractAbiResult) => Promise + set: (key: AbiParams, val: ContractABI) => Promise } ``` diff --git a/apps/docs/src/content/docs/reference/data-loaders.mdx b/apps/docs/src/content/docs/reference/data-loaders.mdx index da4742cb..080224e7 100644 --- a/apps/docs/src/content/docs/reference/data-loaders.mdx +++ b/apps/docs/src/content/docs/reference/data-loaders.mdx @@ -10,10 +10,11 @@ import { Tabs, TabItem } from '@astrojs/starlight/components' Data Loaders are mechanisms for retrieving the necessary ABI and Contract Metadata for transaction decoding. They are responsible for: - Resolving ABIs and Contract Metadata using specified strategies and third-party APIs -- Automatically batching requests when processing logs and traces in parallel -- Caching request results in the [Data Store](/reference/data-store) +- Automatically batching requests and deduplicating identical lookups during parallel log/trace processing +- Caching results and negative lookups in the [Data Store](/reference/data-store) +- Applying rate limits, circuit breaking, and adaptive concurrency via a shared request pool -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. +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. ## ABI Strategies diff --git a/apps/docs/src/content/docs/reference/data-store.mdx b/apps/docs/src/content/docs/reference/data-store.mdx index 3418feff..d2f17435 100644 --- a/apps/docs/src/content/docs/reference/data-store.mdx +++ b/apps/docs/src/content/docs/reference/data-store.mdx @@ -67,7 +67,7 @@ The full interface of ABI store is: -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. +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. ```typescript interface AbiParams { diff --git a/apps/docs/src/content/docs/welcome/overview.md b/apps/docs/src/content/docs/welcome/overview.md index b0bcf297..75849ffc 100644 --- a/apps/docs/src/content/docs/welcome/overview.md +++ b/apps/docs/src/content/docs/welcome/overview.md @@ -26,7 +26,7 @@ The Transaction Decoder package transforms raw transactions, including calldata, See the Decoded Transaction section in our [playground](https://loop-decoder-web.vercel.app/) for transaction examples. -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. +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. ### Transaction Interpreter From c26f9e9f25cd9ec500893947dee788c44ffb19c9 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 17 Aug 2025 10:12:45 +0200 Subject: [PATCH 04/16] Update changeset --- .changeset/old-signs-lay.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/old-signs-lay.md b/.changeset/old-signs-lay.md index 56e86024..5f2c035e 100644 --- a/.changeset/old-signs-lay.md +++ b/.changeset/old-signs-lay.md @@ -2,4 +2,4 @@ '@3loop/transaction-decoder': minor --- -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. +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. From e3651106accefec3a41a0a5b787ec910294de145 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 17 Aug 2025 10:16:08 +0200 Subject: [PATCH 05/16] Run formatting --- packages/transaction-decoder/src/decoding/trace-decode.ts | 8 +++----- .../transaction-decoder/test/mocks/abi-loader-mock.ts | 8 ++++---- packages/transaction-interpreter/interpreters/moxie.ts | 5 ++--- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/transaction-decoder/src/decoding/trace-decode.ts b/packages/transaction-decoder/src/decoding/trace-decode.ts index 16599818..f534ac92 100644 --- a/packages/transaction-decoder/src/decoding/trace-decode.ts +++ b/packages/transaction-decoder/src/decoding/trace-decode.ts @@ -1,13 +1,11 @@ import { Effect } from 'effect' import type { DecodeTraceResult, Interaction, InteractionEvent } from '../types.js' import type { CallTraceLog, TraceLog } from '../schema/trace.js' -import { DecodeError, decodeMethod } from './abi-decode.js' - +import { DecodeError } from './abi-decode.js' import { validateAndDecodeWithABIs } from './abi-decode.js' -import { type Hex, type GetTransactionReturnType, Abi, getAddress } from 'viem' +import { type Hex, type GetTransactionReturnType, getAddress } from 'viem' import { stringify } from '../helpers/stringify.js' -import { errorFunctionSignatures, panicReasons, solidityError, solidityPanic } from '../helpers/error.js' -import { e } from 'vitest/dist/types-63abf2e0.js' +import { panicReasons } from '../helpers/error.js' //because some transactions are multicalls, we need to get the second level calls //to decode the actual method calls diff --git a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts index c13ff0af..d633ac5e 100644 --- a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts +++ b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts @@ -45,8 +45,8 @@ export const MockedAbiStoreLive = AbiStore.layer({ const addressExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${address.toLowerCase()}.json`)) if (addressExists) { - const abi = yield* Effect.sync(() => - fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), + const abi = yield* Effect.sync( + () => fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), ) results.push({ @@ -64,8 +64,8 @@ export const MockedAbiStoreLive = AbiStore.layer({ const signatureExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)) if (signatureExists) { - const signatureAbi = yield* Effect.sync(() => - fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), + const signatureAbi = yield* Effect.sync( + () => fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), ) if (signature) { diff --git a/packages/transaction-interpreter/interpreters/moxie.ts b/packages/transaction-interpreter/interpreters/moxie.ts index 1bcdeba4..469c2007 100644 --- a/packages/transaction-interpreter/interpreters/moxie.ts +++ b/packages/transaction-interpreter/interpreters/moxie.ts @@ -48,9 +48,8 @@ export function transformEvent(event: DecodedTransaction): InterpretedTransactio return { ...newEvent, type: 'stake-token', - action: `Bought and Locked ${formatNumber(bougt[0].amount)} Fan Tokens of ${ - bougt[0].asset?.name - } for ${displayAsset(sold[0])}`, + action: `Bought and Locked ${formatNumber(bougt[0].amount)} Fan Tokens of ${bougt[0].asset + ?.name} for ${displayAsset(sold[0])}`, } } From 05f4d826f60f13fef445fc927c95d7fba1a17946 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 17 Aug 2025 10:58:03 +0200 Subject: [PATCH 06/16] Update migrations --- apps/web/src/lib/decode.ts | 2 +- .../transaction-decoder/src/sql/abi-store.ts | 158 +++++++++++------- .../transaction-decoder/src/sql/migrations.ts | 28 ++-- 3 files changed, 119 insertions(+), 69 deletions(-) diff --git a/apps/web/src/lib/decode.ts b/apps/web/src/lib/decode.ts index d62be905..22dd9ff9 100644 --- a/apps/web/src/lib/decode.ts +++ b/apps/web/src/lib/decode.ts @@ -85,7 +85,7 @@ export async function decodeTransaction({ return { decoded: result } } catch (error: unknown) { const endTime = performance.now() - const message = error instanceof Error ? JSON.parse(error.message) : 'Failed to decode transaction' + const message = error instanceof Error ? error.message : 'Failed to decode transaction' console.log(message) console.log(`Failed decode transaction took ${endTime - startTime}ms`) return { diff --git a/packages/transaction-decoder/src/sql/abi-store.ts b/packages/transaction-decoder/src/sql/abi-store.ts index 199a13f9..92d690ef 100644 --- a/packages/transaction-decoder/src/sql/abi-store.ts +++ b/packages/transaction-decoder/src/sql/abi-store.ts @@ -90,6 +90,7 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => yield* runMigrations([ migration('001_create_contract_abi_v3', (q) => Effect.gen(function* () { + // Create the v3 table first yield* q`CREATE TABLE IF NOT EXISTS ${tableV3} ( ${id} type TEXT NOT NULL, @@ -101,7 +102,36 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => status TEXT NOT NULL, timestamp TEXT DEFAULT CURRENT_TIMESTAMP, source TEXT DEFAULT 'unknown' - )` + )`.pipe(Effect.tapError((error) => Effect.logError(`Failed to create v3 table during migration: ${error}`))) + + // Check if v2 table exists before attempting migration + const v2TableExists = yield* q.onDialectOrElse({ + sqlite: () => q`SELECT name FROM sqlite_master WHERE type='table' AND name='_loop_decoder_contract_abi_v2'`, + pg: () => q`SELECT tablename FROM pg_tables WHERE tablename='_loop_decoder_contract_abi_v2'`, + mysql: () => q`SELECT table_name FROM information_schema.tables WHERE table_name='_loop_decoder_contract_abi_v2'`, + orElse: () => q`SELECT COUNT(*) as count FROM ${tableV2} WHERE 1=0`, // Try to query table directly + }).pipe( + Effect.map((rows) => rows.length > 0), + Effect.catchAll(() => Effect.succeed(false)), + ) + + if (!v2TableExists) { + yield* Effect.logInfo('No v2 table found, skipping data migration') + return + } + + // Check if there's any data to migrate + const v2Count = yield* q`SELECT COUNT(*) as count FROM ${tableV2}`.pipe( + Effect.map((rows) => rows[0]?.count || 0), + Effect.catchAll(() => Effect.succeed(0)), + ) + + if (v2Count === 0) { + yield* Effect.logInfo('v2 table is empty, skipping data migration') + return + } + + yield* Effect.logInfo(`Starting migration of ${v2Count} records from v2 to v3 table`) const tsCoalesce = q.onDialectOrElse({ sqlite: () => q`COALESCE(timestamp, CURRENT_TIMESTAMP)`, @@ -110,39 +140,42 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => orElse: () => q`CURRENT_TIMESTAMP`, }) + // Migrate data with improved error handling, preserving IDs yield* q` - INSERT INTO ${tableV3} (type, address, chain, abi, status, timestamp, source) - SELECT 'address' as type, v.address, v.chain, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source - FROM ${tableV2} as v - WHERE v.type = 'address' - AND v.address IS NOT NULL AND v.chain IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM ${tableV3} t - WHERE t.type = 'address' AND t.address = v.address AND t.chain = v.chain - ) - `.pipe(Effect.catchAll(Effect.logError)) - - yield* q` - INSERT INTO ${tableV3} (type, signature, abi, status, timestamp, source) - SELECT 'func' as type, v.signature, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source - FROM ${tableV2} as v - WHERE v.type = 'func' AND v.signature IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM ${tableV3} t - WHERE t.type = 'func' AND t.signature = v.signature - ) - `.pipe(Effect.catchAll(Effect.logError)) - - yield* q` - INSERT INTO ${tableV3} (type, event, abi, status, timestamp, source) - SELECT 'event' as type, v.event, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source + INSERT INTO ${tableV3} ( + id, + type, + address, + chain, + signature, + event, + abi, + status, + timestamp, + source + ) + SELECT + v.id, + v.type, + v.address, + v.chain, + v.signature, + v.event, + v.abi, + v.status, + ${tsCoalesce} as timestamp, + 'unknown' as source FROM ${tableV2} as v - WHERE v.type = 'event' AND v.event IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM ${tableV3} t - WHERE t.type = 'event' AND t.event = v.event - ) - `.pipe(Effect.catchAll(Effect.logError)) + WHERE NOT EXISTS ( + SELECT 1 FROM ${tableV3} t + WHERE t.id = v.id + ) + `.pipe( + Effect.tap(() => Effect.logInfo('Successfully migrated ABIs from v2 to v3 table with preserved IDs')), + Effect.tapError((error) => + Effect.logError(`Failed to migrate ABIs from v2 to v3 table: ${error}`) + ) + ) }), ), ]) @@ -156,36 +189,40 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => const normalizedAddress = key.address.toLowerCase() if (abi.type === 'address') { + const insertData = { + type: abi.type, + address: normalizedAddress, + chain: key.chainID, + abi: abi.abi, + status: abi.status, + source: abi.source || 'unknown', + } yield* sql` INSERT INTO ${table} - ${sql.insert([ - { - type: abi.type, - address: normalizedAddress, - chain: key.chainID, - abi: abi.abi, - status: abi.status, - source: abi.source || 'unknown', - }, - ])} + ${sql.insert([insertData])} ` } else { + const insertData = { + type: abi.type, + event: 'event' in abi ? abi.event : null, + signature: 'signature' in abi ? abi.signature : null, + abi: abi.abi, + status: abi.status, + source: abi.source || 'unknown', + } yield* sql` INSERT INTO ${table} - ${sql.insert([ - { - type: abi.type, - event: 'event' in abi ? abi.event : null, - signature: 'signature' in abi ? abi.signature : null, - abi: abi.abi, - status: abi.status, - source: abi.source || 'unknown', - }, - ])} + ${sql.insert([insertData])} ` } }).pipe( - Effect.tapError(Effect.logError), + Effect.tapError((error) => + Effect.logError( + `Failed to insert ABI into database for ${abi.type} key (address: ${key.address}, chainID: ${key.chainID + }). ABI status: ${abi.status}, ABI length: ${abi.abi?.length || 'null'}, source: ${abi.source || 'unknown' + }. Error: ${error}`, + ), + ), Effect.catchAll(() => { return Effect.succeed(null) }), @@ -196,7 +233,12 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => const query = buildQueryForKey(sql, { address, signature, event, chainID }) const items = yield* sql` SELECT * FROM ${table} WHERE ${query}`.pipe( - Effect.tapError(Effect.logError), + Effect.tapError((error) => + Effect.logError( + `Failed to query ABI from database for key (address: ${address}, signature: ${signature || 'none' + }, event: ${event || 'none'}, chainID: ${chainID}): ${error}`, + ), + ), Effect.catchAll(() => Effect.succeed([])), ) @@ -212,7 +254,9 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => const batchQuery = sql.or(conditions) const allItems = yield* sql`SELECT * FROM ${table} WHERE ${batchQuery}`.pipe( - Effect.tapError(Effect.logError), + Effect.tapError((error) => + Effect.logError(`Failed to query ABIs from database for batch of ${keys.length} keys: ${error}`), + ), Effect.catchAll(() => Effect.succeed([])), ) @@ -252,7 +296,9 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => SET status = ${status} WHERE id = ${id} `.pipe( - Effect.tapError(Effect.logError), + Effect.tapError((error) => + Effect.logError(`Failed to update ABI status in database for id ${id} to status '${status}': ${error}`), + ), Effect.catchAll(() => Effect.succeed(null)), ) }), diff --git a/packages/transaction-decoder/src/sql/migrations.ts b/packages/transaction-decoder/src/sql/migrations.ts index 8cedca1d..5d180815 100644 --- a/packages/transaction-decoder/src/sql/migrations.ts +++ b/packages/transaction-decoder/src/sql/migrations.ts @@ -44,22 +44,26 @@ export const runMigrations = (migrations: readonly Migration[]) => yield* ensureMigrationsTable(sql) // compute latest applied (lexicographic order). Expect zero-padded ids like 001_... - const latest = yield* getLatestAppliedMigration(sql) + const latest = yield* getLatestAppliedMigration(sql).pipe( + Effect.tapError((error) => + Effect.logError(`Migration failed: ${error}`) + ), + ) // filter only migrations with id greater than latest const pending = latest == null ? migrations : migrations.filter((m) => m.id > latest) - for (const m of pending) { - // Run each migration in a transaction when supported - yield* sql.withTransaction( - Effect.gen(function* () { - const table = sql(MIGRATIONS_TABLE) - // record as applied - yield* sql`INSERT INTO ${table} (id) VALUES (${m.id})` - }), - ) - } - }) + yield* Effect.forEach(pending, (m) => + Effect.gen(function* () { + // First run the migration + yield* m.up(sql) + // Only mark as applied if migration succeeded + const table = sql(MIGRATIONS_TABLE) + yield* sql`INSERT INTO ${table} (id) VALUES (${m.id})` + }), + { discard: true } + ) + }) // Helpers to define migrations succinctly export const migration = (id: string, up: Migration['up']): Migration => ({ id, up }) From 17f7f88b2655cca9c07aab03221de0bddb90f360 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 17 Aug 2025 17:41:59 +0200 Subject: [PATCH 07/16] Code formating --- .../transaction-decoder/src/sql/abi-store.ts | 35 +++++++++++-------- .../transaction-decoder/src/sql/migrations.ts | 28 +++++++-------- .../test/mocks/abi-loader-mock.ts | 11 +++--- .../transaction-decoder/test/vanilla.test.ts | 3 ++ packages/transaction-decoder/tsup.config.ts | 2 +- .../transaction-interpreter/tsup.config.ts | 2 +- 6 files changed, 46 insertions(+), 35 deletions(-) diff --git a/packages/transaction-decoder/src/sql/abi-store.ts b/packages/transaction-decoder/src/sql/abi-store.ts index 92d690ef..e89761fc 100644 --- a/packages/transaction-decoder/src/sql/abi-store.ts +++ b/packages/transaction-decoder/src/sql/abi-store.ts @@ -105,15 +105,19 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => )`.pipe(Effect.tapError((error) => Effect.logError(`Failed to create v3 table during migration: ${error}`))) // Check if v2 table exists before attempting migration - const v2TableExists = yield* q.onDialectOrElse({ - sqlite: () => q`SELECT name FROM sqlite_master WHERE type='table' AND name='_loop_decoder_contract_abi_v2'`, - pg: () => q`SELECT tablename FROM pg_tables WHERE tablename='_loop_decoder_contract_abi_v2'`, - mysql: () => q`SELECT table_name FROM information_schema.tables WHERE table_name='_loop_decoder_contract_abi_v2'`, - orElse: () => q`SELECT COUNT(*) as count FROM ${tableV2} WHERE 1=0`, // Try to query table directly - }).pipe( - Effect.map((rows) => rows.length > 0), - Effect.catchAll(() => Effect.succeed(false)), - ) + const v2TableExists = yield* q + .onDialectOrElse({ + sqlite: () => + q`SELECT name FROM sqlite_master WHERE type='table' AND name='_loop_decoder_contract_abi_v2'`, + pg: () => q`SELECT tablename FROM pg_tables WHERE tablename='_loop_decoder_contract_abi_v2'`, + mysql: () => + q`SELECT table_name FROM information_schema.tables WHERE table_name='_loop_decoder_contract_abi_v2'`, + orElse: () => q`SELECT COUNT(*) as count FROM ${tableV2} WHERE 1=0`, // Try to query table directly + }) + .pipe( + Effect.map((rows) => rows.length > 0), + Effect.catchAll(() => Effect.succeed(false)), + ) if (!v2TableExists) { yield* Effect.logInfo('No v2 table found, skipping data migration') @@ -172,9 +176,7 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => ) `.pipe( Effect.tap(() => Effect.logInfo('Successfully migrated ABIs from v2 to v3 table with preserved IDs')), - Effect.tapError((error) => - Effect.logError(`Failed to migrate ABIs from v2 to v3 table: ${error}`) - ) + Effect.tapError((error) => Effect.logError(`Failed to migrate ABIs from v2 to v3 table: ${error}`)), ) }), ), @@ -218,8 +220,10 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => }).pipe( Effect.tapError((error) => Effect.logError( - `Failed to insert ABI into database for ${abi.type} key (address: ${key.address}, chainID: ${key.chainID - }). ABI status: ${abi.status}, ABI length: ${abi.abi?.length || 'null'}, source: ${abi.source || 'unknown' + `Failed to insert ABI into database for ${abi.type} key (address: ${key.address}, chainID: ${ + key.chainID + }). ABI status: ${abi.status}, ABI length: ${abi.abi?.length || 'null'}, source: ${ + abi.source || 'unknown' }. Error: ${error}`, ), ), @@ -235,7 +239,8 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) => const items = yield* sql` SELECT * FROM ${table} WHERE ${query}`.pipe( Effect.tapError((error) => Effect.logError( - `Failed to query ABI from database for key (address: ${address}, signature: ${signature || 'none' + `Failed to query ABI from database for key (address: ${address}, signature: ${ + signature || 'none' }, event: ${event || 'none'}, chainID: ${chainID}): ${error}`, ), ), diff --git a/packages/transaction-decoder/src/sql/migrations.ts b/packages/transaction-decoder/src/sql/migrations.ts index 5d180815..99b81d47 100644 --- a/packages/transaction-decoder/src/sql/migrations.ts +++ b/packages/transaction-decoder/src/sql/migrations.ts @@ -1,5 +1,5 @@ import { Effect } from 'effect' -import { SqlClient } from '@effect/sql' +import { SqlClient, type SqlError } from '@effect/sql' // _loop_decoder_migrations ( // id TEXT PRIMARY KEY, -- usually a timestamped name like 001_init @@ -8,7 +8,7 @@ import { SqlClient } from '@effect/sql' export type Migration = { id: string - up: (sql: SqlClient.SqlClient) => Effect.Effect + up: (sql: SqlClient.SqlClient) => Effect.Effect } const MIGRATIONS_TABLE = '_loop_decoder_migrations' @@ -45,24 +45,24 @@ export const runMigrations = (migrations: readonly Migration[]) => // compute latest applied (lexicographic order). Expect zero-padded ids like 001_... const latest = yield* getLatestAppliedMigration(sql).pipe( - Effect.tapError((error) => - Effect.logError(`Migration failed: ${error}`) - ), + Effect.tapError((error) => Effect.logError(`Migration failed: ${error}`)), ) // filter only migrations with id greater than latest const pending = latest == null ? migrations : migrations.filter((m) => m.id > latest) - yield* Effect.forEach(pending, (m) => - Effect.gen(function* () { - // First run the migration - yield* m.up(sql) + yield* Effect.forEach( + pending, + (m) => + Effect.gen(function* () { + // First run the migration + yield* m.up(sql) - // Only mark as applied if migration succeeded - const table = sql(MIGRATIONS_TABLE) - yield* sql`INSERT INTO ${table} (id) VALUES (${m.id})` - }), - { discard: true } + // Only mark as applied if migration succeeded + const table = sql(MIGRATIONS_TABLE) + yield* sql`INSERT INTO ${table} (id) VALUES (${m.id})` + }), + { discard: true }, ) }) // Helpers to define migrations succinctly diff --git a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts index d633ac5e..f03bde04 100644 --- a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts +++ b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts @@ -45,11 +45,12 @@ export const MockedAbiStoreLive = AbiStore.layer({ const addressExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${address.toLowerCase()}.json`)) if (addressExists) { - const abi = yield* Effect.sync( - () => fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), + const abi = yield* Effect.sync(() => + fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), ) results.push({ + id: `${address.toLowerCase()}-address`, type: 'address', abi, address, @@ -64,12 +65,13 @@ export const MockedAbiStoreLive = AbiStore.layer({ const signatureExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)) if (signatureExists) { - const signatureAbi = yield* Effect.sync( - () => fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), + const signatureAbi = yield* Effect.sync(() => + fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), ) if (signature) { results.push({ + id: `${address.toLowerCase()}-${signature.toLowerCase()}-func`, type: 'func', abi: signatureAbi, address, @@ -79,6 +81,7 @@ export const MockedAbiStoreLive = AbiStore.layer({ }) } else if (event) { results.push({ + id: `${address.toLowerCase()}-${event.toLowerCase()}-event`, type: 'event', abi: signatureAbi, address, diff --git a/packages/transaction-decoder/test/vanilla.test.ts b/packages/transaction-decoder/test/vanilla.test.ts index 42f7fb1d..49f8d510 100644 --- a/packages/transaction-decoder/test/vanilla.test.ts +++ b/packages/transaction-decoder/test/vanilla.test.ts @@ -28,6 +28,7 @@ describe('Transaction Decoder', () => { if (addressExists) { return [ { + id: `${address.toLowerCase()}-address`, type: 'address', abi: fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), address, @@ -44,6 +45,7 @@ describe('Transaction Decoder', () => { if (signature) { return [ { + id: `${address.toLowerCase()}-${signature.toLowerCase()}-func`, type: 'func', abi: signatureAbi, address, @@ -55,6 +57,7 @@ describe('Transaction Decoder', () => { } else if (event) { return [ { + id: `${address.toLowerCase()}-${event.toLowerCase()}-event`, type: 'event', abi: signatureAbi, address, diff --git a/packages/transaction-decoder/tsup.config.ts b/packages/transaction-decoder/tsup.config.ts index 96f2fe2d..43f4f666 100644 --- a/packages/transaction-decoder/tsup.config.ts +++ b/packages/transaction-decoder/tsup.config.ts @@ -1,4 +1,4 @@ -import path from 'path' +import * as path from 'path' import { globSync } from 'glob' import { defineConfig } from 'tsup' diff --git a/packages/transaction-interpreter/tsup.config.ts b/packages/transaction-interpreter/tsup.config.ts index c20cb533..e2c94351 100644 --- a/packages/transaction-interpreter/tsup.config.ts +++ b/packages/transaction-interpreter/tsup.config.ts @@ -1,4 +1,4 @@ -import path from 'path' +import * as path from 'path' import { defineConfig } from 'tsup' export default defineConfig({ From 93753fe7289901b511d595618440a834d1c78cc0 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 17 Aug 2025 19:31:55 +0200 Subject: [PATCH 08/16] Strategy executor returns abi with strategy id --- .../transaction-decoder/src/abi-loader.ts | 25 ++++++++++----- .../src/abi-strategy/strategy-executor.ts | 31 ++++++++++++++----- .../src/decoding/log-decode.ts | 4 +-- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index 82c25e0d..c15ed1c1 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -69,7 +69,7 @@ const getMany = (requests: Array) => } }) -const setValue = (key: AbiLoader, abi: ContractABI | null) => +const setValue = (key: AbiLoader, abi: (ContractABI & { strategyId: string }) | null) => Effect.gen(function* () { const { set } = yield* AbiStore.AbiStore yield* set( @@ -88,7 +88,11 @@ const setValue = (key: AbiLoader, abi: ContractABI | null) => signature: key.signature || '', status: 'not-found' as const, } - : { ...abi, status: 'success' as const }, + : { + ...abi, + source: abi.strategyId, + status: 'success' as const, + }, ) }) @@ -218,7 +222,6 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A .executeStrategiesSequentially(allAvailableStrategies, { address: req.address, chainId: req.chainID, - strategyId: 'address-batch', }) .pipe( Effect.tapError(Effect.logDebug), @@ -255,7 +258,6 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A chainId: req.chainID, event: req.event, signature: req.signature, - strategyId: 'fragment-batch', }) .pipe( Effect.tapError(Effect.logDebug), @@ -269,7 +271,7 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A ) // Create a map to track which requests got results from address strategies - const addressResultsMap = new Map() + const addressResultsMap = new Map() addressStrategyResults.forEach((abis, i) => { const request = remaining[i] if (abis && abis.length > 0) { @@ -278,7 +280,7 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A }) // Create a map to track which requests got results from fragment strategies - const fragmentResultsMap = new Map() + const fragmentResultsMap = new Map() fragmentStrategyResults.forEach((abis, i) => { const request = notFound[i] if (abis && abis.length > 0) { @@ -294,7 +296,6 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A const addressAbis = addressResultsMap.get(makeRequestKey(request)) || [] const fragmentAbis = fragmentResultsMap.get(makeRequestKey(request)) || [] const allAbis = [...addressAbis, ...fragmentAbis] - const firstAbi = allAbis[0] || null const allMatches = allAbis.map((abi) => { const parsedAbi = abi.type === 'address' ? (JSON.parse(abi.abi) as Abi) : (JSON.parse(`[${abi.abi}]`) as Abi) @@ -304,8 +305,16 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A const result = allMatches.length > 0 ? Effect.succeed(allMatches) : Effect.fail(new MissingABIError(request)) + const cacheEffect = + allAbis.length > 0 + ? Effect.forEach(allAbis, (abi) => setValue(request, abi), { + discard: true, + concurrency: 'unbounded', + }) + : setValue(request, null) + return Effect.zipRight( - setValue(request, firstAbi), + cacheEffect, Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }), ) }, diff --git a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts index dc54abbb..c8bfe9e1 100644 --- a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts +++ b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts @@ -13,10 +13,17 @@ export class MissingHealthyStrategy extends Data.TaggedError('MissingHealthyStra } } +interface ExecuteParams { + chainId: number + address: string + event?: string + signature?: string +} + export const make = (circuitBreaker: CircuitBreaker, requestPool: RequestPool) => { - const executeStrategy = (strategy: ContractAbiResolverStrategy, params: GetContractABIStrategyParams) => { + const executeStrategy = (strategy: ContractAbiResolverStrategy, params: ExecuteParams) => { return pipe( - strategy.resolver(params), + strategy.resolver({ ...params, strategyId: strategy.id }), Effect.timeout(Duration.decode(Constants.STRATEGY_TIMEOUT)), Effect.catchTag('MissingABIStrategyError', (error) => { // Log error but don't fail the entire operation @@ -32,14 +39,20 @@ export const make = (circuitBreaker: CircuitBreaker, requestPool: RequestPool) = ), (effect) => circuitBreaker.withCircuitBreaker(strategy.id, effect), (effect) => requestPool.withPoolManagement(params.chainId, effect), - Effect.flatMap((data) => (data instanceof MissingABIStrategyError ? Effect.fail(data) : Effect.succeed(data))), + Effect.flatMap((data) => + data instanceof MissingABIStrategyError + ? Effect.fail(data) + : Effect.succeed( + data.map((abi) => ({ + ...abi, + strategyId: strategy.id, + })), + ), + ), ) } - const executeStrategiesSequentially = ( - strategies: ContractAbiResolverStrategy[], - params: GetContractABIStrategyParams, - ) => + const executeStrategiesSequentially = (strategies: ContractAbiResolverStrategy[], params: ExecuteParams) => Effect.gen(function* () { // Filter out unhealthy strategies first const healthyStrategies: ContractAbiResolverStrategy[] = [] @@ -62,6 +75,10 @@ export const make = (circuitBreaker: CircuitBreaker, requestPool: RequestPool) = ) } + if (params.address.toLowerCase() === '0x49828c61a923624e22ce5b169be2bd650abc9bc8') { + console.log('ABIS; ', params, healthyStrategies) // Debugging line + } + // Try strategies one by one until one succeeds return yield* Effect.validateFirst(healthyStrategies, (strategy) => executeStrategy(strategy, params)) }) diff --git a/packages/transaction-decoder/src/decoding/log-decode.ts b/packages/transaction-decoder/src/decoding/log-decode.ts index 4e57987e..2b8d4c3e 100644 --- a/packages/transaction-decoder/src/decoding/log-decode.ts +++ b/packages/transaction-decoder/src/decoding/log-decode.ts @@ -34,7 +34,7 @@ const decodedLog = (transaction: GetTransactionReturnType, logItem: Log) => // Try to decode with all available ABIs for this event const { eventName, - args: args_, + args, abiItem, } = yield* AbiDecoder.validateAndDecodeEventWithABIs(logItem.topics, logItem.data, { address: abiAddress, @@ -42,8 +42,6 @@ const decodedLog = (transaction: GetTransactionReturnType, logItem: Log) => chainID, }) - const args = args_ as any - const fragment = yield* Effect.try({ try: () => getAbiItem({ abi: abiItem, name: eventName }), catch: () => { From 2a6940bde7b3bd22b6345586e411dc252cee7df9 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Sun, 17 Aug 2025 19:42:57 +0200 Subject: [PATCH 09/16] Minor cleanup --- apps/web/src/lib/decode.ts | 7 +------ .../transaction-decoder/src/abi-loader.ts | 1 + .../src/decoding/abi-decode.ts | 8 ++++++-- .../src/decoding/log-decode.ts | 19 +++++++++---------- .../test/mocks/abi-loader-mock.ts | 8 ++++---- 5 files changed, 21 insertions(+), 22 deletions(-) diff --git a/apps/web/src/lib/decode.ts b/apps/web/src/lib/decode.ts index 22dd9ff9..c40079d3 100644 --- a/apps/web/src/lib/decode.ts +++ b/apps/web/src/lib/decode.ts @@ -19,12 +19,7 @@ import { SqlAbiStore, SqlContractMetaStore } from '@3loop/transaction-decoder/sq import { Hex } from 'viem' import { DatabaseLive } from './database' -const LogLevelLive = Layer.unwrapEffect( - Effect.gen(function* () { - const level = LogLevel.Warning - return Logger.minimumLogLevel(level) - }), -) +const LogLevelLive = Logger.minimumLogLevel(LogLevel.Warning) const AbiStoreLive = Layer.unwrapEffect( Effect.gen(function* () { diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index c15ed1c1..382f0e39 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -140,6 +140,7 @@ const setValue = (key: AbiLoader, abi: (ContractABI & { strategyId: string }) | * - Request pooling with back-pressure handling */ +// TODO: there is an overfetching, find why export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array) => Effect.gen(function* () { if (requests.length === 0) return diff --git a/packages/transaction-decoder/src/decoding/abi-decode.ts b/packages/transaction-decoder/src/decoding/abi-decode.ts index a1e37f6a..9c408758 100644 --- a/packages/transaction-decoder/src/decoding/abi-decode.ts +++ b/packages/transaction-decoder/src/decoding/abi-decode.ts @@ -159,6 +159,8 @@ export const validateAndDecodeEventWithABIs = ( params: AbiStore.AbiParams, ): Effect.Effect<{ eventName: string; args: any; abiItem: Abi }, DecodeError, AbiStore.AbiStore> => Effect.gen(function* () { + const { updateStatus } = yield* AbiStore.AbiStore + const abiWithIds = yield* getAndCacheAbi(params) const validationEffects = abiWithIds.map(({ abi, id }) => @@ -173,8 +175,10 @@ export const validateAndDecodeEventWithABIs = ( }).pipe( Effect.catchAll((error: DecodeError) => { return Effect.gen(function* () { - // Note: We don't mark ABIs as invalid for event decoding failures - // as the same ABI might work for other events on the same contract + if (updateStatus && id != null) { + // Mark this ABI as invalid when it fails + yield* updateStatus(id, 'invalid').pipe(Effect.catchAll(() => Effect.void)) + } return yield* Effect.fail(error) }) }), diff --git a/packages/transaction-decoder/src/decoding/log-decode.ts b/packages/transaction-decoder/src/decoding/log-decode.ts index 2b8d4c3e..70ad2497 100644 --- a/packages/transaction-decoder/src/decoding/log-decode.ts +++ b/packages/transaction-decoder/src/decoding/log-decode.ts @@ -31,16 +31,15 @@ const decodedLog = (transaction: GetTransactionReturnType, logItem: Log) => chainID: Number(transaction.chainId), }) - // Try to decode with all available ABIs for this event - const { - eventName, - args, - abiItem, - } = yield* AbiDecoder.validateAndDecodeEventWithABIs(logItem.topics, logItem.data, { - address: abiAddress, - event: logItem.topics[0], - chainID, - }) + const { eventName, args, abiItem } = yield* AbiDecoder.validateAndDecodeEventWithABIs( + logItem.topics, + logItem.data, + { + address: abiAddress, + event: logItem.topics[0], + chainID, + }, + ) const fragment = yield* Effect.try({ try: () => getAbiItem({ abi: abiItem, name: eventName }), diff --git a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts index f03bde04..5708b9e8 100644 --- a/packages/transaction-decoder/test/mocks/abi-loader-mock.ts +++ b/packages/transaction-decoder/test/mocks/abi-loader-mock.ts @@ -45,8 +45,8 @@ export const MockedAbiStoreLive = AbiStore.layer({ const addressExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${address.toLowerCase()}.json`)) if (addressExists) { - const abi = yield* Effect.sync(() => - fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), + const abi = yield* Effect.sync( + () => fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), ) results.push({ @@ -65,8 +65,8 @@ export const MockedAbiStoreLive = AbiStore.layer({ const signatureExists = yield* Effect.sync(() => fs.existsSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)) if (signatureExists) { - const signatureAbi = yield* Effect.sync(() => - fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), + const signatureAbi = yield* Effect.sync( + () => fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString(), ) if (signature) { From 5cd7d627d2009cb6895629a680b5b34abae4284a Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Tue, 19 Aug 2025 19:59:49 +0200 Subject: [PATCH 10/16] Add default ABIs as fallback --- .../transaction-decoder/src/abi-loader.ts | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index 382f0e39..5377783b 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -1,6 +1,6 @@ import { Effect, Either, RequestResolver, Request, Array, Data, PrimaryKey, Schema, SchemaAST } from 'effect' import { ContractABI } from './abi-strategy/request-model.js' -import { Abi } from 'viem' +import { Abi, erc20Abi, erc721Abi } from 'viem' import * as AbiStore from './abi-store.js' import * as StrategyExecutorModule from './abi-strategy/strategy-executor.js' import { SAFE_MULTISEND_SIGNATURE, SAFE_MULTISEND_ABI, AA_ABIS } from './decoding/constants.js' @@ -32,7 +32,7 @@ export class EmptyCalldataError extends Data.TaggedError('DecodeError')< } } -class SchemaAbi extends Schema.make(SchemaAST.objectKeyword) {} +class SchemaAbi extends Schema.make(SchemaAST.objectKeyword) { } class AbiLoader extends Schema.TaggedRequest()('AbiLoader', { failure: Schema.instanceOf(MissingABIError), success: Schema.Array(Schema.Struct({ abi: SchemaAbi, id: Schema.optional(Schema.String) })), @@ -81,18 +81,18 @@ const setValue = (key: AbiLoader, abi: (ContractABI & { strategyId: string }) | }, abi == null ? { - type: 'func' as const, - abi: '', - address: key.address, - chainID: key.chainID, - signature: key.signature || '', - status: 'not-found' as const, - } + type: 'func' as const, + abi: '', + address: key.address, + chainID: key.chainID, + signature: key.signature || '', + status: 'not-found' as const, + } : { - ...abi, - source: abi.strategyId, - status: 'success' as const, - }, + ...abi, + source: abi.strategyId, + status: 'success' as const, + }, ) }) @@ -309,9 +309,9 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A const cacheEffect = allAbis.length > 0 ? Effect.forEach(allAbis, (abi) => setValue(request, abi), { - discard: true, - concurrency: 'unbounded', - }) + discard: true, + concurrency: 'unbounded', + }) : setValue(request, null) return Effect.zipRight( @@ -335,6 +335,8 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A // in a missing Fragment. We treat this issue as a minor one for now, as we expect it to occur rarely on contracts that // are not verified and with a non standard events structure. +const errorAbis: Abi = [...solidityPanic, ...solidityError] + export const getAndCacheAbi = (params: AbiStore.AbiParams) => Effect.gen(function* () { if (params.event === '0x' || params.signature === '0x') { @@ -342,7 +344,6 @@ export const getAndCacheAbi = (params: AbiStore.AbiParams) => } if (params.signature && errorFunctionSignatures.includes(params.signature)) { - const errorAbis: Abi = [...solidityPanic, ...solidityError] return [{ abi: errorAbis, id: undefined }] } @@ -354,7 +355,11 @@ export const getAndCacheAbi = (params: AbiStore.AbiParams) => return [{ abi: AA_ABIS[params.signature], id: undefined }] } - return yield* Effect.request(new AbiLoader(params), AbiLoaderRequestResolver) + const abis = yield* Effect.request(new AbiLoader(params), AbiLoaderRequestResolver) + return Array.appendAll(abis, [ + { abi: erc20Abi, id: undefined }, + { abi: erc721Abi, id: undefined }, + ]) }).pipe( Effect.withSpan('AbiLoader.GetAndCacheAbi', { attributes: { From 66337f2596cfd6baebf66669e05a2ad4e94ca1d5 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Tue, 19 Aug 2025 20:18:56 +0200 Subject: [PATCH 11/16] Use strict decode to ensure params --- packages/transaction-decoder/src/decoding/abi-decode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-decoder/src/decoding/abi-decode.ts b/packages/transaction-decoder/src/decoding/abi-decode.ts index 9c408758..f4c046bb 100644 --- a/packages/transaction-decoder/src/decoding/abi-decode.ts +++ b/packages/transaction-decoder/src/decoding/abi-decode.ts @@ -196,7 +196,7 @@ export const decodeEventLog = ( Effect.gen(function* () { const { eventName, args = {} } = yield* Effect.try({ try: () => - viemDecodeEventLog({ abi, topics: topics as [] | [`0x${string}`, ...`0x${string}`[]], data, strict: false }), + viemDecodeEventLog({ abi, topics: topics as [] | [`0x${string}`, ...`0x${string}`[]], data, strict: true }), catch: (error) => new DecodeError(`Could not decode event log`, error), }) From d7b32844126abbc06bbd8eed38d0c8cac7af4c9b Mon Sep 17 00:00:00 2001 From: Anastasia Rodionova Date: Tue, 19 Aug 2025 20:21:11 +0200 Subject: [PATCH 12/16] Fix linter --- .../transaction-decoder/src/abi-strategy/strategy-executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts index c8bfe9e1..45d210b4 100644 --- a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts +++ b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts @@ -1,5 +1,5 @@ import { Effect, Schedule, Duration, pipe, Data } from 'effect' -import { GetContractABIStrategyParams, ContractAbiResolverStrategy, MissingABIStrategyError } from './request-model.js' +import { ContractAbiResolverStrategy, MissingABIStrategyError } from './request-model.js' import type { CircuitBreaker } from '../circuit-breaker/circuit-breaker.js' import { RequestPool } from '../circuit-breaker/request-pool.js' import * as Constants from '../circuit-breaker/constants.js' From f55cf717ba051af7fba6ea9ec7fb1565d9e1a91f Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Tue, 19 Aug 2025 20:37:51 +0200 Subject: [PATCH 13/16] Do not store null values --- packages/transaction-decoder/src/abi-loader.ts | 2 +- .../src/abi-strategy/strategy-executor.ts | 14 +++++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index 5377783b..285d9467 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -312,7 +312,7 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A discard: true, concurrency: 'unbounded', }) - : setValue(request, null) + : Effect.void return Effect.zipRight( cacheEffect, diff --git a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts index 45d210b4..7700e9d0 100644 --- a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts +++ b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts @@ -43,11 +43,11 @@ export const make = (circuitBreaker: CircuitBreaker, requestPool: RequestPool) = data instanceof MissingABIStrategyError ? Effect.fail(data) : Effect.succeed( - data.map((abi) => ({ - ...abi, - strategyId: strategy.id, - })), - ), + data.map((abi) => ({ + ...abi, + strategyId: strategy.id, + })), + ), ), ) } @@ -75,10 +75,6 @@ export const make = (circuitBreaker: CircuitBreaker, requestPool: RequestPool) = ) } - if (params.address.toLowerCase() === '0x49828c61a923624e22ce5b169be2bd650abc9bc8') { - console.log('ABIS; ', params, healthyStrategies) // Debugging line - } - // Try strategies one by one until one succeeds return yield* Effect.validateFirst(healthyStrategies, (strategy) => executeStrategy(strategy, params)) }) From 282380bd01a7c06d707f08ba0fa5bb7cc6443ce1 Mon Sep 17 00:00:00 2001 From: Gheorghe Pinzaru Date: Tue, 19 Aug 2025 20:45:42 +0200 Subject: [PATCH 14/16] Succeed with empty ABI list --- packages/transaction-decoder/src/abi-loader.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index 285d9467..d191aeaa 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -304,8 +304,6 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A return { abi: parsedAbi, id: undefined } }) - const result = allMatches.length > 0 ? Effect.succeed(allMatches) : Effect.fail(new MissingABIError(request)) - const cacheEffect = allAbis.length > 0 ? Effect.forEach(allAbis, (abi) => setValue(request, abi), { @@ -316,7 +314,7 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A return Effect.zipRight( cacheEffect, - Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }), + Effect.forEach(group, (req) => Request.completeEffect(req, Effect.succeed(allMatches)), { discard: true }), ) }, { From 37e0a78233069efa12e34c9d59ea6402759be98e Mon Sep 17 00:00:00 2001 From: Anastasia Rodionova Date: Thu, 21 Aug 2025 09:23:02 +0200 Subject: [PATCH 15/16] Fix formater --- .../transaction-decoder/src/abi-loader.ts | 30 +++++++++---------- .../src/abi-strategy/strategy-executor.ts | 10 +++---- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index d191aeaa..1d814d48 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -32,7 +32,7 @@ export class EmptyCalldataError extends Data.TaggedError('DecodeError')< } } -class SchemaAbi extends Schema.make(SchemaAST.objectKeyword) { } +class SchemaAbi extends Schema.make(SchemaAST.objectKeyword) {} class AbiLoader extends Schema.TaggedRequest()('AbiLoader', { failure: Schema.instanceOf(MissingABIError), success: Schema.Array(Schema.Struct({ abi: SchemaAbi, id: Schema.optional(Schema.String) })), @@ -81,18 +81,18 @@ const setValue = (key: AbiLoader, abi: (ContractABI & { strategyId: string }) | }, abi == null ? { - type: 'func' as const, - abi: '', - address: key.address, - chainID: key.chainID, - signature: key.signature || '', - status: 'not-found' as const, - } + type: 'func' as const, + abi: '', + address: key.address, + chainID: key.chainID, + signature: key.signature || '', + status: 'not-found' as const, + } : { - ...abi, - source: abi.strategyId, - status: 'success' as const, - }, + ...abi, + source: abi.strategyId, + status: 'success' as const, + }, ) }) @@ -307,9 +307,9 @@ export const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: A const cacheEffect = allAbis.length > 0 ? Effect.forEach(allAbis, (abi) => setValue(request, abi), { - discard: true, - concurrency: 'unbounded', - }) + discard: true, + concurrency: 'unbounded', + }) : Effect.void return Effect.zipRight( diff --git a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts index 7700e9d0..b439bc5b 100644 --- a/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts +++ b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts @@ -43,11 +43,11 @@ export const make = (circuitBreaker: CircuitBreaker, requestPool: RequestPool) = data instanceof MissingABIStrategyError ? Effect.fail(data) : Effect.succeed( - data.map((abi) => ({ - ...abi, - strategyId: strategy.id, - })), - ), + data.map((abi) => ({ + ...abi, + strategyId: strategy.id, + })), + ), ), ) } From cc791f84c90f9b507281c011d9780b4cd9ea075b Mon Sep 17 00:00:00 2001 From: Anastasia Rodionova Date: Thu, 21 Aug 2025 09:52:41 +0200 Subject: [PATCH 16/16] Regenrate tests --- ...94a1c23daf54efe80faff4bb612e410ba.snapshot | 50 ++++++++++++++++++- ...5fac222c130cf4a3e2c4d438d88fe2280.snapshot | 34 +++++++++++-- ...e7abcab112d6529b0c77ee43954202cab.snapshot | 45 +++++++++++++++-- ...545ea69e98e15a054bf4458258fd6d068.snapshot | 49 ++++++++++++++++-- 4 files changed, 164 insertions(+), 14 deletions(-) diff --git a/packages/transaction-decoder/test/snapshots/decoder/0x074e27d856aae900c2a16f8577baa4194a1c23daf54efe80faff4bb612e410ba.snapshot b/packages/transaction-decoder/test/snapshots/decoder/0x074e27d856aae900c2a16f8577baa4194a1c23daf54efe80faff4bb612e410ba.snapshot index e2fe822f..3c8d7469 100644 --- a/packages/transaction-decoder/test/snapshots/decoder/0x074e27d856aae900c2a16f8577baa4194a1c23daf54efe80faff4bb612e410ba.snapshot +++ b/packages/transaction-decoder/test/snapshots/decoder/0x074e27d856aae900c2a16f8577baa4194a1c23daf54efe80faff4bb612e410ba.snapshot @@ -18,6 +18,15 @@ "tokenSymbol": "ERC20", "type": "ERC20", }, + "0x867F9b5a43cF854494702d1709527Bc84d022510": { + "address": "0x867F9b5a43cF854494702d1709527Bc84d022510", + "chainID": 1, + "contractAddress": "0x867F9b5a43cF854494702d1709527Bc84d022510", + "contractName": "Mock ERC20 Contract", + "decimals": 18, + "tokenSymbol": "ERC20", + "type": "ERC20", + }, "0x98FB2B6C27A62113a0f89272EbBB6FEb957e966f": { "address": "0x98FB2B6C27A62113a0f89272EbBB6FEb957e966f", "chainID": 1, @@ -54,6 +63,15 @@ "tokenSymbol": "ERC20", "type": "ERC20", }, + "0xe4900A56fEB3D92B3D174C98C948e052b13d4c35": { + "address": "0xe4900A56fEB3D92B3D174C98C948e052b13d4c35", + "chainID": 1, + "contractAddress": "0xe4900A56fEB3D92B3D174C98C948e052b13d4c35", + "contractName": "Mock ERC20 Contract", + "decimals": 18, + "tokenSymbol": "ERC20", + "type": "ERC20", + }, }, "chainID": 1, "chainSymbol": "mainnet", @@ -69,8 +87,10 @@ "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "0x54CAe6EDCDec4c62AD5f7a8288447EAD0eb2995a", + "0xe4900A56fEB3D92B3D174C98C948e052b13d4c35", "0xdAC17F958D2ee523a2206206994597C13D831ec7", "0x98FB2B6C27A62113a0f89272EbBB6FEb957e966f", + "0x867F9b5a43cF854494702d1709527Bc84d022510", ], "interactions": [ { @@ -83,7 +103,11 @@ "event": { "eventName": "Transfer", "logIndex": 449, - "params": {}, + "params": { + "from": "0x54CAe6EDCDec4c62AD5f7a8288447EAD0eb2995a", + "to": "0xe4900A56fEB3D92B3D174C98C948e052b13d4c35", + "value": "150000000", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -149,7 +173,11 @@ "event": { "eventName": "Transfer", "logIndex": 453, - "params": {}, + "params": { + "from": "0x54CAe6EDCDec4c62AD5f7a8288447EAD0eb2995a", + "to": "0x867F9b5a43cF854494702d1709527Bc84d022510", + "value": "3793080000", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -517,6 +545,15 @@ "toAddress": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", "traceCalls": [], "transfers": [ + { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "0.00000000015", + "from": "0x54CAe6EDCDec4c62AD5f7a8288447EAD0eb2995a", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0xe4900A56fEB3D92B3D174C98C948e052b13d4c35", + "type": "ERC20", + }, { "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", "amount": "0.0000000003", @@ -526,6 +563,15 @@ "to": "0x98FB2B6C27A62113a0f89272EbBB6FEb957e966f", "type": "ERC20", }, + { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "0.00000000379308", + "from": "0x54CAe6EDCDec4c62AD5f7a8288447EAD0eb2995a", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x867F9b5a43cF854494702d1709527Bc84d022510", + "type": "ERC20", + }, ], "txHash": "0x074e27d856aae900c2a16f8577baa4194a1c23daf54efe80faff4bb612e410ba", "txIndex": 206, diff --git a/packages/transaction-decoder/test/snapshots/decoder/0x548af97ffad9b36b4ec40b403299dda5fac222c130cf4a3e2c4d438d88fe2280.snapshot b/packages/transaction-decoder/test/snapshots/decoder/0x548af97ffad9b36b4ec40b403299dda5fac222c130cf4a3e2c4d438d88fe2280.snapshot index 68d70e8e..93d347f9 100644 --- a/packages/transaction-decoder/test/snapshots/decoder/0x548af97ffad9b36b4ec40b403299dda5fac222c130cf4a3e2c4d438d88fe2280.snapshot +++ b/packages/transaction-decoder/test/snapshots/decoder/0x548af97ffad9b36b4ec40b403299dda5fac222c130cf4a3e2c4d438d88fe2280.snapshot @@ -97,9 +97,9 @@ "0xEB4C2781e4ebA804CE9a9803C67d0893436bB27D", "0xe1C83D448788af03049c7239da79007536F8AFfD", "0x000000000022D473030F116dDEE9F6B43aC78BA3", - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", - "0x81FbEf4704776cc5bBa0A5dF3a90056d2C6900B3", "0x12D737470fB3ec6C3DeEC9b518100Bec9D520144", + "0x81FbEf4704776cc5bBa0A5dF3a90056d2C6900B3", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "0x38F5E5b4DA37531a6e85161e337e0238bB27aa90", ], "interactions": [ @@ -131,7 +131,11 @@ "event": { "eventName": "Transfer", "logIndex": 198, - "params": {}, + "params": { + "from": "0xe1C83D448788af03049c7239da79007536F8AFfD", + "to": "0x12D737470fB3ec6C3DeEC9b518100Bec9D520144", + "value": "5765", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -163,7 +167,11 @@ "event": { "eventName": "Transfer", "logIndex": 200, - "params": {}, + "params": { + "from": "0x12D737470fB3ec6C3DeEC9b518100Bec9D520144", + "to": "0x81FbEf4704776cc5bBa0A5dF3a90056d2C6900B3", + "value": "5765", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -468,6 +476,24 @@ }, ], "transfers": [ + { + "address": "0xEB4C2781e4ebA804CE9a9803C67d0893436bB27D", + "amount": "0.000000000000005765", + "from": "0xe1C83D448788af03049c7239da79007536F8AFfD", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x12D737470fB3ec6C3DeEC9b518100Bec9D520144", + "type": "ERC20", + }, + { + "address": "0xEB4C2781e4ebA804CE9a9803C67d0893436bB27D", + "amount": "0.000000000000005765", + "from": "0x12D737470fB3ec6C3DeEC9b518100Bec9D520144", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x81FbEf4704776cc5bBa0A5dF3a90056d2C6900B3", + "type": "ERC20", + }, { "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "amount": "0.001361017781386941", diff --git a/packages/transaction-decoder/test/snapshots/decoder/0x9ed41e3d626605673c282dc8a0f7429e7abcab112d6529b0c77ee43954202cab.snapshot b/packages/transaction-decoder/test/snapshots/decoder/0x9ed41e3d626605673c282dc8a0f7429e7abcab112d6529b0c77ee43954202cab.snapshot index 4bc34612..09b70fd7 100644 --- a/packages/transaction-decoder/test/snapshots/decoder/0x9ed41e3d626605673c282dc8a0f7429e7abcab112d6529b0c77ee43954202cab.snapshot +++ b/packages/transaction-decoder/test/snapshots/decoder/0x9ed41e3d626605673c282dc8a0f7429e7abcab112d6529b0c77ee43954202cab.snapshot @@ -83,7 +83,11 @@ "event": { "eventName": "Transfer", "logIndex": 90, - "params": {}, + "params": { + "from": "0x928c2909847B884ba5Dd473568De6382b028F7b8", + "to": "0x22b81A596d3A1f727801bac52FC1B6c258765f48", + "tokenId": "1772", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -97,7 +101,11 @@ "event": { "eventName": "Transfer", "logIndex": 91, - "params": {}, + "params": { + "from": "0x906A43Ef2107aC308335a3809f07bAa9644d46F6", + "to": "0x22b81A596d3A1f727801bac52FC1B6c258765f48", + "tokenId": "599", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -111,7 +119,11 @@ "event": { "eventName": "Transfer", "logIndex": 92, - "params": {}, + "params": { + "from": "0x928c2909847B884ba5Dd473568De6382b028F7b8", + "to": "0x22b81A596d3A1f727801bac52FC1B6c258765f48", + "tokenId": "755", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -1107,6 +1119,33 @@ }, ], "transfers": [ + { + "address": "0xA4084B46F01d518616B0cDcC557b7f7E0cF8Bd50", + "amount": "0", + "from": "0x928c2909847B884ba5Dd473568De6382b028F7b8", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x22b81A596d3A1f727801bac52FC1B6c258765f48", + "type": "ERC20", + }, + { + "address": "0xA4084B46F01d518616B0cDcC557b7f7E0cF8Bd50", + "amount": "0", + "from": "0x906A43Ef2107aC308335a3809f07bAa9644d46F6", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x22b81A596d3A1f727801bac52FC1B6c258765f48", + "type": "ERC20", + }, + { + "address": "0xA4084B46F01d518616B0cDcC557b7f7E0cF8Bd50", + "amount": "0", + "from": "0x928c2909847B884ba5Dd473568De6382b028F7b8", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x22b81A596d3A1f727801bac52FC1B6c258765f48", + "type": "ERC20", + }, { "address": "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", "amount": "0.285", diff --git a/packages/transaction-decoder/test/snapshots/decoder/0xd83d86917c0a4b67b73bebce6822bd2545ea69e98e15a054bf4458258fd6d068.snapshot b/packages/transaction-decoder/test/snapshots/decoder/0xd83d86917c0a4b67b73bebce6822bd2545ea69e98e15a054bf4458258fd6d068.snapshot index aab151dd..2e4cdbde 100644 --- a/packages/transaction-decoder/test/snapshots/decoder/0xd83d86917c0a4b67b73bebce6822bd2545ea69e98e15a054bf4458258fd6d068.snapshot +++ b/packages/transaction-decoder/test/snapshots/decoder/0xd83d86917c0a4b67b73bebce6822bd2545ea69e98e15a054bf4458258fd6d068.snapshot @@ -243,12 +243,12 @@ "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", "0xdAC17F958D2ee523a2206206994597C13D831ec7", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "0x15652636f3898F550b257B89926d5566821c32E1", + "0x41ee28EE05341E7fdDdc8d433BA66054Cd302cA1", "0x07aE8551Be970cB1cCa11Dd7a11F47Ae82e70E67", "0x147B12C06D9e8e3837280F783Fd8070848D4412e", "0x74726F7574017B05A6aDCB2d4e11E7aDcF80F06C", - "0x41ee28EE05341E7fdDdc8d433BA66054Cd302cA1", "0x1b59718eaFA2BFFE5318E07c1C3cB2edde354f9C", - "0x15652636f3898F550b257B89926d5566821c32E1", "0x96AE533814f9A128333a2914A631b9Ae690E2B0a", "0x4a4e392290A382C9d2754E5Dca8581eA1893db5D", "0x6B175474E89094C44Da98b954EedeAC495271d0F", @@ -348,7 +348,11 @@ "event": { "eventName": "Transfer", "logIndex": 20, - "params": {}, + "params": { + "from": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", + "to": "0x15652636f3898F550b257B89926d5566821c32E1", + "value": "8422014254", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -362,7 +366,11 @@ "event": { "eventName": "Transfer", "logIndex": 21, - "params": {}, + "params": { + "from": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", + "to": "0x41ee28EE05341E7fdDdc8d433BA66054Cd302cA1", + "value": "2127797006", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -376,7 +384,11 @@ "event": { "eventName": "Transfer", "logIndex": 22, - "params": {}, + "params": { + "from": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", + "to": "0xc186fA914353c44b2E33eBE05f21846F1048bEda", + "value": "2432779426", + }, }, "signature": "Transfer(address,address,uint256)", }, @@ -2836,6 +2848,33 @@ "to": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", "type": "ERC20", }, + { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "0.000000008422014254", + "from": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x15652636f3898F550b257B89926d5566821c32E1", + "type": "ERC20", + }, + { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "0.000000002127797006", + "from": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0x41ee28EE05341E7fdDdc8d433BA66054Cd302cA1", + "type": "ERC20", + }, + { + "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "amount": "0.000000002432779426", + "from": "0x5c7BCd6E7De5423a257D81B442095A1a6ced35C5", + "name": "Mock ERC20 Contract", + "symbol": "ERC20", + "to": "0xc186fA914353c44b2E33eBE05f21846F1048bEda", + "type": "ERC20", + }, { "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "amount": "89.807",