diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 18526b5dd5b..2272b15fcda 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -2,9 +2,6 @@ "packages/accounts-controller/src/AccountsController.test.ts": { "import-x/namespace": 1 }, - "packages/address-book-controller/src/AddressBookController.ts": { - "jsdoc/check-tag-names": 13 - }, "packages/approval-controller/src/ApprovalController.test.ts": { "import-x/order": 1, "jest/no-conditional-in-test": 16 diff --git a/packages/address-book-controller/CHANGELOG.md b/packages/address-book-controller/CHANGELOG.md index 225053c2054..a6252b13fd7 100644 --- a/packages/address-book-controller/CHANGELOG.md +++ b/packages/address-book-controller/CHANGELOG.md @@ -7,11 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AddressBookControllerContactUpdatedEvent` and `AddressBookControllerContactDeletedEvent` types for contact events ([#5779](https://github.com/MetaMask/core/pull/5779)) +- Add `list` method on `AddressBookController` to get all address book entries as an array ([#5779](https://github.com/MetaMask/core/pull/5779)) +- Register message handlers for `list`, `set`, and `delete` actions ([#5779](https://github.com/MetaMask/core/pull/5779)) + ### Changed - Bump `@metamask/base-controller` from ^8.0.0 to ^8.0.1 ([#5722](https://github.com/MetaMask/core/pull/5722)) - Bump `@metamask/controller-utils` to `^11.9.0` ([#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) +### Fixed + +- Fix `delete` method to clean up empty chainId objects when the last address in a chain is deleted ([#5779](https://github.com/MetaMask/core/pull/5779)) + ## [6.0.3] ### Changed diff --git a/packages/address-book-controller/src/AddressBookController.test.ts b/packages/address-book-controller/src/AddressBookController.test.ts index f2fa616652a..03a21280fe0 100644 --- a/packages/address-book-controller/src/AddressBookController.test.ts +++ b/packages/address-book-controller/src/AddressBookController.test.ts @@ -1,9 +1,12 @@ import { Messenger } from '@metamask/base-controller'; import { toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; import type { AddressBookControllerActions, AddressBookControllerEvents, + AddressBookControllerContactUpdatedEvent, + AddressBookControllerContactDeletedEvent, } from './AddressBookController'; import { AddressBookController, @@ -12,34 +15,69 @@ import { } from './AddressBookController'; /** - * Constructs a restricted controller messenger. + * Helper function to create test fixtures * - * @returns A restricted controller messenger. + * @returns Test fixtures including messenger, controller, and event listeners */ -function getRestrictedMessenger() { +function arrangeMocks() { const messenger = new Messenger< AddressBookControllerActions, AddressBookControllerEvents >(); - return messenger.getRestricted({ + const restrictedMessenger = messenger.getRestricted({ name: controllerName, allowedActions: [], allowedEvents: [], }); + const controller = new AddressBookController({ + messenger: restrictedMessenger, + }); + + // Set up mock event listeners + const contactUpdatedListener = jest.fn(); + const contactDeletedListener = jest.fn(); + + // Subscribe to events + messenger.subscribe( + 'AddressBookController:contactUpdated' as AddressBookControllerContactUpdatedEvent['type'], + contactUpdatedListener, + ); + messenger.subscribe( + 'AddressBookController:contactDeleted' as AddressBookControllerContactDeletedEvent['type'], + contactDeletedListener, + ); + + return { + controller, + contactUpdatedListener, + contactDeletedListener, + }; } describe('AddressBookController', () => { - it('should set default state', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + // Mock Date.now to return a fixed value for tests + const originalDateNow = Date.now; + const MOCK_TIMESTAMP = 1000000000000; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => MOCK_TIMESTAMP); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + afterAll(() => { + Date.now = originalDateNow; + }); + + it('sets default state', () => { + const { controller } = arrangeMocks(); expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should add a contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); expect(controller.state).toStrictEqual({ @@ -52,16 +90,15 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add a contact entry with chainId and memo', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry with chainId and memo', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -80,16 +117,15 @@ describe('AddressBookController', () => { memo: 'account 1', name: 'foo', addressType: AddressType.externallyOwnedAccounts, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add a contact entry with address type contract accounts', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry with address type contract accounts', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -108,16 +144,15 @@ describe('AddressBookController', () => { memo: 'account 1', name: 'foo', addressType: AddressType.contractAccounts, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add a contact entry with address type non accounts', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds a contact entry with address type non accounts', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -136,16 +171,15 @@ describe('AddressBookController', () => { memo: 'account 1', name: 'foo', addressType: AddressType.nonAccounts, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add multiple contact entries with different chainIds', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds multiple contact entries with different chainIds', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo', @@ -170,6 +204,7 @@ describe('AddressBookController', () => { memo: 'account 2', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, [toHex(2)]: { @@ -180,16 +215,15 @@ describe('AddressBookController', () => { memo: 'account 2', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should update a contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('updates a contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'bar'); @@ -204,35 +238,30 @@ describe('AddressBookController', () => { memo: '', name: 'bar', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should not add invalid contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('does not add invalid contact entry', () => { + const { controller } = arrangeMocks(); // @ts-expect-error Intentionally invalid entry controller.set('0x01', 'foo', AddressType.externallyOwnedAccounts); expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should remove one contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('removes one contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.delete(toHex(1), '0x32Be343B94f860124dC4fEe278FDCBD38C102D88'); expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should remove only one contact entry', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('removes only one contact entry', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -248,16 +277,15 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should add two contact entries with the same chainId', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('adds two contact entries with the same chainId', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -272,6 +300,7 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, '0xC38bF1aD06ef69F0c04E29DBeB4152B4175f0A8D': { address: '0xC38bF1aD06ef69F0c04E29DBeB4152B4175f0A8D', @@ -280,16 +309,15 @@ describe('AddressBookController', () => { memo: '', name: 'bar', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should correctly mark ens entries', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('marks correctly ens entries', () => { + const { controller } = arrangeMocks(); controller.set( '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'metamask.eth', @@ -305,16 +333,15 @@ describe('AddressBookController', () => { memo: '', name: 'metamask.eth', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); - it('should clear all contact entries', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('clears all contact entries', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -322,29 +349,23 @@ describe('AddressBookController', () => { expect(controller.state).toStrictEqual({ addressBook: {} }); }); - it('should return true to indicate an address book entry has been added', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns true to indicate an address book entry has been added', () => { + const { controller } = arrangeMocks(); expect( controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'), ).toBe(true); }); - it('should return false to indicate an address book entry has NOT been added', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns false to indicate an address book entry has NOT been added', () => { + const { controller } = arrangeMocks(); expect( // @ts-expect-error Intentionally invalid entry controller.set('0x00', 'foo', AddressType.externallyOwnedAccounts), ).toBe(false); }); - it('should return true to indicate an address book entry has been deleted', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns true to indicate an address book entry has been deleted', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); expect( @@ -352,27 +373,21 @@ describe('AddressBookController', () => { ).toBe(true); }); - it('should return false to indicate an address book entry has NOT been deleted due to unsafe input', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns false to indicate an address book entry has NOT been deleted due to unsafe input', () => { + const { controller } = arrangeMocks(); // @ts-expect-error Suppressing error to test runtime behavior expect(controller.delete('__proto__', '0x01')).toBe(false); expect(controller.delete(toHex(1), 'constructor')).toBe(false); }); - it('should return false to indicate an address book entry has NOT been deleted', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('returns false to indicate an address book entry has NOT been deleted', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', '0x00'); expect(controller.delete(toHex(1), '0x01')).toBe(false); }); - it('should normalize addresses so adding and removing entries work across casings', () => { - const controller = new AddressBookController({ - messenger: getRestrictedMessenger(), - }); + it('normalizes addresses so adding and removing entries work across casings', () => { + const { controller } = arrangeMocks(); controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); controller.set('0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', 'bar'); @@ -388,9 +403,126 @@ describe('AddressBookController', () => { memo: '', name: 'foo', addressType: undefined, + lastUpdatedAt: MOCK_TIMESTAMP, }, }, }, }); }); + + it('emits contactUpdated event when adding a contact', () => { + const { controller, contactUpdatedListener } = arrangeMocks(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); + + expect(contactUpdatedListener).toHaveBeenCalledTimes(1); + expect(contactUpdatedListener).toHaveBeenCalledWith({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + isEns: false, + memo: '', + name: 'foo', + addressType: undefined, + lastUpdatedAt: expect.any(Number), + }); + }); + + it('emits contactUpdated event when updating a contact', () => { + const { controller, contactUpdatedListener } = arrangeMocks(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); + + // Clear the mock to reset call count since the first set also triggers the event + contactUpdatedListener.mockClear(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'bar'); + + expect(contactUpdatedListener).toHaveBeenCalledTimes(1); + expect(contactUpdatedListener).toHaveBeenCalledWith({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + isEns: false, + memo: '', + name: 'bar', + addressType: undefined, + lastUpdatedAt: expect.any(Number), + }); + }); + + it('emits contactDeleted event when deleting a contact', () => { + const { controller, contactDeletedListener } = arrangeMocks(); + + controller.set('0x32Be343B94f860124dC4fEe278FDCBD38C102D88', 'foo'); + controller.delete(toHex(1), '0x32Be343B94f860124dC4fEe278FDCBD38C102D88'); + + expect(contactDeletedListener).toHaveBeenCalledTimes(1); + expect(contactDeletedListener).toHaveBeenCalledWith({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + isEns: false, + memo: '', + name: 'foo', + addressType: undefined, + lastUpdatedAt: expect.any(Number), + }); + }); + + it('does not emit events for contacts with chainId "*" (wallet accounts)', () => { + const { controller, contactUpdatedListener, contactDeletedListener } = + arrangeMocks(); + + // Add with chainId "*" + controller.set( + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + 'foo', + '*' as unknown as Hex, + ); + expect(contactUpdatedListener).not.toHaveBeenCalled(); + + // Update with chainId "*" + controller.set( + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + 'bar', + '*' as unknown as Hex, + ); + expect(contactUpdatedListener).not.toHaveBeenCalled(); + + // Delete with chainId "*" + controller.delete( + '*' as unknown as Hex, + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + ); + expect(contactDeletedListener).not.toHaveBeenCalled(); + }); + + it('lists all contacts', () => { + const { controller } = arrangeMocks(); + controller.set( + '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + 'foo', + toHex(1), + ); + controller.set( + '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d', + 'bar', + toHex(2), + ); + + const contacts = controller.list(); + expect(contacts).toHaveLength(2); + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0x32Be343B94f860124dC4fEe278FDCBD38C102D88', + chainId: toHex(1), + name: 'foo', + }), + ); + expect(contacts).toContainEqual( + expect.objectContaining({ + address: '0xC38bF1aD06ef69F0c04E29DBeB4152B4175f0A8D', + chainId: toHex(2), + name: 'bar', + }), + ); + }); }); diff --git a/packages/address-book-controller/src/AddressBookController.ts b/packages/address-book-controller/src/AddressBookController.ts index cf1f1239d7b..d97143f6bed 100644 --- a/packages/address-book-controller/src/AddressBookController.ts +++ b/packages/address-book-controller/src/AddressBookController.ts @@ -13,45 +13,33 @@ import { } from '@metamask/controller-utils'; import type { Hex } from '@metamask/utils'; -/** - * @type ContactEntry - * - * ContactEntry representation - * @property address - Hex address of a recipient account - * @property name - Nickname associated with this address - * @property importTime - Data time when an account as created/imported - */ -export type ContactEntry = { - address: string; - name: string; - importTime?: number; -}; - /** * The type of address. */ export enum AddressType { - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention externallyOwnedAccounts = 'EXTERNALLY_OWNED_ACCOUNTS', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention contractAccounts = 'CONTRACT_ACCOUNTS', - // TODO: Either fix this lint violation or explain why it's necessary to ignore. - // eslint-disable-next-line @typescript-eslint/naming-convention nonAccounts = 'NON_ACCOUNTS', } /** - * @type AddressBookEntry + * AddressBookEntry * * AddressBookEntry representation - * @property address - Hex address of a recipient account - * @property name - Nickname associated with this address - * @property chainId - Chain id identifies the current chain - * @property memo - User's note about address - * @property isEns - is the entry an ENS name - * @property addressType - is the type of this address + * + * address - Hex address of a recipient account + * + * name - Nickname associated with this address + * + * chainId - Chain id identifies the current chain + * + * memo - User's note about address + * + * isEns - is the entry an ENS name + * + * addressType - is the type of this address + * + * lastUpdatedAt - timestamp of when this entry was last updated */ export type AddressBookEntry = { address: string; @@ -60,13 +48,15 @@ export type AddressBookEntry = { memo: string; isEns: boolean; addressType?: AddressType; + lastUpdatedAt?: number; }; /** - * @type AddressBookState + * AddressBookState * * Address book controller state - * @property addressBook - Array of contact entry objects + * + * addressBook - Array of contact entry objects */ export type AddressBookControllerState = { addressBook: { [chainId: Hex]: { [address: string]: AddressBookEntry } }; @@ -85,10 +75,54 @@ export type AddressBookControllerGetStateAction = ControllerGetStateAction< AddressBookControllerState >; +/** + * The action that can be performed to list contacts from the {@link AddressBookController}. + */ +export type AddressBookControllerListAction = { + type: `${typeof controllerName}:list`; + handler: AddressBookController['list']; +}; + +/** + * The action that can be performed to set a contact in the {@link AddressBookController}. + */ +export type AddressBookControllerSetAction = { + type: `${typeof controllerName}:set`; + handler: AddressBookController['set']; +}; + +/** + * The action that can be performed to delete a contact from the {@link AddressBookController}. + */ +export type AddressBookControllerDeleteAction = { + type: `${typeof controllerName}:delete`; + handler: AddressBookController['delete']; +}; + +/** + * Event emitted when a contact is added or updated + */ +export type AddressBookControllerContactUpdatedEvent = { + type: `${typeof controllerName}:contactUpdated`; + payload: [AddressBookEntry]; +}; + +/** + * Event emitted when a contact is deleted + */ +export type AddressBookControllerContactDeletedEvent = { + type: `${typeof controllerName}:contactDeleted`; + payload: [AddressBookEntry]; +}; + /** * The actions that can be performed using the {@link AddressBookController}. */ -export type AddressBookControllerActions = AddressBookControllerGetStateAction; +export type AddressBookControllerActions = + | AddressBookControllerGetStateAction + | AddressBookControllerListAction + | AddressBookControllerSetAction + | AddressBookControllerDeleteAction; /** * The event that {@link AddressBookController} can emit. @@ -101,7 +135,10 @@ export type AddressBookControllerStateChangeEvent = ControllerStateChangeEvent< /** * The events that {@link AddressBookController} can emit. */ -export type AddressBookControllerEvents = AddressBookControllerStateChangeEvent; +export type AddressBookControllerEvents = + | AddressBookControllerStateChangeEvent + | AddressBookControllerContactUpdatedEvent + | AddressBookControllerContactDeletedEvent; const addressBookControllerMetadata = { addressBook: { persist: true, anonymous: false }, @@ -159,6 +196,28 @@ export class AddressBookController extends BaseController< name: controllerName, state: mergedState, }); + + this.#registerMessageHandlers(); + } + + /** + * Returns all address book entries as an array. + * + * @returns Array of all address book entries. + */ + list(): AddressBookEntry[] { + const { addressBook } = this.state; + const contacts: AddressBookEntry[] = []; + + Object.keys(addressBook).forEach((chainId) => { + const chainIdHex = chainId as Hex; + Object.keys(addressBook[chainIdHex]).forEach((address) => { + const contact = addressBook[chainIdHex][address]; + contacts.push(contact); + }); + }); + + return contacts; } /** @@ -188,13 +247,29 @@ export class AddressBookController extends BaseController< return false; } + const deletedEntry = { ...this.state.addressBook[chainId][address] }; + this.update((state) => { - delete state.addressBook[chainId][address]; - if (Object.keys(state.addressBook[chainId]).length === 0) { - delete state.addressBook[chainId]; + if (state.addressBook[chainId] && state.addressBook[chainId][address]) { + delete state.addressBook[chainId][address]; + + // Clean up empty chainId objects + if (Object.keys(state.addressBook[chainId]).length === 0) { + delete state.addressBook[chainId]; + } } }); + // Skip sending delete event for global contacts with chainId '*' + // These entries with chainId='*' are the wallet's own accounts (internal MetaMask accounts), + // not user-created contacts. They don't need to trigger sync events. + if (String(chainId) !== '*') { + this.messagingSystem.publish( + 'AddressBookController:contactDeleted', + deletedEntry, + ); + } + return true; } @@ -227,8 +302,8 @@ export class AddressBookController extends BaseController< memo, name, addressType, + lastUpdatedAt: Date.now(), }; - const ensName = normalizeEnsName(name); if (ensName) { entry.name = ensName; @@ -245,8 +320,36 @@ export class AddressBookController extends BaseController< }; }); + // Skip sending update event for global contacts with chainId '*' + // These entries with chainId='*' are the wallet's own accounts (internal MetaMask accounts), + // not user-created contacts. They don't need to trigger sync events. + if (String(chainId) !== '*') { + this.messagingSystem.publish( + 'AddressBookController:contactUpdated', + entry, + ); + } + return true; } + + /** + * Registers message handlers for the AddressBookController. + */ + #registerMessageHandlers() { + this.messagingSystem.registerActionHandler( + `${controllerName}:list`, + this.list.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${controllerName}:set`, + this.set.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${controllerName}:delete`, + this.delete.bind(this), + ); + } } export default AddressBookController; diff --git a/packages/address-book-controller/src/index.ts b/packages/address-book-controller/src/index.ts index 85ae3c72bd2..776665174c8 100644 --- a/packages/address-book-controller/src/index.ts +++ b/packages/address-book-controller/src/index.ts @@ -3,11 +3,15 @@ export type { AddressBookEntry, AddressBookControllerState, AddressBookControllerGetStateAction, + AddressBookControllerListAction, + AddressBookControllerSetAction, + AddressBookControllerDeleteAction, AddressBookControllerActions, AddressBookControllerStateChangeEvent, + AddressBookControllerContactUpdatedEvent, + AddressBookControllerContactDeletedEvent, AddressBookControllerEvents, AddressBookControllerMessenger, - ContactEntry, } from './AddressBookController'; export { getDefaultAddressBookControllerState, diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index fbd330a19e3..31b064b27a9 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add contact syncing capability with conflict resolution b/w local and remote storage ([#5776](https://github.com/MetaMask/core/pull/5776)) + ## [15.0.0] ### Changed diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index c8b7aefeaf9..3cfc8f85358 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -628,6 +628,8 @@ describe('user-storage/user-storage-controller - setIsBackupAndSyncFeatureEnable hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + isContactSyncingEnabled: false, + isContactSyncingInProgress: false, }, }); @@ -654,6 +656,8 @@ describe('user-storage/user-storage-controller - setIsBackupAndSyncFeatureEnable hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + isContactSyncingEnabled: false, + isContactSyncingInProgress: false, }, }); @@ -682,6 +686,8 @@ describe('user-storage/user-storage-controller - setIsBackupAndSyncFeatureEnable hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + isContactSyncingEnabled: true, + isContactSyncingInProgress: false, }, }); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index 06af7a089a5..b076b4465e1 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -4,6 +4,14 @@ import type { AccountsControllerAccountRenamedEvent, AccountsControllerAccountAddedEvent, } from '@metamask/accounts-controller'; +import type { + AddressBookControllerContactUpdatedEvent, + AddressBookControllerContactDeletedEvent, + AddressBookControllerActions, + AddressBookControllerListAction, + AddressBookControllerSetAction, + AddressBookControllerDeleteAction, +} from '@metamask/address-book-controller'; import type { ControllerGetStateAction, ControllerStateChangeEvent, @@ -33,6 +41,8 @@ import { } from './account-syncing/controller-integration'; import { setupAccountSyncingSubscriptions } from './account-syncing/setup-subscriptions'; import { BACKUPANDSYNC_FEATURES } from './constants'; +import { syncContactsWithUserStorage } from './contact-syncing/controller-integration'; +import { setupContactSyncingSubscriptions } from './contact-syncing/setup-subscriptions'; import { performMainNetworkSync, startNetworkSyncing, @@ -68,6 +78,14 @@ export type UserStorageControllerState = { * Condition used by UI to determine if account syncing is enabled. */ isAccountSyncingEnabled: boolean; + /** + * Condition used by UI to determine if contact syncing is enabled. + */ + isContactSyncingEnabled: boolean; + /** + * Condition used by UI to determine if contact syncing is in progress. + */ + isContactSyncingInProgress: boolean; /** * Condition used to determine if account syncing has been dispatched at least once. * This is used for event listeners to determine if they should be triggered. @@ -92,6 +110,8 @@ export const defaultState: UserStorageControllerState = { isBackupAndSyncEnabled: true, isBackupAndSyncUpdateLoading: false, isAccountSyncingEnabled: true, + isContactSyncingEnabled: true, + isContactSyncingInProgress: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, @@ -110,6 +130,14 @@ const metadata: StateMetadata = { persist: true, anonymous: true, }, + isContactSyncingEnabled: { + persist: true, + anonymous: true, + }, + isContactSyncingInProgress: { + persist: false, + anonymous: false, + }, hasAccountSyncingSyncedAtLeastOnce: { persist: true, anonymous: false, @@ -153,7 +181,29 @@ type ControllerConfig = { sentryContext?: Record, ) => void; }; + contactSyncing?: { + /** + * Callback that fires when contact sync updates a contact. + * This is used for analytics. + */ + onContactUpdated?: (profileId: string) => void; + + /** + * Callback that fires when contact sync deletes a contact. + * This is used for analytics. + */ + onContactDeleted?: (profileId: string) => void; + /** + * Callback that fires when an erroneous situation happens during contact sync. + * This is used for analytics. + */ + onContactSyncErroneousSituation?: ( + profileId: string, + situationMessage: string, + sentryContext?: Record, + ) => void; + }; networkSyncing?: { maxNumberOfNetworksToAdd?: number; /** @@ -238,7 +288,12 @@ export type AllowedActions = | NetworkControllerGetStateAction | NetworkControllerAddNetworkAction | NetworkControllerRemoveNetworkAction - | NetworkControllerUpdateNetworkAction; + | NetworkControllerUpdateNetworkAction + // Contact Syncing + | AddressBookControllerListAction + | AddressBookControllerSetAction + | AddressBookControllerDeleteAction + | AddressBookControllerActions; // Messenger events export type UserStorageControllerStateChangeEvent = ControllerStateChangeEvent< @@ -256,7 +311,10 @@ export type AllowedEvents = | AccountsControllerAccountAddedEvent | AccountsControllerAccountRenamedEvent // Network Syncing Events - | NetworkControllerNetworkRemovedEvent; + | NetworkControllerNetworkRemovedEvent + // Address Book Events + | AddressBookControllerContactUpdatedEvent + | AddressBookControllerContactDeletedEvent; // Messenger export type UserStorageControllerMessenger = RestrictedMessenger< @@ -393,6 +451,12 @@ export default class UserStorageController extends BaseController< getMessenger: () => this.messagingSystem, }); + // Contact Syncing + setupContactSyncingSubscriptions({ + getUserStorageControllerInstance: () => this, + getMessenger: () => this.messagingSystem, + }); + // Network Syncing if (this.#env.isNetworkSyncingEnabled) { startNetworkSyncing({ @@ -630,6 +694,10 @@ export default class UserStorageController extends BaseController< if (feature === BACKUPANDSYNC_FEATURES.accountSyncing) { state.isAccountSyncingEnabled = enabled; } + + if (feature === BACKUPANDSYNC_FEATURES.contactSyncing) { + state.isContactSyncingEnabled = enabled; + } }); } catch (e) { // istanbul ignore next @@ -677,6 +745,19 @@ export default class UserStorageController extends BaseController< }); } + /** + * Sets the isContactSyncingInProgress flag to prevent infinite loops during contact synchronization + * + * @param isContactSyncingInProgress - Whether contact syncing is in progress + */ + async setIsContactSyncingInProgress( + isContactSyncingInProgress: boolean, + ): Promise { + this.update((state) => { + state.isContactSyncingInProgress = isContactSyncingInProgress; + }); + } + /** * Syncs the internal accounts list with the user storage accounts list. * This method is used to make sure that the internal accounts list is up-to-date with the user storage accounts list and vice-versa. @@ -744,4 +825,56 @@ export default class UserStorageController extends BaseController< s.hasNetworkSyncingSyncedAtLeastOnce = true; }); } + + /** + * Syncs the address book list with the user storage address book list. + * This method is used to make sure that the address book list is up-to-date with the user storage address book list and vice-versa. + * It will add new contacts to the address book list, update/merge conflicting contacts and re-upload the results in some cases to the user storage. + */ + async syncContactsWithUserStorage(): Promise { + console.log( + '📓 Contact Sync: Starting in UserStorageController.syncContactsWithUserStorage', + ); + try { + const profileId = await this.#auth.getProfileId(); + console.log('📓 Contact Sync: Got profileId', profileId); + + const config = { + onContactUpdated: () => { + console.log('📓 Contact Sync: Contact updated callback triggered'); + this.#config?.contactSyncing?.onContactUpdated?.(profileId); + }, + onContactDeleted: () => { + console.log('📓 Contact Sync: Contact deleted callback triggered'); + this.#config?.contactSyncing?.onContactDeleted?.(profileId); + }, + onContactSyncErroneousSituation: ( + errorMessage: string, + sentryContext?: Record, + ) => { + console.log( + '📓 Contact Sync: Error callback triggered', + errorMessage, + ); + this.#config?.contactSyncing?.onContactSyncErroneousSituation?.( + profileId, + errorMessage, + sentryContext, + ); + }, + }; + + await syncContactsWithUserStorage(config, { + getMessenger: () => this.messagingSystem, + getUserStorageControllerInstance: () => this, + }); + + console.log( + '📓 Contact Sync: Completed successfully in UserStorageController', + ); + } catch (error) { + console.error('📓 Contact Sync: Error in UserStorageController', error); + throw error; + } + } } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts index eeb9f4861f6..0e6c70fc72f 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts @@ -72,6 +72,8 @@ export function createCustomUserStorageMessenger(props?: { 'AccountsController:accountAdded', 'AccountsController:accountRenamed', 'NetworkController:networkRemoved', + 'AddressBookController:contactUpdated', + 'AddressBookController:contactDeleted', ], }); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts index 620f19e8ef9..011589e34d3 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts @@ -33,6 +33,8 @@ const baseState = { hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, isAccountSyncingInProgress: false, + isContactSyncingEnabled: true, + isContactSyncingInProgress: false, }; const arrangeMocks = async ( diff --git a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts index c47d69a9f40..f0b375a4ce7 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/constants.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/constants.ts @@ -1,4 +1,5 @@ export const BACKUPANDSYNC_FEATURES = { main: 'main', accountSyncing: 'accountSyncing', + contactSyncing: 'contactSyncing', } as const; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/__fixtures__/mockContacts.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/__fixtures__/mockContacts.ts new file mode 100644 index 00000000000..903e6e66277 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/__fixtures__/mockContacts.ts @@ -0,0 +1,141 @@ +import type { AddressBookEntry } from '@metamask/address-book-controller'; + +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from '../constants'; +import type { UserStorageContactEntry } from '../types'; + +// Base timestamp for predictable testing +const NOW = 1657000000000; + +// Local AddressBookEntry mock objects +export const MOCK_LOCAL_CONTACTS = { + // One contact on chain 1 + ONE: [ + { + address: '0x123456789012345678901234567890abcdef1234', + name: 'Contact One', + chainId: '0x1', + memo: 'First contact', + isEns: false, + lastUpdatedAt: NOW, + } as AddressBookEntry, + ], + + // Two contacts on different chains + TWO_DIFF_CHAINS: [ + { + address: '0x123456789012345678901234567890abcdef1234', + name: 'Contact One', + chainId: '0x1', + memo: 'First contact', + isEns: false, + lastUpdatedAt: NOW, + } as AddressBookEntry, + { + address: '0x123456789012345678901234567890abcdef1234', + name: 'Contact One on Goerli', + chainId: '0x5', + memo: 'Goerli test contact', + isEns: false, + lastUpdatedAt: NOW, + } as AddressBookEntry, + ], + + // Same contact as remote but different name (newer) + ONE_UPDATED_NAME: [ + { + address: '0x123456789012345678901234567890abcdef1234', + name: 'Contact One Updated', + chainId: '0x1', + memo: 'First contact', + isEns: false, + lastUpdatedAt: NOW + 1000, + } as AddressBookEntry, + ], + + // Contact that has been marked as deleted + ONE_DELETED: [ + { + address: '0x123456789012345678901234567890abcdef1234', + name: 'Contact One', + chainId: '0x1', + memo: 'First contact', + isEns: false, + lastUpdatedAt: NOW, + deleted: true, + deletedAt: NOW + 2000, + } as AddressBookEntry, + ], +}; + +// Remote UserStorageContactEntry mock objects +export const MOCK_REMOTE_CONTACTS = { + // One contact on chain 1 + ONE: [ + { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: '0x123456789012345678901234567890abcdef1234', + n: 'Contact One', + c: '0x1', + m: 'First contact', + lu: NOW, + } as UserStorageContactEntry, + ], + + // Two contacts on different chains + TWO_DIFF_CHAINS: [ + { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: '0x123456789012345678901234567890abcdef1234', + n: 'Contact One', + c: '0x1', + m: 'First contact', + lu: NOW, + } as UserStorageContactEntry, + { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: '0x123456789012345678901234567890abcdef1234', + n: 'Contact One on Goerli', + c: '0x5', + m: 'Goerli test contact', + lu: NOW, + } as UserStorageContactEntry, + ], + + // Different contact than local + ONE_DIFFERENT: [ + { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: '0xabcdef1234567890123456789012345678901234', + n: 'Different Contact', + c: '0x1', + m: 'Another contact', + lu: NOW, + } as UserStorageContactEntry, + ], + + // Same contact as local but with different name + ONE_DIFFERENT_NAME: [ + { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: '0x123456789012345678901234567890abcdef1234', + n: 'Contact One Old Name', + c: '0x1', + m: 'First contact', + lu: NOW - 1000, // Older timestamp + } as UserStorageContactEntry, + ], + + // Deleted contact + ONE_DELETED: [ + { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: '0x123456789012345678901234567890abcdef1234', + n: 'Contact One', + c: '0x1', + m: 'First contact', + lu: NOW, + d: true, + dt: NOW + 1000, + } as unknown as UserStorageContactEntry, + ], +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/__fixtures__/test-utils.ts new file mode 100644 index 00000000000..be6ea9803af --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/__fixtures__/test-utils.ts @@ -0,0 +1,148 @@ +import type { + AddressBookEntry, + AddressType, +} from '@metamask/address-book-controller'; +import type { + ActionConstraint, + EventConstraint, +} from '@metamask/base-controller'; +import { Messenger as MessengerImpl } from '@metamask/base-controller'; + +import { MOCK_LOCAL_CONTACTS } from './mockContacts'; + +/** + * Test Utility - create a mock user storage messenger for contact syncing tests + * + * @param options - options for the mock messenger + * @param options.addressBook - options for the address book part of the controller + * @param options.addressBook.contactsList - List of address book contacts to use + * @returns Mock User Storage Messenger + */ +export function mockUserStorageMessengerForContactSyncing(options?: { + addressBook?: { + contactsList?: AddressBookEntry[]; + }; +}): { + messenger: { + call: jest.Mock; + registerActionHandler: jest.Mock; + publish: unknown; + subscribe: unknown; + unsubscribe: unknown; + clearEventSubscriptions: unknown; + registerInitialEventPayload: jest.Mock; + }; + baseMessenger: MessengerImpl; + mockAddressBookList: jest.Mock; + mockAddressBookSet: jest.Mock; + mockAddressBookDelete: jest.Mock; + contactsUpdatedFromSync: AddressBookEntry[]; // Track contacts that were updated via sync +} { + // Start with a fresh messenger mock + const baseMessenger = new MessengerImpl(); + + // Contacts that are synced/updated will be stored here for test inspection + const contactsUpdatedFromSync: AddressBookEntry[] = []; + + // Create our address book specific mocks + const mockAddressBookList = jest.fn().mockImplementation(() => { + return options?.addressBook?.contactsList || MOCK_LOCAL_CONTACTS.ONE; + }); + + const mockAddressBookSet = jest + .fn() + .mockImplementation( + ( + address: string, + name: string, + chainId: string, + memo: string, + addressType?: AddressType, + ) => { + // Store the contact being set for later inspection + contactsUpdatedFromSync.push({ + address, + name, + chainId: chainId as `0x${string}`, + memo, + isEns: false, + addressType, + }); + return true; + }, + ); + + const mockAddressBookDelete = jest.fn().mockImplementation(() => true); + + // Create a complete mock implementation + const messenger = { + call: jest.fn().mockImplementation((method: string, ...args: unknown[]) => { + // Address book specific methods + if (method === 'AddressBookController:list') { + return mockAddressBookList(...args); + } + if (method === 'AddressBookController:set') { + return mockAddressBookSet(...args); + } + if (method === 'AddressBookController:delete') { + return mockAddressBookDelete(...args); + } + + // Common methods needed by the controller + if (method === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (method === 'AuthenticationController:isSignedIn') { + return true; + } + if (method === 'KeyringController:keyringInitialized') { + return true; + } + if (method === 'AuthenticationController:getSession') { + return { profile: { v1: 'mockSessionProfile' } }; + } + if (method === 'AuthenticationController:getSessionProfile') { + return { + identifierId: 'test-identifier-id', + profileId: 'test-profile-id', + metaMetricsId: 'test-metrics-id', + }; + } + if (method === 'AuthenticationController:getBearerToken') { + return 'test-token'; + } + if (method === 'AuthenticationController:checkAndRequestRenewSession') { + return true; + } + if (method === 'PreferencesController:getState') { + return { selectedAddress: '0x123', identities: {} }; + } + if (method === 'UserService:performRequest') { + // Mock successful API response for performRequest + return { data: 'success' }; + } + + return undefined; + }), + registerActionHandler: jest.fn(), + publish: baseMessenger.publish.bind(baseMessenger), + subscribe: baseMessenger.subscribe.bind(baseMessenger), + unsubscribe: baseMessenger.unsubscribe.bind(baseMessenger), + clearEventSubscriptions: + baseMessenger.clearEventSubscriptions.bind(baseMessenger), + registerInitialEventPayload: jest.fn(), + }; + + return { + messenger, + baseMessenger, + mockAddressBookList, + mockAddressBookSet, + mockAddressBookDelete, + contactsUpdatedFromSync, + }; +} + +export const createMockUserStorageContacts = async (contacts: unknown) => { + return JSON.stringify(contacts); +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/constants.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/constants.ts new file mode 100644 index 00000000000..c5eb080273b --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/constants.ts @@ -0,0 +1,9 @@ +/** + * Key for version in User Storage schema + */ +export const USER_STORAGE_VERSION_KEY = 'v'; + +/** + * Current version of User Storage schema + */ +export const USER_STORAGE_VERSION = '1'; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts new file mode 100644 index 00000000000..67dae653d01 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.test.ts @@ -0,0 +1,731 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { AddressBookEntry } from '@metamask/address-book-controller'; + +import { + MOCK_LOCAL_CONTACTS, + MOCK_REMOTE_CONTACTS, +} from './__fixtures__/mockContacts'; +import { + mockUserStorageMessengerForContactSyncing, + createMockUserStorageContacts, +} from './__fixtures__/test-utils'; +import * as ContactSyncingControllerIntegrationModule from './controller-integration'; +import * as ContactSyncingUtils from './sync-utils'; +import type { ContactSyncingOptions } from './types'; +import UserStorageController, { USER_STORAGE_FEATURE_NAMES } from '..'; + +const baseState = { + isBackupAndSyncEnabled: true, + isAccountSyncingEnabled: true, + isContactSyncingEnabled: true, + isBackupAndSyncUpdateLoading: false, + hasAccountSyncingSyncedAtLeastOnce: false, + isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, + isContactSyncingInProgress: false, +}; + +const arrangeMocks = async ( + { + stateOverrides = baseState as Partial, + messengerMockOptions, + }: { + stateOverrides?: Partial; + messengerMockOptions?: Parameters< + typeof mockUserStorageMessengerForContactSyncing + >[0]; + } = { + stateOverrides: baseState as Partial, + messengerMockOptions: undefined, + }, +) => { + const messengerMocks = + mockUserStorageMessengerForContactSyncing(messengerMockOptions); + + const controller = new UserStorageController({ + messenger: messengerMocks.messenger as any, + state: { + ...baseState, + ...stateOverrides, + }, + }); + + const options = { + getMessenger: () => messengerMocks.messenger, + getUserStorageControllerInstance: () => controller, + }; + + return { + messengerMocks, + controller, + options, + }; +}; + +describe('user-storage/contact-syncing/controller-integration - syncContactsWithUserStorage() tests', () => { + beforeEach(() => { + // Create mock implementations to avoid actual API calls + jest + .spyOn(UserStorageController.prototype, 'performGetStorage') + .mockImplementation((path) => { + if (path === `${USER_STORAGE_FEATURE_NAMES.addressBook}.contacts`) { + return Promise.resolve(null); + } + return Promise.resolve(null); + }); + + jest + .spyOn(UserStorageController.prototype, 'performSetStorage') + .mockResolvedValue(undefined); + + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns void if contact syncing is not enabled', async () => { + const { options } = await arrangeMocks({ + stateOverrides: { + isContactSyncingEnabled: false, + }, + }); + + // Override the default mock + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => false); + + const mockList = jest.fn(); + options.getMessenger().call = mockList; + + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + {} as any, + options as any, + ); + + expect(mockList).not.toHaveBeenCalled(); + }); + + it('uploads local contacts to user storage if user storage is empty (first sync)', async () => { + const { options, controller, messengerMocks } = await arrangeMocks({ + messengerMockOptions: { + addressBook: { + contactsList: MOCK_LOCAL_CONTACTS.ONE, + }, + }, + }); + + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => true); + + const mockPerformGetStorage = jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(null); + + const mockPerformSetStorage = jest + .spyOn(controller, 'performSetStorage') + .mockResolvedValue(undefined); + + const onContactUpdated = jest.fn(); + const onContactDeleted = jest.fn(); + + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + { + onContactUpdated, + onContactDeleted, + } as any, + options as any, + ); + + expect(mockPerformGetStorage).toHaveBeenCalledWith( + `${USER_STORAGE_FEATURE_NAMES.addressBook}.contacts`, + ); + + expect(mockPerformSetStorage).toHaveBeenCalled(); + + expect(onContactUpdated).not.toHaveBeenCalled(); + expect(onContactDeleted).not.toHaveBeenCalled(); + + // Assert that set wasn't called since we're only uploading to remote + expect(messengerMocks.mockAddressBookSet).not.toHaveBeenCalled(); + }); + + it('imports remote contacts to local if local is empty (e.g. new device)', async () => { + const localContacts: AddressBookEntry[] = []; // Empty local contacts + const remoteContacts = [...MOCK_REMOTE_CONTACTS.ONE]; // Not deleted remotely + + // Make sure remote contacts aren't already deleted + remoteContacts.forEach((c: any) => { + c.d = false; + }); + + const { options, controller, messengerMocks } = await arrangeMocks({ + messengerMockOptions: { + addressBook: { + contactsList: localContacts, + }, + } as any, + }); + + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => true); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const onContactUpdated = jest.fn(); + + // Don't include onContactDeleted in this test since we don't expect any deletions + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + { + onContactUpdated, + } as any, + options as any, + ); + + // Assert that set was called to add the remote contacts + expect(messengerMocks.mockAddressBookSet).toHaveBeenCalled(); + + // Verify that the remote contact was added + expect(messengerMocks.contactsUpdatedFromSync.length).toBeGreaterThan(0); + const importedContact = messengerMocks.contactsUpdatedFromSync.find( + (c) => c.address.toLowerCase() === remoteContacts[0].a.toLowerCase(), + ); + expect(importedContact).toBeDefined(); + + expect(onContactUpdated).toHaveBeenCalled(); + }); + + it('resolves conflicts by using the most recent timestamp (local wins when newer)', async () => { + // Create contacts with different names and explicit timestamps + const baseTimestamp = 1657000000000; + + // Local contact has NEWER timestamp + const localContact = { + ...MOCK_LOCAL_CONTACTS.ONE[0], + name: 'Local Name', + lastUpdatedAt: baseTimestamp + 20000, // Local is 20 seconds newer + }; + + // Remote contact has OLDER timestamp + const remoteContact = { + ...MOCK_REMOTE_CONTACTS.ONE_DIFFERENT_NAME[0], + n: 'Remote Name', + lu: baseTimestamp + 10000, // Remote is 10 seconds newer + }; + + const localContacts = [localContact]; + const remoteContacts = [remoteContact]; + + const { options, controller, messengerMocks } = await arrangeMocks({ + messengerMockOptions: { + addressBook: { + contactsList: localContacts, + }, + }, + }); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + {}, + options as unknown as ContactSyncingOptions, + ); + + // Verify local version was preferred (local wins by timestamp) + expect(mockPerformSetStorage).toHaveBeenCalled(); + + // The local contact should be sent to remote storage + const setStorageCall = mockPerformSetStorage.mock.calls[0]; + const parsedContacts = JSON.parse(setStorageCall[1]); + + // Find contact by address (case-insensitive) + const updatedContact = parsedContacts.find( + (c: any) => c.a.toLowerCase() === localContact.address.toLowerCase(), + ); + + expect(updatedContact).toBeDefined(); + expect(updatedContact.n).toBe('Local Name'); // Should use local name + + // No contacts should be imported locally + expect(messengerMocks.mockAddressBookSet).not.toHaveBeenCalled(); + }); + + it('resolves conflicts by using the most recent timestamp (remote wins when newer)', async () => { + // Create contacts with different names and explicit timestamps + const baseTimestamp = 1657000000000; + + // Local contact has OLDER timestamp + const localContact = { + ...MOCK_LOCAL_CONTACTS.ONE[0], + name: 'Local Name', + lastUpdatedAt: baseTimestamp + 10000, // Local is 10 seconds newer + }; + + // Remote contact has NEWER timestamp + const remoteContact = { + ...MOCK_REMOTE_CONTACTS.ONE_DIFFERENT_NAME[0], + n: 'Remote Name', + lu: baseTimestamp + 20000, // Remote is 20 seconds newer + }; + + const localContacts = [localContact]; + const remoteContacts = [remoteContact]; + + const { options, controller, messengerMocks } = await arrangeMocks({ + messengerMockOptions: { + addressBook: { + contactsList: localContacts, + }, + }, + }); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + {}, + options as unknown as ContactSyncingOptions, + ); + + // Verify remote version was preferred (remote wins by timestamp) + // The remote contact should be imported locally using set + expect(messengerMocks.mockAddressBookSet).toHaveBeenCalled(); + + // Find the contact that was set by its address + const importedContact = messengerMocks.contactsUpdatedFromSync.find( + (c) => c.address.toLowerCase() === localContact.address.toLowerCase(), + ); + + expect(importedContact).toBeDefined(); + expect(importedContact?.name).toBe('Remote Name'); // Should use remote name + }); + + it('syncs local deletions to remote storage', async () => { + // This test is challenging because the local deletion detection code + // checks for the "new device scenario" (empty local but existing remote contacts) + // which would prevent our test from working as expected + + // For simplicity, we'll just directly verify the function logic: + // When a remote contact exists but a local contact doesn't, + // the remote contact should be marked as deleted + + // Manually create a simple condition where we have: + // - One local contact + // - Two remote contacts (one matching local, one not in local -> should be marked as deleted) + const localContact = { + ...MOCK_LOCAL_CONTACTS.ONE[0], + }; + + // Create a second remote contact that doesn't exist locally + const uniqueRemoteContact = { + ...MOCK_REMOTE_CONTACTS.ONE[0], + a: '0xABCDEF1234567890ABCDEF1234567890ABCDEF12', // Different address + n: 'Unique Remote Contact', + }; + + const localContacts = [localContact]; + const remoteContacts = [ + { ...MOCK_REMOTE_CONTACTS.ONE[0] }, // Will match local + uniqueRemoteContact, // Will be detected as deleted + ]; + + // Ensure remote contacts aren't already marked as deleted + remoteContacts.forEach((c: any) => { + c.d = false; + }); + + const { options, controller } = await arrangeMocks({ + messengerMockOptions: { + addressBook: { + contactsList: localContacts, + }, + }, + }); + + // Setup mocks for storage + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + mockPerformSetStorage.mockImplementation(() => { + return Promise.resolve(); + }); + + const onContactDeleted = jest.fn(); + + // Run the sync process + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + { onContactDeleted }, + options as unknown as ContactSyncingOptions, + ); + + // Storage should have been called with updated data + expect(mockPerformSetStorage).toHaveBeenCalled(); + + // Extract the updated contacts + const setStorageCall = mockPerformSetStorage.mock.calls[0]; + const parsedContacts = JSON.parse(setStorageCall[1]); + + // Find the unique remote contact in the updated data + const updatedUniqueContact = parsedContacts.find( + (c: any) => c.a.toLowerCase() === uniqueRemoteContact.a.toLowerCase(), + ); + + // Verify it was marked as deleted + expect(updatedUniqueContact).toBeDefined(); + expect(updatedUniqueContact.d).toBe(true); + + // Verify the callback was called + expect(onContactDeleted).toHaveBeenCalled(); + }); + + it('syncs remote deletions to local', async () => { + // Setup: We have a contact locally that's marked as deleted in remote storage + const localContacts = [...MOCK_LOCAL_CONTACTS.ONE]; // One local contact + const remoteContacts = [...MOCK_REMOTE_CONTACTS.ONE_DELETED]; // Same contact but deleted remotely + + // Make sure the remote contact is actually marked as deleted + (remoteContacts[0] as any).d = true; // Explicitly mark as deleted + (remoteContacts[0] as any).dt = Date.now(); // Set a deletedAt timestamp + + const { options, controller, messengerMocks } = await arrangeMocks({ + messengerMockOptions: { + addressBook: { + contactsList: localContacts, + }, + }, + }); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockReturnValue(true); + + const onContactDeleted = jest.fn(); + + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + { onContactDeleted }, + options as unknown as ContactSyncingOptions, + ); + + // Assert: 'delete' was called for the remote deletion + expect(messengerMocks.mockAddressBookDelete).toHaveBeenCalled(); + + // Assert: the deletion callback was called + expect(onContactDeleted).toHaveBeenCalled(); + }); + + it('restores a contact locally if remote has newer non-deleted version', async () => { + // Create a scenario where remote has newer non-deleted version of a deleted local contact + // 1. Local contact is deleted at time X + // 2. Remote contact is updated at time X+1 (after deletion) + const deletedAt = 1657000005000; // Deleted 5 seconds after base timestamp + const updatedAt = 1657000010000; // Updated 10 seconds after base timestamp (after deletion) + + // Create a locally deleted contact + const localDeletedContact = { + ...MOCK_LOCAL_CONTACTS.ONE[0], + deleted: true, + deletedAt, + }; + + // Create a remotely updated contact with newer timestamp + const remoteUpdatedContact = { + ...MOCK_REMOTE_CONTACTS.ONE[0], + n: 'Restored Contact Name', // Changed name + lu: updatedAt, // Updated AFTER the local deletion + }; + + const { options, controller } = await arrangeMocks({ + messengerMockOptions: { + addressBook: { + contactsList: [localDeletedContact], + }, + }, + }); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue( + await createMockUserStorageContacts([remoteUpdatedContact]), + ); + + const onContactUpdated = jest.fn(); + const onContactDeleted = jest.fn(); + + await ContactSyncingControllerIntegrationModule.syncContactsWithUserStorage( + { + onContactUpdated, + onContactDeleted, + }, + options as unknown as ContactSyncingOptions, + ); + + expect(onContactUpdated).toHaveBeenCalled(); + expect(onContactDeleted).not.toHaveBeenCalled(); + }); +}); + +describe('user-storage/contact-syncing/controller-integration - updateContactInRemoteStorage() tests', () => { + beforeEach(() => { + jest + .spyOn(UserStorageController.prototype, 'performGetStorage') + .mockResolvedValue(null); + + jest + .spyOn(UserStorageController.prototype, 'performSetStorage') + .mockResolvedValue(undefined); + + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns void if contact syncing is not enabled', async () => { + const { options, controller } = await arrangeMocks({ + stateOverrides: { + isContactSyncingEnabled: false, + }, + }); + + // Override the default mock + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => false); + + const mockPerformGetStorage = jest.spyOn(controller, 'performGetStorage'); + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.updateContactInRemoteStorage( + MOCK_LOCAL_CONTACTS.ONE[0], + options as any, + ); + + expect(mockPerformGetStorage).not.toHaveBeenCalled(); + expect(mockPerformSetStorage).not.toHaveBeenCalled(); + }); + + it('updates an existing contact in remote storage', async () => { + const localContact = MOCK_LOCAL_CONTACTS.ONE[0]; + const remoteContacts = [...MOCK_REMOTE_CONTACTS.ONE]; // Same contact exists in remote + + const { options, controller } = await arrangeMocks(); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.updateContactInRemoteStorage( + localContact, + options as any, + ); + + expect(mockPerformSetStorage).toHaveBeenCalled(); + + // Check that setStorage was called with an array containing our updated contact + const setStorageCall = mockPerformSetStorage.mock.calls[0]; + expect(setStorageCall[0]).toContain('addressBook.contacts'); + + // Verify the contact was updated and not deleted + const parsedContacts = JSON.parse(setStorageCall[1]); + + // Find contact by address (case-insensitive) + const updatedContact = parsedContacts.find( + (c: any) => c.a.toLowerCase() === localContact.address.toLowerCase(), + ); + + expect(updatedContact).toBeDefined(); + expect(updatedContact.d).toBeUndefined(); // Should not be marked as deleted + }); + + it('adds a new contact to remote storage if it does not exist', async () => { + const localContact = MOCK_LOCAL_CONTACTS.ONE[0]; + + // Empty remote contacts + const remoteContacts: any[] = []; + + const { options, controller } = await arrangeMocks(); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.updateContactInRemoteStorage( + localContact, + options as any, + ); + + expect(mockPerformSetStorage).toHaveBeenCalled(); + + // Check that setStorage was called with an array containing our new contact + const setStorageCall = mockPerformSetStorage.mock.calls[0]; + const parsedContacts = JSON.parse(setStorageCall[1]); + + // Verify correct number of contacts + expect(parsedContacts).toHaveLength(1); + + // Verify contact properties (case-insensitive address comparison) + const addedContact = parsedContacts[0]; + expect(addedContact.a.toLowerCase()).toBe( + localContact.address.toLowerCase(), + ); + expect(addedContact.n).toBe(localContact.name); + expect(addedContact.d).toBeUndefined(); // Should not be marked as deleted + }); + + it('preserves existing lastUpdatedAt timestamp when updating contact', async () => { + const timestamp = 1657000000000; + const localContact = { + ...MOCK_LOCAL_CONTACTS.ONE[0], + lastUpdatedAt: timestamp, + }; + const remoteContacts: any[] = []; + + const { options, controller } = await arrangeMocks(); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.updateContactInRemoteStorage( + localContact, + options as any, + ); + + expect(mockPerformSetStorage).toHaveBeenCalled(); + + // Check that the timestamp was preserved + const setStorageCall = mockPerformSetStorage.mock.calls[0]; + const parsedContacts = JSON.parse(setStorageCall[1]); + const addedContact = parsedContacts[0]; + + expect(addedContact.lu).toBe(timestamp); + }); +}); + +describe('user-storage/contact-syncing/controller-integration - deleteContactInRemoteStorage() tests', () => { + beforeEach(() => { + jest + .spyOn(UserStorageController.prototype, 'performGetStorage') + .mockResolvedValue(null); + + jest + .spyOn(UserStorageController.prototype, 'performSetStorage') + .mockResolvedValue(undefined); + + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns void if contact syncing is not enabled', async () => { + const { options, controller } = await arrangeMocks({ + stateOverrides: { + isContactSyncingEnabled: false, + }, + }); + + // Override the default mock + jest + .spyOn(ContactSyncingUtils, 'canPerformContactSyncing') + .mockImplementation(() => false); + + const mockPerformGetStorage = jest.spyOn(controller, 'performGetStorage'); + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.deleteContactInRemoteStorage( + MOCK_LOCAL_CONTACTS.ONE[0], + options as any, + ); + + expect(mockPerformGetStorage).not.toHaveBeenCalled(); + expect(mockPerformSetStorage).not.toHaveBeenCalled(); + }); + + it('marks an existing contact as deleted in remote storage', async () => { + const contactToDelete = MOCK_LOCAL_CONTACTS.ONE[0]; + const remoteContacts = [...MOCK_REMOTE_CONTACTS.ONE]; // Same contact exists in remote + + const { options, controller } = await arrangeMocks(); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.deleteContactInRemoteStorage( + contactToDelete, + options as any, + ); + + expect(mockPerformSetStorage).toHaveBeenCalled(); + + // Check that setStorage was called with the contact marked as deleted + const setStorageCall = mockPerformSetStorage.mock.calls[0]; + const parsedContacts = JSON.parse(setStorageCall[1]); + + // Find contact by address (case-insensitive) + const deletedContact = parsedContacts.find( + (c: any) => c.a.toLowerCase() === contactToDelete.address.toLowerCase(), + ); + + expect(deletedContact).toBeDefined(); + expect(deletedContact.d).toBe(true); // Should be marked as deleted + expect(deletedContact.dt).toBeDefined(); // Should have a deletion timestamp + }); + + it('does nothing if contact does not exist in remote storage', async () => { + const contactToDelete = MOCK_LOCAL_CONTACTS.ONE[0]; + + // Empty remote contacts + const remoteContacts: any[] = []; + + const { options, controller } = await arrangeMocks(); + + jest + .spyOn(controller, 'performGetStorage') + .mockResolvedValue(await createMockUserStorageContacts(remoteContacts)); + + const mockPerformSetStorage = jest.spyOn(controller, 'performSetStorage'); + + await ContactSyncingControllerIntegrationModule.deleteContactInRemoteStorage( + contactToDelete, + options as any, + ); + + // SetStorage should not be called if the contact doesn't exist + expect(mockPerformSetStorage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts new file mode 100644 index 00000000000..943466221bf --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/controller-integration.ts @@ -0,0 +1,525 @@ +import type { AddressBookEntry } from '@metamask/address-book-controller'; + +import { canPerformContactSyncing } from './sync-utils'; +import type { ContactSyncingOptions } from './types'; +import type { UserStorageContactEntry } from './types'; +import { + mapAddressBookEntryToUserStorageEntry, + mapUserStorageEntryToAddressBookEntry, + type SyncAddressBookEntry, +} from './utils'; +import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; + +// Define a constant to use as the key for storing all contacts +export const ADDRESS_BOOK_CONTACTS_KEY = 'contacts'; + +export type SyncContactsWithUserStorageConfig = { + onContactSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; + onContactUpdated?: () => void; + onContactDeleted?: () => void; +}; + +/** + * Creates a unique key for a contact based on chainId and address + * + * @param contact - The contact to create a key for + * @returns A unique string key + */ +function createContactKey(contact: AddressBookEntry): string { + return `${contact.chainId}:${contact.address.toLowerCase()}`; +} + +/** + * Syncs contacts between local storage and user storage (remote). + * + * Handles the following syncing scenarios: + * 1. First Sync: When local contacts exist but there are no remote contacts, uploads all local contacts. + * 2. New Device Sync: Downloads remote contacts that don't exist locally (empty local address book). + * 3. Simple Merge: Ensures both sides (local & remote) have all contacts. + * 4. Contact Naming Conflicts: When same contact has different names, uses most recent by timestamp. + * 5. Local Updates: When a contact was updated locally, syncs changes to remote if local is newer. + * 6. Remote Updates: When a contact was updated remotely, applies changes locally if remote is newer. + * 7. Local Deletions: When a contact was deleted locally, marks it as deleted in remote storage. + * 8. Remote Deletions: When a contact was deleted remotely, applies deletion locally. + * 9. Concurrent Updates: Resolves conflicts using timestamps to determine the winner. + * 10. Restore After Delete: If a contact is modified after being deleted, restores it. + * 11. ChainId Differences: Treats same address on different chains as separate contacts. + * + * @param config - Parameters used for syncing callbacks + * @param options - Parameters used for syncing operations + */ +export async function syncContactsWithUserStorage( + config: SyncContactsWithUserStorageConfig, + options: ContactSyncingOptions, +): Promise { + const { getMessenger, getUserStorageControllerInstance } = options; + const { + onContactSyncErroneousSituation, + onContactUpdated, + onContactDeleted, + } = config; + + try { + if (!canPerformContactSyncing(options)) { + console.log('🔄 Contacts Sync: Cannot perform sync, conditions not met'); + return; + } + + console.log('🔄 Contacts Sync: Starting sync process'); + + // Activate sync semaphore to prevent event loops + await getUserStorageControllerInstance().setIsContactSyncingInProgress( + true, + ); + + // Get all local contacts from AddressBookController (exclude chain "*" contacts) + const localVisibleContacts = + getMessenger() + .call('AddressBookController:list') + .filter((contact) => String(contact.chainId) !== '*') || []; + + console.log('🔄 Contacts Sync: Local contacts', localVisibleContacts); + + // Get remote contacts from user storage API + const remoteContacts = await getRemoteContacts(options); + console.log('🔄 Contacts Sync: Remote contacts', remoteContacts); + + // SCENARIO 1: First Sync - No remote contacts but local contacts exist + if (!remoteContacts || remoteContacts.length === 0) { + if (localVisibleContacts.length > 0) { + console.log( + '🔄 Contacts Sync: First sync - uploading local contacts to remote', + ); + await saveContactsToUserStorage(localVisibleContacts, options); + } + return; + } + + // Prepare maps for efficient lookup + const localContactsMap = new Map(); + const remoteContactsMap = new Map(); + + localVisibleContacts.forEach((contact) => { + const key = createContactKey(contact); + localContactsMap.set(key, contact); + }); + + remoteContacts.forEach((contact) => { + const key = createContactKey(contact); + remoteContactsMap.set(key, contact); + }); + + // Lists to track contacts that need to be synced + const contactsToAddOrUpdateLocally: SyncAddressBookEntry[] = []; + const contactsToDeleteLocally: SyncAddressBookEntry[] = []; + const contactsToUpdateRemotely: AddressBookEntry[] = []; + + // SCENARIO 2 & 6: Process remote contacts - handle new device sync and remote updates + for (const remoteContact of remoteContacts) { + const key = createContactKey(remoteContact); + const localContact = localContactsMap.get(key); + + // Handle remote contact based on its status and local existence + if (remoteContact.deleted) { + // SCENARIO 8: Remote deletion - should be applied locally if contact exists locally + if (localContact) { + console.log( + '🔄 Contacts Sync: Applying remote deletion locally', + remoteContact, + ); + contactsToDeleteLocally.push(remoteContact); + + // Invoke deletion callback if needed + if (onContactDeleted) { + onContactDeleted(); + } + } + } else if (!localContact) { + // SCENARIO 2: New contact from remote - import to local + console.log( + '🔄 Contacts Sync: Importing new remote contact', + remoteContact, + ); + contactsToAddOrUpdateLocally.push(remoteContact); + + // Invoke update callback if needed + if (onContactUpdated) { + onContactUpdated(); + } + } else { + // SCENARIO 4 & 6: Contact exists on both sides - check for conflicts + const hasContentDifference = + localContact.name !== remoteContact.name || + localContact.memo !== remoteContact.memo; + + if (hasContentDifference) { + // Check timestamps to determine which version to keep + const localTimestamp = localContact.lastUpdatedAt || 0; + const remoteTimestamp = remoteContact.lastUpdatedAt || 0; + + if (localTimestamp >= remoteTimestamp) { + // Local is newer (or same age) - use local version + console.log('🔄 Contacts Sync: Local wins by timestamp', { + local: localContact, + remote: remoteContact, + localTimestamp, + remoteTimestamp, + }); + contactsToUpdateRemotely.push(localContact); + } else { + // Remote is newer - use remote version + console.log('🔄 Contacts Sync: Remote wins by timestamp', { + local: localContact, + remote: remoteContact, + localTimestamp, + remoteTimestamp, + }); + contactsToAddOrUpdateLocally.push(remoteContact); + + // Invoke update callback if needed + if (onContactUpdated) { + onContactUpdated(); + } + } + } else { + // Content is identical, no action needed + console.log('🔄 Contacts Sync: Content identical, no action needed', { + local: localContact, + remote: remoteContact, + }); + } + } + } + + // SCENARIO 3 & 5: Process local contacts not in remote + for (const localContact of localVisibleContacts) { + const key = createContactKey(localContact); + const remoteContact = remoteContactsMap.get(key); + + if (!remoteContact) { + // New local contact - add to remote + console.log( + '🔄 Contacts Sync: New local contact - adding to remote', + localContact, + ); + contactsToUpdateRemotely.push(localContact); + } + } + + // SCENARIO 7: Detect local deletions + // Skip for new device scenario (no local contacts but remote has contacts) + const isNewDeviceScenario = + localVisibleContacts.length === 0 && remoteContacts.length > 0; + + if (!isNewDeviceScenario) { + // Find contacts that exist in remote but not locally (they were deleted locally) + const contactsToDeleteRemotely: SyncAddressBookEntry[] = []; + + for (const remoteContact of remoteContacts) { + if (!remoteContact.deleted) { + const key = createContactKey(remoteContact); + if (!localContactsMap.has(key)) { + console.log( + '🔄 Contacts Sync: Contact deleted locally, marking as deleted in remote', + remoteContact, + ); + contactsToDeleteRemotely.push(remoteContact); + + // Invoke deletion callback if needed + if (onContactDeleted) { + onContactDeleted(); + } + } + } + } + + // Mark contacts as deleted in remote + if (contactsToDeleteRemotely.length > 0) { + const remoteContactsWithDeletions = [...remoteContacts]; + + for (const contactToDelete of contactsToDeleteRemotely) { + const key = createContactKey(contactToDelete); + const indexToDelete = remoteContactsWithDeletions.findIndex( + (c) => createContactKey(c) === key, + ); + + if (indexToDelete !== -1) { + const now = Date.now(); + remoteContactsWithDeletions[indexToDelete] = { + ...remoteContactsWithDeletions[indexToDelete], + deleted: true, + deletedAt: now, + lastUpdatedAt: now, + }; + } + } + + // Save updated remote contacts with deletions + await saveContactsToUserStorage(remoteContactsWithDeletions, options); + } + } else { + console.log( + '🔄 Contacts Sync: Skipping local deletion detection - this is a new device', + ); + } + + // Apply local deletions + for (const contact of contactsToDeleteLocally) { + try { + console.log('🔄 Contacts Sync: Deleting contact locally', contact); + await getMessenger().call( + 'AddressBookController:delete', + contact.chainId, + contact.address, + ); + } catch (error) { + console.error('Error deleting contact:', error); + } + } + + // Apply local additions/updates + for (const contact of contactsToAddOrUpdateLocally) { + if (!contact.deleted) { + try { + console.log( + '🔄 Contacts Sync: Adding/updating contact locally', + contact, + ); + await getMessenger().call( + 'AddressBookController:set', + contact.address, + contact.name || '', + contact.chainId, + contact.memo || '', + contact.addressType, + ); + } catch (error) { + console.error('Error updating contact:', error); + } + } + } + + // Apply changes to remote storage + if (contactsToUpdateRemotely.length > 0) { + console.log( + '🔄 Contacts Sync: Updating contacts in remote storage', + contactsToUpdateRemotely, + ); + + // Update existing remote contacts with new contacts + const updatedRemoteContacts = [...remoteContacts]; + + for (const localContact of contactsToUpdateRemotely) { + const key = createContactKey(localContact); + const existingIndex = updatedRemoteContacts.findIndex( + (c) => createContactKey(c) === key, + ); + + const now = Date.now(); + const updatedEntry = { + ...localContact, + lastUpdatedAt: now, + deleted: false, // Ensure it's not deleted + } as SyncAddressBookEntry; + + if (existingIndex !== -1) { + // Update existing contact + updatedRemoteContacts[existingIndex] = updatedEntry; + } else { + // Add new contact + updatedRemoteContacts.push(updatedEntry); + } + } + + // Save updated contacts to remote storage + await saveContactsToUserStorage(updatedRemoteContacts, options); + } + + console.log('🔄 Contacts Sync: Sync completed successfully'); + } catch (error) { + console.error('🔄 Contacts Sync: Error during sync', error); + if (onContactSyncErroneousSituation) { + onContactSyncErroneousSituation('Error synchronizing contacts', { + error, + }); + } + } finally { + await getUserStorageControllerInstance().setIsContactSyncingInProgress( + false, + ); + } +} + +/** + * Retrieves remote contacts from user storage API + * + * @param options - Parameters used for retrieving remote contacts + * @returns Array of contacts from remote storage, or null if none found + */ +async function getRemoteContacts( + options: ContactSyncingOptions, +): Promise { + const { getUserStorageControllerInstance } = options; + + try { + const remoteContactsJson = + await getUserStorageControllerInstance().performGetStorage( + `${USER_STORAGE_FEATURE_NAMES.addressBook}.${ADDRESS_BOOK_CONTACTS_KEY}`, + ); + + if (!remoteContactsJson) { + return null; + } + + // Parse the JSON and convert each entry from UserStorageContactEntry to AddressBookEntry + const remoteStorageEntries = JSON.parse( + remoteContactsJson, + ) as UserStorageContactEntry[]; + return remoteStorageEntries.map((entry) => + mapUserStorageEntryToAddressBookEntry(entry), + ); + } catch { + return null; + } +} + +/** + * Saves local contacts to user storage + * + * @param contacts - The contacts to save to user storage + * @param options - Parameters used for saving contacts + */ +async function saveContactsToUserStorage( + contacts: AddressBookEntry[], + options: ContactSyncingOptions, +): Promise { + const { getUserStorageControllerInstance } = options; + + if (!contacts || contacts.length === 0) { + return; + } + + // Convert each AddressBookEntry to UserStorageContactEntry format before saving + const storageEntries = contacts.map((contact) => + mapAddressBookEntryToUserStorageEntry(contact), + ); + + await getUserStorageControllerInstance().performSetStorage( + `${USER_STORAGE_FEATURE_NAMES.addressBook}.${ADDRESS_BOOK_CONTACTS_KEY}`, + JSON.stringify(storageEntries), + ); +} + +/** + * Updates a single contact in remote storage without performing a full sync + * This is used when a contact is updated locally to efficiently push changes to remote + * + * @param contact - The contact that was updated locally + * @param options - Parameters used for syncing operations + */ +export async function updateContactInRemoteStorage( + contact: AddressBookEntry, + options: ContactSyncingOptions, +): Promise { + if (!canPerformContactSyncing(options)) { + return; + } + + console.log( + '🔄 Contacts Sync: Updating single contact in remote storage', + contact, + ); + + try { + // Get current remote contacts or initialize empty array + const remoteContacts = (await getRemoteContacts(options)) || []; + + // Find if this contact already exists in remote + const key = createContactKey(contact); + const existingIndex = remoteContacts.findIndex( + (c) => createContactKey(c) === key, + ); + + const updatedRemoteContacts = [...remoteContacts]; + + // Create an updated entry with timestamp + const updatedEntry = { + ...contact, + lastUpdatedAt: contact.lastUpdatedAt || Date.now(), + deleted: false, // Explicitly set to false in case this was previously deleted + } as SyncAddressBookEntry; + + if (existingIndex !== -1) { + // Update existing contact + updatedRemoteContacts[existingIndex] = updatedEntry; + } else { + // Add as new contact + updatedRemoteContacts.push(updatedEntry); + } + + // Save to remote storage + await saveContactsToUserStorage(updatedRemoteContacts, options); + } catch (error) { + console.error( + '🔄 Contacts Sync: Error updating contact in remote storage', + error, + ); + } +} + +/** + * Marks a single contact as deleted in remote storage without performing a full sync + * This is used when a contact is deleted locally to efficiently push the deletion to remote + * + * @param contact - The contact that was deleted locally (contains at least address and chainId) + * @param options - Parameters used for syncing operations + */ +export async function deleteContactInRemoteStorage( + contact: AddressBookEntry, + options: ContactSyncingOptions, +): Promise { + if (!canPerformContactSyncing(options)) { + return; + } + + console.log( + '🔄 Contacts Sync: Marking contact as deleted in remote storage', + contact, + ); + + try { + // Get current remote contacts + const remoteContacts = (await getRemoteContacts(options)) || []; + + // Find the contact in remote storage + const key = createContactKey(contact); + const existingIndex = remoteContacts.findIndex( + (c) => createContactKey(c) === key, + ); + + // If the contact exists in remote storage + if (existingIndex !== -1) { + const updatedRemoteContacts = [...remoteContacts]; + const now = Date.now(); + + // Mark the contact as deleted + updatedRemoteContacts[existingIndex] = { + ...updatedRemoteContacts[existingIndex], + deleted: true, + deletedAt: now, + lastUpdatedAt: now, + }; + + // Save to remote storage + await saveContactsToUserStorage(updatedRemoteContacts, options); + } + } catch (error) { + console.error( + '🔄 Contacts Sync: Error marking contact as deleted in remote storage', + error, + ); + } +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/setup-subscriptions.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/setup-subscriptions.test.ts new file mode 100644 index 00000000000..00475971500 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/setup-subscriptions.test.ts @@ -0,0 +1,278 @@ +import * as ControllerIntegration from './controller-integration'; +import { setupContactSyncingSubscriptions } from './setup-subscriptions'; +import * as SyncUtils from './sync-utils'; + +// Define a type for the contact data +type AddressBookContactData = { + address: string; + name: string; + chainId?: string; +}; + +describe('user-storage/contact-syncing/setup-subscriptions - setupContactSyncingSubscriptions', () => { + beforeEach(() => { + jest + .spyOn(SyncUtils, 'canPerformContactSyncing') + .mockImplementation(() => true); + + // Mock the individual operations methods + jest + .spyOn(ControllerIntegration, 'updateContactInRemoteStorage') + .mockResolvedValue(undefined); + + jest + .spyOn(ControllerIntegration, 'deleteContactInRemoteStorage') + .mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should subscribe to contactUpdated and contactDeleted events', () => { + const options = { + getMessenger: jest.fn().mockReturnValue({ + subscribe: jest.fn(), + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + syncContactsWithUserStorage: jest.fn(), + state: { + isProfileSyncingEnabled: true, + isContactSyncingEnabled: true, + }, + }), + }; + + setupContactSyncingSubscriptions(options); + + expect(options.getMessenger().subscribe).toHaveBeenCalledWith( + 'AddressBookController:contactUpdated', + expect.any(Function), + ); + + expect(options.getMessenger().subscribe).toHaveBeenCalledWith( + 'AddressBookController:contactDeleted', + expect.any(Function), + ); + }); + + it('should call updateContactInRemoteStorage when contactUpdated event is triggered', () => { + // Store the callbacks + const callbacks: Record void> = + {}; + + // Mock the subscribe function to capture callbacks + const mockSubscribe = jest + .fn() + .mockImplementation( + (event: string, callback: (data: AddressBookContactData) => void) => { + callbacks[event] = callback; + }, + ); + + const mocksyncContactsWithUserStorage = jest.fn(); + const mockUpdateContactInRemoteStorage = jest + .spyOn(ControllerIntegration, 'updateContactInRemoteStorage') + .mockResolvedValue(undefined); + + const options = { + getMessenger: jest.fn().mockReturnValue({ + subscribe: mockSubscribe, + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + syncContactsWithUserStorage: mocksyncContactsWithUserStorage, + state: { + isProfileSyncingEnabled: true, + isContactSyncingEnabled: true, + }, + }), + }; + + setupContactSyncingSubscriptions(options); + + // Assert that callback was registered + expect(callbacks['AddressBookController:contactUpdated']).toBeDefined(); + + // Sample contact with required properties + const sampleContact = { + address: '0x123', + name: 'Test', + chainId: '0x1', + }; + + // Simulate contactUpdated event + callbacks['AddressBookController:contactUpdated'](sampleContact); + + // Verify the individual update method was called instead of full sync + expect(mockUpdateContactInRemoteStorage).toHaveBeenCalledWith( + sampleContact, + options, + ); + expect(mocksyncContactsWithUserStorage).not.toHaveBeenCalled(); + }); + + it('should call deleteContactInRemoteStorage when contactDeleted event is triggered', () => { + // Store the callbacks + const callbacks: Record void> = + {}; + + // Mock the subscribe function to capture callbacks + const mockSubscribe = jest + .fn() + .mockImplementation( + (event: string, callback: (data: AddressBookContactData) => void) => { + callbacks[event] = callback; + }, + ); + + const mocksyncContactsWithUserStorage = jest.fn(); + const mockDeleteContactInRemoteStorage = jest + .spyOn(ControllerIntegration, 'deleteContactInRemoteStorage') + .mockResolvedValue(undefined); + + const options = { + getMessenger: jest.fn().mockReturnValue({ + subscribe: mockSubscribe, + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + syncContactsWithUserStorage: mocksyncContactsWithUserStorage, + state: { + isProfileSyncingEnabled: true, + isContactSyncingEnabled: true, + }, + }), + }; + + setupContactSyncingSubscriptions(options); + + // Assert that callback was registered + expect(callbacks['AddressBookController:contactDeleted']).toBeDefined(); + + // Sample contact with required properties + const sampleContact = { + address: '0x123', + name: 'Test', + chainId: '0x1', + }; + + // Simulate contactDeleted event + callbacks['AddressBookController:contactDeleted'](sampleContact); + + // Verify the individual delete method was called instead of full sync + expect(mockDeleteContactInRemoteStorage).toHaveBeenCalledWith( + sampleContact, + options, + ); + expect(mocksyncContactsWithUserStorage).not.toHaveBeenCalled(); + }); + + it('should not call operations when canPerformContactSyncing returns false', () => { + // Override the default mock to return false for this test + jest + .spyOn(SyncUtils, 'canPerformContactSyncing') + .mockImplementation(() => false); + + // Store the callbacks + const callbacks: Record void> = + {}; + + // Mock the subscribe function to capture callbacks + const mockSubscribe = jest + .fn() + .mockImplementation( + (event: string, callback: (data: AddressBookContactData) => void) => { + callbacks[event] = callback; + }, + ); + + const mocksyncContactsWithUserStorage = jest.fn(); + const mockUpdateContactInRemoteStorage = jest + .spyOn(ControllerIntegration, 'updateContactInRemoteStorage') + .mockResolvedValue(undefined); + const mockDeleteContactInRemoteStorage = jest + .spyOn(ControllerIntegration, 'deleteContactInRemoteStorage') + .mockResolvedValue(undefined); + + const options = { + getMessenger: jest.fn().mockReturnValue({ + subscribe: mockSubscribe, + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + syncContactsWithUserStorage: mocksyncContactsWithUserStorage, + state: { + isProfileSyncingEnabled: false, + isContactSyncingEnabled: false, + }, + }), + }; + + setupContactSyncingSubscriptions(options); + + // Assert that callbacks were registered + expect(callbacks['AddressBookController:contactUpdated']).toBeDefined(); + expect(callbacks['AddressBookController:contactDeleted']).toBeDefined(); + + // Sample contact + const sampleContact = { + address: '0x123', + name: 'Test', + chainId: '0x1', + }; + + // Simulate events + callbacks['AddressBookController:contactUpdated'](sampleContact); + callbacks['AddressBookController:contactDeleted'](sampleContact); + + // Verify no operations were called + expect(mockUpdateContactInRemoteStorage).not.toHaveBeenCalled(); + expect(mockDeleteContactInRemoteStorage).not.toHaveBeenCalled(); + expect(mocksyncContactsWithUserStorage).not.toHaveBeenCalled(); + }); + + it('should ignore contacts with chainId "*" for syncing', () => { + // Store the callbacks + const callbacks: Record void> = + {}; + + // Mock the subscribe function to capture callbacks + const mockSubscribe = jest + .fn() + .mockImplementation( + (event: string, callback: (data: AddressBookContactData) => void) => { + callbacks[event] = callback; + }, + ); + + const mockUpdateContactInRemoteStorage = jest + .spyOn(ControllerIntegration, 'updateContactInRemoteStorage') + .mockResolvedValue(undefined); + + const options = { + getMessenger: jest.fn().mockReturnValue({ + subscribe: mockSubscribe, + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + syncContactsWithUserStorage: jest.fn(), + state: { + isProfileSyncingEnabled: true, + isContactSyncingEnabled: true, + }, + }), + }; + + setupContactSyncingSubscriptions(options); + + // Global account contact with chainId "*" + const globalContact = { + address: '0x123', + name: 'Test Global', + chainId: '*', + }; + + // Simulate contactUpdated event with global contact + callbacks['AddressBookController:contactUpdated'](globalContact); + + // Verify the update method was NOT called for global contacts + expect(mockUpdateContactInRemoteStorage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/setup-subscriptions.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/setup-subscriptions.ts new file mode 100644 index 00000000000..70ac94befcd --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/setup-subscriptions.ts @@ -0,0 +1,73 @@ +import type { AddressBookEntry } from '@metamask/address-book-controller'; + +import { + updateContactInRemoteStorage, + deleteContactInRemoteStorage, +} from './controller-integration'; +import { canPerformContactSyncing } from './sync-utils'; +import type { ContactSyncingOptions } from './types'; + +/** + * Initialize and setup events to listen to for contact syncing + * + * @param options - parameters used for initializing and enabling contact syncing + */ +export function setupContactSyncingSubscriptions( + options: ContactSyncingOptions, +): void { + const { getMessenger } = options; + + // Listen for contact updates and immediately sync the individual contact + getMessenger().subscribe( + 'AddressBookController:contactUpdated', + (contactEntry: AddressBookEntry) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + if (!canPerformContactSyncing(options)) { + return; + } + + console.log('AddressBookController:contactUpdated', contactEntry); + + // Skip global accounts with chainId "*" : they are contacts bridged from accounts + if (String(contactEntry.chainId) === '*') { + return; + } + + try { + // Use the targeted method to update just this contact + await updateContactInRemoteStorage(contactEntry, options); + } catch (error) { + console.error('Error updating contact in remote storage:', error); + } + })(); + }, + ); + + // Listen for contact deletions and immediately sync the individual deletion + getMessenger().subscribe( + 'AddressBookController:contactDeleted', + (contactEntry: AddressBookEntry) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + if (!canPerformContactSyncing(options)) { + return; + } + + console.log('AddressBookController:contactDeleted', contactEntry); + + // Skip global accounts with chainId "*" : they are contacts bridged from accounts + if (String(contactEntry.chainId) === '*') { + return; + } + + try { + // Use the targeted method to delete just this contact + await deleteContactInRemoteStorage(contactEntry, options); + } catch (error) { + console.error('Error deleting contact from remote storage:', error); + } + })(); + }, + ); +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/sync-utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/sync-utils.test.ts new file mode 100644 index 00000000000..989cb3be494 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/sync-utils.test.ts @@ -0,0 +1,65 @@ +import { canPerformContactSyncing } from './sync-utils'; +import type { ContactSyncingOptions } from './types'; + +describe('user-storage/contact-syncing/sync-utils', () => { + describe('canPerformContactSyncing', () => { + const arrangeMocks = ({ + isBackupAndSyncEnabled = true, + isContactSyncingEnabled = true, + messengerCallControllerAndAction = 'AuthenticationController:isSignedIn', + messengerCallCallback = () => true, + }) => { + const options: ContactSyncingOptions = { + getMessenger: jest.fn().mockReturnValue({ + call: jest + .fn() + .mockImplementation((controllerAndActionName) => + controllerAndActionName === messengerCallControllerAndAction + ? messengerCallCallback() + : null, + ), + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + state: { + isBackupAndSyncEnabled, + isContactSyncingEnabled, + }, + }), + }; + + return { options }; + }; + + const failureCases = [ + ['profile syncing is not enabled', { isBackupAndSyncEnabled: false }], + [ + 'profile syncing is not enabled but contact syncing is', + { isBackupAndSyncEnabled: false, isContactSyncingEnabled: true }, + ], + [ + 'profile syncing is enabled but not contact syncing', + { isBackupAndSyncEnabled: true, isContactSyncingEnabled: false }, + ], + [ + 'authentication is not enabled', + { + messengerCallControllerAndAction: + 'AuthenticationController:isSignedIn', + messengerCallCallback: () => false, + }, + ], + ] as const; + + it.each(failureCases)('returns false if %s', (_message, mocks) => { + const { options } = arrangeMocks(mocks); + + expect(canPerformContactSyncing(options)).toBe(false); + }); + + it('returns true if all conditions are met', () => { + const { options } = arrangeMocks({}); + + expect(canPerformContactSyncing(options)).toBe(true); + }); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/sync-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/sync-utils.ts new file mode 100644 index 00000000000..f5767356830 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/sync-utils.ts @@ -0,0 +1,33 @@ +import type { ContactSyncingOptions } from './types'; + +/** + * Check if we can perform contact syncing + * + * @param options - parameters used for checking if we can perform contact syncing + * @returns whether we can perform contact syncing + */ +export function canPerformContactSyncing( + options: ContactSyncingOptions, +): boolean { + const { getMessenger, getUserStorageControllerInstance } = options; + + const { + isBackupAndSyncEnabled, + isContactSyncingEnabled, + isContactSyncingInProgress, + } = getUserStorageControllerInstance().state; + const isAuthEnabled = getMessenger().call( + 'AuthenticationController:isSignedIn', + ); + + if ( + !isBackupAndSyncEnabled || + !isContactSyncingEnabled || + isContactSyncingInProgress || + !isAuthEnabled + ) { + return false; + } + + return true; +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/types.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/types.ts new file mode 100644 index 00000000000..1895d4e3ee9 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/types.ts @@ -0,0 +1,40 @@ +import type { Hex } from '@metamask/utils'; + +import type { + USER_STORAGE_VERSION_KEY, + USER_STORAGE_VERSION, +} from './constants'; +import type { UserStorageControllerMessenger } from '../UserStorageController'; +import type UserStorageController from '../UserStorageController'; + +export type UserStorageContactEntry = { + /** + * The Version 'v' of the User Storage. + * NOTE - will allow us to support upgrade/downgrades in the future + */ + [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; + /** the address 'a' of the contact */ + a: string; + /** the name 'n' of the contact */ + n: string; + /** the chainId 'c' of the contact */ + c: Hex; + /** the memo 'm' of the contact (optional) */ + m?: string; + /** the addressType 't' of the contact (optional) */ + t?: string; + /** the lastUpdatedAt timestamp 'lu' of the contact */ + lu?: number; + /** the deleted flag 'd' of the contact (optional) */ + d?: boolean; + /** the deletedAt timestamp 'dt' of the contact (optional) */ + dt?: number; +}; + +/** + * Options for contact syncing operations + */ +export type ContactSyncingOptions = { + getUserStorageControllerInstance: () => UserStorageController; + getMessenger: () => UserStorageControllerMessenger; +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.test.ts new file mode 100644 index 00000000000..3a2e2e08bf6 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.test.ts @@ -0,0 +1,220 @@ +import type { AddressBookEntry } from '@metamask/address-book-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; + +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants'; +import type { UserStorageContactEntry } from './types'; +import { + mapAddressBookEntryToUserStorageEntry, + mapUserStorageEntryToAddressBookEntry, + type SyncAddressBookEntry, +} from './utils'; + +describe('user-storage/contact-syncing/utils', () => { + // Use checksum address format for consistent testing + const mockAddress = '0x123456789012345678901234567890abCdEF1234'; + const mockChainId = '0x1'; + const mockName = 'Test Contact'; + const mockMemo = 'This is a test contact'; + const mockTimestamp = 1657000000000; + const mockDeletedTimestamp = 1657000100000; + + beforeEach(() => { + // Mock Date.now() to return a fixed timestamp for consistent testing + jest.spyOn(Date, 'now').mockImplementation(() => mockTimestamp); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('mapAddressBookEntryToUserStorageEntry', () => { + it('should map a basic address book entry to a user storage entry', () => { + const addressBookEntry: AddressBookEntry = { + address: mockAddress, + chainId: mockChainId, + name: mockName, + memo: mockMemo, + isEns: false, + }; + + const userStorageEntry = + mapAddressBookEntryToUserStorageEntry(addressBookEntry); + + expect(userStorageEntry).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: mockAddress, + n: mockName, + c: mockChainId, + m: mockMemo, + // lu will be generated with Date.now(), so we just check it exists + lu: expect.any(Number), + }); + }); + + it('should map an address book entry with a timestamp to a user storage entry', () => { + const addressBookEntry = { + address: mockAddress, + chainId: mockChainId as `0x${string}`, + name: mockName, + memo: mockMemo, + isEns: false, + lastUpdatedAt: mockTimestamp, + } as SyncAddressBookEntry; + + const userStorageEntry = + mapAddressBookEntryToUserStorageEntry(addressBookEntry); + + expect(userStorageEntry).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: mockAddress, + n: mockName, + c: mockChainId, + m: mockMemo, + lu: mockTimestamp, + }); + }); + + it('should map a deleted address book entry to a user storage entry', () => { + const addressBookEntry = { + address: mockAddress, + chainId: mockChainId as `0x${string}`, + name: mockName, + memo: mockMemo, + isEns: false, + lastUpdatedAt: mockTimestamp, + deleted: true, + deletedAt: mockDeletedTimestamp, + } as SyncAddressBookEntry; + + const userStorageEntry = + mapAddressBookEntryToUserStorageEntry(addressBookEntry); + + expect(userStorageEntry).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: mockAddress, + n: mockName, + c: mockChainId, + m: mockMemo, + lu: mockTimestamp, + d: true, + dt: mockDeletedTimestamp, + }); + }); + + it('should handle empty memo field', () => { + const addressBookEntry: AddressBookEntry = { + address: mockAddress, + chainId: mockChainId, + name: mockName, + memo: '', + isEns: false, + }; + + const userStorageEntry = + mapAddressBookEntryToUserStorageEntry(addressBookEntry); + + expect(userStorageEntry).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: mockAddress, + n: mockName, + c: mockChainId, + lu: expect.any(Number), + }); + + // Ensure memo is not included when empty + expect(userStorageEntry.m).toBeUndefined(); + }); + }); + + describe('mapUserStorageEntryToAddressBookEntry', () => { + it('should map a basic user storage entry to an address book entry', () => { + const userStorageEntry: UserStorageContactEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: mockAddress, + n: mockName, + c: mockChainId, + m: mockMemo, + lu: mockTimestamp, + }; + + const addressBookEntry = + mapUserStorageEntryToAddressBookEntry(userStorageEntry); + + expect(addressBookEntry).toStrictEqual({ + address: mockAddress, + chainId: mockChainId, + name: mockName, + memo: mockMemo, + isEns: false, + lastUpdatedAt: mockTimestamp, + }); + }); + + it('should map a deleted user storage entry to an address book entry', () => { + const userStorageEntry: UserStorageContactEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: mockAddress, + n: mockName, + c: mockChainId, + m: mockMemo, + lu: mockTimestamp, + d: true, + dt: mockDeletedTimestamp, + }; + + const addressBookEntry = + mapUserStorageEntryToAddressBookEntry(userStorageEntry); + + expect(addressBookEntry).toStrictEqual({ + address: mockAddress, + chainId: mockChainId, + name: mockName, + memo: mockMemo, + isEns: false, + lastUpdatedAt: mockTimestamp, + deleted: true, + deletedAt: mockDeletedTimestamp, + }); + }); + + it('should handle missing optional fields', () => { + const userStorageEntry: UserStorageContactEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: mockAddress, + n: mockName, + c: mockChainId, + }; + + const addressBookEntry = + mapUserStorageEntryToAddressBookEntry(userStorageEntry); + + expect(addressBookEntry).toStrictEqual({ + address: mockAddress, + chainId: mockChainId, + name: mockName, + memo: '', + isEns: false, + }); + }); + + it('should normalize addresses to checksummed format', () => { + // Use lowercase address for this test specifically + const lowerCaseAddress = mockAddress.toLowerCase(); + const userStorageEntry: UserStorageContactEntry = { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: lowerCaseAddress, + n: mockName, + c: mockChainId, + }; + + const addressBookEntry = + mapUserStorageEntryToAddressBookEntry(userStorageEntry); + + expect(addressBookEntry.address).toBe( + toChecksumHexAddress(lowerCaseAddress), + ); + // Also verify it matches our mockAddress which is already in checksum format + expect(addressBookEntry.address).toBe(mockAddress); + }); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.ts new file mode 100644 index 00000000000..309b0e5b2da --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/contact-syncing/utils.ts @@ -0,0 +1,90 @@ +import type { + AddressBookEntry, + AddressType, +} from '@metamask/address-book-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; + +import { USER_STORAGE_VERSION_KEY, USER_STORAGE_VERSION } from './constants'; +import type { UserStorageContactEntry } from './types'; + +/** + * Extends AddressBookEntry with sync metadata + * This is only used internally during the sync process and is not stored in AddressBookController + */ +export type SyncAddressBookEntry = AddressBookEntry & { + lastUpdatedAt?: number; + deleted?: boolean; + deletedAt?: number; +}; + +/** + * Map an address book entry to a user storage address book entry + * Always sets a current timestamp for entries going to remote storage + * + * @param addressBookEntry - An address book entry + * @returns A user storage address book entry + */ +export const mapAddressBookEntryToUserStorageEntry = ( + addressBookEntry: AddressBookEntry, +): UserStorageContactEntry => { + const { address, name, chainId, memo, addressType } = addressBookEntry; + + // Get sync metadata from the input or use current timestamp if not present + const syncAddressBookEntry = addressBookEntry as SyncAddressBookEntry; + const now = Date.now(); + + return { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: toChecksumHexAddress(address), + n: name, + c: chainId, + ...(memo ? { m: memo } : {}), + ...(addressType ? { t: addressType } : {}), + lu: syncAddressBookEntry.lastUpdatedAt || now, + ...(syncAddressBookEntry.deleted + ? { d: syncAddressBookEntry.deleted } + : {}), + ...(syncAddressBookEntry.deletedAt + ? { dt: syncAddressBookEntry.deletedAt } + : {}), + }; +}; + +/** + * Map a user storage address book entry to an address book entry + * Preserves sync metadata from remote storage while keeping the + * entry compatible with AddressBookController + * + * @param userStorageEntry - A user storage address book entry + * @returns An address book entry with sync metadata for internal use + */ +export const mapUserStorageEntryToAddressBookEntry = ( + userStorageEntry: UserStorageContactEntry, +): SyncAddressBookEntry => { + // Create a standard AddressBookEntry + const addressBookEntry: SyncAddressBookEntry = { + address: toChecksumHexAddress(userStorageEntry.a), + name: userStorageEntry.n, + chainId: userStorageEntry.c, + memo: userStorageEntry.m || '', + isEns: false, // This will be updated by the AddressBookController + ...(userStorageEntry.t + ? { addressType: userStorageEntry.t as AddressType } + : {}), + }; + + // Include remote metadata for sync operation only (not stored in AddressBookController) + if (userStorageEntry.d) { + addressBookEntry.deleted = userStorageEntry.d; + } + + if (userStorageEntry.dt) { + addressBookEntry.deletedAt = userStorageEntry.dt; + } + + if (userStorageEntry.lu) { + addressBookEntry.lastUpdatedAt = userStorageEntry.lu; + } + + return addressBookEntry; +}; diff --git a/packages/profile-sync-controller/src/shared/storage-schema.ts b/packages/profile-sync-controller/src/shared/storage-schema.ts index 51fe4b3f8fc..7151eb71431 100644 --- a/packages/profile-sync-controller/src/shared/storage-schema.ts +++ b/packages/profile-sync-controller/src/shared/storage-schema.ts @@ -13,6 +13,7 @@ export const USER_STORAGE_FEATURE_NAMES = { notifications: 'notifications', accounts: 'accounts_v2', networks: 'networks', + addressBook: 'addressBook', } as const; export type UserStorageFeatureNames = @@ -22,6 +23,7 @@ export const USER_STORAGE_SCHEMA = { [USER_STORAGE_FEATURE_NAMES.notifications]: ['notification_settings'], [USER_STORAGE_FEATURE_NAMES.accounts]: [ALLOW_ARBITRARY_KEYS], // keyed by account addresses [USER_STORAGE_FEATURE_NAMES.networks]: [ALLOW_ARBITRARY_KEYS], // keyed by chains/networks + [USER_STORAGE_FEATURE_NAMES.addressBook]: [ALLOW_ARBITRARY_KEYS], // keyed by address_chainId } as const; type UserStorageSchema = typeof USER_STORAGE_SCHEMA; diff --git a/packages/profile-sync-controller/tsconfig.build.json b/packages/profile-sync-controller/tsconfig.build.json index 7f80dc1554f..d8b324d00cd 100644 --- a/packages/profile-sync-controller/tsconfig.build.json +++ b/packages/profile-sync-controller/tsconfig.build.json @@ -10,7 +10,8 @@ { "path": "../base-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../accounts-controller/tsconfig.build.json" }, - { "path": "../network-controller/tsconfig.build.json" } + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../address-book-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"], "exclude": [ diff --git a/packages/profile-sync-controller/tsconfig.json b/packages/profile-sync-controller/tsconfig.json index 8e86565b1eb..a1d57d98b89 100644 --- a/packages/profile-sync-controller/tsconfig.json +++ b/packages/profile-sync-controller/tsconfig.json @@ -7,7 +7,8 @@ { "path": "../base-controller" }, { "path": "../keyring-controller" }, { "path": "../accounts-controller" }, - { "path": "../network-controller" } + { "path": "../network-controller" }, + { "path": "../address-book-controller" } ], "include": ["../../types", "./src"] }