diff --git a/packages/ensjs/src/dns.ts b/packages/ensjs/src/dns.ts index b4f8fb06..62b5273c 100644 --- a/packages/ensjs/src/dns.ts +++ b/packages/ensjs/src/dns.ts @@ -3,6 +3,11 @@ export { type GetDnsImportDataParameters, type GetDnsImportDataReturnType, } from './functions/dns/getDnsImportData.js' +export { + default as getDnsOffchainData, + type GetDnsOffchainDataParameters, + type GetDnsOffchainDataReturnType, +} from './functions/dns/getDnsOffchainData.js' export { default as getDnsOwner, type GetDnsOwnerParameters, diff --git a/packages/ensjs/src/functions/dns/getDnsOffchainData.test.ts b/packages/ensjs/src/functions/dns/getDnsOffchainData.test.ts new file mode 100644 index 00000000..a154df6f --- /dev/null +++ b/packages/ensjs/src/functions/dns/getDnsOffchainData.test.ts @@ -0,0 +1,463 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import type { RequestListener } from 'http' +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' +import { getVersion } from '../../errors/error-utils.js' +import { addEnsContracts } from '../../index.js' +import { createHttpServer } from '../../test/createHttpServer.js' +import { createHandlerResponse } from '../../test/dns.js' +import getDnsOffchainData from './getDnsOffchainData.js' + +const handler: jest.MockedFunction = jest.fn() +let closeServer: () => Promise +let serverUrl: `http://${string}` = 'http://' + +beforeAll(async () => { + const { close, url } = await createHttpServer(handler) + closeServer = close + serverUrl = url +}) + +afterAll(async () => { + await closeServer() +}) + +beforeEach(() => { + handler.mockReset() +}) + +jest.setTimeout(10000) +jest.retryTimes(2) + +const mainnetPublicClient = createPublicClient({ + chain: addEnsContracts(mainnet), + transport: http('https://web3.ens.domains/v1/mainnet'), +}) + +it('returns offchain data', async () => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01"', + }, + ], + }) + + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + }), + ).resolves.toMatchInlineSnapshot(` + { + "extraData": null, + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) + +it('returns offchain data with extra data as address', async () => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 0x8e8Db5CcEF88cca9d624701Db544989C996E3216"', + }, + ], + }) + + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + }), + ).resolves.toMatchInlineSnapshot(` + { + "extraData": "0x8e8Db5CcEF88cca9d624701Db544989C996E3216", + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) + +it('returns offchain data with extra data as text', async () => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01 hello world"', + }, + ], + }) + + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + }), + ).resolves.toMatchInlineSnapshot(` + { + "extraData": "hello world", + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) + +it('returns offchain data from ens name', async () => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 dnsname.ens.eth"', + }, + ], + }) + + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + }), + ).resolves.toMatchInlineSnapshot(` + { + "extraData": null, + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) + +it('returns first offchain data from multiple', async () => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01"', + }, + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x8e8Db5CcEF88cca9d624701Db544989C996E3216"', + }, + ], + }) + + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + }), + ).resolves.toMatchInlineSnapshot(` + { + "extraData": null, + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) + +it('returns first valid offchain data when multiple invalid', async () => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F7"', + }, + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 randomnonsense"', + }, + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01"', + }, + ], + }) + + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + }), + ).resolves.toMatchInlineSnapshot(` + { + "extraData": null, + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) + +it('allows subname input', async () => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'sub.example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F792dFA6033814B18618aD4100654aeef01"', + }, + ], + }) + + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'sub.example.com', + endpoint: serverUrl, + }), + ).resolves.toMatchInlineSnapshot(` + { + "extraData": null, + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) + +it('throws error when name type is .eth', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.eth', + endpoint: serverUrl, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Unsupported name type: eth-2ld + + - Supported name types: other-2ld, other-subname + + Version: ${getVersion()}" + `) +}) + +describe('DnsResponseStatus is not NOERROR', () => { + beforeEach(() => { + createHandlerResponse(handler, { + Status: 3, // NXDOMAIN + AD: true, + }) + }) + + it('strict: throws error', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: true, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "DNS query failed with status: NXDOMAIN + + Version: ${getVersion()}" + `) + }) + + it('not strict: returns null', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: false, + }), + ).resolves.toBeNull() + }) +}) + +describe('AD is false', () => { + beforeEach(() => { + createHandlerResponse(handler, { + Status: 0, + AD: false, + }) + }) + + it('strict: throws error', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: true, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "DNSSEC verification failed + + Version: ${getVersion()}" + `) + }) + + it('not strict: returns null', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: false, + }), + ).resolves.toBeNull() + }) +}) + +describe('no TXT records', () => { + beforeEach(() => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + }) + }) + + it('strict: throws error', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: true, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "No TXT record found + + Version: ${getVersion()}" + `) + }) + + it('not strict: returns null', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: false, + }), + ).resolves.toBeNull() + }) +}) + +describe('only invalid records', () => { + beforeEach(() => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 0x238A8F7"', + }, + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"ENS1 randomnonsense"', + }, + ], + }) + }) + + it('strict: throws error', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: true, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Invalid TXT record: ENS1 0x238A8F7 + + Version: ${getVersion()}" + `) + }) + + it('not strict: returns null', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: false, + }), + ).resolves.toBeNull() + }) +}) + +describe('no eligible invalid records', () => { + beforeEach(() => { + createHandlerResponse(handler, { + Status: 0, + AD: true, + Answer: [ + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"foo bar"', + }, + { + name: 'example.com', + type: 16, + TTL: 0, + data: '"random"', + }, + ], + }) + }) + + it('strict: throws error', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: true, + }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "No TXT record found + + Version: ${getVersion()}" + `) + }) + + it('not strict: returns null', async () => { + await expect( + getDnsOffchainData(mainnetPublicClient, { + name: 'example.com', + endpoint: serverUrl, + strict: false, + }), + ).resolves.toBeNull() + }) +}) + +it('real test', async () => { + const offchainData = await getDnsOffchainData(mainnetPublicClient, { + name: 'ethleaderboard.xyz', + strict: true, + }) + expect(offchainData).toMatchInlineSnapshot(` + { + "extraData": "0xD0AeA65bb96b823cb30724ee0a6B7588c77dE486", + "resolverAddress": "0x238A8F792dFA6033814B18618aD4100654aeef01", + } + `) +}) diff --git a/packages/ensjs/src/functions/dns/getDnsOffchainData.ts b/packages/ensjs/src/functions/dns/getDnsOffchainData.ts new file mode 100644 index 00000000..75d07048 --- /dev/null +++ b/packages/ensjs/src/functions/dns/getDnsOffchainData.ts @@ -0,0 +1,139 @@ +import { isAddress, type Address, type Client, type Transport } from 'viem' +import type { ChainWithEns } from '../../contracts/consts.js' +import { + DnsDnssecVerificationFailedError, + DnsInvalidTxtRecordError, + DnsNoTxtRecordError, + DnsResponseStatusError, +} from '../../errors/dns.js' +import { UnsupportedNameTypeError } from '../../errors/general.js' +import { getDnsTxtRecords } from '../../utils/dns/getDnsTxtRecords.js' +import { + DnsRecordType, + DnsResponseStatus, + type DnsResponseItem, +} from '../../utils/dns/misc.js' +import { getNameType } from '../../utils/getNameType.js' +import getAddressRecord from '../public/getAddressRecord.js' +import type { Endpoint } from './types.js' + +export type GetDnsOffchainDataParameters = { + /** Name to get the offchain data for */ + name: string + /** An RFC-1035 compatible DNS endpoint to use (default: `https://cloudflare-dns.com/dns-query`) */ + endpoint?: Endpoint + /** Whether or not to throw errors */ + strict?: boolean +} + +export type GetDnsOffchainDataReturnType = { + resolverAddress: Address + extraData: string | null +} | null + +type ValidTextRecord = { + isValid: true + resolverAddress: Address + extraData: string | null +} +type InvalidTextRecord = { isValid: false; recordData: string } + +const checkValidEnsTxtRecord = async ( + client: Client, + record: DnsResponseItem, +): Promise => { + if (record.type !== DnsRecordType.TXT) return null + if (!record.data.startsWith('"ENS1 ')) return null + + const unwrappedRecordData = record.data.replace(/^"(.*)"$/g, '$1') + + const resolverAndExtraData = unwrappedRecordData.slice(5) + const splitIndex = resolverAndExtraData.indexOf(' ') + const resolverNameOrAddress = + splitIndex === -1 + ? resolverAndExtraData + : resolverAndExtraData.slice(0, splitIndex) + const extraData = + splitIndex === -1 ? null : resolverAndExtraData.slice(splitIndex + 1) + + if (isAddress(resolverNameOrAddress)) + return { isValid: true, resolverAddress: resolverNameOrAddress, extraData } + + const resolverAddress = await getAddressRecord(client, { + name: resolverNameOrAddress, + // force no ccip-read, since dnsregistrar doesn't allow resolvers with ccip-read addresses + gatewayUrls: [], + }).catch(() => null) // if ccip-read is attempted, an error will be thrown. we can just ignore it + + if (resolverAddress) + return { + isValid: true, + resolverAddress: resolverAddress.value as Address, + extraData, + } + + return { isValid: false, recordData: unwrappedRecordData } +} + +/** + * Gets the DNS offchain data for a name, via DNS record lookup + * @param parameters - {@link GetDnsOffchainDataParameters} + * @returns Resolver address and extra data, or null. {@link GetDnsOffchainDataReturnType} + * + * @example + * import { getDnsOffchainData } from '@ensdomains/ensjs/dns' + * + * const owner = await getDnsOffchainData({ name: 'ethleaderboard.xyz' }) + */ +const getDnsOffchainData = async ( + client: Client, + { name, endpoint, strict }: GetDnsOffchainDataParameters, +): Promise => { + const nameType = getNameType(name) + + if (nameType !== 'other-2ld' && nameType !== 'other-subname') + throw new UnsupportedNameTypeError({ + nameType, + supportedNameTypes: ['other-2ld', 'other-subname'], + }) + + try { + const response = await getDnsTxtRecords({ name, endpoint }) + + if (response.Status !== DnsResponseStatus.NOERROR) + throw new DnsResponseStatusError({ + responseStatus: DnsResponseStatus[response.Status], + }) + + if (response.AD === false) + throw new DnsDnssecVerificationFailedError({ record: undefined }) + + if (!response.Answer?.length) throw new DnsNoTxtRecordError() + + const ensTxtRecords = await Promise.all( + response.Answer.map((record) => checkValidEnsTxtRecord(client, record)), + ) + + const validRecord = ensTxtRecords.find( + (record): record is ValidTextRecord => record?.isValid === true, + ) + if (validRecord) + return { + resolverAddress: validRecord.resolverAddress, + extraData: validRecord.extraData, + } + + const invalidRecord = ensTxtRecords.find( + (record): record is InvalidTextRecord => record?.isValid === false, + ) + if (invalidRecord) + throw new DnsInvalidTxtRecordError({ record: invalidRecord.recordData }) + + throw new DnsNoTxtRecordError() + } catch (error) { + if (!strict) return null + throw error + } +} + +export default getDnsOffchainData diff --git a/packages/ensjs/src/functions/dns/getDnsOwner.ts b/packages/ensjs/src/functions/dns/getDnsOwner.ts index 887ecd08..7c40fee3 100644 --- a/packages/ensjs/src/functions/dns/getDnsOwner.ts +++ b/packages/ensjs/src/functions/dns/getDnsOwner.ts @@ -7,11 +7,8 @@ import { DnsResponseStatusError, } from '../../errors/dns.js' import { UnsupportedNameTypeError } from '../../errors/general.js' -import { - DnsRecordType, - DnsResponseStatus, - type DnsResponse, -} from '../../utils/dns.js' +import { getDnsTxtRecords } from '../../utils/dns/getDnsTxtRecords.js' +import { DnsRecordType, DnsResponseStatus } from '../../utils/dns/misc.js' import { getNameType } from '../../utils/getNameType.js' import type { Endpoint } from './types.js' @@ -39,7 +36,7 @@ export type GetDnsOwnerReturnType = Address | null */ const getDnsOwner = async ({ name, - endpoint = 'https://cloudflare-dns.com/dns-query', + endpoint, strict, }: GetDnsOwnerParameters): Promise => { const nameType = getNameType(name) @@ -50,54 +47,43 @@ const getDnsOwner = async ({ supportedNameTypes: ['other-2ld'], }) - const response: DnsResponse = await fetch( - `${endpoint}?name=_ens.${name}.&type=TXT`, - { - method: 'GET', - headers: { - accept: 'application/dns-json', - }, - }, - ).then((res) => res.json()) + try { + const response = await getDnsTxtRecords({ name: `_ens.${name}`, endpoint }) - if (response.Status !== DnsResponseStatus.NOERROR) { - if (!strict) return null - throw new DnsResponseStatusError({ - responseStatus: DnsResponseStatus[response.Status], - }) - } + if (response.Status !== DnsResponseStatus.NOERROR) + throw new DnsResponseStatusError({ + responseStatus: DnsResponseStatus[response.Status], + }) - const addressRecord = response.Answer?.find( - (record) => record.type === DnsRecordType.TXT, - ) - const unwrappedAddressRecord = addressRecord?.data?.replace(/^"(.*)"$/g, '$1') + const addressRecord = response.Answer?.find( + (record) => record.type === DnsRecordType.TXT, + ) + const unwrappedAddressRecord = addressRecord?.data?.replace( + /^"(.*)"$/g, + '$1', + ) - if (response.AD === false) { - if (!strict) return null - throw new DnsDnssecVerificationFailedError({ - record: unwrappedAddressRecord, - }) - } + if (response.AD === false) + throw new DnsDnssecVerificationFailedError({ + record: unwrappedAddressRecord, + }) - if (!addressRecord?.data) { - if (!strict) return null - throw new DnsNoTxtRecordError() - } + if (!addressRecord?.data) throw new DnsNoTxtRecordError() - if (!unwrappedAddressRecord!.match(/^a=0x[a-fA-F0-9]{40}$/g)) { - if (!strict) return null - throw new DnsInvalidTxtRecordError({ record: unwrappedAddressRecord! }) - } + if (!unwrappedAddressRecord!.match(/^a=0x[a-fA-F0-9]{40}$/g)) + throw new DnsInvalidTxtRecordError({ record: unwrappedAddressRecord! }) + + const address = unwrappedAddressRecord!.slice(2) + const checksumAddress = getAddress(address) - const address = unwrappedAddressRecord!.slice(2) - const checksumAddress = getAddress(address) + if (address !== checksumAddress) + throw new DnsInvalidAddressChecksumError({ address }) - if (address !== checksumAddress) { + return checksumAddress + } catch (error) { if (!strict) return null - throw new DnsInvalidAddressChecksumError({ address }) + throw error } - - return checksumAddress } export default getDnsOwner diff --git a/packages/ensjs/src/test/dns.ts b/packages/ensjs/src/test/dns.ts new file mode 100644 index 00000000..416a802c --- /dev/null +++ b/packages/ensjs/src/test/dns.ts @@ -0,0 +1,12 @@ +import type { RequestListener } from 'http' + +type Handler = jest.MockedFunction + +export const createHandlerResponse = (handler: Handler, response: object) => { + handler.mockImplementation((_req, res) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + res.writeHead(200, { 'Content-Type': 'application/dns-json' }) + res.end(JSON.stringify(response)) + res.destroy() + }) +} diff --git a/packages/ensjs/src/utils/dns/getDnsTxtRecords.ts b/packages/ensjs/src/utils/dns/getDnsTxtRecords.ts new file mode 100644 index 00000000..368cc2bd --- /dev/null +++ b/packages/ensjs/src/utils/dns/getDnsTxtRecords.ts @@ -0,0 +1,38 @@ +import type { Endpoint } from '../../functions/dns/types.js' +import { type DnsResponse } from './misc.js' + +export type GetDnsTxtRecordsParameters = { + /** Name to get the txt records for */ + name: string + /** An RFC-1035 compatible DNS endpoint to use (default: `https://cloudflare-dns.com/dns-query`) */ + endpoint?: Endpoint +} + +export type GetDnsTxtRecordsReturnType = DnsResponse + +/** + * Gets the DNS record response of a name, via DNS record lookup + * @param parameters - {@link GetDnsTxtRecordsParameters} + * @returns DNS response. {@link GetDnsTxtRecordsReturnType} + * + * @example + * import { getDnsTxtRecords } from '@ensdomains/ensjs/utils' + * + * const owner = await getDnsTxtRecords({ name: '_ens.ens.domains' }) + */ +export const getDnsTxtRecords = async ({ + name, + endpoint = 'https://cloudflare-dns.com/dns-query', +}: GetDnsTxtRecordsParameters): Promise => { + const response: DnsResponse = await fetch( + `${endpoint}?name=${name}.&type=TXT`, + { + method: 'GET', + headers: { + accept: 'application/dns-json', + }, + }, + ).then((res) => res.json()) + + return response +} diff --git a/packages/ensjs/src/utils/dns.ts b/packages/ensjs/src/utils/dns/misc.ts similarity index 100% rename from packages/ensjs/src/utils/dns.ts rename to packages/ensjs/src/utils/dns/misc.ts diff --git a/packages/ensjs/src/utils/index.ts b/packages/ensjs/src/utils/index.ts index d1615267..27ae582b 100644 --- a/packages/ensjs/src/utils/index.ts +++ b/packages/ensjs/src/utils/index.ts @@ -1,3 +1,15 @@ +export { + getDnsTxtRecords, + type GetDnsTxtRecordsParameters, + type GetDnsTxtRecordsReturnType, +} from './dns/getDnsTxtRecords.js' +export { + DnsRecordType, + DnsResponseStatus, + type DnsQuestionItem, + type DnsResponse, + type DnsResponseItem, +} from './dns/misc.js' export { contentTypeToEncodeAs, encodeAbi,