diff --git a/lib/types/ethers-types.js b/lib/types/ethers-types.js index 7cf0486be..4c18ca440 100644 --- a/lib/types/ethers-types.js +++ b/lib/types/ethers-types.js @@ -1,8 +1,9 @@ // @flow 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 73cdfe7a8..1aaf14cbf 100644 --- a/lib/utils/ens-cache.js +++ b/lib/utils/ens-cache.js @@ -1,327 +1,467 @@ // @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'; const cacheTimeout = 24 * 60 * 60 * 1000; // one day const failedQueryCacheTimeout = 5 * 60 * 1000; // five minutes const queryTimeout = 10 * 1000; // ten seconds async function throwOnTimeout(identifier: string) { await sleep(queryTimeout); throw new Error(`ENS fetch for ${identifier} timed out`); } type ENSNameQueryCacheEntry = { // We normalize ETH addresses to lowercase characters +normalizedETHAddress: string, +expirationTime: number, // We normalize ENS names using eth-ens-namehash +normalizedENSName: ?string | Promise, }; type ENSAddressQueryCacheEntry = { +normalizedENSName: string, +expirationTime: number, +normalizedETHAddress: ?string | Promise, }; type ENSAvatarQueryCacheEntry = { +normalizedETHAddress: string, +expirationTime: number, +avatarURI: ?string | Promise, }; const normalizeETHAddress = (ethAddress: string) => ethAddress.toLowerCase(); // Note: this normalization is a little different than the ETH address // normalization. The difference is that ETH addresses are // case-insensitive, but a normalized ENS name is not the same as its input. // Whereas we use normalizeETHAddress just to dedup inputs, we use this // function to check if an ENS name matches its normalized ENS name, as a way // to prevent homograph attacks. // See https://docs.ens.domains/dapp-developer-guide/resolving-names#reverse-resolution const normalizeENSName = (ensName: string) => namehash.normalize(ensName); // We have a need for querying ENS names from both clients as well as from // keyserver code. On the client side, we could use wagmi's caching behavior, // but that doesn't work for keyserver since it's React-specific. To keep // caching behavior consistent across platforms, we instead introduce this // 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 addressQueryCache: Map = new Map(); // Maps from normalized ETH address to a cache entry for its avatar avatarQueryCache: Map = new Map(); 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". // 1. Since any address can set a reverse resolution to an arbitrary ENS name // (without permission from the owner), this function will also perform a // "forward resolution" to confirm that the owner of the ENS name has // mapped it to this address // 2. We only consider an ENS name valid if it's equal to its normalized // version via eth-ens-namehash. This is to protect against homograph // attacks // If we fail to find an ENS name for an address, fail to confirm a matching // forward resolution, or if the ENS name does not equal its normalized // version, we will return undefined. getNameForAddress(ethAddress: string): Promise { const normalizedETHAddress = normalizeETHAddress(ethAddress); const cacheResult = this.getCachedNameEntryForAddress(normalizedETHAddress); if (cacheResult) { return Promise.resolve(cacheResult.normalizedENSName); } const fetchENSNamePromise = (async () => { // ethers.js handles checking forward resolution (point 1 above) for us let ensName; try { ensName = await Promise.race([ this.provider.lookupAddress(normalizedETHAddress), throwOnTimeout(`${normalizedETHAddress}'s name`), ]); } catch (e) { console.log(e); return null; } if (!ensName) { return undefined; } const normalizedENSName = normalizeENSName(ensName); if (normalizedENSName !== ensName) { return undefined; } return normalizedENSName; })(); this.nameQueryCache.set(normalizedETHAddress, { normalizedETHAddress, expirationTime: Date.now() + queryTimeout * 2, normalizedENSName: fetchENSNamePromise, }); return (async () => { const normalizedENSName = await fetchENSNamePromise; const timeout = normalizedENSName === null ? failedQueryCacheTimeout : cacheTimeout; this.nameQueryCache.set(normalizedETHAddress, { normalizedETHAddress, expirationTime: Date.now() + timeout, normalizedENSName, }); return normalizedENSName; })(); } + 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); const cacheResult = this.nameQueryCache.get(normalizedETHAddress); if (!cacheResult) { return undefined; } const { expirationTime } = cacheResult; if (expirationTime <= Date.now()) { this.nameQueryCache.delete(normalizedETHAddress); return undefined; } return cacheResult; } getCachedNameForAddress(ethAddress: string): ?string { const cacheResult = this.getCachedNameEntryForAddress(ethAddress); if (!cacheResult) { return undefined; } const { normalizedENSName } = cacheResult; if (typeof normalizedENSName !== 'string') { return undefined; } return normalizedENSName; } getAddressForName(ensName: string): Promise { const normalizedENSName = normalizeENSName(ensName); if (normalizedENSName !== ensName) { return Promise.resolve(undefined); } const cacheResult = this.getCachedAddressEntryForName(normalizedENSName); if (cacheResult) { return Promise.resolve(cacheResult.normalizedETHAddress); } const fetchETHAddressPromise = (async () => { let ethAddress; try { ethAddress = await Promise.race([ this.provider.resolveName(normalizedENSName), throwOnTimeout(`${normalizedENSName}'s address`), ]); } catch (e) { console.log(e); return null; } if (!ethAddress) { return undefined; } return normalizeETHAddress(ethAddress); })(); this.addressQueryCache.set(normalizedENSName, { normalizedENSName, expirationTime: Date.now() + queryTimeout * 2, normalizedETHAddress: fetchETHAddressPromise, }); return (async () => { const normalizedETHAddress = await fetchETHAddressPromise; const timeout = normalizedETHAddress === null ? failedQueryCacheTimeout : cacheTimeout; this.addressQueryCache.set(normalizedENSName, { normalizedENSName, expirationTime: Date.now() + timeout, normalizedETHAddress, }); return normalizedETHAddress; })(); } getCachedAddressEntryForName(ensName: string): ?ENSAddressQueryCacheEntry { const normalizedENSName = normalizeENSName(ensName); if (normalizedENSName !== ensName) { return undefined; } const cacheResult = this.addressQueryCache.get(normalizedENSName); if (!cacheResult) { return undefined; } const { expirationTime } = cacheResult; if (expirationTime <= Date.now()) { this.addressQueryCache.delete(normalizedENSName); return undefined; } return cacheResult; } getCachedAddressForName(ensName: string): ?string { const cacheResult = this.getCachedAddressEntryForName(ensName); if (!cacheResult) { return undefined; } const { normalizedETHAddress } = cacheResult; if (typeof normalizedETHAddress !== 'string') { return undefined; } return normalizedETHAddress; } getAvatarURIForAddress(ethAddress: string): Promise { const normalizedETHAddress = normalizeETHAddress(ethAddress); const cacheResult = this.getCachedAvatarEntryForAddress(normalizedETHAddress); if (cacheResult) { return Promise.resolve(cacheResult.avatarURI); } const fetchENSAvatarPromise = (async () => { const ensName = await this.getNameForAddress(normalizedETHAddress); if (!ensName) { return ensName; } let ensAvatar; try { ensAvatar = await Promise.race([ this.provider.getAvatar(ensName), throwOnTimeout(`${normalizedETHAddress}'s avatar`), ]); } catch (e) { console.log(e); return null; } if (!ensAvatar) { return undefined; } return ensAvatar; })(); this.avatarQueryCache.set(normalizedETHAddress, { normalizedETHAddress, expirationTime: Date.now() + queryTimeout * 4, avatarURI: fetchENSAvatarPromise, }); return (async () => { const avatarURI = await fetchENSAvatarPromise; const timeout = avatarURI === null ? failedQueryCacheTimeout : cacheTimeout; this.avatarQueryCache.set(normalizedETHAddress, { normalizedETHAddress, expirationTime: Date.now() + timeout, avatarURI, }); return avatarURI; })(); } getCachedAvatarEntryForAddress( ethAddress: string, ): ?ENSAvatarQueryCacheEntry { const normalizedETHAddress = normalizeETHAddress(ethAddress); const cacheResult = this.avatarQueryCache.get(normalizedETHAddress); if (!cacheResult) { return undefined; } const { expirationTime } = cacheResult; if (expirationTime <= Date.now()) { this.avatarQueryCache.delete(normalizedETHAddress); return undefined; } return cacheResult; } getCachedAvatarURIForAddress(ethAddress: string): ?string { const cacheResult = this.getCachedAvatarEntryForAddress(ethAddress); if (!cacheResult) { return undefined; } const { avatarURI } = cacheResult; if (typeof avatarURI !== 'string') { return undefined; } return avatarURI; } clearCache(): void { this.nameQueryCache = new Map(); this.addressQueryCache = new Map(); this.avatarQueryCache = new Map(); } } export { ENSCache }; diff --git a/lib/utils/ens-cache.test.js b/lib/utils/ens-cache.test.js index 80e96e09f..77813a68b 100644 --- a/lib/utils/ens-cache.test.js +++ b/lib/utils/ens-cache.test.js @@ -1,260 +1,357 @@ // @flow import { ethers } from 'ethers'; import { ENSCache } from './ens-cache.js'; const provider = new ethers.providers.AlchemyProvider( 'goerli', process.env.ALCHEMY_API_KEY, ); const ensCache = new ENSCache(provider); const baseLookupAddress = provider.lookupAddress.bind(provider); let timesLookupAddressCalled = 0; provider.lookupAddress = (ethAddress: string) => { timesLookupAddressCalled++; return baseLookupAddress(ethAddress); }; const baseResolveName = provider.resolveName.bind(provider); let timesResolveNameCalled = 0; provider.resolveName = (ensName: string) => { timesResolveNameCalled++; return baseResolveName(ensName); }; const baseGetAvatar = provider.getAvatar.bind(provider); let timesGetAvatarCalled = 0; provider.getAvatar = (ethAddress: string) => { timesGetAvatarCalled++; return baseGetAvatar(ethAddress); }; if (!process.env.ALCHEMY_API_KEY) { // Test only works if we can query blockchain console.log( 'skipped running ENSCache tests because of missing ALCHEMY_API_KEY ' + 'environmental variable', ); } 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; } 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.getNameForAddress(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 have ashoat.eth cached', async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const timesLookupAddressCalledBefore = timesLookupAddressCalled; const ashoatEthResult = await ensCache.getNameForAddress( ashoatAddr.toUpperCase(), ); expect(ashoatEthResult).toBe(ashoatDotEth); expect(timesLookupAddressCalled).toBe(timesLookupAddressCalledBefore); }); it('should dedup simultaneous fetches', async () => { if (!process.env.ALCHEMY_API_KEY) { return; } ensCache.clearCache(); const timesLookupAddressCalledBeforeSingleFetch = timesLookupAddressCalled; const ashoatEthResult1 = await ensCache.getNameForAddress(ashoatAddr); expect(ashoatEthResult1).toBe(ashoatDotEth); const timesLookupAddressCalledForSingleFetch = timesLookupAddressCalled - timesLookupAddressCalledBeforeSingleFetch; ensCache.clearCache(); const timesLookupAddressCalledBeforeDoubleFetch = timesLookupAddressCalled; const [ashoatEthResult2, ashoatEthResult3] = await Promise.all([ ensCache.getNameForAddress(ashoatAddr), ensCache.getNameForAddress(ashoatAddr.toUpperCase()), ]); expect(ashoatEthResult2).toBe(ashoatDotEth); expect(ashoatEthResult3).toBe(ashoatDotEth); const timesLookupAddressCalledForDoubleFetch = timesLookupAddressCalled - timesLookupAddressCalledBeforeDoubleFetch; expect(timesLookupAddressCalledForDoubleFetch).toBe( timesLookupAddressCalledForSingleFetch, ); }); }); +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; } const ashoatAddrResult = ensCache.getCachedAddressForName(ashoatDotEth); expect(ashoatAddrResult).toBe(undefined); }); it("should return ashoat.eth's address", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const ashoatAddrResult = await ensCache.getAddressForName(ashoatDotEth); expect(ashoatAddrResult).toBe(ashoatAddr); }); it("should return ashoat.eth's address if in cache", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const ashoatAddrResult = ensCache.getCachedAddressForName(ashoatDotEth); expect(ashoatAddrResult).toBe(ashoatAddr); }); it("should have ashoat.eth's address cached", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const timesResolveNameCalledBefore = timesResolveNameCalled; const ashoatAddrResult = await ensCache.getAddressForName(ashoatDotEth); expect(ashoatAddrResult).toBe(ashoatAddr); expect(timesResolveNameCalled).toBe(timesResolveNameCalledBefore); }); it('should dedup simultaneous fetches', async () => { if (!process.env.ALCHEMY_API_KEY) { return; } ensCache.clearCache(); const timesResolveNameCalledBeforeSingleFetch = timesResolveNameCalled; const ashoatAddrResult1 = await ensCache.getAddressForName(ashoatDotEth); expect(ashoatAddrResult1).toBe(ashoatAddr); const timesResolveNameCalledForSingleFetch = timesResolveNameCalled - timesResolveNameCalledBeforeSingleFetch; ensCache.clearCache(); const timesResolveNameCalledBeforeDoubleFetch = timesResolveNameCalled; const [ashoatAddrResult2, ashoatAddrResult3] = await Promise.all([ ensCache.getAddressForName(ashoatDotEth), ensCache.getAddressForName(ashoatDotEth), ]); expect(ashoatAddrResult2).toBe(ashoatAddr); expect(ashoatAddrResult3).toBe(ashoatAddr); const timesResolveNamesCalledForDoubleFetch = timesResolveNameCalled - timesResolveNameCalledBeforeDoubleFetch; expect(timesResolveNamesCalledForDoubleFetch).toBe( timesResolveNameCalledForSingleFetch, ); }); }); 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; } const ashoatAvatarResult = ensCache.getCachedAvatarURIForAddress(ashoatAddr); expect(ashoatAvatarResult).toBe(undefined); }); it("should return ashoat.eth's avatar, an HTTP URI pointing to a PNG", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const ashoatAvatarResult = await ensCache.getAvatarURIForAddress( ashoatAddr, ); expect(ashoatAvatarResult).toBe(ashoatAvatar); }); it("should return ashoat.eth's avatar if in cache", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const ashoatAvatarResult = ensCache.getCachedAvatarURIForAddress(ashoatAddr); expect(ashoatAvatarResult).toBe(ashoatAvatar); }); it("should have ashoat.eth's avatar cached", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const timesGetAvatarCalledBefore = timesGetAvatarCalled; const ashoatAvatarResult = await ensCache.getAvatarURIForAddress( ashoatAddr, ); expect(ashoatAvatarResult).toBe(ashoatAvatar); expect(timesGetAvatarCalled).toBe(timesGetAvatarCalledBefore); }); it('should dedup simultaneous fetches', async () => { if (!process.env.ALCHEMY_API_KEY) { return; } ensCache.clearCache(); const timesGetAvatarCalledBeforeSingleFetch = timesGetAvatarCalled; const ashoatAvatarResult1 = await ensCache.getAvatarURIForAddress( ashoatAddr, ); expect(ashoatAvatarResult1).toBe(ashoatAvatar); const timesGetAvatarCalledForSingleFetch = timesGetAvatarCalled - timesGetAvatarCalledBeforeSingleFetch; ensCache.clearCache(); const timesGetAvatarCalledBeforeDoubleFetch = timesGetAvatarCalled; const [ashoatAvatarResult2, ashoatAvatarResult3] = await Promise.all([ ensCache.getAvatarURIForAddress(ashoatAddr), ensCache.getAvatarURIForAddress(ashoatAddr), ]); expect(ashoatAvatarResult2).toBe(ashoatAvatar); expect(ashoatAvatarResult3).toBe(ashoatAvatar); const timesGetAvatarCalledForDoubleFetch = timesGetAvatarCalled - timesGetAvatarCalledBeforeDoubleFetch; expect(timesGetAvatarCalledForDoubleFetch).toBe( timesGetAvatarCalledForSingleFetch, ); }); it("should return commalpha.eth's avatar, an IPFS URI pointing to a JPEG", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const commalphaAvatarResult = await ensCache.getAvatarURIForAddress( commalphaEthAddr, ); expect(commalphaAvatarResult).toBe(commalphaEthAvatar); }); it("should return commbeta.eth's avatar, an eip155:1/erc721 URI pointing to an NFT with an SVG data URI", async () => { if (!process.env.ALCHEMY_API_KEY) { return; } const commbetaAvatarResult = await ensCache.getAvatarURIForAddress( commbetaEthAddr, ); expect(commbetaAvatarResult).toBe(commbetaEthAvatar); }); }); diff --git a/lib/utils/ens-helpers.js b/lib/utils/ens-helpers.js index 5b51b3f5a..595e6d0a8 100644 --- a/lib/utils/ens-helpers.js +++ b/lib/utils/ens-helpers.js @@ -1,73 +1,73 @@ // @flow import { ENSCache } from './ens-cache.js'; import { getETHAddressForUserInfo } from '../shared/account-utils.js'; type BaseUserInfo = { +username?: ?string, ... }; export type GetENSNames = ( users: $ReadOnlyArray, ) => Promise; async function getENSNames( ensCache: ENSCache, users: $ReadOnlyArray, ): Promise { const info = users.map(user => { if (!user) { return user; } const ethAddress = getETHAddressForUserInfo(user); const cachedResult = ethAddress ? ensCache.getCachedNameForAddress(ethAddress) : null; return { input: user, ethAddress, cachedResult, }; }); const needFetch = info .map(user => { if (!user) { return null; } const { ethAddress, cachedResult } = user; if (cachedResult || !ethAddress) { return null; } return ethAddress; }) .filter(Boolean); 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 => { if (!user) { return user; } const { input, ethAddress, cachedResult } = user; if (cachedResult) { return { ...input, username: cachedResult }; } else if (!ethAddress) { return input; } const ensName = ensNames.get(ethAddress); if (ensName) { return { ...input, username: ensName }; } return input; }); } export { getENSNames }; diff --git a/lib/utils/reverse-records.js b/lib/utils/reverse-records.js new file mode 100644 index 000000000..9cc791425 --- /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 };