Skip to content
21 changes: 21 additions & 0 deletions packages/notification-services-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **BREAKING:** Moved Notification API from v2 to v3 ([#7102](https://github.com/MetaMask/core/pull/7102))
- API Endpoint Changes: Updated from `/api/v2/notifications` to `/api/v3/notifications` for listing notifications and marking as read
- Request Format: The list notifications endpoint now expects `{ addresses: string[], locale?: string }` instead of `{ address: string }[]`
- Response Structure: Notifications now include a `notification_type` field ('on-chain' or 'platform') and nested payload structure
- On-chain notifications: data moved from root level to `payload.data`
- Platform notifications: new type with `template` containing localized content (`title`, `body`, `image_url`, `cta`)
- Type System Overhaul:
- `OnChainRawNotification` → `NormalisedAPINotification` (union of on-chain and platform)
- `UnprocessedOnChainRawNotification` → `UnprocessedRawNotification`
- Removed specific DeFi notification types (Aave, ENS, Lido rewards, etc.) - now will be handled generically
- Added `TRIGGER_TYPES.PLATFORM` for platform notifications
- Function Signatures:
- `getOnChainNotifications()` → `getAPINotifications()` with new `locale` parameter
- `getOnChainNotificationsConfigCached()` → `getNotificationsApiConfigCached()`
- `processOnChainNotification()` → `processAPINotifications()`
- Service Imports: Update imports from `onchain-notifications` to `api-notifications`
- Auto-expiry: Reduced from 90 days to 30 days for notification auto-expiry
- Locale Support: Added locale parameter to controller constructor for localized server notifications

## [19.0.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { ADDRESS_1, ADDRESS_2 } from './__fixtures__/mockAddresses';
import {
mockGetOnChainNotificationsConfig,
mockUpdateOnChainNotifications,
mockGetOnChainNotifications,
mockGetAPINotifications,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed mock util

mockFetchFeatureAnnouncementNotifications,
mockMarkNotificationsAsRead,
mockCreatePerpNotification,
Expand Down Expand Up @@ -594,7 +594,7 @@ describe('NotificationServicesController', () => {
const mockOnChainNotificationsAPIResult = [
createMockNotificationEthSent(),
];
const mockOnChainNotificationsAPI = mockGetOnChainNotifications({
const mockOnChainNotificationsAPI = mockGetAPINotifications({
status: 200,
body: mockOnChainNotificationsAPIResult,
});
Expand Down Expand Up @@ -705,7 +705,7 @@ describe('NotificationServicesController', () => {

// Mock APIs to fail
mockFetchFeatureAnnouncementNotifications({ status: 500 });
mockGetOnChainNotifications({ status: 500 });
mockGetAPINotifications({ status: 500 });

const controller = arrangeController(messenger);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,24 @@ import type { AuthenticationController } from '@metamask/profile-sync-controller
import { assert } from '@metamask/utils';
import log from 'loglevel';

import type { NormalisedAPINotification } from '.';
import { TRIGGER_TYPES } from './constants/notification-schema';
import {
processAndFilterNotifications,
safeProcessNotification,
} from './processors/process-notifications';
import * as FeatureNotifications from './services/feature-announcements';
import * as OnChainNotifications from './services/onchain-notifications';
import {
getAPINotifications,
getNotificationsApiConfigCached,
markNotificationsAsRead,
updateOnChainNotifications,
} from './services/api-notifications';
import { getFeatureAnnouncementNotifications } from './services/feature-announcements';
import { createPerpOrderNotification } from './services/perp-notifications';
import type {
INotification,
MarkAsReadNotificationsParam,
} from './types/notification/notification';
import type { OnChainRawNotification } from './types/on-chain-notification/on-chain-notification';
import type { OrderInput } from './types/perps';
import type {
NotificationServicesPushControllerEnablePushNotificationsAction,
Expand Down Expand Up @@ -500,6 +505,8 @@ export default class NotificationServicesController extends BaseController<
},
};

readonly #locale: () => string;

readonly #featureAnnouncementEnv: FeatureAnnouncementEnv;

/**
Expand All @@ -510,7 +517,7 @@ export default class NotificationServicesController extends BaseController<
* @param args.state - Initial state to set on this controller.
* @param args.env - environment variables for a given controller.
* @param args.env.featureAnnouncements - env variables for feature announcements.
* @param args.env.isPushIntegrated - toggle push notifications on/off if client has integrated them.
* @param args.env.locale - users locale for better dynamic server notifications
*/
constructor({
messenger,
Expand All @@ -521,7 +528,7 @@ export default class NotificationServicesController extends BaseController<
state?: Partial<NotificationServicesControllerState>;
env: {
featureAnnouncements: FeatureAnnouncementEnv;
isPushIntegrated?: boolean;
locale?: () => string;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We now pass in locale to handle server side in-app platform notifications

};
}) {
super({
Expand All @@ -532,6 +539,7 @@ export default class NotificationServicesController extends BaseController<
});

this.#featureAnnouncementEnv = env.featureAnnouncements;
this.#locale = env.locale ?? (() => 'en');
this.#registerMessageHandlers();
this.#clearLoadingStates();
}
Expand Down Expand Up @@ -694,11 +702,10 @@ export default class NotificationServicesController extends BaseController<
try {
const { bearerToken } = await this.#getBearerToken();
const { accounts } = this.#accounts.listAccounts();
const addressesWithNotifications =
await OnChainNotifications.getOnChainNotificationsConfigCached(
bearerToken,
accounts,
);
const addressesWithNotifications = await getNotificationsApiConfigCached(
bearerToken,
accounts,
);
const addresses = addressesWithNotifications
.filter((a) => Boolean(a.enabled))
.map((a) => a.address);
Expand All @@ -725,11 +732,10 @@ export default class NotificationServicesController extends BaseController<

// Retrieve user storage
const { bearerToken } = await this.#getBearerToken();
const addressesWithNotifications =
await OnChainNotifications.getOnChainNotificationsConfigCached(
bearerToken,
accounts,
);
const addressesWithNotifications = await getNotificationsApiConfigCached(
bearerToken,
accounts,
);

const result: Record<string, boolean> = {};
addressesWithNotifications.forEach((a) => {
Expand Down Expand Up @@ -788,11 +794,10 @@ export default class NotificationServicesController extends BaseController<
const { accounts } = this.#accounts.listAccounts();

// 1. See if has enabled notifications before
const addressesWithNotifications =
await OnChainNotifications.getOnChainNotificationsConfigCached(
bearerToken,
accounts,
);
const addressesWithNotifications = await getNotificationsApiConfigCached(
bearerToken,
accounts,
);

// Notifications API can return array with addresses set to false
// So assert that at least one address is enabled
Expand All @@ -802,7 +807,7 @@ export default class NotificationServicesController extends BaseController<

// 2. Enable Notifications (if no accounts subscribed or we are resetting)
if (accountsWithNotifications.length === 0 || opts?.resetNotifications) {
await OnChainNotifications.updateOnChainNotifications(
await updateOnChainNotifications(
bearerToken,
accounts.map((address) => ({ address, enabled: true })),
);
Expand Down Expand Up @@ -903,7 +908,7 @@ export default class NotificationServicesController extends BaseController<
const { bearerToken } = await this.#getBearerToken();

// Delete these UUIDs (Mutates User Storage)
await OnChainNotifications.updateOnChainNotifications(
await updateOnChainNotifications(
bearerToken,
accounts.map((address) => ({ address, enabled: false })),
);
Expand Down Expand Up @@ -935,7 +940,7 @@ export default class NotificationServicesController extends BaseController<
this.#updateUpdatingAccountsState(accounts);

const { bearerToken } = await this.#getBearerToken();
await OnChainNotifications.updateOnChainNotifications(
await updateOnChainNotifications(
bearerToken,
accounts.map((address) => ({ address, enabled: true })),
);
Expand Down Expand Up @@ -970,31 +975,29 @@ export default class NotificationServicesController extends BaseController<
// Raw Feature Notifications
const rawAnnouncements =
isGlobalNotifsEnabled && this.state.isFeatureAnnouncementsEnabled
? await FeatureNotifications.getFeatureAnnouncementNotifications(
? await getFeatureAnnouncementNotifications(
this.#featureAnnouncementEnv,
previewToken,
).catch(() => [])
: [];

// Raw On Chain Notifications
const rawOnChainNotifications: OnChainRawNotification[] = [];
const rawOnChainNotifications: NormalisedAPINotification[] = [];
if (isGlobalNotifsEnabled) {
try {
const { bearerToken } = await this.#getBearerToken();
const { accounts } = this.#accounts.listAccounts();
const addressesWithNotifications = (
await OnChainNotifications.getOnChainNotificationsConfigCached(
bearerToken,
accounts,
)
await getNotificationsApiConfigCached(bearerToken, accounts)
)
.filter((a) => Boolean(a.enabled))
.map((a) => a.address);
const notifications =
await OnChainNotifications.getOnChainNotifications(
bearerToken,
addressesWithNotifications,
).catch(() => []);
const notifications = await getAPINotifications(
bearerToken,
addressesWithNotifications,
this.#locale(),
this.#featureAnnouncementEnv.platform,
).catch(() => []);
rawOnChainNotifications.push(...notifications);
} catch {
// Do nothing
Expand Down Expand Up @@ -1165,7 +1168,7 @@ export default class NotificationServicesController extends BaseController<
onchainNotificationIds = onChainNotifications.map(
(notification) => notification.id,
);
await OnChainNotifications.markNotificationsAsRead(
await markNotificationsAsRead(
bearerToken,
onchainNotificationIds,
).catch(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const mockGetOnChainNotificationsConfig = (mockReply?: MockReply) => {
return mockEndpoint;
};

export const mockGetOnChainNotifications = (mockReply?: MockReply) => {
export const mockGetAPINotifications = (mockReply?: MockReply) => {
const mockResponse = getMockListNotificationsResponse();
const reply = mockReply ?? { status: 200, body: mockResponse.response };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,11 @@ export enum TRIGGER_TYPES {
ERC721_RECEIVED = 'erc721_received',
ERC1155_SENT = 'erc1155_sent',
ERC1155_RECEIVED = 'erc1155_received',
AAVE_V3_HEALTH_FACTOR = 'aave_v3_health_factor',
ENS_EXPIRATION = 'ens_expiration',
LIDO_STAKING_REWARDS = 'lido_staking_rewards',
ROCKETPOOL_STAKING_REWARDS = 'rocketpool_staking_rewards',
NOTIONAL_LOAN_EXPIRATION = 'notional_loan_expiration',
SPARK_FI_HEALTH_FACTOR = 'spark_fi_health_factor',
SNAP = 'snap',
PLATFORM = 'platform',
}

export const TRIGGER_TYPES_WALLET_SET: Set<string> = new Set([
export const NOTIFICATION_API_TRIGGER_TYPES_SET: Set<string> = new Set([
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update set and add new types supported by our API

TRIGGER_TYPES.METAMASK_SWAP_COMPLETED,
TRIGGER_TYPES.ERC20_SENT,
TRIGGER_TYPES.ERC20_RECEIVED,
Expand All @@ -40,13 +35,8 @@ export const TRIGGER_TYPES_WALLET_SET: Set<string> = new Set([
TRIGGER_TYPES.ERC721_RECEIVED,
TRIGGER_TYPES.ERC1155_SENT,
TRIGGER_TYPES.ERC1155_RECEIVED,
]) satisfies Set<Exclude<TRIGGER_TYPES, TRIGGER_TYPES.FEATURES_ANNOUNCEMENT>>;

export enum TRIGGER_TYPES_GROUPS {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

never used, removed

RECEIVED = 'received',
SENT = 'sent',
DEFI = 'defi',
}
TRIGGER_TYPES.PLATFORM,
]);

export const NOTIFICATION_CHAINS_ID = {
ETHEREUM: '1',
Expand Down
Loading
Loading