Skip to content

feat(authenticated-user-storage): add user custom tokens feature (imported/hidden assets) - #10233

Open
Prithpal-Sooriya wants to merge 5 commits into
mainfrom
feature/act-as-a-senior-soft-rhj
Open

Prithpal-Sooriya wants to merge 5 commits into
mainfrom
feature/act-as-a-senior-soft-rhj

Conversation

@Prithpal-Sooriya

@Prithpal-Sooriya Prithpal-Sooriya commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Jira: ASSETS-3937
Pattern references: #8836 (assets-watchlist domain), #9441 (/preferences/* endpoint path)
Design doc: ADR 0002-authenticated-user-storage-for-user-imported-tokens (aus-implementation notes)

Summary

Adds a user custom tokens domain to the Authenticated User Storage SDK: a per-user singleton blob
recording which tokens the user chose to import and which they chose to hide, keyed by CAIP-19
asset identifiers. The PR contains both the endpoint layer (HTTP contract + strict schema validation)
and the high-level SDK API (importTokens / hideTokens) that hides the state-management complexity.

// GET/PUT /api/v1/preferences/user-assets
{
  "version": 1,
  "importedAssets": [
    "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
    "eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
  ],
  "hiddenAssets": [
    "eip155:10/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce36000000"
  ]
}

API

Method Kind Description
getUserAssets(): Promise<UserAssetsBlob | null> Low-level GET /preferences/user-assets; returns null on 404 (first read), mirroring getAssetsWatchlist
setUserAssets(blob, clientType?): Promise<void> Low-level PUT /preferences/user-assets; validates (strict) → normalizes → PUTs the normalized blob → invalidates the get cache
importTokens(ids, clientType?): Promise<UserAssetsBlob> High-level Merges ids into importedAssets (dedup, order-preserving), removes them from hiddenAssets (import wins), persists, returns the resolved blob
hideTokens(ids, clientType?): Promise<UserAssetsBlob> High-level Merges ids into hiddenAssets (dedup), removes them from importedAssets (mutual exclusivity), persists, returns the resolved blob

All four are exposed as messenger actions (AuthenticatedUserStorageService:getUserAssets,
:setUserAssets, :importTokens, :hideTokens) via MESSENGER_EXPOSED_METHODS; the action-types
file is regenerated with yarn messenger-action-types:generate.

Semantics & guarantees

  • Fail-open conflict resolution (ticket requirement): if a CAIP-19 identifier exists in both
    importedAssets and hiddenAssets, the user's intent to import wins — it stays in
    importedAssets and is removed from hiddenAssets. The write is never rejected because of a
    conflict. Implemented as a pure function (normalizeUserAssetsBlob) applied on every write, so
    even direct setUserAssets calls and pre-existing server-side conflicts are resolved.
  • Deduplication safeguard (ticket requirement): all lists are de-duplicated (order-preserving,
    first occurrence wins) before every write, both in the high-level merge and in setUserAssets.
  • Mutual exclusivity: importTokens removes ids from hiddenAssets; hideTokens removes ids
    from importedAssets. Each identifier lives in at most one list.
  • Strict schema validation (ticket requirement): write-side every entry must be a CAIP-19 asset
    identifier (CaipAssetTypeStruct from @metamask/utils); version is a literal(1). Malformed
    blobs throw a superstruct StructError before the request is sent. Read-side stays lenient
    (plain strings) so existing server data is never rejected — same philosophy as the watchlist blob.
  • Fail-fast input validation: importTokens/hideTokens validate ids before any network I/O.

Backend contract (for the AUS server team)

The server implementation of PUT /preferences/user-assets should mirror the SDK as defense-in-depth:

  1. Validate the blob: version === 1, importedAssets/hiddenAssets arrays of CAIP-19 identifiers.
  2. Fail-open normalization on write: dedup both arrays; for any identifier present in both lists,
    keep it in importedAssets and remove it from hiddenAssets.
  3. Store the normalized blob; GET returns it verbatim (404 until first write).

The SDK already sends normalized blobs, so the server rule is a safety net, not a dependency.

