Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add automatic contract meta revolvers for erc20, erc721 and erc1155 #29

Merged
merged 5 commits into from
Apr 2, 2024
Merged
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/famous-mayflies-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@3loop/transaction-decoder': minor
---

Add automatic resolvers for contract metadata
2 changes: 1 addition & 1 deletion .changeset/hungry-kings-applaud.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
"@3loop/transaction-decoder": minor
'@3loop/transaction-decoder': minor
---

Change interpretation from jsonata to js code using QuickJS
Original file line number Diff line number Diff line change
@@ -4,4 +4,4 @@
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
}
}
6 changes: 5 additions & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"recommendations": ["astro-build.astro-vscode"],
"recommendations": [
"astro-build.astro-vscode",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"unwantedRecommendations": []
}
10 changes: 10 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"editor.rulers": [120],
"editor.tabSize": 4,
"editor.detectIndentation": false,
"editor.trimAutoWhitespace": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true
}
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -6,8 +6,8 @@

A library to transform any EVM transaction into a human-readable format. It consists of 2 parts:

- [Transaction decoder](https://github.com/3loop/loop-decoder/tree/main/packages/transaction-decoder)
- Customizable transaction interpreter
- [Transaction decoder](https://github.com/3loop/loop-decoder/tree/main/packages/transaction-decoder)
- Customizable transaction interpreter

## Why

@@ -17,9 +17,9 @@ Currently, the available EVM transaction decoders require developers to use spec

## Features

- [x] Can be used in any JavaScript environment
- [x] Minimal external dependencies - connect your own storage
- [x] Flexible interpreter that allows you to define any custom interpretation of EVM transactions.
- [x] Can be used in any JavaScript environment
- [x] Minimal external dependencies - connect your own storage
- [x] Flexible interpreter that allows you to define any custom interpretation of EVM transactions.

## Looking for feedback

10 changes: 5 additions & 5 deletions apps/docs/src/content/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defineCollection } from "astro:content";
import { docsSchema, i18nSchema } from "@astrojs/starlight/schema";
import { defineCollection } from 'astro:content'
import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'

export const collections = {
docs: defineCollection({ schema: docsSchema() }),
i18n: defineCollection({ type: "data", schema: i18nSchema() }),
};
docs: defineCollection({ schema: docsSchema() }),
i18n: defineCollection({ type: 'data', schema: i18nSchema() }),
}
2 changes: 1 addition & 1 deletion apps/docs/src/content/docs/contribution.md
Original file line number Diff line number Diff line change
@@ -21,4 +21,4 @@ To create a new release, one of the maintainers will merge the changeset PR into

Some ideas for the decoder and interpreter were inspired by open-source software. Special thanks to:

- [EVM Translator](https://github.com/metagame-xyz/evm-translator) - some data types and data manipulations were heavily inspired by this source.
- [EVM Translator](https://github.com/metagame-xyz/evm-translator) - some data types and data manipulations were heavily inspired by this source.
63 changes: 30 additions & 33 deletions apps/docs/src/content/docs/getting-started.md
Original file line number Diff line number Diff line change
@@ -5,8 +5,8 @@ description: A guide in my new Starlight docs site.

### Requirements

- TypeScript 5.x
- `exactOptionalPropertyTypes` and `strict` enabled in your tsconfig.json
- TypeScript 5.x
- `exactOptionalPropertyTypes` and `strict` enabled in your tsconfig.json

### Dependencies

@@ -24,12 +24,12 @@ To begin using the Loop Decoder, you need to create an instance of the LoopDecod

```ts
const getPublicClient = (chainId: number) => {
return {
client: createPublicClient({
transport: http(RPC_URL[chainId]),
}),
};
};
return {
client: createPublicClient({
transport: http(RPC_URL[chainId]),
}),
}
}
```

2. `contractMetaStore`: This object has 2 properties `get` and `set` that returns and caches contract meta-information. See the `ContractData` type for the required properties.
@@ -48,44 +48,41 @@ const contractMetaStore = {
address: string
chainID: number
}) {
// NOTE: not yet called as we do not have any automatic resolve strategy implemented
// NOTE: ignore for now
},
}
```

3. `abiStore`: Similarly, this object has 2 properties `get` and `set` that returns and cache the contract or fragment ABI based on the chain ID, address, and/or signature.

```ts
const db = {}; // Your data source
const db = {} // Your data source

