Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/authenticated-user-storage/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Route `setAssetsWatchlist` through `BaseDataService.executeMutation` so
watchlist writes are not cached, deduplicated, or retried.
- Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076))

### Removed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export type AuthenticatedUserStorageServiceGetAssetsWatchlistAction = {
* 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.
* @param clientTypeOrGlobalId - Optional client type header or mutation ID.
* @param globalId - Optional mutation ID when a client type is provided.
* @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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,59 @@ describe('AuthenticatedUserStorageService', () => {

expect(mock.isDone()).toBe(true);
});

it('does not retry a failed request', async () => {
nock(MOCK_ASSETS_WATCHLIST_URL)
.put('')
.reply(500, { error: 'server error' })
.put('')
.reply(200);
const { service } = createService();

await expect(
service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB),
).rejects.toThrow('Failed to put assets watchlist: 500');
});

it('does not deduplicate concurrent requests', async () => {
const scope = nock(MOCK_ASSETS_WATCHLIST_URL).put('').twice().reply(200);
const { service } = createService();

await Promise.all([
service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB),
service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB),
]);

expect(scope.isDone()).toBe(true);
});

it('includes the global ID in mutation cache updates', async () => {
handleMockSetAssetsWatchlist();
const { service, messenger } = createService();
const publishSpy = jest.spyOn(messenger, 'publish');

await service.setAssetsWatchlist(MOCK_ASSETS_WATCHLIST_BLOB, 'global-id');

expect(publishSpy).toHaveBeenCalledWith(
expect.stringContaining(
'AuthenticatedUserStorageService:cacheUpdated:',
),
expect.objectContaining({
objectType: 'mutation',
state: expect.objectContaining({
mutations: [
expect.objectContaining({
mutationKey: [
'AuthenticatedUserStorageService:setAssetsWatchlist',
MOCK_ASSETS_WATCHLIST_BLOB,
],
meta: { globalId: 'global-id' },
}),
],
}),
}),
);
});
});

describe('cache invalidation', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ import {
*/
export const serviceName = 'AuthenticatedUserStorageService';

/**
* Checks whether a value is a supported client type.
*
* This distinguishes the existing client-type argument from the optional
* mutation global ID when the method is invoked through a UI query client.
*
* @param value - The value to check.
* @returns Whether the value is a client type.
*/
function isClientType(value: string | undefined): value is ClientType {
return value === 'extension' || value === 'mobile' || value === 'portfolio';
}

/**
* Builds the versioned API base URL for a given environment.
*
Expand Down Expand Up @@ -392,23 +405,31 @@ export class AuthenticatedUserStorageService extends BaseDataService<
* 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.
* @param clientTypeOrGlobalId - Optional client type header or mutation ID.
* @param globalId - Optional mutation ID when a client type is provided.
* @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,
clientTypeOrGlobalId?: ClientType | string,
globalId?: string,
): Promise<void> {
assertAssetsWatchlistBlobForWrite(blob);

const url = `${getAuthenticatedStorageUrl(this.#environment)}/preferences/assets-watchlist`;

await this.fetchQuery({
queryKey: [`${this.name}:setAssetsWatchlist`, blob as unknown as Json],
staleTime: 0,
queryFn: async () => {
const clientType = isClientType(clientTypeOrGlobalId)
? clientTypeOrGlobalId
: undefined;
const mutationGlobalId = clientType
? globalId
: (globalId ?? clientTypeOrGlobalId);

await this.executeMutation({
mutationKey: [`${this.name}:setAssetsWatchlist`, blob as Json],
globalId: mutationGlobalId,
mutationFn: async () => {
const headers = await this.#getHeaders(clientType);
const response = await fetch(url, {
method: 'PUT',
Expand Down
Loading