diff --git a/.changeset/old-signs-lay.md b/.changeset/old-signs-lay.md new file mode 100644 index 00000000..5f2c035e --- /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. 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. 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 diff --git a/apps/web/src/lib/decode.ts b/apps/web/src/lib/decode.ts index d62be905..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* () { @@ -85,7 +80,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/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index efa92bf6..1d814d48 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 { 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' +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, @@ -68,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( @@ -78,20 +79,23 @@ 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, + source: abi.strategyId, + 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 * @@ -136,6 +140,7 @@ const getBestMatch = (abi: ContractABI | null) => { * - 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 @@ -146,27 +151,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,15 +210,19 @@ 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, { address: req.address, chainId: req.chainID, - strategyId: 'address-batch', }) .pipe( Effect.tapError(Effect.logDebug), @@ -220,18 +243,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, - strategyId: 'fragment-batch', + address: req.address, + chainId: req.chainID, + event: req.event, + signature: req.signature, }) .pipe( Effect.tapError(Effect.logDebug), @@ -244,21 +271,50 @@ 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 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 cacheEffect = + allAbis.length > 0 + ? Effect.forEach(allAbis, (abi) => setValue(request, abi), { + discard: true, + concurrency: 'unbounded', + }) + : Effect.void return Effect.zipRight( - setValue(request, abi), - Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }), + cacheEffect, + Effect.forEach(group, (req) => Request.completeEffect(req, Effect.succeed(allMatches)), { discard: true }), ) }, { @@ -277,21 +333,31 @@ 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') { return yield* Effect.fail(new EmptyCalldataError(params)) } + if (params.signature && errorFunctionSignatures.includes(params.signature)) { + 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) + 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: { 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/abi-strategy/strategy-executor.ts b/packages/transaction-decoder/src/abi-strategy/strategy-executor.ts index dc54abbb..b439bc5b 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' @@ -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[] = [] diff --git a/packages/transaction-decoder/src/decoding/abi-decode.ts b/packages/transaction-decoder/src/decoding/abi-decode.ts index 781ff20f..f4c046bb 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,106 @@ 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 { updateStatus } = yield* AbiStore.AbiStore + + 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* () { + 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(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: true }), + 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..70ad2497 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,41 +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), - }), - ], + const contractData = yield* getAndCacheContractMeta({ + address, + chainID: Number(transaction.chainId), + }) + + const { eventName, args, abiItem } = yield* AbiDecoder.validateAndDecodeEventWithABIs( + logItem.topics, + logItem.data, { - concurrency: 'unbounded', - batching: true, + address: abiAddress, + event: logItem.topics[0], + chainID, }, ) - 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), - }) - - if (eventName == null) { - return yield* new AbiDecoder.DecodeError(`Could not decode log ${abiAddress}`) - } - - const args = args_ as any - const fragment = yield* Effect.try({ try: () => getAbiItem({ abi: abiItem, name: eventName }), catch: () => { diff --git a/packages/transaction-decoder/src/decoding/trace-decode.ts b/packages/transaction-decoder/src/decoding/trace-decode.ts index 906cab47..f534ac92 100644 --- a/packages/transaction-decoder/src/decoding/trace-decode.ts +++ b/packages/transaction-decoder/src/decoding/trace-decode.ts @@ -1,11 +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 { getAndCacheAbi } from '../abi-loader.js' -import { type Hex, type GetTransactionReturnType, Abi, getAddress } from 'viem' +import { DecodeError } from './abi-decode.js' +import { validateAndDecodeWithABIs } from './abi-decode.js' +import { type Hex, type GetTransactionReturnType, getAddress } from 'viem' import { stringify } from '../helpers/stringify.js' -import { errorFunctionSignatures, panicReasons, solidityError, solidityPanic } from '../helpers/error.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 @@ -29,14 +29,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 +51,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 122ccffa..d6ab7c93 100644 --- a/packages/transaction-decoder/src/in-memory/abi-store.ts +++ b/packages/transaction-decoder/src/in-memory/abi-store.ts @@ -1,7 +1,9 @@ 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() export const make = (strategies: AbiStore.AbiStore['strategies']) => @@ -9,44 +11,59 @@ 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: CachedContractABI[] = [] - 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)) + ) { + // 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) } } - 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 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. + // 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..e89761fc 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,72 +85,148 @@ 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* () { + // Create the v3 table first + 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' + )`.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)`, + pg: () => q`COALESCE(timestamp, CURRENT_TIMESTAMP)`, + mysql: () => q`IFNULL(timestamp, CURRENT_TIMESTAMP)`, + orElse: () => q`CURRENT_TIMESTAMP`, + }) + + // Migrate data with improved error handling, preserving IDs + yield* q` + 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 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}`)), + ) + }), + ), + ]) + + 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') { + 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: 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([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: 'address', - address: normalizedAddress, - chain: key.chainID, - status: 'not-found', - }, - ])} - ` + INSERT INTO ${table} + ${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) }), @@ -178,7 +237,13 @@ 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([])), ) @@ -194,7 +259,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([])), ) @@ -226,6 +293,20 @@ 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((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 new file mode 100644 index 00000000..99b81d47 --- /dev/null +++ b/packages/transaction-decoder/src/sql/migrations.ts @@ -0,0 +1,69 @@ +import { Effect } from 'effect' +import { SqlClient, type SqlError } 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).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) + + 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 }) 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..5708b9e8 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,6 +40,8 @@ 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) { @@ -47,15 +49,14 @@ export const MockedAbiStoreLive = AbiStore.layer({ () => fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), ) - return { + results.push({ + id: `${address.toLowerCase()}-address`, + type: 'address', + abi, + address, + chainID: 1, status: 'success', - result: { - type: 'address', - abi, - address, - chainID: 1, - }, - } + }) } const sig = signature ?? event @@ -69,34 +70,29 @@ export const MockedAbiStoreLive = AbiStore.layer({ ) if (signature) { - return { + results.push({ + id: `${address.toLowerCase()}-${signature.toLowerCase()}-func`, + 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({ + id: `${address.toLowerCase()}-${event.toLowerCase()}-event`, + 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/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", 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..49f8d510 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,16 @@ describe('Transaction Decoder', () => { const addressExists = fs.existsSync(`./test/mocks/abi/${address.toLowerCase()}.json`) if (addressExists) { - return { - status: 'success', - result: { + return [ + { + id: `${address.toLowerCase()}-address`, type: 'address', abi: fs.readFileSync(`./test/mocks/abi/${address.toLowerCase()}.json`)?.toString(), address, chainID: 5, + status: 'success', }, - } + ] } const sig = signature ?? event @@ -42,34 +43,33 @@ describe('Transaction Decoder', () => { const signatureAbi = fs.readFileSync(`./test/mocks/abi/${sig.toLowerCase()}.json`)?.toString() if (signature) { - return { - status: 'success', - result: { + return [ + { + id: `${address.toLowerCase()}-${signature.toLowerCase()}-func`, type: 'func', abi: signatureAbi, address, chainID: 1, signature, + status: 'success', }, - } + ] } else if (event) { - return { - status: 'success', - result: { + return [ + { + id: `${address.toLowerCase()}-${event.toLowerCase()}-event`, type: 'event', abi: signatureAbi, address, chainID: 1, event, + status: 'success', }, - } + ] } } - return { - status: 'empty', - result: null, - } + return [] }, set: async () => { console.debug('Not implemented') 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({