const abiStore = {
get: async (req: {
chainID: number;
address: string;
event?: string | undefined;
signature?: string | undefined;
}) => {
return db.getContractAbi(req);
},
set: async (req: {
address?: Record<string, string>;
signature?: Record<string, string>;
}) => {
await db.setContractAbi(req);
},
};
get: async (req: {
chainID: number
address: string
event?: string | undefined
signature?: string | undefined
}) => {
return db.getContractAbi(req)
},
set: async (req: { address?: Record<string, string>; signature?: Record<string, string> }) => {
await db.setContractAbi(req)
},
}
```

Finally, you can create a new instance of the LoopDecoder class:

```ts
import { TransactionDecoder } from "@3loop/transaction-decoder";
import { TransactionDecoder } from '@3loop/transaction-decoder'

const decoded = new TransactionDecoder({
getProvider: getPublicClient,
abiStore: abiStore,
contractMetaStore: contractMetaStore,
});
getProvider: getPublicClient,
abiStore: abiStore,
contractMetaStore: contractMetaStore,
})
```

It's important to note that the Loop Decoder does not enforce any specific data source, allowing users of the library to load contract data as they see fit. Depending on the requirements of your application, you can either include the necessary data directly in your code for a small number of contracts or use a database as a cache.
@@ -94,7 +91,7 @@ LoopDecoder instances provide a public method, `decodeTransaction`, which fetche

```ts
const result = await decoded.decodeTransaction({
chainID: 5,
hash: "0x...",
});
chainID: 5,
hash: '0x...',
})
```
134 changes: 66 additions & 68 deletions apps/docs/src/content/docs/guides/decode-transaction.md
Original file line number Diff line number Diff line change
@@ -31,9 +31,9 @@ npx tsc --init

```json
{
"compilerOptions": {
"strict": true
}
"compilerOptions": {
"strict": true
}
}
```

@@ -43,9 +43,9 @@ npx tsc --init

```json
{
"scripts": {
"start": "tsc && node index.js"
}
"scripts": {
"start": "tsc && node index.js"
}
}
```

@@ -72,19 +72,19 @@ Loop Decoder requires some data sources to be able to decode transactions. We wi
We will start by creating a function which will return an object with PublicClient based on the chain ID. For the sake of this example, we will only support mainnet.

```ts
import { createPublicClient, http } from "viem";
import { createPublicClient, http } from 'viem'

const getPublicClient = (chainId: number) => {
if (chainId !== 1) {
throw new Error(`Missing RPC provider for chain ID ${chainId}`);
}

return {
client: createPublicClient({
transport: http("https://rpc.ankr.com/eth"),
}),
};
};
if (chainId !== 1) {
throw new Error(`Missing RPC provider for chain ID ${chainId}`)
}

return {
client: createPublicClient({
transport: http('https://rpc.ankr.com/eth'),
}),
}
}
```

### ABI loader
@@ -94,64 +94,62 @@ To avoid making unecessary calls to third-party APIs, Loop Decoder uses an API t
Create a cache for contract ABI:

```ts
import { EtherscanStrategyResolver } from "@3loop/transaction-decoder";
const abiCache = new Map<string, string>();
import { EtherscanStrategyResolver } from '@3loop/transaction-decoder'
const abiCache = new Map<string, string>()

const abiStore = {
strategies: [
EtherscanStrategyResolver({
apikey: "YourApiKeyToken",
}),
FourByteStrategyResolver(),
],
get: async (req: {
chainID: number;
address: string;
event?: string | undefined;
signature?: string | undefined;
}) => {
return Promise.resolve(abiCache.get(req.address) ?? null);
},
set: async (req: {
address?: Record<string, string>;
signature?: Record<string, string>;
}) => {
const addresses = Object.keys(req.address ?? {});
addresses.forEach((address) => {
abiCache.set(address, req.address?.[address] ?? "");
});
},
};
strategies: [
EtherscanStrategyResolver({
apikey: 'YourApiKeyToken',
}),
FourByteStrategyResolver(),
],
get: async (req: {
chainID: number
address: string
event?: string | undefined
signature?: string | undefined
}) => {
return Promise.resolve(abiCache.get(req.address) ?? null)
},
set: async (req: { address?: Record<string, string>; signature?: Record<string, string> }) => {
const addresses = Object.keys(req.address ?? {})
addresses.forEach((address) => {
abiCache.set(address, req.address?.[address] ?? '')
})
},
}
```

### Contract Metadata loader

Create a cache for contract meta-information, such as token name, decimals, symbol, etc.:

```ts
import type { ContractData } from "@3loop/transaction-decoder";
const contractMeta = new Map<string, ContractData>();
import type { ContractData } from '@3loop/transaction-decoder'
const contractMeta = new Map<string, ContractData>()

