Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/authenticated-user-storage/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion packages/authenticated-user-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -66,4 +93,6 @@ export type AuthenticatedUserStorageServiceMethodActions =
| AuthenticatedUserStorageServiceCreateDelegationAction
| AuthenticatedUserStorageServiceRevokeDelegationAction
| AuthenticatedUserStorageServiceGetNotificationPreferencesAction
| AuthenticatedUserStorageServicePutNotificationPreferencesAction;
| AuthenticatedUserStorageServicePutNotificationPreferencesAction
| AuthenticatedUserStorageServiceGetAssetsWatchlistAction
| AuthenticatedUserStorageServiceSetAssetsWatchlistAction;
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';

Expand Down Expand Up @@ -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<typeof service.setAssetsWatchlist>[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();
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading