Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"count": 1
},
"n/no-sync": {
"count": 22
"count": 23
}
},
"packages/account-tree-controller/src/backup-and-sync/service/index.ts": {
Expand Down
4 changes: 4 additions & 0 deletions packages/account-tree-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `AccountTreeController.syncWalletWithUserStorage(entropySourceId)` and the corresponding `AccountTreeController:syncWalletWithUserStorage` messenger action, which performs a bidirectional user-storage sync for a single entropy wallet (wallet metadata + groups) without iterating every local wallet. Use this in place of `syncWithUserStorage` after operations that only affect one wallet (e.g., SRP import) ([#8929](https://github.com/MetaMask/core/pull/8929))

## [7.5.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,25 @@ export type AccountTreeControllerSyncWithUserStorageAtLeastOnceAction = {
handler: AccountTreeController['syncWithUserStorageAtLeastOnce'];
};

/**
* Bi-directionally syncs a single entropy wallet with user storage, scoped
* by entropy source ID. Use this in place of `syncWithUserStorage` when
* only one wallet's state has changed (e.g., after an SRP import) to
* avoid the per-wallet fanout of fetches that a full sync triggers.
*
* IMPORTANT:
* If a full sync is already in progress, returns the ongoing full-sync
* promise so callers cooperatively wait for it instead of racing. Does
* NOT mark the controller as having completed its first full sync.
*
* @param entropySourceId - The entropy source ID of the wallet to sync.
* @returns A promise that resolves when the sync is complete.
*/
export type AccountTreeControllerSyncWalletWithUserStorageAction = {
type: `AccountTreeController:syncWalletWithUserStorage`;
handler: AccountTreeController['syncWalletWithUserStorage'];
};

/**
* Union of all AccountTreeController action types.
*/
Expand All @@ -218,4 +237,5 @@ export type AccountTreeControllerMethodActions =
| AccountTreeControllerSetAccountGroupHiddenAction
| AccountTreeControllerClearStateAction
| AccountTreeControllerSyncWithUserStorageAction
| AccountTreeControllerSyncWithUserStorageAtLeastOnceAction;
| AccountTreeControllerSyncWithUserStorageAtLeastOnceAction
| AccountTreeControllerSyncWalletWithUserStorageAction;
Original file line number Diff line number Diff line change
Expand Up @@ -5138,6 +5138,49 @@ describe('AccountTreeController', () => {
});
});

describe('syncWalletWithUserStorage', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('calls performSyncForWallet on the syncing service', async () => {
const performSyncForWalletSpy = jest
.spyOn(BackupAndSyncService.prototype, 'performSyncForWallet')
.mockResolvedValue(undefined);

const { controller } = setup({
accounts: [MOCK_HARDWARE_ACCOUNT_1],
keyrings: [MOCK_HD_KEYRING_1],
});

controller.init();

await controller.syncWalletWithUserStorage('test-entropy-id');

expect(performSyncForWalletSpy).toHaveBeenCalledTimes(1);
expect(performSyncForWalletSpy).toHaveBeenCalledWith('test-entropy-id');
});

it('propagates errors from the syncing service', async () => {
const syncError = new Error('Sync failed');
const performSyncForWalletSpy = jest
.spyOn(BackupAndSyncService.prototype, 'performSyncForWallet')
.mockRejectedValue(syncError);

const { controller } = setup({
accounts: [MOCK_HARDWARE_ACCOUNT_1],
keyrings: [MOCK_HD_KEYRING_1],
});

controller.init();

await expect(
controller.syncWalletWithUserStorage('test-entropy-id'),
).rejects.toThrow(syncError.message);
expect(performSyncForWalletSpy).toHaveBeenCalledTimes(1);
});
});

describe('UserStorageController:stateChange subscription', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
19 changes: 19 additions & 0 deletions packages/account-tree-controller/src/AccountTreeController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const MESSENGER_EXPOSED_METHODS = [
'clearState',
'syncWithUserStorage',
'syncWithUserStorageAtLeastOnce',
'syncWalletWithUserStorage',
'init',
'reinit',
] as const;
Expand Down Expand Up @@ -1789,6 +1790,24 @@ export class AccountTreeController extends BaseController<
return this.#backupAndSyncService.performFullSyncAtLeastOnce();
}

