From 535d3fd5ef2c777104f6aa0e426d011802795f39 Mon Sep 17 00:00:00 2001 From: Malone Hedges Date: Thu, 1 Sep 2022 17:51:32 -0400 Subject: [PATCH] www: add ens support (#62) --- www/components/CreateRoot.tsx | 96 ++++++++++++++++++++++++++++++----- www/utils/addressParsing.ts | 46 +++++++++++++++++ www/utils/ens.ts | 66 ++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 14 deletions(-) create mode 100644 www/utils/ens.ts diff --git a/www/components/CreateRoot.tsx b/www/components/CreateRoot.tsx index 97830d7..ba218d7 100644 --- a/www/components/CreateRoot.tsx +++ b/www/components/CreateRoot.tsx @@ -3,13 +3,49 @@ import classNames from 'classnames' import { useCallback, useEffect, useMemo, useState } from 'react' import { isErrorResponse, createMerkleRoot } from 'utils/api' import Button from 'components/Button' -import { parseAddressesFromText } from 'utils/addressParsing' +import { parseAddressesFromText, prepareAddresses } from 'utils/addressParsing' import { useRouter } from 'next/router' import { randomBytes } from 'crypto' +import { resolveEnsDomains } from 'utils/ens' const useCreateMerkleRoot = () => { - const [{ value, status }, create] = useAsync( - async (addresses: string[]) => await createMerkleRoot(addresses), + const [ensMap, setEnsMap] = useState>({}) + + const [{ value, status, error: reqError }, create] = useAsync( + async (addressesOrENSNames: string[]) => { + let prepared = prepareAddresses(addressesOrENSNames, ensMap) + + if (prepared.unresolvedEnsNames.length > 0) { + const ensAddresses = await resolveEnsDomains( + prepared.unresolvedEnsNames, + ) + + setEnsMap((prev) => ({ + ...prev, + ...ensAddresses, + })) + + prepared = prepareAddresses(addressesOrENSNames, { + ...ensMap, + ...ensAddresses, + }) + + if (prepared.unresolvedEnsNames.length > 0) { + throw new Error(`Could not resolve all ENS names`) + } + } + + if (prepared.addresses.length !== prepared.dedupedAddresses.length) { + return ( + await Promise.all([ + createMerkleRoot(prepared.dedupedAddresses), + createMerkleRoot(prepared.addresses), + ]) + )[0] + } + + return await createMerkleRoot(prepared.dedupedAddresses) + }, ) const merkleRoot = useMemo(() => { @@ -20,10 +56,12 @@ const useCreateMerkleRoot = () => { const error = useMemo(() => { if (isErrorResponse(value)) return value + if (reqError !== undefined) + return { error: true, message: reqError.message } return undefined - }, [value]) + }, [value, reqError]) - return { merkleRoot, error, status, create } + return { merkleRoot, error, status, create, parsedEnsNames: ensMap } } const randomAddress = () => `0x${randomBytes(20).toString('hex')}` @@ -34,6 +72,7 @@ export default function CreateRoot() { error: errorResponse, status, create, + parsedEnsNames, } = useCreateMerkleRoot() const [addressInput, addressInputSet] = useState('') @@ -46,13 +85,10 @@ export default function CreateRoot() { create(addresses) }, [addressInput, create]) - const parsedAddresses = useMemo(() => { - if (addressInput.trim().length === 0) { - return [] - } - - return parseAddressesFromText(addressInput) - }, [addressInput]) + const parsedAddresses = useMemo( + () => parseAddressesFromText(addressInput), + [addressInput], + ) const parsedAddressesCount = useMemo( () => parsedAddresses.length, @@ -75,6 +111,26 @@ export default function CreateRoot() { addressInputSet(addresses.join('\n')) }, []) + const handleRemoveInvalidENSNames = useCallback(() => { + const addresses = parsedAddresses + .filter((address) => { + return ( + !address.includes('.') || + parsedEnsNames[address.toLowerCase()] !== undefined + ) + }) + .join('\n') + addressInputSet(addresses) + }, [parsedAddresses, parsedEnsNames]) + + const showRemoveInvalidENSNames = useMemo( + // show the button if the error message contains `Could not resolve all ENS names` + () => + errorResponse?.message?.includes('Could not resolve all ENS names') ?? + false, + [errorResponse?.message], + ) + const buttonPending = status === 'loading' || merkleRoot !== undefined return ( @@ -90,7 +146,7 @@ export default function CreateRoot() { )} value={addressInput} onChange={(e) => addressInputSet(e.target.value)} - placeholder="Paste addresses here, separated by commas, spaces or new lines" + placeholder="Paste addresses or ENS names here, separated by commas, spaces or new lines" /> @@ -118,9 +174,21 @@ export default function CreateRoot() { )} - {status === 'success' && errorResponse !== undefined && ( + {errorResponse !== undefined && (
Error: {errorResponse.message} + {showRemoveInvalidENSNames && ( + <> + {' '} + + + )}
)} diff --git a/www/utils/addressParsing.ts b/www/utils/addressParsing.ts index d738d88..39ae8df 100644 --- a/www/utils/addressParsing.ts +++ b/www/utils/addressParsing.ts @@ -6,3 +6,49 @@ export function parseAddressesFromText(text: string) { .map((s) => s.trim()) .filter((s) => s.length > 0) } + +export const prepareAddresses = ( + addressesOrENSNames: string[], + ensMap: Record, +): { + addresses: string[] + dedupedAddresses: string[] + unresolvedEnsNames: string[] +} => { + const seenAddresses = new Set() + const unresolvedEnsNames: string[] = [] + const addresses: string[] = [] + const dedupedAddresses: string[] = [] + + for (const addressOrENSName of addressesOrENSNames) { + const lowercasedAddressOrENSName = addressOrENSName.toLowerCase() + if (addressOrENSName.includes('.')) { + const addressFromEns: string | undefined = + ensMap[lowercasedAddressOrENSName]?.toLowerCase() + + if (addressFromEns !== undefined) { + addresses.push(addressFromEns) + if (seenAddresses.has(addressFromEns)) { + continue + } + seenAddresses.add(addressFromEns) + dedupedAddresses.push(addressFromEns) + } else { + unresolvedEnsNames.push(lowercasedAddressOrENSName) + } + } else { + addresses.push(addressOrENSName) + if (seenAddresses.has(lowercasedAddressOrENSName)) { + continue + } + seenAddresses.add(lowercasedAddressOrENSName) + dedupedAddresses.push(addressOrENSName) + } + } + + return { + addresses, + dedupedAddresses, + unresolvedEnsNames, + } +} diff --git a/www/utils/ens.ts b/www/utils/ens.ts new file mode 100644 index 0000000..63755fa --- /dev/null +++ b/www/utils/ens.ts @@ -0,0 +1,66 @@ +const ensSubgraphUrl = 'https://api.thegraph.com/subgraphs/name/ensdomains/ens' + +const query = ` +query DomainsQuery($names: [String!]!) { + domains(where: { name_in: $names }) { + resolvedAddress { + id + } + name + } +} +` + +const resolveEnsDomainsBatch = async ( + ensNames: string[], +): Promise<{ [name: string]: string }> => { + const variables = { + names: ensNames, + } + const response = await fetch(ensSubgraphUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }) + const { + data, + }: { + data?: { + domains: { resolvedAddress: { id: string } | null; name: string }[] + } + } = await response.json() + if (!data) { + throw new Error('No data returned from subgraph') + } + + return data.domains.reduce((acc, domain) => { + if (domain.resolvedAddress !== null) { + acc[domain.name] = domain.resolvedAddress.id + } + return acc + }, {} as { [name: string]: string }) +} + +export const resolveEnsDomains = async ( + ensNames: string[], +): Promise<{ [name: string]: string }> => { + const batches = chunk(ensNames, 100) + const results = await Promise.all(batches.map(resolveEnsDomainsBatch)) + return results.reduce((acc, batch) => { + return { ...acc, ...batch } + }, {} as { [name: string]: string }) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const chunk = (arr: any[], size: number): any[][] => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const chunks: any[][] = [] + let i = 0 + while (i < arr.length) { + chunks.push(arr.slice(i, i + size)) + i += size + } + return chunks +}