diff --git a/.changeset/famous-mayflies-switch.md b/.changeset/famous-mayflies-switch.md new file mode 100644 index 00000000..570ec02b --- /dev/null +++ b/.changeset/famous-mayflies-switch.md @@ -0,0 +1,5 @@ +--- +'@3loop/transaction-decoder': minor +--- + +Add automatic resolvers for contract metadata diff --git a/.changeset/hungry-kings-applaud.md b/.changeset/hungry-kings-applaud.md index 42d1ed53..7cd5e5c1 100644 --- a/.changeset/hungry-kings-applaud.md +++ b/.changeset/hungry-kings-applaud.md @@ -1,5 +1,5 @@ --- -"@3loop/transaction-decoder": minor +'@3loop/transaction-decoder': minor --- Change interpretation from jsonata to js code using QuickJS diff --git a/packages/transaction-decoder/.prettierrc.json b/.prettierrc.json similarity index 98% rename from packages/transaction-decoder/.prettierrc.json rename to .prettierrc.json index c49db7e3..50493561 100644 --- a/packages/transaction-decoder/.prettierrc.json +++ b/.prettierrc.json @@ -4,4 +4,4 @@ "printWidth": 120, "singleQuote": true, "trailingComma": "all" -} \ No newline at end of file +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 22a15055..4d457c1a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,4 +1,8 @@ { - "recommendations": ["astro-build.astro-vscode"], + "recommendations": [ + "astro-build.astro-vscode", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ], "unwantedRecommendations": [] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e9d0174d --- /dev/null +++ b/.vscode/settings.json @@ -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 +} diff --git a/README.md b/README.md index 82058f52..37aeb2a7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/docs/src/content/config.ts b/apps/docs/src/content/config.ts index f043fb40..49db6ead 100644 --- a/apps/docs/src/content/config.ts +++ b/apps/docs/src/content/config.ts @@ -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() }), +} diff --git a/apps/docs/src/content/docs/contribution.md b/apps/docs/src/content/docs/contribution.md index 8820726e..28029ae1 100644 --- a/apps/docs/src/content/docs/contribution.md +++ b/apps/docs/src/content/docs/contribution.md @@ -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. diff --git a/apps/docs/src/content/docs/getting-started.md b/apps/docs/src/content/docs/getting-started.md index eb480e58..6fdff248 100644 --- a/apps/docs/src/content/docs/getting-started.md +++ b/apps/docs/src/content/docs/getting-started.md @@ -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,7 +48,7 @@ const contractMetaStore = { address: string chainID: number }) { - // NOTE: not yet called as we do not have any automatic resolve strategy implemented + // NOTE: ignore for now }, } ``` @@ -56,36 +56,33 @@ const contractMetaStore = { 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; - signature?: Record; - }) => { - 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; signature?: Record }) => { + 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...', +}) ``` diff --git a/apps/docs/src/content/docs/guides/decode-transaction.md b/apps/docs/src/content/docs/guides/decode-transaction.md index 4b9c5bfb..c697194b 100644 --- a/apps/docs/src/content/docs/guides/decode-transaction.md +++ b/apps/docs/src/content/docs/guides/decode-transaction.md @@ -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,34 +94,31 @@ 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(); +import { EtherscanStrategyResolver } from '@3loop/transaction-decoder' +const abiCache = new Map() 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; - signature?: Record; - }) => { - 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; signature?: Record }) => { + const addresses = Object.keys(req.address ?? {}) + addresses.forEach((address) => { + abiCache.set(address, req.address?.[address] ?? '') + }) + }, +} ``` ### Contract Metadata loader @@ -129,29 +126,30 @@ const abiStore = { 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(); +import type { ContractData } from '@3loop/transaction-decoder' +const contractMeta = new Map() 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() ``` diff --git a/apps/docs/src/content/docs/guides/effect-api.md b/apps/docs/src/content/docs/guides/effect-api.md index b8028fb9..0fe4a9b5 100644 --- a/apps/docs/src/content/docs/guides/effect-api.md +++ b/apps/docs/src/content/docs/guides/effect-api.md @@ -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 => { - 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 => { + 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) ``` diff --git a/apps/docs/src/content/docs/guides/erc20-contract-meta.md b/apps/docs/src/content/docs/guides/erc20-contract-meta.md deleted file mode 100644 index 402bcd85..00000000 --- a/apps/docs/src/content/docs/guides/erc20-contract-meta.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -title: ERC20 Contract Metadata -description: On this page you will provide a step-by-step guide on how to fetch ERC20 contract metadata to use with Loop Decoder. ---- - -## Code - -Fetch ERC20 contract metadata using Effect API - -```ts -import { Effect, Layer } from "effect"; -import { - ContractData, - ContractType, - PublicClient, - ContractMetaStore, -} from "@3loop/transaction-decoder"; -import { erc20Abi, getAddress, getContract } from "viem"; - -export const fetchAndCacheErc20Meta = ({ - contractAddress, - chainID, -}: { - contractAddress: string; - chainID: number; -}) => - Effect.gen(function* (_) { - const service = yield* _(PublicClient); - const { client } = yield* _(service.getPublicClient(chainID)); - - const inst = yield* _( - Effect.sync(() => - getContract({ - address: getAddress(contractAddress), - abi: erc20Abi, - client, - }), - ), - ); - - const name = yield* _( - Effect.tryPromise(() => inst.read.name() as Promise), - ); - - if (name == null) { - return null; - } - - const [symbol, decimals] = yield* _( - Effect.all( - [ - Effect.tryPromise(() => inst.read.symbol() as Promise), - Effect.tryPromise(() => inst.read.decimals() as Promise), - ], - { - concurrency: "unbounded", - }, - ), - ); - - if (symbol == null || decimals == null) { - return null; - } - - const meta: ContractData = { - address: contractAddress, - contractAddress, - contractName: name, - tokenSymbol: symbol, - decimals: Number(decimals), - type: "ERC20" as ContractType, - chainID, - }; - - return meta; - }); - -export const ContractMetaStoreLive = Layer.effect( - ContractMetaStore, - Effect.gen(function* (_) { - const rpcProvider = yield* _(RPCProvider); - - return ContractMetaStore.of({ - get: ({ address, chainID }) => - Effect.gen(function* (_) { - const normAddress = address.toLowerCase(); - - const tryERC20 = yield* _( - fetchAndCacheErc20Meta({ - contractAddress: normAddress, - chainID, - }).pipe( - Effect.provideService(RPCProvider, rpcProvider), - Effect.catchAll((_) => Effect.succeed(null)), - ), - ); - - if (tryERC20 != null) { - return tryERC20; - } - - return null; - }), - set: () => Effect.sync(() => null), - }); - }), -); -``` diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 9cc7d04b..6448b027 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -3,29 +3,27 @@ title: Loop Decoder 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' - - 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. - - - Loop Decoder provides a set of data loaders to simplify resolution of ABIs - and other data required for decoding. - + + 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. + + + Loop Decoder provides a set of data loaders to simplify resolution of ABIs and other data required for decoding. + diff --git a/apps/docs/src/content/docs/reference/abi-loaders.md b/apps/docs/src/content/docs/reference/abi-loaders.md index 6986ea8c..1abe266c 100644 --- a/apps/docs/src/content/docs/reference/abi-loaders.md +++ b/apps/docs/src/content/docs/reference/abi-loaders.md @@ -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. diff --git a/apps/web/.prettierrc.json b/apps/web/.prettierrc.json new file mode 100644 index 00000000..45615ba8 --- /dev/null +++ b/apps/web/.prettierrc.json @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "trailingComma": "all" +} diff --git a/apps/web/src/lib/contract-loader.ts b/apps/web/src/lib/contract-loader.ts index 7d4ecc38..2698a3d4 100644 --- a/apps/web/src/lib/contract-loader.ts +++ b/apps/web/src/lib/contract-loader.ts @@ -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, + ).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; }), }); }), diff --git a/apps/web/src/lib/contract-meta.ts b/apps/web/src/lib/contract-meta.ts deleted file mode 100644 index bcd387ab..00000000 --- a/apps/web/src/lib/contract-meta.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Effect } from "effect"; -import { - ContractData, - ContractType, - PublicClient, -} from "@3loop/transaction-decoder"; -import { erc20Abi, getAddress, getContract } from "viem"; - -export const fetchAndCacheErc20Meta = ({ - contractAddress, - chainID, -}: { - contractAddress: string; - chainID: number; -}) => - Effect.gen(function* (_) { - const service = yield* _(PublicClient); - const { client } = yield* _(service.getPublicClient(chainID)); - - const inst = yield* _( - Effect.sync(() => - getContract({ - address: getAddress(contractAddress), - abi: erc20Abi, - client, - }), - ), - ); - - const name = yield* _( - Effect.tryPromise({ - try: () => inst.read.name() as Promise, - catch: () => null, - }), - ); - - if (name == null) { - return null; - } - - const [symbol, decimals] = yield* _( - Effect.all( - [ - Effect.tryPromise({ - try: () => inst.read.symbol() as Promise, - catch: () => null, - }), - Effect.tryPromise({ - try: () => inst.read.decimals() as Promise, - catch: () => null, - }), - ], - { - concurrency: "unbounded", - }, - ), - ); - - if (symbol == null || decimals == null) { - return null; - } - - const meta: ContractData = { - address: contractAddress, - contractAddress, - contractName: name, - tokenSymbol: symbol, - decimals: Number(decimals), - type: "ERC20" as ContractType, - chainID, - }; - - return meta; - }); diff --git a/packages/transaction-decoder/src/contract-meta-loader.ts b/packages/transaction-decoder/src/contract-meta-loader.ts index 84b90124..b4247b31 100644 --- a/packages/transaction-decoder/src/contract-meta-loader.ts +++ b/packages/transaction-decoder/src/contract-meta-loader.ts @@ -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 { + readonly strategies: Record[]> readonly set: (arg: Key, value: Value) => Effect.Effect readonly get: (arg: Key) => Effect.Effect } export const ContractMetaStore = Context.Tag('@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 }) diff --git a/packages/transaction-decoder/src/decoding/log-decode.ts b/packages/transaction-decoder/src/decoding/log-decode.ts index add74d00..41effe75 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 } 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({ diff --git a/packages/transaction-decoder/src/effect.ts b/packages/transaction-decoder/src/effect.ts index bb8432be..feb0a274 100644 --- a/packages/transaction-decoder/src/effect.ts +++ b/packages/transaction-decoder/src/effect.ts @@ -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' diff --git a/packages/transaction-decoder/src/meta-strategy/constants.ts b/packages/transaction-decoder/src/meta-strategy/constants.ts new file mode 100644 index 00000000..090a054f --- /dev/null +++ b/packages/transaction-decoder/src/meta-strategy/constants.ts @@ -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' diff --git a/packages/transaction-decoder/src/meta-strategy/erc20-rpc-strategy.ts b/packages/transaction-decoder/src/meta-strategy/erc20-rpc-strategy.ts new file mode 100644 index 00000000..744efd03 --- /dev/null +++ b/packages/transaction-decoder/src/meta-strategy/erc20-rpc-strategy.ts @@ -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, + ) diff --git a/packages/transaction-decoder/src/meta-strategy/index.ts b/packages/transaction-decoder/src/meta-strategy/index.ts new file mode 100644 index 00000000..3f924bd3 --- /dev/null +++ b/packages/transaction-decoder/src/meta-strategy/index.ts @@ -0,0 +1,3 @@ +export * from './erc20-rpc-strategy.js' +export * from './nft-rpc-strategy.js' +export * from './request-model.js' diff --git a/packages/transaction-decoder/src/meta-strategy/nft-rpc-strategy.ts b/packages/transaction-decoder/src/meta-strategy/nft-rpc-strategy.ts new file mode 100644 index 00000000..c8ccaa9e --- /dev/null +++ b/packages/transaction-decoder/src/meta-strategy/nft-rpc-strategy.ts @@ -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, + ) diff --git a/packages/transaction-decoder/src/meta-strategy/request-model.ts b/packages/transaction-decoder/src/meta-strategy/request-model.ts new file mode 100644 index 00000000..a472a48b --- /dev/null +++ b/packages/transaction-decoder/src/meta-strategy/request-model.ts @@ -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, + FetchMetaParams { + readonly _tag: 'GetContractMetaStrategy' +} + +export const GetContractMetaStrategy = Request.tagged('GetContractMetaStrategy') diff --git a/packages/transaction-decoder/src/transaction-decoder.ts b/packages/transaction-decoder/src/transaction-decoder.ts index 9af24a07..5712bf6b 100644 --- a/packages/transaction-decoder/src/transaction-decoder.ts +++ b/packages/transaction-decoder/src/transaction-decoder.ts @@ -78,7 +78,14 @@ export const decodeMethod = ({ transaction }: { transaction: GetTransactionRetur 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}`))) diff --git a/packages/transaction-decoder/src/vanilla.ts b/packages/transaction-decoder/src/vanilla.ts index 9d02824d..376b1803 100644 --- a/packages/transaction-decoder/src/vanilla.ts +++ b/packages/transaction-decoder/src/vanilla.ts @@ -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 } +type VanillaContractMetaStategy = (client: PublicClient) => RequestResolver.RequestResolver + export interface VanillaContractMetaStore { + strategies?: readonly VanillaContractMetaStategy[] get: (key: ContractMetaParams) => Promise set: (key: ContractMetaParams, val: ContractData) => Promise } @@ -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,7 +59,10 @@ 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)), }) @@ -62,7 +70,7 @@ export class TransactionDecoder { this.context = Context.empty().pipe( Context.add(PublicClient, PublicClientLive), Context.add(EffectAbiStore, AbiStoreLive), - Context.add(EffectContractMetaStore, MockedMetaStoreLive), + Context.add(EffectContractMetaStore, MetaStoreLive), ) } diff --git a/packages/transaction-decoder/test/vanilla.test.ts b/packages/transaction-decoder/test/vanilla.test.ts index ae9a943f..48e6c4b8 100644 --- a/packages/transaction-decoder/test/vanilla.test.ts +++ b/packages/transaction-decoder/test/vanilla.test.ts @@ -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 {