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
8 changes: 8 additions & 0 deletions packages/authenticated-user-storage/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
44 changes: 43 additions & 1 deletion packages/authenticated-user-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Comment thread
Prithpal-Sooriya marked this conversation as resolved.
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.
*/
Expand All @@ -95,4 +152,8 @@ export type AuthenticatedUserStorageServiceMethodActions =
| AuthenticatedUserStorageServiceGetNotificationPreferencesAction
| AuthenticatedUserStorageServicePutNotificationPreferencesAction
| AuthenticatedUserStorageServiceGetAssetsWatchlistAction
| AuthenticatedUserStorageServiceSetAssetsWatchlistAction;
| AuthenticatedUserStorageServiceSetAssetsWatchlistAction
| AuthenticatedUserStorageServiceGetUserAssetsAction
| AuthenticatedUserStorageServiceSetUserAssetsAction
| AuthenticatedUserStorageServiceImportTokensAction
| AuthenticatedUserStorageServiceHideTokensAction;
Loading