diff --git a/.changeset/silent-games-double.md b/.changeset/silent-games-double.md new file mode 100644 index 00000000..5464df50 --- /dev/null +++ b/.changeset/silent-games-double.md @@ -0,0 +1,5 @@ +--- +'@3loop/transaction-decoder': minor +--- + +Return array of ABIs from AbiStrategy, this will allow us to match over multiple fragments when we have multiple matches for the same signature diff --git a/.changeset/unlucky-boxes-behave.md b/.changeset/unlucky-boxes-behave.md new file mode 100644 index 00000000..62a5e4cb --- /dev/null +++ b/.changeset/unlucky-boxes-behave.md @@ -0,0 +1,5 @@ +--- +'@3loop/transaction-decoder': patch +--- + +Propagate errors from loading ABIs up to the decoding diff --git a/packages/transaction-decoder/src/abi-loader.ts b/packages/transaction-decoder/src/abi-loader.ts index 1f854ac5..4a94cb41 100644 --- a/packages/transaction-decoder/src/abi-loader.ts +++ b/packages/transaction-decoder/src/abi-loader.ts @@ -1,5 +1,6 @@ -import { Context, Effect, Either, RequestResolver, Request, Array, pipe } from 'effect' +import { Context, Effect, Either, RequestResolver, Request, Array, pipe, Data } from 'effect' import { ContractABI, ContractAbiResolverStrategy, GetContractABIStrategy } from './abi-strategy/request-model.js' +import { Abi } from 'viem' const STRATEGY_TIMEOUT = 5000 export interface AbiParams { @@ -36,13 +37,35 @@ export interface AbiStore { export const AbiStore = Context.GenericTag('@3loop-decoder/AbiStore') -export interface AbiLoader extends Request.Request { - _tag: 'AbiLoader' +interface LoadParameters { readonly chainID: number readonly address: string readonly event?: string | undefined readonly signature?: string | undefined } +export class MissingABIError extends Data.TaggedError('DecodeError')< + { + message: string + } & LoadParameters +> { + constructor(props: LoadParameters) { + super({ message: `Missing ABI`, ...props }) + } +} + +export class EmptyCalldataError extends Data.TaggedError('DecodeError')< + { + message: string + } & LoadParameters +> { + constructor(props: LoadParameters) { + super({ message: `Empty calldata`, ...props }) + } +} + +export interface AbiLoader extends Request.Request, LoadParameters { + _tag: 'AbiLoader' +} const AbiLoader = Request.tagged('AbiLoader') @@ -85,10 +108,10 @@ const getBestMatch = (abi: ContractABI | null) => { if (abi == null) return null if (abi.type === 'address') { - return abi.abi + return JSON.parse(abi.abi) as Abi } - return `[${abi.abi}]` + return JSON.parse(`[${abi.abi}]`) as Abi } /** @@ -126,7 +149,11 @@ const getBestMatch = (abi: ContractABI | null) => { * requests and resolve the pending requests in a group with the same result. * */ -const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array) => +const AbiLoaderRequestResolver: Effect.Effect< + RequestResolver.RequestResolver, + never, + AbiStore +> = RequestResolver.makeBatched((requests: Array) => Effect.gen(function* () { if (requests.length === 0) return @@ -150,11 +177,12 @@ const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array { + ([request, abi]) => { const group = requestGroups[makeRequestKey(request)] - const abi = getBestMatch(result) + const bestMatch = getBestMatch(abi) + const result = bestMatch ? Effect.succeed(bestMatch) : Effect.fail(new MissingABIError(request)) - return Effect.forEach(group, (req) => Request.succeed(req, abi), { discard: true }) + return Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }) }, { discard: true, @@ -214,15 +242,16 @@ const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array { + (abis, i) => { const request = remaining[i] - const result = getBestMatch(abi) - + const abi = abis?.[0] ?? null + const bestMatch = getBestMatch(abi) + const result = bestMatch ? Effect.succeed(bestMatch) : Effect.fail(new MissingABIError(request)) const group = requestGroups[makeRequestKey(request)] return Effect.zipRight( setValue(request, abi), - Effect.forEach(group, (req) => Request.succeed(req, result), { discard: true }), + Effect.forEach(group, (req) => Request.completeEffect(req, result), { discard: true }), ) }, { discard: true }, @@ -231,16 +260,25 @@ const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array { - if (params.event === '0x' || params.signature === '0x') { - return Effect.succeed(null) - } - return Effect.withSpan(Effect.request(AbiLoader(params), AbiLoaderRequestResolver), 'AbiLoader.GetAndCacheAbi', { - attributes: { - chainID: params.chainID, - address: params.address, - event: params.event, - signature: params.signature, - }, - }) -} +// We can decode with Effect.validateFirst(abis, (abi) => decodeMethod(input as Hex, abi)) and to find the first ABIs +// that decodes successfully. We might enforce a sorted array to prioritize the address match. We will have to think +// how to handle the strategy resolver in this case. Currently, we stop at first successful strategy, which might result +// in a missing Fragment. We treat this issue as a minor one for now, as we epect it to occur rarely on contracts that +// are not verified and with a non standard events structure. +export const getAndCacheAbi = (params: AbiParams) => + Effect.gen(function* () { + if (params.event === '0x' || params.signature === '0x') { + return yield* Effect.fail(new EmptyCalldataError(params)) + } + + return yield* Effect.request(AbiLoader(params), AbiLoaderRequestResolver) + }).pipe( + Effect.withSpan('AbiLoader.GetAndCacheAbi', { + attributes: { + chainID: params.chainID, + address: params.address, + event: params.event, + signature: params.signature, + }, + }), + ) diff --git a/packages/transaction-decoder/src/abi-strategy/blockscout-abi.ts b/packages/transaction-decoder/src/abi-strategy/blockscout-abi.ts index c873a3e1..aae65274 100644 --- a/packages/transaction-decoder/src/abi-strategy/blockscout-abi.ts +++ b/packages/transaction-decoder/src/abi-strategy/blockscout-abi.ts @@ -4,7 +4,7 @@ import * as RequestModel from './request-model.js' async function fetchContractABI( { address, chainID }: RequestModel.GetContractABIStrategy, config: { apikey?: string; endpoint: string }, -): Promise { +): Promise { const endpoint = config.endpoint const params: Record = { @@ -23,12 +23,14 @@ async function fetchContractABI( const json = (await response.json()) as { status: string; result: string; message: string } if (json.status === '1') { - return { - chainID, - address, - abi: json.result, - type: 'address', - } + return [ + { + chainID, + address, + abi: json.result, + type: 'address', + }, + ] } throw new Error(`Failed to fetch ABI for ${address} on chain ${chainID}`) diff --git a/packages/transaction-decoder/src/abi-strategy/etherscan-abi.ts b/packages/transaction-decoder/src/abi-strategy/etherscan-abi.ts index 5eb2007d..17bdb1c2 100644 --- a/packages/transaction-decoder/src/abi-strategy/etherscan-abi.ts +++ b/packages/transaction-decoder/src/abi-strategy/etherscan-abi.ts @@ -50,7 +50,7 @@ const endpoints: { [k: number]: string } = { async function fetchContractABI( { address, chainID }: RequestModel.GetContractABIStrategy, config?: { apikey?: string; endpoint?: string }, -): Promise { +): Promise { const endpoint = config?.endpoint ?? endpoints[chainID] const params: Record = { module: 'contract', @@ -68,12 +68,14 @@ async function fetchContractABI( const json = (await response.json()) as { status: string; result: string } if (json.status === '1') { - return { - type: 'address', - address, - chainID, - abi: json.result, - } + return [ + { + type: 'address', + address, + chainID, + abi: json.result, + }, + ] } throw new Error(`Failed to fetch ABI for ${address} on chain ${chainID}`) diff --git a/packages/transaction-decoder/src/abi-strategy/fourbyte-abi.ts b/packages/transaction-decoder/src/abi-strategy/fourbyte-abi.ts index c773f6a4..33b0c295 100644 --- a/packages/transaction-decoder/src/abi-strategy/fourbyte-abi.ts +++ b/packages/transaction-decoder/src/abi-strategy/fourbyte-abi.ts @@ -25,19 +25,19 @@ async function fetchABI({ event, signature, chainID, -}: RequestModel.GetContractABIStrategy): Promise { +}: RequestModel.GetContractABIStrategy): Promise { if (signature != null) { const full_match = await fetch(`${endpoint}/signatures/?hex_signature=${signature}`) if (full_match.status === 200) { const json = (await full_match.json()) as FourBytesResponse - return { + return json.results.map((result) => ({ type: 'func', address, chainID, - abi: parseFunctionSignature(json.results[0]?.text_signature), + abi: parseFunctionSignature(result.text_signature), signature, - } + })) } } @@ -45,13 +45,13 @@ async function fetchABI({ const partial_match = await fetch(`${endpoint}/event-signatures/?hex_signature=${event}`) if (partial_match.status === 200) { const json = (await partial_match.json()) as FourBytesResponse - return { + return json.results.map((result) => ({ type: 'event', address, chainID, - abi: parseEventSignature(json.results[0]?.text_signature), + abi: parseEventSignature(result.text_signature), event, - } + })) } } diff --git a/packages/transaction-decoder/src/abi-strategy/openchain-abi.ts b/packages/transaction-decoder/src/abi-strategy/openchain-abi.ts index 0c84deaa..f71b35d7 100644 --- a/packages/transaction-decoder/src/abi-strategy/openchain-abi.ts +++ b/packages/transaction-decoder/src/abi-strategy/openchain-abi.ts @@ -43,19 +43,19 @@ async function fetchABI({ chainID, signature, event, -}: RequestModel.GetContractABIStrategy): Promise { +}: RequestModel.GetContractABIStrategy): Promise { if (signature != null) { const response = await fetch(`${endpoint}?function=${signature}`, options) if (response.status === 200) { const json = (await response.json()) as OpenchainResponse - return { + return json.result.function[signature].map((f) => ({ type: 'func', address, chainID, - abi: parseFunctionSignature(json.result.function[signature][0].name), + abi: parseFunctionSignature(f.name), signature, - } + })) } } if (event != null) { @@ -63,13 +63,13 @@ async function fetchABI({ if (response.status === 200) { const json = (await response.json()) as OpenchainResponse - return { + return json.result.event[event].map((e) => ({ type: 'event', address, chainID, - abi: parseEventSignature(json.result.event[event][0].name), + abi: parseEventSignature(e.name), event, - } + })) } } diff --git a/packages/transaction-decoder/src/abi-strategy/request-model.ts b/packages/transaction-decoder/src/abi-strategy/request-model.ts index be3a7943..ae237d51 100644 --- a/packages/transaction-decoder/src/abi-strategy/request-model.ts +++ b/packages/transaction-decoder/src/abi-strategy/request-model.ts @@ -42,7 +42,9 @@ interface AddressABI { export type ContractABI = FunctionFragmentABI | EventFragmentABI | AddressABI // NOTE: We might want to return a list of ABIs, this might be helpful when fetching for signature -export interface GetContractABIStrategy extends Request.Request, FetchABIParams { +export interface GetContractABIStrategy + extends Request.Request, + FetchABIParams { readonly _tag: 'GetContractABIStrategy' } diff --git a/packages/transaction-decoder/src/abi-strategy/sourcify-abi.ts b/packages/transaction-decoder/src/abi-strategy/sourcify-abi.ts index 21149c04..d80e5fd0 100644 --- a/packages/transaction-decoder/src/abi-strategy/sourcify-abi.ts +++ b/packages/transaction-decoder/src/abi-strategy/sourcify-abi.ts @@ -13,7 +13,7 @@ const endpoint = 'https://repo.sourcify.dev/contracts/' async function fetchContractABI({ address, chainID, -}: RequestModel.GetContractABIStrategy): Promise { +}: RequestModel.GetContractABIStrategy): Promise { const normalisedAddress = getAddress(address) const full_match = await fetch(`${endpoint}/full_match/${chainID}/${normalisedAddress}/metadata.json`) @@ -21,23 +21,27 @@ async function fetchContractABI({ if (full_match.status === 200) { const json = (await full_match.json()) as SourcifyResponse - return { - type: 'address', - address, - chainID, - abi: JSON.stringify(json.output.abi), - } + return [ + { + type: 'address', + address, + chainID, + abi: JSON.stringify(json.output.abi), + }, + ] } const partial_match = await fetch(`${endpoint}/partial_match/${chainID}/${normalisedAddress}/metadata.json`) if (partial_match.status === 200) { const json = (await partial_match.json()) as SourcifyResponse - return { - type: 'address', - address, - chainID, - abi: JSON.stringify(json.output.abi), - } + return [ + { + type: 'address', + address, + chainID, + abi: JSON.stringify(json.output.abi), + }, + ] } throw new Error(`Failed to fetch ABI for ${address} on chain ${chainID}`) diff --git a/packages/transaction-decoder/src/contract-meta-loader.ts b/packages/transaction-decoder/src/contract-meta-loader.ts index 71ec86eb..f398a967 100644 --- a/packages/transaction-decoder/src/contract-meta-loader.ts +++ b/packages/transaction-decoder/src/contract-meta-loader.ts @@ -50,7 +50,7 @@ export interface ContractMetaStore('@3loop-decoder/ContractMetaStore') -export interface ContractMetaLoader extends Request.Request { +export interface ContractMetaLoader extends Request.Request { _tag: 'ContractMetaLoader' address: Address chainID: number diff --git a/packages/transaction-decoder/src/decoding/abi-decode.ts b/packages/transaction-decoder/src/decoding/abi-decode.ts index b8a56032..7526edc0 100644 --- a/packages/transaction-decoder/src/decoding/abi-decode.ts +++ b/packages/transaction-decoder/src/decoding/abi-decode.ts @@ -10,16 +10,6 @@ export class DecodeError extends Data.TaggedError('DecodeError')<{ message: stri } } -export class MissingABIError extends Data.TaggedError('DecodeError')<{ message: string }> { - constructor( - readonly address: string, - readonly signature: string, - readonly chainID: number, - ) { - super({ message: `Missing ABI for ${address} with signature ${signature} on chain ${chainID}` }) - } -} - function stringifyValue(value: MostTypes): string | string[] { if (Array.isArray(value)) { return value.map((v) => v.toString()) diff --git a/packages/transaction-decoder/src/decoding/calldata-decode.ts b/packages/transaction-decoder/src/decoding/calldata-decode.ts index 86e3ce2f..53fcb472 100644 --- a/packages/transaction-decoder/src/decoding/calldata-decode.ts +++ b/packages/transaction-decoder/src/decoding/calldata-decode.ts @@ -25,18 +25,12 @@ export const decodeMethod = ({ } } - const abi_ = yield* getAndCacheAbi({ + const abi = yield* getAndCacheAbi({ address: contractAddress, signature, chainID, }) - if (!abi_) { - return yield* new AbiDecoder.MissingABIError(contractAddress, signature, chainID) - } - - const abi = JSON.parse(abi_) as Abi - const decoded = yield* AbiDecoder.decodeMethod(data, abi) if (decoded == null) { diff --git a/packages/transaction-decoder/src/decoding/log-decode.ts b/packages/transaction-decoder/src/decoding/log-decode.ts index 00379a94..722e87aa 100644 --- a/packages/transaction-decoder/src/decoding/log-decode.ts +++ b/packages/transaction-decoder/src/decoding/log-decode.ts @@ -1,4 +1,4 @@ -import { type GetTransactionReturnType, type Log, decodeEventLog, getAbiItem, type Abi, getAddress } from 'viem' +import { type GetTransactionReturnType, type Log, decodeEventLog, getAbiItem, getAddress } from 'viem' import { Effect } from 'effect' import type { DecodedLogEvent, Interaction, RawDecodedLog } from '../types.js' import { getProxyStorageSlot } from './proxies.js' @@ -21,18 +21,12 @@ const decodedLog = (transaction: GetTransactionReturnType, logItem: Log) => abiAddress = implementation } - const abiItem_ = yield* getAndCacheAbi({ + const abiItem = yield* getAndCacheAbi({ address: abiAddress, event: logItem.topics[0], chainID, }) - if (abiItem_ == null) { - return yield* new AbiDecoder.MissingABIError(abiAddress, logItem.topics[0]!, chainID) - } - - const abiItem = JSON.parse(abiItem_) as Abi[] - const { eventName, args: args_ } = yield* Effect.try({ try: () => decodeEventLog({ @@ -43,7 +37,7 @@ const decodedLog = (transaction: GetTransactionReturnType, logItem: Log) => }), catch: (err) => Effect.gen(function* () { - yield* Effect.logError(`Could not decode log ${abiAddress} ${stringify(logItem)}`, err) + yield* Effect.logError(`Could not decode log ${abiAddress} `, err) return new AbiDecoder.DecodeError(`Could not decode log ${abiAddress}`) }), }) diff --git a/packages/transaction-decoder/src/decoding/trace-decode.ts b/packages/transaction-decoder/src/decoding/trace-decode.ts index 0f902501..03e9bb75 100644 --- a/packages/transaction-decoder/src/decoding/trace-decode.ts +++ b/packages/transaction-decoder/src/decoding/trace-decode.ts @@ -1,7 +1,7 @@ import { Effect } from 'effect' import type { DecodeTraceResult, Interaction, InteractionEvent } from '../types.js' import type { CallTraceLog, TraceLog } from '../schema/trace.js' -import { DecodeError, MissingABIError, decodeMethod } from './abi-decode.js' +import { DecodeError, decodeMethod } from './abi-decode.js' import { getAndCacheAbi } from '../abi-loader.js' import { type Hex, type GetTransactionReturnType, Abi, getAddress } from 'viem' import { stringify } from '../helpers/stringify.js' @@ -29,18 +29,12 @@ const decodeTraceLog = (call: TraceLog, transaction: GetTransactionReturnType) = const signature = call.action.input.slice(0, 10) const contractAddress = to - const abi_ = yield* getAndCacheAbi({ + const abi = yield* getAndCacheAbi({ address: contractAddress, signature, chainID, }) - if (abi_ == null) { - return yield* new MissingABIError(contractAddress, signature, chainID) - } - - const abi = JSON.parse(abi_) as Abi - const method = yield* decodeMethod(input as Hex, abi) return { @@ -70,11 +64,7 @@ const decodeTraceLogOutput = (call: TraceLog) => chainID: 0, }) - if (abi_ == null) { - return yield* new MissingABIError('', signature, 0) - } - - abi = [...abi, ...(JSON.parse(abi_) as Abi)] + abi = [...abi, ...abi_] } return yield* decodeMethod(data as Hex, abi) diff --git a/packages/transaction-decoder/src/effect.ts b/packages/transaction-decoder/src/effect.ts index 26145e17..0d5385cc 100644 --- a/packages/transaction-decoder/src/effect.ts +++ b/packages/transaction-decoder/src/effect.ts @@ -6,3 +6,4 @@ export * from './public-client.js' export * from './transaction-decoder.js' export * from './transaction-loader.js' export * from './types.js' +export { DecodeError } from './decoding/abi-decode.js' diff --git a/packages/transaction-decoder/src/index.ts b/packages/transaction-decoder/src/index.ts index d63e68d4..b3fd40de 100644 --- a/packages/transaction-decoder/src/index.ts +++ b/packages/transaction-decoder/src/index.ts @@ -3,6 +3,5 @@ * https://stackoverflow.com/questions/70296652/how-can-i-use-exports-in-package-json-for-nested-submodules-and-typescript */ export * from './effect.js' -export * from './transaction-decoder.js' export * from './types.js' export * from './vanilla.js' diff --git a/packages/transaction-decoder/src/transaction-decoder.ts b/packages/transaction-decoder/src/transaction-decoder.ts index e9c91b98..e11dcc80 100644 --- a/packages/transaction-decoder/src/transaction-decoder.ts +++ b/packages/transaction-decoder/src/transaction-decoder.ts @@ -73,11 +73,11 @@ export const decodeTransaction = ({ }) => Effect.gen(function* () { if (transaction.to == null) { - return yield* Effect.die(new UnsupportedEvent({ message: 'Contract creation' })) + return yield* Effect.fail(new UnsupportedEvent({ message: 'Contract creation' })) } if (!('input' in transaction)) { - return yield* Effect.die(new UnsupportedEvent({ message: 'Unsupported transaction' })) + return yield* Effect.fail(new UnsupportedEvent({ message: 'Unsupported transaction' })) } if (transaction.input === '0x') { diff --git a/packages/transaction-decoder/src/transaction-loader.ts b/packages/transaction-decoder/src/transaction-loader.ts index 563df598..4047263f 100644 --- a/packages/transaction-decoder/src/transaction-loader.ts +++ b/packages/transaction-decoder/src/transaction-loader.ts @@ -113,5 +113,5 @@ export const getBlockTimestamp = (blockNumber: bigint, chainID: number) => return block.timestamp } - return 0 + return BigInt(0) })