diff --git a/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts b/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts
index dd9214e904..b767230b0b 100644
--- a/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts
+++ b/indexer/packages/postgres/src/stores/firebase-notification-token-table.ts
@@ -21,6 +21,7 @@ export async function findAll(
{
address,
limit,
+ updatedBeforeOrAt,
}: FirebaseNotificationTokenQueryConfig,
requiredFields: QueryableField[],
options: Options = DEFAULT_POSTGRES_OPTIONS,
@@ -42,6 +43,10 @@ export async function findAll(
baseQuery = baseQuery.where(FirebaseNotificationTokenColumns.address, address);
}
+ if (updatedBeforeOrAt) {
+ baseQuery = baseQuery.where(FirebaseNotificationTokenColumns.updatedAt, '<=', updatedBeforeOrAt);
+ }
+
if (options.orderBy !== undefined) {
for (const [column, order] of options.orderBy) {
baseQuery = baseQuery.orderBy(
diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts
index 66e059fb36..d52f90ca12 100644
--- a/indexer/packages/postgres/src/types/query-types.ts
+++ b/indexer/packages/postgres/src/types/query-types.ts
@@ -342,4 +342,5 @@ export interface AffiliateInfoQueryConfig extends QueryConfig {
export interface FirebaseNotificationTokenQueryConfig extends QueryConfig {
[QueryableField.ADDRESS]?: string,
[QueryableField.TOKEN]?: string,
+ [QueryableField.UPDATED_BEFORE_OR_AT]?: IsoString,
}
diff --git a/indexer/pnpm-lock.yaml b/indexer/pnpm-lock.yaml
index 25c6981e7e..238ed2e048 100644
--- a/indexer/pnpm-lock.yaml
+++ b/indexer/pnpm-lock.yaml
@@ -448,6 +448,7 @@ importers:
'@dydxprotocol-indexer/base': workspace:^0.0.1
'@dydxprotocol-indexer/compliance': workspace:^0.0.1
'@dydxprotocol-indexer/dev': 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
@@ -499,6 +500,7 @@ importers:
'@cosmjs/encoding': 0.32.3
'@dydxprotocol-indexer/base': link:../../packages/base
'@dydxprotocol-indexer/compliance': link:../../packages/compliance
+ '@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
diff --git a/indexer/services/comlink/.env.test b/indexer/services/comlink/.env.test
index 167901ba36..30ae74517a 100644
--- a/indexer/services/comlink/.env.test
+++ b/indexer/services/comlink/.env.test
@@ -7,3 +7,6 @@ DB_PORT=5436
RATE_LIMIT_ENABLED=false
INDEXER_LEVEL_GEOBLOCKING_ENABLED=false
EXPOSE_SET_COMPLIANCE_ENDPOINT=true
+FIREBASE_PROJECT_ID=projectID
+FIREBASE_PRIVATE_KEY='-----BEGIN RSA PRIVATE KEY----------END RSA PRIVATE KEY-----'
+FIREBASE_CLIENT_EMAIL=clientEmail@test.com
diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts
index 9930eb80df..6a7cb0d8b6 100644
--- a/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts
+++ b/indexer/services/comlink/__tests__/controllers/api/v4/addresses-controller.test.ts
@@ -10,11 +10,13 @@ import {
BlockTable,
liquidityTierRefresher,
SubaccountTable,
+ FirebaseNotificationTokenTable,
} from '@dydxprotocol-indexer/postgres';
import { RequestMethod } from '../../../../src/types';
import request from 'supertest';
import { getFixedRepresentation, sendRequest } from '../../../helpers/helpers';
import { stats } from '@dydxprotocol-indexer/base';
+import config from '../../../../src/config';
describe('addresses-controller#V4', () => {
const latestHeight: string = '3';
@@ -42,6 +44,7 @@ describe('addresses-controller#V4', () => {
afterEach(async () => {
await dbHelpers.clearData();
+ jest.clearAllMocks();
});
const invalidAddress: string = 'invalidAddress';
@@ -574,4 +577,142 @@ describe('addresses-controller#V4', () => {
});
});
+ describe('/:address/testNotification', () => {
+ it('Post /:address/testNotification throws error in production', async () => {
+ // Mock the config to simulate production environment
+ const originalNodeEnv = config.NODE_ENV;
+ config.NODE_ENV = 'production';
+
+ const response: request.Response = await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${testConstants.defaultAddress}/testNotification`,
+ expectedStatus: 404,
+ });
+
+ expect(response.statusCode).toEqual(404);
+ // Restore the original NODE_ENV
+ config.NODE_ENV = originalNodeEnv;
+ });
+ });
+
+ describe('/:address/registerToken', () => {
+ it('Post /:address/registerToken with valid params returns 200', async () => {
+ const token = 'validToken';
+ const language = 'en';
+ const response: request.Response = await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`,
+ body: { token, language },
+ expectedStatus: 200,
+ });
+
+ expect(response.body).toEqual({});
+ expect(stats.increment).toHaveBeenCalledWith('comlink.addresses-controller.response_status_code.200', 1, {
+ path: '/:address/registerToken',
+ method: 'POST',
+ });
+ });
+
+ it('should register a new token', async () => {
+ // Register a new token
+ const newToken = 'newToken';
+ const language = 'en';
+ await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`,
+ body: { token: newToken, language },
+ expectedStatus: 200,
+ });
+
+ // Check that old tokens are deleted and new token is registered
+ const remainingTokens = await FirebaseNotificationTokenTable.findAll({}, []);
+ expect(remainingTokens.map((t) => t.token)).toContain(newToken);
+ });
+
+ it('Post /:address/registerToken with valid params calls TokenTable registerToken', async () => {
+ jest.spyOn(FirebaseNotificationTokenTable, 'registerToken');
+ const token = 'validToken';
+ const language = 'en';
+ await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`,
+ body: { token, language },
+ expectedStatus: 200,
+ });
+ expect(FirebaseNotificationTokenTable.registerToken).toHaveBeenCalledWith(
+ token, testConstants.defaultAddress, language,
+ );
+ expect(stats.increment).toHaveBeenCalledWith('comlink.addresses-controller.response_status_code.200', 1, {
+ path: '/:address/registerToken',
+ method: 'POST',
+ });
+ });
+
+ it('Post /:address/registerToken with invalid address returns 404', async () => {
+ const token = 'validToken';
+ const response: request.Response = await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${invalidAddress}/registerToken`,
+ body: { token },
+ expectedStatus: 404,
+ });
+
+ expect(response.body).toEqual({
+ errors: [
+ {
+ msg: 'No wallet found with address: invalidAddress',
+ },
+ ],
+ });
+ expect(stats.increment).toHaveBeenCalledWith('comlink.addresses-controller.response_status_code.404', 1, {
+ path: '/:address/registerToken',
+ method: 'POST',
+ });
+ });
+
+ it.each([
+ ['validToken', '', 'Invalid language code', 'language'],
+ ['validToken', 'qq', 'Invalid language code', 'language'],
+ ])('Post /:address/registerToken with bad language params returns 400', async (token, language, errorMsg, errorParam) => {
+ const response: request.Response = await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`,
+ body: { token, language },
+ expectedStatus: 400,
+ });
+
+ expect(response.body).toEqual({
+ errors: [
+ {
+ location: 'body',
+ msg: errorMsg,
+ param: errorParam,
+ value: language,
+ },
+ ],
+ });
+ });
+
+ it.each([
+ ['', 'en', 'Token cannot be empty', 'token'],
+ ])('Post /:address/registerToken with bad token params returns 400', async (token, language, errorMsg, errorParam) => {
+ const response: request.Response = await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`,
+ body: { token, language },
+ expectedStatus: 400,
+ });
+
+ expect(response.body).toEqual({
+ errors: [
+ {
+ location: 'body',
+ msg: errorMsg,
+ param: errorParam,
+ value: token,
+ },
+ ],
+ });
+ });
+ });
});
diff --git a/indexer/services/comlink/package.json b/indexer/services/comlink/package.json
index ba12e7064b..29ea1def60 100644
--- a/indexer/services/comlink/package.json
+++ b/indexer/services/comlink/package.json
@@ -33,6 +33,7 @@
"@dydxprotocol-indexer/v4-proto-parser": "workspace:^0.0.1",
"@dydxprotocol-indexer/v4-protos": "workspace:^0.0.1",
"@keplr-wallet/cosmos": "^0.12.122",
+ "@dydxprotocol-indexer/notifications": "workspace:^0.0.1",
"@tsoa/runtime": "^5.0.0",
"big.js": "^6.2.1",
"body-parser": "^1.20.0",
diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md
index b047e1291a..0439c0e85f 100644
--- a/indexer/services/comlink/public/api-documentation.md
+++ b/indexer/services/comlink/public/api-documentation.md
@@ -368,6 +368,141 @@ fetch(`${baseURL}/addresses/{address}/parentSubaccountNumber/{parentSubaccountNu
This operation does not require authentication
+## RegisterToken
+
+
+
+> Code samples
+
+```python
+import requests
+headers = {
+ 'Content-Type': 'application/json'
+}
+
+# For the deployment by DYDX token holders, use
+# baseURL = 'https://indexer.dydx.trade/v4'
+baseURL = 'https://dydx-testnet.imperator.co/v4'
+
+r = requests.post(f'{baseURL}/addresses/{address}/registerToken', headers = headers)
+
+print(r.json())
+
+```
+
+```javascript
+const inputBody = '{
+ "language": "string",
+ "token": "string"
+}';
+const headers = {
+ 'Content-Type':'application/json'
+};
+
+// For the deployment by DYDX token holders, use
+// const baseURL = 'https://indexer.dydx.trade/v4';
+const baseURL = 'https://dydx-testnet.imperator.co/v4';
+
+fetch(`${baseURL}/addresses/{address}/registerToken`,
+{
+ method: 'POST',
+ body: inputBody,
+ headers: headers
+})
+.then(function(res) {
+ return res.json();
+}).then(function(body) {
+ console.log(body);
+});
+
+```
+
+`POST /addresses/{address}/registerToken`
+
+> Body parameter
+
+```json
+{
+ "language": "string",
+ "token": "string"
+}
+```
+
+### Parameters
+
+|Name|In|Type|Required|Description|
+|---|---|---|---|---|
+|address|path|string|true|none|
+|body|body|object|true|none|
+|» language|body|string|true|none|
+|» token|body|string|true|none|
+
+### Responses
+
+|Status|Meaning|Description|Schema|
+|---|---|---|---|
+|204|[No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5)|No content|None|
+
+
+
+## TestNotification
+
+
+
+> Code samples
+
+```python
+import requests
+
+# For the deployment by DYDX token holders, use
+# baseURL = 'https://indexer.dydx.trade/v4'
+baseURL = 'https://dydx-testnet.imperator.co/v4'
+
+r = requests.post(f'{baseURL}/addresses/{address}/testNotification')
+
+print(r.json())
+
+```
+
+```javascript
+
+// For the deployment by DYDX token holders, use
+// const baseURL = 'https://indexer.dydx.trade/v4';
+const baseURL = 'https://dydx-testnet.imperator.co/v4';
+
+fetch(`${baseURL}/addresses/{address}/testNotification`,
+{
+ method: 'POST'
+
+})
+.then(function(res) {
+ return res.json();
+}).then(function(body) {
+ console.log(body);
+});
+
+```
+
+`POST /addresses/{address}/testNotification`
+
+### Parameters
+
+|Name|In|Type|Required|Description|
+|---|---|---|---|---|
+|address|path|string|true|none|
+
+### Responses
+
+|Status|Meaning|Description|Schema|
+|---|---|---|---|
+|204|[No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5)|No content|None|
+
+
+
## GetMetadata
diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json
index 628991933a..5fc7a5cff8 100644
--- a/indexer/services/comlink/public/swagger.json
+++ b/indexer/services/comlink/public/swagger.json
@@ -1679,6 +1679,70 @@
]
}
},
+ "/addresses/{address}/registerToken": {
+ "post": {
+ "operationId": "RegisterToken",
+ "responses": {
+ "204": {
+ "description": "No content"
+ }
+ },
+ "security": [],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "address",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "language": {
+ "type": "string"
+ },
+ "token": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "language",
+ "token"
+ ],
+ "type": "object"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/addresses/{address}/testNotification": {
+ "post": {
+ "operationId": "TestNotification",
+ "responses": {
+ "204": {
+ "description": "No content"
+ }
+ },
+ "security": [],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "address",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
"/affiliates/metadata": {
"get": {
"operationId": "GetMetadata",
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 7847a1cf16..509daf0fcd 100644
--- a/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts
+++ b/indexer/services/comlink/src/controllers/api/v4/addresses-controller.ts
@@ -1,4 +1,7 @@
-import { stats } from '@dydxprotocol-indexer/base';
+import { logger, NodeEnv, stats } from '@dydxprotocol-indexer/base';
+import {
+ createNotification, NotificationType, NotificationDynamicFieldKey, sendFirebaseMessage,
+} from '@dydxprotocol-indexer/notifications';
import {
AssetPositionFromDatabase,
BlockTable,
@@ -20,6 +23,7 @@ import {
WalletTable,
WalletFromDatabase,
perpetualMarketRefresher,
+ FirebaseNotificationTokenTable,
} from '@dydxprotocol-indexer/postgres';
import Big from 'big.js';
import express from 'express';
@@ -28,12 +32,14 @@ import {
} from 'express-validator';
import {
Route, Get, Path, Controller,
+ Post,
+ Body,
} from 'tsoa';
import { getReqRateLimiter } from '../../../caches/rate-limiters';
import config from '../../../config';
import { complianceAndGeoCheck } from '../../../lib/compliance-and-geo-check';
-import { NotFoundError } from '../../../lib/errors';
+import { DatabaseError, NotFoundError } from '../../../lib/errors';
import {
getFundingIndexMaps,
handleControllerError,
@@ -41,7 +47,12 @@ import {
getSubaccountResponse,
} from '../../../lib/helpers';
import { rateLimiterMiddleware } from '../../../lib/rate-limit';
-import { CheckAddressSchema, CheckParentSubaccountSchema, CheckSubaccountSchema } from '../../../lib/validation/schemas';
+import {
+ CheckAddressSchema,
+ CheckParentSubaccountSchema,
+ CheckSubaccountSchema,
+ RegisterTokenValidationSchema,
+} from '../../../lib/validation/schemas';
import { handleValidationErrors } from '../../../request-helpers/error-handler';
import ExportResponseCodeStats from '../../../request-helpers/export-response-code-stats';
import {
@@ -51,6 +62,7 @@ import {
AddressResponse,
ParentSubaccountResponse,
ParentSubaccountRequest,
+ RegisterTokenRequest,
} from '../../../types';
const router: express.Router = express.Router();
@@ -294,6 +306,59 @@ class AddressesController extends Controller {
childSubaccounts: subaccountResponses,
};
}
+
+ @Post('/:address/registerToken')
+ public async registerToken(
+ @Path() address: string,
+ @Body() body: { token: string, language: string },
+ ): Promise {
+ const { token, language } = body;
+ const wallet = await WalletTable.findById(address);
+ if (!wallet) {
+ throw new NotFoundError(`No wallet found with address: ${address}`);
+ }
+ try {
+ // Register the new token
+ await FirebaseNotificationTokenTable.registerToken(
+ token,
+ wallet.address,
+ language,
+ );
+ } catch (error) {
+ throw new DatabaseError(`Error registering token: ${error}`);
+ }
+ }
+
+ @Post('/:address/testNotification')
+ public async testNotification(
+ @Path() address: string,
+ ): Promise {
+ try {
+ const wallet = await WalletTable.findById(address);
+ if (!wallet) {
+ throw new NotFoundError(`No wallet found for address: ${address}`);
+ }
+ const allTokens = await FirebaseNotificationTokenTable.findAll(
+ { address: wallet.address }, [],
+ );
+ if (allTokens.length === 0) {
+ throw new NotFoundError(`No tokens found for address: ${address}`);
+ }
+
+ const notification = createNotification(NotificationType.ORDER_FILLED, {
+ [NotificationDynamicFieldKey.MARKET]: 'BTC/USD',
+ [NotificationDynamicFieldKey.AMOUNT]: '100',
+ [NotificationDynamicFieldKey.AVERAGE_PRICE]: '1000',
+ });
+ await sendFirebaseMessage(allTokens, notification);
+ } catch (error) {
+ logger.error({
+ at: 'addresses-controller#testNotification',
+ message: error.message,
+ error,
+ });
+ }
+ }
}
router.get(
@@ -426,6 +491,67 @@ router.get(
},
);
+router.post(
+ '/:address/registerToken',
+ CheckAddressSchema,
+ RegisterTokenValidationSchema,
+ handleValidationErrors,
+ ExportResponseCodeStats({ controllerName }),
+ async (req: express.Request, res: express.Response) => {
+ const start: number = Date.now();
+ const { address, token, language = 'en' } = matchedData(req) as RegisterTokenRequest;
+
+ try {
+ const controller: AddressesController = new AddressesController();
+ await controller.registerToken(address, { token, language });
+ return res.status(200).send({});
+ } catch (error) {
+ return handleControllerError(
+ 'AddressesController POST /:address/registerToken',
+ 'Addresses error',
+ error,
+ req,
+ res,
+ );
+ } finally {
+ stats.timing(
+ `${config.SERVICE_NAME}.${controllerName}.post_registerToken.timing`,
+ Date.now() - start,
+ );
+ }
+ },
+);
+
+router.post(
+ '/:address/testNotification',
+ rateLimiterMiddleware(getReqRateLimiter),
+ ...CheckAddressSchema,
+ handleValidationErrors,
+ ExportResponseCodeStats({ controllerName }),
+ async (req: express.Request, res: express.Response) => {
+ // This endpoint should only be avaliable in testnet / staging
+ if (config.NODE_ENV === NodeEnv.PRODUCTION) {
+ return res.status(404).send();
+ }
+
+ const { address } = matchedData(req) as AddressRequest;
+
+ try {
+ const controller: AddressesController = new AddressesController();
+ await controller.testNotification(address);
+ return res.status(200).send({ message: 'Test notification sent successfully' });
+ } catch (error) {
+ return handleControllerError(
+ 'AddressesController POST /:address/testNotification',
+ 'Test notification error',
+ error,
+ req,
+ res,
+ );
+ }
+ },
+);
+
// eslint-disable-next-line @typescript-eslint/require-await
async function getOpenPerpetualPositionsForSubaccount(
subaccountId: string,
diff --git a/indexer/services/comlink/src/lib/errors.ts b/indexer/services/comlink/src/lib/errors.ts
index 8081c3fee4..19a0971dee 100644
--- a/indexer/services/comlink/src/lib/errors.ts
+++ b/indexer/services/comlink/src/lib/errors.ts
@@ -18,3 +18,24 @@ export class NotFoundError extends Error {
this.name = 'NotFoundError';
}
}
+
+export class BadRequestError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'BadRequestError';
+ }
+}
+
+export class DatabaseError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'DatabaseError';
+ }
+}
+
+export class InvalidParamError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'InvalidParamError';
+ }
+}
diff --git a/indexer/services/comlink/src/lib/helpers.ts b/indexer/services/comlink/src/lib/helpers.ts
index 5041c6fb11..6da4106907 100644
--- a/indexer/services/comlink/src/lib/helpers.ts
+++ b/indexer/services/comlink/src/lib/helpers.ts
@@ -47,7 +47,7 @@ import {
SubaccountResponseObject,
} from '../types';
import { ZERO, ZERO_USDC_POSITION } from './constants';
-import { NotFoundError } from './errors';
+import { InvalidParamError, NotFoundError } from './errors';
/* ------- GENERIC HELPERS ------- */
@@ -68,6 +68,9 @@ export function handleControllerError(
if (error instanceof NotFoundError) {
return handleNotFoundError(error.message, res);
}
+ if (error instanceof InvalidParamError) {
+ return handleInvalidParamError(error.message, res);
+ }
return handleInternalServerError(
at,
message,
@@ -100,6 +103,17 @@ function handleInternalServerError(
return createInternalServerErrorResponse(res);
}
+function handleInvalidParamError(
+ message: string,
+ res: express.Response,
+): express.Response {
+ return res.status(400).json({
+ errors: [{
+ msg: message,
+ }],
+ });
+}
+
function handleNotFoundError(
message: string,
res: express.Response,
diff --git a/indexer/services/comlink/src/lib/validation/schemas.ts b/indexer/services/comlink/src/lib/validation/schemas.ts
index 6a2d79d11b..d954f31cec 100644
--- a/indexer/services/comlink/src/lib/validation/schemas.ts
+++ b/indexer/services/comlink/src/lib/validation/schemas.ts
@@ -1,9 +1,10 @@
+import { isValidLanguageCode } from '@dydxprotocol-indexer/notifications';
import {
perpetualMarketRefresher,
MAX_PARENT_SUBACCOUNTS,
CHILD_SUBACCOUNT_MULTIPLIER,
} from '@dydxprotocol-indexer/postgres';
-import { checkSchema, ParamSchema } from 'express-validator';
+import { body, checkSchema, ParamSchema } from 'express-validator';
import config from '../../config';
@@ -212,3 +213,22 @@ export const CheckHistoricalBlockTradingRewardsSchema = checkSchema({
});
export const CheckTransferBetweenSchema = checkSchema(transferBetweenSchemaRecord);
+
+export const RegisterTokenValidationSchema = [
+ body('token')
+ .exists().withMessage('Token is required')
+ .isString()
+ .withMessage('Token must be a string')
+ .notEmpty()
+ .withMessage('Token cannot be empty'),
+ body('language')
+ .optional()
+ .isString()
+ .withMessage('Language must be a string')
+ .custom((value: string) => {
+ if (!isValidLanguageCode(value)) {
+ throw new Error('Invalid language code');
+ }
+ return true;
+ }),
+];
diff --git a/indexer/services/comlink/src/types.ts b/indexer/services/comlink/src/types.ts
index 9e502e7cd6..2867e0827b 100644
--- a/indexer/services/comlink/src/types.ts
+++ b/indexer/services/comlink/src/types.ts
@@ -550,6 +550,12 @@ export interface HistoricalFundingRequest extends LimitAndEffectiveBeforeRequest
ticker: string,
}
+export interface RegisterTokenRequest {
+ address: string,
+ token: string,
+ language: string,
+}
+
/* ------- COLLATERALIZATION TYPES ------- */
export interface Risk {
diff --git a/indexer/services/roundtable/__tests__/tasks/delete-old-firebase-notification-tokens.test.ts b/indexer/services/roundtable/__tests__/tasks/delete-old-firebase-notification-tokens.test.ts
new file mode 100644
index 0000000000..3b3b3ff581
--- /dev/null
+++ b/indexer/services/roundtable/__tests__/tasks/delete-old-firebase-notification-tokens.test.ts
@@ -0,0 +1,64 @@
+import { stats } from '@dydxprotocol-indexer/base';
+import { dbHelpers, FirebaseNotificationTokenTable, testMocks } from '@dydxprotocol-indexer/postgres';
+
+import config from '../../src/config';
+import runTask from '../../src/tasks/delete-old-firebase-notification-tokens';
+import { defaultWallet } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants';
+
+describe('delete-old-firebase-notification-tokens', () => {
+ beforeAll(async () => {
+ await dbHelpers.migrate();
+ jest.spyOn(stats, 'timing');
+ });
+
+ beforeEach(async () => {
+ await testMocks.seedData();
+ });
+
+ afterEach(async () => {
+ await dbHelpers.clearData();
+ jest.clearAllMocks();
+ });
+
+ afterAll(async () => {
+ await dbHelpers.teardown();
+ jest.resetAllMocks();
+ });
+
+ it('deletes old Firebase notification tokens', async () => {
+ // Create test data
+ const currentDate = new Date();
+ const oldDate = new Date(currentDate.getTime() - 40 * 24 * 60 * 60 * 1000); // 40 days ago
+ const recentDate = new Date(currentDate.getTime() - 15 * 24 * 60 * 60 * 1000); // 15 days ago
+
+ await FirebaseNotificationTokenTable.create({
+ token: 'old_token',
+ updatedAt: oldDate.toISOString(),
+ address: defaultWallet.address,
+ language: 'en',
+ });
+ await FirebaseNotificationTokenTable.create({
+ token: 'recent_token',
+ updatedAt: recentDate.toISOString(),
+ address: defaultWallet.address,
+ language: 'fr',
+ });
+
+ const initialTokens = await FirebaseNotificationTokenTable.findAll({}, []);
+ expect(initialTokens.length).toBe(3);
+
+ // Run the task
+ await runTask();
+
+ // Check if old token was deleted and recent token remains
+ const remainingTokens = await FirebaseNotificationTokenTable.findAll({}, []);
+ expect(remainingTokens.length).toBe(2);
+ expect(remainingTokens[0].token).toBe('recent_token');
+
+ // Check if stats.timing was called
+ expect(stats.timing).toHaveBeenCalledWith(
+ expect.stringContaining(`${config.SERVICE_NAME}.delete_old_firebase_notification_tokens`),
+ expect.any(Number),
+ );
+ });
+});
diff --git a/indexer/services/roundtable/src/config.ts b/indexer/services/roundtable/src/config.ts
index a0f3a03bb5..2e712c54ac 100644
--- a/indexer/services/roundtable/src/config.ts
+++ b/indexer/services/roundtable/src/config.ts
@@ -58,6 +58,7 @@ export const configSchema = {
LOOPS_ENABLED_LEADERBOARD_PNL_MONTHLY: parseBoolean({ default: false }),
LOOPS_ENABLED_LEADERBOARD_PNL_YEARLY: parseBoolean({ default: false }),
LOOPS_ENABLED_UPDATE_WALLET_TOTAL_VOLUME: parseBoolean({ default: true }),
+ LOOPS_ENABLED_DELETE_OLD_FIREBASE_NOTIFICATION_TOKENS: parseBoolean({ default: true }),
// Loop Timing
LOOPS_INTERVAL_MS_MARKET_UPDATER: parseInteger({
@@ -129,6 +130,9 @@ export const configSchema = {
LOOPS_INTERVAL_MS_UPDATE_WALLET_TOTAL_VOLUME: parseInteger({
default: THIRTY_SECONDS_IN_MILLISECONDS,
}),
+ LOOPS_INTERVAL_MS_DELETE_FIREBASE_NOTIFICATION_TOKENS_MONTHLY: parseInteger({
+ default: 30 * ONE_DAY_IN_MILLISECONDS,
+ }),
// Start delay
START_DELAY_ENABLED: parseBoolean({ default: true }),
diff --git a/indexer/services/roundtable/src/index.ts b/indexer/services/roundtable/src/index.ts
index c36af62b5a..09b83c5e77 100644
--- a/indexer/services/roundtable/src/index.ts
+++ b/indexer/services/roundtable/src/index.ts
@@ -14,6 +14,7 @@ import cancelStaleOrdersTask from './tasks/cancel-stale-orders';
import createLeaderboardTask from './tasks/create-leaderboard';
import createPnlTicksTask from './tasks/create-pnl-ticks';
import deleteOldFastSyncSnapshots from './tasks/delete-old-fast-sync-snapshots';
+import deleteOldFirebaseNotificationTokensTask from './tasks/delete-old-firebase-notification-tokens';
import deleteZeroPriceLevelsTask from './tasks/delete-zero-price-levels';
import marketUpdaterTask from './tasks/market-updater';
import orderbookInstrumentationTask from './tasks/orderbook-instrumentation';
@@ -256,6 +257,14 @@ async function start(): Promise {
);
}
+ if (config.LOOPS_ENABLED_DELETE_OLD_FIREBASE_NOTIFICATION_TOKENS) {
+ startLoop(
+ deleteOldFirebaseNotificationTokensTask,
+ 'delete-old-firebase-notification-tokens',
+ config.LOOPS_INTERVAL_MS_DELETE_FIREBASE_NOTIFICATION_TOKENS_MONTHLY,
+ );
+ }
+
logger.info({
at: 'index',
message: 'Successfully started',
diff --git a/indexer/services/roundtable/src/tasks/delete-old-firebase-notification-tokens.ts b/indexer/services/roundtable/src/tasks/delete-old-firebase-notification-tokens.ts
new file mode 100644
index 0000000000..a29148dad5
--- /dev/null
+++ b/indexer/services/roundtable/src/tasks/delete-old-firebase-notification-tokens.ts
@@ -0,0 +1,38 @@
+import { logger, stats } from '@dydxprotocol-indexer/base';
+import { FirebaseNotificationTokenTable } from '@dydxprotocol-indexer/postgres';
+
+import config from '../config';
+
+const statStart: string = `${config.SERVICE_NAME}.delete_old_firebase_notification_tokens`;
+
+export default async function runTask(): Promise {
+ const at: string = 'delete-old-firebase-notification-tokens#runTask';
+ const startDeleteOldFirebase: number = Date.now();
+ // Delete old snapshots.
+ stats.timing(statStart, Date.now() - startDeleteOldFirebase);
+
+ try {
+ // Delete tokens older than a month
+ const oneMonthAgo = new Date();
+ oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
+
+ const tokensToDelete = await FirebaseNotificationTokenTable.findAll(
+ {
+ updatedBeforeOrAt: oneMonthAgo.toISOString(),
+ },
+ [],
+ );
+
+ if (tokensToDelete.length > 0) {
+ await FirebaseNotificationTokenTable.deleteMany(
+ tokensToDelete.map((tokenRecord) => tokenRecord.token),
+ ).then((count) => {
+ stats.increment(`${config.SERVICE_NAME}.firebase_notification_tokens_deleted`, count);
+ });
+ }
+ } catch (error) {
+ logger.info({ at, error, message: 'Failed to delete old Firebase notification tokens' });
+ } finally {
+ stats.timing(statStart, Date.now() - startDeleteOldFirebase);
+ }
+}