Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4d5a16c
feat(authenticated-user-storage): add user custom tokens feature (imp…
Prithpal-Sooriya Sep 14, 2026
5b81ad3
chore(authenticated-user-storage): fill in changelog PR link
Prithpal-Sooriya Sep 14, 2026
55b1852
docs(authenticated-user-storage): trim verbose JSDoc on user-assets code
Prithpal-Sooriya Sep 14, 2026
4e9c513
test(authenticated-user-storage): convert repetitive user-assets test…
Prithpal-Sooriya Sep 14, 2026
0a9f73c
feat(authenticated-user-storage): enforce mutual exclusivity on user-…
Prithpal-Sooriya Sep 14, 2026
5b3fd6c
fix(authenticated-user-storage): align user-assets client with custom…
Prithpal-Sooriya Sep 21, 2026
1b7b115
feat(authenticated-user-storage): add clearUserAssets for wiping cust…
Prithpal-Sooriya Sep 21, 2026
3208a82
chore(authenticated-user-storage): adapt user-assets code to main's V…
Prithpal-Sooriya Oct 26, 2026
173baaf
feat(assets-controller): mirror custom token changes to Authenticated…
Prithpal-Sooriya Sep 21, 2026
f0b7dd9
docs: reference PR #10336 in assets-controller changelog
Prithpal-Sooriya Sep 21, 2026
4928a3d
fix(assets-controller): queue AUS syncs at invocation time in FIFO order
Prithpal-Sooriya Sep 22, 2026
786cf6a
Simplify AUS sync: fire-and-forget at invocation time
Prithpal-Sooriya Sep 22, 2026
da2d647
fix(assets-controller): adapt AUS sync to main's Vitest migration
Prithpal-Sooriya Oct 26, 2026
e31b164
Merge branch 'main' into feature/act-as-a-senior-soft-rhj
Prithpal-Sooriya Nov 2, 2026
6bd1ff9
test(authenticated-user-storage): adapt user-assets mocks to nock v15…
Prithpal-Sooriya Nov 2, 2026
382308d
Merge remote-tracking branch 'origin/feature/act-as-a-senior-soft-rhj…
Prithpal-Sooriya Nov 2, 2026
677dff2
chore(assets-controller): restore Unreleased changelog entry after ba…
Prithpal-Sooriya Nov 2, 2026
9ac69a7
fix(authenticated-user-storage): call /preferences/custom-tokens endp…
Prithpal-Sooriya Nov 5, 2026
eaf6978
Merge remote-tracking branch 'origin/feature/act-as-a-senior-soft-rhj…
Prithpal-Sooriya Nov 10, 2026
2c2634a
fix(assets-controller): report AUS custom-token sync failures via cap…
Prithpal-Sooriya Nov 10, 2026
55655ba
Merge remote-tracking branch 'origin/main' into feat/assets-aus-custo…
Prithpal-Sooriya Nov 10, 2026
4ca608e
Merge remote-tracking branch 'origin/main' into feat/assets-aus-custo…
Prithpal-Sooriya Nov 10, 2026
420cd37
Merge remote-tracking branch 'origin/main' into feat/assets-aus-custo…
Prithpal-Sooriya Nov 11, 2026
0ca2b9a
fix(assets-controller): serialize AUS user-assets syncs per controlle…
Prithpal-Sooriya Nov 11, 2026
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
1 change: 1 addition & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Sync custom-token changes to Authenticated User Storage: adding or hiding a custom asset now mirrors it to the user's AUS user-assets blob (fire-and-forget; failures are reported via the messenger's `captureException` and never roll back local state), and removing or unhiding strips the asset from the corresponding AUS list ([#10336](https://github.com/MetaMask/core/pull/10336))
- Add `assetsBackgroundLoadingStatus` to `AssetsControllerState`, a transient per-account status of the background (Snap + RPC) fetch that runs after the initial balance has resolved ([#11412](https://github.com/MetaMask/core/pull/11412))
- Unlike `assetsLoadingStatus`, which only tracks an account's first load, it is set to `'loading'` on every forced fetch that has slow-pipeline chains and settles to `'loaded'` once every overlapping background fetch for the account has finished, whether it succeeded or failed.
- Add `getAccountBackgroundLoadingStatus`, `isAccountBackgroundLoading`, `getAccountGroupBackgroundLoadingStatus`, `isAccountGroupBackgroundLoading` and `getIsAssetsBackgroundLoadingForSelectedAccountGroup` selectors ([#11412](https://github.com/MetaMask/core/pull/11412))
Expand Down
1 change: 1 addition & 0 deletions packages/assets-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"lodash-es": "^4.18.1"
},
"devDependencies": {
"@metamask/authenticated-user-storage": "^4.1.0",
"@metamask/auto-changelog": "^7.1.0",
"@metamask/eth-json-rpc-provider": "^7.0.0",
"@types/lodash-es": "^4.17.12",
Expand Down
246 changes: 245 additions & 1 deletion packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { UserAssetsBlob } from '@metamask/authenticated-user-storage';
import { deriveStateFromMetadata } from '@metamask/base-controller';
import { clientControllerSelectors } from '@metamask/client-controller';
import type { TraceCallback, TraceRequest } from '@metamask/controller-utils';
Expand Down Expand Up @@ -64,7 +65,7 @@ const formatExchangeRatesForBridgeMock = vi.mocked(

/**
* Flush pending microtasks so fire-and-forget background pipelines settle
* before Jest tears down the test. Multiple rounds are needed because the
* before the test runner tears down the test. Multiple rounds are needed because the
* background pipeline chains several async steps.
*
* @returns A promise that resolves after pending callbacks.
Expand Down Expand Up @@ -209,6 +210,9 @@ const MOCK_NATIVE_ASSET_ID = 'eip155:1/slip44:60' as Caip19AssetId;
/** mUSD on mainnet — a controller-managed default tracked asset. */
const [MOCK_DEFAULT_TRACKED_ASSET_ID] =
getDefaultTrackedAssetsForChain('eip155:1');
// DAI — a second checksummed address so filtered blob assertions are exact.
const OTHER_ASSET_ID =
'eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F' as Caip19AssetId;

/**
* Activate asset tracking by marking the UI open, the keyring unlocked, and
Expand Down Expand Up @@ -270,6 +274,68 @@ function createMockKeyringTransaction(
};
}

type AusUserAssetsActionMocks = {
importTokens: Mock;
hideTokens: Mock;
getUserAssets: Mock;
setUserAssets: Mock;
};

/**
* Register mock handlers for the four AuthenticatedUserStorageService
* user-assets actions that the controller's fire-and-forget AUS sync calls.
*
* @param messenger - The root messenger to register the handlers on.
* @param options - Options controlling the mocks' behavior.
* @param options.blob - The user-assets blob `getUserAssets` serves.
* @param options.rejectWith - An error every AUS action rejects with.
* @returns The mock handlers, for call assertions.
*/
function registerAusUserAssetsActionMocks(
messenger: RootMessenger,
options: {
blob?: UserAssetsBlob | null;
rejectWith?: Error;
} = {},
): AusUserAssetsActionMocks {
const { blob = null, rejectWith } = options;
const emptyBlob: UserAssetsBlob = {
version: 1,
importedAssets: [],
hiddenAssets: [],
};
const makeHandler = <ReturnValue>(
resolveValue: () => ReturnValue,
): Mock<() => Promise<ReturnValue>> =>
rejectWith
? vi.fn(() => Promise.reject(rejectWith))
: vi.fn(async () => resolveValue());

const importTokens = makeHandler((): UserAssetsBlob => emptyBlob);
const hideTokens = makeHandler((): UserAssetsBlob => emptyBlob);
const getUserAssets = makeHandler((): UserAssetsBlob | null => blob);
const setUserAssets = makeHandler((): void => undefined);

messenger.registerActionHandler(
'AuthenticatedUserStorageService:importTokens',
importTokens,
);
messenger.registerActionHandler(
'AuthenticatedUserStorageService:hideTokens',
hideTokens,
);
messenger.registerActionHandler(
'AuthenticatedUserStorageService:getUserAssets',
getUserAssets,
);
messenger.registerActionHandler(
'AuthenticatedUserStorageService:setUserAssets',
setUserAssets,
);

return { importTokens, hideTokens, getUserAssets, setUserAssets };
}

type WithControllerOptions = {
state?: Partial<AssetsControllerState>;
isBasicFunctionality?: () => boolean;
Expand Down Expand Up @@ -941,6 +1007,40 @@ describe('AssetsController', () => {
},
);
});

it('mirrors the added custom asset to AUS user assets', async () => {
await withController(async ({ controller, messenger }) => {
const { importTokens } = registerAusUserAssetsActionMocks(messenger);

await controller.addCustomAsset(
MOCK_ACCOUNT_ID,
MOCK_ASSET_ID_LOWERCASE,
);
await flushPromises();

// Single high-level import call, with the checksummed id.
expect(importTokens).toHaveBeenCalledTimes(1);
expect(importTokens).toHaveBeenCalledWith([MOCK_ASSET_ID]);
});
});

it('still adds the custom asset locally when the AUS sync rejects', async () => {
await withController(async ({ controller, messenger }) => {
registerAusUserAssetsActionMocks(messenger, {
rejectWith: new Error('AUS unavailable'),
});

expect(
await controller.addCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID),
).toBeUndefined();
await flushPromises();

// Fire-and-forget: the local state change stands despite the AUS failure.
expect(controller.state.customAssets[MOCK_ACCOUNT_ID]).toContain(
MOCK_ASSET_ID,
);
});
});
});

describe('removeCustomAsset', () => {
Expand Down Expand Up @@ -979,6 +1079,64 @@ describe('AssetsController', () => {
);
});
});

it('strips the removed custom asset from the AUS imported list', async () => {
await withController(
{
state: {
customAssets: { [MOCK_ACCOUNT_ID]: [MOCK_ASSET_ID] },
},
},
async ({ controller, messenger }) => {
const { getUserAssets, setUserAssets } =
registerAusUserAssetsActionMocks(messenger, {
blob: {
version: 1,
importedAssets: [MOCK_ASSET_ID, OTHER_ASSET_ID],
hiddenAssets: [OTHER_ASSET_ID],
},
});

controller.removeCustomAsset(MOCK_ACCOUNT_ID, MOCK_ASSET_ID);
await flushPromises();

// Strip-only read-modify-write: the imported list loses the id and
// every other entry (including the hidden list) is untouched.
expect(getUserAssets).toHaveBeenCalledTimes(1);
expect(setUserAssets).toHaveBeenCalledTimes(1);
expect(setUserAssets).toHaveBeenCalledWith({
version: 1,
importedAssets: [OTHER_ASSET_ID],
hiddenAssets: [OTHER_ASSET_ID],
});
},
);
});
});

