From 46abc79fbc858fea86e6a8ec3786dac8737ca952 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 14 Sep 2026 17:03:00 +0100 Subject: [PATCH 1/5] feat(authenticated-user-storage): add user custom tokens feature (imported/hidden assets) Add a user-assets domain to the Authenticated User Storage SDK, backed by the new GET/PUT /preferences/user-assets endpoints: - getUserAssets / setUserAssets: low-level blob access, mirroring the assets-watchlist pattern (404 -> null on read, strict write validation) - importTokens / hideTokens: high-level API that handles deduplication and mutual exclusivity internally, returning the resolved blob - Fail-open conflict resolution: an identifier present in both importedAssets and hiddenAssets stays imported and is removed from hiddenAssets; writes are never rejected due to a conflict - Strict write-side schema validation: every entry must be a CAIP-19 asset identifier (CaipAssetTypeStruct); reads stay lenient Jira: ASSETS-3937 --- .../authenticated-user-storage/CHANGELOG.md | 8 + packages/authenticated-user-storage/README.md | 44 +- ...icated-user-storage-method-action-types.ts | 95 ++- .../src/authenticated-user-storage.test.ts | 596 +++++++++++++++++- .../src/authenticated-user-storage.ts | 207 ++++++ .../authenticated-user-storage/src/index.ts | 5 + .../authenticated-user-storage/src/types.ts | 10 + .../src/validators.ts | 124 ++++ .../fixtures/authenticated-userstorage.ts | 28 + .../tests/mocks/authenticated-userstorage.ts | 17 + 10 files changed, 1131 insertions(+), 3 deletions(-) diff --git a/packages/authenticated-user-storage/CHANGELOG.md b/packages/authenticated-user-storage/CHANGELOG.md index 9118b952bfd..95f839dfb30 100644 --- a/packages/authenticated-user-storage/CHANGELOG.md +++ b/packages/authenticated-user-storage/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getUserAssets`, `setUserAssets`, `importTokens`, and `hideTokens` methods to `AuthenticatedUserStorageService` for managing the authenticated user's custom tokens, along with corresponding messenger actions (`AuthenticatedUserStorageService:getUserAssets`, `AuthenticatedUserStorageService:setUserAssets`, `AuthenticatedUserStorageService:importTokens`, `AuthenticatedUserStorageService:hideTokens`) and the `UserAssetsBlob` type ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) + - Backed by the new `GET`/`PUT /preferences/user-assets` API endpoints; `getUserAssets` returns the blob or `null` on 404, mirroring `getAssetsWatchlist`. + - Every write is normalized: entries are de-duplicated (order-preserving) and conflicts between `importedAssets` and `hiddenAssets` are resolved "fail-open" — an identifier present in both lists stays in `importedAssets` (the user's intent to import wins) and is removed from `hiddenAssets`. + - `importTokens`/`hideTokens` are high-level wrappers that fetch the current blob, merge with deduplication and mutual exclusivity, persist, and return the resolved blob. + - Writes enforce that every entry is a CAIP-19 asset identifier, throwing a superstruct `StructError` before the request is sent. + ### Changed - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) diff --git a/packages/authenticated-user-storage/README.md b/packages/authenticated-user-storage/README.md index 5feef69789f..723559fcba0 100644 --- a/packages/authenticated-user-storage/README.md +++ b/packages/authenticated-user-storage/README.md @@ -2,11 +2,12 @@ A TypeScript SDK for MetaMask's Authenticated User Storage API. Unlike E2EE user-storage, authenticated user storage holds **structured JSON** scoped to the authenticated user. The server can read and validate the contents, which allows other backend services to consume the data (e.g. delegation execution, notification delivery). -The SDK currently supports three domains: +The SDK currently supports four domains: - **Delegations** -- immutable, EIP-712 signed delegation records (list, create, revoke). - **Notification Preferences** -- mutable per-user notification settings (get, put). - **Assets watchlist** -- mutable per-user list of CAIP-19 asset identifiers (get, set). +- **User assets (custom tokens)** -- mutable per-user record of imported and hidden tokens (get, set, import, hide). ## Installation @@ -134,6 +135,47 @@ const updated: AssetsWatchlistBlob = { await service.setAssetsWatchlist(updated, 'extension'); ``` +### User assets (custom tokens) + +The user-assets blob is a mutable per-user singleton blob recording which custom tokens the user chose to import and which they chose to hide. The first call to `setUserAssets` creates the record; subsequent calls overwrite it. Each entry of `importedAssets` and `hiddenAssets` is a [CAIP-19](https://chainagnostic.org/CAIPs/caip-19) asset identifier (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). The blob carries an explicit `version: 1` literal so the shape can evolve without breaking existing consumers. + +The two lists are mutually exclusive by contract: a given identifier may appear in at most one of them. Every write is normalized before the request is sent — entries are de-duplicated (order-preserving) and conflicts are resolved "fail-open": an identifier present in both lists stays in `importedAssets` (the user's intent to import wins) and is removed from `hiddenAssets`, so a write is never rejected because of a conflict. On writes, every entry must be a valid CAIP-19 asset identifier; malformed blobs throw a superstruct `StructError` before the request is sent. + +Most consumers should not read-modify-write the blob themselves. The high-level `importTokens` and `hideTokens` methods fetch the current blob (creating a fresh one if none exists yet), apply the merge with deduplication and mutual exclusivity, persist the result, and return the resolved blob: + +```typescript +import type { UserAssetsBlob } from '@metamask/authenticated-user-storage'; + +// Retrieve the current user-assets blob (returns null on the first read) +const userAssets: UserAssetsBlob | null = await service.getUserAssets(); + +// Import custom tokens (de-duplicated; removes them from hiddenAssets) +const imported: UserAssetsBlob = await service.importTokens( + [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + ], + 'extension', +); + +// Hide tokens (de-duplicated; removes them from importedAssets) +const hidden: UserAssetsBlob = await service.hideTokens([ + 'eip155:10/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce36000000', +]); + +// Low-level: replace the whole blob (still normalized and validated) +await service.setUserAssets( + { + version: 1, + importedAssets: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ], + hiddenAssets: [], + }, + 'extension', +); +``` + ## Response validation All API responses are validated at runtime using [`@metamask/superstruct`](https://github.com/MetaMask/superstruct) schemas before being returned to callers. If the server returns data that doesn't match the expected shape, the SDK throws with details about the structural mismatch rather than silently returning malformed data. diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts index 39c88ae9c5b..b73baa62486 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts @@ -85,6 +85,95 @@ export type AuthenticatedUserStorageServiceSetAssetsWatchlistAction = { handler: AuthenticatedUserStorageService['setAssetsWatchlist']; }; +/** + * Returns the user-assets (custom tokens) blob for the authenticated user. + * + * @returns The user-assets blob, or `null` if none has been set (404). + */ +export type AuthenticatedUserStorageServiceGetUserAssetsAction = { + type: `AuthenticatedUserStorageService:getUserAssets`; + handler: AuthenticatedUserStorageService['getUserAssets']; +}; + +/** + * Creates or updates the user-assets (custom tokens) blob for the + * authenticated user. + * + * The blob is normalized before it is sent: entries are de-duplicated + * (order-preserving) and conflicts between `importedAssets` and + * `hiddenAssets` are resolved "fail-open" — an identifier present in both + * lists stays in `importedAssets` (the user's intent to import wins) and is + * removed from `hiddenAssets`, so the write is never rejected because of a + * conflict. + * + * @param blob - The full user-assets blob. Every entry of + * `importedAssets` and `hiddenAssets` must be a CAIP-19 asset identifier + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`); + * this is enforced by `assertUserAssetsBlobForWrite` before the request + * is sent. + * @param clientType - Optional client type header. + * @throws A `StructError` from `@metamask/superstruct` if `blob` is + * structurally invalid; an `HttpError` from `@metamask/controller-utils` + * if the API responds with a non-2xx status. + */ +export type AuthenticatedUserStorageServiceSetUserAssetsAction = { + type: `AuthenticatedUserStorageService:setUserAssets`; + handler: AuthenticatedUserStorageService['setUserAssets']; +}; + +/** + * Imports custom tokens for the authenticated user. + * + * Adds the given CAIP-19 asset identifiers to `importedAssets` + * (de-duplicated, existing order preserved) and removes them from + * `hiddenAssets`, since the two lists are mutually exclusive and the + * user's intent to import wins ("fail-open"). If no user-assets blob + * exists yet (404), a fresh one is created. + * + * This is a convenience wrapper around `getUserAssets` and + * `setUserAssets`; the SDK handles deduplication and mutual exclusivity + * internally as a safeguard, so callers never need to read-modify-write + * the blob themselves. + * + * @param ids - The CAIP-19 asset identifiers of the tokens to import + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param clientType - Optional client type header. + * @returns The resolved user-assets blob that was persisted. + * @throws A `StructError` from `@metamask/superstruct` if any entry of + * `ids` is not a CAIP-19 asset identifier; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ +export type AuthenticatedUserStorageServiceImportTokensAction = { + type: `AuthenticatedUserStorageService:importTokens`; + handler: AuthenticatedUserStorageService['importTokens']; +}; + +/** + * Hides custom tokens for the authenticated user. + * + * Adds the given CAIP-19 asset identifiers to `hiddenAssets` + * (de-duplicated, existing order preserved) and removes them from + * `importedAssets`, since the two lists are mutually exclusive. If no + * user-assets blob exists yet (404), a fresh one is created. + * + * This is a convenience wrapper around `getUserAssets` and + * `setUserAssets`; the SDK handles deduplication and mutual exclusivity + * internally as a safeguard, so callers never need to read-modify-write + * the blob themselves. + * + * @param ids - The CAIP-19 asset identifiers of the tokens to hide + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param clientType - Optional client type header. + * @returns The resolved user-assets blob that was persisted. + * @throws A `StructError` from `@metamask/superstruct` if any entry of + * `ids` is not a CAIP-19 asset identifier; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ +export type AuthenticatedUserStorageServiceHideTokensAction = { + type: `AuthenticatedUserStorageService:hideTokens`; + handler: AuthenticatedUserStorageService['hideTokens']; +}; + /** * Union of all AuthenticatedUserStorageService action types. */ @@ -95,4 +184,8 @@ export type AuthenticatedUserStorageServiceMethodActions = | AuthenticatedUserStorageServiceGetNotificationPreferencesAction | AuthenticatedUserStorageServicePutNotificationPreferencesAction | AuthenticatedUserStorageServiceGetAssetsWatchlistAction - | AuthenticatedUserStorageServiceSetAssetsWatchlistAction; + | AuthenticatedUserStorageServiceSetAssetsWatchlistAction + | AuthenticatedUserStorageServiceGetUserAssetsAction + | AuthenticatedUserStorageServiceSetUserAssetsAction + | AuthenticatedUserStorageServiceImportTokensAction + | AuthenticatedUserStorageServiceHideTokensAction; diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts index f0c5c3ee8f8..b7789f77e2f 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts @@ -14,6 +14,8 @@ import { handleMockPutNotificationPreferences, handleMockGetAssetsWatchlist, handleMockSetAssetsWatchlist, + handleMockGetUserAssets, + handleMockSetUserAssets, } from '../tests/fixtures/authenticated-userstorage.js'; import { MOCK_DELEGATION_RESPONSE, @@ -22,6 +24,9 @@ import { MOCK_NOTIFICATION_PREFERENCES, MOCK_ASSETS_WATCHLIST_BLOB, MOCK_ASSETS_WATCHLIST_URL, + MOCK_USER_ASSETS_BLOB, + MOCK_USER_ASSETS_URL, + MOCK_INVALID_USER_ASSETS_BLOB, } from '../tests/mocks/authenticated-userstorage.js'; import type { AuthenticatedUserStorageMessenger } from './authenticated-user-storage.js'; import { @@ -30,10 +35,23 @@ import { } from './authenticated-user-storage.js'; import type { Environment } from './env.js'; import { getUserStorageApiUrl } from './env.js'; -import { ASSETS_WATCHLIST_MAX_ASSETS } from './validators.js'; +import { + ASSETS_WATCHLIST_MAX_ASSETS, + normalizeUserAssetsBlob, +} from './validators.js'; const MOCK_ACCESS_TOKEN = 'mock-access-token'; +const MOCK_USDC_ETH_ASSET_ID = + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; +const MOCK_USDC_BASE_ASSET_ID = + 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913'; +const MOCK_USDC_OP_ASSET_ID = + 'eip155:10/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce36000000'; +const MOCK_USDC_POLYGON_ASSET_ID = + 'eip155:137/erc20:0x3c499c542cef5e3811e1192ce70d8cc03d5c3359'; +const MOCK_INVALID_ASSET_ID = 'not-a-caip-19-asset-id'; + describe('getUserStorageApiUrl()', () => { it('returns the API URL for a valid environment', () => { const result = getUserStorageApiUrl('prod'); @@ -54,6 +72,56 @@ describe('getAuthenticatedStorageUrl()', () => { }); }); +describe('normalizeUserAssetsBlob()', () => { + it('de-duplicates entries, preserving first-occurrence order', () => { + const result = normalizeUserAssetsBlob({ + version: 1, + importedAssets: [ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_BASE_ASSET_ID, + ], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_OP_ASSET_ID], + }); + + expect(result).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }); + }); + + it('resolves conflicts fail-open: an identifier in both lists stays imported and is removed from hidden', () => { + const result = normalizeUserAssetsBlob({ + version: 1, + importedAssets: [MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_BASE_ASSET_ID, MOCK_USDC_OP_ASSET_ID], + }); + + expect(result).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }); + }); + + it('does not mutate the input blob', () => { + const input = { + version: 1 as const, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [MOCK_USDC_ETH_ASSET_ID], + }; + + normalizeUserAssetsBlob(input); + + expect(input).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [MOCK_USDC_ETH_ASSET_ID], + }); + }); +}); + describe('AuthenticatedUserStorageService', () => { afterEach(() => { nock.cleanAll(); // eslint-disable-line import-x/no-named-as-default-member @@ -463,6 +531,469 @@ describe('AuthenticatedUserStorageService', () => { }); }); + describe('AuthenticatedUserStorageService:getUserAssets', () => { + it('returns the user-assets blob via the messenger', async () => { + handleMockGetUserAssets(); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'AuthenticatedUserStorageService:getUserAssets', + ); + + expect(result).toStrictEqual(MOCK_USER_ASSETS_BLOB); + }); + }); + + describe('AuthenticatedUserStorageService:setUserAssets', () => { + it('sets the user-assets blob via the messenger', async () => { + const mock = handleMockSetUserAssets(); + const { rootMessenger } = createService(); + + await rootMessenger.call( + 'AuthenticatedUserStorageService:setUserAssets', + MOCK_USER_ASSETS_BLOB, + ); + + expect(mock.isDone()).toBe(true); + }); + }); + + describe('AuthenticatedUserStorageService:importTokens', () => { + it('imports tokens via the messenger', async () => { + handleMockGetUserAssets(); + const mock = handleMockSetUserAssets(); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'AuthenticatedUserStorageService:importTokens', + [MOCK_USDC_POLYGON_ASSET_ID], + ); + + expect(mock.isDone()).toBe(true); + expect(result?.importedAssets).toContain(MOCK_USDC_POLYGON_ASSET_ID); + expect(result?.hiddenAssets).toStrictEqual([MOCK_USDC_OP_ASSET_ID]); + }); + }); + + describe('AuthenticatedUserStorageService:hideTokens', () => { + it('hides tokens via the messenger', async () => { + handleMockGetUserAssets(); + const mock = handleMockSetUserAssets(); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'AuthenticatedUserStorageService:hideTokens', + [MOCK_USDC_ETH_ASSET_ID], + ); + + expect(mock.isDone()).toBe(true); + expect(result?.hiddenAssets).toContain(MOCK_USDC_ETH_ASSET_ID); + expect(result?.importedAssets).toStrictEqual([MOCK_USDC_BASE_ASSET_ID]); + }); + }); + + describe('getUserAssets', () => { + it('returns the user-assets blob from the API', async () => { + const mock = handleMockGetUserAssets(); + const { service } = createService(); + + const result = await service.getUserAssets(); + + expect(mock.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_USER_ASSETS_BLOB); + }); + + it('sends the Authorization header', async () => { + const scope = nock(MOCK_USER_ASSETS_URL, { + reqheaders: { + authorization: 'Bearer mock-access-token', + }, + }) + .get('') + .reply(200, MOCK_USER_ASSETS_BLOB); + + const { service } = createService(); + const result = await service.getUserAssets(); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_USER_ASSETS_BLOB); + }); + + it('returns null when the user-assets blob is not found', async () => { + handleMockGetUserAssets({ status: 404 }); + const { service } = createService(); + + const result = await service.getUserAssets(); + + expect(result).toBeNull(); + }); + + it('throws when the API returns a non-200/404 status', async () => { + handleMockGetUserAssets({ status: 500 }); + const { service } = createService(); + + await expect(service.getUserAssets()).rejects.toThrow( + 'Failed to get user assets: 500', + ); + }); + + it('throws when the API returns a 401', async () => { + handleMockGetUserAssets({ status: 401 }); + const { service } = createService(); + + await expect(service.getUserAssets()).rejects.toThrow( + 'Failed to get user assets: 401', + ); + }); + + it('throws when the response body is malformed', async () => { + handleMockGetUserAssets({ + status: 200, + body: MOCK_INVALID_USER_ASSETS_BLOB, + }); + const { service } = createService(); + + await expect(service.getUserAssets()).rejects.toThrow( + /Expected.*but received/u, + ); + }); + + it('caches the result so a second call within staleTime does not re-fetch', async () => { + const scope = nock(MOCK_USER_ASSETS_URL) + .get('') + .once() + .reply(200, MOCK_USER_ASSETS_BLOB); + const { service } = createService(); + + const first = await service.getUserAssets(); + const second = await service.getUserAssets(); + + expect(scope.isDone()).toBe(true); + expect(first).toStrictEqual(MOCK_USER_ASSETS_BLOB); + expect(second).toStrictEqual(MOCK_USER_ASSETS_BLOB); + }); + }); + + describe('setUserAssets', () => { + it('submits the user-assets blob to the API', async () => { + const mock = handleMockSetUserAssets(); + const { service } = createService(); + + await service.setUserAssets(MOCK_USER_ASSETS_BLOB); + + expect(mock.isDone()).toBe(true); + }); + + it('sends the correct request body', async () => { + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(MOCK_USER_ASSETS_BLOB); + }); + const { service } = createService(); + + await service.setUserAssets(MOCK_USER_ASSETS_BLOB); + }); + + it('sends Content-Type and Authorization headers but no X-Client-Type when clientType is omitted', async () => { + const scope = nock(MOCK_USER_ASSETS_URL, { + reqheaders: { + 'content-type': 'application/json', + authorization: 'Bearer mock-access-token', + }, + badheaders: ['x-client-type'], + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.setUserAssets(MOCK_USER_ASSETS_BLOB); + + expect(scope.isDone()).toBe(true); + }); + + it('includes X-Client-Type header when clientType is provided', async () => { + const scope = nock(MOCK_USER_ASSETS_URL, { + reqheaders: { + 'x-client-type': 'extension', + }, + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.setUserAssets(MOCK_USER_ASSETS_BLOB, 'extension'); + + expect(scope.isDone()).toBe(true); + }); + + it('throws when the API returns a non-200 status', async () => { + handleMockSetUserAssets({ status: 400 }); + const { service } = createService(); + + await expect( + service.setUserAssets(MOCK_USER_ASSETS_BLOB), + ).rejects.toThrow('Failed to put user assets: 400'); + }); + + it('throws a structural error before sending the request when the blob is malformed', async () => { + const { service } = createService(); + const malformed = { + version: 2, + importedAssets: [MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [], + } as unknown as Parameters[0]; + + await expect(service.setUserAssets(malformed)).rejects.toThrow( + /At path: version -- Expected the literal/u, + ); + }); + + it('throws a structural error before sending the request when an entry is not a CAIP-19 asset identifier', async () => { + const { service } = createService(); + const malformed = { + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_INVALID_ASSET_ID], + hiddenAssets: [], + } as unknown as Parameters[0]; + + await expect(service.setUserAssets(malformed)).rejects.toThrow( + /At path: importedAssets\.1 -- Expected a value of type `CaipAssetType`/u, + ); + }); + + it('de-duplicates entries before sending the request', async () => { + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }); + }); + const { service } = createService(); + + await service.setUserAssets({ + version: 1 as const, + importedAssets: [ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_BASE_ASSET_ID, + ], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_OP_ASSET_ID], + }); + }); + + it('resolves conflicts fail-open (import wins) before sending the request', async () => { + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [], + }); + }); + const { service } = createService(); + + await service.setUserAssets({ + version: 1 as const, + // Deliberately present in both lists: import must win. + importedAssets: [MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_BASE_ASSET_ID], + }); + }); + }); + + describe('importTokens', () => { + it('creates a fresh blob when none exists (404)', async () => { + handleMockGetUserAssets({ status: 404 }); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [], + }); + }); + const { service } = createService(); + + const result = await service.importTokens([MOCK_USDC_ETH_ASSET_ID]); + + expect(result).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [], + }); + }); + + it('merges into the existing imported list, de-duplicating entries', async () => { + handleMockGetUserAssets(); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_BASE_ASSET_ID, + MOCK_USDC_POLYGON_ASSET_ID, + ], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }); + }); + const { service } = createService(); + + const result = await service.importTokens([ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_POLYGON_ASSET_ID, + ]); + + expect(result.importedAssets).toStrictEqual([ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_BASE_ASSET_ID, + MOCK_USDC_POLYGON_ASSET_ID, + ]); + }); + + it('removes imported tokens from hiddenAssets (mutual exclusivity)', async () => { + handleMockGetUserAssets(); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_BASE_ASSET_ID, + MOCK_USDC_OP_ASSET_ID, + ], + hiddenAssets: [], + }); + }); + const { service } = createService(); + + const result = await service.importTokens([MOCK_USDC_OP_ASSET_ID]); + + expect(result.importedAssets).toContain(MOCK_USDC_OP_ASSET_ID); + expect(result.hiddenAssets).toStrictEqual([]); + }); + + it('throws before any request when an entry is not a CAIP-19 asset identifier', async () => { + const getScope = nock(MOCK_USER_ASSETS_URL) + .get('') + .reply(200, MOCK_USER_ASSETS_BLOB); + const { service } = createService(); + + await expect( + service.importTokens([MOCK_INVALID_ASSET_ID]), + ).rejects.toThrow( + /At path: 0 -- Expected a value of type `CaipAssetType`/u, + ); + + expect(getScope.isDone()).toBe(false); + }); + + it('includes X-Client-Type header when clientType is provided', async () => { + handleMockGetUserAssets(); + const scope = nock(MOCK_USER_ASSETS_URL, { + reqheaders: { + 'x-client-type': 'extension', + }, + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.importTokens([MOCK_USDC_ETH_ASSET_ID], 'extension'); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('hideTokens', () => { + it('creates a fresh blob when none exists (404)', async () => { + handleMockGetUserAssets({ status: 404 }); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }); + }); + const { service } = createService(); + + const result = await service.hideTokens([MOCK_USDC_OP_ASSET_ID]); + + expect(result).toStrictEqual({ + version: 1, + importedAssets: [], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }); + }); + + it('merges into the existing hidden list, de-duplicating entries', async () => { + handleMockGetUserAssets(); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_POLYGON_ASSET_ID], + }); + }); + const { service } = createService(); + + const result = await service.hideTokens([ + MOCK_USDC_OP_ASSET_ID, + MOCK_USDC_POLYGON_ASSET_ID, + ]); + + expect(result.hiddenAssets).toStrictEqual([ + MOCK_USDC_OP_ASSET_ID, + MOCK_USDC_POLYGON_ASSET_ID, + ]); + }); + + it('removes hidden tokens from importedAssets (mutual exclusivity)', async () => { + handleMockGetUserAssets(); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual({ + version: 1, + importedAssets: [MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_ETH_ASSET_ID], + }); + }); + const { service } = createService(); + + const result = await service.hideTokens([MOCK_USDC_ETH_ASSET_ID]); + + expect(result.hiddenAssets).toStrictEqual([ + MOCK_USDC_OP_ASSET_ID, + MOCK_USDC_ETH_ASSET_ID, + ]); + expect(result.importedAssets).toStrictEqual([MOCK_USDC_BASE_ASSET_ID]); + }); + + it('throws before any request when an entry is not a CAIP-19 asset identifier', async () => { + const getScope = nock(MOCK_USER_ASSETS_URL) + .get('') + .reply(200, MOCK_USER_ASSETS_BLOB); + const { service } = createService(); + + await expect(service.hideTokens([MOCK_INVALID_ASSET_ID])).rejects.toThrow( + /At path: 0 -- Expected a value of type `CaipAssetType`/u, + ); + + expect(getScope.isDone()).toBe(false); + }); + + it('includes X-Client-Type header when clientType is provided', async () => { + handleMockGetUserAssets(); + const scope = nock(MOCK_USER_ASSETS_URL, { + reqheaders: { + 'x-client-type': 'mobile', + }, + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.hideTokens([MOCK_USDC_ETH_ASSET_ID], 'mobile'); + + expect(scope.isDone()).toBe(true); + }); + }); + describe('cache invalidation', () => { it('invalidates listDelegations cache after createDelegation', async () => { handleMockCreateDelegation(); @@ -542,6 +1073,69 @@ describe('AuthenticatedUserStorageService', () => { expect(first).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); expect(second).toStrictEqual(updatedBlob); }); + + it('invalidates getUserAssets cache after setUserAssets', async () => { + handleMockSetUserAssets(); + handleMockGetUserAssets(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.setUserAssets(MOCK_USER_ASSETS_BLOB); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:getUserAssets'], + }); + }); + + it('causes a subsequent getUserAssets to refetch after setUserAssets', async () => { + const updatedBlob = { + version: 1 as const, + importedAssets: [MOCK_USDC_POLYGON_ASSET_ID], + hiddenAssets: [], + }; + const getScope = nock(MOCK_USER_ASSETS_URL) + .get('') + .reply(200, MOCK_USER_ASSETS_BLOB) + .put('') + .reply(200) + .get('') + .reply(200, updatedBlob); + + const { service } = createService(); + const first = await service.getUserAssets(); + await service.setUserAssets(updatedBlob); + const second = await service.getUserAssets(); + + expect(getScope.isDone()).toBe(true); + expect(first).toStrictEqual(MOCK_USER_ASSETS_BLOB); + expect(second).toStrictEqual(updatedBlob); + }); + + it('invalidates getUserAssets cache after importTokens', async () => { + handleMockGetUserAssets(); + handleMockSetUserAssets(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.importTokens([MOCK_USDC_POLYGON_ASSET_ID]); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:getUserAssets'], + }); + }); + + it('invalidates getUserAssets cache after hideTokens', async () => { + handleMockGetUserAssets(); + handleMockSetUserAssets(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.hideTokens([MOCK_USDC_ETH_ASSET_ID]); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:getUserAssets'], + }); + }); }); describe('authorization', () => { diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.ts index 2b14522fe3c..4a447c3c96a 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.ts @@ -18,12 +18,17 @@ import type { DelegationResponse, DelegationSubmission, NotificationPreferences, + UserAssetsBlob, } from './types.js'; import { assertAssetsWatchlistBlob, assertAssetsWatchlistBlobForWrite, assertDelegationResponseArray, assertNotificationPreferences, + assertUserAssetIds, + assertUserAssetsBlob, + assertUserAssetsBlobForWrite, + normalizeUserAssetsBlob, } from './validators.js'; // === GENERAL === @@ -54,6 +59,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'putNotificationPreferences', 'getAssetsWatchlist', 'setAssetsWatchlist', + 'getUserAssets', + 'setUserAssets', + 'importTokens', + 'hideTokens', ] as const; /** @@ -432,6 +441,204 @@ export class AuthenticatedUserStorageService extends BaseDataService< }); } + /** + * Returns the user-assets (custom tokens) blob for the authenticated user. + * + * @returns The user-assets blob, or `null` if none has been set (404). + */ + async getUserAssets(): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/user-assets`; + + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getUserAssets`], + queryFn: async () => { + const headers = await this.#getHeaders(); + const response = await fetch(url, { headers }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to get user assets: ${response.status}`, + ); + } + + return response.json(); + }, + }); + + if (data === null) { + return null; + } + + assertUserAssetsBlob(data); + return data; + } + + /** + * Creates or updates the user-assets (custom tokens) blob for the + * authenticated user. + * + * The blob is normalized before it is sent: entries are de-duplicated + * (order-preserving) and conflicts between `importedAssets` and + * `hiddenAssets` are resolved "fail-open" — an identifier present in both + * lists stays in `importedAssets` (the user's intent to import wins) and is + * removed from `hiddenAssets`, so the write is never rejected because of a + * conflict. + * + * @param blob - The full user-assets blob. Every entry of + * `importedAssets` and `hiddenAssets` must be a CAIP-19 asset identifier + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`); + * this is enforced by `assertUserAssetsBlobForWrite` before the request + * is sent. + * @param clientType - Optional client type header. + * @throws A `StructError` from `@metamask/superstruct` if `blob` is + * structurally invalid; an `HttpError` from `@metamask/controller-utils` + * if the API responds with a non-2xx status. + */ + async setUserAssets( + blob: UserAssetsBlob, + clientType?: ClientType, + ): Promise { + assertUserAssetsBlobForWrite(blob); + const normalizedBlob = normalizeUserAssetsBlob(blob); + + const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/user-assets`; + + await this.fetchQuery({ + queryKey: [ + `${this.name}:setUserAssets`, + normalizedBlob as unknown as Json, + ], + staleTime: 0, + queryFn: async () => { + const headers = await this.#getHeaders(clientType); + const response = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify(normalizedBlob), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to put user assets: ${response.status}`, + ); + } + + return null; + }, + }); + + await this.invalidateQueries({ + queryKey: [`${this.name}:getUserAssets`], + }); + } + + /** + * Imports custom tokens for the authenticated user. + * + * Adds the given CAIP-19 asset identifiers to `importedAssets` + * (de-duplicated, existing order preserved) and removes them from + * `hiddenAssets`, since the two lists are mutually exclusive and the + * user's intent to import wins ("fail-open"). If no user-assets blob + * exists yet (404), a fresh one is created. + * + * This is a convenience wrapper around `getUserAssets` and + * `setUserAssets`; the SDK handles deduplication and mutual exclusivity + * internally as a safeguard, so callers never need to read-modify-write + * the blob themselves. + * + * @param ids - The CAIP-19 asset identifiers of the tokens to import + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param clientType - Optional client type header. + * @returns The resolved user-assets blob that was persisted. + * @throws A `StructError` from `@metamask/superstruct` if any entry of + * `ids` is not a CAIP-19 asset identifier; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ + async importTokens( + ids: string[], + clientType?: ClientType, + ): Promise { + assertUserAssetIds(ids); + + const currentBlob: UserAssetsBlob = (await this.getUserAssets()) ?? { + version: 1, + importedAssets: [], + hiddenAssets: [], + }; + + const importedAssets = new Set(currentBlob.importedAssets); + const hiddenAssets = new Set(currentBlob.hiddenAssets); + for (const assetId of ids) { + importedAssets.add(assetId); + hiddenAssets.delete(assetId); + } + + const nextBlob = normalizeUserAssetsBlob({ + version: 1, + importedAssets: [...importedAssets], + hiddenAssets: [...hiddenAssets], + }); + + await this.setUserAssets(nextBlob, clientType); + return nextBlob; + } + + /** + * Hides custom tokens for the authenticated user. + * + * Adds the given CAIP-19 asset identifiers to `hiddenAssets` + * (de-duplicated, existing order preserved) and removes them from + * `importedAssets`, since the two lists are mutually exclusive. If no + * user-assets blob exists yet (404), a fresh one is created. + * + * This is a convenience wrapper around `getUserAssets` and + * `setUserAssets`; the SDK handles deduplication and mutual exclusivity + * internally as a safeguard, so callers never need to read-modify-write + * the blob themselves. + * + * @param ids - The CAIP-19 asset identifiers of the tokens to hide + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param clientType - Optional client type header. + * @returns The resolved user-assets blob that was persisted. + * @throws A `StructError` from `@metamask/superstruct` if any entry of + * `ids` is not a CAIP-19 asset identifier; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ + async hideTokens( + ids: string[], + clientType?: ClientType, + ): Promise { + assertUserAssetIds(ids); + + const currentBlob: UserAssetsBlob = (await this.getUserAssets()) ?? { + version: 1, + importedAssets: [], + hiddenAssets: [], + }; + + const importedAssets = new Set(currentBlob.importedAssets); + const hiddenAssets = new Set(currentBlob.hiddenAssets); + for (const assetId of ids) { + hiddenAssets.add(assetId); + importedAssets.delete(assetId); + } + + const nextBlob = normalizeUserAssetsBlob({ + version: 1, + importedAssets: [...importedAssets], + hiddenAssets: [...hiddenAssets], + }); + + await this.setUserAssets(nextBlob, clientType); + return nextBlob; + } + async #getHeaders(clientType?: ClientType): Promise> { const accessToken = await this.messenger.call( 'AuthenticationController:getBearerToken', diff --git a/packages/authenticated-user-storage/src/index.ts b/packages/authenticated-user-storage/src/index.ts index d1bb02bdbca..3eaec9b8568 100644 --- a/packages/authenticated-user-storage/src/index.ts +++ b/packages/authenticated-user-storage/src/index.ts @@ -23,6 +23,10 @@ export type { AuthenticatedUserStorageServicePutNotificationPreferencesAction, AuthenticatedUserStorageServiceGetAssetsWatchlistAction, AuthenticatedUserStorageServiceSetAssetsWatchlistAction, + AuthenticatedUserStorageServiceGetUserAssetsAction, + AuthenticatedUserStorageServiceSetUserAssetsAction, + AuthenticatedUserStorageServiceImportTokensAction, + AuthenticatedUserStorageServiceHideTokensAction, } from './authenticated-user-storage-method-action-types.js'; export { getUserStorageApiUrl } from './env.js'; export type { Environment } from './env.js'; @@ -43,5 +47,6 @@ export type { PriceAlertPreference, NotificationPreferences, AssetsWatchlistBlob, + UserAssetsBlob, ClientType, } from './types.js'; diff --git a/packages/authenticated-user-storage/src/types.ts b/packages/authenticated-user-storage/src/types.ts index f875833977a..d44f3869b18 100644 --- a/packages/authenticated-user-storage/src/types.ts +++ b/packages/authenticated-user-storage/src/types.ts @@ -134,6 +134,16 @@ export type NotificationPreferences = { // one file keeps the two in lock-step. export type { AssetsWatchlistBlob } from './validators.js'; +// --------------------------------------------------------------------------- +// User assets (custom tokens) +// --------------------------------------------------------------------------- + +// `UserAssetsBlob` is inferred from `UserAssetsBlobSchema` in `./validators` +// and re-exported here so the public type surface remains in `./types`. +// Keeping the runtime schema and the static type co-located in one file +// keeps the two in lock-step. +export type { UserAssetsBlob } from './validators.js'; + // --------------------------------------------------------------------------- // Shared // --------------------------------------------------------------------------- diff --git a/packages/authenticated-user-storage/src/validators.ts b/packages/authenticated-user-storage/src/validators.ts index ca62b703d88..d8ffe85c862 100644 --- a/packages/authenticated-user-storage/src/validators.ts +++ b/packages/authenticated-user-storage/src/validators.ts @@ -12,6 +12,7 @@ import { string, type, } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; import type { AgenticCliPreference, @@ -235,3 +236,126 @@ export function assertAssetsWatchlistBlobForWrite( ): asserts data is AssetsWatchlistBlob { assert(data, AssetsWatchlistBlobWriteSchema); } + +// --------------------------------------------------------------------------- +// User assets (custom tokens) +// --------------------------------------------------------------------------- + +/** + * The shape we accept on the way **in** from the server. Lenient by design: + * a malformed payload throws, but a well-formed payload whose entries are + * not valid CAIP-19 asset identifiers is still considered valid so we don't + * reject existing server-side data. + */ +const UserAssetsBlobSchema = type({ + version: literal(1), + importedAssets: array(string()), + hiddenAssets: array(string()), +}); + +/** + * The shape we accept on the way **out** to the server. Strict by design: + * every entry must be a CAIP-19 asset identifier + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`), + * enforced via {@link CaipAssetTypeStruct} from `@metamask/utils`. + * Validation failures throw a `StructError` before the request is sent. + */ +const UserAssetsBlobWriteSchema = type({ + version: literal(1), + importedAssets: array(CaipAssetTypeStruct), + hiddenAssets: array(CaipAssetTypeStruct), +}); + +/** + * The authenticated user's custom tokens: a mutable per-user singleton blob + * recording which assets the user chose to import and which they chose to + * hide. + * + * Each entry is a CAIP-19 asset identifier + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * + * The `version` literal is carried inside the blob (not in the URL) so the + * schema can evolve in a backwards-compatible way; bumping the version + * indicates a different blob shape. + * + * `importedAssets` and `hiddenAssets` are mutually exclusive by contract: a + * given identifier may appear in at most one of the two lists. Conflicts are + * resolved "fail-open" by {@link normalizeUserAssetsBlob}: the user's intent + * to import wins, so a conflicting identifier stays in `importedAssets` and + * is removed from `hiddenAssets` rather than the write being rejected. + * + * Inferred from {@link UserAssetsBlobSchema} so the runtime schema and the + * static type stay in lock-step. The stricter CAIP-19 constraint on writes is + * enforced by {@link UserAssetsBlobWriteSchema} and is not encoded in this + * static type (the read side is deliberately lenient). + */ +export type UserAssetsBlob = Infer; + +/** + * Asserts that the given value is a valid `UserAssetsBlob` (read-side, + * lenient). + * + * @param data - The unknown value to validate. + * @throws If the value does not match the expected schema. + */ +export function assertUserAssetsBlob( + data: unknown, +): asserts data is UserAssetsBlob { + assert(data, UserAssetsBlobSchema); +} + +/** + * Asserts that the given value is a valid `UserAssetsBlob` for **writes**. + * In addition to the structural checks performed by + * {@link assertUserAssetsBlob}, this enforces that every entry of + * `importedAssets` and `hiddenAssets` is a CAIP-19 asset identifier. + * + * @param data - The unknown value to validate. + * @throws A `StructError` if the value does not match the expected schema + * (including the CAIP-19 constraint). + */ +export function assertUserAssetsBlobForWrite( + data: unknown, +): asserts data is UserAssetsBlob { + assert(data, UserAssetsBlobWriteSchema); +} + +/** + * Asserts that every entry of the given list is a CAIP-19 asset identifier + * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * + * Used to fail fast on invalid input to the high-level + * `importTokens`/`hideTokens` methods, before any network request is made. + * + * @param ids - The list of identifiers to validate. + * @throws A `StructError` if any entry is not a CAIP-19 asset identifier. + */ +export function assertUserAssetIds(ids: string[]): void { + assert(ids, array(CaipAssetTypeStruct)); +} + +/** + * Normalizes a `UserAssetsBlob` into the canonical form that is safe to + * persist: entries are de-duplicated (order-preserving, first occurrence + * wins) and conflicts between the two lists are resolved "fail-open". + * + * Fail-open conflict resolution: if a CAIP-19 asset identifier appears in + * both `importedAssets` and `hiddenAssets`, the user's intent to import wins — + * the identifier is kept in `importedAssets` and removed from + * `hiddenAssets`. The write is never rejected because of a conflict. + * + * This is a pure function; the result is what {@link + * AuthenticatedUserStorageService.setUserAssets} sends to the API, and the + * same rule is expected of the server as defense-in-depth. + * + * @param blob - The blob to normalize. + * @returns A new, normalized blob; the input is not mutated. + */ +export function normalizeUserAssetsBlob(blob: UserAssetsBlob): UserAssetsBlob { + const importedAssets = [...new Set(blob.importedAssets)]; + const importedAssetIds = new Set(importedAssets); + const hiddenAssets = [...new Set(blob.hiddenAssets)].filter( + (assetId) => !importedAssetIds.has(assetId), + ); + return { version: 1, importedAssets, hiddenAssets }; +} diff --git a/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts b/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts index 10d005b4e90..9d971167987 100644 --- a/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts +++ b/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts @@ -7,6 +7,8 @@ import { MOCK_DELEGATION_RESPONSE, MOCK_NOTIFICATION_PREFERENCES, MOCK_NOTIFICATION_PREFERENCES_URL, + MOCK_USER_ASSETS_BLOB, + MOCK_USER_ASSETS_URL, } from '../mocks/authenticated-userstorage.js'; type MockReply = { @@ -103,3 +105,29 @@ export function handleMockSetAssetsWatchlist( } return interceptor.reply(reply.status, reply.body); } + +export function handleMockGetUserAssets(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: MOCK_USER_ASSETS_BLOB, + }; + return nock(MOCK_USER_ASSETS_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +} + +export function handleMockSetUserAssets( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +): nock.Scope { + const reply = mockReply ?? { status: 200 }; + const interceptor = nock(MOCK_USER_ASSETS_URL).persist().put(''); + + if (callback) { + return interceptor.reply(reply.status, async (uri, requestBody) => { + return callback(uri, requestBody); + }); + } + return interceptor.reply(reply.status, reply.body); +} diff --git a/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts b/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts index e959a2fa211..4a76bae5d05 100644 --- a/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts +++ b/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts @@ -4,12 +4,14 @@ import type { DelegationResponse, DelegationSubmission, NotificationPreferences, + UserAssetsBlob, } from '../../src/types.js'; import { DEFAULT_PRICE_ALERT_PREFERENCES } from '../../src/validators.js'; export const MOCK_DELEGATIONS_URL = `${getAuthenticatedStorageUrl('prod')}/delegations`; export const MOCK_NOTIFICATION_PREFERENCES_URL = `${getAuthenticatedStorageUrl('prod')}/preferences/notifications`; export const MOCK_ASSETS_WATCHLIST_URL = `${getAuthenticatedStorageUrl('prod')}/preferences/assets-watchlist`; +export const MOCK_USER_ASSETS_URL = `${getAuthenticatedStorageUrl('prod')}/preferences/user-assets`; export const MOCK_DELEGATION_SUBMISSION: DelegationSubmission = { signedDelegation: { @@ -88,3 +90,18 @@ export const MOCK_INVALID_ASSETS_WATCHLIST_BLOB = { version: 2, assets: 'not-an-array', } as const; + +export const MOCK_USER_ASSETS_BLOB: UserAssetsBlob = { + version: 1, + importedAssets: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + ], + hiddenAssets: ['eip155:10/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce36000000'], +}; + +export const MOCK_INVALID_USER_ASSETS_BLOB = { + version: 2, + importedAssets: 'not-an-array', + hiddenAssets: [], +} as const; From 6da88106eab2752e79cf1aa27bd0908e912cd44a Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 14 Sep 2026 20:44:13 +0100 Subject: [PATCH 2/5] chore(authenticated-user-storage): fill in changelog PR link --- packages/authenticated-user-storage/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/authenticated-user-storage/CHANGELOG.md b/packages/authenticated-user-storage/CHANGELOG.md index 95f839dfb30..4e400f79995 100644 --- a/packages/authenticated-user-storage/CHANGELOG.md +++ b/packages/authenticated-user-storage/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `getUserAssets`, `setUserAssets`, `importTokens`, and `hideTokens` methods to `AuthenticatedUserStorageService` for managing the authenticated user's custom tokens, along with corresponding messenger actions (`AuthenticatedUserStorageService:getUserAssets`, `AuthenticatedUserStorageService:setUserAssets`, `AuthenticatedUserStorageService:importTokens`, `AuthenticatedUserStorageService:hideTokens`) and the `UserAssetsBlob` type ([#XXXX](https://github.com/MetaMask/core/pull/XXXX)) +- Add `getUserAssets`, `setUserAssets`, `importTokens`, and `hideTokens` methods to `AuthenticatedUserStorageService` for managing the authenticated user's custom tokens, along with corresponding messenger actions (`AuthenticatedUserStorageService:getUserAssets`, `AuthenticatedUserStorageService:setUserAssets`, `AuthenticatedUserStorageService:importTokens`, `AuthenticatedUserStorageService:hideTokens`) and the `UserAssetsBlob` type ([#10233](https://github.com/MetaMask/core/pull/10233)) - Backed by the new `GET`/`PUT /preferences/user-assets` API endpoints; `getUserAssets` returns the blob or `null` on 404, mirroring `getAssetsWatchlist`. - Every write is normalized: entries are de-duplicated (order-preserving) and conflicts between `importedAssets` and `hiddenAssets` are resolved "fail-open" — an identifier present in both lists stays in `importedAssets` (the user's intent to import wins) and is removed from `hiddenAssets`. - `importTokens`/`hideTokens` are high-level wrappers that fetch the current blob, merge with deduplication and mutual exclusivity, persist, and return the resolved blob. From a09928ee55f52fceda932c969112bbc90e1cf720 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 14 Sep 2026 21:43:10 +0100 Subject: [PATCH 3/5] docs(authenticated-user-storage): trim verbose JSDoc on user-assets code Per review feedback on #10233: keep JSDoc to concise summaries with the necessary param/returns/throws tags instead of multi-paragraph essays. The generated action-types file is regenerated to match. No functional change. --- ...icated-user-storage-method-action-types.ts | 64 +++++------------ .../src/authenticated-user-storage.ts | 64 +++++------------ .../authenticated-user-storage/src/types.ts | 5 +- .../src/validators.ts | 72 ++++--------------- 4 files changed, 48 insertions(+), 157 deletions(-) diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts index b73baa62486..37e5ad57bbd 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage-method-action-types.ts @@ -97,23 +97,12 @@ export type AuthenticatedUserStorageServiceGetUserAssetsAction = { /** * Creates or updates the user-assets (custom tokens) blob for the - * authenticated user. + * authenticated user. The blob is normalized (de-duplicated, conflicts + * resolved fail-open in favor of `importedAssets`) before it is sent. * - * The blob is normalized before it is sent: entries are de-duplicated - * (order-preserving) and conflicts between `importedAssets` and - * `hiddenAssets` are resolved "fail-open" — an identifier present in both - * lists stays in `importedAssets` (the user's intent to import wins) and is - * removed from `hiddenAssets`, so the write is never rejected because of a - * conflict. - * - * @param blob - The full user-assets blob. Every entry of - * `importedAssets` and `hiddenAssets` must be a CAIP-19 asset identifier - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`); - * this is enforced by `assertUserAssetsBlobForWrite` before the request - * is sent. + * @param blob - The full user-assets blob, with CAIP-19 asset identifiers. * @param clientType - Optional client type header. - * @throws A `StructError` from `@metamask/superstruct` if `blob` is - * structurally invalid; an `HttpError` from `@metamask/controller-utils` + * @throws A `StructError` if `blob` is structurally invalid; an `HttpError` * if the API responds with a non-2xx status. */ export type AuthenticatedUserStorageServiceSetUserAssetsAction = { @@ -122,26 +111,15 @@ export type AuthenticatedUserStorageServiceSetUserAssetsAction = { }; /** - * Imports custom tokens for the authenticated user. - * - * Adds the given CAIP-19 asset identifiers to `importedAssets` - * (de-duplicated, existing order preserved) and removes them from - * `hiddenAssets`, since the two lists are mutually exclusive and the - * user's intent to import wins ("fail-open"). If no user-assets blob - * exists yet (404), a fresh one is created. - * - * This is a convenience wrapper around `getUserAssets` and - * `setUserAssets`; the SDK handles deduplication and mutual exclusivity - * internally as a safeguard, so callers never need to read-modify-write - * the blob themselves. + * Imports custom tokens: adds the given identifiers to `importedAssets` + * (de-duplicated, order preserved) and removes them from `hiddenAssets`. + * Creates a fresh blob if none exists yet. * - * @param ids - The CAIP-19 asset identifiers of the tokens to import - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param ids - The CAIP-19 asset identifiers of the tokens to import. * @param clientType - Optional client type header. * @returns The resolved user-assets blob that was persisted. - * @throws A `StructError` from `@metamask/superstruct` if any entry of - * `ids` is not a CAIP-19 asset identifier; an `HttpError` from - * `@metamask/controller-utils` if the API responds with a non-2xx status. + * @throws A `StructError` if any entry of `ids` is not a CAIP-19 asset + * identifier; an `HttpError` if the API responds with a non-2xx status. */ export type AuthenticatedUserStorageServiceImportTokensAction = { type: `AuthenticatedUserStorageService:importTokens`; @@ -149,25 +127,15 @@ export type AuthenticatedUserStorageServiceImportTokensAction = { }; /** - * Hides custom tokens for the authenticated user. - * - * Adds the given CAIP-19 asset identifiers to `hiddenAssets` - * (de-duplicated, existing order preserved) and removes them from - * `importedAssets`, since the two lists are mutually exclusive. If no - * user-assets blob exists yet (404), a fresh one is created. + * Hides custom tokens: adds the given identifiers to `hiddenAssets` + * (de-duplicated, order preserved) and removes them from + * `importedAssets`. Creates a fresh blob if none exists yet. * - * This is a convenience wrapper around `getUserAssets` and - * `setUserAssets`; the SDK handles deduplication and mutual exclusivity - * internally as a safeguard, so callers never need to read-modify-write - * the blob themselves. - * - * @param ids - The CAIP-19 asset identifiers of the tokens to hide - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param ids - The CAIP-19 asset identifiers of the tokens to hide. * @param clientType - Optional client type header. * @returns The resolved user-assets blob that was persisted. - * @throws A `StructError` from `@metamask/superstruct` if any entry of - * `ids` is not a CAIP-19 asset identifier; an `HttpError` from - * `@metamask/controller-utils` if the API responds with a non-2xx status. + * @throws A `StructError` if any entry of `ids` is not a CAIP-19 asset + * identifier; an `HttpError` if the API responds with a non-2xx status. */ export type AuthenticatedUserStorageServiceHideTokensAction = { type: `AuthenticatedUserStorageService:hideTokens`; diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.ts index 4a447c3c96a..918d9b58149 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.ts @@ -480,23 +480,12 @@ export class AuthenticatedUserStorageService extends BaseDataService< /** * Creates or updates the user-assets (custom tokens) blob for the - * authenticated user. + * authenticated user. The blob is normalized (de-duplicated, conflicts + * resolved fail-open in favor of `importedAssets`) before it is sent. * - * The blob is normalized before it is sent: entries are de-duplicated - * (order-preserving) and conflicts between `importedAssets` and - * `hiddenAssets` are resolved "fail-open" — an identifier present in both - * lists stays in `importedAssets` (the user's intent to import wins) and is - * removed from `hiddenAssets`, so the write is never rejected because of a - * conflict. - * - * @param blob - The full user-assets blob. Every entry of - * `importedAssets` and `hiddenAssets` must be a CAIP-19 asset identifier - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`); - * this is enforced by `assertUserAssetsBlobForWrite` before the request - * is sent. + * @param blob - The full user-assets blob, with CAIP-19 asset identifiers. * @param clientType - Optional client type header. - * @throws A `StructError` from `@metamask/superstruct` if `blob` is - * structurally invalid; an `HttpError` from `@metamask/controller-utils` + * @throws A `StructError` if `blob` is structurally invalid; an `HttpError` * if the API responds with a non-2xx status. */ async setUserAssets( @@ -539,26 +528,15 @@ export class AuthenticatedUserStorageService extends BaseDataService< } /** - * Imports custom tokens for the authenticated user. - * - * Adds the given CAIP-19 asset identifiers to `importedAssets` - * (de-duplicated, existing order preserved) and removes them from - * `hiddenAssets`, since the two lists are mutually exclusive and the - * user's intent to import wins ("fail-open"). If no user-assets blob - * exists yet (404), a fresh one is created. - * - * This is a convenience wrapper around `getUserAssets` and - * `setUserAssets`; the SDK handles deduplication and mutual exclusivity - * internally as a safeguard, so callers never need to read-modify-write - * the blob themselves. + * Imports custom tokens: adds the given identifiers to `importedAssets` + * (de-duplicated, order preserved) and removes them from `hiddenAssets`. + * Creates a fresh blob if none exists yet. * - * @param ids - The CAIP-19 asset identifiers of the tokens to import - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param ids - The CAIP-19 asset identifiers of the tokens to import. * @param clientType - Optional client type header. * @returns The resolved user-assets blob that was persisted. - * @throws A `StructError` from `@metamask/superstruct` if any entry of - * `ids` is not a CAIP-19 asset identifier; an `HttpError` from - * `@metamask/controller-utils` if the API responds with a non-2xx status. + * @throws A `StructError` if any entry of `ids` is not a CAIP-19 asset + * identifier; an `HttpError` if the API responds with a non-2xx status. */ async importTokens( ids: string[], @@ -590,25 +568,15 @@ export class AuthenticatedUserStorageService extends BaseDataService< } /** - * Hides custom tokens for the authenticated user. - * - * Adds the given CAIP-19 asset identifiers to `hiddenAssets` - * (de-duplicated, existing order preserved) and removes them from - * `importedAssets`, since the two lists are mutually exclusive. If no - * user-assets blob exists yet (404), a fresh one is created. + * Hides custom tokens: adds the given identifiers to `hiddenAssets` + * (de-duplicated, order preserved) and removes them from + * `importedAssets`. Creates a fresh blob if none exists yet. * - * This is a convenience wrapper around `getUserAssets` and - * `setUserAssets`; the SDK handles deduplication and mutual exclusivity - * internally as a safeguard, so callers never need to read-modify-write - * the blob themselves. - * - * @param ids - The CAIP-19 asset identifiers of the tokens to hide - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). + * @param ids - The CAIP-19 asset identifiers of the tokens to hide. * @param clientType - Optional client type header. * @returns The resolved user-assets blob that was persisted. - * @throws A `StructError` from `@metamask/superstruct` if any entry of - * `ids` is not a CAIP-19 asset identifier; an `HttpError` from - * `@metamask/controller-utils` if the API responds with a non-2xx status. + * @throws A `StructError` if any entry of `ids` is not a CAIP-19 asset + * identifier; an `HttpError` if the API responds with a non-2xx status. */ async hideTokens( ids: string[], diff --git a/packages/authenticated-user-storage/src/types.ts b/packages/authenticated-user-storage/src/types.ts index d44f3869b18..66d57cc648e 100644 --- a/packages/authenticated-user-storage/src/types.ts +++ b/packages/authenticated-user-storage/src/types.ts @@ -138,10 +138,7 @@ export type { AssetsWatchlistBlob } from './validators.js'; // User assets (custom tokens) // --------------------------------------------------------------------------- -// `UserAssetsBlob` is inferred from `UserAssetsBlobSchema` in `./validators` -// and re-exported here so the public type surface remains in `./types`. -// Keeping the runtime schema and the static type co-located in one file -// keeps the two in lock-step. +// Re-exported from './validators' so the public type surface stays in './types'. export type { UserAssetsBlob } from './validators.js'; // --------------------------------------------------------------------------- diff --git a/packages/authenticated-user-storage/src/validators.ts b/packages/authenticated-user-storage/src/validators.ts index d8ffe85c862..a86cfa71fe7 100644 --- a/packages/authenticated-user-storage/src/validators.ts +++ b/packages/authenticated-user-storage/src/validators.ts @@ -253,13 +253,7 @@ const UserAssetsBlobSchema = type({ hiddenAssets: array(string()), }); -/** - * The shape we accept on the way **out** to the server. Strict by design: - * every entry must be a CAIP-19 asset identifier - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`), - * enforced via {@link CaipAssetTypeStruct} from `@metamask/utils`. - * Validation failures throw a `StructError` before the request is sent. - */ +/** Write-side schema: every entry must be a CAIP-19 asset identifier. */ const UserAssetsBlobWriteSchema = type({ version: literal(1), importedAssets: array(CaipAssetTypeStruct), @@ -267,35 +261,15 @@ const UserAssetsBlobWriteSchema = type({ }); /** - * The authenticated user's custom tokens: a mutable per-user singleton blob - * recording which assets the user chose to import and which they chose to - * hide. - * - * Each entry is a CAIP-19 asset identifier - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). - * - * The `version` literal is carried inside the blob (not in the URL) so the - * schema can evolve in a backwards-compatible way; bumping the version - * indicates a different blob shape. - * - * `importedAssets` and `hiddenAssets` are mutually exclusive by contract: a - * given identifier may appear in at most one of the two lists. Conflicts are - * resolved "fail-open" by {@link normalizeUserAssetsBlob}: the user's intent - * to import wins, so a conflicting identifier stays in `importedAssets` and - * is removed from `hiddenAssets` rather than the write being rejected. - * - * Inferred from {@link UserAssetsBlobSchema} so the runtime schema and the - * static type stay in lock-step. The stricter CAIP-19 constraint on writes is - * enforced by {@link UserAssetsBlobWriteSchema} and is not encoded in this - * static type (the read side is deliberately lenient). + * The authenticated user's custom tokens: mutually exclusive lists of + * CAIP-19 asset identifiers the user chose to import or hide. */ export type UserAssetsBlob = Infer; /** - * Asserts that the given value is a valid `UserAssetsBlob` (read-side, - * lenient). + * Asserts that the given value is a `UserAssetsBlob` (lenient read side). * - * @param data - The unknown value to validate. + * @param data - The value to validate. * @throws If the value does not match the expected schema. */ export function assertUserAssetsBlob( @@ -305,14 +279,11 @@ export function assertUserAssetsBlob( } /** - * Asserts that the given value is a valid `UserAssetsBlob` for **writes**. - * In addition to the structural checks performed by - * {@link assertUserAssetsBlob}, this enforces that every entry of - * `importedAssets` and `hiddenAssets` is a CAIP-19 asset identifier. + * Asserts that the given value is a `UserAssetsBlob` safe to write, with + * CAIP-19 asset identifiers in both lists. * - * @param data - The unknown value to validate. - * @throws A `StructError` if the value does not match the expected schema - * (including the CAIP-19 constraint). + * @param data - The value to validate. + * @throws A `StructError` if the value does not match the expected schema. */ export function assertUserAssetsBlobForWrite( data: unknown, @@ -321,32 +292,19 @@ export function assertUserAssetsBlobForWrite( } /** - * Asserts that every entry of the given list is a CAIP-19 asset identifier - * (e.g. `eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48`). - * - * Used to fail fast on invalid input to the high-level - * `importTokens`/`hideTokens` methods, before any network request is made. + * Asserts that every entry is a CAIP-19 asset identifier. * - * @param ids - The list of identifiers to validate. - * @throws A `StructError` if any entry is not a CAIP-19 asset identifier. + * @param ids - The identifiers to validate. + * @throws A `StructError` if any entry is invalid. */ export function assertUserAssetIds(ids: string[]): void { assert(ids, array(CaipAssetTypeStruct)); } /** - * Normalizes a `UserAssetsBlob` into the canonical form that is safe to - * persist: entries are de-duplicated (order-preserving, first occurrence - * wins) and conflicts between the two lists are resolved "fail-open". - * - * Fail-open conflict resolution: if a CAIP-19 asset identifier appears in - * both `importedAssets` and `hiddenAssets`, the user's intent to import wins — - * the identifier is kept in `importedAssets` and removed from - * `hiddenAssets`. The write is never rejected because of a conflict. - * - * This is a pure function; the result is what {@link - * AuthenticatedUserStorageService.setUserAssets} sends to the API, and the - * same rule is expected of the server as defense-in-depth. + * Normalizes a `UserAssetsBlob` for persistence: de-duplicates both lists + * (order-preserving) and resolves conflicts fail-open — an identifier in + * both lists stays in `importedAssets` and is dropped from `hiddenAssets`. * * @param blob - The blob to normalize. * @returns A new, normalized blob; the input is not mutated. From 43305bea6d292e65c6accb3befd5e6f5adfc7390 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 14 Sep 2026 21:54:29 +0100 Subject: [PATCH 4/5] test(authenticated-user-storage): convert repetitive user-assets tests to it.each tables Per review feedback on #10233: the mirrored importTokens/hideTokens tests, the error-status and malformed-blob cases, and the cache invalidation trio are now table-driven via it.each, split per scenario. Adds a hiddenAssets-side malformed-blob row; all existing assertions preserved. --- .../src/authenticated-user-storage.test.ts | 440 +++++++++--------- 1 file changed, 209 insertions(+), 231 deletions(-) diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts index b7789f77e2f..ed21e10de39 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts @@ -35,6 +35,7 @@ import { } from './authenticated-user-storage.js'; import type { Environment } from './env.js'; import { getUserStorageApiUrl } from './env.js'; +import type { UserAssetsBlob } from './types.js'; import { ASSETS_WATCHLIST_MAX_ASSETS, normalizeUserAssetsBlob, @@ -628,21 +629,12 @@ describe('AuthenticatedUserStorageService', () => { expect(result).toBeNull(); }); - it('throws when the API returns a non-200/404 status', async () => { - handleMockGetUserAssets({ status: 500 }); - const { service } = createService(); - - await expect(service.getUserAssets()).rejects.toThrow( - 'Failed to get user assets: 500', - ); - }); - - it('throws when the API returns a 401', async () => { - handleMockGetUserAssets({ status: 401 }); + it.each([401, 500])('throws when the API returns a %s', async (status) => { + handleMockGetUserAssets({ status }); const { service } = createService(); await expect(service.getUserAssets()).rejects.toThrow( - 'Failed to get user assets: 401', + `Failed to get user assets: ${status}`, ); }); @@ -734,31 +726,48 @@ describe('AuthenticatedUserStorageService', () => { ).rejects.toThrow('Failed to put user assets: 400'); }); - it('throws a structural error before sending the request when the blob is malformed', async () => { - const { service } = createService(); - const malformed = { - version: 2, - importedAssets: [MOCK_USDC_ETH_ASSET_ID], - hiddenAssets: [], - } as unknown as Parameters[0]; - - await expect(service.setUserAssets(malformed)).rejects.toThrow( - /At path: version -- Expected the literal/u, - ); - }); - - it('throws a structural error before sending the request when an entry is not a CAIP-19 asset identifier', async () => { - const { service } = createService(); - const malformed = { - version: 1, - importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_INVALID_ASSET_ID], - hiddenAssets: [], - } as unknown as Parameters[0]; - - await expect(service.setUserAssets(malformed)).rejects.toThrow( - /At path: importedAssets\.1 -- Expected a value of type `CaipAssetType`/u, - ); - }); + it.each([ + { + name: 'the blob version is not 1', + blob: { + version: 2, + importedAssets: [MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [], + }, + expectedError: /At path: version -- Expected the literal/u, + }, + { + name: 'an importedAssets entry is not a CAIP-19 asset identifier', + blob: { + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_INVALID_ASSET_ID], + hiddenAssets: [], + }, + expectedError: + /At path: importedAssets\.1 -- Expected a value of type `CaipAssetType`/u, + }, + { + name: 'a hiddenAssets entry is not a CAIP-19 asset identifier', + blob: { + version: 1, + importedAssets: [], + hiddenAssets: [MOCK_INVALID_ASSET_ID], + }, + expectedError: + /At path: hiddenAssets\.0 -- Expected a value of type `CaipAssetType`/u, + }, + ])( + 'throws a structural error before sending the request when $name', + async ({ blob, expectedError }) => { + const { service } = createService(); + + await expect( + service.setUserAssets( + blob as unknown as Parameters[0], + ), + ).rejects.toThrow(expectedError); + }, + ); it('de-duplicates entries before sending the request', async () => { handleMockSetUserAssets(undefined, async (_, requestBody) => { @@ -800,31 +809,52 @@ describe('AuthenticatedUserStorageService', () => { }); }); - describe('importTokens', () => { - it('creates a fresh blob when none exists (404)', async () => { - handleMockGetUserAssets({ status: 404 }); - handleMockSetUserAssets(undefined, async (_, requestBody) => { - expect(requestBody).toStrictEqual({ + describe('importTokens / hideTokens', () => { + it.each([ + { + method: 'importTokens', + ids: [MOCK_USDC_ETH_ASSET_ID], + expectedBlob: { version: 1, importedAssets: [MOCK_USDC_ETH_ASSET_ID], hiddenAssets: [], + }, + }, + { + method: 'hideTokens', + ids: [MOCK_USDC_OP_ASSET_ID], + expectedBlob: { + version: 1, + importedAssets: [], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }, + }, + ] as const)( + '$method creates a fresh blob when none exists (404)', + async ({ method, ids, expectedBlob }) => { + handleMockGetUserAssets({ status: 404 }); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(expectedBlob); }); - }); - const { service } = createService(); + const { service } = createService(); - const result = await service.importTokens([MOCK_USDC_ETH_ASSET_ID]); + const result = await service[method]([...ids]); - expect(result).toStrictEqual({ - version: 1, - importedAssets: [MOCK_USDC_ETH_ASSET_ID], - hiddenAssets: [], - }); - }); + expect(result).toStrictEqual(expectedBlob); + }, + ); - it('merges into the existing imported list, de-duplicating entries', async () => { - handleMockGetUserAssets(); - handleMockSetUserAssets(undefined, async (_, requestBody) => { - expect(requestBody).toStrictEqual({ + it.each([ + { + method: 'importTokens', + addedList: 'importedAssets', + ids: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_POLYGON_ASSET_ID], + expectedList: [ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_BASE_ASSET_ID, + MOCK_USDC_POLYGON_ASSET_ID, + ], + expectedRequest: { version: 1, importedAssets: [ MOCK_USDC_ETH_ASSET_ID, @@ -832,26 +862,39 @@ describe('AuthenticatedUserStorageService', () => { MOCK_USDC_POLYGON_ASSET_ID, ], hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }, + }, + { + method: 'hideTokens', + addedList: 'hiddenAssets', + ids: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_POLYGON_ASSET_ID], + expectedList: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_POLYGON_ASSET_ID], + expectedRequest: { + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_BASE_ASSET_ID], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_POLYGON_ASSET_ID], + }, + }, + ] as const)( + '$method merges into the existing list, de-duplicating entries', + async ({ method, addedList, ids, expectedList, expectedRequest }) => { + handleMockGetUserAssets(); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(expectedRequest); }); - }); - const { service } = createService(); + const { service } = createService(); - const result = await service.importTokens([ - MOCK_USDC_ETH_ASSET_ID, - MOCK_USDC_POLYGON_ASSET_ID, - ]); + const result = await service[method]([...ids]); - expect(result.importedAssets).toStrictEqual([ - MOCK_USDC_ETH_ASSET_ID, - MOCK_USDC_BASE_ASSET_ID, - MOCK_USDC_POLYGON_ASSET_ID, - ]); - }); + expect(result[addedList]).toStrictEqual(expectedList); + }, + ); - it('removes imported tokens from hiddenAssets (mutual exclusivity)', async () => { - handleMockGetUserAssets(); - handleMockSetUserAssets(undefined, async (_, requestBody) => { - expect(requestBody).toStrictEqual({ + it.each([ + { + method: 'importTokens', + ids: [MOCK_USDC_OP_ASSET_ID], + expectedRequest: { version: 1, importedAssets: [ MOCK_USDC_ETH_ASSET_ID, @@ -859,139 +902,72 @@ describe('AuthenticatedUserStorageService', () => { MOCK_USDC_OP_ASSET_ID, ], hiddenAssets: [], - }); - }); - const { service } = createService(); - - const result = await service.importTokens([MOCK_USDC_OP_ASSET_ID]); - - expect(result.importedAssets).toContain(MOCK_USDC_OP_ASSET_ID); - expect(result.hiddenAssets).toStrictEqual([]); - }); - - it('throws before any request when an entry is not a CAIP-19 asset identifier', async () => { - const getScope = nock(MOCK_USER_ASSETS_URL) - .get('') - .reply(200, MOCK_USER_ASSETS_BLOB); - const { service } = createService(); - - await expect( - service.importTokens([MOCK_INVALID_ASSET_ID]), - ).rejects.toThrow( - /At path: 0 -- Expected a value of type `CaipAssetType`/u, - ); - - expect(getScope.isDone()).toBe(false); - }); - - it('includes X-Client-Type header when clientType is provided', async () => { - handleMockGetUserAssets(); - const scope = nock(MOCK_USER_ASSETS_URL, { - reqheaders: { - 'x-client-type': 'extension', }, - }) - .put('') - .reply(200); - const { service } = createService(); - - await service.importTokens([MOCK_USDC_ETH_ASSET_ID], 'extension'); - - expect(scope.isDone()).toBe(true); - }); - }); - - describe('hideTokens', () => { - it('creates a fresh blob when none exists (404)', async () => { - handleMockGetUserAssets({ status: 404 }); - handleMockSetUserAssets(undefined, async (_, requestBody) => { - expect(requestBody).toStrictEqual({ - version: 1, - importedAssets: [], - hiddenAssets: [MOCK_USDC_OP_ASSET_ID], - }); - }); - const { service } = createService(); - - const result = await service.hideTokens([MOCK_USDC_OP_ASSET_ID]); - - expect(result).toStrictEqual({ - version: 1, - importedAssets: [], - hiddenAssets: [MOCK_USDC_OP_ASSET_ID], - }); - }); - - it('merges into the existing hidden list, de-duplicating entries', async () => { - handleMockGetUserAssets(); - handleMockSetUserAssets(undefined, async (_, requestBody) => { - expect(requestBody).toStrictEqual({ - version: 1, - importedAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_BASE_ASSET_ID], - hiddenAssets: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_POLYGON_ASSET_ID], - }); - }); - const { service } = createService(); - - const result = await service.hideTokens([ - MOCK_USDC_OP_ASSET_ID, - MOCK_USDC_POLYGON_ASSET_ID, - ]); - - expect(result.hiddenAssets).toStrictEqual([ - MOCK_USDC_OP_ASSET_ID, - MOCK_USDC_POLYGON_ASSET_ID, - ]); - }); - - it('removes hidden tokens from importedAssets (mutual exclusivity)', async () => { - handleMockGetUserAssets(); - handleMockSetUserAssets(undefined, async (_, requestBody) => { - expect(requestBody).toStrictEqual({ + }, + { + method: 'hideTokens', + ids: [MOCK_USDC_ETH_ASSET_ID], + expectedRequest: { version: 1, importedAssets: [MOCK_USDC_BASE_ASSET_ID], hiddenAssets: [MOCK_USDC_OP_ASSET_ID, MOCK_USDC_ETH_ASSET_ID], + }, + }, + ] as const)( + '$method removes the tokens from the opposite list (mutual exclusivity)', + async ({ method, ids, expectedRequest }) => { + handleMockGetUserAssets(); + handleMockSetUserAssets(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(expectedRequest); }); - }); - const { service } = createService(); + const { service } = createService(); - const result = await service.hideTokens([MOCK_USDC_ETH_ASSET_ID]); + const result = await service[method]([...ids]); - expect(result.hiddenAssets).toStrictEqual([ - MOCK_USDC_OP_ASSET_ID, - MOCK_USDC_ETH_ASSET_ID, - ]); - expect(result.importedAssets).toStrictEqual([MOCK_USDC_BASE_ASSET_ID]); - }); - - it('throws before any request when an entry is not a CAIP-19 asset identifier', async () => { - const getScope = nock(MOCK_USER_ASSETS_URL) - .get('') - .reply(200, MOCK_USER_ASSETS_BLOB); - const { service } = createService(); + expect(result.importedAssets).toStrictEqual( + expectedRequest.importedAssets, + ); + expect(result.hiddenAssets).toStrictEqual(expectedRequest.hiddenAssets); + }, + ); - await expect(service.hideTokens([MOCK_INVALID_ASSET_ID])).rejects.toThrow( - /At path: 0 -- Expected a value of type `CaipAssetType`/u, - ); + it.each(['importTokens', 'hideTokens'] as const)( + '%s throws before any request when an entry is not a CAIP-19 asset identifier', + async (method) => { + const getScope = nock(MOCK_USER_ASSETS_URL) + .get('') + .reply(200, MOCK_USER_ASSETS_BLOB); + const { service } = createService(); - expect(getScope.isDone()).toBe(false); - }); + await expect(service[method]([MOCK_INVALID_ASSET_ID])).rejects.toThrow( + /At path: 0 -- Expected a value of type `CaipAssetType`/u, + ); - it('includes X-Client-Type header when clientType is provided', async () => { - handleMockGetUserAssets(); - const scope = nock(MOCK_USER_ASSETS_URL, { - reqheaders: { - 'x-client-type': 'mobile', - }, - }) - .put('') - .reply(200); - const { service } = createService(); - - await service.hideTokens([MOCK_USDC_ETH_ASSET_ID], 'mobile'); + expect(getScope.isDone()).toBe(false); + }, + ); - expect(scope.isDone()).toBe(true); - }); + it.each([ + { method: 'importTokens', clientType: 'extension' }, + { method: 'hideTokens', clientType: 'mobile' }, + ] as const)( + '$method includes the X-Client-Type header when clientType is provided', + async ({ method, clientType }) => { + handleMockGetUserAssets(); + const scope = nock(MOCK_USER_ASSETS_URL, { + reqheaders: { + 'x-client-type': clientType, + }, + }) + .put('') + .reply(200); + const { service } = createService(); + + await service[method]([MOCK_USDC_ETH_ASSET_ID], clientType); + + expect(scope.isDone()).toBe(true); + }, + ); }); describe('cache invalidation', () => { @@ -1074,18 +1050,46 @@ describe('AuthenticatedUserStorageService', () => { expect(second).toStrictEqual(updatedBlob); }); - it('invalidates getUserAssets cache after setUserAssets', async () => { - handleMockSetUserAssets(); - handleMockGetUserAssets(); - const { service } = createService(); - const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); - - await service.setUserAssets(MOCK_USER_ASSETS_BLOB); - - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ['AuthenticatedUserStorageService:getUserAssets'], - }); - }); + it.each([ + { + mutator: 'setUserAssets', + seedGet: false, + run: (service: AuthenticatedUserStorageService): Promise => + service.setUserAssets(MOCK_USER_ASSETS_BLOB), + }, + { + mutator: 'importTokens', + seedGet: true, + run: ( + service: AuthenticatedUserStorageService, + ): Promise => + service.importTokens([MOCK_USDC_POLYGON_ASSET_ID]), + }, + { + mutator: 'hideTokens', + seedGet: true, + run: ( + service: AuthenticatedUserStorageService, + ): Promise => + service.hideTokens([MOCK_USDC_ETH_ASSET_ID]), + }, + ])( + 'invalidates the getUserAssets cache after $mutator', + async ({ seedGet, run }) => { + if (seedGet) { + handleMockGetUserAssets(); + } + handleMockSetUserAssets(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await run(service); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:getUserAssets'], + }); + }, + ); it('causes a subsequent getUserAssets to refetch after setUserAssets', async () => { const updatedBlob = { @@ -1110,32 +1114,6 @@ describe('AuthenticatedUserStorageService', () => { expect(first).toStrictEqual(MOCK_USER_ASSETS_BLOB); expect(second).toStrictEqual(updatedBlob); }); - - it('invalidates getUserAssets cache after importTokens', async () => { - handleMockGetUserAssets(); - handleMockSetUserAssets(); - const { service } = createService(); - const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); - - await service.importTokens([MOCK_USDC_POLYGON_ASSET_ID]); - - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ['AuthenticatedUserStorageService:getUserAssets'], - }); - }); - - it('invalidates getUserAssets cache after hideTokens', async () => { - handleMockGetUserAssets(); - handleMockSetUserAssets(); - const { service } = createService(); - const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); - - await service.hideTokens([MOCK_USDC_ETH_ASSET_ID]); - - expect(invalidateSpy).toHaveBeenCalledWith({ - queryKey: ['AuthenticatedUserStorageService:getUserAssets'], - }); - }); }); describe('authorization', () => { From 78fdd35a8c73ee3ea6f48559c690cf445e1b3daa Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Mon, 14 Sep 2026 21:58:11 +0100 Subject: [PATCH 5/5] feat(authenticated-user-storage): enforce mutual exclusivity on user-assets writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on #10233: add a UserAssetsBlobNormalizedWriteSchema refinement asserting no identifier appears in both importedAssets and hiddenAssets, applied to the normalized blob right before the write. Structural validation of the raw input still fails fast, but the exclusivity check runs after fail-open normalization, so a write can never be rejected because of a conflict — it only guards against internal logic bugs. --- .../src/authenticated-user-storage.test.ts | 25 ++++++++++++++++++ .../src/authenticated-user-storage.ts | 3 +++ .../src/validators.ts | 26 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts index ed21e10de39..7845bca66ea 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts @@ -38,6 +38,7 @@ import { getUserStorageApiUrl } from './env.js'; import type { UserAssetsBlob } from './types.js'; import { ASSETS_WATCHLIST_MAX_ASSETS, + assertUserAssetsBlobNormalized, normalizeUserAssetsBlob, } from './validators.js'; @@ -123,6 +124,30 @@ describe('normalizeUserAssetsBlob()', () => { }); }); +describe('assertUserAssetsBlobNormalized', () => { + it('accepts a blob with no identifier in both lists', () => { + expect(() => + assertUserAssetsBlobNormalized({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [MOCK_USDC_OP_ASSET_ID], + }), + ).not.toThrow(); + }); + + it('rejects a blob with an identifier in both lists', () => { + expect(() => + assertUserAssetsBlobNormalized({ + version: 1, + importedAssets: [MOCK_USDC_ETH_ASSET_ID], + hiddenAssets: [MOCK_USDC_ETH_ASSET_ID, MOCK_USDC_OP_ASSET_ID], + }), + ).toThrow( + 'An identifier may not appear in both importedAssets and hiddenAssets', + ); + }); +}); + describe('AuthenticatedUserStorageService', () => { afterEach(() => { nock.cleanAll(); // eslint-disable-line import-x/no-named-as-default-member diff --git a/packages/authenticated-user-storage/src/authenticated-user-storage.ts b/packages/authenticated-user-storage/src/authenticated-user-storage.ts index 918d9b58149..534795bae64 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.ts @@ -28,6 +28,7 @@ import { assertUserAssetIds, assertUserAssetsBlob, assertUserAssetsBlobForWrite, + assertUserAssetsBlobNormalized, normalizeUserAssetsBlob, } from './validators.js'; @@ -494,6 +495,8 @@ export class AuthenticatedUserStorageService extends BaseDataService< ): Promise { assertUserAssetsBlobForWrite(blob); const normalizedBlob = normalizeUserAssetsBlob(blob); + // Cannot reject user input: normalization already resolved conflicts. + assertUserAssetsBlobNormalized(normalizedBlob); const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/user-assets`; diff --git a/packages/authenticated-user-storage/src/validators.ts b/packages/authenticated-user-storage/src/validators.ts index a86cfa71fe7..2c771fdd61a 100644 --- a/packages/authenticated-user-storage/src/validators.ts +++ b/packages/authenticated-user-storage/src/validators.ts @@ -8,6 +8,7 @@ import { number, optional, pattern, + refine, size, string, type, @@ -260,6 +261,20 @@ const UserAssetsBlobWriteSchema = type({ hiddenAssets: array(CaipAssetTypeStruct), }); +/** + * Normalized write schema: additionally enforces mutual exclusivity — no + * identifier may appear in both lists. Applied to the normalized blob only, + * so it can never reject user input; normalization resolves conflicts first. + */ +const UserAssetsBlobNormalizedWriteSchema = refine( + UserAssetsBlobWriteSchema, + 'MutuallyExclusiveUserAssets', + (blob) => + blob.hiddenAssets.every( + (assetId) => !blob.importedAssets.includes(assetId), + ) || 'An identifier may not appear in both importedAssets and hiddenAssets', +); + /** * The authenticated user's custom tokens: mutually exclusive lists of * CAIP-19 asset identifiers the user chose to import or hide. @@ -291,6 +306,17 @@ export function assertUserAssetsBlobForWrite( assert(data, UserAssetsBlobWriteSchema); } +/** + * Asserts that a normalized `UserAssetsBlob` is safe to send: structurally + * valid and mutually exclusive (no identifier in both lists). + * + * @param blob - The normalized blob to validate. + * @throws A `StructError` if the blob is invalid or contains a conflict. + */ +export function assertUserAssetsBlobNormalized(blob: UserAssetsBlob): void { + assert(blob, UserAssetsBlobNormalizedWriteSchema); +} + /** * Asserts that every entry is a CAIP-19 asset identifier. *