From 8802233178a0cfa1b987991633e9686d9f3770b3 Mon Sep 17 00:00:00 2001 From: Ashoat Tevosyan Date: Wed, 18 Oct 2023 15:25:52 -0400 Subject: [PATCH] [lib] Batch ENS queries using ReverseRecords smart contract Summary: This resolves [ENG-2593](https://linear.app/comm/issue/ENG-2593/use-reverserecords-smart-contract-for-bulk-ens-name-lookups). For more info about this smart contract, see [official ENS docs](https://docs.ens.domains/dapp-developer-guide/resolving-names#reverse-resolution) and [GitHub repo](https://github.com/ensdomains/reverse-records). When I start my iOS app, it needs to resolve 142 ENS names to build the in-memory search indices for @-mentioning and inbox search. Before this diff, this meant 142 individual network requests to Alchemy. After this diff, it requires only 1. Depends on D9524 Test Plan: 1. Included updated unit tests. 2. I ran the iOS app and confirmed that the regression described [here](https://linear.app/comm/issue/ENG-5274/ens-resolution-for-chat-mentioning-causes-too-many-react-rerenders#comment-a7405e2f) was resolved. 3. I added a log statement to the code that issues a "single fetch" to confirm that it wasn't getting triggered anymore on iOS app start. (One "single fetch" was actually triggered, but it was for an ENS name that appeared immediately in my inbox... I think this is actually what we want, since we need that result more urgently.) 4. I confirmed that ENS resolution still worked on the iOS app. 5. I confirmed that ENS resolution still worked on the web app. 6. I confirmed that the keyserver is still able to resolve ENS names in notifs. Reviewers: rohan, atul, tomek Reviewed By: tomek Subscribers: tomek, wyilio Differential Revision: https://phab.comm.dev/D9525 --- lib/types/ethers-types.js | 1 + lib/utils/ens-cache.js | 142 ++++++++++++++++++++++++++++++++++- lib/utils/ens-cache.test.js | 97 ++++++++++++++++++++++++ lib/utils/ens-helpers.js | 16 ++-- lib/utils/reverse-records.js | 45 +++++++++++ 5 files changed, 292 insertions(+), 9 deletions(-) create mode 100644 lib/utils/reverse-records.js diff --git a/lib/types/ethers-types.js b/lib/types/ethers-types.js index 7cf0486be0..4c18ca4406 100644 --- a/lib/types/ethers-types.js +++ b/lib/types/ethers-types.js @@ -4,5 +4,6 @@ export type EthersProvider = { +lookupAddress: (address: string) => Promise, +resolveName: (name: string) => Promise, +getAvatar: (name: string) => Promise, + +getNetwork: () => Promise<{ +chainId: number, ... }>, ... }; diff --git a/lib/utils/ens-cache.js b/lib/utils/ens-cache.js index 73cdfe7a85..1aaf14cbfa 100644 --- a/lib/utils/ens-cache.js +++ b/lib/utils/ens-cache.js @@ -1,7 +1,14 @@ // @flow import namehash from 'eth-ens-namehash'; - +import { Contract } from 'ethers'; +import invariant from 'invariant'; + +import { + resolverABI, + resolverAddresses, + type ReverseRecordsEthersSmartContract, +} from './reverse-records.js'; import sleep from './sleep.js'; import type { EthersProvider } from '../types/ethers-types.js'; @@ -50,6 +57,9 @@ const normalizeENSName = (ensName: string) => namehash.normalize(ensName); // vanilla JS class that handles querying and caching ENS for all cases. class ENSCache { provider: EthersProvider; + batchReverseResolverSmartContract: ?ReverseRecordsEthersSmartContract; + batchReverseResolverSmartContractPromise: Promise; + // Maps from normalized ETH address to a cache entry for its name nameQueryCache: Map = new Map(); // Maps from normalized ETH name to a cache entry for its address @@ -59,6 +69,20 @@ class ENSCache { constructor(provider: EthersProvider) { this.provider = provider; + this.batchReverseResolverSmartContractPromise = (async () => { + const { chainId } = await provider.getNetwork(); + const reverseRecordsAddress = resolverAddresses[chainId]; + invariant( + reverseRecordsAddress, + `no ReverseRecords smart contract address for chaind ID ${chainId}!`, + ); + this.batchReverseResolverSmartContract = new Contract( + reverseRecordsAddress, + resolverABI, + provider, + ); + return this.batchReverseResolverSmartContract; + })(); } // Getting a name for an ETH address is referred to as "reverse resolution". @@ -123,6 +147,122 @@ class ENSCache { })(); } + getNamesForAddresses( + ethAddresses: $ReadOnlyArray, + ): Promise> { + const normalizedETHAddresses = ethAddresses.map(normalizeETHAddress); + + const cacheMatches = normalizedETHAddresses.map(ethAddress => + this.getCachedNameEntryForAddress(ethAddress), + ); + const cacheResultsPromise = Promise.all( + cacheMatches.map(match => + Promise.resolve(match ? match.normalizedENSName : match), + ), + ); + if (cacheMatches.every(Boolean)) { + return cacheResultsPromise; + } + + const needFetch = []; + for (let i = 0; i < normalizedETHAddresses.length; i++) { + const ethAddress = normalizedETHAddresses[i]; + const cacheMatch = cacheMatches[i]; + if (!cacheMatch) { + needFetch.push(ethAddress); + } + } + + const fetchENSNamesPromise = (async () => { + const { + batchReverseResolverSmartContract, + batchReverseResolverSmartContractPromise, + } = this; + + let smartContract; + if (batchReverseResolverSmartContract) { + smartContract = batchReverseResolverSmartContract; + } else { + smartContract = await batchReverseResolverSmartContractPromise; + } + + // ReverseRecords smart contract handles checking forward resolution + let ensNames: $ReadOnlyArray; + try { + const raceResult = await Promise.race([ + smartContract['getNames(address[])'](needFetch), + throwOnTimeout(`names for ${JSON.stringify(needFetch)}`), + ]); + invariant( + Array.isArray(raceResult), + 'ReverseRecords smart contract should return array', + ); + ensNames = raceResult; + } catch (e) { + console.log(e); + ensNames = new Array(needFetch.length).fill(null); + } + + const resultMap = new Map(); + for (let i = 0; i < needFetch.length; i++) { + const ethAddress = needFetch[i]; + let ensName = ensNames[i]; + if ( + ensName !== null && + (!ensName || ensName !== normalizeENSName(ensName)) + ) { + ensName = undefined; + } + resultMap.set(ethAddress, ensName); + } + + return resultMap; + })(); + + for (let i = 0; i < needFetch.length; i++) { + const normalizedETHAddress = needFetch[i]; + const fetchENSNamePromise = (async () => { + const resultMap = await fetchENSNamesPromise; + return resultMap.get(normalizedETHAddress) ?? null; + })(); + this.nameQueryCache.set(normalizedETHAddress, { + normalizedETHAddress, + expirationTime: Date.now() + queryTimeout * 2, + normalizedENSName: fetchENSNamePromise, + }); + } + + return (async () => { + const [resultMap, cacheResults] = await Promise.all([ + fetchENSNamesPromise, + cacheResultsPromise, + ]); + for (let i = 0; i < needFetch.length; i++) { + const normalizedETHAddress = needFetch[i]; + const normalizedENSName = resultMap.get(normalizedETHAddress); + const timeout = + normalizedENSName === null ? failedQueryCacheTimeout : cacheTimeout; + this.nameQueryCache.set(normalizedETHAddress, { + normalizedETHAddress, + expirationTime: Date.now() + timeout, + normalizedENSName, + }); + } + + const results = []; + for (let i = 0; i < normalizedETHAddresses.length; i++) { + const cachedResult = cacheResults[i]; + if (cachedResult) { + results.push(cachedResult); + } else { + const normalizedETHAddress = normalizedETHAddresses[i]; + results.push(resultMap.get(normalizedETHAddress)); + } + } + return results; + })(); + } + getCachedNameEntryForAddress(ethAddress: string): ?ENSNameQueryCacheEntry { const normalizedETHAddress = normalizeETHAddress(ethAddress); diff --git a/lib/utils/ens-cache.test.js b/lib/utils/ens-cache.test.js index 80e96e09f6..77813a68ba 100644 --- a/lib/utils/ens-cache.test.js +++ b/lib/utils/ens-cache.test.js @@ -43,15 +43,22 @@ const ashoatDotEth = 'ashoat.eth'; const ashoatAddr = '0x911413ef4127910d79303483f7470d095f399ca9'; const ashoatAvatar = 'https://ashoat.com/small_searching.png'; +const commalphaDotEth = 'commalpha.eth'; const commalphaEthAddr = '0x727ad7F5134C03e88087a8019b80388b22aaD24d'; const commalphaEthAvatar = 'https://gateway.ipfs.io/ipfs/Qmb6CCsr5Hvv1DKr9Yt9ucbaK8Fz9MUP1kW9NTqAJhk7o8'; +const commbetaDotEth = 'commbeta.eth'; const commbetaEthAddr = '0x07124c3b6687e78aec8f13a2312cba72a0bed387'; const commbetaEthAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiB2aWV3Qm94PSIwIDAgMjAwIDIwMCIgc3R5bGU9ImJhY2tncm91bmQ6ZGFya3Zpb2xldCI+PHBhdGggZD0iTTY4LjQ0IDE0My40NEM2MS44OCAxNDMuNDQgNTYuMDQgMTQxLjg0IDUwLjkyIDEzOC42NEM0NS44IDEzNS4zNiA0MS43NiAxMzAuNjggMzguOCAxMjQuNkMzNS44NCAxMTguNTIgMzQuMzYgMTExLjIgMzQuMzYgMTAyLjY0QzM0LjM2IDk0LjE2IDM1Ljg0IDg2Ljg4IDM4LjggODAuOEM0MS44NCA3NC42NCA0NS45NiA2OS45NiA1MS4xNiA2Ni43NkM1Ni40NCA2My40OCA2Mi40OCA2MS44NCA2OS4yOCA2MS44NEM3NC40OCA2MS44NCA3OC44NCA2Mi44OCA4Mi4zNiA2NC45NkM4NS44OCA2Ni45NiA4OC43NiA2OS4xMiA5MSA3MS40NEw4NS4zNiA3Ny44QzgzLjQ0IDc1LjcyIDgxLjIgNzQgNzguNjQgNzIuNjRDNzYuMTYgNzEuMjggNzMuMDQgNzAuNiA2OS4yOCA3MC42QzY0LjQgNzAuNiA2MC4xMiA3MS45MiA1Ni40NCA3NC41NkM1Mi43NiA3Ny4xMiA0OS44OCA4MC43NiA0Ny44IDg1LjQ4QzQ1LjggOTAuMiA0NC44IDk1Ljg0IDQ0LjggMTAyLjRDNDQuOCAxMDkuMDQgNDUuNzYgMTE0Ljc2IDQ3LjY4IDExOS41NkM0OS42IDEyNC4zNiA1Mi4zNiAxMjguMDggNTUuOTYgMTMwLjcyQzU5LjU2IDEzMy4zNiA2My45MiAxMzQuNjggNjkuMDQgMTM0LjY4QzcxLjc2IDEzNC42OCA3NC4zNiAxMzQuMjggNzYuODQgMTMzLjQ4Qzc5LjMyIDEzMi42IDgxLjI4IDEzMS40NCA4Mi43MiAxMzBWMTA5LjQ4SDY3VjEwMS4ySDkxLjk2VjEzNC4zMkM4OS40OCAxMzYuODggODYuMiAxMzkuMDQgODIuMTIgMTQwLjhDNzguMTIgMTQyLjU2IDczLjU2IDE0My40NCA2OC40NCAxNDMuNDRaTTEzNS45NTMgMTQzLjQ0QzEzMC44MzMgMTQzLjQ0IDEyNi4wNzMgMTQyLjI0IDEyMS42NzMgMTM5Ljg0QzExNy4zNTMgMTM3LjQ0IDExMy44MzMgMTMzLjk2IDExMS4xMTMgMTI5LjRDMTA4LjQ3MyAxMjQuODQgMTA3LjE1MyAxMTkuMzYgMTA3LjE1MyAxMTIuOTZDMTA3LjE1MyAxMDYuNCAxMDguNDczIDEwMC44NCAxMTEuMTEzIDk2LjI4QzExMy44MzMgOTEuNzIgMTE3LjM1MyA4OC4yNCAxMjEuNjczIDg1Ljg0QzEyNi4wNzMgODMuNDQgMTMwLjgzMyA4Mi4yNCAxMzUuOTUzIDgyLjI0QzE0MS4wNzMgODIuMjQgMTQ1Ljc5MyA4My40NCAxNTAuMTEzIDg1Ljg0QzE1NC41MTMgODguMjQgMTU4LjAzMyA5MS43MiAxNjAuNjczIDk2LjI4QzE2My4zOTMgMTAwLjg0IDE2NC43NTMgMTA2LjQgMTY0Ljc1MyAxMTIuOTZDMTY0Ljc1MyAxMTkuMzYgMTYzLjM5MyAxMjQuODQgMTYwLjY3MyAxMjkuNEMxNTguMDMzIDEzMy45NiAxNTQuNTEzIDEzNy40NCAxNTAuMTEzIDEzOS44NEMxNDUuNzkzIDE0Mi4yNCAxNDEuMDczIDE0My40NCAxMzUuOTUzIDE0My40NFpNMTM1Ljk1MyAxMzUuMjhDMTM5LjcxMyAxMzUuMjggMTQyLjk5MyAxMzQuMzYgMTQ1Ljc5MyAxMzIuNTJDMTQ4LjU5MyAxMzAuNiAxNTAuNzUzIDEyNy45NiAxNTIuMjczIDEyNC42QzE1My43OTMgMTIxLjI0IDE1NC41NTMgMTE3LjM2IDE1NC41NTMgMTEyLjk2QzE1NC41NTMgMTA4LjQ4IDE1My43OTMgMTA0LjU2IDE1Mi4yNzMgMTAxLjJDMTUwLjc1MyA5Ny43NiAxNDguNTkzIDk1LjEyIDE0NS43OTMgOTMuMjhDMTQyLjk5MyA5MS4zNiAxMzkuNzEzIDkwLjQgMTM1Ljk1MyA5MC40QzEzMi4xOTMgOTAuNCAxMjguOTEzIDkxLjM2IDEyNi4xMTMgOTMuMjhDMTIzLjM5MyA5NS4xMiAxMjEuMjMzIDk3Ljc2IDExOS42MzMgMTAxLjJDMTE4LjExMyAxMDQuNTYgMTE3LjM1MyAxMDguNDggMTE3LjM1MyAxMTIuOTZDMTE3LjM1MyAxMTcuMzYgMTE4LjExMyAxMjEuMjQgMTE5LjYzMyAxMjQuNkMxMjEuMjMzIDEyNy45NiAxMjMuMzkzIDEzMC42IDEyNi4xMTMgMTMyLjUyQzEyOC45MTMgMTM0LjM2IDEzMi4xOTMgMTM1LjI4IDEzNS45NTMgMTM1LjI4Wk0xMjQuMzEzIDcxLjQ0QzEyMi4zOTMgNzEuNDQgMTIwLjc5MyA3MC44IDExOS41MTMgNjkuNTJDMTE4LjMxMyA2OC4xNiAxMTcuNzEzIDY2LjU2IDExNy43MTMgNjQuNzJDMTE3LjcxMyA2Mi44OCAxMTguMzEzIDYxLjMyIDExOS41MTMgNjAuMDRDMTIwLjc5MyA1OC42OCAxMjIuMzkzIDU4IDEyNC4zMTMgNThDMTI2LjIzMyA1OCAxMjcuNzkzIDU4LjY4IDEyOC45OTMgNjAuMDRDMTMwLjI3MyA2MS4zMiAxMzAuOTEzIDYyLjg4IDEzMC45MTMgNjQuNzJDMTMwLjkxMyA2Ni41NiAxMzAuMjczIDY4LjE2IDEyOC45OTMgNjkuNTJDMTI3Ljc5MyA3MC44IDEyNi4yMzMgNzEuNDQgMTI0LjMxMyA3MS40NFpNMTQ3LjU5MyA3MS40NEMxNDUuNjczIDcxLjQ0IDE0NC4wNzMgNzAuOCAxNDIuNzkzIDY5LjUyQzE0MS41OTMgNjguMTYgMTQwLjk5MyA2Ni41NiAxNDAuOTkzIDY0LjcyQzE0MC45OTMgNjIuODggMTQxLjU5MyA2MS4zMiAxNDIuNzkzIDYwLjA0QzE0NC4wNzMgNTguNjggMTQ1LjY3MyA1OCAxNDcuNTkzIDU4QzE0OS41MTMgNTggMTUxLjA3MyA1OC42OCAxNTIuMjczIDYwLjA0QzE1My41NTMgNjEuMzIgMTU0LjE5MyA2Mi44OCAxNTQuMTkzIDY0LjcyQzE1NC4xOTMgNjYuNTYgMTUzLjU1MyA2OC4xNiAxNTIuMjczIDY5LjUyQzE1MS4wNzMgNzAuOCAxNDkuNTEzIDcxLjQ0IDE0Ny41OTMgNzEuNDRaIiBmaWxsPSJibGFjayIgLz48dGV4dCB4PSIyMCIgeT0iMTgwIiBmaWxsPSJibGFjayI+VG9rZW4gIyAzNjI3PC90ZXh0Pjwvc3ZnPg=='; +const noENSNameAddr = '0xcF986104d869967381dFfAb3A4127bCe6a404362'; + describe('getNameForAddress', () => { + beforeAll(() => { + ensCache.clearCache(); + }); it('should fail to return ashoat.eth if not in cache', async () => { if (!process.env.ALCHEMY_API_KEY) { return; @@ -113,7 +120,94 @@ describe('getNameForAddress', () => { }); }); +describe('getNamesForAddresses', () => { + beforeAll(() => { + ensCache.clearCache(); + }); + it('should fail to return ashoat.eth if not in cache', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + const ashoatEthResult = ensCache.getCachedNameForAddress(ashoatAddr); + expect(ashoatEthResult).toBe(undefined); + }); + it('should return ashoat.eth', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + const [ashoatEthResult] = await ensCache.getNamesForAddresses([ashoatAddr]); + expect(ashoatEthResult).toBe(ashoatDotEth); + }); + it('should return ashoat.eth if in cache', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + const ashoatEthResult = ensCache.getCachedNameForAddress(ashoatAddr); + expect(ashoatEthResult).toBe(ashoatDotEth); + }); + it('should fetch multiple at a time', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + const [ashoatEthResult, commalphaEthResult, commbetaEthResult] = + await ensCache.getNamesForAddresses([ + ashoatAddr, + commalphaEthAddr, + commbetaEthAddr, + ]); + expect(ashoatEthResult).toBe(ashoatDotEth); + expect(commalphaEthResult).toBe(commalphaDotEth); + expect(commbetaEthResult).toBe(commbetaDotEth); + }); + it('should dedup simultaneous fetches', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + + ensCache.clearCache(); + const timesLookupAddressCalledBefore = timesLookupAddressCalled; + + const [ + [ashoatEthResult1, commalphaEthResult1, commbetaEthResult1], + ashoatEthResult2, + commalphaEthResult2, + commbetaEthResult2, + ] = await Promise.all([ + ensCache.getNamesForAddresses([ + ashoatAddr, + commalphaEthAddr, + commbetaEthAddr, + ]), + ensCache.getNameForAddress(ashoatAddr), + ensCache.getNameForAddress(commalphaEthAddr), + ensCache.getNameForAddress(commbetaEthAddr), + ]); + + const timesLookupAddressCalledAfter = timesLookupAddressCalled; + const timesLookupAddressCalledDuringTest = + timesLookupAddressCalledAfter - timesLookupAddressCalledBefore; + expect(timesLookupAddressCalledDuringTest).toBe(0); + + expect(ashoatEthResult1).toBe(ashoatDotEth); + expect(commalphaEthResult1).toBe(commalphaDotEth); + expect(commbetaEthResult1).toBe(commbetaDotEth); + expect(ashoatEthResult2).toBe(ashoatDotEth); + expect(commalphaEthResult2).toBe(commalphaDotEth); + expect(commbetaEthResult2).toBe(commbetaDotEth); + }); + it('should return undefined if no ENS name', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + const [noNameResult] = await ensCache.getNamesForAddresses([noENSNameAddr]); + expect(noNameResult).toBe(undefined); + }); +}); + describe('getAddressForName', () => { + beforeAll(() => { + ensCache.clearCache(); + }); it("should fail to return ashoat.eth's address if not in cache", async () => { if (!process.env.ALCHEMY_API_KEY) { return; @@ -174,6 +268,9 @@ describe('getAddressForName', () => { }); describe('getAvatarURIForAddress', () => { + beforeAll(() => { + ensCache.clearCache(); + }); it("should fail to return ashoat.eth's avatar if not in cache", async () => { if (!process.env.ALCHEMY_API_KEY) { return; diff --git a/lib/utils/ens-helpers.js b/lib/utils/ens-helpers.js index 5b51b3f5a0..595e6d0a86 100644 --- a/lib/utils/ens-helpers.js +++ b/lib/utils/ens-helpers.js @@ -42,14 +42,14 @@ async function getENSNames( const ensNames = new Map(); if (needFetch.length > 0) { - await Promise.all( - needFetch.map(async (ethAddress: string) => { - const ensName = await ensCache.getNameForAddress(ethAddress); - if (ensName) { - ensNames.set(ethAddress, ensName); - } - }), - ); + const results = await ensCache.getNamesForAddresses(needFetch); + for (let i = 0; i < needFetch.length; i++) { + const ethAddress = needFetch[i]; + const result = results[i]; + if (result) { + ensNames.set(ethAddress, result); + } + } } return info.map(user => { diff --git a/lib/utils/reverse-records.js b/lib/utils/reverse-records.js new file mode 100644 index 0000000000..9cc7914256 --- /dev/null +++ b/lib/utils/reverse-records.js @@ -0,0 +1,45 @@ +// @flow + +type ABIParam = { + +internalType: string, + +name: string, + +type: string, +}; +type EthereumSmartContractABI = $ReadOnlyArray<{ + +inputs: $ReadOnlyArray, + +stateMutability: string, + +type: string, + +name?: ?string, + +outputs?: ?$ReadOnlyArray, +}>; + +const resolverABI: EthereumSmartContractABI = [ + { + inputs: [{ internalType: 'contract ENS', name: '_ens', type: 'address' }], + stateMutability: 'nonpayable', + type: 'constructor', + }, + { + inputs: [ + { internalType: 'address[]', name: 'addresses', type: 'address[]' }, + ], + name: 'getNames', + outputs: [{ internalType: 'string[]', name: 'r', type: 'string[]' }], + stateMutability: 'view', + type: 'function', + }, +]; + +const mainnetChainID = 1; +const goerliChainID = 5; +const resolverAddresses: { +[chainID: number]: string } = { + [mainnetChainID]: '0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C', + [goerliChainID]: '0x333Fc8f550043f239a2CF79aEd5e9cF4A20Eb41e', +}; + +export type ReverseRecordsEthersSmartContract = { + +'getNames(address[])': ($ReadOnlyArray) => Promise, + ... +}; + +export { resolverABI, resolverAddresses };