diff --git a/packages/authenticated-user-storage/CHANGELOG.md b/packages/authenticated-user-storage/CHANGELOG.md index f01e3dfe14..a1081ed800 100644 --- a/packages/authenticated-user-storage/CHANGELOG.md +++ b/packages/authenticated-user-storage/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getAssetsWatchlist` and `setAssetsWatchlist` methods to `AuthenticatedUserStorageService` for managing the authenticated user's assets-watchlist, along with corresponding messenger actions (`AuthenticatedUserStorageService:getAssetsWatchlist`, `AuthenticatedUserStorageService:setAssetsWatchlist`), the `AssetsWatchlistBlob` type, and the `ASSETS_WATCHLIST_MAX_ASSETS` constant ([#8836](https://github.com/MetaMask/core/pull/8836)) + - `getAssetsWatchlist` returns the assets-watchlist blob or `null` on 404, mirroring `getNotificationPreferences`. + - `setAssetsWatchlist` writes the full blob and enforces a maximum of `ASSETS_WATCHLIST_MAX_ASSETS` (100) assets before sending the request, via a superstruct `size` constraint on the write-side schema. + ## [2.0.0] ### Changed diff --git a/packages/authenticated-user-storage/README.md b/packages/authenticated-user-storage/README.md index fd9c53ccc7..5feef69789 100644 --- a/packages/authenticated-user-storage/README.md +++ b/packages/authenticated-user-storage/README.md @@ -2,10 +2,11 @@ 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 two domains: +The SDK currently supports three 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). ## Installation @@ -109,6 +110,30 @@ const updated: NotificationPreferences = { await service.putNotificationPreferences(updated, 'extension'); ``` +### Assets watchlist + +The assets-watchlist is a mutable per-user singleton blob. The first call to `setAssetsWatchlist` creates the record; subsequent calls overwrite it. Each entry in `assets` 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 SDK enforces a maximum of `ASSETS_WATCHLIST_MAX_ASSETS` (100) entries on writes; oversized blobs throw a superstruct `StructError` before the request is sent. + +```typescript +import { ASSETS_WATCHLIST_MAX_ASSETS } from '@metamask/authenticated-user-storage'; +import type { AssetsWatchlistBlob } from '@metamask/authenticated-user-storage'; + +// Retrieve the current assets-watchlist (returns null on the first read) +const watchlist = await service.getAssetsWatchlist(); + +// Create or update the assets-watchlist +const updated: AssetsWatchlistBlob = { + version: 1, + assets: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], +}; +await service.setAssetsWatchlist(updated, '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 365ab1b2f4..b400de367c 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 @@ -58,6 +58,33 @@ export type AuthenticatedUserStorageServicePutNotificationPreferencesAction = { handler: AuthenticatedUserStorageService['putNotificationPreferences']; }; +/** + * Returns the assets-watchlist for the authenticated user. + * + * @returns The assets-watchlist blob, or `null` if none has been set (404). + */ +export type AuthenticatedUserStorageServiceGetAssetsWatchlistAction = { + type: `AuthenticatedUserStorageService:getAssetsWatchlist`; + handler: AuthenticatedUserStorageService['getAssetsWatchlist']; +}; + +/** + * Creates or updates the assets-watchlist for the authenticated user. + * + * @param blob - The full assets-watchlist blob. The `assets` array may + * contain at most `ASSETS_WATCHLIST_MAX_ASSETS` CAIP-19 asset identifiers; + * this is enforced by `assertAssetsWatchlistBlobForWrite` before the + * request is sent. + * @param clientType - Optional client type header. + * @throws A `StructError` from `@metamask/superstruct` if `blob` is + * structurally invalid or `assets` exceeds the cap; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ +export type AuthenticatedUserStorageServiceSetAssetsWatchlistAction = { + type: `AuthenticatedUserStorageService:setAssetsWatchlist`; + handler: AuthenticatedUserStorageService['setAssetsWatchlist']; +}; + /** * Union of all AuthenticatedUserStorageService action types. */ @@ -66,4 +93,6 @@ export type AuthenticatedUserStorageServiceMethodActions = | AuthenticatedUserStorageServiceCreateDelegationAction | AuthenticatedUserStorageServiceRevokeDelegationAction | AuthenticatedUserStorageServiceGetNotificationPreferencesAction - | AuthenticatedUserStorageServicePutNotificationPreferencesAction; + | AuthenticatedUserStorageServicePutNotificationPreferencesAction + | AuthenticatedUserStorageServiceGetAssetsWatchlistAction + | AuthenticatedUserStorageServiceSetAssetsWatchlistAction; 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 84cde245b2..6b8ed45cda 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.test.ts @@ -12,11 +12,16 @@ import { handleMockRevokeDelegation, handleMockGetNotificationPreferences, handleMockPutNotificationPreferences, + handleMockGetAssetsWatchlist, + handleMockSetAssetsWatchlist, } from '../tests/fixtures/authenticated-userstorage'; import { MOCK_DELEGATION_RESPONSE, MOCK_DELEGATION_SUBMISSION, + MOCK_INVALID_ASSETS_WATCHLIST_BLOB, MOCK_NOTIFICATION_PREFERENCES, + MOCK_ASSETS_WATCHLIST_BLOB, + MOCK_ASSETS_WATCHLIST_URL, } from '../tests/mocks/authenticated-userstorage'; import type { AuthenticatedUserStorageMessenger } from './authenticated-user-storage'; import { @@ -25,6 +30,7 @@ import { } from './authenticated-user-storage'; import type { Environment } from './env'; import { getUserStorageApiUrl } from './env'; +import { ASSETS_WATCHLIST_MAX_ASSETS } from './validators'; const MOCK_ACCESS_TOKEN = 'mock-access-token'; @@ -239,6 +245,224 @@ describe('AuthenticatedUserStorageService', () => { }); }); + describe('AuthenticatedUserStorageService:getAssetsWatchlist', () => { + it('returns the assets-watchlist via the messenger', async () => { + handleMockGetAssetsWatchlist(); + const { rootMessenger } = createService(); + + const result = await rootMessenger.call( + 'AuthenticatedUserStorageService:getAssetsWatchlist', + ); + + expect(result).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + }); + + describe('AuthenticatedUserStorageService:setAssetsWatchlist', () => { + it('sets the assets-watchlist via the messenger', async () => { + const mock = handleMockSetAssetsWatchlist(); + const { rootMessenger } = createService(); + + await rootMessenger.call( + 'AuthenticatedUserStorageService:setAssetsWatchlist', + MOCK_ASSETS_WATCHLIST_BLOB, + ); + + expect(mock.isDone()).toBe(true); + }); + }); + + describe('getAssetsWatchlist', () => { + it('returns the assets-watchlist from the API', async () => { + const mock = handleMockGetAssetsWatchlist(); + const { service } = createService(); + + const result = await service.getAssetsWatchlist(); + + expect(mock.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + + it('sends the Authorization header', async () => { + const scope = nock(MOCK_ASSETS_WATCHLIST_URL, { + reqheaders: { + authorization: 'Bearer mock-access-token', + }, + }) + .get('') + .reply(200, MOCK_ASSETS_WATCHLIST_BLOB); + + const { service } = createService(); + const result = await service.getAssetsWatchlist(); + + expect(scope.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + + it('returns null when the assets-watchlist is not found', async () => { + handleMockGetAssetsWatchlist({ status: 404 }); + const { service } = createService(); + + const result = await service.getAssetsWatchlist(); + + expect(result).toBeNull(); + }); + + it('throws when the API returns a non-200/404 status', async () => { + handleMockGetAssetsWatchlist({ status: 500 }); + const { service } = createService(); + + await expect(service.getAssetsWatchlist()).rejects.toThrow( + 'Failed to get assets watchlist: 500', + ); + }); + + it('throws when the API returns a 401', async () => { + handleMockGetAssetsWatchlist({ status: 401 }); + const { service } = createService(); + + await expect(service.getAssetsWatchlist()).rejects.toThrow( + 'Failed to get assets watchlist: 401', + ); + }); + + it('throws when the response body is malformed', async () => { + handleMockGetAssetsWatchlist({ + status: 200, + body: MOCK_INVALID_ASSETS_WATCHLIST_BLOB, + }); + const { service } = createService(); + + await expect(service.getAssetsWatchlist()).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_ASSETS_WATCHLIST_URL) + .get('') + .once() + .reply(200, MOCK_ASSETS_WATCHLIST_BLOB); + const { service } = createService(); + + const first = await service.getAssetsWatchlist(); + const second = await service.getAssetsWatchlist(); + + expect(scope.isDone()).toBe(true); + expect(first).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + expect(second).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + }); + + describe('setAssetsWatchlist', () => { + it('submits the assets-watchlist to the API', async () => { + const mock = handleMockSetAssetsWatchlist(); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + + expect(mock.isDone()).toBe(true); + }); + + it('sends the correct request body', async () => { + handleMockSetAssetsWatchlist(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(MOCK_ASSETS_WATCHLIST_BLOB); + }); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + }); + + it('sends Content-Type and Authorization headers but no X-Client-Type when clientType is omitted', async () => { + const scope = nock(MOCK_ASSETS_WATCHLIST_URL, { + reqheaders: { + 'content-type': 'application/json', + authorization: 'Bearer mock-access-token', + }, + badheaders: ['x-client-type'], + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + + expect(scope.isDone()).toBe(true); + }); + + it('includes X-Client-Type header when clientType is provided', async () => { + const scope = nock(MOCK_ASSETS_WATCHLIST_URL, { + reqheaders: { + 'x-client-type': 'extension', + }, + }) + .put('') + .reply(200); + const { service } = createService(); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB, 'extension'); + + expect(scope.isDone()).toBe(true); + }); + + it('throws when the API returns a non-200 status', async () => { + handleMockSetAssetsWatchlist({ status: 400 }); + const { service } = createService(); + + await expect( + service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB), + ).rejects.toThrow('Failed to put assets watchlist: 400'); + }); + + it(`throws synchronously when the blob exceeds ${ASSETS_WATCHLIST_MAX_ASSETS} assets`, async () => { + const { service } = createService(); + const oversized = { + version: 1 as const, + assets: Array.from( + { length: ASSETS_WATCHLIST_MAX_ASSETS + 1 }, + (_, index) => + `eip155:1/erc20:0x${index.toString(16).padStart(40, '0')}`, + ), + }; + + await expect(service.setAssetsWatchlist(oversized)).rejects.toThrow( + new RegExp( + `At path: assets -- Expected a array with a length between \`0\` and \`${ASSETS_WATCHLIST_MAX_ASSETS}\` but received one with a length of \`${ASSETS_WATCHLIST_MAX_ASSETS + 1}\``, + 'u', + ), + ); + }); + + it('throws a structural error before sending the request when the blob is malformed', async () => { + const { service } = createService(); + const malformed = { + version: 2, + assets: ['eip155:1/slip44:60'], + } as unknown as Parameters[0]; + + await expect(service.setAssetsWatchlist(malformed)).rejects.toThrow( + /At path: version -- Expected the literal/u, + ); + }); + + it(`accepts a blob with exactly ${ASSETS_WATCHLIST_MAX_ASSETS} assets`, async () => { + const mock = handleMockSetAssetsWatchlist(); + const { service } = createService(); + const maxBlob = { + version: 1 as const, + assets: Array.from( + { length: ASSETS_WATCHLIST_MAX_ASSETS }, + (_, index) => + `eip155:1/erc20:0x${index.toString(16).padStart(40, '0')}`, + ), + }; + + await service.setAssetsWatchlist(maxBlob); + + expect(mock.isDone()).toBe(true); + }); + }); + describe('cache invalidation', () => { it('invalidates listDelegations cache after createDelegation', async () => { handleMockCreateDelegation(); @@ -282,6 +506,42 @@ describe('AuthenticatedUserStorageService', () => { ], }); }); + + it('invalidates getAssetsWatchlist cache after setAssetsWatchlist', async () => { + handleMockSetAssetsWatchlist(); + handleMockGetAssetsWatchlist(); + const { service } = createService(); + const invalidateSpy = jest.spyOn(service, 'invalidateQueries'); + + await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB); + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['AuthenticatedUserStorageService:getAssetsWatchlist'], + }); + }); + + it('causes a subsequent getAssetsWatchlist to refetch after setAssetsWatchlist', async () => { + const updatedBlob = { + version: 1 as const, + assets: ['eip155:137/slip44:966'], + }; + const getScope = nock(MOCK_ASSETS_WATCHLIST_URL) + .get('') + .reply(200, MOCK_ASSETS_WATCHLIST_BLOB) + .put('') + .reply(200) + .get('') + .reply(200, updatedBlob); + + const { service } = createService(); + const first = await service.getAssetsWatchlist(); + await service.setAssetsWatchlist(updatedBlob); + const second = await service.getAssetsWatchlist(); + + expect(getScope.isDone()).toBe(true); + expect(first).toStrictEqual(MOCK_ASSETS_WATCHLIST_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 5991335478..2dd3bb29ad 100644 --- a/packages/authenticated-user-storage/src/authenticated-user-storage.ts +++ b/packages/authenticated-user-storage/src/authenticated-user-storage.ts @@ -13,12 +13,15 @@ import type { AuthenticatedUserStorageServiceMethodActions } from './authenticat import type { Environment } from './env'; import { getUserStorageApiUrl } from './env'; import type { + AssetsWatchlistBlob, ClientType, DelegationResponse, DelegationSubmission, NotificationPreferences, } from './types'; import { + assertAssetsWatchlistBlob, + assertAssetsWatchlistBlobForWrite, assertDelegationResponseArray, assertNotificationPreferences, } from './validators'; @@ -49,6 +52,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'revokeDelegation', 'getNotificationPreferences', 'putNotificationPreferences', + 'getAssetsWatchlist', + 'setAssetsWatchlist', ] as const; /** @@ -343,6 +348,90 @@ export class AuthenticatedUserStorageService extends BaseDataService< }); } + /** + * Returns the assets-watchlist for the authenticated user. + * + * @returns The assets-watchlist blob, or `null` if none has been set (404). + */ + async getAssetsWatchlist(): Promise { + const url = `${getAuthenticatedStorageUrl(this.#environment)}/assets-watchlist`; + + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getAssetsWatchlist`], + 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 assets watchlist: ${response.status}`, + ); + } + + return response.json(); + }, + }); + + if (data === null) { + return null; + } + + assertAssetsWatchlistBlob(data); + return data; + } + + /** + * Creates or updates the assets-watchlist for the authenticated user. + * + * @param blob - The full assets-watchlist blob. The `assets` array may + * contain at most `ASSETS_WATCHLIST_MAX_ASSETS` CAIP-19 asset identifiers; + * this is enforced by `assertAssetsWatchlistBlobForWrite` before the + * request is sent. + * @param clientType - Optional client type header. + * @throws A `StructError` from `@metamask/superstruct` if `blob` is + * structurally invalid or `assets` exceeds the cap; an `HttpError` from + * `@metamask/controller-utils` if the API responds with a non-2xx status. + */ + async setAssetsWatchlist( + blob: AssetsWatchlistBlob, + clientType?: ClientType, + ): Promise { + assertAssetsWatchlistBlobForWrite(blob); + + const url = `${getAuthenticatedStorageUrl(this.#environment)}/assets-watchlist`; + + await this.fetchQuery({ + queryKey: [`${this.name}:setAssetsWatchlist`, blob 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(blob), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Failed to put assets watchlist: ${response.status}`, + ); + } + + return null; + }, + }); + + await this.invalidateQueries({ + queryKey: [`${this.name}:getAssetsWatchlist`], + }); + } + 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 94d1579699..74bc6d2624 100644 --- a/packages/authenticated-user-storage/src/index.ts +++ b/packages/authenticated-user-storage/src/index.ts @@ -2,6 +2,7 @@ export { getAuthenticatedStorageUrl, AuthenticatedUserStorageService, } from './authenticated-user-storage'; +export { ASSETS_WATCHLIST_MAX_ASSETS } from './validators'; export type { AuthenticatedUserStorageActions, AuthenticatedUserStorageCacheUpdatedEvent, @@ -16,6 +17,8 @@ export type { AuthenticatedUserStorageServiceRevokeDelegationAction, AuthenticatedUserStorageServiceGetNotificationPreferencesAction, AuthenticatedUserStorageServicePutNotificationPreferencesAction, + AuthenticatedUserStorageServiceGetAssetsWatchlistAction, + AuthenticatedUserStorageServiceSetAssetsWatchlistAction, } from './authenticated-user-storage-method-action-types'; export { getUserStorageApiUrl } from './env'; export type { Environment } from './env'; @@ -33,5 +36,6 @@ export type { PerpsPreference, SocialAIPreference, NotificationPreferences, + AssetsWatchlistBlob, ClientType, } from './types'; diff --git a/packages/authenticated-user-storage/src/types.ts b/packages/authenticated-user-storage/src/types.ts index e263160d12..b57c7e6aaa 100644 --- a/packages/authenticated-user-storage/src/types.ts +++ b/packages/authenticated-user-storage/src/types.ts @@ -111,6 +111,16 @@ export type NotificationPreferences = { socialAI: SocialAIPreference; }; +// --------------------------------------------------------------------------- +// Assets watchlist +// --------------------------------------------------------------------------- + +// `AssetsWatchlistBlob` is inferred from `AssetsWatchlistBlobSchema` 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 { AssetsWatchlistBlob } from './validators'; + // --------------------------------------------------------------------------- // Shared // --------------------------------------------------------------------------- diff --git a/packages/authenticated-user-storage/src/validators.ts b/packages/authenticated-user-storage/src/validators.ts index e9bef8850c..0a9b406b9d 100644 --- a/packages/authenticated-user-storage/src/validators.ts +++ b/packages/authenticated-user-storage/src/validators.ts @@ -1,10 +1,14 @@ +import type { Infer } from '@metamask/superstruct'; import { array, assert, + assign, boolean, + literal, number, optional, pattern, + size, string, type, } from '@metamask/superstruct'; @@ -94,6 +98,57 @@ const NotificationPreferencesSchema = type({ socialAI: SocialAIPreferenceSchema, }); +/** + * Maximum number of entries allowed in an assets-watchlist on write. Reads + * are lenient: a server payload exceeding this size will still validate as + * an `AssetsWatchlistBlob`. Encoded into + * {@link AssetsWatchlistBlobWriteSchema}. + */ +export const ASSETS_WATCHLIST_MAX_ASSETS = 100; + +/** + * The shape we accept on the way **in** from the server. Lenient by design: + * a malformed payload throws, but a well-formed payload with more than + * {@link ASSETS_WATCHLIST_MAX_ASSETS} assets is still considered valid so we + * don't reject existing server-side data. + */ +const AssetsWatchlistBlobSchema = type({ + version: literal(1), + assets: array(string()), +}); + +/** + * The shape we accept on the way **out** to the server. Extends + * {@link AssetsWatchlistBlobSchema} with a hard cap on `assets.length`. + * Validation failures throw a `StructError`, e.g. + * `"At path: assets -- Expected a array with a length between \`0\` and + * \`100\` but received one with a length of \`N\`"`. + */ +const AssetsWatchlistBlobWriteSchema = assign( + AssetsWatchlistBlobSchema, + type({ + assets: size(array(string()), 0, ASSETS_WATCHLIST_MAX_ASSETS), + }), +); + +/** + * The authenticated user's assets-watchlist: a mutable per-user singleton + * blob. + * + * 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 `assets` element shape. + * + * Inferred from {@link AssetsWatchlistBlobSchema} so the runtime schema and + * the static type stay in lock-step. The size constraint on writes is + * enforced by {@link AssetsWatchlistBlobWriteSchema} and is not encoded in + * this static type (TypeScript cannot express "array of length ≤ N"). + */ +export type AssetsWatchlistBlob = Infer; + /** * Asserts that the given value is a valid `DelegationResponse[]`. * @@ -117,3 +172,32 @@ export function assertNotificationPreferences( ): asserts data is NotificationPreferences { assert(data, NotificationPreferencesSchema); } + +/** + * Asserts that the given value is a valid `AssetsWatchlistBlob` (read-side, + * lenient). + * + * @param data - The unknown value to validate. + * @throws If the value does not match the expected schema. + */ +export function assertAssetsWatchlistBlob( + data: unknown, +): asserts data is AssetsWatchlistBlob { + assert(data, AssetsWatchlistBlobSchema); +} + +/** + * Asserts that the given value is a valid `AssetsWatchlistBlob` for + * **writes**. In addition to the structural checks performed by + * {@link assertAssetsWatchlistBlob}, this enforces that `assets` contains at + * most {@link ASSETS_WATCHLIST_MAX_ASSETS} entries. + * + * @param data - The unknown value to validate. + * @throws A `StructError` if the value does not match the expected schema + * (including the size constraint). + */ +export function assertAssetsWatchlistBlobForWrite( + data: unknown, +): asserts data is AssetsWatchlistBlob { + assert(data, AssetsWatchlistBlobWriteSchema); +} diff --git a/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts b/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts index 196714cb0f..564ab5f1a0 100644 --- a/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts +++ b/packages/authenticated-user-storage/tests/fixtures/authenticated-userstorage.ts @@ -1,6 +1,8 @@ import nock from 'nock'; import { + MOCK_ASSETS_WATCHLIST_BLOB, + MOCK_ASSETS_WATCHLIST_URL, MOCK_DELEGATIONS_URL, MOCK_DELEGATION_RESPONSE, MOCK_NOTIFICATION_PREFERENCES, @@ -73,3 +75,31 @@ export function handleMockPutNotificationPreferences( } return interceptor.reply(reply.status, reply.body); } + +export function handleMockGetAssetsWatchlist( + mockReply?: MockReply, +): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: MOCK_ASSETS_WATCHLIST_BLOB, + }; + return nock(MOCK_ASSETS_WATCHLIST_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +} + +export function handleMockSetAssetsWatchlist( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +): nock.Scope { + const reply = mockReply ?? { status: 200 }; + const interceptor = nock(MOCK_ASSETS_WATCHLIST_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 39f42beb72..871f5f5149 100644 --- a/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts +++ b/packages/authenticated-user-storage/tests/mocks/authenticated-userstorage.ts @@ -1,5 +1,6 @@ import { getAuthenticatedStorageUrl } from '../../src/authenticated-user-storage'; import type { + AssetsWatchlistBlob, DelegationResponse, DelegationSubmission, NotificationPreferences, @@ -7,6 +8,7 @@ import type { 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')}/assets-watchlist`; export const MOCK_DELEGATION_SUBMISSION: DelegationSubmission = { signedDelegation: { @@ -67,3 +69,16 @@ export const MOCK_NOTIFICATION_PREFERENCES: NotificationPreferences = { ], }, }; + +export const MOCK_ASSETS_WATCHLIST_BLOB: AssetsWatchlistBlob = { + version: 1, + assets: [ + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + 'eip155:1/slip44:60', + ], +}; + +export const MOCK_INVALID_ASSETS_WATCHLIST_BLOB = { + version: 2, + assets: 'not-an-array', +} as const;