describe('hideAsset', () => {
it('marks the asset hidden in assetPreferences', async () => {
await withController(async ({ controller }) => {
controller.hideAsset(MOCK_ASSET_ID);

expect(controller.state.assetPreferences[MOCK_ASSET_ID]).toStrictEqual({
hidden: true,
});
});
});

it('mirrors the hidden asset to AUS user assets', async () => {
await withController(async ({ controller, messenger }) => {
const { hideTokens } = registerAusUserAssetsActionMocks(messenger);

controller.hideAsset(MOCK_ASSET_ID_LOWERCASE);
await flushPromises();

// Single high-level hide call, with the checksummed id.
expect(hideTokens).toHaveBeenCalledTimes(1);
expect(hideTokens).toHaveBeenCalledWith([MOCK_ASSET_ID]);
});
});
});

describe('custom asset graduation', () => {
Expand Down Expand Up @@ -1072,6 +1230,42 @@ describe('AssetsController', () => {
},
);
});

it('does not sync to AUS when graduation removes a custom asset', async () => {
await withController(
{
state: {
customAssets: { [MOCK_ACCOUNT_ID]: [MOCK_ASSET_ID] },
},
},
async ({ controller, messenger }) => {
const ausMocks = registerAusUserAssetsActionMocks(messenger);

await controller.handleAssetsUpdate(
{
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_ASSET_ID]: { amount: '1000000' },
},
},
},
'AccountsApiDataSource',
);

expect(
controller.state.customAssets[MOCK_ACCOUNT_ID],
).toBeUndefined();
await flushPromises();

// Graduation is automatic detection, not user intent: it must not
// strip the still-visible token from the AUS user-assets blob.
expect(ausMocks.importTokens).not.toHaveBeenCalled();
expect(ausMocks.hideTokens).not.toHaveBeenCalled();
expect(ausMocks.getUserAssets).not.toHaveBeenCalled();
expect(ausMocks.setUserAssets).not.toHaveBeenCalled();
},
);
});
});

