Skip to content

Commit fb601af

Browse files
authored
fix(account-tree-controller/backup-and-sync): do not send extra metadata properties (#8300)
## Explanation Since the introduction of `lastSelected` metadata (which should not be synced), backup & sync was not accepting the data coming back from user-storage. Mostly because the payload was like this: ```txt "{\"name\":{\"value\":\"Account 1\",\"lastUpdatedAt\":0},\"pinned\":{\"value\":false,\"lastUpdatedAt\":0},\"hidden\":{\"value\":false,\"lastUpdatedAt\":0},\"lastSelected\":0,\"groupIndex\":0}" "{\"name\":{\"value\":\"Account 2\",\"lastUpdatedAt\":0},\"pinned\":{\"value\":false,\"lastUpdatedAt\":0},\"hidden\":{\"value\":false,\"lastUpdatedAt\":0},\"lastSelected\":0,\"groupIndex\":1}" ``` The extra `lastSelected` shouldn't have been part of that payload. This was making the `superstruct` validation to fail when getting those data back, thus, preventing account syncing to finally restore accounts. We now `mask` the extra properties that are not part of backup & sync schema. ## References N/A ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches backup/sync serialization and error handling; incorrect masking/fallback could cause some group metadata (e.g., name/pin/hide) not to persist or to be dropped in edge cases. > > **Overview** > Fixes backup & sync group serialization to **drop extra/invalid metadata fields** before writing to user storage by masking group metadata against `UserStorageSyncedWalletGroupSchema` (preventing fields like `lastSelected` from breaking later reads/validation), with a safe fallback to minimal `{ groupIndex }` plus logging. > > Standardizes unknown-error stringification via a new `toErrorMessage` utility and applies it across backup/sync error paths, alongside new/updated unit tests and a changelog entry documenting the user-storage filtering fix. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cfa55f2. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 210e337 commit fb601af

9 files changed

Lines changed: 104 additions & 17 deletions

File tree

packages/account-tree-controller/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Add `lastSelected` (timestamp) to account group tree node metadata ([#8261](https://github.com/MetaMask/core/pull/8261))
12+
- Add `lastSelected` (timestamp) to account group tree node metadata ([#8261](https://github.com/MetaMask/core/pull/8261)), ([#8300](https://github.com/MetaMask/core/pull/8300))
1313
- `group.metadata.lastSelected` is set to `Date.now()` whenever a group becomes the selected group, either via `setSelectedAccountGroup` or `AccountsController:selectedAccountChange`.
1414
- The value is persisted in `accountGroupsMetadata` and restored on `init`/`reinit`.
1515
- The value is not synchronize through backup and sync.
@@ -25,6 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525

2626
### Fixed
2727

28+
- Filter out extra properties from groups before sending to user storage ([#8300](https://github.com/MetaMask/core/pull/8300))
29+
- The `lastSelected` metadata should not be persisted, to we automatically remove it from the payload.
30+
- This uses the user storage schema to only keep what needs to be persisted.
2831
- Fix `AccountTreeControllerMessenger` type so that the union type for events is not `any` ([#8240](https://github.com/MetaMask/core/pull/8240))
2932

3033
## [5.0.1]

packages/account-tree-controller/src/backup-and-sync/service/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
restoreStateFromSnapshot,
3333
getLocalEntropyWallets,
3434
getLocalGroupsForEntropyWallet,
35+
toErrorMessage,
3536
} from '../utils';
3637
import type { StateSnapshot } from '../utils';
3738

@@ -345,8 +346,7 @@ export class BackupAndSyncService {
345346
);
346347
}
347348
} catch (error) {
348-
const errorMessage =
349-
error instanceof Error ? error.message : String(error);
349+
const errorMessage = toErrorMessage(error);
350350
const errorString = `Legacy syncing failed for wallet ${wallet.id}: ${errorMessage}`;
351351

352352
backupAndSyncLogger(errorString);
@@ -400,8 +400,7 @@ export class BackupAndSyncService {
400400
walletProfileId,
401401
);
402402
} catch (error) {
403-
const errorMessage =
404-
error instanceof Error ? error.message : String(error);
403+
const errorMessage = toErrorMessage(error);
405404
const errorString = `Error during multichain account syncing for wallet ${wallet.id}: ${errorMessage}`;
406405

407406
backupAndSyncLogger(errorString);

packages/account-tree-controller/src/backup-and-sync/syncing/group.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
pushGroupToUserStorageBatch,
1818
} from '../user-storage/network-operations';
1919
import { getLocalGroupsForEntropyWallet } from '../utils';
20+
import { toErrorMessage } from '../utils/errors';
2021

2122
/**
2223
* Creates multiple multichain account groups in batch (from 0 to maxGroupIndex).
@@ -90,7 +91,7 @@ export const createMultichainAccountGroupsBatch = async (
9091
backupAndSyncLogger(
9192
`Failed to create account groups batch:`,
9293
// istanbul ignore next
93-
error instanceof Error ? error.message : String(error),
94+
toErrorMessage(error),
9495
);
9596
}
9697
};

packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ import {
1111
assertValidLegacyUserStorageAccount,
1212
} from './validation';
1313
import type { AccountGroupMultichainAccountObject } from '../../group';
14+
import { backupAndSyncLogger } from '../../logger';
1415
import type { AccountWalletEntropyObject } from '../../wallet';
1516
import type { BackupAndSyncContext, UserStorageSyncedWallet } from '../types';
1617

1718
jest.mock('./validation');
19+
jest.mock('../../logger');
20+
21+
const mockBackupAndSyncLogger = backupAndSyncLogger as jest.MockedFunction<
22+
typeof backupAndSyncLogger
23+
>;
1824

1925
const mockAssertValidUserStorageWallet =
2026
assertValidUserStorageWallet as jest.MockedFunction<
@@ -109,6 +115,36 @@ describe('BackupAndSync - UserStorage - FormatUtils', () => {
109115
groupIndex: 0,
110116
});
111117
});
118+
119+
it('falls back to blank metadata and logs if mask throws due to invalid field types', () => {
120+
mockContext.controller.state.accountGroupsMetadata[mockGroup.id] = {
121+
// `name` should be an UpdatableField object, not a plain string
122+
name: 'invalid-not-an-object' as never,
123+
};
124+
125+
const result = formatGroupForUserStorageUsage(mockContext, mockGroup);
126+
127+
expect(result).toStrictEqual({ groupIndex: 0 });
128+
expect(mockBackupAndSyncLogger).toHaveBeenCalledWith(
129+
expect.stringContaining(
130+
'Error trying to format group for user storage usage',
131+
),
132+
);
133+
});
134+
135+
it('strips fields not in the schema (e.g. lastSelected)', () => {
136+
mockContext.controller.state.accountGroupsMetadata[mockGroup.id] = {
137+
name: { value: 'Group Name', lastUpdatedAt: 123456 },
138+
lastSelected: 999999,
139+
};
140+
141+
const result = formatGroupForUserStorageUsage(mockContext, mockGroup);
142+
143+
expect(result).toStrictEqual({
144+
name: { value: 'Group Name', lastUpdatedAt: 123456 },
145+
groupIndex: 0,
146+
});
147+
});
112148
});
113149

114150
describe('parseWalletFromUserStorageResponse', () => {

packages/account-tree-controller/src/backup-and-sync/user-storage/format-utils.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
1+
import { mask } from '@metamask/superstruct';
2+
13
import {
24
assertValidUserStorageWallet,
35
assertValidUserStorageGroup,
46
assertValidLegacyUserStorageAccount,
57
} from './validation';
68
import type { AccountGroupMultichainAccountObject } from '../../group';
9+
import { backupAndSyncLogger } from '../../logger';
710
import type { AccountWalletEntropyObject } from '../../wallet';
811
import type {
912
BackupAndSyncContext,
1013
LegacyUserStorageSyncedAccount,
1114
UserStorageSyncedWallet,
1215
UserStorageSyncedWalletGroup,
1316
} from '../types';
17+
import { UserStorageSyncedWalletGroupSchema } from '../types';
18+
import { toErrorMessage } from '../utils/errors';
1419

1520
/**
1621
* Formats the wallet for user storage usage.
@@ -51,11 +56,26 @@ export const formatGroupForUserStorageUsage = (
5156
// This can be null if the user has not manually set a name, pinned or hidden the group
5257
const persistedGroupMetadata =
5358
context.controller.state.accountGroupsMetadata[group.id];
59+
const { groupIndex } = group.metadata.entropy;
5460

55-
return {
56-
...(persistedGroupMetadata ?? {}),
57-
groupIndex: group.metadata.entropy.groupIndex,
58-
};
61+
try {
62+
// We mask and we try catch, since `mask` will throw if the persisted metadata has
63+
// fields with wrong types.
64+
return mask(
65+
{
66+
...(persistedGroupMetadata ?? {}),
67+
groupIndex,
68+
},
69+
UserStorageSyncedWalletGroupSchema,
70+
);
71+
} catch (error) {
72+
backupAndSyncLogger(
73+
`Error trying to format group for user storage usage: ${toErrorMessage(error)}`,
74+
);
75+
76+
// If anything goes wrong with this group, we use blank metadata for it.
77+
return { groupIndex };
78+
}
5979
};
6080

6181
/**
@@ -76,7 +96,7 @@ export const parseWalletFromUserStorageResponse = (
7696
return walletData;
7797
} catch (error: unknown) {
7898
throw new Error(
79-
`Error trying to parse wallet from user storage response: ${error instanceof Error ? error.message : String(error)}`,
99+
`Error trying to parse wallet from user storage response: ${toErrorMessage(error)}`,
80100
);
81101
}
82102
};
@@ -99,7 +119,7 @@ export const parseGroupFromUserStorageResponse = (
99119
return groupData;
100120
} catch (error: unknown) {
101121
throw new Error(
102-
`Error trying to parse group from user storage response: ${error instanceof Error ? error.message : String(error)}`,
122+
`Error trying to parse group from user storage response: ${toErrorMessage(error)}`,
103123
);
104124
}
105125
};
@@ -122,7 +142,7 @@ export const parseLegacyAccountFromUserStorageResponse = (
122142
return accountData;
123143
} catch (error: unknown) {
124144
throw new Error(
125-
`Error trying to parse legacy account from user storage response: ${error instanceof Error ? error.message : String(error)}`,
145+
`Error trying to parse legacy account from user storage response: ${toErrorMessage(error)}`,
126146
);
127147
}
128148
};

packages/account-tree-controller/src/backup-and-sync/user-storage/network-operations.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
UserStorageSyncedWallet,
2323
UserStorageSyncedWalletGroup,
2424
} from '../types';
25+
import { toErrorMessage } from '../utils/errors';
2526

2627
/**
2728
* Retrieves the wallet from user storage.
@@ -53,7 +54,7 @@ export const getWalletFromUserStorage = async (
5354
return parseWalletFromUserStorageResponse(walletData);
5455
} catch (error) {
5556
backupAndSyncLogger(
56-
`Failed to parse wallet data from user storage: ${error instanceof Error ? error.message : String(error)}`,
57+
`Failed to parse wallet data from user storage: ${toErrorMessage(error)}`,
5758
);
5859
return null;
5960
}
@@ -119,7 +120,7 @@ export const getAllGroupsFromUserStorage = async (
119120
return parseGroupFromUserStorageResponse(stringifiedGroup);
120121
} catch (error) {
121122
backupAndSyncLogger(
122-
`Failed to parse group data from user storage: ${error instanceof Error ? error.message : String(error)}`,
123+
`Failed to parse group data from user storage: ${toErrorMessage(error)}`,
123124
);
124125
return null;
125126
}
@@ -163,7 +164,7 @@ export const getGroupFromUserStorage = async (
163164
return parseGroupFromUserStorageResponse(groupData);
164165
} catch (error) {
165166
backupAndSyncLogger(
166-
`Failed to parse group data from user storage: ${error instanceof Error ? error.message : String(error)}`,
167+
`Failed to parse group data from user storage: ${toErrorMessage(error)}`,
167168
);
168169
return null;
169170
}
@@ -270,7 +271,7 @@ export const getAllLegacyUserStorageAccounts = async (
270271
return parseLegacyAccountFromUserStorageResponse(stringifiedAccount);
271272
} catch (error) {
272273
backupAndSyncLogger(
273-
`Failed to parse legacy account data from user storage: ${error instanceof Error ? error.message : String(error)}`,
274+
`Failed to parse legacy account data from user storage: ${toErrorMessage(error)}`,
274275
);
275276
return null;
276277
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { toErrorMessage } from './errors';
2+
3+
describe('toErrorMessage', () => {
4+
it('returns the message property for Error instances', () => {
5+
expect(toErrorMessage(new Error('something went wrong'))).toBe(
6+
'something went wrong',
7+
);
8+
});
9+
10+
it('returns String() for non-Error values', () => {
11+
expect(toErrorMessage('raw string error')).toBe('raw string error');
12+
expect(toErrorMessage(42)).toBe('42');
13+
expect(toErrorMessage(null)).toBe('null');
14+
expect(toErrorMessage(undefined)).toBe('undefined');
15+
expect(toErrorMessage({ code: 500 })).toBe('[object Object]');
16+
});
17+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Extracts a string message from an unknown error value.
3+
*
4+
* @param error - The caught error value.
5+
* @returns The error message string.
6+
*/
7+
export function toErrorMessage(error: unknown): string {
8+
return error instanceof Error ? error.message : String(error);
9+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './controller';
2+
export { toErrorMessage } from './errors';

0 commit comments

Comments
 (0)