From e9d6cd150bc07a2da790f44c26ded134a63d232b Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Thu, 27 Aug 2026 10:03:59 +0100 Subject: [PATCH 1/4] chore: update notif-api schema --- .../mocks/mock-raw-notifications.ts | 16 ++++ .../types/notification-api/schema.ts | 84 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-raw-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-raw-notifications.ts index 5e493fe25c7..3f80ef1c3e4 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-raw-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mock-raw-notifications.ts @@ -10,6 +10,7 @@ export function createMockNotificationEthSent(): NormalisedAPINotification { const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ETH_SENT, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'eth_sent', id: '3fa85f64-5717-4562-b3fc-2c963f66afa7', unread: true, @@ -57,6 +58,7 @@ export function createMockNotificationEthReceived(): NormalisedAPINotification { const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ETH_RECEIVED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'eth_received', id: '3fa85f64-5717-4562-b3fc-2c963f66afa8', unread: true, @@ -104,6 +106,7 @@ export function createMockNotificationERC20Sent(): NormalisedAPINotification { const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ERC20_SENT, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'erc20_sent', id: '3fa85f64-5717-4562-b3fc-2c963f66afa9', unread: true, @@ -157,6 +160,7 @@ export function createMockNotificationERC20Received(): NormalisedAPINotification const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ERC20_RECEIVED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'erc20_received', id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', unread: true, @@ -210,6 +214,7 @@ export function createMockNotificationERC721Sent(): NormalisedAPINotification { const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ERC721_SENT, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'erc721_sent', id: 'a4193058-9814-537e-9df4-79dcac727fb6', created_at: '2023-11-15T11:08:17.895407Z', @@ -266,6 +271,7 @@ export function createMockNotificationERC721Received(): NormalisedAPINotificatio const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ERC721_RECEIVED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'erc721_received', id: '00a79d24-befa-57ed-a55a-9eb8696e1654', created_at: '2023-11-14T17:40:52.319281Z', @@ -322,6 +328,7 @@ export function createMockNotificationERC1155Sent(): NormalisedAPINotification { const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ERC1155_SENT, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'erc1155_sent', id: 'a09ff9d1-623a-52ab-a3d4-c7c8c9a58362', created_at: '2023-11-20T20:44:10.110706Z', @@ -378,6 +385,7 @@ export function createMockNotificationERC1155Received(): NormalisedAPINotificati const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ERC1155_RECEIVED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'erc1155_received', id: 'b6b93c84-e8dc-54ed-9396-7ea50474843a', created_at: '2023-11-20T20:44:10.110706Z', @@ -434,6 +442,7 @@ export function createMockNotificationMetaMaskSwapsCompleted(): NormalisedAPINot const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.METAMASK_SWAP_COMPLETED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'metamask_swap_completed', id: '7ddfe6a1-ac52-5ffe-aa40-f04242db4b8b', created_at: '2023-10-18T13:58:49.854596Z', @@ -496,6 +505,7 @@ export function createMockNotificationRocketPoolStakeCompleted(): NormalisedAPIN const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ROCKETPOOL_STAKE_COMPLETED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'rocketpool_stake_completed', id: 'c2a2f225-b2fb-5d6c-ba56-e27a5c71ffb9', created_at: '2023-11-20T12:02:48.796824Z', @@ -557,6 +567,7 @@ export function createMockNotificationRocketPoolUnStakeCompleted(): NormalisedAP const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.ROCKETPOOL_UNSTAKE_COMPLETED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'rocketpool_unstake_completed', id: '291ec897-f569-4837-b6c0-21001b198dff', created_at: '2023-10-19T13:11:10.623042Z', @@ -618,6 +629,7 @@ export function createMockNotificationLidoStakeCompleted(): NormalisedAPINotific const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.LIDO_STAKE_COMPLETED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'lido_stake_completed', id: 'ec10d66a-f78f-461f-83c9-609aada8cc50', created_at: '2023-11-02T22:28:49.970865Z', @@ -679,6 +691,7 @@ export function createMockNotificationLidoWithdrawalRequested(): NormalisedAPINo const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.LIDO_WITHDRAWAL_REQUESTED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'lido_withdrawal_requested', id: 'ef003925-3379-4ba7-9e2d-8218690cadc9', created_at: '2023-10-18T15:04:02.482526Z', @@ -740,6 +753,7 @@ export function createMockNotificationLidoWithdrawalCompleted(): NormalisedAPINo const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.LIDO_WITHDRAWAL_COMPLETED, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'lido_withdrawal_completed', id: 'd73df14d-ce73-4f38-bad3-ab028154042f', created_at: '2023-10-18T16:35:03.147606Z', @@ -801,6 +815,7 @@ export function createMockNotificationLidoReadyToBeWithdrawn(): NormalisedAPINot const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.LIDO_STAKE_READY_TO_BE_WITHDRAWN, notification_type: 'wallet_activity', + category: 'wallet_activity', notification_subtype: 'lido_stake_ready_to_be_withdrawn', id: 'd73df14d-ce73-4f38-bad3-ab028154042e', created_at: '2023-10-18T16:35:03.147606Z', @@ -849,6 +864,7 @@ export function createMockPlatformNotification(): NormalisedAPINotification { const mockNotification: NormalisedAPINotification = { type: TRIGGER_TYPES.PLATFORM, notification_type: 'perps', + category: 'trading_activity', notification_subtype: 'position_liquidated', id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', unread: true, diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts index 619a061b260..430977afc74 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/schema.ts @@ -21,6 +21,8 @@ export type paths = { /** * List both platform and on-chain notifications for a certain user/address(es) * @description Same behaviour as /api/v3/notifications, but the returned notification_type and notification_subtype are taken directly from the producer-set database fields: platform notifications expose platform_notifications.notification_type / notification_subtype, while on-chain notifications expose the constant "wallet_activity" as notification_type and notifications_part.kind as notification_subtype. Clients should distinguish the two shapes structurally (presence of "payload" for on-chain vs "template" for platform). + * + * Both shapes also carry a "category": the category_id of the settings category the notification_type belongs to, resolved server-side from the same manifest served by GET /api/v4/notifications/categories. It lets clients group or filter a notification list with the same taxonomy the settings screen uses, without duplicating the notification_type -> category mapping client-side. */ post: { parameters: { @@ -106,6 +108,53 @@ export type paths = { patch?: never; trace?: never; }; + '/api/v4/notifications/categories': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List notification categories (server-driven settings manifest) + * @description Read-only manifest that drives the client notification-settings UX. It is a single artifact identical for every user and platform: the client renders only the entries whose `visible_on` includes its own platform, in the order they appear in the array, and toggles the AUS preference keys listed in `aus_keys`. It ties together the AUS preference keys (user-storage), the platform notification_types, and the UX grouping, so changing the taxonomy no longer requires a client release. + * Presentation (labels, descriptions, icons) is client-owned for now and keyed off `category_id`; server-side localization is a future, additive change. This endpoint is unauthenticated — it carries no per-user state. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The notification categories manifest */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['NotificationCategories']; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v3/notifications': { parameters: { query?: never; @@ -393,6 +442,31 @@ export type components = { * @enum {string} */ AppPlatform: 'portfolio' | 'extension' | 'mobile'; + /** @description The full set of notification categories, in display order. Returned in its entirety to every client regardless of platform; the client renders only the entries whose `visible_on` includes its platform, in the order they appear here. */ + NotificationCategories: components['schemas']['NotificationCategory'][]; + NotificationCategory: { + /** + * @description Stable identifier for the category. The client keys its localized copy and icon off this value. + * @example trading_activity + */ + category_id: string; + /** @description Platforms on which the client should render this category. An empty array means it is hidden everywhere but still present in the manifest (so the set of categories stays complete with respect to the platform notification_types). */ + visible_on: components['schemas']['AppPlatform'][]; + /** + * @description The user-storage notification-preference keys this category toggles. The AUS key is the toggle pivot: turning the category off disables every listed key, turning it on enables them. An empty array means the category is display-only (not user-toggleable). + * @example [ + * "perps" + * ] + */ + aus_keys: string[]; + /** + * @description The platform notification_type values this category covers. Advisory / validation only — the client toggles `aus_keys`, not these. May be empty when a category maps to an AUS-only key (e.g. `marketing`, or the reserved on-chain `wallet_activity`) that has no platform notification_type. + * @example [ + * "perps" + * ] + */ + notification_types: string[]; + }; NotificationInputV3: { /** @example en-US */ locale: string; @@ -492,6 +566,11 @@ export type components = { * @example perps */ notification_type: string; + /** + * @description The `category_id` of the settings category this notification belongs to, derived server-side from `notification_type` via the manifest served by GET /api/v4/notifications/categories. Empty string when no category covers the notification_type (e.g. a type newer than the manifest); clients should treat that, and any category_id they do not know, as uncategorized. + * @example trading_activity + */ + category: string; /** * @description Producer-set platform_notifications.notification_subtype value. * @example position_liquidated @@ -514,6 +593,11 @@ export type components = { id: string; /** @enum {string} */ notification_type: 'wallet_activity'; + /** + * @description The `category_id` of the settings category this notification belongs to, derived server-side from `notification_type` via the manifest served by GET /api/v4/notifications/categories. + * @example wallet_activity + */ + category: string; /** * @description notifications_part.kind value. * @example metamask_swap_completed From 7a731389dec3602bebaad18dfb186ee9cb6a7966 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Thu, 27 Aug 2026 11:17:30 +0100 Subject: [PATCH 2/4] feat: add notifications category fetching to NSC --- .../NotificationServicesController.ts | 81 ++++++++++++++++++- .../services/api-notifications.ts | 54 +++++++++---- .../notification-api/notification-api.ts | 2 + .../utils/utils.ts | 49 +++++++---- 4 files changed, 151 insertions(+), 35 deletions(-) diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index 5fac52146a2..29641f9a4d7 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -12,7 +12,7 @@ import { } from '@metamask/authenticated-user-storage'; import type { ControllerGetStateAction, - ControllerStateChangeEvent, + ControllerStateChangedEvent, StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; @@ -40,7 +40,10 @@ import type { } from '../NotificationServicesPushController/index.js'; import type { NotificationServicesPushControllerMethodActions } from '../NotificationServicesPushController/NotificationServicesPushController-method-action-types.js'; import { TRIGGER_TYPES } from './constants/notification-schema.js'; -import type { NormalisedAPINotification } from './index.js'; +import type { + NormalisedAPINotification, + NotificationsCategory, +} from './index.js'; import type { NotificationServicesControllerMethodActions } from './NotificationServicesController-method-action-types.js'; import { processAndFilterNotifications, @@ -50,6 +53,7 @@ import type { ENV } from './services/api-notifications.js'; import { getAPINotifications, getNotificationsApiConfigCached, + getNotificationsCategories, markNotificationsAsRead, } from './services/api-notifications.js'; import { getFeatureAnnouncementNotifications } from './services/feature-announcements.js'; @@ -99,6 +103,10 @@ export type NotificationServicesControllerState = { * List of read metamask notifications */ metamaskNotificationsReadList: string[]; + /** + * List of notification categories + */ + metamaskNotificationsCategories: NotificationsCategory[]; /** * Flag that indicates that the creating notifications is in progress */ @@ -109,6 +117,11 @@ export type NotificationServicesControllerState = { * when fetching notifications */ isFetchingMetamaskNotifications: boolean; + /** + * Flag that indicates that fetching notification categories + * is in progress. Used for readiness checks for categories consumers + */ + isFetchingMetamaskNotificationsCategories: boolean; /** * Flag that indicates that the updating notifications for a specific address is in progress */ @@ -157,6 +170,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: true, usedInUi: true, }, + metamaskNotificationsCategories: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, isUpdatingMetamaskNotifications: { includeInStateLogs: false, persist: false, @@ -181,6 +200,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + isFetchingMetamaskNotificationsCategories: { + includeInStateLogs: false, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; export const defaultState: NotificationServicesControllerState = { subscriptionAccountsSeen: [], @@ -189,10 +214,12 @@ export const defaultState: NotificationServicesControllerState = { isFeatureAnnouncementsEnabled: false, metamaskNotificationsList: [], metamaskNotificationsReadList: [], + metamaskNotificationsCategories: [], isUpdatingMetamaskNotifications: false, isFetchingMetamaskNotifications: false, isUpdatingMetamaskNotificationsAccount: [], isCheckingAccountsPresence: false, + isFetchingMetamaskNotificationsCategories: false, }; export type NotificationServicesControllerEnableNotificationsOptions = { @@ -385,7 +412,7 @@ type AllowedActions = // Events export type NotificationServicesControllerStateChangeEvent = - ControllerStateChangeEvent< + ControllerStateChangedEvent< typeof controllerName, NotificationServicesControllerState >; @@ -973,6 +1000,23 @@ export class NotificationServicesController extends BaseController< }); } + /** + * Updates the state to indicate whether fetching of MetaMask notification categories is in progress. + * + * This method is used to set the `isFetchingMetamaskNotificationCategories` state, which can be utilized + * to show or hide loading indicators in the UI when notifications categories are being fetched. + * + * @param isFetchingMetamaskNotificationCategories - A boolean value representing the fetching state. + */ + #setIsFetchingNotificationsCategories( + isFetchingNotificationCategories: boolean, + ) { + this.update((state) => { + state.isFetchingMetamaskNotificationsCategories = + isFetchingNotificationCategories; + }); + } + /** * Public method to expose enabling push notifications */ @@ -1617,4 +1661,35 @@ export class NotificationServicesController extends BaseController< // Do Nothing } } + + + /** + * Fetches the list of MetaMask notification categories from the notifications API, + * stores the result in controller state, and returns the categories. + * + * This method sets the categories-loading flag while the request is in flight + * so the UI can reflect the loading state. If the request fails, it logs the + * error and throws a generic failure error. + * + * @returns A promise that resolves to the fetched notification categories. + * @throws {Error} If the categories request fails. + */ + public async fetchMetamaskNotificationsCategories() { + this.#setIsFetchingNotificationsCategories(true); + + try { + const categories = await getNotificationsCategories(); + + this.update((state) => { + state.metamaskNotificationsCategories = categories; + }); + + return categories; + } catch (error) { + log.error('Failed to fetch notifications categories', error); + throw new Error('Failed to fetch notifications categories'); + } finally { + this.#setIsFetchingNotificationsCategories(false); + } + } } diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts index 3fcbc95a0d5..30b58bc9107 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts @@ -43,6 +43,10 @@ export const NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT = ( env: ENV = 'prd', ): string => `${NOTIFICATION_API(env)}/api/v4/notifications/mark-as-read`; +export const NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT = ( + env: ENV = 'prd', +): string => `${NOTIFICATION_API(env)}/api/v4/notifications/categories`; + /** * fetches notification config (accounts enabled vs disabled) * @@ -72,12 +76,11 @@ export async function getNotificationsApiConfigCached( type RequestBody = { address: string }[]; type Response = { address: string; enabled: boolean }[]; const body: RequestBody = normalizedAddresses.map((address) => ({ address })); - const apiResponse = await makeApiCall( - bearerToken, - TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT(env), - 'POST', + const apiResponse = await makeApiCall(TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT(env), { + method: 'POST', body, - ) + bearerToken, + }) .then((response) => (response.ok ? response.json() : null)) .catch(() => null); @@ -121,12 +124,11 @@ export async function getAPINotifications( locale, platform, }; - const notifications = await makeApiCall( - bearerToken, - NOTIFICATION_API_LIST_ENDPOINT(env), - 'POST', + const notifications = await makeApiCall(NOTIFICATION_API_LIST_ENDPOINT(env), { + method: 'POST', body, - ) + bearerToken, + }) .then((response) => response.ok ? response.json() : null, ) @@ -176,13 +178,35 @@ export async function markNotificationsAsRead( }; try { - await makeApiCall( - bearerToken, - NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT(env), - 'POST', + await makeApiCall(NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT(env), { + method: 'POST', body, - ); + bearerToken, + }); } catch (error) { log.error('Error marking notifications as read:', error); } } + +export async function getNotificationsCategories( + env: ENV = 'prd', +) { + type APIResponse = + Schema.paths['/api/v4/notifications/categories']['get']['responses']['200']['content']['application/json']; + + let categories: APIResponse | null; + + categories = await makeApiCall(NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT(env), { + method: 'GET', + }) + .then((response) => + response.ok ? response.json() : null, + ) + .catch((error) => { + log.error("Error fetching notifications categories", error) + + return null + }); + + return categories ?? []; +} diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts index 08fd046f4f3..150f5437c08 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts @@ -34,6 +34,8 @@ export type PlatformNotification = export type OnChainNotification = components['schemas']['OnChainNotificationV4']; +export type NotificationsCategory = components['schemas']['NotificationCategory'] + type ConvertToEnum = { [K in TRIGGER_TYPES]: Kind extends `${K}` ? K : never; }[TRIGGER_TYPES]; diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts index 068782514c5..4880b31e186 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts @@ -1,26 +1,41 @@ +type JsonRequestMethod = 'POST'; +type NoBodyMethod = 'GET' | 'DELETE'; + +type MakeApiCallOptions = + | { + method: NoBodyMethod; + bearerToken?: string; + } + | { + method: JsonRequestMethod; + body: Body; + bearerToken?: string; + }; + /** - * Performs an API call with automatic retries on failure. + * Performs an API call with optional bearer authentication and JSON body support. * - * @param bearerToken - The JSON Web Token for authorization. * @param endpoint - The URL of the API endpoint to call. - * @param method - The HTTP method ('POST' or 'DELETE'). - * @param body - The body of the request. It should be an object that can be serialized to JSON. + * @param options - Request configuration. * @returns A Promise that resolves to the response of the fetch request. */ -export async function makeApiCall( - bearerToken: string, +export async function makeApiCall( endpoint: string, - method: 'POST' | 'DELETE', - body: Body, + options: MakeApiCallOptions, ): Promise { - const options: RequestInit = { - method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${bearerToken}`, - }, - body: JSON.stringify(body), - }; + const headers: HeadersInit = {}; + + if (options.bearerToken) { + headers.Authorization = `Bearer ${options.bearerToken}`; + } + + if ('body' in options) { + headers['Content-Type'] = 'application/json'; + } - return await fetch(endpoint, options); + return fetch(endpoint, { + method: options.method, + headers, + body: 'body' in options ? JSON.stringify(options.body) : undefined, + }); } From 286dce43ebbd67311167da913d54962ce96bf028 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Thu, 27 Aug 2026 11:57:48 +0100 Subject: [PATCH 3/4] feat: expose methods to messenger --- ...nServicesController-method-action-types.ts | 20 ++++- .../NotificationServicesController.test.ts | 83 +++++++++++++++++++ .../NotificationServicesController.ts | 12 +-- .../__fixtures__/mockServices.ts | 14 ++++ .../NotificationServicesController/index.ts | 1 + .../mocks/mockResponses.ts | 22 +++++ .../services/api-notifications.test.ts | 41 +++++++++ .../services/api-notifications.ts | 39 ++++++--- .../notification-api/notification-api.ts | 3 +- .../utils/utils.ts | 6 +- 10 files changed, 219 insertions(+), 22 deletions(-) diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts index c4f85cec6cb..2ed2bfc7afb 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController-method-action-types.ts @@ -223,6 +223,23 @@ export type NotificationServicesControllerSendPerpPlaceOrderNotificationAction = handler: NotificationServicesController['sendPerpPlaceOrderNotification']; }; +/** + * Fetches the list of MetaMask notification categories from the notifications API, + * stores the result in controller state, and returns the categories. + * + * This method sets the categories-loading flag while the request is in flight + * so the UI can reflect the loading state. If the request fails, it logs the + * error and throws a generic failure error. + * + * @returns A promise that resolves to the fetched notification categories. + * @throws {Error} If the categories request fails. + */ +export type NotificationServicesControllerFetchMetamaskNotificationsCategoriesAction = + { + type: `NotificationServicesController:fetchMetamaskNotificationsCategories`; + handler: NotificationServicesController['fetchMetamaskNotificationsCategories']; + }; + /** * Union of all NotificationServicesController action types. */ @@ -243,4 +260,5 @@ export type NotificationServicesControllerMethodActions = | NotificationServicesControllerDeleteNotificationsByIdAction | NotificationServicesControllerMarkMetamaskNotificationsAsReadAction | NotificationServicesControllerUpdateMetamaskNotificationsListAction - | NotificationServicesControllerSendPerpPlaceOrderNotificationAction; + | NotificationServicesControllerSendPerpPlaceOrderNotificationAction + | NotificationServicesControllerFetchMetamaskNotificationsCategoriesAction; diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts index 86f39be5c25..7a938324c9f 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.test.ts @@ -38,6 +38,7 @@ import { mockFetchFeatureAnnouncementNotifications, mockMarkNotificationsAsRead, mockCreatePerpNotification, + mockGetNotificationsCategories, } from './__fixtures__/mockServices.js'; import { waitFor } from './__fixtures__/test-utils.js'; import { TRIGGER_TYPES } from './constants/index.js'; @@ -47,6 +48,7 @@ import { createMockFeatureAnnouncementRaw, } from './mocks/mock-feature-announcements.js'; import { createMockNotificationEthSent } from './mocks/mock-raw-notifications.js'; +import { getMockNotificationsCategoriesResponse } from './mocks/mockResponses.js'; import { DEFAULT_AGENTIC_CLI_PREFERENCES, DEFAULT_PERPS_PREFERENCES, @@ -63,6 +65,7 @@ import type { import { processFeatureAnnouncement } from './processors/index.js'; import { processNotification } from './processors/process-notifications.js'; import { processSnapNotification } from './processors/process-snap-notifications.js'; +import * as OnChainNotifications from './services/api-notifications.js'; import { notificationsConfigCache } from './services/notification-config-cache.js'; import type { INotification, OrderInput } from './types/index.js'; @@ -1259,6 +1262,81 @@ describe('NotificationServicesController', () => { }); }); + describe('fetchMetamaskNotificationsCategories', () => { + it('fetches notification categories and updates state', async () => { + const { messenger } = mockNotificationMessenger(); + const mockCategoriesAPI = mockGetNotificationsCategories(); + const expectedCategories = + getMockNotificationsCategoriesResponse().response; + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + const result = await controller.fetchMetamaskNotificationsCategories(); + + expect(mockCategoriesAPI.isDone()).toBe(true); + expect(result).toStrictEqual(expectedCategories); + expect(controller.state.metamaskNotificationsCategories).toStrictEqual( + expectedCategories, + ); + expect(controller.state.isFetchingMetamaskNotificationsCategories).toBe( + false, + ); + }); + + it('returns an empty array and resets loading state when the API fails', async () => { + const { messenger } = mockNotificationMessenger(); + const mockCategoriesAPI = mockGetNotificationsCategories({ + status: 500, + body: { error: 'mock api failure' }, + }); + const mockLogError = mockErrorLog(); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + const result = await controller.fetchMetamaskNotificationsCategories(); + + expect(mockCategoriesAPI.isDone()).toBe(true); + expect(result).toStrictEqual([]); + expect(controller.state.metamaskNotificationsCategories).toStrictEqual( + [], + ); + expect(controller.state.isFetchingMetamaskNotificationsCategories).toBe( + false, + ); + mockLogError.mockRestore(); + }); + + it('throws and resets loading state when fetching categories throws', async () => { + const { messenger } = mockNotificationMessenger(); + const mockLogError = mockErrorLog(); + const getCategoriesSpy = jest + .spyOn(OnChainNotifications, 'getNotificationsCategories') + .mockRejectedValue(new Error('unexpected error')); + const controller = new NotificationServicesController({ + messenger, + env: { featureAnnouncements: featureAnnouncementsEnv }, + }); + + await expect( + controller.fetchMetamaskNotificationsCategories(), + ).rejects.toThrow('Failed to fetch notifications categories'); + + expect(controller.state.metamaskNotificationsCategories).toStrictEqual( + [], + ); + expect(controller.state.isFetchingMetamaskNotificationsCategories).toBe( + false, + ); + expect(mockLogError).toHaveBeenCalled(); + getCategoriesSpy.mockRestore(); + mockLogError.mockRestore(); + }); + }); + describe('getNotificationsByType', () => { it('can fetch notifications by their type', async () => { const { messenger } = mockNotificationMessenger(); @@ -1850,6 +1928,7 @@ describe('NotificationServicesController', () => { ), ).toMatchInlineSnapshot(` { + "metamaskNotificationsCategories": [], "metamaskNotificationsList": [], "metamaskNotificationsReadList": [], "subscriptionAccountsSeen": [], @@ -1875,6 +1954,7 @@ describe('NotificationServicesController', () => { "isFeatureAnnouncementsEnabled": false, "isMetamaskNotificationsFeatureSeen": false, "isNotificationServicesEnabled": false, + "metamaskNotificationsCategories": [], "metamaskNotificationsList": [], "subscriptionAccountsSeen": [], } @@ -1899,6 +1979,7 @@ describe('NotificationServicesController', () => { "isFeatureAnnouncementsEnabled": false, "isMetamaskNotificationsFeatureSeen": false, "isNotificationServicesEnabled": false, + "metamaskNotificationsCategories": [], "metamaskNotificationsList": [], "metamaskNotificationsReadList": [], "subscriptionAccountsSeen": [], @@ -1924,10 +2005,12 @@ describe('NotificationServicesController', () => { "isCheckingAccountsPresence": false, "isFeatureAnnouncementsEnabled": false, "isFetchingMetamaskNotifications": false, + "isFetchingMetamaskNotificationsCategories": false, "isMetamaskNotificationsFeatureSeen": false, "isNotificationServicesEnabled": false, "isUpdatingMetamaskNotifications": false, "isUpdatingMetamaskNotificationsAccount": [], + "metamaskNotificationsCategories": [], "metamaskNotificationsList": [], "metamaskNotificationsReadList": [], "subscriptionAccountsSeen": [], diff --git a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts index 29641f9a4d7..e0d2ab39c40 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/NotificationServicesController.ts @@ -377,6 +377,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'disableAccounts', 'enableAccounts', 'fetchAndUpdateMetamaskNotifications', + 'fetchMetamaskNotificationsCategories', 'getNotificationsByType', 'deleteNotificationById', 'deleteNotificationsById', @@ -1003,14 +1004,14 @@ export class NotificationServicesController extends BaseController< /** * Updates the state to indicate whether fetching of MetaMask notification categories is in progress. * - * This method is used to set the `isFetchingMetamaskNotificationCategories` state, which can be utilized + * This method is used to set the `isFetchingMetamaskNotificationsCategories` state, which can be utilized * to show or hide loading indicators in the UI when notifications categories are being fetched. * - * @param isFetchingMetamaskNotificationCategories - A boolean value representing the fetching state. + * @param isFetchingNotificationCategories - A boolean value representing the fetching state. */ #setIsFetchingNotificationsCategories( isFetchingNotificationCategories: boolean, - ) { + ): void { this.update((state) => { state.isFetchingMetamaskNotificationsCategories = isFetchingNotificationCategories; @@ -1662,7 +1663,6 @@ export class NotificationServicesController extends BaseController< } } - /** * Fetches the list of MetaMask notification categories from the notifications API, * stores the result in controller state, and returns the categories. @@ -1674,7 +1674,9 @@ export class NotificationServicesController extends BaseController< * @returns A promise that resolves to the fetched notification categories. * @throws {Error} If the categories request fails. */ - public async fetchMetamaskNotificationsCategories() { + public async fetchMetamaskNotificationsCategories(): Promise< + NotificationsCategory[] + > { this.#setIsFetchingNotificationsCategories(true); try { diff --git a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts index bdab54c3f6d..0518feb6be1 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/__fixtures__/mockServices.ts @@ -6,6 +6,7 @@ import { getMockListNotificationsResponse, getMockMarkNotificationsAsReadResponse, getMockCreatePerpOrderNotification, + getMockNotificationsCategoriesResponse, } from '../mocks/mockResponses.js'; type MockReply = { @@ -64,6 +65,19 @@ export const mockMarkNotificationsAsRead = ( return mockEndpoint; }; +export const mockGetNotificationsCategories = ( + mockReply?: MockReply, +): nock.Scope => { + const mockResponse = getMockNotificationsCategoriesResponse(); + const reply = mockReply ?? { status: 200, body: mockResponse.response }; + + const mockEndpoint = nock(mockResponse.url) + .get('') + .reply(reply.status, reply.body); + + return mockEndpoint; +}; + export const mockCreatePerpNotification = ( mockReply?: MockReply, ): nock.Scope => { diff --git a/packages/notification-services-controller/src/NotificationServicesController/index.ts b/packages/notification-services-controller/src/NotificationServicesController/index.ts index 08fa0e6df85..e7e8ff694f5 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/index.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/index.ts @@ -26,6 +26,7 @@ export type { NotificationServicesControllerDisableAccountsAction, NotificationServicesControllerEnableAccountsAction, NotificationServicesControllerFetchAndUpdateMetamaskNotificationsAction, + NotificationServicesControllerFetchMetamaskNotificationsCategoriesAction, NotificationServicesControllerGetNotificationsByTypeAction, NotificationServicesControllerDeleteNotificationByIdAction, NotificationServicesControllerDeleteNotificationsByIdAction, diff --git a/packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts b/packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts index 279067a5f0b..bfd24952705 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/mocks/mockResponses.ts @@ -1,4 +1,5 @@ import { + NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT, NOTIFICATION_API_LIST_ENDPOINT, NOTIFICATION_API_MARK_ALL_AS_READ_ENDPOINT, TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT, @@ -53,6 +54,27 @@ export const getMockMarkNotificationsAsReadResponse = (): MockResponse => { } satisfies MockResponse; }; +export const getMockNotificationsCategoriesResponse = (): MockResponse => { + return { + url: NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT(), + requestMethod: 'GET', + response: [ + { + category_id: 'trading_activity', + visible_on: ['extension', 'mobile'], + aus_keys: ['perps'], + notification_types: ['perps'], + }, + { + category_id: 'wallet_activity', + visible_on: ['extension', 'mobile', 'portfolio'], + aus_keys: ['walletActivity'], + notification_types: [], + }, + ], + } satisfies MockResponse; +}; + export const getMockCreatePerpOrderNotification = (): MockResponse => { return { url: PERPS_API_CREATE_ORDERS, diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.test.ts b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.test.ts index c2253737853..713c71f5ea1 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.test.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.test.ts @@ -1,13 +1,18 @@ +import nock from 'nock'; + import { mockGetOnChainNotificationsConfig, mockGetAPINotifications, mockMarkNotificationsAsRead, + mockGetNotificationsCategories, } from '../__fixtures__/mockServices.js'; import { createMockNotificationERC20Sent, createMockPlatformNotification, + getMockNotificationsCategoriesResponse, } from '../mocks/index.js'; import * as OnChainNotifications from './api-notifications.js'; +import { NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT } from './api-notifications.js'; const MOCK_BEARER_TOKEN = 'MOCK_BEARER_TOKEN'; const MOCK_ADDRESSES = ['0x123', '0x456', '0x789']; @@ -162,3 +167,39 @@ describe('On Chain Notifications - markNotificationsAsRead()', () => { expect(mockEndpoint.isDone()).toBe(false); }); }); + +describe('On Chain Notifications - getNotificationsCategories()', () => { + it('should return a list of notification categories', async () => { + const mockEndpoint = mockGetNotificationsCategories(); + + const result = await OnChainNotifications.getNotificationsCategories(); + + expect(mockEndpoint.isDone()).toBe(true); + expect(result).toStrictEqual( + getMockNotificationsCategoriesResponse().response, + ); + }); + + it('should return an empty array if endpoint fails', async () => { + const mockBadEndpoint = mockGetNotificationsCategories({ + status: 500, + body: { error: 'mock api failure' }, + }); + + const result = await OnChainNotifications.getNotificationsCategories(); + + expect(mockBadEndpoint.isDone()).toBe(true); + expect(result).toStrictEqual([]); + }); + + it('should return an empty array if the request fails at the network level', async () => { + const mockBadEndpoint = nock(NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT()) + .get('') + .replyWithError('network error'); + + const result = await OnChainNotifications.getNotificationsCategories(); + + expect(mockBadEndpoint.isDone()).toBe(true); + expect(result).toStrictEqual([]); + }); +}); diff --git a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts index 30b58bc9107..7ff4c286bc6 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/services/api-notifications.ts @@ -3,6 +3,7 @@ import log from 'loglevel'; import { toRawAPINotification } from '../../shared/to-raw-notification.js'; import type { NormalisedAPINotification, + NotificationsCategory, Schema, UnprocessedRawNotification, } from '../types/notification-api/index.js'; @@ -76,11 +77,14 @@ export async function getNotificationsApiConfigCached( type RequestBody = { address: string }[]; type Response = { address: string; enabled: boolean }[]; const body: RequestBody = normalizedAddresses.map((address) => ({ address })); - const apiResponse = await makeApiCall(TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT(env), { - method: 'POST', - body, - bearerToken, - }) + const apiResponse = await makeApiCall( + TRIGGER_API_NOTIFICATIONS_QUERY_ENDPOINT(env), + { + method: 'POST', + body, + bearerToken, + }, + ) .then((response) => (response.ok ? response.json() : null)) .catch(() => null); @@ -188,24 +192,33 @@ export async function markNotificationsAsRead( } } +/** + * Fetches the notification categories manifest (server-driven settings taxonomy). + * + * This endpoint is unauthenticated and returns the same manifest for every user. + * + * @param env - the environment to use for the API call + * @returns The list of notification categories, or an empty array on transport or parse errors. + */ export async function getNotificationsCategories( env: ENV = 'prd', -) { +): Promise { type APIResponse = Schema.paths['/api/v4/notifications/categories']['get']['responses']['200']['content']['application/json']; - let categories: APIResponse | null; - - categories = await makeApiCall(NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT(env), { - method: 'GET', - }) + const categories: APIResponse | null = await makeApiCall( + NOTIFICATION_API_CATEGORIES_LIST_ENDPOINT(env), + { + method: 'GET', + }, + ) .then((response) => response.ok ? response.json() : null, ) .catch((error) => { - log.error("Error fetching notifications categories", error) + log.error('Error fetching notifications categories', error); - return null + return null; }); return categories ?? []; diff --git a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts index 150f5437c08..10936c7d747 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/types/notification-api/notification-api.ts @@ -34,7 +34,8 @@ export type PlatformNotification = export type OnChainNotification = components['schemas']['OnChainNotificationV4']; -export type NotificationsCategory = components['schemas']['NotificationCategory'] +export type NotificationsCategory = + components['schemas']['NotificationCategory']; type ConvertToEnum = { [K in TRIGGER_TYPES]: Kind extends `${K}` ? K : never; diff --git a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts index 4880b31e186..942ca69f37f 100644 --- a/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts +++ b/packages/notification-services-controller/src/NotificationServicesController/utils/utils.ts @@ -29,13 +29,15 @@ export async function makeApiCall( headers.Authorization = `Bearer ${options.bearerToken}`; } - if ('body' in options) { + let body: string | undefined; + if (options.method === 'POST') { headers['Content-Type'] = 'application/json'; + body = JSON.stringify(options.body); } return fetch(endpoint, { method: options.method, headers, - body: 'body' in options ? JSON.stringify(options.body) : undefined, + body, }); } From 36c9af57eb5ee696c0e1d1937ef087985826e806 Mon Sep 17 00:00:00 2001 From: Pedro Brighenti Date: Thu, 27 Aug 2026 12:27:56 +0100 Subject: [PATCH 4/4] chore: add changelog entry --- packages/notification-services-controller/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/notification-services-controller/CHANGELOG.md b/packages/notification-services-controller/CHANGELOG.md index 80a6fc263ff..30273d059a1 100644 --- a/packages/notification-services-controller/CHANGELOG.md +++ b/packages/notification-services-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `fetchMetamaskNotificationsCategories()` controller method that fetches the notification categories manifest from `GET /api/v4/notifications/categories`, stores the result in new `metamaskNotificationsCategories` state, and exposes it through the messenger as `NotificationServicesController:fetchMetamaskNotificationsCategories` ([#9984](https://github.com/MetaMask/core/pull/9984)) +- Add `metamaskNotificationsCategories` state field (`NotificationsCategory[]`) for the server-driven notification settings taxonomy ([#9984](https://github.com/MetaMask/core/pull/9984)) +- Add `isFetchingMetamaskNotificationsCategories` state flag that reflects whether the categories request is in flight ([#9984](https://github.com/MetaMask/core/pull/9984)) +- Export `NotificationsCategory` type from the notification-api schema ([#9984](https://github.com/MetaMask/core/pull/9984)) + ### Changed - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791))