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 {