const contractMetaStore = {
get: async (req: { address: string; chainID: number }) => {
return contractMeta.get(req.address) ?? null;
},
set: async (req: { address: string; chainID: number }) => {
// NOTE: not yet called as we do not have any automatic resolve strategy implemented
},
};
strategies: { default: [ERC20RPCStrategyResolver] },
get: async (req: { address: string; chainID: number }) => {
return contractMeta.get(req.address) ?? null
},
set: async (req: { address: string; chainID: number }, data) => {
contractMeta.set(req.address, data)
},
}
```

Finally, you can create a new instance of the LoopDecoder class:

```ts
import { TransactionDecoder } from "@3loop/transaction-decoder";
import { TransactionDecoder } from '@3loop/transaction-decoder'

const decoder = new TransactionDecoder({
getPublicClient: getPublicClient,
abiStore: abiStore,
contractMetaStore: contractMetaStore,
});
getPublicClient: getPublicClient,
abiStore: abiStore,
contractMetaStore: contractMetaStore,
})
```

## Decoding a Transaction
@@ -160,17 +158,17 @@ Now that we have all the necessary components, we can start decoding a transacti

```ts
async function main() {
try {
const decoded = await decoder.decodeTransaction({
chainID: 1,
hash: "0xc0bd04d7e94542e58709f51879f64946ff4a744e1c37f5f920cea3d478e115d7",
});

console.log(JSON.stringify(decoded, null, 2));
} catch (e) {
console.error(JSON.stringify(e, null, 2));
}
try {
const decoded = await decoder.decodeTransaction({
chainID: 1,
hash: '0xc0bd04d7e94542e58709f51879f64946ff4a744e1c37f5f920cea3d478e115d7',
})

console.log(JSON.stringify(decoded, null, 2))
} catch (e) {
console.error(JSON.stringify(e, null, 2))
}
}

main();
main()
```
115 changes: 53 additions & 62 deletions apps/docs/src/content/docs/guides/effect-api.md
Original file line number Diff line number Diff line change
@@ -12,21 +12,19 @@ To get started with using the Decoder, first, you have to provide the RPC Provid
1. Create an RPC Provider

```ts
import { PublicClient, PublicClientObject } from "@3loop/transaction-decoder";
import { Effect } from "effect";

const getPublicClient = (
chainID: number,
): Effect.Effect<never, UnknownNetwork, PublicClientObject> => {
if (chainID === 5) {
return Effect.succeed({
client: createPublicClient({
transport: http(GOERLI_RPC),
}),
});
}
return Effect.fail(new UnknownNetwork(chainID));
};
import { PublicClient, PublicClientObject } from '@3loop/transaction-decoder'
import { Effect } from 'effect'

const getPublicClient = (chainID: number): Effect.Effect<never, UnknownNetwork, PublicClientObject> => {
if (chainID === 5) {
return Effect.succeed({
client: createPublicClient({
transport: http(GOERLI_RPC),
}),
})
}
return Effect.fail(new UnknownNetwork(chainID))
}
```

2. Create the AbiStore
@@ -37,34 +35,34 @@ To create a new `AbiStore` service you will need to implement two methods `set`

```ts
const AbiStoreLive = Layer.succeed(
AbiStore,
AbiStore.of({
strategies: { default: [] },
set: ({ address = {}, func = {}, event = {} }) =>
Effect.sync(() => {
// NOTE: Ignore caching as we relay only on local abis
}),
get: ({ address, signature, event }) =>
Effect.sync(() => {
const signatureAbiMap = {
"0x3593564c": "execute(bytes,bytes[],uint256)",
"0x0902f1ac": "getReserves()",
"0x36c78516": "transferFrom(address,address,uint160,address) ",
"0x70a08231": "balanceOf(address)",
"0x022c0d9f": "swap(uint256,uint256,address,bytes)",
"0x2e1a7d4d": "withdraw(uint256)",
};

const abi = signatureAbiMap[signature];

if (abi) {
return abi;
}

return null;
}),
}),
);
AbiStore,
AbiStore.of({
strategies: { default: [] },
set: ({ address = {}, func = {}, event = {} }) =>
Effect.sync(() => {
// NOTE: Ignore caching as we relay only on local abis
}),
get: ({ address, signature, event }) =>
Effect.sync(() => {
const signatureAbiMap = {
'0x3593564c': 'execute(bytes,bytes[],uint256)',
'0x0902f1ac': 'getReserves()',
'0x36c78516': 'transferFrom(address,address,uint160,address) ',
'0x70a08231': 'balanceOf(address)',
'0x022c0d9f': 'swap(uint256,uint256,address,bytes)',
'0x2e1a7d4d': 'withdraw(uint256)',
}

const abi = signatureAbiMap[signature]

if (abi) {
return abi
}

return null
}),
}),
)
```

3. Create the ContractMetaStore
@@ -75,6 +73,7 @@ Similarly to AbiStore, but returns all the contract meta data
export const MetaStoreLive = Layer.succeed(
ContractMetaStore,
ContractMetaStore.of({
strategies: { default: [] },
get: ({ address, chainID }) => Effect.sync(() => {
return {
address: request.address,
@@ -95,37 +94,29 @@ export const MetaStoreLive = Layer.succeed(
4. Create a context using the services we created above
```ts
const LoadersLayer = Layer.provideMerge(AbiStoreLive, MetaStoreLive);
const LoadersLayer = Layer.provideMerge(AbiStoreLive, MetaStoreLive)
const PublicClientLive = Layer.succeed(
PublicClient,
PublicClient.of({ _tag: "PublicClient", getPublicClient: getPublicClient }),
);
PublicClient,
PublicClient.of({ _tag: 'PublicClient', getPublicClient: getPublicClient }),
)

