diff --git a/lib/types/ethers-types.js b/lib/types/ethers-types.js --- a/lib/types/ethers-types.js +++ b/lib/types/ethers-types.js @@ -4,5 +4,6 @@ +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 --- 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 @@ // 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 @@ 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,119 @@ })(); } + 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 || 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 --- a/lib/utils/ens-cache.test.js +++ b/lib/utils/ens-cache.test.js @@ -43,15 +43,22 @@ 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('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('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 --- a/lib/utils/ens-helpers.js +++ b/lib/utils/ens-helpers.js @@ -42,14 +42,14 @@ 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 --- /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 };