diff --git a/packages/ensjs/src/functions/getProfile.ts b/packages/ensjs/src/functions/getProfile.ts index 1f44ab81..b7b4ec9d 100644 --- a/packages/ensjs/src/functions/getProfile.ts +++ b/packages/ensjs/src/functions/getProfile.ts @@ -1,11 +1,11 @@ import { formatsByName } from '@ensdomains/address-encoder' import { defaultAbiCoder } from '@ethersproject/abi' +import { isAddress } from '@ethersproject/address' import { hexStripZeros, isBytesLike } from '@ethersproject/bytes' import { ENSArgs } from '..' import { decodeContenthash, DecodedContentHash } from '../utils/contentHash' import { hexEncodeName } from '../utils/hexEncodedName' import { namehash } from '../utils/normalise' -import { parseInputType } from '../utils/validation' type InternalProfileOptions = { contentHash?: boolean | string | DecodedContentHash @@ -653,13 +653,9 @@ export default async function ( } } - const inputType = parseInputType(nameOrAddress) + const inputIsAddress = isAddress(nameOrAddress) - if (inputType.type === 'unknown' || inputType.info === 'unsupported') { - throw new Error('Invalid input type') - } - - if (inputType.type === 'address') { + if (inputIsAddress) { return getProfileFromAddress( { contracts, diff --git a/packages/ensjs/src/functions/getRecords.ts b/packages/ensjs/src/functions/getRecords.ts deleted file mode 100644 index ea62e3ee..00000000 --- a/packages/ensjs/src/functions/getRecords.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ENSArgs } from '..' -import { parseInputType } from '../utils/validation' - -type ProfileOptions = { - contentHash?: boolean - texts?: boolean | string[] - coinTypes?: boolean | string[] - resolverAddress?: string -} - -export default async function ( - { getProfile }: ENSArgs<'getProfile'>, - name: string, - options?: ProfileOptions, -) { - const inputType = parseInputType(name) - - if (inputType.type !== 'name' && inputType.type !== 'label') { - throw new Error('Input must be an ENS name') - } - - return getProfile(name, options) -} diff --git a/packages/ensjs/src/functions/initialGetters.ts b/packages/ensjs/src/functions/initialGetters.ts index 37d479fd..92c500f8 100644 --- a/packages/ensjs/src/functions/initialGetters.ts +++ b/packages/ensjs/src/functions/initialGetters.ts @@ -6,7 +6,6 @@ export { default as getNames } from './getNames' export { default as getOwner } from './getOwner' export { default as getPrice } from './getPrice' export { default as getProfile } from './getProfile' -export { default as getRecords } from './getRecords' export * from './getSpecificRecord' export { default as getSubnames } from './getSubnames' export { default as supportsTLD } from './supportsTLD' diff --git a/packages/ensjs/src/functions/types.ts b/packages/ensjs/src/functions/types.ts index c100b0dd..3137714d 100644 --- a/packages/ensjs/src/functions/types.ts +++ b/packages/ensjs/src/functions/types.ts @@ -17,7 +17,6 @@ import type getNames from './getNames' import type getOwner from './getOwner' import type getPrice from './getPrice' import type getProfile from './getProfile' -import type getRecords from './getRecords' import type getResolver from './getResolver' import type { getABI, @@ -71,7 +70,6 @@ type Function = { getOwner: typeof getOwner getPrice: typeof getPrice getProfile: typeof getProfile - getRecords: typeof getRecords getResolver: typeof getResolver getAddr: typeof getAddr getContentHash: typeof getContentHash diff --git a/packages/ensjs/src/index.ts b/packages/ensjs/src/index.ts index 1e7eff22..27aadc05 100644 --- a/packages/ensjs/src/index.ts +++ b/packages/ensjs/src/index.ts @@ -431,12 +431,6 @@ export class ENS { 'getProfile', ) - public getRecords = this.generateFunction( - 'initialGetters', - ['getProfile'], - 'getRecords', - ) - public getName = this.generateRawFunction( 'initialGetters', ['contracts'], diff --git a/packages/ensjs/src/tests/func-imports.ts b/packages/ensjs/src/tests/func-imports.ts index d72b9cbe..f554364d 100644 --- a/packages/ensjs/src/tests/func-imports.ts +++ b/packages/ensjs/src/tests/func-imports.ts @@ -17,7 +17,6 @@ import getNames from '../functions/getNames' import getOwner from '../functions/getOwner' import getPrice from '../functions/getPrice' import getProfile from '../functions/getProfile' -import getRecords from '../functions/getRecords' import getResolver from '../functions/getResolver' import { getABI, @@ -70,7 +69,6 @@ export default { getOwner, getPrice, getProfile, - getRecords, getResolver, getAddr, getContentHash, diff --git a/packages/ensjs/src/utils/consts.ts b/packages/ensjs/src/utils/consts.ts index 3c656ed8..542d5c99 100644 --- a/packages/ensjs/src/utils/consts.ts +++ b/packages/ensjs/src/utils/consts.ts @@ -1,2 +1,3 @@ export const EMPTY_ADDRESS = '0x0000000000000000000000000000000000000000' export const MAX_INT_64 = BigInt('18446744073709551615') +export const MINIMUM_DOT_ETH_CHARS = 3 diff --git a/packages/ensjs/src/utils/validation.test.ts b/packages/ensjs/src/utils/validation.test.ts new file mode 100644 index 00000000..ec562b05 --- /dev/null +++ b/packages/ensjs/src/utils/validation.test.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { parseInput, validateName } from './validation' + +declare namespace localStorage { + const getItem: jest.MockedFn + const setItem: jest.MockedFn + const removeItem: jest.MockedFn +} + +const labelsMock = { + '0x68371d7e884c168ae2022c82bd837d51837718a7f7dfb7aa3f753074a35e1d87': + 'something', + '0x4f5b812789fc606be1b3b16908db13fc7a9adf7ca72641f84d75b47069d3d7f0': 'eth', +} + +const labelsMockJSON = JSON.stringify(labelsMock) + +describe('validateName()', () => { + beforeEach(() => { + localStorage.getItem.mockClear() + localStorage.setItem.mockClear() + localStorage.getItem.mockImplementation(() => labelsMockJSON) + }) + it('should throw if the name has an empty label', () => { + expect(() => validateName('foo..bar')).toThrowError( + 'Name cannot have empty labels', + ) + expect(() => validateName('.foo.bar')).toThrowError( + 'Name cannot have empty labels', + ) + expect(() => validateName('foo.bar.')).toThrowError( + 'Name cannot have empty labels', + ) + }) + it('should allow names with [root] as a label', () => { + expect(validateName('[root]')).toEqual('[root]') + }) + it('should throw if the name has [root] as a label and is not the only label', () => { + expect(() => validateName('foo.[root].bar')).toThrowError( + 'Root label must be the only label', + ) + }) + + it('should get the decoded label hash from local storage', () => { + expect( + validateName( + 'thing.[68371d7e884c168ae2022c82bd837d51837718a7f7dfb7aa3f753074a35e1d87].eth', + ), + ).toEqual('thing.something.eth') + expect(localStorage.getItem).toHaveBeenCalled() + }) + it('should fallback to encoded label hash if the decoded label hash is not in local storage', () => { + expect( + validateName( + 'something.[8c6c947d200f53fa1127b152f95b118f9e1d0eeb06fc678b6fc8a6d5c6fc5e17].eth', + ), + ).toEqual( + 'something.[8c6c947d200f53fa1127b152f95b118f9e1d0eeb06fc678b6fc8a6d5c6fc5e17].eth', + ) + expect(localStorage.getItem).toHaveBeenCalled() + expect( + validateName( + '[8c6c947d200f53fa1127b152f95b118f9e1d0eeb06fc678b6fc8a6d5c6fc5e17]', + ), + ).toEqual( + '[8c6c947d200f53fa1127b152f95b118f9e1d0eeb06fc678b6fc8a6d5c6fc5e17]', + ) + }) + it('should normalise the name', () => { + expect(validateName('aAaaA.eth')).toEqual('aaaaa.eth') + }) + it('should save the normalised name to local storage', () => { + validateName('swAgCity.eth') + expect(localStorage.setItem).toHaveBeenCalledWith( + 'ensjs:labels', + JSON.stringify({ + ...labelsMock, + '0xee3bbc5c1db3f1dbd5ec4cdfd627fb76aba6215d814354b6c0f8d74253adf1a8': + 'swagcity', + }), + ) + }) + it('should return the normalised name', () => { + expect(validateName('swAgCity.eth')).toEqual('swagcity.eth') + }) +}) + +describe('parseInput()', () => { + it('should parse the input', () => { + expect(parseInput('bar.eth')).toEqual({ + type: 'name', + normalised: 'bar.eth', + isShort: false, + isValid: true, + is2LD: true, + isETH: true, + labelDataArray: expect.any(Array), + }) + }) + it('should return a normalised name', () => { + expect(parseInput('bAr.etH').normalised).toEqual('bar.eth') + }) + it('should parse the input if it is invalid', () => { + expect(parseInput('bar..eth')).toEqual({ + type: 'name', + normalised: undefined, + isShort: false, + isValid: false, + is2LD: false, + isETH: true, + labelDataArray: expect.any(Array), + }) + }) + it('should return type as label if input is a label', () => { + expect(parseInput('bar').type).toEqual('label') + }) + describe('should return correct value', () => { + describe('isShort', () => { + it('should return true if input is label and less than 3 characters', () => { + expect(parseInput('ba').isShort).toEqual(true) + }) + it('should return true if input is less than 3 char emoji', () => { + expect(parseInput('πŸ‡ΊπŸ‡Έ').isShort).toEqual(true) + }) + it('should return false if input is 3 char emoji', () => { + expect(parseInput('πŸ³οΈβ€πŸŒˆ').isShort).toEqual(false) + }) + it('should return false if input is label and 3 characters', () => { + expect(parseInput('bar').isShort).toEqual(false) + }) + it('should return true if input is 2LD .eth name and label is less than 3 characters', () => { + expect(parseInput('ba.eth').isShort).toEqual(true) + }) + it('should return false if input is 2LD .eth name and label is 3 characters', () => { + expect(parseInput('bar.eth').isShort).toEqual(false) + }) + it('should return false if input is 2LD other name and label is less than 3 characters', () => { + expect(parseInput('ba.com').isShort).toEqual(false) + }) + it('should return false if input is 3LD .eth name and label is less than 3 characters', () => { + expect(parseInput('ba.bar.eth').isShort).toEqual(false) + }) + }) + describe('is2LD', () => { + it('should return true if input is 2LD name', () => { + expect(parseInput('bar.eth').is2LD).toEqual(true) + }) + it('should return false if input is 3LD name', () => { + expect(parseInput('bar.foo.eth').is2LD).toEqual(false) + }) + it('should return false if input is label', () => { + expect(parseInput('bar').is2LD).toEqual(false) + }) + }) + describe('isETH', () => { + it('should return true if input is .eth name', () => { + expect(parseInput('bar.eth').isETH).toEqual(true) + }) + it('should return false if input is other name', () => { + expect(parseInput('bar.com').isETH).toEqual(false) + }) + }) + }) +}) diff --git a/packages/ensjs/src/utils/validation.ts b/packages/ensjs/src/utils/validation.ts index 303dfe1c..10ba29a1 100644 --- a/packages/ensjs/src/utils/validation.ts +++ b/packages/ensjs/src/utils/validation.ts @@ -1,73 +1,73 @@ -import { isAddress } from '@ethersproject/address' -import { isEncodedLabelhash, saveName } from './labels' -import { normalise } from './normalise' +import { MINIMUM_DOT_ETH_CHARS } from './consts' +import { checkLabel, isEncodedLabelhash, saveName } from './labels' +import { Label, normalise, split } from './normalise' export const validateName = (name: string) => { const nameArray = name.split('.') - const hasEmptyLabels = nameArray.some((label) => label.length === 0) - if (hasEmptyLabels) throw new Error('Name cannot have empty labels') - const normalizedArray = nameArray.map((label) => { + const normalisedArray = nameArray.map((label) => { + if (label.length === 0) throw new Error('Name cannot have empty labels') if (label === '[root]') { + if (name !== label) throw new Error('Root label must be the only label') return label } - return isEncodedLabelhash(label) ? label : normalise(label) + return isEncodedLabelhash(label) + ? checkLabel(label) || label + : normalise(label) }) - const normalizedName = normalizedArray.join('.') - saveName(normalizedName) - return normalizedName + const normalisedName = normalisedArray.join('.') + saveName(normalisedName) + return normalisedName } -export const validateTLD = (name: string) => { - const labels = name.split('.') - return validateName(labels[labels.length - 1]) +export type ParsedInputResult = { + type: 'name' | 'label' + normalised: string | undefined + isValid: boolean + isShort: boolean + is2LD: boolean + isETH: boolean + labelDataArray: Label[] } -type InputType = { - type: 'name' | 'label' | 'address' | 'unknown' - info?: 'short' | 'supported' | 'unsupported' -} - -export const parseInputType = (input: string): InputType => { - const validTLD = validateTLD(input) - const regex = /[^.]+$/ +export const parseInput = (input: string): ParsedInputResult => { + let nameReference = input + let isValid = false try { - validateName(input) - } catch (e) { - return { - type: 'unknown', - } - } + nameReference = validateName(input) + isValid = true + } catch {} - if (input.indexOf('.') !== -1) { - const termArray = input.split('.') - const tld = input.match(regex) ? input.match(regex)![0] : '' - if (validTLD) { - if (tld === 'eth' && [...termArray[termArray.length - 2]].length < 3) { - // code-point length - return { - type: 'name', - info: 'short', - } - } - return { - type: 'name', - info: 'supported', - } - } + const normalisedName = isValid ? nameReference : undefined + const labels = nameReference.split('.') + const tld = labels[labels.length - 1] + const isETH = tld === 'eth' + const labelDataArray = split(nameReference) + const isShort = + (labelDataArray[0].output?.length || 0) < MINIMUM_DOT_ETH_CHARS + + if (labels.length === 1) { return { - type: 'name', - info: 'unsupported', - } - } - if (isAddress(input)) { - return { - type: 'address', + type: 'label', + normalised: normalisedName, + isShort, + isValid, + is2LD: false, + isETH, + labelDataArray, } } + + const is2LD = labels.length === 2 return { - type: 'label', + type: 'name', + normalised: normalisedName, + isShort: isETH && is2LD ? isShort : false, + isValid, + is2LD, + isETH, + labelDataArray, } }