diff --git a/lib/components/ens-cache-provider.react.js b/lib/components/ens-cache-provider.react.js index fee412128..eaf268049 100644 --- a/lib/components/ens-cache-provider.react.js +++ b/lib/components/ens-cache-provider.react.js @@ -1,43 +1,44 @@ // @flow import * as React from 'react'; -import { ENSCache, type EthersProvider } from '../utils/ens-cache.js'; +import type { EthersProvider } from '../types/ethers-types.js'; +import { ENSCache } from '../utils/ens-cache.js'; import { getENSNames as baseGetENSNames, type GetENSNames, } from '../utils/ens-helpers.js'; type ENSCacheContextType = { +ensCache: ?ENSCache, +getENSNames: ?GetENSNames, }; const defaultContext = { ensCache: undefined, getENSNames: undefined, }; const ENSCacheContext: React.Context = React.createContext(defaultContext); type Props = { +provider: ?EthersProvider, +children: React.Node, }; function ENSCacheProvider(props: Props): React.Node { const { provider, children } = props; const context = React.useMemo(() => { if (!provider) { return defaultContext; } const ensCache = new ENSCache(provider); const getENSNames: GetENSNames = baseGetENSNames.bind(null, ensCache); return { ensCache, getENSNames }; }, [provider]); return ( {children} ); } export { ENSCacheContext, ENSCacheProvider }; diff --git a/lib/types/ethers-types.js b/lib/types/ethers-types.js new file mode 100644 index 000000000..7cf0486be --- /dev/null +++ b/lib/types/ethers-types.js @@ -0,0 +1,8 @@ +// @flow + +export type EthersProvider = { + +lookupAddress: (address: string) => Promise, + +resolveName: (name: string) => Promise, + +getAvatar: (name: string) => Promise, + ... +}; diff --git a/lib/utils/ens-cache.js b/lib/utils/ens-cache.js index 054fc7163..73cdfe7a8 100644 --- a/lib/utils/ens-cache.js +++ b/lib/utils/ens-cache.js @@ -1,332 +1,327 @@ // @flow import namehash from 'eth-ens-namehash'; 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`); } -export type EthersProvider = { - +lookupAddress: (address: string) => Promise, - +resolveName: (name: string) => Promise, - +getAvatar: (name: string) => Promise, - ... -}; 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; // 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; } // 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; })(); } 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/native/utils/ethers-utils.js b/native/utils/ethers-utils.js index 93bac9cd4..d670537ed 100644 --- a/native/utils/ethers-utils.js +++ b/native/utils/ethers-utils.js @@ -1,21 +1,21 @@ // @flow import '@ethersproject/shims'; import { ethers } from 'ethers'; -import type { EthersProvider } from 'lib/utils/ens-cache.js'; +import type { EthersProvider } from 'lib/types/ethers-types.js'; let alchemyKey; try { // $FlowExpectedError: file might not exist const { key } = require('../facts/alchemy.json'); alchemyKey = key; } catch {} let provider: ?EthersProvider; if (alchemyKey) { provider = new ethers.providers.AlchemyProvider('mainnet', alchemyKey); } export { provider };