diff --git a/.github/workflows/eslint_flow_jest.yml b/.github/workflows/eslint_flow_jest.yml --- a/.github/workflows/eslint_flow_jest.yml +++ b/.github/workflows/eslint_flow_jest.yml @@ -47,6 +47,8 @@ - name: '[lib] test' working-directory: ./lib + env: + ALCHEMY_API_KEY: ${{secrets.ALCHEMY_API_KEY}} run: yarn test - name: '[keyserver] test' diff --git a/lib/package.json b/lib/package.json --- a/lib/package.json +++ b/lib/package.json @@ -27,6 +27,8 @@ "dependencies": { "dateformat": "^3.0.3", "emoji-regex": "^9.2.0", + "eth-ens-namehash": "^2.0.8", + "ethers": "^5.7.2", "fast-json-stable-stringify": "^2.0.0", "file-type": "^12.3.0", "invariant": "^2.2.4", diff --git a/lib/utils/ens-cache.js b/lib/utils/ens-cache.js new file mode 100644 --- /dev/null +++ b/lib/utils/ens-cache.js @@ -0,0 +1,81 @@ +// @flow + +import namehash from 'eth-ens-namehash'; + +const cacheTimeout = 24 * 60 * 60 * 1000; // one day + +type EthersProvider = { + +lookupAddress: (address: 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, +}; + +// 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(); + + 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. See https://docs.ens.domains/dapp-developer-guide/resolving-names#reverse-resolution + // 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 { + const normalizedETHAddress = ethAddress.toLowerCase(); + + const cacheResult = this.nameQueryCache.get(normalizedETHAddress); + if (cacheResult) { + const { cacheInsertionTime, normalizedENSName } = cacheResult; + if (cacheInsertionTime + cacheTimeout > Date.now()) { + return normalizedENSName; + } else { + this.nameQueryCache.delete(normalizedETHAddress); + } + } + + const cacheAndReturnResult = (result: ?string) => { + this.nameQueryCache.set(normalizedETHAddress, { + normalizedETHAddress, + cacheInsertionTime: Date.now(), + normalizedENSName: result, + }); + return result; + }; + + // ethers.js handles checking forward resolution (point 1 above) for us + const ensName = await this.provider.lookupAddress(normalizedETHAddress); + if (!ensName) { + return cacheAndReturnResult(undefined); + } + + const normalizedENSName = namehash.normalize(ensName); + if (normalizedENSName !== ensName) { + return cacheAndReturnResult(undefined); + } + + return cacheAndReturnResult(normalizedENSName); + } +} + +export { ENSCache }; diff --git a/lib/utils/ens-cache.test.js b/lib/utils/ens-cache.test.js new file mode 100644 --- /dev/null +++ b/lib/utils/ens-cache.test.js @@ -0,0 +1,49 @@ +// @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); +}; + +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', + ); +} + +describe('getNameForAddress', () => { + it('should return ashoat.eth', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + const obviouslyAshoatEth = await ensCache.getNameForAddress( + '0x911413ef4127910d79303483f7470d095f399ca9', + ); + expect(obviouslyAshoatEth).toBe('ashoat.eth'); + }); + it('should have ashoat.eth cached', async () => { + if (!process.env.ALCHEMY_API_KEY) { + return; + } + const timesLookupAddressCalledBefore = timesLookupAddressCalled; + const obviouslyAshoatEth = await ensCache.getNameForAddress( + '0x911413ef4127910d79303483f7470d095f399ca9', + ); + expect(obviouslyAshoatEth).toBe('ashoat.eth'); + expect(timesLookupAddressCalled).toBe(timesLookupAddressCalledBefore); + }); +}); diff --git a/yarn.lock b/yarn.lock --- a/yarn.lock +++ b/yarn.lock @@ -10373,6 +10373,14 @@ pify "^3.0.0" safe-event-emitter "^1.0.1" +eth-ens-namehash@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/eth-ens-namehash/-/eth-ens-namehash-2.0.8.tgz#229ac46eca86d52e0c991e7cb2aef83ff0f68bcf" + integrity sha512-VWEI1+KJfz4Km//dadyvBBoBeSQ0MHTXPvr8UIXiLW6IanxvAV+DmlZAijZwAyggqGUfwQBeHf7tc9wzc1piSw== + dependencies: + idna-uts46-hx "^2.3.1" + js-sha3 "^0.5.7" + eth-json-rpc-filters@4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/eth-json-rpc-filters/-/eth-json-rpc-filters-4.2.2.tgz#eb35e1dfe9357ace8a8908e7daee80b2cd60a10d" @@ -12749,6 +12757,13 @@ dependencies: harmony-reflect "^1.4.6" +idna-uts46-hx@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/idna-uts46-hx/-/idna-uts46-hx-2.3.1.tgz#a1dc5c4df37eee522bf66d969cc980e00e8711f9" + integrity sha512-PWoF9Keq6laYdIRwwCdhTPl60xRqAloYNMQLiyUnG42VjT53oW07BXIRM+NK7eQjzXjAk2gUvX9caRxlnF9TAA== + dependencies: + punycode "2.1.0" + ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -14288,6 +14303,11 @@ resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== +js-sha3@^0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.5.7.tgz#0d4ffd8002d5333aabaf4a23eed2f6374c9f28e7" + integrity sha512-GII20kjaPX0zJ8wzkTbNDYMY7msuZcTWk8S5UOh6806Jq/wz1J8/bnr8uGU0DAUmYDjj2Mr4X1cW8v/GLYnR+g== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -18423,6 +18443,11 @@ resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= +punycode@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.0.tgz#5f863edc89b96db09074bad7947bf09056ca4e7d" + integrity sha512-Yxz2kRwT90aPiWEMHVYnEf4+rhwF1tBmmZ4KepCP+Wkium9JxtWnUm1nqGwpiAHr/tnTSeHqr3wb++jgSkXjhA== + punycode@^1.2.4: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"