Skip to content

Commit

Permalink
Propagate error from loading ABIs to the decoding (#98)
Browse files Browse the repository at this point in the history
* Propagate error from loading ABIs to the decoding

* Return parsed abi directly from request

* Return an array of matches from abi strategy
  • Loading branch information
Ferossgp authored Sep 7, 2024
1 parent c25178b commit ff61db4
Show file tree
Hide file tree
Showing 18 changed files with 138 additions and 112 deletions.
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

0 comments on commit ff61db4

Please sign in to comment.