diff --git a/indexer/services/comlink/__tests__/controllers/api/v4/affiliates-controller.test.ts b/indexer/services/comlink/__tests__/controllers/api/v4/affiliates-controller.test.ts index 6ea0639617..09c0a3ba70 100644 --- a/indexer/services/comlink/__tests__/controllers/api/v4/affiliates-controller.test.ts +++ b/indexer/services/comlink/__tests__/controllers/api/v4/affiliates-controller.test.ts @@ -1,8 +1,8 @@ -import { RequestMethod } from '../../../../src/types'; +import { AffiliateSnapshotRequest, RequestMethod } from '../../../../src/types'; import request from 'supertest'; import { sendRequest } from '../../../helpers/helpers'; -describe('test-controller#V4', () => { +describe('affiliates-controller#V4', () => { describe('GET /referral_code', () => { it('should return referral code for a valid address string', async () => { const address = 'some_address'; @@ -17,4 +17,65 @@ describe('test-controller#V4', () => { }); }); }); + + describe('GET /address', () => { + it('should return address for a valid referral code string', async () => { + const referralCode = 'TempCode123'; + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/address?referralCode=${referralCode}`, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + address: 'some_address', + }); + }); + }); + + describe('GET /snapshot', () => { + it('should return snapshots when all params specified', async () => { + const req: AffiliateSnapshotRequest = { + limit: 10, + offset: 10, + sortByReferredFees: true, + }; + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/snapshot?limit=${req.limit}&offset=${req.offset}&sortByReferredFees=${req.sortByReferredFees}`, + }); + + expect(response.status).toBe(200); + expect(response.body.affiliateList).toHaveLength(10); + expect(response.body.currentOffset).toBe(10); + expect(response.body.total).toBe(10); + }); + + it('should return snapshots when optional params not specified', async () => { + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: '/v4/affiliates/snapshot', + }); + + expect(response.status).toBe(200); + expect(response.body.affiliateList).toHaveLength(1000); + expect(response.body.currentOffset).toBe(0); + expect(response.body.total).toBe(1000); + }); + }); + + describe('GET /total_volume', () => { + it('should return total_volume for a valid address', async () => { + const address = 'some_address'; + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/total_volume?address=${address}`, + }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + totalVolume: 111.1, + }); + }); + }); }); diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md index 0d7eee846b..0387e35656 100644 --- a/indexer/services/comlink/public/api-documentation.md +++ b/indexer/services/comlink/public/api-documentation.md @@ -30,7 +30,9 @@ headers = { # baseURL = 'https://indexer.dydx.trade/v4' baseURL = 'https://dydx-testnet.imperator.co/v4' -r = requests.get(f'{baseURL}/addresses/{address}', headers = headers) +r = requests.get(f'{baseURL}/affiliates/address', params={ + 'referralCode': 'string' +}, headers = headers) print(r.json()) @@ -46,7 +48,7 @@ const headers = { // const baseURL = 'https://indexer.dydx.trade/v4'; const baseURL = 'https://dydx-testnet.imperator.co/v4'; -fetch(`${baseURL}/addresses/{address}`, +fetch(`${baseURL}/affiliates/address?referralCode=string`, { method: 'GET', @@ -60,13 +62,13 @@ fetch(`${baseURL}/addresses/{address}`, ``` -`GET /addresses/{address}` +`GET /affiliates/address` ### Parameters |Name|In|Type|Required|Description| |---|---|---|---|---| -|address|path|string|true|none| +|referralCode|query|string|true|none| > Example responses @@ -74,72 +76,7 @@ fetch(`${baseURL}/addresses/{address}`, ```json { - "subaccounts": [ - { - "address": "string", - "subaccountNumber": 0, - "equity": "string", - "freeCollateral": "string", - "openPerpetualPositions": { - "property1": { - "market": "string", - "status": "OPEN", - "side": "LONG", - "size": "string", - "maxSize": "string", - "entryPrice": "string", - "realizedPnl": "string", - "createdAt": "string", - "createdAtHeight": "string", - "sumOpen": "string", - "sumClose": "string", - "netFunding": "string", - "unrealizedPnl": "string", - "closedAt": null, - "exitPrice": "string", - "subaccountNumber": 0 - }, - "property2": { - "market": "string", - "status": "OPEN", - "side": "LONG", - "size": "string", - "maxSize": "string", - "entryPrice": "string", - "realizedPnl": "string", - "createdAt": "string", - "createdAtHeight": "string", - "sumOpen": "string", - "sumClose": "string", - "netFunding": "string", - "unrealizedPnl": "string", - "closedAt": null, - "exitPrice": "string", - "subaccountNumber": 0 - } - }, - "assetPositions": { - "property1": { - "symbol": "string", - "side": "LONG", - "size": "string", - "assetId": "string", - "subaccountNumber": 0 - }, - "property2": { - "symbol": "string", - "side": "LONG", - "size": "string", - "assetId": "string", - "subaccountNumber": 0 - } - }, - "marginEnabled": true, - "updatedAtHeight": "string", - "latestProcessedBlockHeight": "string" - } - ], - "totalTradingRewards": "string" + "address": "string" } ``` @@ -147,7 +84,7 @@ fetch(`${baseURL}/addresses/{address}`, |Status|Meaning|Description|Schema| |---|---|---|---| -|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Ok|[AddressResponse](#schemaaddressresponse)| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Ok|[AffiliateAddressResponse](#schemaaffiliateaddressresponse)| +## GetSnapshot + + + +> Code samples + +```python +import requests +headers = { + 'Accept': '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.get(f'{baseURL}/affiliates/snapshot', headers = headers) + +print(r.json()) + +``` + +```javascript + +const headers = { + 'Accept':'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}/affiliates/snapshot`, +{ + method: 'GET', + + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`GET /affiliates/snapshot` + +### Parameters + +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|offset|query|number(double)|false|none| +|limit|query|number(double)|false|none| +|sortByReferredFees|query|boolean|false|none| + +> Example responses + +> 200 Response + +```json +{ + "affiliateList": [ + { + "affiliateAddress": "string", + "affiliateEarnings": 0.1, + "affiliateReferralCode": "string", + "affiliateReferredTrades": 0.1, + "affiliateTotalReferredFees": 0.1, + "affiliateReferredUsers": 0.1, + "affiliateReferredNetProtocolEarnings": 0.1 + } + ], + "total": 0.1, + "currentOffset": 0.1 +} +``` + +### Responses + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Ok|[AffiliateSnapshotResponse](#schemaaffiliatesnapshotresponse)| + + + +## GetTotalVolume + + + +> Code samples + +```python +import requests +headers = { + 'Accept': '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.get(f'{baseURL}/affiliates/total_volume', params={ + 'address': 'string' +}, headers = headers) + +print(r.json()) + +``` + +```javascript + +const headers = { + 'Accept':'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}/affiliates/total_volume?address=string`, +{ + method: 'GET', + + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +`GET /affiliates/total_volume` + +### Parameters + +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|address|query|string|true|none| + +> Example responses + +> 200 Response + +```json +{ + "totalVolume": 0.1 +} +``` + +### Responses + +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Ok|[AffiliateTotalVolumeResponse](#schemaaffiliatetotalvolumeresponse)| + + + ## GetAssetPositions @@ -3838,6 +3939,112 @@ This operation does not require authentication |---|---|---|---|---| |referralCode|string¦null|true|none|none| +## AffiliateAddressResponse + + + + + + +```json +{ + "address": "string" +} + +``` + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|address|string¦null|true|none|none| + +## AffiliateSnapshotResponseObject + + + + + + +```json +{ + "affiliateAddress": "string", + "affiliateEarnings": 0.1, + "affiliateReferralCode": "string", + "affiliateReferredTrades": 0.1, + "affiliateTotalReferredFees": 0.1, + "affiliateReferredUsers": 0.1, + "affiliateReferredNetProtocolEarnings": 0.1 +} + +``` + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|affiliateAddress|string|true|none|none| +|affiliateEarnings|number(double)|true|none|none| +|affiliateReferralCode|string|true|none|none| +|affiliateReferredTrades|number(double)|true|none|none| +|affiliateTotalReferredFees|number(double)|true|none|none| +|affiliateReferredUsers|number(double)|true|none|none| +|affiliateReferredNetProtocolEarnings|number(double)|true|none|none| + +## AffiliateSnapshotResponse + + + + + + +```json +{ + "affiliateList": [ + { + "affiliateAddress": "string", + "affiliateEarnings": 0.1, + "affiliateReferralCode": "string", + "affiliateReferredTrades": 0.1, + "affiliateTotalReferredFees": 0.1, + "affiliateReferredUsers": 0.1, + "affiliateReferredNetProtocolEarnings": 0.1 + } + ], + "total": 0.1, + "currentOffset": 0.1 +} + +``` + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|affiliateList|[[AffiliateSnapshotResponseObject](#schemaaffiliatesnapshotresponseobject)]|true|none|none| +|total|number(double)|true|none|none| +|currentOffset|number(double)|true|none|none| + +## AffiliateTotalVolumeResponse + + + + + + +```json +{ + "totalVolume": 0.1 +} + +``` + +### Properties + +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|totalVolume|number(double)¦null|true|none|none| + ## AssetPositionResponse diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json index 9f534db311..27e6608b94 100644 --- a/indexer/services/comlink/public/swagger.json +++ b/indexer/services/comlink/public/swagger.json @@ -253,6 +253,99 @@ "type": "object", "additionalProperties": false }, + "AffiliateAddressResponse": { + "properties": { + "address": { + "type": "string", + "nullable": true + } + }, + "required": [ + "address" + ], + "type": "object", + "additionalProperties": false + }, + "AffiliateSnapshotResponseObject": { + "properties": { + "affiliateAddress": { + "type": "string" + }, + "affiliateEarnings": { + "type": "number", + "format": "double" + }, + "affiliateReferralCode": { + "type": "string" + }, + "affiliateReferredTrades": { + "type": "number", + "format": "double" + }, + "affiliateTotalReferredFees": { + "type": "number", + "format": "double" + }, + "affiliateReferredUsers": { + "type": "number", + "format": "double" + }, + "affiliateReferredNetProtocolEarnings": { + "type": "number", + "format": "double" + } + }, + "required": [ + "affiliateAddress", + "affiliateEarnings", + "affiliateReferralCode", + "affiliateReferredTrades", + "affiliateTotalReferredFees", + "affiliateReferredUsers", + "affiliateReferredNetProtocolEarnings" + ], + "type": "object", + "additionalProperties": false + }, + "AffiliateSnapshotResponse": { + "properties": { + "affiliateList": { + "items": { + "$ref": "#/components/schemas/AffiliateSnapshotResponseObject" + }, + "type": "array" + }, + "total": { + "type": "number", + "format": "double" + }, + "currentOffset": { + "type": "number", + "format": "double" + } + }, + "required": [ + "affiliateList", + "total", + "currentOffset" + ], + "type": "object", + "additionalProperties": false + }, + "AffiliateTotalVolumeResponse": { + "properties": { + "totalVolume": { + "type": "number", + "format": "double", + "nullable": true + } + }, + "required": [ + "totalVolume" + ], + "type": "object", + "additionalProperties": false + }, "AssetPositionResponse": { "properties": { "positions": { @@ -1561,6 +1654,108 @@ ] } }, + "/affiliates/address": { + "get": { + "operationId": "GetAddress", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AffiliateAddressResponse" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "query", + "name": "referralCode", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/affiliates/snapshot": { + "get": { + "operationId": "GetSnapshot", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AffiliateSnapshotResponse" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "sortByReferredFees", + "required": false, + "schema": { + "type": "boolean" + } + } + ] + } + }, + "/affiliates/total_volume": { + "get": { + "operationId": "GetTotalVolume", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AffiliateTotalVolumeResponse" + } + } + } + } + }, + "security": [], + "parameters": [ + { + "in": "query", + "name": "address", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, "/assetPositions": { "get": { "operationId": "GetAssetPositions", diff --git a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts index 7498758e71..f4ac198ba1 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -9,25 +9,96 @@ import { getReqRateLimiter } from '../../../caches/rate-limiters'; import config from '../../../config'; import { handleControllerError } from '../../../lib/helpers'; import { rateLimiterMiddleware } from '../../../lib/rate-limit'; +import { handleValidationErrors } from '../../../request-helpers/error-handler'; import ExportResponseCodeStats from '../../../request-helpers/export-response-code-stats'; -import { AffiliateReferralCodeRequest, AffiliateReferralCodeResponse } from '../../../types'; +import { + AffiliateAddressRequest, + AffiliateReferralCodeRequest, + AffiliateReferralCodeResponse, + AffiliateAddressResponse, + AffiliateSnapshotResponse, + AffiliateSnapshotResponseObject, + AffiliateSnapshotRequest, + AffiliateTotalVolumeResponse, + AffiliateTotalVolumeRequest, +} from '../../../types'; const router: express.Router = express.Router(); const controllerName: string = 'affiliates-controller'; +// TODO(OTE-731): replace api stubs with real logic @Route('affiliates') class AffiliatesController extends Controller { @Get('/referral_code') async getReferralCode( @Query() address: string, // eslint-disable-line @typescript-eslint/no-unused-vars ): Promise { - // TODO: OTE-731 replace apit stubs with real logic // simulate a delay await new Promise((resolve) => setTimeout(resolve, 100)); return { referralCode: 'TempCode123', }; } + + @Get('/address') + async getAddress( + @Query() referralCode: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // simulate a delay + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + address: 'some_address', + }; + } + + @Get('/snapshot') + async getSnapshot( + @Query() offset?: number, + @Query() limit?: number, + @Query() sortByReferredFees?: boolean, + ): Promise { + const finalOffset = offset ?? 0; + const finalLimit = limit ?? 1000; + // eslint-disable-next-line + const finalSortByReferredFees = sortByReferredFees ?? false; + + // simulate a delay + await new Promise((resolve) => setTimeout(resolve, 100)); + + const snapshot: AffiliateSnapshotResponseObject = { + affiliateAddress: 'some_address', + affiliateEarnings: 100, + affiliateReferralCode: 'TempCode123', + affiliateReferredTrades: 1000, + affiliateTotalReferredFees: 100, + affiliateReferredUsers: 10, + affiliateReferredNetProtocolEarnings: 1000, + }; + + const affiliateSnapshots: AffiliateSnapshotResponseObject[] = []; + for (let i = 0; i < finalLimit; i++) { + affiliateSnapshots.push(snapshot); + } + + const response: AffiliateSnapshotResponse = { + affiliateList: affiliateSnapshots, + total: finalLimit, + currentOffset: finalOffset, + }; + + return response; + } + + @Get('/total_volume') + public async getTotalVolume( + @Query() address: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // simulate a delay + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + totalVolume: 111.1, + }; + } } router.get( @@ -40,6 +111,7 @@ router.get( errorMessage: 'address must be a valid string', }, }), + handleValidationErrors, ExportResponseCodeStats({ controllerName }), async (req: express.Request, res: express.Response) => { const start: number = Date.now(); @@ -68,4 +140,142 @@ router.get( }, ); +router.get( + '/address', + rateLimiterMiddleware(getReqRateLimiter), + ...checkSchema({ + referralCode: { + in: ['query'], + isString: true, + errorMessage: 'referralCode must be a valid string', + }, + }), + handleValidationErrors, + ExportResponseCodeStats({ controllerName }), + async (req: express.Request, res: express.Response) => { + const start: number = Date.now(); + const { + referralCode, + }: AffiliateAddressRequest = matchedData(req) as AffiliateAddressRequest; + + try { + const controller: AffiliatesController = new AffiliatesController(); + const response: AffiliateAddressResponse = await controller.getAddress(referralCode); + return res.send(response); + } catch (error) { + return handleControllerError( + 'AffiliatesController GET /address', + 'Affiliates address error', + error, + req, + res, + ); + } finally { + stats.timing( + `${config.SERVICE_NAME}.${controllerName}.get_address.timing`, + Date.now() - start, + ); + } + }, +); + +router.get( + '/snapshot', + rateLimiterMiddleware(getReqRateLimiter), + ...checkSchema({ + offset: { + in: ['query'], + isInt: true, + toInt: true, + optional: true, + errorMessage: 'offset must be a valid integer', + }, + limit: { + in: ['query'], + isInt: true, + toInt: true, + optional: true, + errorMessage: 'limit must be a valid integer', + }, + sortByReferredFees: { + in: ['query'], + isBoolean: true, + toBoolean: true, + optional: true, + errorMessage: 'sortByReferredFees must be a boolean', + }, + }), + handleValidationErrors, + ExportResponseCodeStats({ controllerName }), + async (req: express.Request, res: express.Response) => { + const start: number = Date.now(); + const { + offset, + limit, + sortByReferredFees, + }: AffiliateSnapshotRequest = matchedData(req) as AffiliateSnapshotRequest; + + try { + const controller: AffiliatesController = new AffiliatesController(); + const response: AffiliateSnapshotResponse = await controller.getSnapshot( + offset, + limit, + sortByReferredFees, + ); + return res.send(response); + } catch (error) { + return handleControllerError( + 'AffiliatesController GET /snapshot', + 'Affiliates snapshot error', + error, + req, + res, + ); + } finally { + stats.timing( + `${config.SERVICE_NAME}.${controllerName}.get_snapshot.timing`, + Date.now() - start, + ); + } + }, +); + +router.get( + '/total_volume', + rateLimiterMiddleware(getReqRateLimiter), + ...checkSchema({ + address: { + in: ['query'], + isString: true, + errorMessage: 'address must be a valid string', + }, + }), + ExportResponseCodeStats({ controllerName }), + async (req: express.Request, res: express.Response) => { + const start: number = Date.now(); + const { + address, + }: AffiliateTotalVolumeRequest = matchedData(req) as AffiliateTotalVolumeRequest; + + try { + const controller: AffiliatesController = new AffiliatesController(); + const response: AffiliateTotalVolumeResponse = await controller.getTotalVolume(address); + return res.send(response); + } catch (error) { + return handleControllerError( + 'AffiliateTotalVolumeResponse GET /total_volume', + 'Affiliate total volume error', + error, + req, + res, + ); + } finally { + stats.timing( + `${config.SERVICE_NAME}.${controllerName}.get_total_volume.timing`, + Date.now() - start, + ); + } + }, +); + export default router; diff --git a/indexer/services/comlink/src/types.ts b/indexer/services/comlink/src/types.ts index 1715cddf8c..f1845c5bc7 100644 --- a/indexer/services/comlink/src/types.ts +++ b/indexer/services/comlink/src/types.ts @@ -669,7 +669,21 @@ export interface MegavaultPositionResponse { /* ------- Affiliates Types ------- */ export interface AffiliateReferralCodeRequest{ - address: string + address: string, +} + +export interface AffiliateAddressRequest{ + referralCode: string, +} + +export interface AffiliateSnapshotRequest{ + limit?: number, + offset?: number, + sortByReferredFees?: boolean, +} + +export interface AffiliateTotalVolumeRequest{ + address: string, } export interface AffiliateReferralCodeResponse { @@ -683,7 +697,7 @@ export interface AffiliateAddressResponse { export interface AffiliateSnapshotResponse { affiliateList: AffiliateSnapshotResponseObject[], total: number, - currentOffset: number + currentOffset: number, } export interface AffiliateSnapshotResponseObject { @@ -693,5 +707,9 @@ export interface AffiliateSnapshotResponseObject { affiliateReferredTrades: number, affiliateTotalReferredFees: number, affiliateReferredUsers: number, - affiliateReferredNetProtocolEarnings: number + affiliateReferredNetProtocolEarnings: number, +} + +export interface AffiliateTotalVolumeResponse { + totalVolume: number | null, }