Skip to content

Commit

Permalink
[lib] Batch ENS queries using ReverseRecords smart contract
Browse files Browse the repository at this point in the history
Summary:
This resolves [ENG-2593](https://linear.app/comm/issue/ENG-2593/use-reverserecords-smart-contract-for-bulk-ens-name-lookups). For more info about this smart contract, see [official ENS docs](https://docs.ens.domains/dapp-developer-guide/resolving-names#reverse-resolution) and [GitHub repo](https://github.com/ensdomains/reverse-records).

When I start my iOS app, it needs to resolve 142 ENS names to build the in-memory search indices for @-mentioning and inbox search.

Before this diff, this meant 142 individual network requests to Alchemy. After this diff, it requires only 1.

Depends on D9524

Test Plan:
1. Included updated unit tests.
2. I ran the iOS app and confirmed that the regression described [here](https://linear.app/comm/issue/ENG-5274/ens-resolution-for-chat-mentioning-causes-too-many-react-rerenders#comment-a7405e2f) was resolved.
3. I added a log statement to the code that issues a "single fetch" to confirm that it wasn't getting triggered anymore on iOS app start. (One "single fetch" was actually triggered, but it was for an ENS name that appeared immediately in my inbox... I think this is actually what we want, since we need that result more urgently.)
4. I confirmed that ENS resolution still worked on the iOS app.
5. I confirmed that ENS resolution still worked on the web app.
6. I confirmed that the keyserver is still able to resolve ENS names in notifs.

Reviewers: rohan, atul, tomek

Reviewed By: tomek

Subscribers: tomek, wyilio

Differential Revision: https://phab.comm.dev/D9525
  • Loading branch information
Ashoat committed Oct 19, 2023
1 parent 1225499 commit 8802233
Show file tree
Hide file tree
Showing 5 changed files with 292 additions and 9 deletions.
1 change: 1 addition & 0 deletions lib/types/ethers-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export type EthersProvider = {
+lookupAddress: (address: string) => Promise<?string>,
+resolveName: (name: string) => Promise<?string>,
+getAvatar: (name: string) => Promise<?string>,
+getNetwork: () => Promise<{ +chainId: number, ... }>,
...
};
142 changes: 141 additions & 1 deletion lib/utils/ens-cache.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -50,6 +57,9 @@ const normalizeENSName = (ensName: string) => namehash.normalize(ensName);
// vanilla JS class that handles querying and caching ENS for all cases.
class ENSCache {
provider: EthersProvider;
batchReverseResolverSmartContract: ?ReverseRecordsEthersSmartContract;
batchReverseResolverSmartContractPromise: Promise<ReverseRecordsEthersSmartContract>;

// Maps from normalized ETH address to a cache entry for its name
nameQueryCache: Map<string, ENSNameQueryCacheEntry> = new Map();
// Maps from normalized ETH name to a cache entry for its address
Expand All @@ -59,6 +69,20 @@ class ENSCache {

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".
Expand Down Expand Up @@ -123,6 +147,122 @@ class ENSCache {
})();
}

getNamesForAddresses(
ethAddresses: $ReadOnlyArray<string>,
): Promise<Array<?string>> {
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<?string>;
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);

Expand Down
97 changes: 97 additions & 0 deletions lib/utils/ens-cache.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,22 @@ 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;
Expand Down Expand Up @@ -113,7 +120,94 @@ describe('getNameForAddress', () => {
});
});

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;
Expand Down Expand Up @@ -174,6 +268,9 @@ describe('getAddressForName', () => {
});

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;
Expand Down
16 changes: 8 additions & 8 deletions lib/utils/ens-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,14 @@ async function getENSNames<T: ?BaseUserInfo>(

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 => {
Expand Down
45 changes: 45 additions & 0 deletions lib/utils/reverse-records.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @flow

type ABIParam = {
+internalType: string,
+name: string,
+type: string,
};
type EthereumSmartContractABI = $ReadOnlyArray<{
+inputs: $ReadOnlyArray<ABIParam>,
+stateMutability: string,
+type: string,
+name?: ?string,
+outputs?: ?$ReadOnlyArray<ABIParam>,
}>;

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<string>) => Promise<string[]>,
...
};

export { resolverABI, resolverAddresses };

0 comments on commit 8802233

Please sign in to comment.