Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silent-games-double.md
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .changeset/unlucky-boxes-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@3loop/transaction-decoder': patch
---

Propagate errors from loading ABIs up to the decoding
90 changes: 64 additions & 26 deletions packages/transaction-decoder/src/abi-loader.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -36,13 +37,35 @@ export interface AbiStore<Key = AbiParams, Value = ContractAbiResult> {

export const AbiStore = Context.GenericTag<AbiStore>('@3loop-decoder/AbiStore')

export interface AbiLoader extends Request.Request<string | null, unknown> {
_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<Abi, MissingABIError>, LoadParameters {
_tag: 'AbiLoader'
}

const AbiLoader = Request.tagged<AbiLoader>('AbiLoader')

Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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<AbiLoader>) =>
const AbiLoaderRequestResolver: Effect.Effect<
RequestResolver.RequestResolver<AbiLoader, never>,
never,
AbiStore<AbiParams, ContractAbiResult>
> = RequestResolver.makeBatched((requests: Array<AbiLoader>) =>
Effect.gen(function* () {
if (requests.length === 0) return

Expand All @@ -150,11 +177,12 @@ const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array<Ab
// Resolve ABI from the store
yield* Effect.forEach(
cachedResults,
([request, result]) => {
([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,
Expand Down Expand Up @@ -214,15 +242,16 @@ const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array<Ab
// Store results and resolve pending requests
yield* Effect.forEach(
strategyResults,
(abi, i) => {
(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 },
Expand All @@ -231,16 +260,25 @@ const AbiLoaderRequestResolver = RequestResolver.makeBatched((requests: Array<Ab
).pipe(RequestResolver.contextFromServices(AbiStore), Effect.withRequestCaching(true))

// TODO: When failing to decode with one ABI, we should retry with other resolved ABIs
export const getAndCacheAbi = (params: AbiParams) => {
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,
},
}),
)
16 changes: 9 additions & 7 deletions packages/transaction-decoder/src/abi-strategy/blockscout-abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as RequestModel from './request-model.js'
async function fetchContractABI(
{ address, chainID }: RequestModel.GetContractABIStrategy,
config: { apikey?: string; endpoint: string },
): Promise<RequestModel.ContractABI> {
): Promise<RequestModel.ContractABI[]> {
const endpoint = config.endpoint

const params: Record<string, string> = {
Expand All @@ -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}`)
Expand Down
16 changes: 9 additions & 7 deletions packages/transaction-decoder/src/abi-strategy/etherscan-abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const endpoints: { [k: number]: string } = {
async function fetchContractABI(
{ address, chainID }: RequestModel.GetContractABIStrategy,
config?: { apikey?: string; endpoint?: string },
): Promise<RequestModel.ContractABI> {
): Promise<RequestModel.ContractABI[]> {
const endpoint = config?.endpoint ?? endpoints[chainID]
const params: Record<string, string> = {
module: 'contract',
Expand All @@ -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}`)
Expand Down
14 changes: 7 additions & 7 deletions packages/transaction-decoder/src/abi-strategy/fourbyte-abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,33 +25,33 @@ async function fetchABI({
event,
signature,
chainID,
}: RequestModel.GetContractABIStrategy): Promise<RequestModel.ContractABI> {
}: RequestModel.GetContractABIStrategy): Promise<RequestModel.ContractABI[]> {
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,
}
}))
}
}

if (event != null) {
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,
}
}))
}
}

Expand Down
14 changes: 7 additions & 7 deletions packages/transaction-decoder/src/abi-strategy/openchain-abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,33 +43,33 @@ async function fetchABI({
chainID,
signature,
event,
}: RequestModel.GetContractABIStrategy): Promise<RequestModel.ContractABI> {
}: RequestModel.GetContractABIStrategy): Promise<RequestModel.ContractABI[]> {
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) {
const response = await fetch(`${endpoint}?event=${event}`, options)
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,
}
}))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ContractABI, ResolveStrategyABIError>, FetchABIParams {
export interface GetContractABIStrategy
extends Request.Request<ContractABI[], ResolveStrategyABIError>,
FetchABIParams {
readonly _tag: 'GetContractABIStrategy'
}

Expand Down
30 changes: 17 additions & 13 deletions packages/transaction-decoder/src/abi-strategy/sourcify-abi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,35 @@ const endpoint = 'https://repo.sourcify.dev/contracts/'
async function fetchContractABI({
address,
chainID,
}: RequestModel.GetContractABIStrategy): Promise<RequestModel.ContractABI> {
}: RequestModel.GetContractABIStrategy): Promise<RequestModel.ContractABI[]> {
const normalisedAddress = getAddress(address)

const full_match = await fetch(`${endpoint}/full_match/${chainID}/${normalisedAddress}/metadata.json`)

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}`)
Expand Down
2 changes: 1 addition & 1 deletion packages/transaction-decoder/src/contract-meta-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface ContractMetaStore<Key = ContractMetaParams, Value = ContractMet

export const ContractMetaStore = Context.GenericTag<ContractMetaStore>('@3loop-decoder/ContractMetaStore')

export interface ContractMetaLoader extends Request.Request<ContractData | null, unknown> {
export interface ContractMetaLoader extends Request.Request<ContractData | null, never> {
_tag: 'ContractMetaLoader'
address: Address
chainID: number
Expand Down
10 changes: 0 additions & 10 deletions packages/transaction-decoder/src/decoding/abi-decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading
Loading