diff --git a/.changeset/famous-knives-flash.md b/.changeset/famous-knives-flash.md new file mode 100644 index 0000000..5143c36 --- /dev/null +++ b/.changeset/famous-knives-flash.md @@ -0,0 +1,5 @@ +--- +"@rabbitholegg/questdk": minor +--- + +Adds a client and helper functions for fetching metadata given either contract or boost information diff --git a/package.json b/package.json index df0713e..3f77378 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,8 @@ "dependencies": { "abitype": "^0.9.0", "rimraf": "^5.0.1", - "viem": "^1.15.1" + "viem": "2.7.9", + "axios": "1.6.7" }, "repository": "rabbitholegg/questdk", "publishConfig": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f2e272..a7a6d55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,15 @@ importers: abitype: specifier: ^0.9.0 version: 0.9.10(typescript@5.1.6) + axios: + specifier: 1.6.7 + version: 1.6.7 rimraf: specifier: ^5.0.1 version: 5.0.5 viem: - specifier: ^1.15.1 - version: 1.21.4(typescript@5.1.6) + specifier: 2.7.9 + version: 2.7.9(typescript@5.1.6) devDependencies: '@actions/core': specifier: ^1.10.0 @@ -1209,11 +1212,11 @@ packages: typescript: 5.1.6 dev: false - /abitype@0.9.8(typescript@5.1.6): - resolution: {integrity: sha512-puLifILdm+8sjyss4S+fsUN09obiT1g2YW6CtcQF+QDzxR0euzgEB29MZujC6zMk2a6SVmtttq1fc6+YFA7WYQ==} + /abitype@1.0.0(typescript@5.1.6): + resolution: {integrity: sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ==} peerDependencies: typescript: '>=5.0.4' - zod: ^3 >=3.19.1 + zod: ^3 >=3.22.0 peerDependenciesMeta: typescript: optional: true @@ -1363,11 +1366,25 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + dev: false + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} dev: true + /axios@1.6.7: + resolution: {integrity: sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==} + dependencies: + follow-redirects: 1.15.5 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1612,6 +1629,13 @@ packages: /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + dev: false + /commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} dev: true @@ -1736,6 +1760,11 @@ packages: object-keys: 1.1.1 dev: true + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dev: false + /deprecation@2.3.1: resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==} dev: true @@ -2068,6 +2097,16 @@ packages: pkg-dir: 4.2.0 dev: true + /follow-redirects@1.15.5: + resolution: {integrity: sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: @@ -2082,6 +2121,15 @@ packages: signal-exit: 4.1.0 dev: false + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: true @@ -2756,14 +2804,12 @@ packages: /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - dev: true /mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} dependencies: mime-db: 1.52.0 - dev: true /min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} @@ -3092,7 +3138,6 @@ packages: /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: true /pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} @@ -3919,8 +3964,8 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /viem@1.21.4(typescript@5.1.6): - resolution: {integrity: sha512-BNVYdSaUjeS2zKQgPs+49e5JKocfo60Ib2yiXOWBT6LuVxY1I/6fFX3waEtpXvL1Xn4qu+BVitVtMh9lyThyhQ==} + /viem@2.7.9(typescript@5.1.6): + resolution: {integrity: sha512-iDfc8TwaZFp1K95zlsxYh6Cs0OWCt35Tqs8uYgXKSxtz7w075mZ0H5SJ8zSyJGoEaticVDhtdmRRX6TtcW9EeQ==} peerDependencies: typescript: '>=5.0.4' peerDependenciesMeta: @@ -3932,7 +3977,7 @@ packages: '@noble/hashes': 1.3.2 '@scure/bip32': 1.3.2 '@scure/bip39': 1.2.1 - abitype: 0.9.8(typescript@5.1.6) + abitype: 1.0.0(typescript@5.1.6) isows: 1.0.3(ws@8.13.0) typescript: 5.1.6 ws: 8.13.0 diff --git a/src/client/ethereumClient.ts b/src/client/ethereumClient.ts new file mode 100644 index 0000000..9d830e4 --- /dev/null +++ b/src/client/ethereumClient.ts @@ -0,0 +1,38 @@ +import { + fetchERC721Media, + fetchERC721Metadata, + fetchERC721MetadataByUUID, +} from '../tokens/erc721.js' +import { + fetchERC1155Media, + fetchERC1155Metadata, + fetchERC1155MetadataByUUID, +} from '../tokens/erc1155.js' +import { type PublicClient, createClient, http } from 'viem' +/** + * Initializes an Ethereum client with the specified RPC URL and provides methods for ERC721 and ERC1155 token interactions. + * @param {string} rpcUrl - The RPC URL for connecting to the Ethereum network. + * @returns An object with methods for fetching token metadata and media. + */ +function createEthereumClient(rpcUrl: string) { + const client = createClient({ + transport: http(rpcUrl), + }) as PublicClient + + return { + fetchERC721Metadata: (contractAddress: string, tokenId: number) => + fetchERC721Metadata(client, contractAddress, tokenId), + fetchERC721Media: (contractAddress: string, tokenId: number) => + fetchERC721Media(client, contractAddress, tokenId), + fetchERC1155Metadata: (contractAddress: string, tokenId: number) => + fetchERC1155Metadata(client, contractAddress, tokenId), + fetchERC1155Media: (contractAddress: string, tokenId: number) => + fetchERC1155Media(client, contractAddress, tokenId), + fetchERC721MetadataByUUID: (uuid: string) => + fetchERC721MetadataByUUID(client, uuid), + fetchERC1155MetadataByUUID: (uuid: string) => + fetchERC1155MetadataByUUID(client, uuid), + } +} + +export default createEthereumClient diff --git a/src/filter/filters.ts b/src/filter/filters.ts index 2dec1a7..5b4f394 100644 --- a/src/filter/filters.ts +++ b/src/filter/filters.ts @@ -9,15 +9,16 @@ import type { TransactionFilter, } from './types.js' import { + type AbiFunction, type Address, type TransactionEIP1559, decodeAbiParameters, decodeFunctionData, getAbiItem, - getFunctionSelector, isAddress, parseAbiParameters, slice, + toFunctionSelector, } from 'viem' type OperatorKey = keyof typeof operators @@ -217,7 +218,7 @@ export const handleAbiDecode = (context: any, filter: AbiFilter) => { abi: filter.$abi, name: functionName, args, - }) + }) as AbiFunction const namedArgs = [...abiItem.inputs].reduce( (acc: Record, input, index) => { @@ -253,7 +254,7 @@ export const handleAbstractAbiDecode = ( for (let i = 0; i < elementCount; i++) { const abiItem = $abiAbstract![i] if (abiItem.type === 'function') { - const functionSelector = getFunctionSelector(abiItem) + const functionSelector = toFunctionSelector(abiItem) // We want to omit the leading 0x from the function selector const functionSelectorSubstring = functionSelector.substring(2) const index = contextMap.get(functionSelectorSubstring) diff --git a/src/quests/fetchQuestData.ts b/src/quests/fetchQuestData.ts new file mode 100644 index 0000000..b487f91 --- /dev/null +++ b/src/quests/fetchQuestData.ts @@ -0,0 +1,21 @@ +import axios from 'axios' + +/** + * Fetches quest data from RabbitHole API and extracts action parameters. + * @param {string} uuid The UUID of the quest. + * @returns {Promise} The action parameters extracted from the quest data. + */ +export async function fetchQuestActionParams(uuid: string): Promise { + const endpoint = `https://api.rabbithole.gg/v1.2/quest/public/${uuid}` + + try { + const response = await axios.get(endpoint) + const actionParams = response.data.actionParams + return actionParams + } catch (error) { + console.error('Error fetching quest data:', error) + throw new Error('Failed to fetch quest data') + } +} + +export default fetchQuestActionParams diff --git a/src/tokens/erc1155.ts b/src/tokens/erc1155.ts new file mode 100644 index 0000000..846471f --- /dev/null +++ b/src/tokens/erc1155.ts @@ -0,0 +1,65 @@ +import fetchQuestActionParams from '../quests/fetchQuestData.js' +import axios from 'axios' +import { type Address, type PublicClient } from 'viem' + +/** + * Fetches ERC1155 token metadata from a given contract. + * @async + * @param {PublicClient} client The Viem client instance. + * @param {string} contractAddress The ERC1155 contract address. + * @param {number} tokenId The token ID. + * @returns {Promise} The metadata of the ERC1155 token. + */ +export async function fetchERC1155Metadata( + client: PublicClient, + contractAddress: string, + tokenId: number, +): Promise { + const tokenURI: string = await (client.readContract({ + address: contractAddress as Address, + abi: [ + 'function uri(uint256 tokenId) external view returns (string memory)', + ], + functionName: 'uri', + args: [tokenId], + }) as Promise) + + const response = await axios.get(tokenURI) + return response.data +} + +/** + * Fetches the media URL from ERC1155 token metadata and performs basic validation or sanitization. + * @async + * @param {PublicClient} client - The Viem client instance. + * @param {string} contractAddress - The ERC1155 contract address. + * @param {number} tokenId - The token ID. + * @returns {Promise} - The media URL if available and valid. + */ +export async function fetchERC1155Media( + client: PublicClient, + contractAddress: string, + tokenId: number, +): Promise { + const metadata = await fetchERC1155Metadata(client, contractAddress, tokenId) + return metadata.image || metadata.animation_url +} + +/** + * Fetches ERC1155 token metadata using quest UUID from RabbitHole. + * @param {PublicClient} client The Viem client instance. + * @param {string} uuid The UUID of the quest. + * @returns {Promise} The metadata of the ERC1155 token associated with the quest. + */ +export async function fetchERC1155MetadataByUUID( + client: PublicClient, + uuid: string, +): Promise { + const actionParams = await fetchQuestActionParams(uuid) + if (actionParams.type !== 'mint') { + throw new Error('Quest action is not of type mint') + } + const contractAddress = actionParams.data.contractAddress + const tokenId = actionParams.data.tokenId + return fetchERC1155Metadata(client, contractAddress, tokenId) +} diff --git a/src/tokens/erc721.ts b/src/tokens/erc721.ts new file mode 100644 index 0000000..7d17566 --- /dev/null +++ b/src/tokens/erc721.ts @@ -0,0 +1,66 @@ +import fetchQuestActionParams from '../quests/fetchQuestData.js' +import axios from 'axios' +import { type Address, type PublicClient } from 'viem' + +/** + * Fetches ERC721 token metadata from a given contract. + * @async + * @param {any} client The Viem client instance. + * @param {string} contractAddress The ERC721 contract address. + * @param {number} tokenId The token ID. + * @returns {Promise} The metadata of the ERC721 token. + */ +export async function fetchERC721Metadata( + client: PublicClient, + contractAddress: string, + tokenId: number, +): Promise { + const tokenURI: string = await (client.readContract({ + address: contractAddress as Address, + abi: [ + 'function tokenURI(uint256 tokenId) external view returns (string memory)', + ], + functionName: 'tokenURI', + args: [tokenId], + }) as Promise) + + const response = await axios.get(tokenURI) + return response.data +} + +/** + * Fetches the media URL from ERC721 token metadata and performs basic validation or sanitization. + * @async + * @param {any} client - The Viem client instance. + * @param {string} contractAddress - The ERC721 contract address. + * @param {number} tokenId - The token ID. + * @returns {Promise} - The media URL if available and valid. + */ +export async function fetchERC721Media( + client: PublicClient, + contractAddress: string, + tokenId: number, +): Promise { + const metadata = await fetchERC721Metadata(client, contractAddress, tokenId) + // Here, you'd add logic to sanitize or validate the URL? + return metadata.image || metadata.animation_url +} + +/** + * Fetches ERC721 token metadata using quest UUID from RabbitHole. + * @param {any} client The Viem client instance. + * @param {string} uuid The UUID of the quest. + * @returns {Promise} The metadata of the ERC721 token associated with the quest. + */ +export async function fetchERC721MetadataByUUID( + client: any, + uuid: string, +): Promise { + const actionParams = await fetchQuestActionParams(uuid) + if (actionParams.type !== 'mint') { + throw new Error('Quest action is not of type mint') + } + const contractAddress = actionParams.data.contractAddress + const tokenId = actionParams.data.tokenId + return fetchERC721Metadata(client, contractAddress, tokenId) +}