diff --git a/indexer/pnpm-lock.yaml b/indexer/pnpm-lock.yaml
index a3f5398c43f..abf6a1151df 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 167901ba36d..30ae74517a7 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 9930eb80df7..bf83d3019db 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,6 +10,7 @@ import {
BlockTable,
liquidityTierRefresher,
SubaccountTable,
+ TokenTable,
} from '@dydxprotocol-indexer/postgres';
import { RequestMethod } from '../../../../src/types';
import request from 'supertest';
@@ -42,6 +43,7 @@ describe('addresses-controller#V4', () => {
afterEach(async () => {
await dbHelpers.clearData();
+ jest.clearAllMocks();
});
const invalidAddress: string = 'invalidAddress';
@@ -574,4 +576,108 @@ describe('addresses-controller#V4', () => {
});
});
+ 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('Post /:address/registerToken with valid params calls TokenTable registerToken', async () => {
+ jest.spyOn(TokenTable, 'registerToken');
+ const token = 'validToken';
+ const language = 'en';
+ await sendRequest({
+ type: RequestMethod.POST,
+ path: `/v4/addresses/${testConstants.defaultAddress}/registerToken`,
+ body: { token, language },
+ expectedStatus: 200,
+ });
+ expect(TokenTable.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 address 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 ba12e7064b2..29ea1def603 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 bcd3461ed2f..9df08e3bc24 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|
+
+
+
## GetReferralCode
diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json
index cf70fedb382..fd75a7c4418 100644
--- a/indexer/services/comlink/public/swagger.json
+++ b/indexer/services/comlink/public/swagger.json
@@ -1668,6 +1668,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/referral_code": {
"get": {
"operationId": "GetReferralCode",
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 7847a1cf166..91275ccc23c 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 {
+ createNotification, NotificationType, NotificationDynamicFieldKey, sendFirebaseMessage,
+} from '@dydxprotocol-indexer/notifications';
import {
AssetPositionFromDatabase,
BlockTable,
@@ -20,6 +23,7 @@ import {
WalletTable,
WalletFromDatabase,
perpetualMarketRefresher,
+ TokenTable,
} 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,53 @@ 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 foundAddress = await WalletTable.findById(address);
+ if (!foundAddress) {
+ throw new NotFoundError(`No address found with address: ${address}`);
+ }
+
+ try {
+ await TokenTable.registerToken(
+ token,
+ 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 TokenTable.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) {
+ throw new Error('Failed to send test notification');
+ }
+ }
}
router.get(
@@ -426,6 +485,62 @@ 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) => {
+ 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 8081c3fee43..6a4e9c2801d 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 = 'NotFoundError';
+ }
+}
diff --git a/indexer/services/comlink/src/lib/helpers.ts b/indexer/services/comlink/src/lib/helpers.ts
index 5041c6fb110..6da41069070 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 6a2d79d11b0..d954f31cec1 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 4ef63208360..2c5c2c8c948 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 {