Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/account-tree-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

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

### Fixed

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

## [5.0.1]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
restoreStateFromSnapshot,
getLocalEntropyWallets,
getLocalGroupsForEntropyWallet,
toErrorMessage,
} from '../utils';
import type { StateSnapshot } from '../utils';

Expand Down Expand Up @@ -345,8 +346,7 @@ export class BackupAndSyncService {
);
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = toErrorMessage(error);
const errorString = `Legacy syncing failed for wallet ${wallet.id}: ${errorMessage}`;

backupAndSyncLogger(errorString);
Expand Down Expand Up @@ -400,8 +400,7 @@ export class BackupAndSyncService {
walletProfileId,
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorMessage = toErrorMessage(error);
const errorString = `Error during multichain account syncing for wallet ${wallet.id}: ${errorMessage}`;

backupAndSyncLogger(errorString);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
pushGroupToUserStorageBatch,
} from '../user-storage/network-operations';
import { getLocalGroupsForEntropyWallet } from '../utils';
import { toErrorMessage } from '../utils/errors';

/**
* Creates multiple multichain account groups in batch (from 0 to maxGroupIndex).
Expand Down Expand Up @@ -90,7 +91,7 @@ export const createMultichainAccountGroupsBatch = async (
backupAndSyncLogger(
`Failed to create account groups batch:`,
// istanbul ignore next
error instanceof Error ? error.message : String(error),
toErrorMessage(error),
);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ import {
assertValidLegacyUserStorageAccount,
} from './validation';
import type { AccountGroupMultichainAccountObject } from '../../group';
import { backupAndSyncLogger } from '../../logger';
import type { AccountWalletEntropyObject } from '../../wallet';
import type { BackupAndSyncContext, UserStorageSyncedWallet } from '../types';

jest.mock('./validation');
jest.mock('../../logger');

const mockBackupAndSyncLogger = backupAndSyncLogger as jest.MockedFunction<
typeof backupAndSyncLogger
>;

const mockAssertValidUserStorageWallet =
assertValidUserStorageWallet as jest.MockedFunction<
Expand Down Expand Up @@ -109,6 +115,36 @@ describe('BackupAndSync - UserStorage - FormatUtils', () => {
groupIndex: 0,
});
});

it('falls back to blank metadata and logs if mask throws due to invalid field types', () => {
mockContext.controller.state.accountGroupsMetadata[mockGroup.id] = {
// `name` should be an UpdatableField object, not a plain string
name: 'invalid-not-an-object' as never,
};

const result = formatGroupForUserStorageUsage(mockContext, mockGroup);

expect(result).toStrictEqual({ groupIndex: 0 });
expect(mockBackupAndSyncLogger).toHaveBeenCalledWith(
expect.stringContaining(
'Error trying to format group for user storage usage',
),
);
});

it('strips fields not in the schema (e.g. lastSelected)', () => {
mockContext.controller.state.accountGroupsMetadata[mockGroup.id] = {
name: { value: 'Group Name', lastUpdatedAt: 123456 },
lastSelected: 999999,
};

const result = formatGroupForUserStorageUsage(mockContext, mockGroup);

expect(result).toStrictEqual({
name: { value: 'Group Name', lastUpdatedAt: 123456 },
groupIndex: 0,
});
});
});

describe('parseWalletFromUserStorageResponse', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { mask } from '@metamask/superstruct';

import {
assertValidUserStorageWallet,
assertValidUserStorageGroup,
assertValidLegacyUserStorageAccount,
} from './validation';
import type { AccountGroupMultichainAccountObject } from '../../group';
import { backupAndSyncLogger } from '../../logger';
import type { AccountWalletEntropyObject } from '../../wallet';
import type {
BackupAndSyncContext,
LegacyUserStorageSyncedAccount,
UserStorageSyncedWallet,
UserStorageSyncedWalletGroup,
} from '../types';
import { UserStorageSyncedWalletGroupSchema } from '../types';
import { toErrorMessage } from '../utils/errors';

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

return {
...(persistedGroupMetadata ?? {}),
groupIndex: group.metadata.entropy.groupIndex,
};
try {
// We mask and we try catch, since `mask` will throw if the persisted metadata has
// fields with wrong types.
return mask(
{
...(persistedGroupMetadata ?? {}),
groupIndex,
},
UserStorageSyncedWalletGroupSchema,
);
} catch (error) {
backupAndSyncLogger(
`Error trying to format group for user storage usage: ${toErrorMessage(error)}`,
);

// If anything goes wrong with this group, we use blank metadata for it.
return { groupIndex };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the rationale behind keeping groupIndex here? Does this help the account tree to be built properly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just kept the semantic of the function (it couldn't fail/throw before we started introducing the mask call), so in case an error happens, I don't want to make it throw either.

IDK if we can do better than that actually 🤔 the way I see this is that, we need at least the groupIndex, everything else (for now) is optional, so worst case scenario, we omit all metadata and go with the groupIndex.

Would you prefer to throw instead, so this could bubble up and potentially make B&S fail entirely (I haven't analyzed the call sites TBH 🙊)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! No, I actually didn't think that far ahead, I just wanted to understand your intent. I think your logic is sound :)

}
};

/**
Expand All @@ -76,7 +96,7 @@ export const parseWalletFromUserStorageResponse = (
return walletData;
} catch (error: unknown) {
throw new Error(
`Error trying to parse wallet from user storage response: ${error instanceof Error ? error.message : String(error)}`,
`Error trying to parse wallet from user storage response: ${toErrorMessage(error)}`,
);
}
};
Expand All @@ -99,7 +119,7 @@ export const parseGroupFromUserStorageResponse = (
return groupData;
} catch (error: unknown) {
throw new Error(
`Error trying to parse group from user storage response: ${error instanceof Error ? error.message : String(error)}`,
`Error trying to parse group from user storage response: ${toErrorMessage(error)}`,
);
}
};
Expand All @@ -122,7 +142,7 @@ export const parseLegacyAccountFromUserStorageResponse = (
return accountData;
} catch (error: unknown) {
throw new Error(
`Error trying to parse legacy account from user storage response: ${error instanceof Error ? error.message : String(error)}`,
`Error trying to parse legacy account from user storage response: ${toErrorMessage(error)}`,
);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
UserStorageSyncedWallet,
UserStorageSyncedWalletGroup,
} from '../types';
import { toErrorMessage } from '../utils/errors';

/**
* Retrieves the wallet from user storage.
Expand Down Expand Up @@ -53,7 +54,7 @@ export const getWalletFromUserStorage = async (
return parseWalletFromUserStorageResponse(walletData);
} catch (error) {
backupAndSyncLogger(
`Failed to parse wallet data from user storage: ${error instanceof Error ? error.message : String(error)}`,
`Failed to parse wallet data from user storage: ${toErrorMessage(error)}`,
);
return null;
}
Expand Down Expand Up @@ -119,7 +120,7 @@ export const getAllGroupsFromUserStorage = async (
return parseGroupFromUserStorageResponse(stringifiedGroup);
} catch (error) {
backupAndSyncLogger(
`Failed to parse group data from user storage: ${error instanceof Error ? error.message : String(error)}`,
`Failed to parse group data from user storage: ${toErrorMessage(error)}`,
);
return null;
}
Expand Down Expand Up @@ -163,7 +164,7 @@ export const getGroupFromUserStorage = async (
return parseGroupFromUserStorageResponse(groupData);
} catch (error) {
backupAndSyncLogger(
`Failed to parse group data from user storage: ${error instanceof Error ? error.message : String(error)}`,
`Failed to parse group data from user storage: ${toErrorMessage(error)}`,
);
return null;
}
Expand Down Expand Up @@ -270,7 +271,7 @@ export const getAllLegacyUserStorageAccounts = async (
return parseLegacyAccountFromUserStorageResponse(stringifiedAccount);
} catch (error) {
backupAndSyncLogger(
`Failed to parse legacy account data from user storage: ${error instanceof Error ? error.message : String(error)}`,
`Failed to parse legacy account data from user storage: ${toErrorMessage(error)}`,
);
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { toErrorMessage } from './errors';

describe('toErrorMessage', () => {
it('returns the message property for Error instances', () => {
expect(toErrorMessage(new Error('something went wrong'))).toBe(
'something went wrong',
);
});

it('returns String() for non-Error values', () => {
expect(toErrorMessage('raw string error')).toBe('raw string error');
expect(toErrorMessage(42)).toBe('42');
expect(toErrorMessage(null)).toBe('null');
expect(toErrorMessage(undefined)).toBe('undefined');
expect(toErrorMessage({ code: 500 })).toBe('[object Object]');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Extracts a string message from an unknown error value.
*
* @param error - The caught error value.
* @returns The error message string.
*/
export function toErrorMessage(error: unknown): string {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this mostly because it was hard to get coverage when re-using this pattern in the fix I made in formatGroupForUserStorageUsage.

Otherwise I would have to mock superstruct.mask just to throw non-Error errors, and that felt like a bit of an anti-pattern.

Delegating this coverage to this helper makes the code easier and keep the test focused on the new behavior fix (IMO)!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I used a similar pattern in the service too, toErrorMessage makes things a bit more readable IMO)

return error instanceof Error ? error.message : String(error);
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './controller';
export { toErrorMessage } from './errors';
Loading