/**
* Bi-directionally syncs a single entropy wallet with user storage, scoped
* by entropy source ID. Use this in place of `syncWithUserStorage` when
* only one wallet's state has changed (e.g., after an SRP import) to
* avoid the per-wallet fanout of fetches that a full sync triggers.
*
* IMPORTANT:
* If a full sync is already in progress, returns the ongoing full-sync
* promise so callers cooperatively wait for it instead of racing. Does
* NOT mark the controller as having completed its first full sync.
*
* @param entropySourceId - The entropy source ID of the wallet to sync.
* @returns A promise that resolves when the sync is complete.
*/
async syncWalletWithUserStorage(entropySourceId: string): Promise<void> {
return this.#backupAndSyncService.performSyncForWallet(entropySourceId);
}

/**
* Creates an backup and sync context for sync operations.
* Used by the backup and sync service.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('BackupAndSyncAnalytics - Traces', () => {
it('contains expected trace names', () => {
expect(TraceName).toStrictEqual({
AccountSyncFull: 'Multichain Account Syncing - Full',
AccountSyncWallet: 'Multichain Account Syncing - Wallet',
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {

export const TraceName = {
AccountSyncFull: 'Multichain Account Syncing - Full',
AccountSyncWallet: 'Multichain Account Syncing - Wallet',
} as const;

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type { AccountGroupObject } from '../../group';
import type { AccountWalletEntropyObject } from '../../wallet';
import { getProfileId } from '../authentication';
import type { BackupAndSyncContext } from '../types';
import {
getAllGroupsFromUserStorage,
getWalletFromUserStorage,
} from '../user-storage';
// We only need to import the functions we actually spy on
import { getLocalEntropyWallets } from '../utils';

Expand All @@ -20,6 +24,14 @@ const mockGetProfileId = getProfileId as jest.MockedFunction<
>;
const mockGetLocalEntropyWallets =
getLocalEntropyWallets as jest.MockedFunction<typeof getLocalEntropyWallets>;
const mockGetWalletFromUserStorage =
getWalletFromUserStorage as jest.MockedFunction<
typeof getWalletFromUserStorage
>;
const mockGetAllGroupsFromUserStorage =
getAllGroupsFromUserStorage as jest.MockedFunction<
typeof getAllGroupsFromUserStorage
>;

describe('BackupAndSync - Service - BackupAndSyncService', () => {
let mockContext: BackupAndSyncContext;
Expand Down Expand Up @@ -710,4 +722,179 @@ describe('BackupAndSync - Service - BackupAndSyncService', () => {
expect(syncExecutionCount).toBe(2); // Still only 2
}, 15000); // Increase timeout to 15 seconds
});

describe('performSyncForWallet', () => {
beforeEach(() => {
setupMockUserStorageControllerState(true, true);
jest.clearAllMocks();
mockGetLocalEntropyWallets.mockClear();
});

it('returns early (resolved) when backup and sync is disabled', async () => {
setupMockUserStorageControllerState(false, true);

await backupAndSyncService.performSyncForWallet('test-entropy-id');

expect(mockGetWalletFromUserStorage).not.toHaveBeenCalled();
expect(mockGetLocalEntropyWallets).not.toHaveBeenCalled();
});

it('syncs only the matched wallet, not every local wallet', async () => {
mockGetLocalEntropyWallets.mockReturnValue([
{
id: 'entropy:wallet-1',
metadata: { entropy: { id: 'entropy-id-1' } },
} as unknown as AccountWalletEntropyObject,
{
id: 'entropy:wallet-2',
metadata: { entropy: { id: 'entropy-id-2' } },
} as unknown as AccountWalletEntropyObject,
]);
mockGetWalletFromUserStorage.mockResolvedValue(null);
mockGetAllGroupsFromUserStorage.mockResolvedValue([]);

await backupAndSyncService.performSyncForWallet('entropy-id-2');

// The fetches should target only the requested wallet's entropy source.
expect(mockGetWalletFromUserStorage).toHaveBeenCalledTimes(1);
expect(mockGetWalletFromUserStorage).toHaveBeenCalledWith(
expect.anything(),
'entropy-id-2',
);
expect(mockGetAllGroupsFromUserStorage).toHaveBeenCalledTimes(1);
expect(mockGetAllGroupsFromUserStorage).toHaveBeenCalledWith(
expect.anything(),
'entropy-id-2',
);
expect(mockGetProfileId).toHaveBeenCalledTimes(1);
expect(mockGetProfileId).toHaveBeenCalledWith(
expect.anything(),
'entropy-id-2',
);
});

it('does not flip hasAccountTreeSyncingSyncedAtLeastOnce', async () => {
mockContext.controller.state.hasAccountTreeSyncingSyncedAtLeastOnce = false;
mockGetLocalEntropyWallets.mockReturnValue([
{
id: 'entropy:wallet-1',
metadata: { entropy: { id: 'entropy-id-1' } },
} as unknown as AccountWalletEntropyObject,
]);
mockGetWalletFromUserStorage.mockResolvedValue(null);
mockGetAllGroupsFromUserStorage.mockResolvedValue([]);

const draft: Record<string, unknown> = {
hasAccountTreeSyncingSyncedAtLeastOnce: false,
isAccountTreeSyncingInProgress: false,
};
(mockContext.controllerStateUpdateFn as jest.Mock).mockImplementation(
(updater: (state: typeof draft) => void) => {
updater(draft);
},
);

await backupAndSyncService.performSyncForWallet('entropy-id-1');

// Scoped sync must NOT satisfy the first-full-sync contract.
expect(draft.hasAccountTreeSyncingSyncedAtLeastOnce).toBe(false);
});

it('toggles isAccountTreeSyncingInProgress around the sync body', async () => {
let flagWhileRunning: boolean | undefined;
const draft: Record<string, unknown> = {
isAccountTreeSyncingInProgress: false,
};
(mockContext.controllerStateUpdateFn as jest.Mock).mockImplementation(
(updater: (state: typeof draft) => void) => {
updater(draft);
},
);

mockGetLocalEntropyWallets.mockReturnValue([
{
id: 'entropy:wallet-1',
metadata: { entropy: { id: 'entropy-id-1' } },
} as unknown as AccountWalletEntropyObject,
]);
// Capture the flag value at the moment the sync body runs — proves it
// was set true before the body started.
(mockContext.traceFn as jest.Mock).mockImplementation(
async (_: unknown, fn: () => Promise<void>) => {
flagWhileRunning = draft.isAccountTreeSyncingInProgress as boolean;
await fn();
},
);
mockGetWalletFromUserStorage.mockResolvedValue(null);
mockGetAllGroupsFromUserStorage.mockResolvedValue([]);

await backupAndSyncService.performSyncForWallet('entropy-id-1');

expect(flagWhileRunning).toBe(true);
// And reset after the sync completes.
expect(draft.isAccountTreeSyncingInProgress).toBe(false);
});

it('returns the in-flight full sync promise when one is running', async () => {
mockGetLocalEntropyWallets.mockReturnValue([
{
id: 'entropy:wallet-1',
metadata: { entropy: { id: 'entropy-id-1' } },
} as unknown as AccountWalletEntropyObject,
]);

// Make the full sync hang so we can observe the wallet-scoped call
// dedup against it.
let resolveTrace: (() => void) | undefined;
const tracePromise = new Promise<void>((resolve) => {
resolveTrace = resolve;
});
(mockContext.traceFn as jest.Mock).mockImplementation(
(_: unknown, fn: () => unknown) => {
fn();
return tracePromise;
},
);

const fullSyncPromise = backupAndSyncService.performFullSync();
const walletSyncPromise =
backupAndSyncService.performSyncForWallet('entropy-id-1');

expect(walletSyncPromise).toStrictEqual(fullSyncPromise);

resolveTrace?.();
await Promise.all([fullSyncPromise, walletSyncPromise]);
});

it('is a no-op when no local wallet matches the entropy source ID', async () => {
mockGetLocalEntropyWallets.mockReturnValue([
{
id: 'entropy:wallet-1',
metadata: { entropy: { id: 'entropy-id-1' } },
} as unknown as AccountWalletEntropyObject,
]);

await backupAndSyncService.performSyncForWallet('unknown-entropy-id');

expect(mockGetWalletFromUserStorage).not.toHaveBeenCalled();
expect(mockGetAllGroupsFromUserStorage).not.toHaveBeenCalled();
expect(mockGetProfileId).not.toHaveBeenCalled();
// No flag toggle when there's no wallet to sync.
expect(mockContext.controllerStateUpdateFn).not.toHaveBeenCalled();
});

it('returns early when the in-progress flag is already set', async () => {
mockContext.controller.state.isAccountTreeSyncingInProgress = true;
mockGetLocalEntropyWallets.mockReturnValue([
{
id: 'entropy:wallet-1',
metadata: { entropy: { id: 'entropy-id-1' } },
} as unknown as AccountWalletEntropyObject,
]);

await backupAndSyncService.performSyncForWallet('entropy-id-1');

expect(mockGetWalletFromUserStorage).not.toHaveBeenCalled();
});
});
});
Loading
Loading