From 368742b41faa354ee1aeb0b4873df9a5cdbbaf96 Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Wed, 11 Sep 2024 11:21:01 -0400 Subject: [PATCH 1/9] modify affiliate api stubs --- .../api/v4/affiliates-controller.test.ts | 4 +-- .../comlink/public/api-documentation.md | 28 +++++++++++-------- indexer/services/comlink/public/swagger.json | 19 ++++++++----- .../api/v4/affiliates-controller.ts | 15 +++++----- indexer/services/comlink/src/types.ts | 3 +- 5 files changed, 40 insertions(+), 29 deletions(-) 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 09c0a3ba70..ff203417a5 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 @@ -3,12 +3,12 @@ import request from 'supertest'; import { sendRequest } from '../../../helpers/helpers'; describe('affiliates-controller#V4', () => { - describe('GET /referral_code', () => { + describe('GET /metadata', () => { it('should return referral code for a valid address string', async () => { const address = 'some_address'; const response: request.Response = await sendRequest({ type: RequestMethod.GET, - path: `/v4/affiliates/referral_code?address=${address}`, + path: `/v4/affiliates/metadata?address=${address}`, }); expect(response.status).toBe(200); diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md index f5cf63ba61..4c56a11551 100644 --- a/indexer/services/comlink/public/api-documentation.md +++ b/indexer/services/comlink/public/api-documentation.md @@ -368,9 +368,9 @@ fetch(`${baseURL}/addresses/{address}/parentSubaccountNumber/{parentSubaccountNu This operation does not require authentication -## GetReferralCode +## GetMetadata - + > Code samples @@ -384,7 +384,7 @@ headers = { # baseURL = 'https://indexer.dydx.trade/v4' baseURL = 'https://dydx-testnet.imperator.co/v4' -r = requests.get(f'{baseURL}/affiliates/referral_code', params={ +r = requests.get(f'{baseURL}/affiliates/metadata', params={ 'address': 'string' }, headers = headers) @@ -402,7 +402,7 @@ const headers = { // const baseURL = 'https://indexer.dydx.trade/v4'; const baseURL = 'https://dydx-testnet.imperator.co/v4'; -fetch(`${baseURL}/affiliates/referral_code?address=string`, +fetch(`${baseURL}/affiliates/metadata?address=string`, { method: 'GET', @@ -416,7 +416,7 @@ fetch(`${baseURL}/affiliates/referral_code?address=string`, ``` -`GET /affiliates/referral_code` +`GET /affiliates/metadata` ### Parameters @@ -509,12 +509,13 @@ fetch(`${baseURL}/affiliates/snapshot`, "affiliateList": [ { "affiliateAddress": "string", - "affiliateEarnings": 0.1, "affiliateReferralCode": "string", + "affiliateEarnings": 0.1, "affiliateReferredTrades": 0.1, "affiliateTotalReferredFees": 0.1, "affiliateReferredUsers": 0.1, - "affiliateReferredNetProtocolEarnings": 0.1 + "affiliateReferredNetProtocolEarnings": 0.1, + "affiliateReferredTotalVolume": 0.1 } ], "total": 0.1, @@ -4069,12 +4070,13 @@ This operation does not require authentication ```json { "affiliateAddress": "string", - "affiliateEarnings": 0.1, "affiliateReferralCode": "string", + "affiliateEarnings": 0.1, "affiliateReferredTrades": 0.1, "affiliateTotalReferredFees": 0.1, "affiliateReferredUsers": 0.1, - "affiliateReferredNetProtocolEarnings": 0.1 + "affiliateReferredNetProtocolEarnings": 0.1, + "affiliateReferredTotalVolume": 0.1 } ``` @@ -4084,12 +4086,13 @@ This operation does not require authentication |Name|Type|Required|Restrictions|Description| |---|---|---|---|---| |affiliateAddress|string|true|none|none| -|affiliateEarnings|number(double)|true|none|none| |affiliateReferralCode|string|true|none|none| +|affiliateEarnings|number(double)|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| +|affiliateReferredTotalVolume|number(double)|true|none|none| ## AffiliateSnapshotResponse @@ -4103,12 +4106,13 @@ This operation does not require authentication "affiliateList": [ { "affiliateAddress": "string", - "affiliateEarnings": 0.1, "affiliateReferralCode": "string", + "affiliateEarnings": 0.1, "affiliateReferredTrades": 0.1, "affiliateTotalReferredFees": 0.1, "affiliateReferredUsers": 0.1, - "affiliateReferredNetProtocolEarnings": 0.1 + "affiliateReferredNetProtocolEarnings": 0.1, + "affiliateReferredTotalVolume": 0.1 } ], "total": 0.1, diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json index 1054683fcd..1113841e85 100644 --- a/indexer/services/comlink/public/swagger.json +++ b/indexer/services/comlink/public/swagger.json @@ -271,13 +271,13 @@ "affiliateAddress": { "type": "string" }, + "affiliateReferralCode": { + "type": "string" + }, "affiliateEarnings": { "type": "number", "format": "double" }, - "affiliateReferralCode": { - "type": "string" - }, "affiliateReferredTrades": { "type": "number", "format": "double" @@ -293,16 +293,21 @@ "affiliateReferredNetProtocolEarnings": { "type": "number", "format": "double" + }, + "affiliateReferredTotalVolume": { + "type": "number", + "format": "double" } }, "required": [ "affiliateAddress", - "affiliateEarnings", "affiliateReferralCode", + "affiliateEarnings", "affiliateReferredTrades", "affiliateTotalReferredFees", "affiliateReferredUsers", - "affiliateReferredNetProtocolEarnings" + "affiliateReferredNetProtocolEarnings", + "affiliateReferredTotalVolume" ], "type": "object", "additionalProperties": false @@ -1668,9 +1673,9 @@ ] } }, - "/affiliates/referral_code": { + "/affiliates/metadata": { "get": { - "operationId": "GetReferralCode", + "operationId": "GetMetadata", "responses": { "200": { "description": "Ok", 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 f4ac198ba1..2d100df478 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -29,8 +29,8 @@ 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( + @Get('/metadata') + async getMetadata( @Query() address: string, // eslint-disable-line @typescript-eslint/no-unused-vars ): Promise { // simulate a delay @@ -67,12 +67,13 @@ class AffiliatesController extends Controller { const snapshot: AffiliateSnapshotResponseObject = { affiliateAddress: 'some_address', - affiliateEarnings: 100, affiliateReferralCode: 'TempCode123', + affiliateEarnings: 100, affiliateReferredTrades: 1000, affiliateTotalReferredFees: 100, affiliateReferredUsers: 10, affiliateReferredNetProtocolEarnings: 1000, + affiliateReferredTotalVolume: 1000000, }; const affiliateSnapshots: AffiliateSnapshotResponseObject[] = []; @@ -102,7 +103,7 @@ class AffiliatesController extends Controller { } router.get( - '/referral_code', + '/metadata', rateLimiterMiddleware(getReqRateLimiter), ...checkSchema({ address: { @@ -121,11 +122,11 @@ router.get( try { const controller: AffiliatesController = new AffiliatesController(); - const response: AffiliateReferralCodeResponse = await controller.getReferralCode(address); + const response: AffiliateReferralCodeResponse = await controller.getMetadata(address); return res.send(response); } catch (error) { return handleControllerError( - 'AffiliatesController GET /referral_code', + 'AffiliatesController GET /metadata', 'Affiliates referral code error', error, req, @@ -133,7 +134,7 @@ router.get( ); } finally { stats.timing( - `${config.SERVICE_NAME}.${controllerName}.get_referral_code.timing`, + `${config.SERVICE_NAME}.${controllerName}.get_metadata.timing`, Date.now() - start, ); } diff --git a/indexer/services/comlink/src/types.ts b/indexer/services/comlink/src/types.ts index 45622e56d5..894caba79a 100644 --- a/indexer/services/comlink/src/types.ts +++ b/indexer/services/comlink/src/types.ts @@ -703,12 +703,13 @@ export interface AffiliateSnapshotResponse { export interface AffiliateSnapshotResponseObject { affiliateAddress: string, - affiliateEarnings: number, affiliateReferralCode: string, + affiliateEarnings: number, affiliateReferredTrades: number, affiliateTotalReferredFees: number, affiliateReferredUsers: number, affiliateReferredNetProtocolEarnings: number, + affiliateReferredTotalVolume: number, } export interface AffiliateTotalVolumeResponse { From bc16518230b687c8eecb1ccb2e458a60a5899f17 Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Wed, 11 Sep 2024 13:27:16 -0400 Subject: [PATCH 2/9] implement comlink GET affiliates/metadata --- indexer/services/comlink/src/config.ts | 3 ++ .../api/v4/affiliates-controller.ts | 51 +++++++++++++++++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/indexer/services/comlink/src/config.ts b/indexer/services/comlink/src/config.ts index eb3713bc14..e2c85ade2f 100644 --- a/indexer/services/comlink/src/config.ts +++ b/indexer/services/comlink/src/config.ts @@ -60,6 +60,9 @@ export const configSchema = { // vaults table is added. EXPERIMENT_VAULTS: parseString({ default: '' }), EXPERIMENT_VAULT_MARKETS: parseString({ default: '' }), + + // Affiliates config + VOLUME_ELIGIBILITY_THRESHOLD: parseInteger({ default: 10_000 }), }; //////////////////////////////////////////////////////////////////////////////// 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 2d100df478..768cffccd6 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -4,7 +4,13 @@ import { checkSchema, matchedData } from 'express-validator'; import { Controller, Get, Query, Route, } from 'tsoa'; - +import { + WalletTable, + AffiliateReferredUsersTable, + SubaccountTable, + SubaccountUsernamesTable, +} from '@dydxprotocol-indexer/postgres'; +import { NotFoundError, UnexpectedServerError } from '../../../lib/errors'; import { getReqRateLimiter } from '../../../caches/rate-limiters'; import config from '../../../config'; import { handleControllerError } from '../../../lib/helpers'; @@ -31,12 +37,47 @@ const controllerName: string = 'affiliates-controller'; class AffiliatesController extends Controller { @Get('/metadata') async getMetadata( - @Query() address: string, // eslint-disable-line @typescript-eslint/no-unused-vars + @Query() address: string, ): Promise { - // simulate a delay - await new Promise((resolve) => setTimeout(resolve, 100)); + // Check that the address exists + const walletRow = await WalletTable.findById(address); + if (!walletRow) { + throw new NotFoundError(`Wallet with address ${address} not found`); + } + const isVolumeEligible = Number(walletRow.totalVolume) >= config.VOLUME_ELIGIBILITY_THRESHOLD; + + // Check if the address is an affiliate (has referred users) + const referredUserRows = await AffiliateReferredUsersTable.findByAffiliateAddress(address); + const isAffiliate = referredUserRows != undefined ? referredUserRows.length > 0 : false; + + // Get referral code (subaccount 0 username) + const subaccountRows = await SubaccountTable.findAll( + { + address: address, + subaccountNumber: 0, + }, + [], + ) + // No need to check subaccountRows.length > 1 because subaccountNumber is unique for an address + if (subaccountRows.length === 0) { + throw new UnexpectedServerError(`Subaccount 0 not found for address ${address}`); + } + const subaccountId = subaccountRows[0].id; + const usernameRows = await SubaccountUsernamesTable.findAll( + { + subaccountId: [subaccountId], + }, + [], + ) + if (usernameRows.length === 0) { + throw new UnexpectedServerError(`Username not found for subaccount ${subaccountId}`); + } else if (usernameRows.length > 1) { + throw new UnexpectedServerError(`Found multiple usernames for subaccount ${subaccountId}`); + } + const referralCode = usernameRows[0].username; + return { - referralCode: 'TempCode123', + referralCode: referralCode, }; } From ac62c32c015dbe47726ef37aa3ce855e75f8fc6e Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Thu, 12 Sep 2024 12:42:26 -0400 Subject: [PATCH 3/9] tests --- .../api/v4/affiliates-controller.test.ts | 127 +++++++++++++++++- indexer/services/comlink/package.json | 2 +- .../api/v4/affiliates-controller.ts | 19 +-- 3 files changed, 126 insertions(+), 22 deletions(-) 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 1f86ebf54e..7cf7847a79 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,22 +1,139 @@ +import { + dbHelpers, + testConstants, + testMocks, + SubaccountUsernamesTable, + WalletTable, + AffiliateReferredUsersTable, +} from '@dydxprotocol-indexer/postgres'; import { AffiliateSnapshotRequest, RequestMethod } from '../../../../src/types'; import request from 'supertest'; import { sendRequest } from '../../../helpers/helpers'; +import { defaultWallet, defaultWallet2 } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; describe('affiliates-controller#V4', () => { + beforeAll(async () => { + await dbHelpers.migrate(); + }); + + afterAll(async () => { + await dbHelpers.teardown(); + }); + describe('GET /metadata', () => { - it('should return referral code for a valid address string', async () => { - const address = 'some_address'; + beforeEach(async () => { + await testMocks.seedData(); + await SubaccountUsernamesTable.create(testConstants.defaultSubaccountUsername); + }); + + afterEach(async () => { + await dbHelpers.clearData(); + }); + + it('should return referral code for address with username', async () => { const response: request.Response = await sendRequest({ type: RequestMethod.GET, - path: `/v4/affiliates/metadata?address=${address}`, + path: `/v4/affiliates/metadata?address=${testConstants.defaultWallet.address}`, + expectedStatus: 200, // helper performs expect on status }); - expect(response.status).toBe(200); expect(response.body).toEqual({ - referralCode: 'TempCode123', + // username is the referral code + referralCode: testConstants.defaultSubaccountUsername.username, + isVolumeEligible: false, + isAffiliate: false, + }); + }); + + it('should fail if address does not exist', async () => { + const nonExistentAddress = 'adgsakhasgt' + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/metadata?address=${nonExistentAddress}`, + expectedStatus: 404, // helper performs expect on status + }); + }); + + it('should classify not volume eligible', async () => { + await WalletTable.update( + { + address: testConstants.defaultWallet.address, + totalVolume: "0", + totalTradingRewards: "0", + }, + ); + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/metadata?address=${testConstants.defaultWallet.address}`, + expectedStatus: 200, // helper performs expect on status + }); + expect(response.body).toEqual({ + referralCode: testConstants.defaultSubaccountUsername.username, + isVolumeEligible: false, + isAffiliate: false, + }); + }); + + it('should classify volume eligible', async () => { + await WalletTable.update( + { + address: testConstants.defaultWallet.address, + totalVolume: "100000", + totalTradingRewards: "0", + }, + ); + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/metadata?address=${testConstants.defaultWallet.address}`, + expectedStatus: 200, // helper performs expect on status + }); + expect(response.body).toEqual({ + referralCode: testConstants.defaultSubaccountUsername.username, isVolumeEligible: true, isAffiliate: false, }); + }); + + it('should classify is not affiliate', async () => { + // AffiliateReferredUsersTable is empty + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/metadata?address=${testConstants.defaultWallet.address}`, + expectedStatus: 200, // helper performs expect on status + }); + expect(response.body).toEqual({ + referralCode: testConstants.defaultSubaccountUsername.username, + isVolumeEligible: false, + isAffiliate: false, + }); + }); + + it('should classify is affiliate', async () => { + await AffiliateReferredUsersTable.create({ + affiliateAddress: defaultWallet.address, + refereeAddress: defaultWallet2.address, + referredAtBlock: '1', + }); + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/metadata?address=${testConstants.defaultWallet.address}`, + expectedStatus: 200, // helper performs expect on status + }); + expect(response.body).toEqual({ + referralCode: testConstants.defaultSubaccountUsername.username, + isVolumeEligible: false, + isAffiliate: true, + }); + }); + + it('should fail if subaccount username not found', async () => { + // create defaultWallet2 without subaccount username + WalletTable.create(testConstants.defaultWallet2); + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/metadata?address=${testConstants.defaultWallet2.address}`, + expectedStatus: 500, // helper performs expect on status + }); }); }); diff --git a/indexer/services/comlink/package.json b/indexer/services/comlink/package.json index ba12e7064b..d86c94d2c4 100644 --- a/indexer/services/comlink/package.json +++ b/indexer/services/comlink/package.json @@ -13,7 +13,7 @@ "coverage": "pnpm test -- --coverage", "lint": "eslint --ext .ts,.js .", "lint:fix": "eslint --ext .ts,.js . --fix", - "test": "NODE_ENV=test jest --runInBand --forceExit", + "test-comlink": "NODE_ENV=test jest --runInBand --forceExit", "swagger": "ts-node -r dotenv-flow/config src/scripts/generate-swagger.ts", "gen-markdown": "widdershins public/swagger.json -o public/api-documentation.md --omitHeader --language_tabs 'python:Python' 'javascript:Javascript'" }, 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 34be54dbad..78d51a9a32 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -37,9 +37,8 @@ const controllerName: string = 'affiliates-controller'; class AffiliatesController extends Controller { @Get('/metadata') async getMetadata( -<<<<<<< HEAD @Query() address: string, - ): Promise { + ): Promise { // Check that the address exists const walletRow = await WalletTable.findById(address); if (!walletRow) { @@ -79,16 +78,8 @@ class AffiliatesController extends Controller { return { referralCode: referralCode, -======= - @Query() address: string, // eslint-disable-line @typescript-eslint/no-unused-vars - ): Promise { - // simulate a delay - await new Promise((resolve) => setTimeout(resolve, 100)); - return { - referralCode: 'TempCode123', - isVolumeEligible: true, - isAffiliate: false, ->>>>>>> main + isVolumeEligible: isVolumeEligible, + isAffiliate: isAffiliate, }; } @@ -174,11 +165,7 @@ router.get( try { const controller: AffiliatesController = new AffiliatesController(); -<<<<<<< HEAD - const response: AffiliateReferralCodeResponse = await controller.getMetadata(address); -======= const response: AffiliateMetadataResponse = await controller.getMetadata(address); ->>>>>>> main return res.send(response); } catch (error) { return handleControllerError( From 0784cfa3b7de2e20133430e7f241fd6385a8bc96 Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Thu, 12 Sep 2024 12:47:33 -0400 Subject: [PATCH 4/9] revert package.json --- indexer/services/comlink/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/indexer/services/comlink/package.json b/indexer/services/comlink/package.json index 5e8085911c..29ea1def60 100644 --- a/indexer/services/comlink/package.json +++ b/indexer/services/comlink/package.json @@ -13,7 +13,7 @@ "coverage": "pnpm test -- --coverage", "lint": "eslint --ext .ts,.js .", "lint:fix": "eslint --ext .ts,.js . --fix", - "test-comlink": "NODE_ENV=test jest --runInBand --forceExit", + "test": "NODE_ENV=test jest --runInBand --forceExit", "swagger": "ts-node -r dotenv-flow/config src/scripts/generate-swagger.ts", "gen-markdown": "widdershins public/swagger.json -o public/api-documentation.md --omitHeader --language_tabs 'python:Python' 'javascript:Javascript'" }, From 21481ecbb9b98843c8c437ed736168883ed224c1 Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Thu, 12 Sep 2024 12:58:52 -0400 Subject: [PATCH 5/9] lint fix --- .../api/v4/affiliates-controller.test.ts | 28 ++++++++-------- .../api/v4/affiliates-controller.ts | 33 ++++++++++--------- 2 files changed, 31 insertions(+), 30 deletions(-) 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 7cf7847a79..465865020d 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 @@ -25,7 +25,7 @@ describe('affiliates-controller#V4', () => { await testMocks.seedData(); await SubaccountUsernamesTable.create(testConstants.defaultSubaccountUsername); }); - + afterEach(async () => { await dbHelpers.clearData(); }); @@ -46,20 +46,20 @@ describe('affiliates-controller#V4', () => { }); it('should fail if address does not exist', async () => { - const nonExistentAddress = 'adgsakhasgt' - const response: request.Response = await sendRequest({ + const nonExistentAddress = 'adgsakhasgt'; + await sendRequest({ type: RequestMethod.GET, path: `/v4/affiliates/metadata?address=${nonExistentAddress}`, expectedStatus: 404, // helper performs expect on status }); }); - it('should classify not volume eligible', async () => { + it('should classify not volume eligible', async () => { await WalletTable.update( - { + { address: testConstants.defaultWallet.address, - totalVolume: "0", - totalTradingRewards: "0", + totalVolume: '0', + totalTradingRewards: '0', }, ); const response: request.Response = await sendRequest({ @@ -74,12 +74,12 @@ describe('affiliates-controller#V4', () => { }); }); - it('should classify volume eligible', async () => { + it('should classify volume eligible', async () => { await WalletTable.update( - { + { address: testConstants.defaultWallet.address, - totalVolume: "100000", - totalTradingRewards: "0", + totalVolume: '100000', + totalTradingRewards: '0', }, ); const response: request.Response = await sendRequest({ @@ -92,7 +92,7 @@ describe('affiliates-controller#V4', () => { isVolumeEligible: true, isAffiliate: false, }); - }); + }); it('should classify is not affiliate', async () => { // AffiliateReferredUsersTable is empty @@ -128,8 +128,8 @@ describe('affiliates-controller#V4', () => { it('should fail if subaccount username not found', async () => { // create defaultWallet2 without subaccount username - WalletTable.create(testConstants.defaultWallet2); - const response: request.Response = await sendRequest({ + await WalletTable.create(testConstants.defaultWallet2); + await sendRequest({ type: RequestMethod.GET, path: `/v4/affiliates/metadata?address=${testConstants.defaultWallet2.address}`, expectedStatus: 500, // helper performs expect on status 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 78d51a9a32..15ca181906 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -1,18 +1,19 @@ import { stats } from '@dydxprotocol-indexer/base'; -import express from 'express'; -import { checkSchema, matchedData } from 'express-validator'; -import { - Controller, Get, Query, Route, -} from 'tsoa'; import { WalletTable, AffiliateReferredUsersTable, SubaccountTable, SubaccountUsernamesTable, } from '@dydxprotocol-indexer/postgres'; -import { NotFoundError, UnexpectedServerError } from '../../../lib/errors'; +import express from 'express'; +import { checkSchema, matchedData } from 'express-validator'; +import { + Controller, Get, Query, Route, +} from 'tsoa'; + import { getReqRateLimiter } from '../../../caches/rate-limiters'; import config from '../../../config'; +import { NotFoundError, UnexpectedServerError } from '../../../lib/errors'; import { handleControllerError } from '../../../lib/helpers'; import { rateLimiterMiddleware } from '../../../lib/rate-limit'; import { handleValidationErrors } from '../../../request-helpers/error-handler'; @@ -48,38 +49,38 @@ class AffiliatesController extends Controller { // Check if the address is an affiliate (has referred users) const referredUserRows = await AffiliateReferredUsersTable.findByAffiliateAddress(address); - const isAffiliate = referredUserRows != undefined ? referredUserRows.length > 0 : false; + const isAffiliate = referredUserRows !== undefined ? referredUserRows.length > 0 : false; // Get referral code (subaccount 0 username) const subaccountRows = await SubaccountTable.findAll( { - address: address, + address, subaccountNumber: 0, }, [], - ) - // No need to check subaccountRows.length > 1 because subaccountNumber is unique for an address + ); + // No need to check subaccountRows.length > 1 as subaccountNumber is unique for an address if (subaccountRows.length === 0) { throw new UnexpectedServerError(`Subaccount 0 not found for address ${address}`); } const subaccountId = subaccountRows[0].id; + const usernameRows = await SubaccountUsernamesTable.findAll( { subaccountId: [subaccountId], }, [], - ) + ); + // No need to check usernameRows.length > 1 as subAccountId is unique (foreign key constraint) if (usernameRows.length === 0) { throw new UnexpectedServerError(`Username not found for subaccount ${subaccountId}`); - } else if (usernameRows.length > 1) { - throw new UnexpectedServerError(`Found multiple usernames for subaccount ${subaccountId}`); } const referralCode = usernameRows[0].username; return { - referralCode: referralCode, - isVolumeEligible: isVolumeEligible, - isAffiliate: isAffiliate, + referralCode, + isVolumeEligible, + isAffiliate, }; } From 3fa0437059f24c2b29a8d8df30c919497afdd88c Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Thu, 12 Sep 2024 16:01:09 -0400 Subject: [PATCH 6/9] replace affiliates address and total_volume stubs with real implementation --- .../api/v4/affiliates-controller.test.ts | 63 +++++++++++++++---- .../api/v4/affiliates-controller.ts | 34 +++++++--- indexer/services/comlink/src/types.ts | 2 +- 3 files changed, 78 insertions(+), 21 deletions(-) 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 465865020d..62ee29513e 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 @@ -138,16 +138,34 @@ describe('affiliates-controller#V4', () => { }); describe('GET /address', () => { - it('should return address for a valid referral code string', async () => { - const referralCode = 'TempCode123'; + beforeEach(async () => { + await testMocks.seedData(); + await SubaccountUsernamesTable.create(testConstants.defaultSubaccountUsername); + }); + + afterEach(async () => { + await dbHelpers.clearData(); + }); + + it('should return address for a valid referral code', async () => { + const referralCode = testConstants.defaultSubaccountUsername.username; const response: request.Response = await sendRequest({ type: RequestMethod.GET, path: `/v4/affiliates/address?referralCode=${referralCode}`, + expectedStatus: 200, // helper performs expect on status }); - expect(response.status).toBe(200); expect(response.body).toEqual({ - address: 'some_address', + address: testConstants.defaultWallet.address, + }); + }); + + it('should fail when referral code not found', async () => { + const nonExistentReferralCode = 'BadCode123'; + await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/address?referralCode=${nonExistentReferralCode}`, + expectedStatus: 404, // helper performs expect on status }); }); }); @@ -157,11 +175,11 @@ describe('affiliates-controller#V4', () => { const req: AffiliateSnapshotRequest = { limit: 10, offset: 10, - sortByReferredFees: true, + sortByAffiliateEarning: true, }; const response: request.Response = await sendRequest({ type: RequestMethod.GET, - path: `/v4/affiliates/snapshot?limit=${req.limit}&offset=${req.offset}&sortByReferredFees=${req.sortByReferredFees}`, + path: `/v4/affiliates/snapshot?limit=${req.limit}&offset=${req.offset}&sortByReferredFees=${req.sortByAffiliateEarning}`, }); expect(response.status).toBe(200); @@ -184,16 +202,39 @@ describe('affiliates-controller#V4', () => { }); describe('GET /total_volume', () => { - it('should return total_volume for a valid address', async () => { - const address = 'some_address'; + beforeEach(async () => { + await testMocks.seedData(); + await WalletTable.update( + { + address: testConstants.defaultWallet.address, + totalVolume: '100000', + totalTradingRewards: '0', + }, + ); + }); + + afterEach(async () => { + await dbHelpers.clearData(); + }); + + it('should return total volume for a valid address', async () => { const response: request.Response = await sendRequest({ type: RequestMethod.GET, - path: `/v4/affiliates/total_volume?address=${address}`, + path: `/v4/affiliates/total_volume?address=${testConstants.defaultWallet.address}`, + expectedStatus: 200, // helper performs expect on status }); - expect(response.status).toBe(200); expect(response.body).toEqual({ - totalVolume: 111.1, + totalVolume: 100000, + }); + }); + + it('should fail if address does not exist', async () => { + const nonExistentAddress = 'adgsakhasgt'; + await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/metadata?address=${nonExistentAddress}`, + expectedStatus: 404, // helper performs expect on status }); }); }); 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 15ca181906..03890bcde5 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -86,12 +86,24 @@ class AffiliatesController extends Controller { @Get('/address') async getAddress( - @Query() referralCode: string, // eslint-disable-line @typescript-eslint/no-unused-vars + @Query() referralCode: string, ): Promise { - // simulate a delay - await new Promise((resolve) => setTimeout(resolve, 100)); + const usernameRow = await SubaccountUsernamesTable.findByUsername(referralCode); + if (!usernameRow) { + throw new NotFoundError(`Referral code ${referralCode} does not exist`); + } + const subAccountId = usernameRow.subaccountId; + + const subaccountRow = await SubaccountTable.findById(subAccountId); + // subaccountRow should never be undefined because of foreign key constraint between subaccounts + // and subaccount_usernames tables + if (!subaccountRow) { + throw new UnexpectedServerError(`Subaccount ${subAccountId} not found`); + } + const address = subaccountRow.address; + return { - address: 'some_address', + address, }; } @@ -136,12 +148,16 @@ class AffiliatesController extends Controller { @Get('/total_volume') public async getTotalVolume( - @Query() address: string, // eslint-disable-line @typescript-eslint/no-unused-vars + @Query() address: string, ): Promise { - // simulate a delay - await new Promise((resolve) => setTimeout(resolve, 100)); + // Check that the address exists + const walletRow = await WalletTable.findById(address); + if (!walletRow) { + throw new NotFoundError(`Wallet with address ${address} not found`); + } + return { - totalVolume: 111.1, + totalVolume: Number(walletRow.totalVolume), }; } } @@ -257,7 +273,7 @@ router.get( const { offset, limit, - sortByReferredFees, + sortByAffiliateEarning: sortByReferredFees, }: AffiliateSnapshotRequest = matchedData(req) as AffiliateSnapshotRequest; try { diff --git a/indexer/services/comlink/src/types.ts b/indexer/services/comlink/src/types.ts index 2867e0827b..04542f0e5e 100644 --- a/indexer/services/comlink/src/types.ts +++ b/indexer/services/comlink/src/types.ts @@ -686,7 +686,7 @@ export interface AffiliateAddressRequest{ export interface AffiliateSnapshotRequest{ limit?: number, offset?: number, - sortByReferredFees?: boolean, + sortByAffiliateEarning?: boolean, } export interface AffiliateTotalVolumeRequest{ From 93d3f83e9a313c7eb50773fce23be299a297008c Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Mon, 16 Sep 2024 15:08:27 -0400 Subject: [PATCH 7/9] replace affiliates snapshot stub with real implementation --- .../postgres/__tests__/helpers/constants.ts | 56 ++++++-- .../stores/affiliate-info-table.test.ts | 104 ++++++++++++++- .../stores/subaccount-usernames-table.test.ts | 25 ++++ ...precision_and_add_total_referred_volume.ts | 22 +++ indexer/packages/postgres/src/index.ts | 2 + .../src/models/affiliate-info-model.ts | 5 + .../src/stores/affiliate-info-table.ts | 30 +++++ .../src/stores/subaccount-usernames-table.ts | 29 ++++ .../src/types/affiliate-info-types.ts | 2 + .../postgres/src/types/db-model-types.ts | 6 + .../api/v4/affiliates-controller.test.ts | 125 +++++++++++++++--- .../comlink/public/api-documentation.md | 3 +- indexer/services/comlink/public/swagger.json | 13 +- .../api/v4/affiliates-controller.ts | 106 +++++++++++---- indexer/services/comlink/src/types.ts | 3 +- 15 files changed, 467 insertions(+), 64 deletions(-) create mode 100644 indexer/packages/postgres/src/db/migrations/migration_files/20240913142157_change_affiliate_info_decimal_precision_and_add_total_referred_volume.ts diff --git a/indexer/packages/postgres/__tests__/helpers/constants.ts b/indexer/packages/postgres/__tests__/helpers/constants.ts index f306550591..cc7175be59 100644 --- a/indexer/packages/postgres/__tests__/helpers/constants.ts +++ b/indexer/packages/postgres/__tests__/helpers/constants.ts @@ -75,6 +75,7 @@ export const blockedAddress: string = 'dydx1f9k5qldwmqrnwy8hcgp4fw6heuvszt35egvt export const vaultAddress: string = 'dydx1c0m5x87llaunl5sgv3q5vd7j5uha26d2q2r2q0'; // ============== Subaccounts ============== +export const defaultWalletAddress: string = 'defaultWalletAddress'; export const defaultSubaccount: SubaccountCreateObject = { address: defaultAddress, @@ -97,6 +98,14 @@ export const defaultSubaccount3: SubaccountCreateObject = { updatedAtHeight: createdHeight, }; +// defaultWalletAddress belongs to defaultWallet2 and is different from defaultAddress +export const defaultSubaccountDefaultWalletAddress: SubaccountCreateObject = { + address: defaultWalletAddress, + subaccountNumber: 0, + updatedAt: createdDateTime.toISO(), + updatedAtHeight: createdHeight, +}; + export const defaultSubaccountWithAlternateAddress: SubaccountCreateObject = { address: defaultAddress2, subaccountNumber: 0, @@ -125,8 +134,6 @@ export const isolatedSubaccount2: SubaccountCreateObject = { updatedAtHeight: createdHeight, }; -export const defaultWalletAddress: string = 'defaultWalletAddress'; - export const defaultSubaccountId: string = SubaccountTable.uuid( defaultAddress, defaultSubaccount.subaccountNumber, @@ -139,6 +146,10 @@ export const defaultSubaccountId3: string = SubaccountTable.uuid( defaultAddress, defaultSubaccount3.subaccountNumber, ); +export const defaultSubaccountIdDefaultWalletAddress: string = SubaccountTable.uuid( + defaultWalletAddress, + defaultSubaccountDefaultWalletAddress.subaccountNumber, +); export const defaultSubaccountIdWithAlternateAddress: string = SubaccountTable.uuid( defaultAddress2, defaultSubaccountWithAlternateAddress.subaccountNumber, @@ -906,6 +917,17 @@ export const duplicatedSubaccountUsername: SubaccountUsernamesCreateObject = { subaccountId: defaultSubaccountId3, }; +// defaultWalletAddress belongs to defaultWallet2 and is different from defaultAddress +export const subaccountUsernameWithDefaultWalletAddress: SubaccountUsernamesCreateObject = { + username: 'EvilRaisin11', + subaccountId: defaultSubaccountIdDefaultWalletAddress, +}; + +export const subaccountUsernameWithAlternativeAddress: SubaccountUsernamesCreateObject = { + username: 'HonestRaisin32', + subaccountId: defaultSubaccountIdWithAlternateAddress, +}; + // ============== Leaderboard pnl Data ============== export const defaultLeaderboardPnlOneDay: LeaderboardPnlCreateObject = { @@ -963,24 +985,38 @@ export const defaultKV2: PersistentCacheCreateObject = { export const defaultAffiliateInfo: AffiliateInfoCreateObject = { address: defaultAddress, - affiliateEarnings: '10.00', + affiliateEarnings: '10', referredMakerTrades: 10, referredTakerTrades: 20, - totalReferredFees: '10.00', + totalReferredFees: '10', totalReferredUsers: 5, - referredNetProtocolEarnings: '20.00', + referredNetProtocolEarnings: '20', firstReferralBlockHeight: '1', + referredTotalVolume: '1000', }; -export const defaultAffiliateInfo1: AffiliateInfoCreateObject = { - address: defaultAddress2, - affiliateEarnings: '11.00', +export const defaultAffiliateInfo2: AffiliateInfoCreateObject = { + address: defaultWalletAddress, + affiliateEarnings: '11', referredMakerTrades: 11, referredTakerTrades: 21, - totalReferredFees: '11.00', + totalReferredFees: '11', totalReferredUsers: 5, - referredNetProtocolEarnings: '21.00', + referredNetProtocolEarnings: '21', firstReferralBlockHeight: '11', + referredTotalVolume: '1000', +}; + +export const defaultAffiliateInfo3: AffiliateInfoCreateObject = { + address: defaultAddress2, + affiliateEarnings: '12', + referredMakerTrades: 12, + referredTakerTrades: 22, + totalReferredFees: '12', + totalReferredUsers: 10, + referredNetProtocolEarnings: '22', + firstReferralBlockHeight: '12', + referredTotalVolume: '1111111', }; // ============== Tokens ============= diff --git a/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts b/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts index 7a8ac32bd7..e5c5ec33b5 100644 --- a/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts @@ -1,6 +1,6 @@ import { AffiliateInfoFromDatabase } from '../../src/types'; import { clearData, migrate, teardown } from '../../src/helpers/db-helpers'; -import { defaultAffiliateInfo, defaultAffiliateInfo1 } from '../helpers/constants'; +import { defaultAffiliateInfo, defaultAffiliateInfo2 } from '../helpers/constants'; import * as AffiliateInfoTable from '../../src/stores/affiliate-info-table'; describe('Affiliate info store', () => { @@ -32,15 +32,15 @@ describe('Affiliate info store', () => { ); expect(info).toEqual(expect.objectContaining(defaultAffiliateInfo)); - await AffiliateInfoTable.upsert(defaultAffiliateInfo1); - info = await AffiliateInfoTable.findById(defaultAffiliateInfo1.address); - expect(info).toEqual(expect.objectContaining(defaultAffiliateInfo1)); + await AffiliateInfoTable.upsert(defaultAffiliateInfo2); + info = await AffiliateInfoTable.findById(defaultAffiliateInfo2.address); + expect(info).toEqual(expect.objectContaining(defaultAffiliateInfo2)); }); it('Successfully finds all affiliate infos', async () => { await Promise.all([ AffiliateInfoTable.create(defaultAffiliateInfo), - AffiliateInfoTable.create(defaultAffiliateInfo1), + AffiliateInfoTable.create(defaultAffiliateInfo2), ]); const infos: AffiliateInfoFromDatabase[] = await AffiliateInfoTable.findAll( @@ -52,7 +52,7 @@ describe('Affiliate info store', () => { expect(infos.length).toEqual(2); expect(infos).toEqual(expect.arrayContaining([ expect.objectContaining(defaultAffiliateInfo), - expect.objectContaining(defaultAffiliateInfo1), + expect.objectContaining(defaultAffiliateInfo2), ])); }); @@ -65,4 +65,96 @@ describe('Affiliate info store', () => { expect(info).toEqual(expect.objectContaining(defaultAffiliateInfo)); }); + + describe('paginatedFindWithAddressFilter', () => { + beforeEach(async () => { + await migrate(); + for (let i = 0; i < 10; i++) { + await AffiliateInfoTable.create({ + ...defaultAffiliateInfo, + address: `address_${i}`, + affiliateEarnings: i.toString(), + }); + } + }); + + it('Successfully filters by address', async () => { + const infos = await AffiliateInfoTable.paginatedFindWithAddressFilter( + ['address_0'], + 0, + 10, + false, + ); + expect(infos).toBeDefined(); + expect(infos!.length).toEqual(1); + expect(infos![0]).toEqual(expect.objectContaining({ + ...defaultAffiliateInfo, + address: 'address_0', + affiliateEarnings: '0', + })); + }); + + it('Successfully sorts by affiliate earning', async () => { + const infos = await AffiliateInfoTable.paginatedFindWithAddressFilter( + [], + 0, + 10, + true, + ); + expect(infos).toBeDefined(); + expect(infos!.length).toEqual(10); + expect(infos![0]).toEqual(expect.objectContaining({ + ...defaultAffiliateInfo, + address: 'address_9', + affiliateEarnings: '9', + })); + expect(infos![9]).toEqual(expect.objectContaining({ + ...defaultAffiliateInfo, + address: 'address_0', + affiliateEarnings: '0', + })); + }); + + it('Successfully uses offset and limit', async () => { + const infos = await AffiliateInfoTable.paginatedFindWithAddressFilter( + [], + 5, + 2, + false, + ); + expect(infos).toBeDefined(); + expect(infos!.length).toEqual(2); + expect(infos![0]).toEqual(expect.objectContaining({ + ...defaultAffiliateInfo, + address: 'address_5', + affiliateEarnings: '5', + })); + expect(infos![1]).toEqual(expect.objectContaining({ + ...defaultAffiliateInfo, + address: 'address_6', + affiliateEarnings: '6', + })); + }); + + it('Successfully filters, sorts, offsets, and limits', async () => { + const infos = await AffiliateInfoTable.paginatedFindWithAddressFilter( + [], + 3, + 2, + true, + ); + expect(infos).toBeDefined(); + expect(infos!.length).toEqual(2); + expect(infos![0]).toEqual(expect.objectContaining({ + ...defaultAffiliateInfo, + address: 'address_6', + affiliateEarnings: '6', + })); + expect(infos![1]).toEqual(expect.objectContaining({ + ...defaultAffiliateInfo, + address: 'address_5', + affiliateEarnings: '5', + })); + }); + }); }); diff --git a/indexer/packages/postgres/__tests__/stores/subaccount-usernames-table.test.ts b/indexer/packages/postgres/__tests__/stores/subaccount-usernames-table.test.ts index 5df00a60ad..c069df9f59 100644 --- a/indexer/packages/postgres/__tests__/stores/subaccount-usernames-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/subaccount-usernames-table.test.ts @@ -1,11 +1,16 @@ import { SubaccountFromDatabase, SubaccountUsernamesFromDatabase, SubaccountsWithoutUsernamesResult } from '../../src/types'; import * as SubaccountUsernamesTable from '../../src/stores/subaccount-usernames-table'; +import * as WalletTable from '../../src/stores/wallet-table'; import * as SubaccountsTable from '../../src/stores/subaccount-table'; import { clearData, migrate, teardown } from '../../src/helpers/db-helpers'; import { defaultSubaccountUsername, defaultSubaccountUsername2, + defaultSubaccountWithAlternateAddress, + defaultWallet, + defaultWallet2, duplicatedSubaccountUsername, + subaccountUsernameWithAlternativeAddress, } from '../helpers/constants'; import { seedData } from '../helpers/mock-generators'; @@ -80,4 +85,24 @@ describe('SubaccountUsernames store', () => { SubaccountUsernamesTable.getSubaccountsWithoutUsernames(); expect(subaccountIds.length).toEqual(subaccountLength - 1); }); + + it('Get username using address', async () => { + await Promise.all([ + // Add two usernames for defaultWallet + SubaccountUsernamesTable.create(defaultSubaccountUsername), + SubaccountUsernamesTable.create(defaultSubaccountUsername2), + // Add one username for alternativeWallet + WalletTable.create(defaultWallet2), + SubaccountsTable.create(defaultSubaccountWithAlternateAddress), + SubaccountUsernamesTable.create(subaccountUsernameWithAlternativeAddress), + ]); + + // Should only get username for defaultWallet's subaccount 0 + const usernames = await SubaccountUsernamesTable.findByAddress([defaultWallet.address]); + expect(usernames.length).toEqual(1); + expect(usernames[0]).toEqual(expect.objectContaining({ + address: defaultWallet.address, + username: defaultSubaccountUsername.username, + })); + }); }); diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20240913142157_change_affiliate_info_decimal_precision_and_add_total_referred_volume.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20240913142157_change_affiliate_info_decimal_precision_and_add_total_referred_volume.ts new file mode 100644 index 0000000000..53f6d6d373 --- /dev/null +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20240913142157_change_affiliate_info_decimal_precision_and_add_total_referred_volume.ts @@ -0,0 +1,22 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.alterTable('affiliate_info', (table) => { + // null indicates variable precision whereas not specifying will result in 8,2 precision,scale + table.decimal('affiliateEarnings', null).alter(); + table.decimal('totalReferredFees', null).alter(); + table.decimal('referredNetProtocolEarnings', null).alter(); + + table.decimal('referredTotalVolume', null).notNullable(); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.alterTable('affiliate_info', (table) => { + table.decimal('affiliateEarnings').alter(); + table.decimal('totalReferredFees').alter(); + table.decimal('referredNetProtocolEarnings').alter(); + + table.dropColumn('referredTotalVolume'); + }); +} diff --git a/indexer/packages/postgres/src/index.ts b/indexer/packages/postgres/src/index.ts index 0081e3bca9..07be84f1b4 100644 --- a/indexer/packages/postgres/src/index.ts +++ b/indexer/packages/postgres/src/index.ts @@ -20,6 +20,7 @@ export { default as SubaccountUsernamesModel } from './models/subaccount-usernam export { default as LeaderboardPnlModel } from './models/leaderboard-pnl-model'; export { default as PersistentCacheModel } from './models/persistent-cache-model'; export { default as AffiliateReferredUsersModel } from './models/affiliate-referred-users-model'; +export { default as AffiliateInfoModel } from './models/affiliate-info-model'; export * as AssetTable from './stores/asset-table'; export * as AssetPositionTable from './stores/asset-position-table'; @@ -48,6 +49,7 @@ export * as SubaccountUsernamesTable from './stores/subaccount-usernames-table'; export * as PersistentCacheTable from './stores/persistent-cache-table'; export * as AffiliateReferredUsersTable from './stores/affiliate-referred-users-table'; export * as FirebaseNotificationTokenTable from './stores/firebase-notification-token-table'; +export * as AffiliateInfoTable from './stores/affiliate-info-table'; export * as perpetualMarketRefresher from './loops/perpetual-market-refresher'; export * as assetRefresher from './loops/asset-refresher'; diff --git a/indexer/packages/postgres/src/models/affiliate-info-model.ts b/indexer/packages/postgres/src/models/affiliate-info-model.ts index 1fe37b2b61..7fcbefac39 100644 --- a/indexer/packages/postgres/src/models/affiliate-info-model.ts +++ b/indexer/packages/postgres/src/models/affiliate-info-model.ts @@ -23,6 +23,7 @@ export default class AffiliateInfoModel extends BaseModel { 'totalReferredUsers', 'referredNetProtocolEarnings', 'firstReferralBlockHeight', + 'referredTotalVolume', ], properties: { address: { type: 'string' }, @@ -33,6 +34,7 @@ export default class AffiliateInfoModel extends BaseModel { totalReferredUsers: { type: 'int' }, referredNetProtocolEarnings: { type: 'string', pattern: NonNegativeNumericPattern }, firstReferralBlockHeight: { type: 'string', pattern: NonNegativeNumericPattern }, + referredTotalVolume: { type: 'string', pattern: NonNegativeNumericPattern }, }, }; } @@ -53,6 +55,7 @@ export default class AffiliateInfoModel extends BaseModel { totalReferredUsers: 'int', referredNetProtocolEarnings: 'string', firstReferralBlockHeight: 'string', + referredTotalVolume: 'string', }; } @@ -73,4 +76,6 @@ export default class AffiliateInfoModel extends BaseModel { referredNetProtocolEarnings!: string; firstReferralBlockHeight!: string; + + referredTotalVolume!: string; } diff --git a/indexer/packages/postgres/src/stores/affiliate-info-table.ts b/indexer/packages/postgres/src/stores/affiliate-info-table.ts index 3f6695592a..7972bfcef5 100644 --- a/indexer/packages/postgres/src/stores/affiliate-info-table.ts +++ b/indexer/packages/postgres/src/stores/affiliate-info-table.ts @@ -80,6 +80,7 @@ export async function upsert( // should only ever be one AffiliateInfo return AffiliateInfos[0]; } + export async function findById( address: string, options: Options = DEFAULT_POSTGRES_OPTIONS, @@ -92,3 +93,32 @@ export async function findById( .findById(address) .returning('*'); } + +export async function paginatedFindWithAddressFilter( + addressFilter: string[], + offset: number, + limit: number, + sortByAffiliateEarning: boolean, + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + let baseQuery: QueryBuilder = setupBaseQuery( + AffiliateInfoModel, + options, + ); + + // Apply address filter if provided + if (addressFilter.length > 0) { + baseQuery = baseQuery.whereIn(AffiliateInfoColumns.address, addressFilter); + } + + // Sorting by affiliate earnings or default sorting by address + if (sortByAffiliateEarning) { + baseQuery = baseQuery.orderBy(AffiliateInfoColumns.affiliateEarnings, Ordering.DESC); + } + + // Apply pagination using offset and limit + baseQuery = baseQuery.offset(offset).limit(limit); + + // Returning all fields + return baseQuery.returning('*'); +} diff --git a/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts b/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts index a553ebc17f..56abec087d 100644 --- a/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts +++ b/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts @@ -1,6 +1,7 @@ import { QueryBuilder } from 'objection'; import { DEFAULT_POSTGRES_OPTIONS } from '../constants'; +import { knexReadReplica } from '../helpers/knex'; import { verifyAllRequiredFields, setupBaseQuery, @@ -18,6 +19,7 @@ import { Options, Ordering, QueryableField, + AddressUsernameFromDatabase, } from '../types'; export async function findAll( @@ -113,3 +115,30 @@ export async function getSubaccountsWithoutUsernames( return result.rows; } + +export async function findByAddress( + addresses: string[], +): Promise { + if (addresses.length === 0) { + return []; + } + + const result: { rows: AddressUsernameFromDatabase[] } = await knexReadReplica + .getConnection() + .raw( + ` + WITH subaccountIds AS ( + SELECT "id", "address" + FROM subaccounts + WHERE "address" = ANY(?) + AND "subaccountNumber" = 0 + ) + SELECT s."address", u."username" + FROM subaccountIds s + LEFT JOIN subaccount_usernames u ON u."subaccountId" = s."id" + `, + [addresses], + ); + + return result.rows; +} diff --git a/indexer/packages/postgres/src/types/affiliate-info-types.ts b/indexer/packages/postgres/src/types/affiliate-info-types.ts index 4c7f11d108..885de8b9b7 100644 --- a/indexer/packages/postgres/src/types/affiliate-info-types.ts +++ b/indexer/packages/postgres/src/types/affiliate-info-types.ts @@ -7,6 +7,7 @@ export interface AffiliateInfoCreateObject { totalReferredUsers: number, referredNetProtocolEarnings: string, firstReferralBlockHeight: string, + referredTotalVolume: string, } export enum AffiliateInfoColumns { @@ -18,4 +19,5 @@ export enum AffiliateInfoColumns { totalReferredUsers = 'totalReferredUsers', referredNetProtocolEarnings = 'referredNetProtocolEarnings', firstReferralBlockHeight = 'firstReferralBlockHeight', + referredTotalVolume = 'referredTotalVolume', } diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index 21441874ef..363676a45d 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -264,6 +264,11 @@ export interface SubaccountUsernamesFromDatabase { subaccountId: string, } +export interface AddressUsernameFromDatabase { + address: string, + username: string, +} + export interface LeaderboardPnlFromDatabase { address: string, timeSpan: string, @@ -286,6 +291,7 @@ export interface AffiliateInfoFromDatabase { totalReferredUsers: number, referredNetProtocolEarnings: string, firstReferralBlockHeight: string, + referredTotalVolume: string, } export interface AffiliateReferredUserFromDatabase { 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 56565269ed..a4c76dd0f4 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 @@ -2,14 +2,21 @@ import { dbHelpers, testConstants, testMocks, + SubaccountTable, SubaccountUsernamesTable, WalletTable, AffiliateReferredUsersTable, + AffiliateInfoTable, + AffiliateInfoCreateObject, } from '@dydxprotocol-indexer/postgres'; -import { AffiliateSnapshotRequest, RequestMethod } from '../../../../src/types'; +import { + AffiliateSnapshotRequest, + AffiliateSnapshotResponse, + RequestMethod, + AffiliateSnapshotResponseObject, +} from '../../../../src/types'; import request from 'supertest'; import { sendRequest } from '../../../helpers/helpers'; -import { defaultWallet, defaultWallet2 } from '@dydxprotocol-indexer/postgres/build/__tests__/helpers/constants'; describe('affiliates-controller#V4', () => { beforeAll(async () => { @@ -110,8 +117,8 @@ describe('affiliates-controller#V4', () => { it('should classify is affiliate', async () => { await AffiliateReferredUsersTable.create({ - affiliateAddress: defaultWallet.address, - refereeAddress: defaultWallet2.address, + affiliateAddress: testConstants.defaultWallet.address, + refereeAddress: testConstants.defaultWallet2.address, referredAtBlock: '1', }); const response: request.Response = await sendRequest({ @@ -171,21 +178,60 @@ describe('affiliates-controller#V4', () => { }); describe('GET /snapshot', () => { - it('should return snapshots when all params specified', async () => { + const defaultInfo = testConstants.defaultAffiliateInfo; + const defaultInfo2 = testConstants.defaultAffiliateInfo2; + const defaultInfo3 = testConstants.defaultAffiliateInfo3; + + beforeEach(async () => { + await testMocks.seedData(); + // Create username for defaultWallet + await SubaccountUsernamesTable.create(testConstants.defaultSubaccountUsername); + + // Create defaultWallet2, subaccount, and username + await WalletTable.create(testConstants.defaultWallet2); + await SubaccountTable.create(testConstants.defaultSubaccountDefaultWalletAddress); + await SubaccountUsernamesTable.create( + testConstants.subaccountUsernameWithDefaultWalletAddress, + ); + + // Create defaultWallet3, create subaccount, create username + await WalletTable.create(testConstants.defaultWallet3); + await SubaccountTable.create(testConstants.defaultSubaccountWithAlternateAddress); + await SubaccountUsernamesTable.create(testConstants.subaccountUsernameWithAlternativeAddress); + + // Create affiliate infos + await AffiliateInfoTable.create(defaultInfo); + await AffiliateInfoTable.create(defaultInfo2); + await AffiliateInfoTable.create(defaultInfo3); + }); + + afterEach(async () => { + await dbHelpers.clearData(); + }); + + it('should filter by address', async () => { const req: AffiliateSnapshotRequest = { - limit: 10, - offset: 10, - sortByReferredFees: true, + addressFilter: [testConstants.defaultWallet.address], }; const response: request.Response = await sendRequest({ type: RequestMethod.GET, - path: `/v4/affiliates/snapshot?limit=${req.limit}&offset=${req.offset}&sortByReferredFees=${req.sortByReferredFees}`, + path: `/v4/affiliates/snapshot?addressFilter=${req.addressFilter!.join(',')}`, + expectedStatus: 200, // helper performs expect on status, }); - expect(response.status).toBe(200); - expect(response.body.affiliateList).toHaveLength(10); - expect(response.body.currentOffset).toBe(10); - expect(response.body.total).toBe(10); + const expectedResponse: AffiliateSnapshotResponse = { + affiliateList: [ + affiliateInfoCreateToResponseObject( + defaultInfo, testConstants.defaultSubaccountUsername.username, + ), + ], + total: 1, + currentOffset: 0, + }; + expect(response.body.affiliateList).toHaveLength(1); + expect(response.body.affiliateList[0]).toEqual(expectedResponse.affiliateList[0]); + expect(response.body.currentOffset).toEqual(expectedResponse.currentOffset); + expect(response.body.total).toEqual(expectedResponse.total); }); it('should return snapshots when optional params not specified', async () => { @@ -195,9 +241,39 @@ describe('affiliates-controller#V4', () => { }); expect(response.status).toBe(200); - expect(response.body.affiliateList).toHaveLength(1000); - expect(response.body.currentOffset).toBe(0); - expect(response.body.total).toBe(1000); + expect(response.body.affiliateList).toHaveLength(3); + expect(response.body.currentOffset).toEqual(0); + expect(response.body.total).toEqual(3); + }); + + it('should return snapshots when all params specified', async () => { + const req: AffiliateSnapshotRequest = { + addressFilter: [testConstants.defaultWallet.address, testConstants.defaultWallet2.address], + sortByAffiliateEarning: true, + }; + const response: request.Response = await sendRequest({ + type: RequestMethod.GET, + path: `/v4/affiliates/snapshot?${req.addressFilter!.map((address) => `addressFilter[]=${address}`).join('&')}&offset=1&limit=1&sortByAffiliateEarning=${req.sortByAffiliateEarning}`, + expectedStatus: 200, // helper performs expect on status + }); + + // addressFilter removes defaultInfo3 + // sortorder -> [defaultInfo2, defaultInfo] + // offset=1 -> defaultInfo + const expectedResponse: AffiliateSnapshotResponse = { + affiliateList: [ + affiliateInfoCreateToResponseObject( + defaultInfo, testConstants.defaultSubaccountUsername.username, + ), + ], + total: 1, + currentOffset: 1, + }; + + expect(response.body.affiliateList).toHaveLength(1); + expect(response.body.currentOffset).toEqual(expectedResponse.currentOffset); + expect(response.body.total).toEqual(expectedResponse.total); + expect(response.body.affiliateList[0]).toEqual(expectedResponse.affiliateList[0]); }); }); @@ -239,3 +315,20 @@ describe('affiliates-controller#V4', () => { }); }); }); + +function affiliateInfoCreateToResponseObject( + info: AffiliateInfoCreateObject, + username: string, +): AffiliateSnapshotResponseObject { + return { + affiliateAddress: info.address, + affiliateReferralCode: username, + affiliateEarnings: Number(info.affiliateEarnings), + affiliateReferredTrades: + Number(info.referredTakerTrades) + Number(info.referredMakerTrades), + affiliateTotalReferredFees: Number(info.totalReferredFees), + affiliateReferredUsers: Number(info.totalReferredUsers), + affiliateReferredNetProtocolEarnings: Number(info.referredNetProtocolEarnings), + affiliateReferredTotalVolume: Number(info.referredTotalVolume), + }; +} diff --git a/indexer/services/comlink/public/api-documentation.md b/indexer/services/comlink/public/api-documentation.md index 0439c0e85f..ceb40bff96 100644 --- a/indexer/services/comlink/public/api-documentation.md +++ b/indexer/services/comlink/public/api-documentation.md @@ -633,9 +633,10 @@ fetch(`${baseURL}/affiliates/snapshot`, |Name|In|Type|Required|Description| |---|---|---|---|---| +|addressFilter|query|array[string]|false|none| |offset|query|number(double)|false|none| |limit|query|number(double)|false|none| -|sortByReferredFees|query|boolean|false|none| +|sortByAffiliateEarning|query|boolean|false|none| > Example responses diff --git a/indexer/services/comlink/public/swagger.json b/indexer/services/comlink/public/swagger.json index 5fc7a5cff8..ff388856a5 100644 --- a/indexer/services/comlink/public/swagger.json +++ b/indexer/services/comlink/public/swagger.json @@ -1816,6 +1816,17 @@ }, "security": [], "parameters": [ + { + "in": "query", + "name": "addressFilter", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, { "in": "query", "name": "offset", @@ -1836,7 +1847,7 @@ }, { "in": "query", - "name": "sortByReferredFees", + "name": "sortByAffiliateEarning", "required": false, "schema": { "type": "boolean" 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 841d37c300..0a64429f36 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -1,6 +1,7 @@ -import { stats } from '@dydxprotocol-indexer/base'; +import { logger, stats } from '@dydxprotocol-indexer/base'; import { WalletTable, + AffiliateInfoTable, AffiliateReferredUsersTable, SubaccountTable, SubaccountUsernamesTable, @@ -116,38 +117,63 @@ class AffiliatesController extends Controller { @Get('/snapshot') async getSnapshot( - @Query() offset?: number, + @Query() addressFilter?: string[], + @Query() offset?: number, @Query() limit?: number, - @Query() sortByReferredFees?: boolean, + @Query() sortByAffiliateEarning?: boolean, ): Promise { + const finalAddressFilter = addressFilter ?? []; const finalOffset = offset ?? 0; const finalLimit = limit ?? 1000; - // eslint-disable-next-line - const finalSortByReferredFees = sortByReferredFees ?? false; + const finalsortByAffiliateEarning = sortByAffiliateEarning ?? false; - // simulate a delay - await new Promise((resolve) => setTimeout(resolve, 100)); + const infos = await AffiliateInfoTable.paginatedFindWithAddressFilter( + finalAddressFilter, + finalOffset, + finalLimit, + finalsortByAffiliateEarning, + ); - const snapshot: AffiliateSnapshotResponseObject = { - affiliateAddress: 'some_address', - affiliateReferralCode: 'TempCode123', - affiliateEarnings: 100, - affiliateReferredTrades: 1000, - affiliateTotalReferredFees: 100, - affiliateReferredUsers: 10, - affiliateReferredNetProtocolEarnings: 1000, - affiliateReferredTotalVolume: 1000000, - }; + // No results found + if (infos === undefined) { + return { + affiliateList: [], + total: finalLimit, + currentOffset: finalOffset, + }; + } - const affiliateSnapshots: AffiliateSnapshotResponseObject[] = []; - for (let i = 0; i < finalLimit; i++) { - affiliateSnapshots.push(snapshot); + // Get referral codes + const addressUsernames = await SubaccountUsernamesTable.findByAddress( + infos.map((info) => info.address), + ); + const addressUsernameMap: Record = {}; + addressUsernames.forEach((addressUsername) => { + addressUsernameMap[addressUsername.address] = addressUsername.username; + }); + if (addressUsernames.length !== infos.length) { + logger.warning({ + at: 'affiliates-controller#snapshot', + message: `Could not find referral code for following addresses: ${infos.map((info) => info.address).filter((address) => !(address in addressUsernameMap)).join(', ')}`, + }); } + const affiliateSnapshots: AffiliateSnapshotResponseObject[] = infos.map((info) => ({ + affiliateAddress: info.address, + affiliateReferralCode: + info.address in addressUsernameMap ? addressUsernameMap[info.address] : '', + affiliateEarnings: Number(info.affiliateEarnings), + affiliateReferredTrades: Number(info.referredMakerTrades) + Number(info.referredTakerTrades), + affiliateTotalReferredFees: Number(info.totalReferredFees), + affiliateReferredUsers: Number(info.totalReferredUsers), + affiliateReferredNetProtocolEarnings: Number(info.referredNetProtocolEarnings), + affiliateReferredTotalVolume: Number(info.referredTotalVolume), + })); + const response: AffiliateSnapshotResponse = { affiliateList: affiliateSnapshots, - total: finalLimit, currentOffset: finalOffset, + total: affiliateSnapshots.length, }; return response; @@ -251,26 +277,46 @@ router.get( '/snapshot', rateLimiterMiddleware(getReqRateLimiter), ...checkSchema({ + addressFilter: { + in: ['query'], + optional: true, + customSanitizer: { + options: (value) => { + // Split the comma-separated string into an array + return typeof value === 'string' ? value.split(',') : value; + }, + }, + custom: { + options: (values) => { + return Array.isArray(values) && values.length > 0 && values.every((val) => typeof val === 'string'); + }, + }, + errorMessage: 'addressFilter must be a non-empy array of comma separated strings', + }, offset: { in: ['query'], - isInt: true, + optional: true, // Make sure this is the first rule + isInt: { + options: { min: 0 }, + }, toInt: true, - optional: true, errorMessage: 'offset must be a valid integer', }, limit: { in: ['query'], - isInt: true, + optional: true, // Make sure this is the first rule + isInt: { + options: { min: 1 }, + }, toInt: true, - optional: true, errorMessage: 'limit must be a valid integer', }, - sortByReferredFees: { + sortByAffiliateEarning: { in: ['query'], isBoolean: true, toBoolean: true, optional: true, - errorMessage: 'sortByReferredFees must be a boolean', + errorMessage: 'sortByAffiliateEarning must be a boolean', }, }), handleValidationErrors, @@ -278,17 +324,19 @@ router.get( async (req: express.Request, res: express.Response) => { const start: number = Date.now(); const { + addressFilter, offset, limit, - sortByReferredFees, + sortByAffiliateEarning, }: AffiliateSnapshotRequest = matchedData(req) as AffiliateSnapshotRequest; try { const controller: AffiliatesController = new AffiliatesController(); const response: AffiliateSnapshotResponse = await controller.getSnapshot( + addressFilter, offset, limit, - sortByReferredFees, + sortByAffiliateEarning, ); return res.send(response); } catch (error) { diff --git a/indexer/services/comlink/src/types.ts b/indexer/services/comlink/src/types.ts index 2867e0827b..6269eecdc3 100644 --- a/indexer/services/comlink/src/types.ts +++ b/indexer/services/comlink/src/types.ts @@ -684,9 +684,10 @@ export interface AffiliateAddressRequest{ } export interface AffiliateSnapshotRequest{ + addressFilter?: string[], limit?: number, offset?: number, - sortByReferredFees?: boolean, + sortByAffiliateEarning?: boolean, } export interface AffiliateTotalVolumeRequest{ From 6ca6830bac99dd4b27a5de535d64b14e4d352820 Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Tue, 17 Sep 2024 13:07:05 -0400 Subject: [PATCH 8/9] pr revision --- .../stores/affiliate-info-table.test.ts | 77 +++++++++++-------- .../src/stores/affiliate-info-table.ts | 1 - .../src/stores/subaccount-usernames-table.ts | 6 +- .../postgres/src/types/db-model-types.ts | 2 +- .../api/v4/affiliates-controller.test.ts | 10 ++- .../api/v4/affiliates-controller.ts | 27 ++++--- 6 files changed, 71 insertions(+), 52 deletions(-) diff --git a/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts b/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts index c3a6c79bd0..79223a837c 100644 --- a/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts @@ -69,23 +69,24 @@ describe('Affiliate info store', () => { describe('paginatedFindWithAddressFilter', () => { beforeEach(async () => { await migrate(); - for (let i = 0; i < 10; i++) { - await AffiliateInfoTable.create({ + await Promise.all( + Array.from({ length: 10 }, (_, i) => AffiliateInfoTable.create({ ...defaultAffiliateInfo, address: `address_${i}`, affiliateEarnings: i.toString(), - }); - } + }), + ), + ); }); it('Successfully filters by address', async () => { - // eslint-disable-next-line max-len - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable.paginatedFindWithAddressFilter( - ['address_0'], - 0, - 10, - false, - ); + const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + .paginatedFindWithAddressFilter( + ['address_0'], + 0, + 10, + false, + ); expect(infos).toBeDefined(); expect(infos!.length).toEqual(1); expect(infos![0]).toEqual(expect.objectContaining({ @@ -96,13 +97,13 @@ describe('Affiliate info store', () => { }); it('Successfully sorts by affiliate earning', async () => { - // eslint-disable-next-line max-len - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable.paginatedFindWithAddressFilter( - [], - 0, - 10, - true, - ); + const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + .paginatedFindWithAddressFilter( + [], + 0, + 10, + true, + ); expect(infos).toBeDefined(); expect(infos!.length).toEqual(10); expect(infos![0]).toEqual(expect.objectContaining({ @@ -118,13 +119,13 @@ describe('Affiliate info store', () => { }); it('Successfully uses offset and limit', async () => { - // eslint-disable-next-line max-len - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable.paginatedFindWithAddressFilter( - [], - 5, - 2, - false, - ); + const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + .paginatedFindWithAddressFilter( + [], + 5, + 2, + false, + ); expect(infos).toBeDefined(); expect(infos!.length).toEqual(2); expect(infos![0]).toEqual(expect.objectContaining({ @@ -140,13 +141,13 @@ describe('Affiliate info store', () => { }); it('Successfully filters, sorts, offsets, and limits', async () => { - // eslint-disable-next-line max-len - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable.paginatedFindWithAddressFilter( - [], - 3, - 2, - true, - ); + const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + .paginatedFindWithAddressFilter( + [], + 3, + 2, + true, + ); expect(infos).toBeDefined(); expect(infos!.length).toEqual(2); expect(infos![0]).toEqual(expect.objectContaining({ @@ -160,5 +161,17 @@ describe('Affiliate info store', () => { affiliateEarnings: '5', })); }); + + it('Returns empty array if no results', async () => { + const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + .paginatedFindWithAddressFilter( + ['address_11'], + 0, + 10, + false, + ); + expect(infos).toBeDefined(); + expect(infos!.length).toEqual(0); + }); }); }); diff --git a/indexer/packages/postgres/src/stores/affiliate-info-table.ts b/indexer/packages/postgres/src/stores/affiliate-info-table.ts index 7972bfcef5..7ef02c7a91 100644 --- a/indexer/packages/postgres/src/stores/affiliate-info-table.ts +++ b/indexer/packages/postgres/src/stores/affiliate-info-table.ts @@ -119,6 +119,5 @@ export async function paginatedFindWithAddressFilter( // Apply pagination using offset and limit baseQuery = baseQuery.offset(offset).limit(limit); - // Returning all fields return baseQuery.returning('*'); } diff --git a/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts b/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts index 56abec087d..a60b1c6da8 100644 --- a/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts +++ b/indexer/packages/postgres/src/stores/subaccount-usernames-table.ts @@ -19,7 +19,7 @@ import { Options, Ordering, QueryableField, - AddressUsernameFromDatabase, + AddressUsername, } from '../types'; export async function findAll( @@ -118,12 +118,12 @@ export async function getSubaccountsWithoutUsernames( export async function findByAddress( addresses: string[], -): Promise { +): Promise { if (addresses.length === 0) { return []; } - const result: { rows: AddressUsernameFromDatabase[] } = await knexReadReplica + const result: { rows: AddressUsername[] } = await knexReadReplica .getConnection() .raw( ` diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index 363676a45d..06b12f36c1 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -264,7 +264,7 @@ export interface SubaccountUsernamesFromDatabase { subaccountId: string, } -export interface AddressUsernameFromDatabase { +export interface AddressUsername { address: string, username: string, } 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 00ddd81972..ea89899e15 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 @@ -200,9 +200,11 @@ describe('affiliates-controller#V4', () => { await SubaccountUsernamesTable.create(testConstants.subaccountUsernameWithAlternativeAddress); // Create affiliate infos - await AffiliateInfoTable.create(defaultInfo); - await AffiliateInfoTable.create(defaultInfo2); - await AffiliateInfoTable.create(defaultInfo3); + await Promise.all([ + AffiliateInfoTable.create(defaultInfo), + AffiliateInfoTable.create(defaultInfo2), + AffiliateInfoTable.create(defaultInfo3), + ]); }); afterEach(async () => { @@ -246,7 +248,7 @@ describe('affiliates-controller#V4', () => { expect(response.body.total).toEqual(expectedResponse.total); }); - it('should handle no results when filter by address', async () => { + it('should handle no results', async () => { const req: AffiliateSnapshotRequest = { addressFilter: ['nonexistentaddress'], }; 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 fec1bed002..5a224a7228 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -1,6 +1,6 @@ import { logger, stats } from '@dydxprotocol-indexer/base'; import { - AddressUsernameFromDatabase, + AddressUsername, WalletTable, AffiliateInfoTable, AffiliateReferredUsersTable, @@ -129,13 +129,13 @@ class AffiliatesController extends Controller { const finalLimit: number = limit ?? 1000; const finalsortByAffiliateEarning: boolean = sortByAffiliateEarning ?? false; - // eslint-disable-next-line max-len - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable.paginatedFindWithAddressFilter( - finalAddressFilter, - finalOffset, - finalLimit, - finalsortByAffiliateEarning, - ); + const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + .paginatedFindWithAddressFilter( + finalAddressFilter, + finalOffset, + finalLimit, + finalsortByAffiliateEarning, + ); // No results found if (infos === undefined) { @@ -147,8 +147,8 @@ class AffiliatesController extends Controller { } // Get referral codes - // eslint-disable-next-line max-len - const addressUsernames: AddressUsernameFromDatabase[] = await SubaccountUsernamesTable.findByAddress( + const addressUsernames: + AddressUsername[] = await SubaccountUsernamesTable.findByAddress( infos.map((info) => info.address), ); const addressUsernameMap: Record = {}; @@ -156,9 +156,14 @@ class AffiliatesController extends Controller { addressUsernameMap[addressUsername.address] = addressUsername.username; }); if (addressUsernames.length !== infos.length) { + const addressesNotFound = infos + .map((info) => info.address) + .filter((address) => !(address in addressUsernameMap)) + .join(', '); + logger.warning({ at: 'affiliates-controller#snapshot', - message: `Could not find referral code for following addresses: ${infos.map((info) => info.address).filter((address) => !(address in addressUsernameMap)).join(', ')}`, + message: `Could not find referral code for the following addresses: ${addressesNotFound}`, }); } From b344fa0e1887e82599507602719d5d3256f00577 Mon Sep 17 00:00:00 2001 From: Jerry Fan Date: Tue, 17 Sep 2024 17:28:58 -0400 Subject: [PATCH 9/9] pr revision 2 --- .../__tests__/stores/affiliate-info-table.test.ts | 14 +++++++------- .../postgres/src/stores/affiliate-info-table.ts | 4 ++-- .../controllers/api/v4/affiliates-controller.ts | 13 ++----------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts b/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts index 79223a837c..206c634652 100644 --- a/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts +++ b/indexer/packages/postgres/__tests__/stores/affiliate-info-table.test.ts @@ -27,7 +27,7 @@ describe('Affiliate info store', () => { it('Can upsert affiliate info multiple times', async () => { await AffiliateInfoTable.upsert(defaultAffiliateInfo); - let info: AffiliateInfoFromDatabase | undefined = await AffiliateInfoTable.findById( + let info: AffiliateInfoFromDatabase = await AffiliateInfoTable.findById( defaultAffiliateInfo.address, ); expect(info).toEqual(expect.objectContaining(defaultAffiliateInfo)); @@ -59,7 +59,7 @@ describe('Affiliate info store', () => { it('Successfully finds an affiliate info', async () => { await AffiliateInfoTable.create(defaultAffiliateInfo); - const info: AffiliateInfoFromDatabase | undefined = await AffiliateInfoTable.findById( + const info: AffiliateInfoFromDatabase = await AffiliateInfoTable.findById( defaultAffiliateInfo.address, ); @@ -80,7 +80,7 @@ describe('Affiliate info store', () => { }); it('Successfully filters by address', async () => { - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + const infos: AffiliateInfoFromDatabase[] = await AffiliateInfoTable .paginatedFindWithAddressFilter( ['address_0'], 0, @@ -97,7 +97,7 @@ describe('Affiliate info store', () => { }); it('Successfully sorts by affiliate earning', async () => { - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + const infos: AffiliateInfoFromDatabase[] = await AffiliateInfoTable .paginatedFindWithAddressFilter( [], 0, @@ -119,7 +119,7 @@ describe('Affiliate info store', () => { }); it('Successfully uses offset and limit', async () => { - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + const infos: AffiliateInfoFromDatabase[] = await AffiliateInfoTable .paginatedFindWithAddressFilter( [], 5, @@ -141,7 +141,7 @@ describe('Affiliate info store', () => { }); it('Successfully filters, sorts, offsets, and limits', async () => { - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + const infos: AffiliateInfoFromDatabase[] = await AffiliateInfoTable .paginatedFindWithAddressFilter( [], 3, @@ -163,7 +163,7 @@ describe('Affiliate info store', () => { }); it('Returns empty array if no results', async () => { - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + const infos: AffiliateInfoFromDatabase[] = await AffiliateInfoTable .paginatedFindWithAddressFilter( ['address_11'], 0, diff --git a/indexer/packages/postgres/src/stores/affiliate-info-table.ts b/indexer/packages/postgres/src/stores/affiliate-info-table.ts index 7ef02c7a91..8a27b8c895 100644 --- a/indexer/packages/postgres/src/stores/affiliate-info-table.ts +++ b/indexer/packages/postgres/src/stores/affiliate-info-table.ts @@ -84,7 +84,7 @@ export async function upsert( export async function findById( address: string, options: Options = DEFAULT_POSTGRES_OPTIONS, -): Promise { +): Promise { const baseQuery: QueryBuilder = setupBaseQuery( AffiliateInfoModel, options, @@ -100,7 +100,7 @@ export async function paginatedFindWithAddressFilter( limit: number, sortByAffiliateEarning: boolean, options: Options = DEFAULT_POSTGRES_OPTIONS, -): Promise { +): Promise { let baseQuery: QueryBuilder = setupBaseQuery( AffiliateInfoModel, options, 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 5a224a7228..76e2433255 100644 --- a/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts +++ b/indexer/services/comlink/src/controllers/api/v4/affiliates-controller.ts @@ -129,7 +129,7 @@ class AffiliatesController extends Controller { const finalLimit: number = limit ?? 1000; const finalsortByAffiliateEarning: boolean = sortByAffiliateEarning ?? false; - const infos: AffiliateInfoFromDatabase[] | undefined = await AffiliateInfoTable + const infos: AffiliateInfoFromDatabase[] = await AffiliateInfoTable .paginatedFindWithAddressFilter( finalAddressFilter, finalOffset, @@ -137,15 +137,6 @@ class AffiliatesController extends Controller { finalsortByAffiliateEarning, ); - // No results found - if (infos === undefined) { - return { - affiliateList: [], - total: 0, - currentOffset: finalOffset, - }; - } - // Get referral codes const addressUsernames: AddressUsername[] = await SubaccountUsernamesTable.findByAddress( @@ -156,7 +147,7 @@ class AffiliatesController extends Controller { addressUsernameMap[addressUsername.address] = addressUsername.username; }); if (addressUsernames.length !== infos.length) { - const addressesNotFound = infos + const addressesNotFound: string = infos .map((info) => info.address) .filter((address) => !(address in addressUsernameMap)) .join(', ');