diff --git a/lib/utils/ens-cache.js b/lib/utils/ens-cache.js index 465f54ce5..3a3fd2377 100644 --- a/lib/utils/ens-cache.js +++ b/lib/utils/ens-cache.js @@ -1,189 +1,210 @@ // @flow import namehash from 'eth-ens-namehash'; const cacheTimeout = 24 * 60 * 60 * 1000; // one day export type EthersProvider = { +lookupAddress: (address: string) => Promise, +resolveName: (name: string) => Promise, ... }; type ENSNameQueryCacheEntry = { // We normalize ETH addresses to lowercase characters +normalizedETHAddress: string, +cacheInsertionTime: number, // We normalize ENS names using eth-ens-namehash +normalizedENSName: ?string | Promise, }; type ENSAddressQueryCacheEntry = { +normalizedENSName: string, +cacheInsertionTime: number, +normalizedETHAddress: ?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; // Maps from normalized ETH address to a cache entry for that address nameQueryCache: Map = new Map(); // Maps from normalized ETH name to a cache entry for that name addressQueryCache: Map = new Map(); constructor(provider: EthersProvider) { this.provider = provider; } // 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. - async getNameForAddress(ethAddress: string): Promise { + getNameForAddress(ethAddress: string): Promise { const normalizedETHAddress = normalizeETHAddress(ethAddress); - const cacheResult = this.getCachedNameForAddress(normalizedETHAddress); + const cacheResult = this.getCachedEntryForAddress(normalizedETHAddress); if (cacheResult) { - return cacheResult; + return Promise.resolve(cacheResult.normalizedENSName); } const fetchENSNamePromise = (async () => { // ethers.js handles checking forward resolution (point 1 above) for us const ensName = await this.provider.lookupAddress(normalizedETHAddress); if (!ensName) { return undefined; } const normalizedENSName = normalizeENSName(ensName); if (normalizedENSName !== ensName) { return undefined; } return normalizedENSName; })(); - const normalizedENSName = await fetchENSNamePromise; this.nameQueryCache.set(normalizedETHAddress, { normalizedETHAddress, cacheInsertionTime: Date.now(), - normalizedENSName, + normalizedENSName: fetchENSNamePromise, }); - return normalizedENSName; + + return (async () => { + const normalizedENSName = await fetchENSNamePromise; + this.nameQueryCache.set(normalizedETHAddress, { + normalizedETHAddress, + cacheInsertionTime: Date.now(), + normalizedENSName, + }); + return normalizedENSName; + })(); } getCachedEntryForAddress(ethAddress: string): ?ENSNameQueryCacheEntry { const normalizedETHAddress = normalizeETHAddress(ethAddress); const cacheResult = this.nameQueryCache.get(normalizedETHAddress); if (!cacheResult) { return undefined; } const { cacheInsertionTime } = cacheResult; if (cacheInsertionTime + cacheTimeout <= Date.now()) { this.nameQueryCache.delete(normalizedETHAddress); return undefined; } return cacheResult; } getCachedNameForAddress(ethAddress: string): ?string { const cacheResult = this.getCachedEntryForAddress(ethAddress); if (!cacheResult) { return undefined; } const { normalizedENSName } = cacheResult; if (typeof normalizedENSName !== 'string') { return undefined; } return normalizedENSName; } - async getAddressForName(ensName: string): Promise { + getAddressForName(ensName: string): Promise { const normalizedENSName = normalizeENSName(ensName); if (normalizedENSName !== ensName) { - return undefined; + return Promise.resolve(undefined); } - const cacheResult = this.getCachedAddressForName(normalizedENSName); + const cacheResult = this.getCachedEntryForName(normalizedENSName); if (cacheResult) { - return cacheResult; + return Promise.resolve(cacheResult.normalizedETHAddress); } const fetchETHAddressPromise = (async () => { const ethAddress = await this.provider.resolveName(normalizedENSName); if (!ethAddress) { return undefined; } return normalizeETHAddress(ethAddress); })(); - const normalizedETHAddress = await fetchETHAddressPromise; this.addressQueryCache.set(normalizedENSName, { normalizedENSName, cacheInsertionTime: Date.now(), - normalizedETHAddress, + normalizedETHAddress: fetchETHAddressPromise, }); - return normalizedETHAddress; + + return (async () => { + const normalizedETHAddress = await fetchETHAddressPromise; + this.addressQueryCache.set(normalizedENSName, { + normalizedENSName, + cacheInsertionTime: Date.now(), + normalizedETHAddress, + }); + return normalizedETHAddress; + })(); } getCachedEntryForName(ensName: string): ?ENSAddressQueryCacheEntry { const normalizedENSName = normalizeENSName(ensName); if (normalizedENSName !== ensName) { return undefined; } const cacheResult = this.addressQueryCache.get(normalizedENSName); if (!cacheResult) { return undefined; } const { cacheInsertionTime } = cacheResult; if (cacheInsertionTime + cacheTimeout <= Date.now()) { this.addressQueryCache.delete(normalizedENSName); return undefined; } return cacheResult; } getCachedAddressForName(ensName: string): ?string { const cacheResult = this.getCachedEntryForName(ensName); if (!cacheResult) { return undefined; } const { normalizedETHAddress } = cacheResult; if (typeof normalizedETHAddress !== 'string') { return undefined; } return normalizedETHAddress; } + + clearCache(): void { + this.nameQueryCache = new Map(); + this.addressQueryCache = new Map(); + } } export { ENSCache }; diff --git a/lib/utils/ens-cache.test.js b/lib/utils/ens-cache.test.js index 34980bb6f..402b735f7 100644 --- a/lib/utils/ens-cache.test.js +++ b/lib/utils/ens-cache.test.js @@ -1,104 +1,158 @@ // @flow import { ethers } from 'ethers'; import { ENSCache } from './ens-cache'; const provider = new ethers.providers.AlchemyProvider( 'mainnet', 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); }; 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'; describe('getNameForAddress', () => { 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('getAddressForName', () => { 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 timesResolveNamesCalledForSingleFetch = + 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( + timesResolveNamesCalledForSingleFetch, + ); + }); });