describe('getCustomAssets', () => {
Expand Down Expand Up @@ -1162,6 +1356,56 @@ describe('AssetsController', () => {
},
);
});

it('removes the hidden flag from assetPreferences', async () => {
await withController(
{
state: {
assetPreferences: { [MOCK_ASSET_ID]: { hidden: true } },
},
},
async ({ controller }) => {
await controller.unhideAsset(MOCK_ASSET_ID);

expect(
controller.state.assetPreferences[MOCK_ASSET_ID],
).toBeUndefined();
},
);
});

it('strips the unhidden asset from the AUS hidden list', async () => {
await withController(
{
state: {
assetPreferences: { [MOCK_ASSET_ID]: { hidden: true } },
},
},
async ({ controller, messenger }) => {
const { getUserAssets, setUserAssets } =
registerAusUserAssetsActionMocks(messenger, {
blob: {
version: 1,
importedAssets: [OTHER_ASSET_ID],
hiddenAssets: [MOCK_ASSET_ID, OTHER_ASSET_ID],
},
});

await controller.unhideAsset(MOCK_ASSET_ID);
await flushPromises();

// Strip-only: the hidden list loses the id; the imported list is
// untouched (unhiding does not re-import the asset).
expect(getUserAssets).toHaveBeenCalledTimes(1);
expect(setUserAssets).toHaveBeenCalledTimes(1);
expect(setUserAssets).toHaveBeenCalledWith({
version: 1,
importedAssets: [OTHER_ASSET_ID],
hiddenAssets: [OTHER_ASSET_ID],
});
},
);
});
});

describe('getAssetMetadata', () => {
Expand Down
Loading
Loading