diff --git a/.changeset/famous-mayflies-switch.md b/.changeset/famous-mayflies-switch.md new file mode 100644 index 0000000..570ec02 --- /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 42d1ed5..7cd5e5c 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 c49db7e..5049356 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 22a1505..4d457c1 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 0000000..e9d0174 --- /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 82058f5..37aeb2a 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 f043fb4..49db6ea 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 8820726..28029ae 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 eb480e5..6fdff24 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 4b9c5bf..c697194 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 b8028fb..0fe4a9b 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 402bcd8..0000000 --- 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 9cc7d04..6448b02 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 6986ea8..1abe266 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 0000000..45615ba --- /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 7d4ecc3..2698a3d 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 bcd387a..0000000 --- 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 84b9012..b4247b3 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 add74d0..41effe7 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 bb8432b..feb0a27 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 0000000..090a054 --- /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 0000000..744efd0 --- /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 0000000..3f924bd --- /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 0000000..c8ccaa9 --- /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 0000000..a472a48 --- /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 9af24a0..5712bf6 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 9d02824..376b180 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 ae9a943..48e6c4b 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 {