diff --git a/packages/authenticated-user-storage/CHANGELOG.md b/packages/authenticated-user-storage/CHANGELOG.md index 9118b952bfd..4e400f79995 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 ([#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. + - 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..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 @@ -85,6 +85,63 @@ 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 (de-duplicated, conflicts + * resolved fail-open in favor of `importedAssets`) before it is sent. + * + * @param blob - The full user-assets blob, with CAIP-19 asset identifiers. + * @param clientType - Optional client type header. + * @throws A `StructError` if `blob` is structurally invalid; an `HttpError` + * if the API responds with a non-2xx status. + */ +export type AuthenticatedUserStorageServiceSetUserAssetsAction = { + type: `AuthenticatedUserStorageService:setUserAssets`; + handler: AuthenticatedUserStorageService['setUserAssets']; +}; + +/** + * 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. + * @param clientType - Optional client type header. + * @returns The resolved user-assets blob that was persisted. + * @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`; + handler: AuthenticatedUserStorageService['importTokens']; +}; + +/** + * 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. + * + * @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` 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`; + handler: AuthenticatedUserStorageService['hideTokens']; +}; + /** * Union of all AuthenticatedUserStorageService action types. */ @@ -95,4 +152,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..7845bca66ea 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,25 @@ 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 type { UserAssetsBlob } from './types.js'; +import { + ASSETS_WATCHLIST_MAX_ASSETS, + assertUserAssetsBlobNormalized, + 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 +74,80 @@ 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('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 @@ -463,6 +557,444 @@ 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.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: ${status}`, + ); + }); + + 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.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) => { + 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 / 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 result = await service[method]([...ids]); + + expect(result).toStrictEqual(expectedBlob); + }, + ); + + 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, + MOCK_USDC_BASE_ASSET_ID, + 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 result = await service[method]([...ids]); + + expect(result[addedList]).toStrictEqual(expectedList); + }, + ); + + it.each([ + { + method: 'importTokens', + ids: [MOCK_USDC_OP_ASSET_ID], + expectedRequest: { + version: 1, + importedAssets: [ + MOCK_USDC_ETH_ASSET_ID, + MOCK_USDC_BASE_ASSET_ID, + MOCK_USDC_OP_ASSET_ID, + ], + hiddenAssets: [], + }, + }, + { + 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 result = await service[method]([...ids]); + + expect(result.importedAssets).toStrictEqual( + expectedRequest.importedAssets, + ); + expect(result.hiddenAssets).toStrictEqual(expectedRequest.hiddenAssets); + }, + ); + + 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(); + + await expect(service[method]([MOCK_INVALID_ASSET_ID])).rejects.toThrow( + /At path: 0 -- Expected a value of type `CaipAssetType`/u, + ); + + expect(getScope.isDone()).toBe(false); + }, + ); + + 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', () => { it('invalidates listDelegations cache after createDelegation', async () => { handleMockCreateDelegation(); @@ -542,6 +1074,71 @@ describe('AuthenticatedUserStorageService', () => { expect(first).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); expect(second).toStrictEqual(updatedBlob); }); + + 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 = { + 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); + }); }); 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..534795bae64 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.ts @@ -18,12 +18,18 @@ import type { DelegationResponse, DelegationSubmission, NotificationPreferences, + UserAssetsBlob, } from './types.js'; import { assertAssetsWatchlistBlob, assertAssetsWatchlistBlobForWrite, assertDelegationResponseArray, assertNotificationPreferences, + assertUserAssetIds, + assertUserAssetsBlob, + assertUserAssetsBlobForWrite, + assertUserAssetsBlobNormalized, + normalizeUserAssetsBlob, } from './validators.js'; // === GENERAL === @@ -54,6 +60,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'putNotificationPreferences', 'getAssetsWatchlist', 'setAssetsWatchlist', + 'getUserAssets', + 'setUserAssets', + 'importTokens', + 'hideTokens', ] as const; /** @@ -432,6 +442,174 @@ 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 (de-duplicated, conflicts + * resolved fail-open in favor of `importedAssets`) before it is sent. + * + * @param blob - The full user-assets blob, with CAIP-19 asset identifiers. + * @param clientType - Optional client type header. + * @throws A `StructError` if `blob` is structurally invalid; an `HttpError` + * if the API responds with a non-2xx status. + */ + async setUserAssets( + blob: UserAssetsBlob, + clientType?: ClientType, + ): 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`; + + 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: 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. + * @param clientType - Optional client type header. + * @returns The resolved user-assets blob that was persisted. + * @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[], + 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: adds the given identifiers to `hiddenAssets` + * (de-duplicated, order preserved) and removes them from + * `importedAssets`. Creates a fresh blob if none exists yet. + * + * @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` 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[], + 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..66d57cc648e 100644 --- a/packages/authenticated-user-storage/src/types.ts +++ b/packages/authenticated-user-storage/src/types.ts @@ -134,6 +134,13 @@ export type NotificationPreferences = { // one file keeps the two in lock-step. export type { AssetsWatchlistBlob } from './validators.js'; +// --------------------------------------------------------------------------- +// User assets (custom tokens) +// --------------------------------------------------------------------------- + +// Re-exported from './validators' so the public type surface stays in './types'. +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..2c771fdd61a 100644 --- a/packages/authenticated-user-storage/src/validators.ts +++ b/packages/authenticated-user-storage/src/validators.ts @@ -8,10 +8,12 @@ import { number, optional, pattern, + refine, size, string, type, } from '@metamask/superstruct'; +import { CaipAssetTypeStruct } from '@metamask/utils'; import type { AgenticCliPreference, @@ -235,3 +237,109 @@ 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()), +}); + +/** Write-side schema: every entry must be a CAIP-19 asset identifier. */ +const UserAssetsBlobWriteSchema = type({ + version: literal(1), + importedAssets: array(CaipAssetTypeStruct), + 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. + */ +export type UserAssetsBlob = Infer; + +/** + * Asserts that the given value is a `UserAssetsBlob` (lenient read side). + * + * @param data - The 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 `UserAssetsBlob` safe to write, with + * CAIP-19 asset identifiers in both lists. + * + * @param data - The value to validate. + * @throws A `StructError` if the value does not match the expected schema. + */ +export function assertUserAssetsBlobForWrite( + data: unknown, +): asserts data is UserAssetsBlob { + 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. + * + * @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` 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. + */ +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;