Known limitations

  • Read-modify-write race: like the existing watchlist/preferences endpoints, there is no ETag/If-Match
    optimistic concurrency in the current API. Concurrent writers can lose updates; same trade-off as
    the existing endpoints, out of scope here.
  • No size cap is enforced (unlike ASSETS_WATCHLIST_MAX_ASSETS) — none is specified by the ticket.

Test plan

  • 81 unit tests pass (package total), 100% coverage maintained (threshold-enforced).
  • New coverage: messenger round-trips for all four actions; 404→null; 401/500 → HttpError; malformed
    response body → StructError; caching + cache invalidation (incl. refetch after write, and after
    importTokens/hideTokens); request-body assertions proving dedup and fail-open normalization
    happen before the request; mutual-exclusivity assertions both directions; CAIP-19 strictness on
    write; fail-fast invalid ids (no network call made); X-Client-Type header handling.
  • yarn build (full monorepo tsc --build): clean. yarn lint:eslint, yarn lint:misc:check
    (oxfmt), yarn constraints, yarn changelog:validate, yarn readme-content:check,
    yarn messenger-action-types:check: all clean.

Changelog

Entry added under ## [Unreleased]### Added, linking this PR: #10233.


Note

Medium Risk
Introduces new authenticated persistence for user token preferences with read-modify-write semantics (same concurrency trade-offs as other preference blobs), but follows established watchlist patterns and validates writes strictly.

Overview
Adds a user custom tokens domain to @metamask/authenticated-user-storage, backed by GET/PUT /preferences/user-assets, so clients can persist which CAIP-19 tokens a user imported vs hidden.

AuthenticatedUserStorageService gains getUserAssets (404 → null, like the watchlist), setUserAssets, and high-level importTokens / hideTokens that read-modify-write the blob. All four are exposed on the messenger and exported as UserAssetsBlob. Writes validate CAIP-19 ids strictly, dedupe lists, and fail-open on import/hide conflicts (import wins); importTokens / hideTokens also keep the two lists mutually exclusive. getUserAssets cache is invalidated after writes.

Docs, changelog, validators, and broad unit coverage (HTTP, caching, normalization, fail-fast validation) accompany the change.

Reviewed by Cursor Bugbot for commit 78fdd35. Bugbot is set up for automated code reviews on this repo. Configure here.

…orted/hidden assets)

Add a user-assets domain to the Authenticated User Storage SDK, backed by
the new GET/PUT /preferences/user-assets endpoints:

- getUserAssets / setUserAssets: low-level blob access, mirroring the
  assets-watchlist pattern (404 -> null on read, strict write validation)
- importTokens / hideTokens: high-level API that handles deduplication
  and mutual exclusivity internally, returning the resolved blob
- Fail-open conflict resolution: an identifier present in both
  importedAssets and hiddenAssets stays imported and is removed from
  hiddenAssets; writes are never rejected due to a conflict
- Strict write-side schema validation: every entry must be a CAIP-19
  asset identifier (CaipAssetTypeStruct); reads stay lenient

Jira: ASSETS-3937
@Prithpal-Sooriya
Prithpal-Sooriya requested review from a team as code owners September 14, 2026 19:44
Per review feedback on #10233: keep JSDoc to concise summaries with the
necessary param/returns/throws tags instead of multi-paragraph essays.
The generated action-types file is regenerated to match. No functional
change.
Comment thread packages/authenticated-user-storage/src/validators.ts
…s to it.each tables

Per review feedback on #10233: the mirrored importTokens/hideTokens
tests, the error-status and malformed-blob cases, and the cache
invalidation trio are now table-driven via it.each, split per scenario.
Adds a hiddenAssets-side malformed-blob row; all existing assertions
preserved.
…assets writes

Per review feedback on #10233: add a UserAssetsBlobNormalizedWriteSchema
refinement asserting no identifier appears in both importedAssets and
hiddenAssets, applied to the normalized blob right before the write.
Structural validation of the raw input still fails fast, but the
exclusivity check runs after fail-open normalization, so a write can
never be rejected because of a conflict — it only guards against
internal logic bugs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant