Skip to content

Commit

Permalink
Allow having custom abi loader strategy per each chain (#21)
Browse files Browse the repository at this point in the history
* Bubble trace and log decoding errors

* Add blockscout strategy

* Allow having custom abi loader per chain

Having one array for all might result on fetching the abi from
a different chain, resulting in bad caching and decoding.

* changeset
  • Loading branch information
Ferossgp authored Jan 2, 2024
1 parent a014848 commit eb73069
Show file tree
Hide file tree
Showing 12 changed files with 127 additions and 95 deletions.
5 changes: 5 additions & 0 deletions .changeset/silver-glasses-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@3loop/transaction-decoder": minor
---

Allow having custom abi loader strategy per each chain
14 changes: 8 additions & 6 deletions apps/web/src/lib/contract-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import prisma from "./prisma";
export const AbiStoreLive = Layer.succeed(
AbiStore,
AbiStore.of({
strategies: [
EtherscanStrategyResolver({
apikey: process.env.ETHERSCAN_API_KEY,
}),
SourcifyStrategyResolver(),
],
strategies: {
default: [
EtherscanStrategyResolver({
apikey: process.env.ETHERSCAN_API_KEY,
}),
SourcifyStrategyResolver(),
],
},
set: ({ address = {} }) =>
Effect.gen(function* (_) {
const addressMatches = Object.entries(address);
Expand Down
2 changes: 1 addition & 1 deletion packages/transaction-decoder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ To create a new `AbiStore` service you will need to implement two methods `set`
const AbiStoreLive = Layer.succeed(
AbiStore,
AbiStore.of({
strategies: [],
strategies: { default: [] },
set: ({ address = {}, func = {}, event = {} }) =>
Effect.sync(() => {
// NOTE: Ignore caching as we relay only on local abis
Expand Down
8 changes: 6 additions & 2 deletions packages/transaction-decoder/src/abi-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ export interface GetAbiParams {
signature?: string | undefined
}

type ChainOrDefault = number | 'default'

export interface AbiStore<Key = GetAbiParams, SetValue = ContractABI, Value = string | null> {
readonly strategies: readonly RequestResolver.RequestResolver<GetContractABIStrategy>[]
readonly strategies: Record<ChainOrDefault, readonly RequestResolver.RequestResolver<GetContractABIStrategy>[]>
// NOTE: I'm not sure if this is the best abi store interface, but it works for our nosql database
readonly set: (value: SetValue) => Effect.Effect<never, never, void>
readonly get: (arg: Key) => Effect.Effect<never, never, Value>
Expand Down Expand Up @@ -39,8 +41,10 @@ export const getAndCacheAbi = ({ chainID, address, event, signature }: GetAbiPar
chainID,
})

const allAvailableStrategies = [...(strategies[chainID] ?? []), ...strategies.default]

const abi = yield* _(
Effect.validateFirst(strategies, (strategy) => Effect.request(request, strategy)).pipe(
Effect.validateFirst(allAvailableStrategies, (strategy) => Effect.request(request, strategy)).pipe(
Effect.catchAll(() => Effect.succeed(null)),
),
)
Expand Down
42 changes: 42 additions & 0 deletions packages/transaction-decoder/src/abi-strategy/blockscout-abi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Effect, RequestResolver } from 'effect'
import * as RequestModel from './request-model.js'

async function fetchContractABI(
{ address, chainID }: RequestModel.GetContractABIStrategy,
config: { apikey?: string; endpoint: string },
) {
const endpoint = config.endpoint

const params: Record<string, string> = {
module: 'contract',
action: 'getabi',
address,
}

if (config?.apikey) {
params['apikey'] = config.apikey
}

const searchParams = new URLSearchParams(params)

const response = await fetch(`${endpoint}?${searchParams.toString()}`)
const json = (await response.json()) as { status: string; result: string; message: string }

if (json.status === '1') {
return {
address: {
[address]: json.result,
},
}
}

throw new Error(`Failed to fetch ABI for ${address} on chain ${chainID}`)
}

export const BlockscoutStrategyResolver = (config: { apikey?: string; endpoint: string }) =>
RequestResolver.fromFunctionEffect((req: RequestModel.GetContractABIStrategy) =>
Effect.tryPromise({
try: () => fetchContractABI(req, config),
catch: () => new RequestModel.ResolveStrategyABIError('Blockscout', req.address, req.chainID),
}),
)
1 change: 1 addition & 0 deletions packages/transaction-decoder/src/abi-strategy/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './blockscout-abi.js'
export * from './etherscan-abi.js'
export * from './fourbyte-abi.js'
export * from './openchain-abi.js'
Expand Down
9 changes: 9 additions & 0 deletions packages/transaction-decoder/src/decoding/abi-decode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ export class DecodeError {
constructor(readonly error: unknown) {}
}

export class MissingABIError {
readonly _tag = 'MissingABIError'
constructor(
readonly address: string,
readonly signature: string,
readonly chainID: number,
) {}
}

function stringifyValue(value: MostTypes): string | string[] {
if (Array.isArray(value)) {
return value.map((v) => v.toString())
Expand Down
84 changes: 30 additions & 54 deletions packages/transaction-decoder/src/decoding/log-decode.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Log, TransactionResponse } from 'ethers'
import { Interface } from 'ethers'
import { Effect } from 'effect'
import type { Interaction, RawDecodedLog } from '../types.js'
import type { DecodedLogEvent, Interaction, RawDecodedLog } from '../types.js'
import { getProxyStorageSlot } from './proxies.js'
import { getAndCacheAbi } from '../abi-loader.js'
import { getAndCacheContractMeta } from '../contract-meta-loader.js'
import * as AbiDecoder from './abi-decode.js'

const decodedLog = (transaction: TransactionResponse, logItem: Log) =>
Effect.gen(function* (_) {
Expand All @@ -21,14 +22,6 @@ const decodedLog = (transaction: TransactionResponse, logItem: Log) =>
abiAddress = implementation
}

const blankRawLog: RawDecodedLog = {
name: null,
events: [],
address: logItem.address,
logIndex: logItem.index,
decoded: false,
}

const abiItem = yield* _(
getAndCacheAbi({
address: abiAddress,
Expand All @@ -38,63 +31,53 @@ const decodedLog = (transaction: TransactionResponse, logItem: Log) =>
)

if (abiItem == null) {
return blankRawLog
return yield* _(Effect.fail(new AbiDecoder.MissingABIError(abiAddress, logItem.topics[0], chainID)))
}

const iface = yield* _(Effect.try(() => new Interface(abiItem)))

const decodedData = yield* _(
Effect.try({
try: () =>
iface.parseLog({
try: () => {
const iface = new Interface(abiItem)

return iface.parseLog({
topics: [...logItem.topics],
data: logItem.data,
}),
catch: () => console.error('Error decoding log', logItem),
})
},
catch: (e) => new AbiDecoder.DecodeError(e),
}),
)

interface DecodedParam {
name: string
value: string
if (decodedData == null) {
return yield* _(Effect.fail(new AbiDecoder.DecodeError('Could not decode log')))
}

const decodedParams = yield* _(
Effect.try({
try: () =>
decodedData?.args.map((arg, index) => {
decodedData.args.map((arg, index) => {
const name = decodedData.fragment.inputs[index].name
const type = decodedData.fragment.inputs[index].type
const value = Array.isArray(arg) ? arg.map((item) => item?.toString()) : arg.toString()
return {
type,
name,
value,
} as DecodedParam
} as DecodedLogEvent
}),
catch: () => undefined,
catch: () => [],
}),
)

if (decodedData != null) {
return {
events: decodedParams,
name: decodedData.name,
address,
logIndex: logItem.index,
decoded: true,
} as RawDecodedLog
const rawLog: RawDecodedLog = {
events: decodedParams,
name: decodedData.name,
address,
logIndex: logItem.index,
decoded: true,
}
return blankRawLog
})

export const decodeLogs = ({ logs, transaction }: { logs: readonly Log[]; transaction: TransactionResponse }) =>
Effect.gen(function* (_) {
const effects = logs.filter((log) => log.topics.length > 0).map((logItem) => decodedLog(transaction, logItem))

return yield* _(
Effect.all(effects, {
concurrency: 'unbounded',
}),
)
return yield* _(transformLog(transaction, rawLog))
})

const transformLog = (transaction: TransactionResponse, log: RawDecodedLog) =>
Expand Down Expand Up @@ -124,24 +107,17 @@ const transformLog = (transaction: TransactionResponse, log: RawDecodedLog) =>
params: events,
...(!log.decoded && { decoded: log.decoded }),
},
}
} as Interaction
})

export const transformDecodedLogs = ({
decodedLogs,
transaction,
}: {
decodedLogs: RawDecodedLog[]
transaction: TransactionResponse
}) =>
export const decodeLogs = ({ logs, transaction }: { logs: readonly Log[]; transaction: TransactionResponse }) =>
Effect.gen(function* (_) {
const effects = decodedLogs.filter((log) => Boolean(log)).map((log) => transformLog(transaction, log))
const effects = logs.filter((log) => log.topics.length > 0).map((logItem) => decodedLog(transaction, logItem))
const eithers = effects.map((e) => Effect.either(e))

const result: Interaction[] = yield* _(
Effect.all(effects, {
return yield* _(
Effect.all(eithers, {
concurrency: 'unbounded',
}),
)

return result
})
12 changes: 4 additions & 8 deletions packages/transaction-decoder/src/decoding/trace-decode.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { TransactionResponse } from 'ethers'
import { Effect, Either } from 'effect'
import { Effect } from 'effect'
import type { DecodeTraceResult, Interaction, InteractionEvent } from '../types.js'
import type { CallTraceLog, TraceLog } from '../schema/trace.js'
import { DecodeError, decodeMethod } from './abi-decode.js'
import { DecodeError, MissingABIError, decodeMethod } from './abi-decode.js'
import { getAndCacheAbi } from '../abi-loader.js'

function getSecondLevelCalls(trace: TraceLog[]) {
Expand Down Expand Up @@ -34,7 +34,7 @@ const decodeTraceLog = (call: TraceLog, transaction: TransactionResponse) =>
)

if (abi == null) {
return yield* _(Effect.fail(new DecodeError(`Missing ABI for ${to} ${signature}`)))
return yield* _(Effect.fail(new MissingABIError(contractAddress, signature, chainID)))
}

return yield* _(
Expand Down Expand Up @@ -85,11 +85,7 @@ export const decodeTransactionTrace = ({
}),
)

const errors = result.filter(Either.isLeft).map((r) => r.left)

yield* _(Effect.logError(errors))

return result.filter(Either.isRight).map((r) => r.right)
return result
})

function traceLogToEvent(nativeTransfer: TraceLog): InteractionEvent {
Expand Down
Loading

0 comments on commit eb73069

Please sign in to comment.