const MainLayer = Layer.provideMerge(PublicClientLive, LoadersLayer);
const MainLayer = Layer.provideMerge(PublicClientLive, LoadersLayer)
```
5. Fetch and decode a transaction
```ts
const program = Effect.gen(function* (_) {
const hash =
"0xab701677e5003fa029164554b81e01bede20b97eda0e2595acda81acf5628f75";
const chainID = 5;
const hash = '0xab701677e5003fa029164554b81e01bede20b97eda0e2595acda81acf5628f75'
const chainID = 5

return yield* _(decodeTransactionByHash(hash, chainID));
});
return yield* _(decodeTransactionByHash(hash, chainID))
})
```
6. Finally provide the context and run the program
```ts
const customRuntime = pipe(
Layer.toRuntime(MainLayer),
Effect.scoped,
Effect.runSync,
);
const result = await program.pipe(
Effect.provideSomeRuntime(customRuntime),
Effect.runPromise,
);
const customRuntime = pipe(Layer.toRuntime(MainLayer), Effect.scoped, Effect.runSync)
const result = await program.pipe(Effect.provideSomeRuntime(customRuntime), Effect.runPromise)
```
108 changes: 0 additions & 108 deletions apps/docs/src/content/docs/guides/erc20-contract-meta.md

This file was deleted.

40 changes: 19 additions & 21 deletions apps/docs/src/content/docs/index.mdx
Original file line number Diff line number Diff line change
@@ -3,29 +3,27 @@
description: A library to transform any EVM (Ethereum Virtual Machine) transaction into a human-readable format
template: splash
hero:
tagline: A library to transform any EVM transaction into a human-readable format
image:
file: ../../assets/tmp-image.png
actions:
- text: Getting Started
link: /getting-started
icon: right-arrow
variant: primary
- text: See our Playground
link: https://loop-decoder-web.vercel.app/
icon: external
tagline: A library to transform any EVM transaction into a human-readable format
image:
file: ../../assets/tmp-image.png
actions:
- text: Getting Started
link: /getting-started
icon: right-arrow
variant: primary
- text: See our Playground
link: https://loop-decoder-web.vercel.app/
icon: external
---

import { Card, CardGrid } from "@astrojs/starlight/components";
import { Card, CardGrid } from '@astrojs/starlight/components'

Check warning on line 19 in apps/docs/src/content/docs/index.mdx

GitHub Actions / pr

'Card' is defined but never used

Check warning on line 19 in apps/docs/src/content/docs/index.mdx

GitHub Actions / pr

'CardGrid' is defined but never used

<CardGrid stagger>
<Card title="Transaction Decoder" icon="rocket">
Decode any EVM transaction into a human-readable format using TypeScript.
Loop Decoder does not enforce any infrastructure requirements, so you can
use it in any environment.
</Card>
<Card title="Data Loaders" icon="add-document">
Loop Decoder provides a set of data loaders to simplify resolution of ABIs
and other data required for decoding.
</Card>
<Card title="Transaction Decoder" icon="rocket">
Decode any EVM transaction into a human-readable format using TypeScript. Loop Decoder does not enforce any
infrastructure requirements, so you can use it in any environment.
</Card>
<Card title="Data Loaders" icon="add-document">
Loop Decoder provides a set of data loaders to simplify resolution of ABIs and other data required for decoding.
</Card>
</CardGrid>
12 changes: 6 additions & 6 deletions apps/docs/src/content/docs/reference/abi-loaders.md
Original file line number Diff line number Diff line change
@@ -9,10 +9,10 @@ description: ABI Data Loaders Strategies used to fetch ABI data from third-party

Loop Decoder provides some strategies out of the box:

- `EtherscanStrategyResolver` - resolves the ABI from Etherscan
- `SourcifyStrategyResolver` - resolves the ABI from Sourcify
- `FourByteStrategyResolver` - resolves the ABI from 4byte.directory
- `OpenchainStrategyResolver` - resolves the ABI from Openchain
- `BlockscoutStrategyResolver` - resolves the ABI from Blockscout
- `EtherscanStrategyResolver` - resolves the ABI from Etherscan
- `SourcifyStrategyResolver` - resolves the ABI from Sourcify
- `FourByteStrategyResolver` - resolves the ABI from 4byte.directory
- `OpenchainStrategyResolver` - resolves the ABI from Openchain
- `BlockscoutStrategyResolver` - resolves the ABI from Blockscout

You can create your own strategy by implementing the `GetContractABIStrategy` Effect RequestResolver.
You can create your own strategy by implementing the `GetContractABIStrategy` Effect RequestResolver.
4 changes: 4 additions & 0 deletions apps/web/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"tabWidth": 2,
"trailingComma": "all"
}
63 changes: 34 additions & 29 deletions apps/web/src/lib/contract-loader.ts
Original file line number Diff line number Diff line change
@@ -4,12 +4,15 @@ import {
ContractMetaStore,
EtherscanStrategyResolver,
SourcifyStrategyResolver,
FourByteStrategyResolver,
OpenchainStrategyResolver,
BlockscoutStrategyResolver,
PublicClient,
ERC20RPCStrategyResolver,
} from "@3loop/transaction-decoder";
import { Effect, Layer } from "effect";
import { fetchAndCacheErc20Meta } from "./contract-meta";
import prisma from "./prisma";
import { NFTRPCStrategyResolver } from "@3loop/transaction-decoder";

export const AbiStoreLive = Layer.succeed(
AbiStore,
@@ -20,6 +23,8 @@ export const AbiStoreLive = Layer.succeed(
apikey: process.env.ETHERSCAN_API_KEY,
}),
SourcifyStrategyResolver(),
OpenchainStrategyResolver(),
FourByteStrategyResolver(),
],
169: [
BlockscoutStrategyResolver({
@@ -80,47 +85,47 @@ export const AbiStoreLive = Layer.succeed(
export const ContractMetaStoreLive = Layer.effect(
ContractMetaStore,
Effect.gen(function* (_) {
const rpcProvider = yield* _(PublicClient);
const publicClient = yield* _(PublicClient);
const erc20Loader = ERC20RPCStrategyResolver(publicClient);
const nftLoader = NFTRPCStrategyResolver(publicClient);

return ContractMetaStore.of({
strategies: {
default: [erc20Loader, nftLoader],
},
get: ({ address, chainID }) =>
Effect.gen(function* (_) {
const normAddress = address.toLowerCase();
const data = yield* _(
Effect.tryPromise(
() =>
prisma.contractMeta.findFirst({
where: {
address: normAddress,
chainID: chainID,
},
}) as Promise<ContractData | null>,
).pipe(Effect.catchAll((_) => Effect.succeed(null))),
);

return data;
}),
set: ({ address, chainID }, contractMeta) =>
Effect.gen(function* (_) {
const normAddress = address.toLowerCase();

yield* _(
Effect.tryPromise(() =>
prisma.contractMeta.findFirst({
where: {
prisma.contractMeta.create({
data: {
...contractMeta,
decimals: contractMeta.decimals ?? 0,
address: normAddress,
chainID: chainID,
},
}),
).pipe(Effect.catchAll((_) => Effect.succeed(null))),
);

if (data != null) {
return data as ContractData;
}

const tryERC20 = yield* _(
fetchAndCacheErc20Meta({
contractAddress: normAddress,
chainID,
}).pipe(
Effect.provideService(PublicClient, rpcProvider),
Effect.catchAll((_) => Effect.succeed(null)),
),
);

if (tryERC20 != null) {
return tryERC20;
}

return null;
}),
set: () =>
Effect.sync(() => {
console.error("Set not implemented for ContractMetaStoreLive");
return null;
}),
});
}),
74 changes: 0 additions & 74 deletions apps/web/src/lib/contract-meta.ts

This file was deleted.

38 changes: 34 additions & 4 deletions packages/transaction-decoder/src/contract-meta-loader.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,57 @@
import { Context, Effect } from 'effect'
import { Context, Effect, RequestResolver } from 'effect'
import { ContractData } from './types.js'
import { GetContractMetaStrategy } from './meta-strategy/request-model.js'
import { Address } from 'viem'

export interface ContractMetaParams {
address: string
chainID: number
}

type ChainOrDefault = number | 'default'

// NOTE: Maybe we can avoid passing RPCProvider and let the user provide it?
export interface ContractMetaStore<Key = ContractMetaParams, Value = ContractData> {
readonly strategies: Record<ChainOrDefault, readonly RequestResolver.RequestResolver<GetContractMetaStrategy>[]>
readonly set: (arg: Key, value: Value) => Effect.Effect<never, never, void>
readonly get: (arg: Key) => Effect.Effect<never, never, Value | null>
}

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

export const getAndCacheContractMeta = ({ chainID, address }: { readonly chainID: number; readonly address: string }) =>
export const getAndCacheContractMeta = ({
chainID,
address,
}: {
readonly chainID: number
readonly address: Address
}) =>
Effect.gen(function* (_) {
const contractMetaStore = yield* _(ContractMetaStore)

const cached = yield* _(contractMetaStore.get({ address: address.toLowerCase(), chainID }))
if (cached != null) {
return cached
}
// TODO: Implement resolvers, we can auto resolve ERC20 and ERC721 contracts using RPC
// we could also use 3rd party apis to get contract metadata

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

const request = GetContractMetaStrategy({
address,
chainID,
})

const contractMeta = yield* _(
Effect.validateFirst(allAvailableStrategies, (strategy) => Effect.request(request, strategy)).pipe(
Effect.catchAll(() => Effect.succeed(null)),
),
)

if (contractMeta != null) {
yield* _(contractMetaStore.set({ address: address.toLowerCase(), chainID }, contractMeta))
return contractMeta
}

return null
})
4 changes: 2 additions & 2 deletions packages/transaction-decoder/src/decoding/log-decode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type GetTransactionReturnType, type Log, decodeEventLog, getAbiItem, type Abi } from 'viem'
import { type GetTransactionReturnType, type Log, decodeEventLog, getAbiItem, type Abi, getAddress } from 'viem'
import { Effect } from 'effect'
import type { DecodedLogEvent, Interaction, RawDecodedLog } from '../types.js'
import { getProxyStorageSlot } from './proxies.js'
@@ -96,7 +96,7 @@ const transformLog = (transaction: GetTransactionReturnType, log: RawDecodedLog)
const events = Object.fromEntries(log.events.map((param) => [param.name, param.value]))

// NOTE: Can use a common parser with branded type evrywhere
const address = log.address.toLowerCase()
const address = getAddress(log.address)

const contractData = yield* _(
getAndCacheContractMeta({
1 change: 1 addition & 0 deletions packages/transaction-decoder/src/effect.ts
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ export * from './abi-loader.js'
export * from './abi-strategy/index.js'
export * from './contract-meta-loader.js'
export * from './interpreters/index.js'
export * from './meta-strategy/index.js'
export * from './public-client.js'
export * from './transaction-decoder.js'
export * from './transaction-loader.js'
35 changes: 35 additions & 0 deletions packages/transaction-decoder/src/meta-strategy/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Hex } from 'viem'

// ERC-165: Standard Interface Detection
export const erc165Abi = [
{
inputs: [],
payable: false,
stateMutability: 'nonpayable',
type: 'constructor',
},
{
constant: true,
inputs: [
{
internalType: 'bytes4',
name: 'interfaceId',
type: 'bytes4',
},
],
name: 'supportsInterface',
outputs: [
{
internalType: 'bool',
name: '',
type: 'bool',
},
],
payable: false,
stateMutability: 'view',
type: 'function',
},
] as const

export const ERC1155InterfaceId: Hex = '0xd9b67a26'
export const ERC721InterfaceId: Hex = '0x80ac58cd'
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ContractData, ContractType } from '@/types.js'
import * as RequestModel from './request-model.js'
import { Effect, RequestResolver } from 'effect'
import { PublicClient } from '../public-client.js'
import { erc20Abi, getContract } from 'viem'

export const ERC20RPCStrategyResolver = (publicClientLive: PublicClient) =>
RequestResolver.fromEffect(({ chainID, address }: RequestModel.GetContractMetaStrategy) =>
Effect.gen(function* (_) {
const service = yield* _(PublicClient)
const { client } = yield* _(service.getPublicClient(chainID))

const inst = getContract({
abi: erc20Abi,
address,
client,
})

const fail = new RequestModel.ResolveStrategyMetaError('ERC20RPCStrategy', address, chainID)

const decimals = yield* _(
Effect.tryPromise({
try: () => inst.read.decimals(),
catch: () => fail,
}),
)

if (decimals == null) {
return yield* _(Effect.fail(fail))
}

const [symbol, name] = yield* _(
Effect.all(
[
Effect.tryPromise({ try: () => inst.read.symbol(), catch: () => fail }),
Effect.tryPromise({ try: () => inst.read.name(), catch: () => fail }),
],
{
concurrency: 'unbounded',
},
),
)

const meta: ContractData = {
address,
contractAddress: address,
contractName: name,
tokenSymbol: symbol,
decimals: Number(decimals),
type: 'ERC20' as ContractType,
chainID,
}

return meta
}),
).pipe(
RequestResolver.contextFromServices(PublicClient),
Effect.provideService(PublicClient, publicClientLive),
Effect.runSync,
)
3 changes: 3 additions & 0 deletions packages/transaction-decoder/src/meta-strategy/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './erc20-rpc-strategy.js'
export * from './nft-rpc-strategy.js'
export * from './request-model.js'
83 changes: 83 additions & 0 deletions packages/transaction-decoder/src/meta-strategy/nft-rpc-strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ContractData, ContractType } from '@/types.js'
import * as RequestModel from './request-model.js'
import { Effect, RequestResolver } from 'effect'
import { PublicClient } from '../public-client.js'
import { erc721Abi, getContract } from 'viem'
import { ERC1155InterfaceId, ERC721InterfaceId, erc165Abi } from './constants.js'

export const NFTRPCStrategyResolver = (publicClientLive: PublicClient) =>
RequestResolver.fromEffect(({ chainID, address }: RequestModel.GetContractMetaStrategy) =>
Effect.gen(function* (_) {
const service = yield* _(PublicClient)
const { client } = yield* _(service.getPublicClient(chainID))

const inst = getContract({
abi: erc165Abi,
address,
client,
})

const fail = new RequestModel.ResolveStrategyMetaError('NFTRPCStrategy', address, chainID)

const [isERC721, isERC1155] = yield* _(
Effect.all(
[
Effect.tryPromise({
try: () => inst.read.supportsInterface([ERC721InterfaceId]),
catch: () => fail,
}),
Effect.tryPromise({
try: () => inst.read.supportsInterface([ERC1155InterfaceId]),
catch: () => fail,
}),
],
{
concurrency: 'unbounded',
},
),
)

if (!isERC721 && !isERC1155) return yield* _(Effect.fail(fail))

const erc721inst = getContract({
abi: erc721Abi,
address,
client,
})

const [name, symbol] = yield* _(
Effect.all(
[
Effect.tryPromise({
try: () => erc721inst.read.name(),
catch: () => fail,
}),
Effect.tryPromise({
try: () => erc721inst.read.symbol(),
catch: () => fail,
}),
],
{
concurrency: 'unbounded',
},
),
)

const type: ContractType = isERC1155 ? 'ERC1155' : 'ERC721'

const meta: ContractData = {
address,
contractAddress: address,
contractName: name,
tokenSymbol: symbol,
type,
chainID,
}

return meta
}),
).pipe(
RequestResolver.contextFromServices(PublicClient),
Effect.provideService(PublicClient, publicClientLive),
Effect.runSync,
)
27 changes: 27 additions & 0 deletions packages/transaction-decoder/src/meta-strategy/request-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { UnknownNetwork } from '@/public-client.js'
import { ContractData } from '@/types.js'
import { Request } from 'effect'
import { Address } from 'viem'

export interface FetchMetaParams {
readonly chainID: number
readonly address: Address
}

export class ResolveStrategyMetaError {
readonly _tag = 'ResolveStrategyMetaError'
constructor(
readonly resolverName: string,
readonly address: Address,
readonly chain: number,
) {}
}

// TODO: Remove UnknownNetwork
export interface GetContractMetaStrategy
extends Request.Request<ResolveStrategyMetaError | UnknownNetwork, ContractData>,
FetchMetaParams {
readonly _tag: 'GetContractMetaStrategy'
}

export const GetContractMetaStrategy = Request.tagged<GetContractMetaStrategy>('GetContractMetaStrategy')
9 changes: 8 additions & 1 deletion packages/transaction-decoder/src/transaction-decoder.ts
Original file line number Diff line number Diff line change
@@ -78,7 +78,14 @@
const abi = JSON.parse(abi_) as Abi

// TODO: Pass the error message, so we can easier debug
const decoded = yield* _(Effect.try(() => AbiDecoder.decodeMethod(data, abi)))
const decoded = yield* _(
Effect.try({
try: () => AbiDecoder.decodeMethod(data, abi),
catch: (e) => {
return new AbiDecoder.DecodeError(e)
},
}),
)

if (decoded == null) {
return yield* _(Effect.fail(new AbiDecoder.DecodeError(`Failed to decode method: ${transaction.input}`)))
@@ -177,7 +184,7 @@

const interpreterMap = yield* _(
getAndCacheContractMeta({
address: receipt.to!,

Check warning on line 187 in packages/transaction-decoder/src/transaction-decoder.ts

GitHub Actions / pr

Forbidden non-null assertion
chainID: Number(transaction.chainId),
}),
)
12 changes: 10 additions & 2 deletions packages/transaction-decoder/src/vanilla.ts
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import { AbiStore as EffectAbiStore, GetAbiParams } from './abi-loader.js'
import { ContractMetaParams, ContractMetaStore as EffectContractMetaStore } from './contract-meta-loader.js'
import { ContractABI, GetContractABIStrategy } from './abi-strategy/index.js'
import { Hex } from 'viem'
import { GetContractMetaStrategy } from './meta-strategy/request-model.js'

export interface TransactionDecoderOptions {
getPublicClient: (chainID: number) => PublicClientObject | undefined
@@ -20,7 +21,10 @@ export interface VanillaAbiStore {
set: (val: ContractABI) => Promise<void>
}

type VanillaContractMetaStategy = (client: PublicClient) => RequestResolver.RequestResolver<GetContractMetaStrategy>

export interface VanillaContractMetaStore {
strategies?: readonly VanillaContractMetaStategy[]
get: (key: ContractMetaParams) => Promise<ContractData | null>
set: (key: ContractMetaParams, val: ContractData) => Promise<void>
}
@@ -32,6 +36,7 @@ export class TransactionDecoder {

constructor({ getPublicClient, abiStore, contractMetaStore, logging = false }: TransactionDecoderOptions) {
this.logging = logging

const PublicClientLive = PublicClient.of({
_tag: 'PublicClient',
getPublicClient: (chainID) => {
@@ -54,15 +59,18 @@ export class TransactionDecoder {
set: (val) => Effect.promise(() => abiStore.set(val)),
})

const MockedMetaStoreLive = EffectContractMetaStore.of({
const contractMetaStrategies = contractMetaStore.strategies?.map((strategy) => strategy(PublicClientLive))

const MetaStoreLive = EffectContractMetaStore.of({
strategies: { default: contractMetaStrategies ?? [] },
get: (key) => Effect.promise(() => contractMetaStore.get(key)),
set: (key, val) => Effect.promise(() => contractMetaStore.set(key, val)),
})

this.context = Context.empty().pipe(
Context.add(PublicClient, PublicClientLive),
Context.add(EffectAbiStore, AbiStoreLive),
Context.add(EffectContractMetaStore, MockedMetaStoreLive),
Context.add(EffectContractMetaStore, MetaStoreLive),
)
}

2 changes: 2 additions & 0 deletions packages/transaction-decoder/test/vanilla.test.ts
Original file line number Diff line number Diff line change
@@ -4,6 +4,7 @@ import { TransactionDecoder } from '@/vanilla.js'
import fs from 'fs'
import { createPublicClient } from 'viem'
import { goerli } from 'viem/chains'
import { ERC20RPCStrategyResolver } from '@/effect.js'

describe('Transaction Decoder', () => {
test('should be able to decode using vanilla API', async () => {
@@ -46,6 +47,7 @@ describe('Transaction Decoder', () => {
},
},
contractMetaStore: {
strategies: [ERC20RPCStrategyResolver],
get: async (request) => {
if ('0xb4fbf271143f4fbf7b91a5ded31805e42b2208d6' === request.address.toLowerCase()) {
return {