diff --git a/indexer/packages/notifications/__tests__/localization.test.ts b/indexer/packages/notifications/__tests__/localization.test.ts index 5bba8f56e3..14707d87be 100644 --- a/indexer/packages/notifications/__tests__/localization.test.ts +++ b/indexer/packages/notifications/__tests__/localization.test.ts @@ -18,7 +18,7 @@ describe('deriveLocalizedNotificationMessage', () => { }); const expected = { - title: 'Deposit successful', + title: 'Deposit Successful', body: 'You have successfully deposited 1000 USDT to your dYdX account.', }; @@ -30,11 +30,12 @@ describe('deriveLocalizedNotificationMessage', () => { const notification = createNotification(NotificationType.ORDER_FILLED, { [NotificationDynamicFieldKey.MARKET]: 'BTC/USD', [NotificationDynamicFieldKey.AVERAGE_PRICE]: '45000', + [NotificationDynamicFieldKey.AMOUNT]: '1000', }); const expected = { - title: 'Filled BTC/USD order at 45000.', - body: 'Order Filled successful', + title: 'Your order for 1000 BTC/USD was filled at $45000', + body: 'Order Filled', }; const result = deriveLocalizedNotificationMessage(notification); diff --git a/indexer/packages/notifications/src/config.ts b/indexer/packages/notifications/src/config.ts index 41c605725e..b001ec4217 100644 --- a/indexer/packages/notifications/src/config.ts +++ b/indexer/packages/notifications/src/config.ts @@ -12,7 +12,7 @@ export const notificationsConfigSchema = { ...baseConfigSchema, // Private Key for the Google Firebase Messaging project - FIREBASE_PRIVATE_KEY_BASE64: parseString({ default: 'BASE64_ENCODED_PRIVATE_KEY' }), + FIREBASE_PRIVATE_KEY_BASE64: parseString({ default: 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tClBMQUNFSE9MREVSX0tFWV9GT1JfREVWRUxPUE1FTlQKLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo=' }), // APP ID for the Google Firebase Messaging project FIREBASE_PROJECT_ID: parseString({ default: 'dydx-v4' }), diff --git a/indexer/packages/notifications/src/lib/firebase.ts b/indexer/packages/notifications/src/lib/firebase.ts index 0468ed721d..905fa71154 100644 --- a/indexer/packages/notifications/src/lib/firebase.ts +++ b/indexer/packages/notifications/src/lib/firebase.ts @@ -1,5 +1,6 @@ import { logger } from '@dydxprotocol-indexer/base'; import { + App, cert, initializeApp, ServiceAccount, @@ -22,9 +23,19 @@ const initializeFirebaseApp = () => { const serviceAccount: ServiceAccount = defaultGoogleApplicationCredentials; - const firebaseApp = initializeApp({ - credential: cert(serviceAccount), - }); + let firebaseApp: App; + try { + firebaseApp = initializeApp({ + credential: cert(serviceAccount), + }); + } catch (error) { + logger.error({ + at: 'notifications#firebase', + message: 'Failed to initialize Firebase App', + error, + }); + return undefined; + } logger.info({ at: 'notifications#firebase', @@ -35,7 +46,30 @@ const initializeFirebaseApp = () => { }; const firebaseApp = initializeFirebaseApp(); -const firebaseMessaging = getMessaging(firebaseApp); +// Initialize Firebase Messaging if the app was initialized successfully +let firebaseMessaging = null; +if (firebaseApp) { + try { + firebaseMessaging = getMessaging(firebaseApp); + logger.info({ + at: 'notifications#firebase', + message: 'Firebase Messaging initialized successfully', + }); + } catch (error) { + logger.error({ + at: 'notifications#firebase', + message: 'Firebase Messaging failed to initialize', + }); + } +} -export const sendMulticast = firebaseMessaging.sendMulticast.bind(firebaseMessaging); +export const sendMulticast = firebaseMessaging + ? firebaseMessaging.sendMulticast.bind(firebaseMessaging) + : () => { + logger.error({ + at: 'notifications#firebase', + message: 'Firebase Messaging is not initialized, sendMulticast is a no-op', + }); + return Promise.resolve(null); + }; export { BatchResponse, getMessaging, MulticastMessage } from 'firebase-admin/messaging'; diff --git a/indexer/packages/notifications/src/localization.ts b/indexer/packages/notifications/src/localization.ts index acbc4f3eef..ac31636a32 100644 --- a/indexer/packages/notifications/src/localization.ts +++ b/indexer/packages/notifications/src/localization.ts @@ -11,10 +11,11 @@ function replacePlaceholders(template: string, variables: Record export function deriveLocalizedNotificationMessage(notification: Notification): NotificationMesage { const tempLocalizationFields = { - [LocalizationKey.DEPOSIT_SUCCESS_TITLE]: 'Deposit successful', + [LocalizationKey.DEPOSIT_SUCCESS_TITLE]: 'Deposit Successful', [LocalizationKey.DEPOSIT_SUCCESS_BODY]: 'You have successfully deposited {AMOUNT} {MARKET} to your dYdX account.', - [LocalizationKey.ORDER_FILLED_BODY]: 'Order Filled successful', - [LocalizationKey.ORDER_FILLED_TITLE]: 'Filled {MARKET} order at {AVERAGE_PRICE}.', + [LocalizationKey.ORDER_FILLED_BODY]: 'Order Filled', + // eslint-disable-next-line no-template-curly-in-string + [LocalizationKey.ORDER_FILLED_TITLE]: 'Your order for {AMOUNT} {MARKET} was filled at ${AVERAGE_PRICE}', }; switch (notification.type) { diff --git a/indexer/packages/notifications/src/message.ts b/indexer/packages/notifications/src/message.ts index 20807ee034..01faf863f6 100644 --- a/indexer/packages/notifications/src/message.ts +++ b/indexer/packages/notifications/src/message.ts @@ -2,7 +2,6 @@ import { logger } from '@dydxprotocol-indexer/base'; import { TokenTable } from '@dydxprotocol-indexer/postgres'; import { - BatchResponse, MulticastMessage, sendMulticast, } from './lib/firebase'; @@ -48,8 +47,8 @@ export async function sendFirebaseMessage( }; try { - const result: BatchResponse = await sendMulticast(message); - if (result.failureCount && result.failureCount > 0) { + const result = await sendMulticast(message); + if (result?.failureCount && result?.failureCount > 0) { logger.info({ at: 'notifications#firebase', message: `Failed to send Firebase message: ${JSON.stringify(message)}`, diff --git a/indexer/packages/notifications/src/types.ts b/indexer/packages/notifications/src/types.ts index ce58c23bed..4be0460380 100644 --- a/indexer/packages/notifications/src/types.ts +++ b/indexer/packages/notifications/src/types.ts @@ -36,42 +36,45 @@ export enum Deeplink { ORDER_FILLED = '/profile', } -export interface UserNotificationFields { - email?: string; - isEmailVerified: boolean; - notifications: {}; +export enum Topic { + TRADING = 'trading', + PRICE_ALERTS = 'price_alerts', } interface BaseNotification > { type: NotificationType, titleKey: LocalizationKey; bodyKey: LocalizationKey; + topic: Topic; deeplink: Deeplink; dynamicValues: T, } -export interface DepositSuccessNotification extends BaseNotification<{ +interface DepositSuccessNotification extends BaseNotification<{ [NotificationDynamicFieldKey.AMOUNT]: string; [NotificationDynamicFieldKey.MARKET]: string; }> { type: NotificationType.DEPOSIT_SUCCESS; titleKey: LocalizationKey.DEPOSIT_SUCCESS_TITLE; bodyKey: LocalizationKey.DEPOSIT_SUCCESS_BODY; + topic: Topic.TRADING; dynamicValues: { [NotificationDynamicFieldKey.AMOUNT]: string; [NotificationDynamicFieldKey.MARKET]: string; } } -export interface OrderFilledNotification extends BaseNotification <{ +interface OrderFilledNotification extends BaseNotification <{ [NotificationDynamicFieldKey.MARKET]: string; [NotificationDynamicFieldKey.AVERAGE_PRICE]: string; }>{ type: NotificationType.ORDER_FILLED; titleKey: LocalizationKey.ORDER_FILLED_TITLE; bodyKey: LocalizationKey.ORDER_FILLED_BODY; + topic: Topic.TRADING; dynamicValues: { [NotificationDynamicFieldKey.MARKET]: string; + [NotificationDynamicFieldKey.AMOUNT]: string; [NotificationDynamicFieldKey.AVERAGE_PRICE]: string; }; } @@ -97,6 +100,7 @@ export function createNotification( type: NotificationType.DEPOSIT_SUCCESS, titleKey: LocalizationKey.DEPOSIT_SUCCESS_TITLE, bodyKey: LocalizationKey.DEPOSIT_SUCCESS_BODY, + topic: Topic.TRADING, deeplink: Deeplink.DEPOSIT, dynamicValues: dynamicValues as DepositSuccessNotification['dynamicValues'], }; @@ -106,6 +110,7 @@ export function createNotification( type: NotificationType.ORDER_FILLED, titleKey: LocalizationKey.ORDER_FILLED_TITLE, bodyKey: LocalizationKey.ORDER_FILLED_BODY, + topic: Topic.TRADING, deeplink: Deeplink.ORDER_FILLED, dynamicValues: dynamicValues as OrderFilledNotification['dynamicValues'], }; diff --git a/indexer/pnpm-lock.yaml b/indexer/pnpm-lock.yaml index fb0a99cc40..50028e0057 100644 --- a/indexer/pnpm-lock.yaml +++ b/indexer/pnpm-lock.yaml @@ -545,6 +545,7 @@ importers: '@dydxprotocol-indexer/base': workspace:^0.0.1 '@dydxprotocol-indexer/dev': workspace:^0.0.1 '@dydxprotocol-indexer/kafka': workspace:^0.0.1 + '@dydxprotocol-indexer/notifications': workspace:^0.0.1 '@dydxprotocol-indexer/postgres': workspace:^0.0.1 '@dydxprotocol-indexer/redis': workspace:^0.0.1 '@dydxprotocol-indexer/v4-proto-parser': workspace:^0.0.1 @@ -571,6 +572,7 @@ importers: dependencies: '@dydxprotocol-indexer/base': link:../../packages/base '@dydxprotocol-indexer/kafka': link:../../packages/kafka + '@dydxprotocol-indexer/notifications': link:../../packages/notifications '@dydxprotocol-indexer/postgres': link:../../packages/postgres '@dydxprotocol-indexer/redis': link:../../packages/redis '@dydxprotocol-indexer/v4-proto-parser': link:../../packages/v4-proto-parser @@ -6406,6 +6408,7 @@ packages: resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} engines: {node: '>=0.4.0'} hasBin: true + dev: false /acorn/8.7.1: resolution: {integrity: sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A==} @@ -10528,7 +10531,7 @@ packages: pretty-format: 28.1.1 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.8.2_typescript@4.7.4 + ts-node: 10.8.2_2dd5d46eecda2aef953638919121af58 transitivePeerDependencies: - supports-color @@ -14008,7 +14011,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 - acorn: 8.10.0 + acorn: 8.7.1 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 @@ -14017,6 +14020,7 @@ packages: typescript: 4.7.4 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + dev: true /tsconfig-paths/3.14.1: resolution: {integrity: sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==} diff --git a/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts b/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts index 5c23391033..c107f8099c 100644 --- a/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts @@ -393,14 +393,10 @@ class AddressesController extends Controller { throw new NotFoundError(`No wallet found for address: ${address}`); } - const token = await TokenTable.findAll({ address }, []); - if (!token) { - throw new NotFoundError(`No token found for address: ${address}`); - } - try { const notification = createNotification(NotificationType.ORDER_FILLED, { [NotificationDynamicFieldKey.MARKET]: 'BTC/USD', + [NotificationDynamicFieldKey.AMOUNT]: '100', [NotificationDynamicFieldKey.AVERAGE_PRICE]: '1000', }); await sendFirebaseMessage(wallet.address, notification); diff --git a/indexer/services/ender/__tests__/helpers/notification-functions.test.ts b/indexer/services/ender/__tests__/helpers/notification-functions.test.ts new file mode 100644 index 0000000000..3f53aa1324 --- /dev/null +++ b/indexer/services/ender/__tests__/helpers/notification-functions.test.ts @@ -0,0 +1,82 @@ +import { sendOrderFilledNotification } from '../../src/helpers/notifications/notifications-functions'; +import { OrderFromDatabase } from '@dydxprotocol-indexer/postgres'; + +import { createNotification, sendFirebaseMessage, NotificationType } from '@dydxprotocol-indexer/notifications'; +import { defaultSubaccountId, defaultMarket } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; + +// Mock only the sendFirebaseMessage function +jest.mock('@dydxprotocol-indexer/notifications', () => { + const actualModule = jest.requireActual('@dydxprotocol-indexer/notifications'); + return { + ...actualModule, // keep all other exports intact + sendFirebaseMessage: jest.fn(), + createNotification: jest.fn(), + }; +}); + +describe('sendOrderFilledNotification', () => { + it('should create and send a notification', async () => { + const mockOrder: OrderFromDatabase = { + id: '1', + subaccountId: defaultSubaccountId, + clientId: '1', + clobPairId: String(defaultMarket.id), + side: 'BUY', + size: '10', + totalFilled: '0', + price: '100.50', + type: 'LIMIT', + status: 'OPEN', + timeInForce: 'GTT', + reduceOnly: false, + orderFlags: '0', + goodTilBlock: '1000000', + createdAtHeight: '900000', + clientMetadata: '0', + triggerPrice: undefined, + updatedAt: new Date().toISOString(), + updatedAtHeight: '900001', + } as OrderFromDatabase; + + await sendOrderFilledNotification(mockOrder); + + // Assert that createNotification was called with correct arguments + expect(createNotification).toHaveBeenCalledWith( + NotificationType.ORDER_FILLED, + { + AMOUNT: '10', + MARKET: 'BTC-USD', + AVERAGE_PRICE: '100.50', + }, + ); + + // Assert that sendFirebaseMessage was called with correct arguments + expect(sendFirebaseMessage).toHaveBeenCalledWith(defaultSubaccountId, undefined); + }); + + it('should throw an error if market is not found', async () => { + const mockOrder: OrderFromDatabase = { + id: '1', + subaccountId: 'subaccount123', + clientId: '1', + clobPairId: '1', + side: 'BUY', + size: '10', + totalFilled: '0', + price: '100.50', + type: 'LIMIT', + status: 'OPEN', + timeInForce: 'GTT', + reduceOnly: false, + orderFlags: '0', + goodTilBlock: '1000000', + createdAtHeight: '900000', + clientMetadata: '0', + triggerPrice: undefined, + updatedAt: new Date().toISOString(), + updatedAtHeight: '900001', + } as OrderFromDatabase; + + await expect(sendOrderFilledNotification(mockOrder)).rejects.toThrow('sendOrderFilledNotification # Market not found'); + }); +}); diff --git a/indexer/services/ender/package.json b/indexer/services/ender/package.json index 32a8199715..b8c40a60f7 100644 --- a/indexer/services/ender/package.json +++ b/indexer/services/ender/package.json @@ -22,6 +22,7 @@ "@dydxprotocol-indexer/redis": "workspace:^0.0.1", "@dydxprotocol-indexer/v4-proto-parser": "workspace:^0.0.1", "@dydxprotocol-indexer/v4-protos": "workspace:^0.0.1", + "@dydxprotocol-indexer/notifications": "workspace:^0.0.1", "big.js": "^6.0.2", "dd-trace": "^3.32.1", "dotenv-flow": "^3.2.0", diff --git a/indexer/services/ender/src/handlers/order-fills/order-handler.ts b/indexer/services/ender/src/handlers/order-fills/order-handler.ts index 1ac71c93d9..a94efcbcd3 100644 --- a/indexer/services/ender/src/handlers/order-fills/order-handler.ts +++ b/indexer/services/ender/src/handlers/order-fills/order-handler.ts @@ -1,9 +1,13 @@ +import { + createNotification, NotificationDynamicFieldKey, NotificationType, sendFirebaseMessage, +} from '@dydxprotocol-indexer/notifications'; import { FillFromDatabase, FillModel, Liquidity, MarketFromDatabase, MarketModel, + MarketTable, OrderFromDatabase, OrderModel, OrderStatus, @@ -31,6 +35,7 @@ import { orderFillWithLiquidityToOrderFillEventWithOrder } from '../../helpers/t import { OrderFillWithLiquidity } from '../../lib/translated-types'; import { ConsolidatedKafkaEvent, OrderFillEventWithOrder } from '../../lib/types'; import { AbstractOrderFillHandler } from './abstract-order-fill-handler'; +import { sendOrderFilledNotification } from 'src/helpers/notifications/notifications-functions'; export class OrderHandler extends AbstractOrderFillHandler { eventType: string = 'OrderFillEvent'; @@ -115,6 +120,11 @@ export class OrderHandler extends AbstractOrderFillHandler