From 62cdc7cc4625bb39e797cc03d72082066aa842bd Mon Sep 17 00:00:00 2001 From: tate Date: Wed, 31 Jan 2024 09:26:35 +1100 Subject: [PATCH 1/3] convert getDnsOwner to use new strict pattern --- .../src/functions/dns/getDnsOwner.test.ts | 259 +++++++++++------- .../ensjs/src/functions/dns/getDnsOwner.ts | 88 ++---- packages/ensjs/src/utils/dns.ts | 54 ++++ 3 files changed, 235 insertions(+), 166 deletions(-) create mode 100644 packages/ensjs/src/utils/dns.ts diff --git a/packages/ensjs/src/functions/dns/getDnsOwner.test.ts b/packages/ensjs/src/functions/dns/getDnsOwner.test.ts index ce093ec7..9308c9b8 100644 --- a/packages/ensjs/src/functions/dns/getDnsOwner.test.ts +++ b/packages/ensjs/src/functions/dns/getDnsOwner.test.ts @@ -58,7 +58,7 @@ it('returns valid address from valid domain and record', async () => { expect(result).toEqual('0x8e8Db5CcEF88cca9d624701Db544989C996E3216') }) -it('throws error if .eth', async () => { +it('throws error when .eth', async () => { await expect(getDnsOwner({ name: 'example.eth' })).rejects .toThrowErrorMatchingInlineSnapshot(` "Unsupported name type: eth-2ld @@ -68,7 +68,7 @@ it('throws error if .eth', async () => { Version: ${getVersion()}" `) }) -it('throws error if >2ld', async () => { +it('throws error when >2ld', async () => { await expect(getDnsOwner({ name: 'subdomain.example.com' })).rejects .toThrowErrorMatchingInlineSnapshot(` "Unsupported name type: other-subname @@ -78,118 +78,171 @@ it('throws error if >2ld', async () => { Version: ${getVersion()}" `) }) -it('returns error if DnsResponseStatus is not NOERROR', async () => { - handler.mockImplementation((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/dns-json' }) - res.end( - JSON.stringify({ - Status: 3, // NXDOMAIN - AD: true, - }), - ) - res.destroy() +describe('DnsResponseStatus is not NOERROR', () => { + beforeEach(() => { + handler.mockImplementation((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/dns-json' }) + res.end( + JSON.stringify({ + Status: 3, // NXDOMAIN + AD: true, + }), + ) + res.destroy() + }) }) - - await expect(getDnsOwner({ name: 'example.com', endpoint: serverUrl })) - .rejects.toThrowErrorMatchingInlineSnapshot(` - "DNS query failed with status: NXDOMAIN - - Version: ${getVersion()}" - `) -}) -it('returns error if AD is false', async () => { - handler.mockImplementation((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/dns-json' }) - res.end( - JSON.stringify({ - Status: 0, - AD: false, - }), - ) - res.destroy() + it('strict: throws error', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: true }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "DNS query failed with status: NXDOMAIN + + Version: ${getVersion()}" + `) }) - - await expect(getDnsOwner({ name: 'example.com', endpoint: serverUrl })) - .rejects.toThrowErrorMatchingInlineSnapshot(` - "DNSSEC verification failed - - Version: ${getVersion()}" - `) -}) -it('returns error if no TXT record', async () => { - handler.mockImplementation((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/dns-json' }) - res.end( - JSON.stringify({ - Status: 0, - AD: true, - Answer: [], + it('not strict: returns null', async () => { + await expect( + getDnsOwner({ + name: 'example.com', + endpoint: serverUrl, + strict: false, }), - ) - res.destroy() + ).resolves.toBeNull() }) - - await expect(getDnsOwner({ name: 'example.com', endpoint: serverUrl })) - .rejects.toThrowErrorMatchingInlineSnapshot(` - "No TXT record found - - Version: ${getVersion()}" - `) }) -it('returns error if TXT record is not formatted correctly', async () => { - handler.mockImplementation((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/dns-json' }) - res.end( - JSON.stringify({ - Status: 0, - AD: true, - Answer: [ - { - name: '_ens.example.com', - type: 16, - TTL: 0, - data: '"0x8e8Db5CcEF88cca9d624701Db544989C996E3216"', - }, - ], - }), - ) - res.destroy() +describe('AD is false', () => { + beforeEach(() => { + handler.mockImplementation((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/dns-json' }) + res.end( + JSON.stringify({ + Status: 0, + AD: false, + }), + ) + res.destroy() + }) + }) + it('strict: throws error', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: true }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "DNSSEC verification failed + + Version: ${getVersion()}" + `) + }) + it('not strict: returns null', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: false }), + ).resolves.toBeNull() }) - - await expect(getDnsOwner({ name: 'example.com', endpoint: serverUrl })) - .rejects.toThrowErrorMatchingInlineSnapshot(` - "Invalid TXT record: 0x8e8Db5CcEF88cca9d624701Db544989C996E3216 - - Version: ${getVersion()}" - `) }) -it('returns error if address is not checksummed', async () => { - handler.mockImplementation((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/dns-json' }) - res.end( - JSON.stringify({ - Status: 0, - AD: true, - Answer: [ - { - name: '_ens.example.com', - type: 16, - TTL: 0, - data: '"a=0x8e8db5CcEF88cca9d624701Db544989C996E3216"', - }, - ], - }), - ) - res.destroy() + +describe('no TXT record', () => { + beforeEach(() => { + handler.mockImplementation((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/dns-json' }) + res.end( + JSON.stringify({ + Status: 0, + AD: true, + Answer: [], + }), + ) + res.destroy() + }) + }) + it('strict: throws error', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: true }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "No TXT record found + + Version: ${getVersion()}" + `) }) + it('not strict: returns null', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: false }), + ).resolves.toBeNull() + }) +}) - await expect(getDnsOwner({ name: 'example.com', endpoint: serverUrl })) - .rejects.toThrowErrorMatchingInlineSnapshot(` - "Invalid address checksum: 0x8e8db5CcEF88cca9d624701Db544989C996E3216 +describe('TXT record is not formatted correctly', () => { + beforeEach(() => { + handler.mockImplementation((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/dns-json' }) + res.end( + JSON.stringify({ + Status: 0, + AD: true, + Answer: [ + { + name: '_ens.example.com', + type: 16, + TTL: 0, + data: '"0x8e8Db5CcEF88cca9d624701Db544989C996E3216"', + }, + ], + }), + ) + res.destroy() + }) + }) + it('strict: throws error', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: true }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Invalid TXT record: 0x8e8Db5CcEF88cca9d624701Db544989C996E3216 + + Version: ${getVersion()}" + `) + }) + it('not strict: returns null', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: false }), + ).resolves.toBeNull() + }) +}) - Version: ${getVersion()}" - `) +describe('address is not checksummed', () => { + beforeEach(() => { + handler.mockImplementation((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/dns-json' }) + res.end( + JSON.stringify({ + Status: 0, + AD: true, + Answer: [ + { + name: '_ens.example.com', + type: 16, + TTL: 0, + data: '"a=0x8e8db5ccef88cca9d624701db544989c996e3216"', + }, + ], + }), + ) + res.destroy() + }) + }) + it('strict: throws error', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: true }), + ).rejects.toThrowErrorMatchingInlineSnapshot(` + "Invalid address checksum: 0x8e8db5ccef88cca9d624701db544989c996e3216 + + Version: ${getVersion()}" + `) + }) + it('not strict: returns null', async () => { + await expect( + getDnsOwner({ name: 'example.com', endpoint: serverUrl, strict: false }), + ).resolves.toBeNull() + }) }) + it('real test', async () => { const result = await getDnsOwner({ name: 'taytems.xyz', diff --git a/packages/ensjs/src/functions/dns/getDnsOwner.ts b/packages/ensjs/src/functions/dns/getDnsOwner.ts index bc4ba629..887ecd08 100644 --- a/packages/ensjs/src/functions/dns/getDnsOwner.ts +++ b/packages/ensjs/src/functions/dns/getDnsOwner.ts @@ -7,6 +7,11 @@ import { DnsResponseStatusError, } from '../../errors/dns.js' import { UnsupportedNameTypeError } from '../../errors/general.js' +import { + DnsRecordType, + DnsResponseStatus, + type DnsResponse, +} from '../../utils/dns.js' import { getNameType } from '../../utils/getNameType.js' import type { Endpoint } from './types.js' @@ -15,66 +20,11 @@ export type GetDnsOwnerParameters = { name: string /** An RFC-1035 compatible DNS endpoint to use (default: `https://cloudflare-dns.com/dns-query`) */ endpoint?: Endpoint - /** Optional flag to allow the function to fail silently (default: `true`) */ - allowFailure?: boolean -} - -export type GetDnsOwnerReturnType = Address - -enum DnsResponseStatus { - NOERROR = 0, - FORMERR = 1, - SERVFAIL = 2, - NXDOMAIN = 3, - NOTIMP = 4, - REFUSED = 5, - YXDOMAIN = 6, - YXRRSET = 7, - NXRRSET = 8, - NOTAUTH = 9, - NOTZONE = 10, - DSOTYPENI = 11, - BADVERS = 16, - BADSIG = 16, - BADKEY = 17, - BADTIME = 18, - BADMODE = 19, - BADNAME = 20, - BADALG = 21, - BADTRUNC = 22, - BADCOOKIE = 23, -} - -enum DnsRecordType { - TXT = 16, - DS = 43, - RRSIG = 46, - DNSKEY = 48, + /** Whether or not to throw errors */ + strict?: boolean } -type DnsQuestionItem = { - name: string - type: DnsRecordType -} - -type DnsResponseItem = DnsQuestionItem & { - TTL: number - data: string -} - -type DnsResponse = { - Status: DnsResponseStatus - TC: boolean - RD: boolean - RA: boolean - AD: boolean - CD: boolean - Question: DnsQuestionItem[] - Answer?: DnsResponseItem[] - Authority?: DnsResponseItem[] - Additional?: DnsResponseItem[] - Comment?: string -} +export type GetDnsOwnerReturnType = Address | null /** * Gets the DNS owner of a name, via DNS record lookup @@ -90,6 +40,7 @@ type DnsResponse = { const getDnsOwner = async ({ name, endpoint = 'https://cloudflare-dns.com/dns-query', + strict, }: GetDnsOwnerParameters): Promise => { const nameType = getNameType(name) @@ -109,31 +60,42 @@ const getDnsOwner = async ({ }, ).then((res) => res.json()) - if (response.Status !== DnsResponseStatus.NOERROR) + if (response.Status !== DnsResponseStatus.NOERROR) { + if (!strict) return null throw new DnsResponseStatusError({ responseStatus: DnsResponseStatus[response.Status], }) + } const addressRecord = response.Answer?.find( (record) => record.type === DnsRecordType.TXT, ) const unwrappedAddressRecord = addressRecord?.data?.replace(/^"(.*)"$/g, '$1') - if (response.AD === false) + if (response.AD === false) { + if (!strict) return null throw new DnsDnssecVerificationFailedError({ record: unwrappedAddressRecord, }) + } - if (!addressRecord?.data) throw new DnsNoTxtRecordError() + if (!addressRecord?.data) { + if (!strict) return null + throw new DnsNoTxtRecordError() + } - if (!unwrappedAddressRecord!.match(/^a=0x[a-fA-F0-9]{40}$/g)) + if (!unwrappedAddressRecord!.match(/^a=0x[a-fA-F0-9]{40}$/g)) { + if (!strict) return null throw new DnsInvalidTxtRecordError({ record: unwrappedAddressRecord! }) + } const address = unwrappedAddressRecord!.slice(2) const checksumAddress = getAddress(address) - if (address !== checksumAddress) + if (address !== checksumAddress) { + if (!strict) return null throw new DnsInvalidAddressChecksumError({ address }) + } return checksumAddress } diff --git a/packages/ensjs/src/utils/dns.ts b/packages/ensjs/src/utils/dns.ts new file mode 100644 index 00000000..588e3b21 --- /dev/null +++ b/packages/ensjs/src/utils/dns.ts @@ -0,0 +1,54 @@ +export enum DnsResponseStatus { + NOERROR = 0, + FORMERR = 1, + SERVFAIL = 2, + NXDOMAIN = 3, + NOTIMP = 4, + REFUSED = 5, + YXDOMAIN = 6, + YXRRSET = 7, + NXRRSET = 8, + NOTAUTH = 9, + NOTZONE = 10, + DSOTYPENI = 11, + BADVERS = 16, + BADSIG = 16, + BADKEY = 17, + BADTIME = 18, + BADMODE = 19, + BADNAME = 20, + BADALG = 21, + BADTRUNC = 22, + BADCOOKIE = 23, +} + +export enum DnsRecordType { + TXT = 16, + DS = 43, + RRSIG = 46, + DNSKEY = 48, +} + +export type DnsQuestionItem = { + name: string + type: DnsRecordType +} + +export type DnsResponseItem = DnsQuestionItem & { + TTL: number + data: string +} + +export type DnsResponse = { + Status: DnsResponseStatus + TC: boolean + RD: boolean + RA: boolean + AD: boolean + CD: boolean + Question: DnsQuestionItem[] + Answer?: DnsResponseItem[] + Authority?: DnsResponseItem[] + Additional?: DnsResponseItem[] + Comment?: string +} From bb731b1a095b43cdf83e5fe4ec0dec22eff3c807 Mon Sep 17 00:00:00 2001 From: tate Date: Wed, 31 Jan 2024 12:18:13 +1100 Subject: [PATCH 2/3] getDnsOffchainData func + getDnsTxtRecords --- packages/ensjs/src/dns.ts | 5 + .../functions/dns/getDnsOffchainData.test.ts | 463 ++++++++++++++++++ .../src/functions/dns/getDnsOffchainData.ts | 139 ++++++ .../ensjs/src/functions/dns/getDnsOwner.ts | 76 ++- packages/ensjs/src/test/dns.ts | 12 + .../ensjs/src/utils/dns/getDnsTxtRecords.ts | 38 ++ .../ensjs/src/utils/{dns.ts => dns/misc.ts} | 0 packages/ensjs/src/utils/index.ts | 12 + 8 files changed, 700 insertions(+), 45 deletions(-) create mode 100644 packages/ensjs/src/functions/dns/getDnsOffchainData.test.ts create mode 100644 packages/ensjs/src/functions/dns/getDnsOffchainData.ts create mode 100644 packages/ensjs/src/test/dns.ts create mode 100644 packages/ensjs/src/utils/dns/getDnsTxtRecords.ts rename packages/ensjs/src/utils/{dns.ts => dns/misc.ts} (100%) 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, From e563a149e8fc6be0f717396981a09832dcaf629c Mon Sep 17 00:00:00 2001 From: tate Date: Wed, 31 Jan 2024 12:39:03 +1100 Subject: [PATCH 3/3] remove duplicate dns status enum --- packages/ensjs/src/utils/dns/misc.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ensjs/src/utils/dns/misc.ts b/packages/ensjs/src/utils/dns/misc.ts index 588e3b21..b700a96b 100644 --- a/packages/ensjs/src/utils/dns/misc.ts +++ b/packages/ensjs/src/utils/dns/misc.ts @@ -11,8 +11,7 @@ export enum DnsResponseStatus { NOTAUTH = 9, NOTZONE = 10, DSOTYPENI = 11, - BADVERS = 16, - BADSIG = 16, + BADVERS = 16, // also 16 = BADSIG but not important BADKEY = 17, BADTIME = 18, BADMODE = 19,