diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 62e9b26130..2ec24a03ad 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -2350,6 +2350,20 @@ } } } + }, + "ethereumjs-util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-5.2.0.tgz", + "integrity": "sha512-CJAKdI0wgMbQFLlLRtZKGcy/L6pzVRgelIZqRqNbuVFM3K9VEnyfbcvz0ncWMRNCe4kaHWjwRYQcYMucmwsnWA==", + "requires": { + "bn.js": "^4.11.0", + "create-hash": "^1.1.2", + "ethjs-util": "^0.1.3", + "keccak": "^1.0.2", + "rlp": "^2.0.0", + "safe-buffer": "^5.1.1", + "secp256k1": "^3.0.1" + } } } }, diff --git a/src/third-party/EnsController.ts b/src/third-party/EnsController.ts new file mode 100644 index 0000000000..8fffd9a534 --- /dev/null +++ b/src/third-party/EnsController.ts @@ -0,0 +1,125 @@ +import BaseController, { BaseConfig, BaseState } from '../BaseController'; +import { isValidAddress, toChecksumAddress } from 'ethereumjs-util'; + +/** + * @type EnsEntry + * + * ENS entry representation + * + * @property chainId - Id of the associated chain + * @property ensName - The ENS name + * @property address - Hex address with the ENS name + */ +export interface EnsEntry { + chainId: string; + ensName: string; + address: string | null; +} + +/** + * @type EnsState + * + * ENS controller state + * + * @property ensEntries - Object of ENS entry objects + */ +export interface EnsState extends BaseState { + ensEntries: { [chainId: string]: { [ensName: string]: EnsEntry } }; +} + +/** + * Controller that manages a list ENS names and their resolved addresses + * by chainId + */ +export class EnsController extends BaseController { + /** + * Name of this controller used during composition + */ + name = 'EnsController'; + + /** + * Creates an EnsController instance + * + * @param config - Initial options used to configure this controller + * @param state - Initial state to set on this controller + */ + constructor(config?: Partial, state?: Partial) { + super(config, state); + + this.defaultState = { ensEntries: {} }; + + this.initialize(); + } + + /** + * Remove all chain Ids and ENS entries from state + */ + clear() { + this.update({ ensEntries: {} }); + } + + /** + * Remove a contract entry by address + * + * @param chainId - Parent chain of the ENS entry to delete + * @param ensName - Name of the ENS entry to delete + */ + delete(chainId: string, ensName: string) { + if (!this.state.ensEntries[chainId] || !this.state.ensEntries[chainId][ensName]) { + return false; + } + + const ensEntries = Object.assign({}, this.state.ensEntries); + delete ensEntries[chainId][ensName]; + + if (Object.keys(ensEntries[chainId]).length === 0) { + delete ensEntries[chainId]; + } + + this.update({ ensEntries }); + return true; + } + + /** + * Add or update an ENS entry by chainId and ensName + * + * @param chainId - Id of the associated chain + * @param ensName - The ENS name + * @param address - Associated address to add or update + * @returns - Boolean indicating whether the entry was set + */ + set(chainId: string, ensName: string, address: string | null): boolean { + if ( + !Number.isInteger(Number.parseInt(chainId, 10)) || + !ensName || + typeof ensName !== 'string' || + (address && !isValidAddress(address)) + ) { + throw new Error(`Invalid ENS entry: { chainId:${chainId}, ensName:${ensName}, address:${address}}`); + } + + const normalizedAddress = address ? toChecksumAddress(address) : null; + const subState = this.state.ensEntries[chainId]; + + if (subState && subState[ensName] && subState[ensName].address === normalizedAddress) { + return false; + } + + this.update({ + ensEntries: { + ...this.state.ensEntries, + [chainId]: { + ...this.state.ensEntries[chainId], + [ensName]: { + address: normalizedAddress, + chainId, + ensName + } + } + } + }); + return true; + } +} + +export default EnsController; diff --git a/tests/ComposableController.test.ts b/tests/ComposableController.test.ts index 234a00edff..d041deae26 100644 --- a/tests/ComposableController.test.ts +++ b/tests/ComposableController.test.ts @@ -1,5 +1,6 @@ import { stub } from 'sinon'; import AddressBookController from '../src/user/AddressBookController'; +import EnsController from '../src/third-party/EnsController'; import ComposableController from '../src/ComposableController'; import PreferencesController from '../src/user/PreferencesController'; import TokenRatesController from '../src/assets/TokenRatesController'; @@ -14,6 +15,7 @@ describe('ComposableController', () => { new AddressBookController(), new AssetsController(), new AssetsContractController(), + new EnsController(), new CurrencyRateController(), new NetworkController(), new PreferencesController(), @@ -39,6 +41,9 @@ describe('ComposableController', () => { currentCurrency: 'usd', nativeCurrency: 'ETH' }, + EnsController: { + ensEntries: {} + }, NetworkController: { network: 'loading', provider: { type: 'mainnet' } @@ -60,6 +65,7 @@ describe('ComposableController', () => { new AddressBookController(), new AssetsController(), new AssetsContractController(), + new EnsController(), new CurrencyRateController(), new NetworkController(), new PreferencesController(), @@ -76,6 +82,7 @@ describe('ComposableController', () => { conversionDate: 0, conversionRate: 0, currentCurrency: 'usd', + ensEntries: {}, featureFlags: {}, frequentRpcList: [], identities: {}, @@ -98,6 +105,7 @@ describe('ComposableController', () => { new AssetsController(), new AssetsContractController(), new CurrencyRateController(), + new EnsController(), new NetworkController(), new PreferencesController(), new TokenRatesController() @@ -127,6 +135,7 @@ describe('ComposableController', () => { conversionDate: 0, conversionRate: 0, currentCurrency: 'usd', + ensEntries: {}, featureFlags: {}, frequentRpcList: [], identities: {}, diff --git a/tests/EnsController.test.ts b/tests/EnsController.test.ts new file mode 100644 index 0000000000..97579eac6e --- /dev/null +++ b/tests/EnsController.test.ts @@ -0,0 +1,219 @@ +import { toChecksumAddress } from 'ethereumjs-util'; + +import EnsController from '../src/third-party/EnsController'; + +const address1 = '0x32Be343B94f860124dC4fEe278FDCBD38C102D88'; +const address2 = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; +const address3 = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; +const name1 = 'foo.eth'; +const name2 = 'bar.eth'; + +const address1Checksum = toChecksumAddress(address1); +const address2Checksum = toChecksumAddress(address2); +const address3Checksum = toChecksumAddress(address3); + +describe('EnsController', () => { + it('should set default state', () => { + const controller = new EnsController(); + expect(controller.state).toEqual({ ensEntries: {} }); + }); + + it('should add a new ENS entry and return true', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: address1Checksum, + chainId: '1', + ensName: name1 + } + } + } + }); + }); + + it('should add a new ENS entry with null address and return true', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, null)).toBeTruthy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: null, + chainId: '1', + ensName: name1 + } + } + } + }); + }); + + it('should update an ENS entry and return true', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.set('1', name1, address2)).toBeTruthy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: address2Checksum, + chainId: '1', + ensName: name1 + } + } + } + }); + }); + + it('should update an ENS entry with null address and return true', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.set('1', name1, null)).toBeTruthy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: null, + chainId: '1', + ensName: name1 + } + } + } + }); + }); + + it('should not update an ENS entry if the address is the same (valid address) and return false', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.set('1', name1, address1)).toBeFalsy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: address1Checksum, + chainId: '1', + ensName: name1 + } + } + } + }); + }); + + it('should not update an ENS entry if the address is the same (null) and return false', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, null)).toBeTruthy(); + expect(controller.set('1', name1, null)).toBeFalsy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: null, + chainId: '1', + ensName: name1 + } + } + } + }); + }); + + it('should add multiple ENS entries and update without side effects', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.set('1', name2, address2)).toBeTruthy(); + expect(controller.set('2', name1, address1)).toBeTruthy(); + expect(controller.set('1', name1, address3)).toBeTruthy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: address3Checksum, + chainId: '1', + ensName: name1 + }, + [name2]: { + address: address2Checksum, + chainId: '1', + ensName: name2 + } + }, + 2: { + [name1]: { + address: address1Checksum, + chainId: '2', + ensName: name1 + } + } + } + }); + }); + + it('should throw on attempt to set invalid ENS entry', () => { + const controller = new EnsController(); + expect(() => { + controller.set('1', '1337', 'foo'); + }).toThrowError(); + expect(controller.state).toEqual({ ensEntries: {} }); + }); + + it('should remove an ENS entry and return true', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.delete('1', name1)).toBeTruthy(); + expect(controller.state).toEqual({ ensEntries: {} }); + }); + + it('should return false if an ENS entry was NOT deleted', () => { + const controller = new EnsController(); + controller.set('1', name1, address1); + expect(controller.delete('1', 'bar')).toBeFalsy(); + expect(controller.delete('2', 'bar')).toBeFalsy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name1]: { + address: address1Checksum, + chainId: '1', + ensName: name1 + } + } + } + }); + }); + + it('should add multiple ENS entries and remove without side effects', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.set('1', name2, address2)).toBeTruthy(); + expect(controller.set('2', name1, address1)).toBeTruthy(); + expect(controller.delete('1', name1)).toBeTruthy(); + expect(controller.state).toEqual({ + ensEntries: { + 1: { + [name2]: { + address: address2Checksum, + chainId: '1', + ensName: name2 + } + }, + 2: { + [name1]: { + address: address1Checksum, + chainId: '2', + ensName: name1 + } + } + } + }); + }); + + it('should clear all ENS entries', () => { + const controller = new EnsController(); + expect(controller.set('1', name1, address1)).toBeTruthy(); + expect(controller.set('1', name2, address2)).toBeTruthy(); + expect(controller.set('2', name1, address1)).toBeTruthy(); + controller.clear(); + expect(controller.state).toEqual({ ensEntries: {} }); + }); +});