diff --git a/eslint-suppressions.json b/eslint-suppressions.json index eedb60a53b..1d8a56bb3e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -37,7 +37,7 @@ "count": 1 }, "n/no-sync": { - "count": 22 + "count": 23 } }, "packages/account-tree-controller/src/backup-and-sync/service/index.ts": { diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index e7d0966aa0..143da637ab 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -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 diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index abc6ca61e9..37442f7ee1 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -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. */ @@ -218,4 +237,5 @@ export type AccountTreeControllerMethodActions = | AccountTreeControllerSetAccountGroupHiddenAction | AccountTreeControllerClearStateAction | AccountTreeControllerSyncWithUserStorageAction - | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction; + | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction + | AccountTreeControllerSyncWalletWithUserStorageAction; diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index dfdf7bfa5e..f8ba02087d 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -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(); diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 5640662e9f..28cceee217 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -59,6 +59,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'clearState', 'syncWithUserStorage', 'syncWithUserStorageAtLeastOnce', + 'syncWalletWithUserStorage', 'init', 'reinit', ] as const; @@ -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 { + return this.#backupAndSyncService.performSyncForWallet(entropySourceId); + } + /** * Creates an backup and sync context for sync operations. * Used by the backup and sync service. diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/traces.test.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.test.ts index 791ccdd563..c37b6773b0 100644 --- a/packages/account-tree-controller/src/backup-and-sync/analytics/traces.test.ts +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.test.ts @@ -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', }); }); }); diff --git a/packages/account-tree-controller/src/backup-and-sync/analytics/traces.ts b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.ts index 7383fadebf..5340cac8ba 100644 --- a/packages/account-tree-controller/src/backup-and-sync/analytics/traces.ts +++ b/packages/account-tree-controller/src/backup-and-sync/analytics/traces.ts @@ -6,6 +6,7 @@ import type { export const TraceName = { AccountSyncFull: 'Multichain Account Syncing - Full', + AccountSyncWallet: 'Multichain Account Syncing - Wallet', } as const; /** diff --git a/packages/account-tree-controller/src/backup-and-sync/service/index.test.ts b/packages/account-tree-controller/src/backup-and-sync/service/index.test.ts index ebbda948e3..c674cf2659 100644 --- a/packages/account-tree-controller/src/backup-and-sync/service/index.test.ts +++ b/packages/account-tree-controller/src/backup-and-sync/service/index.test.ts @@ -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'; @@ -20,6 +24,14 @@ const mockGetProfileId = getProfileId as jest.MockedFunction< >; const mockGetLocalEntropyWallets = getLocalEntropyWallets as jest.MockedFunction; +const mockGetWalletFromUserStorage = + getWalletFromUserStorage as jest.MockedFunction< + typeof getWalletFromUserStorage + >; +const mockGetAllGroupsFromUserStorage = + getAllGroupsFromUserStorage as jest.MockedFunction< + typeof getAllGroupsFromUserStorage + >; describe('BackupAndSync - Service - BackupAndSyncService', () => { let mockContext: BackupAndSyncContext; @@ -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 = { + 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 = { + 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) => { + 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((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(); + }); + }); }); diff --git a/packages/account-tree-controller/src/backup-and-sync/service/index.ts b/packages/account-tree-controller/src/backup-and-sync/service/index.ts index 852af9daad..669412996e 100644 --- a/packages/account-tree-controller/src/backup-and-sync/service/index.ts +++ b/packages/account-tree-controller/src/backup-and-sync/service/index.ts @@ -314,123 +314,7 @@ export class BackupAndSyncService { // 2. Iterate over each local wallet for (const wallet of localSyncableWallets) { - const entropySourceId = wallet.metadata.entropy.id; - - let walletProfileId: ProfileId; - let walletFromUserStorage: UserStorageSyncedWallet | null; - let groupsFromUserStorage: UserStorageSyncedWalletGroup[]; - - try { - walletProfileId = await getProfileId( - this.#context, - entropySourceId, - ); - - [walletFromUserStorage, groupsFromUserStorage] = await Promise.all([ - getWalletFromUserStorage(this.#context, entropySourceId), - getAllGroupsFromUserStorage(this.#context, entropySourceId), - ]); - - // 2.1 Decide if we need to perform legacy account syncing - if ( - !walletFromUserStorage || - !walletFromUserStorage.isLegacyAccountSyncingDisabled - ) { - // 2.2 Perform legacy account syncing - // This will migrate legacy account data to the new structure. - // This operation will only be performed once. - await performLegacyAccountSyncing( - this.#context, - entropySourceId, - walletProfileId, - ); - } - } catch (error) { - const errorMessage = toErrorMessage(error); - - backupAndSyncLogger( - `Legacy syncing failed for wallet ${wallet.id}: ${errorMessage}`, - ); - throw new Error( - `Legacy syncing failed for wallet: ${errorMessage}`, - ); - } - - // 3. Execute multichain account syncing - let stateSnapshot: StateSnapshot | undefined; - - try { - // 3.1 Wallet syncing - // Create a state snapshot before processing each wallet for potential rollback - stateSnapshot = createStateSnapshot(this.#context); - - // Sync wallet metadata bidirectionally - await syncWalletMetadata( - this.#context, - wallet, - walletFromUserStorage, - walletProfileId, - ); - - // 3.2 Groups syncing - // If groups data does not exist in user storage yet, create it - if (!groupsFromUserStorage.length) { - // If no groups exist in user storage, we can push all groups from the wallet to the user storage and exit - await pushGroupToUserStorageBatch( - this.#context, - getLocalGroupsForEntropyWallet(this.#context, wallet.id), - entropySourceId, - ); - - continue; // No need to proceed with metadata comparison if groups are new - } - - // Create local groups for each group from user storage if they do not exist - // This will ensure that we have all groups available locally before syncing metadata - await createLocalGroupsFromUserStorage( - this.#context, - groupsFromUserStorage, - entropySourceId, - walletProfileId, - ); - - // Sync group metadata bidirectionally - await syncGroupsMetadata( - this.#context, - wallet, - groupsFromUserStorage, - entropySourceId, - walletProfileId, - ); - } catch (error) { - const errorMessage = toErrorMessage(error); - const errorString = `Error during multichain account syncing for wallet ${wallet.id}: ${errorMessage}`; - - backupAndSyncLogger(errorString); - - // Attempt to rollback state changes for this wallet - try { - if (!stateSnapshot) { - throw new Error( - `State snapshot is missing for wallet ${wallet.id}`, - ); - } - restoreStateFromSnapshot(this.#context, stateSnapshot); - backupAndSyncLogger( - `Rolled back state changes for wallet ${wallet.id}`, - ); - } catch (rollbackError) { - backupAndSyncLogger( - `Failed to rollback state for wallet ${wallet.id}:`, - rollbackError instanceof Error - ? rollbackError.message - : String(rollbackError), - ); - } - - // Continue with next wallet instead of failing the entire sync - continue; - } + await this.#performWalletSyncInner(wallet); } } catch (error) { backupAndSyncLogger('Error during multichain account syncing:', error); @@ -460,6 +344,213 @@ export class BackupAndSyncService { } } + /** + * Performs the bidirectional sync work for a single entropy wallet: + * legacy account syncing (when needed), wallet metadata sync, and group + * metadata sync. Mirrors the per-wallet body of {@link #performFullSyncInner}. + * + * Per-wallet errors are isolated: legacy sync failures bubble up, but + * multichain sync failures trigger a state rollback for that wallet only, + * allowing the caller to continue with the next wallet (full sync) or + * resolve cleanly (single-wallet sync). + * + * @param wallet - The local entropy wallet to sync. + */ + async #performWalletSyncInner( + wallet: AccountWalletEntropyObject, + ): Promise { + const entropySourceId = wallet.metadata.entropy.id; + + let walletProfileId: ProfileId; + let walletFromUserStorage: UserStorageSyncedWallet | null; + let groupsFromUserStorage: UserStorageSyncedWalletGroup[]; + + try { + walletProfileId = await getProfileId(this.#context, entropySourceId); + + [walletFromUserStorage, groupsFromUserStorage] = await Promise.all([ + getWalletFromUserStorage(this.#context, entropySourceId), + getAllGroupsFromUserStorage(this.#context, entropySourceId), + ]); + + // Decide if we need to perform legacy account syncing + if ( + !walletFromUserStorage || + !walletFromUserStorage.isLegacyAccountSyncingDisabled + ) { + // Perform legacy account syncing. + // This will migrate legacy account data to the new structure. + // This operation will only be performed once. + await performLegacyAccountSyncing( + this.#context, + entropySourceId, + walletProfileId, + ); + } + } catch (error) { + const errorMessage = toErrorMessage(error); + + backupAndSyncLogger( + `Legacy syncing failed for wallet ${wallet.id}: ${errorMessage}`, + ); + throw new Error(`Legacy syncing failed for wallet: ${errorMessage}`); + } + + // Execute multichain account syncing + let stateSnapshot: StateSnapshot | undefined; + + try { + // Wallet syncing. + // Create a state snapshot before processing each wallet for potential rollback + stateSnapshot = createStateSnapshot(this.#context); + + // Sync wallet metadata bidirectionally + await syncWalletMetadata( + this.#context, + wallet, + walletFromUserStorage, + walletProfileId, + ); + + // Groups syncing. + // If groups data does not exist in user storage yet, create it + if (!groupsFromUserStorage.length) { + // If no groups exist in user storage, we can push all groups from the wallet to the user storage and exit + await pushGroupToUserStorageBatch( + this.#context, + getLocalGroupsForEntropyWallet(this.#context, wallet.id), + entropySourceId, + ); + + return; // No need to proceed with metadata comparison if groups are new + } + + // Create local groups for each group from user storage if they do not exist + // This will ensure that we have all groups available locally before syncing metadata + await createLocalGroupsFromUserStorage( + this.#context, + groupsFromUserStorage, + entropySourceId, + walletProfileId, + ); + + // Sync group metadata bidirectionally + await syncGroupsMetadata( + this.#context, + wallet, + groupsFromUserStorage, + entropySourceId, + walletProfileId, + ); + } catch (error) { + const errorMessage = toErrorMessage(error); + const errorString = `Error during multichain account syncing for wallet ${wallet.id}: ${errorMessage}`; + + backupAndSyncLogger(errorString); + + // Attempt to rollback state changes for this wallet + try { + if (!stateSnapshot) { + throw new Error(`State snapshot is missing for wallet ${wallet.id}`); + } + restoreStateFromSnapshot(this.#context, stateSnapshot); + backupAndSyncLogger( + `Rolled back state changes for wallet ${wallet.id}`, + ); + } catch (rollbackError) { + backupAndSyncLogger( + `Failed to rollback state for wallet ${wallet.id}:`, + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError), + ); + } + } + } + + /** + * Performs a bidirectional sync with user storage for a single entropy + * wallet, scoped by entropy source ID. + * + * Use this in place of {@link performFullSync} when only one wallet's + * state has changed (e.g., immediately after an SRP import) to avoid the + * per-wallet fanout of fetches that a full sync triggers. + * + * Behavior: + * - Returns early (resolved) if backup and sync is disabled. + * - If a full sync is already in flight, returns the in-flight promise so + * callers cooperatively wait for the broader operation instead of + * racing against it. + * - Does NOT flip `hasAccountTreeSyncingSyncedAtLeastOnce`. A scoped sync + * for one wallet does not satisfy the canonical "first full sync" + * contract, since other wallets may still need legacy migration. + * + * @param entropySourceId - The entropy source ID of the wallet to sync. + * @returns A promise that resolves when the sync is complete. + */ + async performSyncForWallet(entropySourceId: string): Promise { + if (!this.isBackupAndSyncEnabled) { + return undefined; + } + + // Defer to the in-flight full sync so we don't race against it. + if (this.#ongoingFullSyncPromise) { + return this.#ongoingFullSyncPromise; + } + + return this.#atomicSyncQueue.enqueue(() => + this.#performSyncForWalletInner(entropySourceId), + ); + } + + /** + * Performs the work for {@link performSyncForWallet} once it has been + * dequeued: locates the matching wallet and runs the per-wallet sync body + * under the wallet trace. + * + * Sets `isAccountTreeSyncingInProgress` for the duration of the sync so + * that `enqueueSingleGroupSync` calls triggered by `#insert` (when + * `createLocalGroupsFromUserStorage` materializes remote groups locally) + * are gated off and don't fanout per-group GET+PUT roundtrips for data we + * just pulled. + * + * @param entropySourceId - The entropy source ID of the wallet to sync. + */ + async #performSyncForWalletInner(entropySourceId: string): Promise { + if (this.isInProgress) { + return; + } + + const wallet = getLocalEntropyWallets(this.#context).find( + (candidate) => candidate.metadata.entropy.id === entropySourceId, + ); + + if (!wallet) { + return; + } + + this.#context.controllerStateUpdateFn( + (state: AccountTreeControllerState) => { + state.isAccountTreeSyncingInProgress = true; + }, + ); + + try { + await this.#context.traceFn( + { + name: TraceName.AccountSyncWallet, + }, + () => this.#performWalletSyncInner(wallet), + ); + } finally { + this.#context.controllerStateUpdateFn( + (state: AccountTreeControllerState) => { + state.isAccountTreeSyncingInProgress = false; + }, + ); + } + } + /** * Performs a single wallet's bidirectional metadata sync with user storage. * diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 28b2f17f64..7dac76aa4d 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -36,6 +36,7 @@ export type { AccountTreeControllerClearStateAction, AccountTreeControllerSyncWithUserStorageAction, AccountTreeControllerSyncWithUserStorageAtLeastOnceAction, + AccountTreeControllerSyncWalletWithUserStorageAction, AccountTreeControllerInitAction, AccountTreeControllerReinitAction, } from './AccountTreeController-method-action-types';