diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d945f8..b4e51112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.12.4] - 2026-05-08 + +- Improve efficiency when fetching ENS data. +- Upgrade dependencies to keep maintenance with latest releases. + ## [3.12.3] - 2025-12-12 - Apply security patches to mitigate published CVE-2025-55183 and CVE-2025-55184 @@ -455,7 +460,8 @@ Staking Pools - First release -[unreleased]: https://github.com/cartesi/explorer/compare/v3.12.3...HEAD +[unreleased]: https://github.com/cartesi/explorer/compare/v3.12.4...HEAD +[3.12.4]: https://github.com/cartesi/explorer/compare/v3.12.4...v3.12.3 [3.12.3]: https://github.com/cartesi/explorer/compare/v3.12.3...v3.12.2 [3.12.2]: https://github.com/cartesi/explorer/compare/v3.12.2...v3.12.1 [3.12.1]: https://github.com/cartesi/explorer/compare/v3.12.1...v3.12.0 diff --git a/__tests__/services/server/functions.test.ts b/__tests__/services/server/functions.test.ts index ff6c9d5f..54dafc44 100644 --- a/__tests__/services/server/functions.test.ts +++ b/__tests__/services/server/functions.test.ts @@ -9,7 +9,10 @@ // PARTICULAR PURPOSE. See the GNU General Public License for more details. import { ObservableQuery } from '@apollo/client'; import { ethers } from 'ethers'; -import { GetEnsDomainsQuery } from '../../../src/graphql/queries/ensDomains'; +import { + GetAliasedEnsDomainsQuery, + getDomainAlias, +} from '../../../src/graphql/queries/ensDomains'; import client from '../../../src/services/apolloENSClient'; import { getENSData, @@ -56,9 +59,8 @@ const createAvatarUrlResolver = (url: string) => { const buildENSResponse = () => [ { - createdAt: 1740987259, - id: '1', - name: 'my-pool', + name: 'my-pool.eth', + labelName: 'my-pool', resolvedAddress: { id: validAddress, }, @@ -66,9 +68,15 @@ const buildENSResponse = () => [ ]; const setENSQueryReturn = (list: QueriedDomain[]) => { + const response = list.reduce((acc, curr) => { + const alias = getDomainAlias(curr.resolvedAddress?.id ?? ''); + acc[alias] = [curr]; + return acc; + }, {} as GetAliasedEnsDomainsQuery); + clientMock.query.mockResolvedValue({ - data: { domains: list }, - } as ObservableQuery.Result); + data: response, + } as ObservableQuery.Result); }; const validAddress = '0x07b41c2b437e69dd1523bf1cff5de63ad9bb3dc6'; @@ -108,21 +116,12 @@ describe('ENS Functions', () => { }); it('should set the name when returned from ENS service', async () => { - setENSQueryReturn([ - { - createdAt: 1740987259, - id: '1', - name: 'my-pool', - resolvedAddress: { - id: validAddress, - }, - }, - ]); + setENSQueryReturn(buildENSResponse()); const { data: [expectedData], } = await getENSData([entry]); - expect(expectedData).toHaveProperty('name', 'my-pool'); + expect(expectedData).toHaveProperty('name', 'my-pool.eth'); expect(expectedData).toHaveProperty('hasEns', true); expect(expectedData).toHaveProperty('avatarUrl', ''); }); @@ -138,7 +137,7 @@ describe('ENS Functions', () => { data: [expectedData], } = await getENSData([entry]); - expect(expectedData).toHaveProperty('name', 'my-pool'); + expect(expectedData).toHaveProperty('name', 'my-pool.eth'); expect(expectedData).toHaveProperty('hasEns', true); expect(expectedData).toHaveProperty( 'avatarUrl', @@ -202,12 +201,12 @@ describe('ENS Functions', () => { address: validAddress, avatarUrl: null, hasEns: true, - name: 'my-pool', + name: 'my-pool.eth', }); expect(errorLog).toHaveBeenCalledTimes(1); expect(errorLog.mock.calls[0][0]).toEqual( - 'GET_AVATAR_URL: (my-pool) => Fail to get avatar.\nReason: no name found!' + 'GET_AVATAR_URL: (my-pool.eth) => Fail to get avatar.\nReason: no name found!' ); }); }); @@ -216,9 +215,7 @@ describe('ENS Functions', () => { it('should partition request when entries are above the limit', async () => { setENSQueryReturn([ { - createdAt: 1740987259, - id: '1', - name: 'my-pool', + name: 'my-pool.eth', resolvedAddress: { id: validAddress, }, @@ -241,7 +238,7 @@ describe('ENS Functions', () => { ...entry, avatarUrl: 'http://host.com/avatar.png', hasEns: true, - name: 'my-pool', + name: 'my-pool.eth', }; expect(expectedPayloads.length).toEqual(2); diff --git a/additional.d.ts b/additional.d.ts index 0ce885d5..ed212b82 100644 --- a/additional.d.ts +++ b/additional.d.ts @@ -55,6 +55,14 @@ declare namespace NodeJS { */ HTTP_MAINNET_NODE_RPC: string; + /** + * @summary Maximum number of concurrent calls when fetching ENS record data using the RPC node. + * @description This is to avoid overwhelming the RPC node with too many requests at once, + * which can lead to rate limiting or timeouts. + * @default 8 + */ + ENS_RESOLVER_RPC_CONCURRENT_CALLS: string; + /** * Maximum number of entries per request when fetching ENS information. It is configurable but default and maximum is 900 entries. * Therefore, the number here dictates the relation between entries-limit and concurrent calls to be created when this limit is exceeded. diff --git a/package.json b/package.json index de5a9e34..8d4b90cf 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@libsql/client": "^0.17.2", "@safe-global/safe-apps-provider": "^0.18.6", "@safe-global/safe-apps-sdk": "^9.1.0", + "@supercharge/promise-pool": "3.3.0", "@vercel/speed-insights": "^2.0.0", "@web3-onboard/coinbase": "^2.4.2", "@web3-onboard/common": "^2.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5966cdad..aa2a2c46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: '@safe-global/safe-apps-sdk': specifier: ^9.1.0 version: 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + '@supercharge/promise-pool': + specifier: 3.3.0 + version: 3.3.0 '@vercel/speed-insights': specifier: ^2.0.0 version: 2.0.0(next@15.5.9(@babel/core@7.28.4)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@3.55.0) @@ -2838,6 +2841,10 @@ packages: typescript: optional: true + '@supercharge/promise-pool@3.3.0': + resolution: {integrity: sha512-qGzCltMN05ohRRQAXB8TPWt+0Coz9Va266gr9nYN1qc/f/EIaup3VRg7b59mtRaeKTRcxreF20l/udCM/gOqNg==} + engines: {node: '>=8'} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -13608,6 +13615,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + '@supercharge/promise-pool@3.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 diff --git a/src/graphql/queries/ensDomains.ts b/src/graphql/queries/ensDomains.ts index 2ba93682..0929f7e6 100644 --- a/src/graphql/queries/ensDomains.ts +++ b/src/graphql/queries/ensDomains.ts @@ -11,38 +11,64 @@ import { gql } from '@apollo/client'; -export const DOMAINS = gql` - query getDomains( - $where: Domain_filter - $orderBy: Domain_orderBy - $orderDirection: OrderDirection - $first: Int - ) { - domains( - where: $where - orderBy: $orderBy - orderDirection: $orderDirection - first: $first - ) { - id - name - labelName - createdAt - resolvedAddress { - id - } +export type EnsDomain = { + __typename?: 'Domain'; + name?: string | null; + labelName?: string | null; + resolvedAddress?: { __typename?: 'Account'; id: string } | null; +}; + +export type DomainAlias = `addr${string}`; + +export type GetAliasedEnsDomainsQuery = Record; + +/** + * + * @summary Generates an alias for a given address to be used in the aliased GraphQL query. + * @description + * The alias is generated by prefixing the address with "addr" and replacing any non-alphanumeric characters with underscores. + * This ensures that the alias is a valid GraphQL field name. + * @param address + * @returns + */ +export const getDomainAlias = (address: string): DomainAlias => + `addr${address.toLowerCase().replace(/[^a-z0-9_]/g, '_')}`; + +/** + * @summary Dynamically generates aliased GraphQL query to fetch the latest ENS domain per Address passed. + * @description One address may have multiple domain entries through time. + * Due to constraints in response size it may cause domains to not be included in the response. + * Nonetheless, we want only the latest domain name. + * By aliasing the query there is a perceptible improvement in response time as the database query + * is more efficient due to not using the "in" operator and instead using multiple "where" clauses with equality checks. + * + * @param addresses + * @returns + */ +export const buildAliasedEnsDomainsQuery = (addresses: string[]) => { + const fields = addresses + .map((address) => { + const alias = getDomainAlias(address); + return ` + ${alias}: domains( + first: 1 + where: { resolvedAddress: "${address}" } + orderBy: createdAt + orderDirection: desc + ) { + labelName + name + resolvedAddress { + id + } + } + `; + }) + .join('\n'); + + return gql` + query getDomains { + ${fields} } - } -`; - -export type GetEnsDomainsQuery = { - __typename?: 'Query'; - domains: Array<{ - __typename?: 'Domain'; - id: string; - name?: string | null; - labelName?: string | null; - createdAt: any; - resolvedAddress?: { __typename?: 'Account'; id: string } | null; - }>; + `; }; diff --git a/src/services/server/ens/functions.ts b/src/services/server/ens/functions.ts index 1c28339c..99510bd4 100644 --- a/src/services/server/ens/functions.ts +++ b/src/services/server/ens/functions.ts @@ -9,15 +9,16 @@ // WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A // PARTICULAR PURPOSE. See the GNU General Public License for more details. +import { PromisePool } from '@supercharge/promise-pool'; import { ethers } from 'ethers'; +import defaultTo from 'lodash/fp/defaultTo'; import { - DOMAINS, - GetEnsDomainsQuery, + buildAliasedEnsDomainsQuery, + GetAliasedEnsDomainsQuery, } from '../../../graphql/queries/ensDomains'; import { Network } from '../../../utils/networks'; import ensClient from '../../apolloENSClient'; import { ENSAddressData, Entry, QueriedDomain, StaleEntry } from './types'; -import defaultTo from 'lodash/fp/defaultTo'; type GetDomainsResult = Record; type PayloadState = 'ok' | 'ens_query_failed'; @@ -26,15 +27,47 @@ type ENSPayload = { data: ENSAddressData[]; }; +const defaultMaxAvatarRpcConcurrentCalls = 8 as const; const defaultMaxEntriesPerReqLimit = 900 as const; -const MAX_ENTRIES_PER_REQ = Math.min( + +/** + * @summary Maximum number of concurrent promises to be processed in the pool. + * @description This is to avoid overwhelming the RPC node with too many requests at once, + * which can lead to rate limiting or timeouts. + * The value can be adjusted through the + * environment variable `ENS_RESOLVER_RPC_CONCURRENT_CALLS`. + * @default 8 + */ +const RESOLVER_POOL_SIZE = defaultTo( + defaultMaxAvatarRpcConcurrentCalls, + process.env.ENS_RESOLVER_RPC_CONCURRENT_CALLS + ? parseInt(process.env.ENS_RESOLVER_RPC_CONCURRENT_CALLS, 10) + : undefined +); + +/** + * @summary Maximum number of entries to be included in a single request to fetch ENS data. + * + * @default 900 + */ +const MAX_ENTRIES_PER_REQ = defaultTo( defaultMaxEntriesPerReqLimit, - defaultTo( - defaultMaxEntriesPerReqLimit, - parseInt(process.env.ENS_ENTRIES_PER_REQ_LIMIT ?? '') - ) + process.env.ENS_ENTRIES_PER_REQ_LIMIT + ? parseInt(process.env.ENS_ENTRIES_PER_REQ_LIMIT, 10) + : undefined ); +const createCounter = (initial = 0) => { + let value = initial; + return { + next: () => ++value, + }; +}; + +const addAvatarUrlCounter = createCounter(); +const addEnsNameCounter = createCounter(); +const getDomainsCounter = createCounter(); + const httpNodeRpc = process.env.HTTP_MAINNET_NODE_RPC ?? 'https://cloudflare-eth.com'; @@ -78,58 +111,63 @@ const addAvatarUrl = async (ensPayload: ENSPayload): Promise => { // skip any L1 resolver checks, as the primary query failed. if (ensPayload.state === 'ens_query_failed') return ensPayload; - const timeLabel = `${ACTION_NAME.addAvatarUrl}(${ensPayload.data.length})`; + const timeLabel = `${ + ACTION_NAME.addAvatarUrl + }(${addAvatarUrlCounter.next()})`; + console.time(timeLabel); - const listP = ensPayload.data.map((ensAddressData) => { - if ( - !ensAddressData.hasEns || - (ensAddressData.hasEns && !ensAddressData.name) - ) { - return Promise.resolve(ensAddressData); - } - - return getAvatarUrl(ensAddressData.name) - .then((ensAvatar) => { - console.info( - `${ACTION_NAME.addAvatarUrl}: (${ensAddressData.name}) => avatar(${ensAvatar})` - ); - ensAddressData.avatarUrl = ensAvatar; - return ensAddressData; - }) - .catch((reason: any) => { - console.error( - `${ACTION_NAME.addAvatarUrl}: (Errored) ${ensAddressData.address} - reason (${reason.message})` - ); - return ensAddressData; - }); - }); - const data = await Promise.all(listP); + const { results } = await PromisePool.withConcurrency(RESOLVER_POOL_SIZE) + .for(ensPayload.data) + .process((ensAddressData) => { + if ( + !ensAddressData.hasEns || + (ensAddressData.hasEns && !ensAddressData.name) + ) { + return Promise.resolve(ensAddressData); + } + + return getAvatarUrl(ensAddressData.name) + .then((ensAvatar) => { + console.info( + `${ACTION_NAME.addAvatarUrl}: (${ensAddressData.name}) => avatar(${ensAvatar})` + ); + ensAddressData.avatarUrl = ensAvatar; + return ensAddressData; + }) + .catch((reason: any) => { + console.error( + `${ACTION_NAME.addAvatarUrl}: (Errored) ${ensAddressData.address} - reason (${reason.message})` + ); + return ensAddressData; + }); + }); + console.timeEnd(timeLabel); - return { state: 'ok', data }; + return { state: 'ok', data: results }; }; const getDomains = async (addresses: string[]): Promise => { - const timeLabel = `${ACTION_NAME.getDomains}(${addresses.length})`; + const timeLabel = `${ACTION_NAME.getDomains}(${getDomainsCounter.next()})`; console.time(timeLabel); - const result = await ensClient.query({ - query: DOMAINS, - variables: { - first: addresses.length, - where: { resolvedAddress_in: addresses }, - orderBy: 'createdAt', - orderDirection: 'asc', - }, + + const normalizedAddresses = addresses.map((address) => + address.toLowerCase() + ); + + const result = await ensClient.query({ + query: buildAliasedEnsDomainsQuery(normalizedAddresses), }); - const domains = result.data.domains ?? []; - const domainsByAddress = domains.reduce((prev, curr) => { - const address = curr.resolvedAddress?.id ?? ''; - return { - ...prev, - [address]: curr, - }; + const domains = Object.values(result.data ?? {}).flat(); + + const domainsByAddress = domains.reduce((acc, curr) => { + const address = curr.resolvedAddress?.id.toLowerCase() ?? ''; + + if (address) acc[address] = curr; + + return acc; }, {} as GetDomainsResult); console.info( @@ -147,7 +185,7 @@ const getDomains = async (addresses: string[]): Promise => { const addENSName = async (entries: Entry[]): Promise => { if (!entries || (entries && entries.length === 0)) return { state: 'ok', data: [] }; - const timeLabel = `${ACTION_NAME.addENSName}(${entries.length})`; + const timeLabel = `${ACTION_NAME.addENSName}(${addEnsNameCounter.next()})`; console.time(timeLabel); let state: PayloadState = 'ok'; diff --git a/src/services/server/ens/types.ts b/src/services/server/ens/types.ts index 96e15385..79c75a6b 100644 --- a/src/services/server/ens/types.ts +++ b/src/services/server/ens/types.ts @@ -9,7 +9,10 @@ // WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A // PARTICULAR PURPOSE. See the GNU General Public License for more details. -import { GetEnsDomainsQuery } from '../../../graphql/queries/ensDomains'; +import { + DomainAlias, + GetAliasedEnsDomainsQuery, +} from '../../../graphql/queries/ensDomains'; import AddressENSRepository from './AddressENSRepository'; export type AddressEns = { @@ -25,7 +28,8 @@ export type Entry = { address: string; }; -export type QueriedDomain = GetEnsDomainsQuery['domains'][number]; +export type QueriedDomain = GetAliasedEnsDomainsQuery[DomainAlias][number]; + export type ENSAddressData = { id?: number; hasEns: boolean;