From 21fc5bd28bf1e0c03b60cb4ac40ad7ae138be7ef Mon Sep 17 00:00:00 2001 From: Teddy Ding Date: Mon, 13 Jan 2025 12:27:20 -0500 Subject: [PATCH] Add table `affiliate-referee-stats` and implement table methods --- .../postgres/__tests__/helpers/constants.ts | 46 ++ .../affiliate-referee-stats-table.test.ts | 403 ++++++++++++++++++ .../20241126173317_affiliate_referee_stats.ts | 24 ++ .../postgres/src/helpers/db-helpers.ts | 1 + .../models/affiliate-referee-stats-model.ts | 91 ++++ .../stores/affiliate-referee-stats-table.ts | 328 ++++++++++++++ .../types/affiliate-referee-stats-types.ts | 27 ++ .../postgres/src/types/db-model-types.ts | 14 + indexer/packages/postgres/src/types/index.ts | 1 + .../postgres/src/types/query-types.ts | 4 + 10 files changed, 939 insertions(+) create mode 100644 indexer/packages/postgres/__tests__/stores/affiliate-referee-stats-table.test.ts create mode 100644 indexer/packages/postgres/src/db/migrations/migration_files/20241126173317_affiliate_referee_stats.ts create mode 100644 indexer/packages/postgres/src/models/affiliate-referee-stats-model.ts create mode 100644 indexer/packages/postgres/src/stores/affiliate-referee-stats-table.ts create mode 100644 indexer/packages/postgres/src/types/affiliate-referee-stats-types.ts diff --git a/indexer/packages/postgres/__tests__/helpers/constants.ts b/indexer/packages/postgres/__tests__/helpers/constants.ts index aeaedeae3c..dd8762227f 100644 --- a/indexer/packages/postgres/__tests__/helpers/constants.ts +++ b/indexer/packages/postgres/__tests__/helpers/constants.ts @@ -62,6 +62,7 @@ import { PersistentCacheCreateObject, VaultCreateObject, VaultStatus, + AffiliateRefereeStatsCreateObject, } from '../../src/types'; import { denomToHumanReadableConversion } from './conversion-helpers'; @@ -72,6 +73,7 @@ export const dydxChain: string = 'dydx'; export const defaultAddress: string = 'dydx1n88uc38xhjgxzw9nwre4ep2c8ga4fjxc565lnf'; export const defaultAddress2: string = 'dydx1n88uc38xhjgxzw9nwre4ep2c8ga4fjxc575lnf'; export const defaultAddress3: string = 'dydx199tqg4wdlnu4qjlxchpd7seg454937hjrknju4'; +export const defaultAddress4: string = 'dydx1wau5mja7j7zdavtfq9lu7ejef05hm6ffenlcsn'; export const blockedAddress: string = 'dydx1f9k5qldwmqrnwy8hcgp4fw6heuvszt35egvtx2'; // Vault address for vault id 0 was generated using // script protocol/scripts/vault/get_vault.go @@ -1013,6 +1015,50 @@ export const defaultKV2: PersistentCacheCreateObject = { value: 'otherValue', }; +// ============== Affiliate Per-referee Stats Data ============== + +export const affiliateStatDefaultAddrReferredByAddr2: AffiliateRefereeStatsCreateObject = { + affiliateAddress: defaultAddress2, + refereeAddress: defaultAddress, + affiliateEarnings: '12.5', + referredMakerTrades: 10, + referredTakerTrades: 20, + referredLiquidationFees: '30.5', + referredMakerFees: '2', + referredTakerFees: '20', + referredMakerRebates: '-5.5', + referralBlockHeight: '1', + referredTotalVolume: '12345.6', +}; + +export const affiliateStatAddr3ReferredByAddr2: AffiliateRefereeStatsCreateObject = { + affiliateAddress: defaultAddress2, + refereeAddress: defaultAddress3, + affiliateEarnings: '22.5', + referredMakerTrades: 20, + referredTakerTrades: 40, + referredLiquidationFees: '0', + referredMakerFees: '0', + referredTakerFees: '40.5', + referredMakerRebates: '0', + referralBlockHeight: '2', + referredTotalVolume: '23456.7', +}; + +export const affiliateStatAddr4ReferredByAddr: AffiliateRefereeStatsCreateObject = { + affiliateAddress: defaultAddress, + refereeAddress: defaultAddress4, + affiliateEarnings: '52.5', + referredMakerTrades: 20, + referredTakerTrades: 40, + referredLiquidationFees: '0', + referredMakerFees: '5.5', + referredTakerFees: '60.5', + referredMakerRebates: '0', + referralBlockHeight: '2', + referredTotalVolume: '123456.7', +}; + // ============== Affiliate Info Data ============== export const defaultAffiliateInfo: AffiliateInfoCreateObject = { diff --git a/indexer/packages/postgres/__tests__/stores/affiliate-referee-stats-table.test.ts b/indexer/packages/postgres/__tests__/stores/affiliate-referee-stats-table.test.ts new file mode 100644 index 0000000000..e61e052e33 --- /dev/null +++ b/indexer/packages/postgres/__tests__/stores/affiliate-referee-stats-table.test.ts @@ -0,0 +1,403 @@ +import { + AffiliateRefereeStatsFromDatabase, Liquidity, FillType, +} from '../../src/types'; +import { clearData, migrate, teardown } from '../../src/helpers/db-helpers'; +import { + defaultOrder, + defaultAddress4, + defaultFill, + defaultAddress2, + affiliateStatDefaultAddrReferredByAddr2, + affiliateStatAddr3ReferredByAddr2, + affiliateStatAddr4ReferredByAddr, + defaultTendermintEventId, + defaultTendermintEventId2, + defaultTendermintEventId3, + defaultTendermintEventId4, + // vaultAddress, +} from '../helpers/constants'; +import * as AffiliateRefereeStatsTable from '../../src/stores/affiliate-referee-stats-table'; +import * as OrderTable from '../../src/stores/order-table'; +import * as AffiliateReferredUsersTable from '../../src/stores/affiliate-referred-users-table'; +import * as FillTable from '../../src/stores/fill-table'; +import { seedData } from '../helpers/mock-generators'; +import { DateTime } from 'luxon'; + +describe('Affiliate info store', () => { + beforeAll(async () => { + await migrate(); + }); + + afterEach(async () => { + await clearData(); + }); + + afterAll(async () => { + await teardown(); + }); + + it('Successfully creates affiliate stats', async () => { + await AffiliateRefereeStatsTable.create(affiliateStatDefaultAddrReferredByAddr2); + }); + + it('Cannot create duplicate stats for referee', async () => { + await AffiliateRefereeStatsTable.create(affiliateStatDefaultAddrReferredByAddr2); + await expect(AffiliateRefereeStatsTable.create( + affiliateStatDefaultAddrReferredByAddr2)).rejects.toThrowError(); + }); + + it('Can upsert referee stats multiple times', async () => { + await AffiliateRefereeStatsTable.upsert(affiliateStatDefaultAddrReferredByAddr2); + let info: + AffiliateRefereeStatsFromDatabase | undefined = await AffiliateRefereeStatsTable.findById( + affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + ); + expect(info).toEqual(expect.objectContaining(affiliateStatDefaultAddrReferredByAddr2)); + + await AffiliateRefereeStatsTable.upsert(affiliateStatAddr3ReferredByAddr2); + info = await AffiliateRefereeStatsTable.findById( + affiliateStatAddr3ReferredByAddr2.refereeAddress, + ); + expect(info).toEqual(expect.objectContaining(affiliateStatAddr3ReferredByAddr2)); + }); + + it('Successfully finds all referee stats', async () => { + await Promise.all([ + AffiliateRefereeStatsTable.create(affiliateStatDefaultAddrReferredByAddr2), + AffiliateRefereeStatsTable.create(affiliateStatAddr3ReferredByAddr2), + ]); + + const infos: AffiliateRefereeStatsFromDatabase[] = await AffiliateRefereeStatsTable.findAll( + {}, + [], + { readReplica: true }, + ); + + expect(infos.length).toEqual(2); + expect(infos).toEqual(expect.arrayContaining([ + expect.objectContaining(affiliateStatDefaultAddrReferredByAddr2), + expect.objectContaining(affiliateStatAddr3ReferredByAddr2), + ])); + }); + + it('Successfully finds referee stat by id', async () => { + await AffiliateRefereeStatsTable.create(affiliateStatDefaultAddrReferredByAddr2); + + const info: + AffiliateRefereeStatsFromDatabase | undefined = await AffiliateRefereeStatsTable.findById( + affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + ); + expect(info).toEqual(expect.objectContaining(affiliateStatDefaultAddrReferredByAddr2)); + }); + + it('Successfully finds referee stats refereed by one affiliate user', async () => { + await Promise.all([ + AffiliateRefereeStatsTable.create(affiliateStatDefaultAddrReferredByAddr2), + AffiliateRefereeStatsTable.create(affiliateStatAddr3ReferredByAddr2), + AffiliateRefereeStatsTable.create(affiliateStatAddr4ReferredByAddr), + ]); + + const stats: + AffiliateRefereeStatsFromDatabase[] = await AffiliateRefereeStatsTable.findAll( + { + affiliateAddress: affiliateStatDefaultAddrReferredByAddr2.affiliateAddress, + }, + [], + { readReplica: true }, + ); + + expect(stats).toHaveLength(2); + expect(stats).toEqual(expect.arrayContaining([ + expect.objectContaining(affiliateStatDefaultAddrReferredByAddr2), + expect.objectContaining(affiliateStatAddr3ReferredByAddr2), + ])); + }); + + it('Returns undefined if affiliate stats not found by pair', async () => { + await AffiliateRefereeStatsTable.create(affiliateStatDefaultAddrReferredByAddr2); + + const ret = await AffiliateRefereeStatsTable.findById( + 'non_existent_referee_address', + ); + expect(ret).toBeUndefined(); + }); + + describe('updateStats', () => { + it('Successfully creates new affiliate stats', async () => { + // Get affiliate info (wallet2 is affiliate) + const referenceDt: DateTime = await populateFillsAndReferrals(); + + const oldStats + : AffiliateRefereeStatsFromDatabase | undefined = await AffiliateRefereeStatsTable.findById( + affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + ); + + // No stats yet in DB. + expect(oldStats).toBeUndefined(); + + // Perform update + await AffiliateRefereeStatsTable.updateStats( + // exclusive - only includes all fills > 2 minutes ago + referenceDt.minus({ minutes: 2 }).toISO(), + referenceDt.toISO(), + ); + + const updatedStats + : AffiliateRefereeStatsFromDatabase | undefined = await AffiliateRefereeStatsTable.findById( + affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + ); + + const expectedStats: AffiliateRefereeStatsFromDatabase = { + affiliateAddress: affiliateStatDefaultAddrReferredByAddr2.affiliateAddress, + refereeAddress: affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + affiliateEarnings: '600.6', + referredMakerTrades: 1, + referredTakerTrades: 1, + referredMakerFees: '0', + referredTakerFees: '888.8', + referredMakerRebates: '-22.2', + referredLiquidationFees: '0', + referralBlockHeight: '1', + referredTotalVolume: '2.2', + }; + + expect(updatedStats).toEqual(expectedStats); + }); + + it('Successfully updates/increments affiliate info for stats and new referrals', async () => { + const referenceDt: DateTime = await populateFillsAndReferrals(); + + // Perform update: covers first 4 fills during period (-3min, -2min]. + await AffiliateRefereeStatsTable.updateStats( + referenceDt.minus({ minutes: 3 }).toISO(), + referenceDt.minus({ minutes: 2 }).toISO(), + ); + + let updatedDefaultAddrStat + : AffiliateRefereeStatsFromDatabase | undefined = await AffiliateRefereeStatsTable.findById( + affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + ); + const expectedDefaultAddrStat1: AffiliateRefereeStatsFromDatabase = { + refereeAddress: affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + affiliateAddress: affiliateStatDefaultAddrReferredByAddr2.affiliateAddress, + affiliateEarnings: '500.5', + referredMakerTrades: 3, + referredTakerTrades: 1, + referredMakerFees: '203.2', + referredTakerFees: '0', + referredMakerRebates: '-33.3', + referredLiquidationFees: '1200.2', + referralBlockHeight: '1', + referredTotalVolume: '4.4', + }; + expect(updatedDefaultAddrStat).toEqual(expectedDefaultAddrStat1); + + // Perform update: covers next 2 fills during period (-2min, -1min]. + await AffiliateRefereeStatsTable.updateStats( + referenceDt.minus({ minutes: 2 }).toISO(), + referenceDt.minus({ minutes: 1 }).toISO(), + ); + + updatedDefaultAddrStat = await AffiliateRefereeStatsTable.findById( + affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + ); + const expectedDefaultAddrStat2: AffiliateRefereeStatsFromDatabase = { + refereeAddress: affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + affiliateAddress: affiliateStatDefaultAddrReferredByAddr2.affiliateAddress, + affiliateEarnings: '1101.1', // 600.6 + 500.5 + referredMakerTrades: 4, + referredTakerTrades: 2, + referredMakerFees: '203.2', + referredTakerFees: '888.8', + referredMakerRebates: '-55.5', // -33.3 - 22.2 + referredLiquidationFees: '1200.2', + referralBlockHeight: '1', + referredTotalVolume: '6.6', + }; + expect(updatedDefaultAddrStat).toEqual(expectedDefaultAddrStat2); + + // Perform update: catches no fills but new affiliate referral + await AffiliateReferredUsersTable.create({ + affiliateAddress: defaultAddress2, + refereeAddress: defaultAddress4, + referredAtBlock: '3', + }); + await AffiliateRefereeStatsTable.updateStats( + referenceDt.minus({ minutes: 1 }).toISO(), + referenceDt.toISO(), + ); + // TODO: update query to find by referee only? + const updatedDefaultAddr4Stat = await AffiliateRefereeStatsTable.findById( + defaultAddress4, + ); + const expectedDefaultAddr4Stat: AffiliateRefereeStatsFromDatabase = { + refereeAddress: defaultAddress4, + affiliateAddress: defaultAddress2, + affiliateEarnings: '0', + referredMakerTrades: 0, + referredTakerTrades: 0, + referredMakerFees: '0', + referredTakerFees: '0', + referredMakerRebates: '0', + referredLiquidationFees: '0', + referralBlockHeight: '3', + referredTotalVolume: '0', + }; + expect(updatedDefaultAddr4Stat).toEqual(expectedDefaultAddr4Stat); + }); + + it('Does not use fills from before referal block height', async () => { + const referenceDt: DateTime = DateTime.utc(); + + await seedData(); + await OrderTable.create(defaultOrder); + + // Referal at block 2 but fill is at block 1 + await AffiliateReferredUsersTable.create({ + affiliateAddress: affiliateStatDefaultAddrReferredByAddr2.affiliateAddress, + refereeAddress: affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + referredAtBlock: '2', + }); + await FillTable.create({ + ...defaultFill, + liquidity: Liquidity.TAKER, + subaccountId: defaultOrder.subaccountId, + createdAt: referenceDt.toISO(), + createdAtHeight: '1', + eventId: defaultTendermintEventId, + price: '1', + size: '1', + fee: '1000', + affiliateRevShare: '500', + }); + + await AffiliateRefereeStatsTable.updateStats( + referenceDt.minus({ minutes: 1 }).toISO(), + referenceDt.toISO(), + ); + + const updatedStats + : AffiliateRefereeStatsFromDatabase | undefined = await AffiliateRefereeStatsTable.findById( + affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + ); + // expect one referred user but no fill stats + const expectedRefereeStats: AffiliateRefereeStatsFromDatabase = { + refereeAddress: affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + affiliateAddress: affiliateStatDefaultAddrReferredByAddr2.affiliateAddress, + affiliateEarnings: '0', + referredMakerTrades: 0, + referredTakerTrades: 0, + referredLiquidationFees: '0', + referredMakerFees: '0', + referredTakerFees: '0', + referredMakerRebates: '0', + referralBlockHeight: '2', + referredTotalVolume: '0', + }; + expect(updatedStats).toEqual(expectedRefereeStats); + }); + }); + + // TODO(CT-1341): Add paginated query tests similar to `affiliate-info-table.test.ts` +}); + +async function populateFillsAndReferrals(): Promise { + const referenceDt = DateTime.utc(); + const referredAtBlock = 1; + + await seedData(); + + // default address 2 refers default address + await AffiliateReferredUsersTable.create({ + affiliateAddress: affiliateStatDefaultAddrReferredByAddr2.affiliateAddress, + refereeAddress: affiliateStatDefaultAddrReferredByAddr2.refereeAddress, + referredAtBlock: `${referredAtBlock}`, + }); + + await AffiliateReferredUsersTable.create({ + affiliateAddress: affiliateStatAddr3ReferredByAddr2.affiliateAddress, + refereeAddress: affiliateStatAddr3ReferredByAddr2.refereeAddress, + referredAtBlock: `${referredAtBlock}`, + }); + // Create order and fils for defaultWallet (referee) + await OrderTable.create(defaultOrder); + + await Promise.all([ + FillTable.create({ + ...defaultFill, + liquidity: Liquidity.TAKER, + subaccountId: defaultOrder.subaccountId, + createdAt: referenceDt.minus({ minutes: 1 }).toISO(), + createdAtHeight: `${referredAtBlock}`, + eventId: defaultTendermintEventId, + price: '1', + size: '1.1', + fee: '888.8', + affiliateRevShare: '600.6', + }), + FillTable.create({ + ...defaultFill, + liquidity: Liquidity.MAKER, + subaccountId: defaultOrder.subaccountId, + createdAt: referenceDt.minus({ minutes: 1 }).toISO(), + createdAtHeight: `${referredAtBlock}`, + eventId: defaultTendermintEventId2, + price: '1', + size: '1.1', + fee: '-22.2', + affiliateRevShare: '0', + }), + FillTable.create({ + ...defaultFill, + liquidity: Liquidity.MAKER, // use uneven number of maker/taker + subaccountId: defaultOrder.subaccountId, + createdAt: referenceDt.minus({ minutes: 2 }).toISO(), + createdAtHeight: `${referredAtBlock + 1}`, + eventId: defaultTendermintEventId3, + price: '1', + size: '1.1', + fee: '99.9', + affiliateRevShare: '0', + }), + FillTable.create({ + ...defaultFill, + liquidity: Liquidity.MAKER, + subaccountId: defaultOrder.subaccountId, + createdAt: referenceDt.minus({ minutes: 2 }).toISO(), + createdAtHeight: `${referredAtBlock + 1}`, + eventId: defaultTendermintEventId4, + price: '1', + size: '1.1', + fee: '103.3', + affiliateRevShare: '0', + }), + FillTable.create({ + ...defaultFill, + liquidity: Liquidity.TAKER, + subaccountId: defaultOrder.subaccountId, + createdAt: referenceDt.minus({ minutes: 2 }).toISO(), + createdAtHeight: `${referredAtBlock + 1}`, + eventId: defaultTendermintEventId4, + price: '1', + size: '1.1', + fee: '1200.2', + affiliateRevShare: '500.5', + type: FillType.LIQUIDATED, + }), + FillTable.create({ + ...defaultFill, + liquidity: Liquidity.MAKER, + subaccountId: defaultOrder.subaccountId, + createdAt: referenceDt.minus({ minutes: 2 }).toISO(), + createdAtHeight: `${referredAtBlock + 1}`, + eventId: defaultTendermintEventId, + price: '1', + size: '1.1', + fee: '-33.3', + affiliateRevShare: '0', + type: FillType.LIQUIDATION, + }), + ]); + + return referenceDt; +} diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20241126173317_affiliate_referee_stats.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20241126173317_affiliate_referee_stats.ts new file mode 100644 index 0000000000..a07b0f2257 --- /dev/null +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20241126173317_affiliate_referee_stats.ts @@ -0,0 +1,24 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('affiliate_referee_stats', (table) => { + table.string('refereeAddress').primary().notNullable(); + table.string('affiliateAddress').notNullable(); + table.decimal('affiliateEarnings', null).notNullable().defaultTo(0); + table.integer('referredMakerTrades').notNullable().defaultTo(0); + table.integer('referredTakerTrades').notNullable().defaultTo(0); + table.decimal('referredTotalVolume', null).notNullable().defaultTo(0); + table.bigInteger('referralBlockHeight').notNullable(); + table.decimal('referredTakerFees', null).notNullable().defaultTo(0); + table.decimal('referredMakerFees', null).notNullable().defaultTo(0); + table.decimal('referredMakerRebates', null).notNullable().defaultTo(0); + table.decimal('referredLiquidationFees', null).notNullable().defaultTo(0); + + // Indices + table.index(['affiliateAddress'], 'idx_affiliate_address'); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('affiliate_referee_stats'); +} diff --git a/indexer/packages/postgres/src/helpers/db-helpers.ts b/indexer/packages/postgres/src/helpers/db-helpers.ts index cba32483ba..efc62e7e0f 100644 --- a/indexer/packages/postgres/src/helpers/db-helpers.ts +++ b/indexer/packages/postgres/src/helpers/db-helpers.ts @@ -32,6 +32,7 @@ const layer1Tables = [ 'persistent_cache', 'affiliate_info', 'vaults', + 'affiliate_referee_stats', ]; /** diff --git a/indexer/packages/postgres/src/models/affiliate-referee-stats-model.ts b/indexer/packages/postgres/src/models/affiliate-referee-stats-model.ts new file mode 100644 index 0000000000..d12a4ce3d3 --- /dev/null +++ b/indexer/packages/postgres/src/models/affiliate-referee-stats-model.ts @@ -0,0 +1,91 @@ +import { NonNegativeNumericPattern, NumericPattern } from '../lib/validators'; +import UpsertQueryBuilder from '../query-builders/upsert'; +import BaseModel from './base-model'; + +export default class AffiliateRefereeStatsModel extends BaseModel { + static get tableName() { + return 'affiliate_referee_stats'; + } + + static get idColumn() { + return 'refereeAddress'; + } + + static get jsonSchema() { + return { + type: 'object', + required: [ + 'refereeAddress', + 'affiliateAddress', + 'affiliateEarnings', + 'referredMakerTrades', + 'referredTakerTrades', + 'referredTotalVolume', + 'referralBlockHeight', + 'referredTakerFees', + 'referredMakerFees', + 'referredMakerRebates', + 'referredLiquidationFees', + ], + properties: { + refereeAddress: { type: 'string' }, + affiliateAddress: { type: 'string' }, + affiliateEarnings: { type: 'string', pattern: NonNegativeNumericPattern }, + referredMakerTrades: { type: 'int' }, + referredTakerTrades: { type: 'int' }, + referredTotalVolume: { type: 'string', pattern: NonNegativeNumericPattern }, + referralBlockHeight: { type: 'string', pattern: NonNegativeNumericPattern }, + referredTakerFees: { type: 'string', pattern: NonNegativeNumericPattern }, + referredMakerFees: { type: 'string', pattern: NonNegativeNumericPattern }, + referredMakerRebates: { type: 'string', pattern: NumericPattern }, + referredLiquidationFees: { type: 'string', pattern: NonNegativeNumericPattern }, + }, + }; + } + + /** + * A mapping from column name to JSON conversion expected. + * See getSqlConversionForDydxModelTypes for valid conversions. + * + * TODO(IND-239): Ensure that jsonSchema() / sqlToJsonConversions() / model fields match. + */ + static get sqlToJsonConversions() { + return { + refereeAddress: 'string', + affiliateAddress: 'string', + affiliateEarnings: 'string', + referredMakerTrades: 'int', + referredTakerTrades: 'int', + referredTotalVolume: 'string', + referralBlockHeight: 'string', + referredTakerFees: 'string', + referredMakerFees: 'string', + referredMakerRebates: 'string', + referredLiquidationFees: 'string', + }; + } + + QueryBuilderType!: UpsertQueryBuilder; + + refereeAddress!: string; + + affiliateAddress!: string; + + affiliateEarnings!: string; + + referredMakerTrades!: number; + + referredTakerTrades!: number; + + referredTotalVolume!: string; + + referralBlockHeight!: string; + + referredMakerFees!: string; + + referredTakerFees!: string; + + referredMakerRebates!: string; + + referredLiquidationFees!: string; +} diff --git a/indexer/packages/postgres/src/stores/affiliate-referee-stats-table.ts b/indexer/packages/postgres/src/stores/affiliate-referee-stats-table.ts new file mode 100644 index 0000000000..a55b4c057d --- /dev/null +++ b/indexer/packages/postgres/src/stores/affiliate-referee-stats-table.ts @@ -0,0 +1,328 @@ +import Knex from 'knex'; +import { QueryBuilder } from 'objection'; + +import { DEFAULT_POSTGRES_OPTIONS } from '../constants'; +import { knexPrimary } from '../helpers/knex'; +import { setupBaseQuery, verifyAllRequiredFields } from '../helpers/stores-helpers'; +import Transaction from '../helpers/transaction'; +import AffiliateRefereeStatsModel from '../models/affiliate-referee-stats-model'; +import { + Options, + Ordering, + QueryableField, + QueryConfig, + AffiliateRefereeStatsColumns, + AffiliateRefereeStatsCreateObject, + AffiliateRefereeStatsFromDatabase, + Liquidity, + FillType, + AffiliateRefereeStatsQueryConfig, +} from '../types'; + +export async function findAll( + { + affiliateAddress, + limit, + }: AffiliateRefereeStatsQueryConfig, + requiredFields: QueryableField[], + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + verifyAllRequiredFields( + { + affiliateAddress, + limit, + } as QueryConfig, + requiredFields, + ); + + let baseQuery + : QueryBuilder = setupBaseQuery( + AffiliateRefereeStatsModel, + options, + ); + + if (affiliateAddress) { + baseQuery = baseQuery.where(AffiliateRefereeStatsColumns.affiliateAddress, affiliateAddress); + } + + if (options.orderBy !== undefined) { + for (const [column, order] of options.orderBy) { + baseQuery = baseQuery.orderBy( + column, + order, + ); + } + } else { + baseQuery = baseQuery.orderBy( + AffiliateRefereeStatsColumns.affiliateAddress, + Ordering.ASC, + ); + } + + if (limit) { + baseQuery = baseQuery.limit(limit); + } + + return baseQuery.returning('*'); +} + +export async function create( + AffiliateRefereeStatsToCreate: AffiliateRefereeStatsCreateObject, + options: Options = { txId: undefined }, +): Promise { + return AffiliateRefereeStatsModel.query( + Transaction.get(options.txId), + ).insert(AffiliateRefereeStatsToCreate).returning('*'); +} + +export async function upsert( + AffiliateRefereeStatsToUpsert: AffiliateRefereeStatsCreateObject, + options: Options = { txId: undefined }, +): Promise { + const AffiliateRefereeStats: + AffiliateRefereeStatsModel[] = await AffiliateRefereeStatsModel.query( + Transaction.get(options.txId), + ).upsert(AffiliateRefereeStatsToUpsert).returning('*'); + return AffiliateRefereeStats[0]; +} + +export async function findById( + refereeAddress: string, + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + const baseQuery: + QueryBuilder = setupBaseQuery( + AffiliateRefereeStatsModel, + options, + ); + return baseQuery + .findById(refereeAddress) + .returning('*'); +} + +/** + * Updates per-referee stats in the database based on the provided time window. + * + * This function aggregates per-referee affiliate stats and fill statistics + * from various tables. Then it upserts the aggregated data into the `affiliate_referee_stats` + * table. + * + * @async + * @function updateStats + * @param {string} windowStartTs - The exclusive start timestamp for filtering fills. + * @param {string} windowEndTs - The inclusive end timestamp for filtering fill. + * @param {number} [txId] - Optional transaction ID. + * @returns {Promise} + */ +export async function updateStats( + windowStartTs: string, // exclusive + windowEndTs: string, // inclusive + txId: number | undefined = undefined, +) : Promise { + const transaction: Knex.Transaction | undefined = Transaction.get(txId); + + const query = ` +-- Get metadata for all affiliates +-- Step 1: Get referal height for each affiliate-referee pair +WITH affiliate_metadata_per_referee AS ( + SELECT + affiliate_referred_users."affiliateAddress", + affiliate_referred_users."refereeAddress", +--- There should be only one referredAtBlock for each affiliate-referee pair + MIN("referredAtBlock") AS "referralBlockHeight" + FROM + affiliate_referred_users + GROUP BY + affiliate_referred_users."affiliateAddress", + affiliate_referred_users."refereeAddress" +), + +-- Calculate per-referee fill related stats for affiliates +-- Step 2a: Inner join affiliate_referred_users with subaccounts +affiliate_referred_subaccounts AS ( + SELECT + affiliate_referred_users."affiliateAddress", + affiliate_referred_users."refereeAddress", + affiliate_referred_users."referredAtBlock", + subaccounts."id" AS "subaccountId" + FROM + affiliate_referred_users + INNER JOIN + subaccounts + ON + affiliate_referred_users."refereeAddress" = subaccounts."address" +), + +-- Step 2b: Filter fills by the given time window +filtered_fills AS ( + SELECT + fills."subaccountId", + fills."liquidity", + fills."createdAt", + CAST(fills."fee" AS decimal) AS "fee", + fills."affiliateRevShare", + fills."createdAtHeight", + fills."price", + fills."size", + fills."type" + FROM + fills + WHERE + fills."createdAt" > '${windowStartTs}' + AND fills."createdAt" <= '${windowEndTs}' +), + +-- Step 2c: Inner join filtered_fills with affiliate_referred_subaccounts and filter +affiliate_fills AS ( + SELECT + filtered_fills."subaccountId", + filtered_fills."liquidity", + filtered_fills."createdAt", + filtered_fills."fee", + filtered_fills."affiliateRevShare", + filtered_fills."price", + filtered_fills."size", + filtered_fills."type", + affiliate_referred_subaccounts."affiliateAddress", + affiliate_referred_subaccounts."refereeAddress", + affiliate_referred_subaccounts."referredAtBlock" + FROM + filtered_fills + INNER JOIN + affiliate_referred_subaccounts + ON + filtered_fills."subaccountId" = affiliate_referred_subaccounts."subaccountId" + WHERE + filtered_fills."createdAtHeight" >= affiliate_referred_subaccounts."referredAtBlock" +), + +-- Step 2d: Aggregate stats per affiliate-referee tuple +affiliate_stats_per_referee AS ( + SELECT + affiliate_fills."affiliateAddress", + affiliate_fills."refereeAddress", + SUM(affiliate_fills."fee") AS "totalReferredFees", + SUM(affiliate_fills."affiliateRevShare") AS "affiliateEarnings", + SUM(CASE WHEN affiliate_fills."liquidity" = '${Liquidity.MAKER}' AND affiliate_fills."fee" > 0 THEN affiliate_fills."fee" ELSE 0 END) AS "referredMakerFees", + SUM(CASE WHEN affiliate_fills."liquidity" = '${Liquidity.TAKER}' AND affiliate_fills."type" = '${FillType.LIMIT}' THEN affiliate_fills."fee" ELSE 0 END) AS "referredTakerFees", + SUM(CASE WHEN affiliate_fills."liquidity" = '${Liquidity.MAKER}' AND affiliate_fills."fee" < 0 THEN affiliate_fills."fee" ELSE 0 END) AS "referredMakerRebates", + SUM(CASE WHEN affiliate_fills."liquidity" = '${Liquidity.TAKER}' AND affiliate_fills."type" = '${FillType.LIQUIDATED}' THEN affiliate_fills."fee" ELSE 0 END) AS "referredLiquidationFees", + COUNT(CASE WHEN affiliate_fills."liquidity" = '${Liquidity.MAKER}' THEN 1 END) AS "referredMakerTrades", + COUNT(CASE WHEN affiliate_fills."liquidity" = '${Liquidity.TAKER}' THEN 1 END) AS "referredTakerTrades", + SUM(affiliate_fills."price" * affiliate_fills."size") AS "referredTotalVolume" + FROM + affiliate_fills + GROUP BY + affiliate_fills."affiliateAddress", + affiliate_fills."refereeAddress" +), + +-- Step 3a: Prepare data for updating or inserting into the affiliate_referee_stats table +-- Combine metadata with aggregated stats for each affiliate-referee pair +affiliate_referee_stats_update AS ( + SELECT + affiliate_metadata_per_referee."affiliateAddress", + affiliate_metadata_per_referee."refereeAddress", + affiliate_metadata_per_referee."referralBlockHeight", + COALESCE(affiliate_stats_per_referee."affiliateEarnings", 0) AS "affiliateEarnings", + COALESCE(affiliate_stats_per_referee."referredMakerTrades", 0) AS "referredMakerTrades", + COALESCE(affiliate_stats_per_referee."referredTakerTrades", 0) AS "referredTakerTrades", + COALESCE(affiliate_stats_per_referee."referredMakerFees", 0) AS "referredMakerFees", + COALESCE(affiliate_stats_per_referee."referredTakerFees", 0) AS "referredTakerFees", + COALESCE(affiliate_stats_per_referee."referredLiquidationFees", 0) AS "referredLiquidationFees", + COALESCE(affiliate_stats_per_referee."referredMakerRebates", 0) AS "referredMakerRebates", + COALESCE(affiliate_stats_per_referee."referredTotalVolume", 0) AS "referredTotalVolume" + FROM + affiliate_metadata_per_referee + LEFT JOIN + affiliate_stats_per_referee + ON + affiliate_metadata_per_referee."affiliateAddress" = affiliate_stats_per_referee."affiliateAddress" + AND affiliate_metadata_per_referee."refereeAddress" = affiliate_stats_per_referee."refereeAddress" +) + +-- Step 3b: Insert or update the affiliate_referee_stats table +-- Update existing rows with new data or insert new rows if they don't exist +INSERT INTO affiliate_referee_stats ( + "affiliateAddress", + "refereeAddress", + "referralBlockHeight", + "affiliateEarnings", + "referredMakerTrades", + "referredTakerTrades", + "referredMakerFees", + "referredTakerFees", + "referredLiquidationFees", + "referredMakerRebates", + "referredTotalVolume" +) +SELECT + "affiliateAddress", + "refereeAddress", + "referralBlockHeight", + "affiliateEarnings", + "referredMakerTrades", + "referredTakerTrades", + "referredMakerFees", + "referredTakerFees", + "referredLiquidationFees", + "referredMakerRebates", + "referredTotalVolume" +FROM + affiliate_referee_stats_update +ON CONFLICT ("refereeAddress") +DO UPDATE SET + "referralBlockHeight" = EXCLUDED."referralBlockHeight", + "affiliateEarnings" = affiliate_referee_stats."affiliateEarnings" + EXCLUDED."affiliateEarnings", + "referredMakerTrades" = affiliate_referee_stats."referredMakerTrades" + EXCLUDED."referredMakerTrades", + "referredTakerTrades" = affiliate_referee_stats."referredTakerTrades" + EXCLUDED."referredTakerTrades", + "referredMakerFees" = affiliate_referee_stats."referredMakerFees" + EXCLUDED."referredMakerFees", + "referredTakerFees" = affiliate_referee_stats."referredTakerFees" + EXCLUDED."referredTakerFees", + "referredLiquidationFees" = affiliate_referee_stats."referredLiquidationFees" + EXCLUDED."referredLiquidationFees", + "referredMakerRebates" = affiliate_referee_stats."referredMakerRebates" + EXCLUDED."referredMakerRebates", + "referredTotalVolume" = affiliate_referee_stats."referredTotalVolume" + EXCLUDED."referredTotalVolume"; + `; + + return transaction + ? knexPrimary.raw(query).transacting(transaction) + : knexPrimary.raw(query); +} + +/** + * Given affiliate address, finds per-referee stats from the database with optional sorting, + * and offset based pagination. + * + * @async + * @function paginatedFindWithAffiliateAddress + * @param {string} affiliateAddress - the affiliate address to query for. + * @param {number} offset - The offset for pagination. + * @param {number} limit - The maximum number of records to return. + * @param {boolean} sortByPerRefereeEarning - Sort the results by per-referee-earnings. + * @param {Options} [options=DEFAULT_POSTGRES_OPTIONS] - Optional config for database interaction. + * @returns {Promise} + */ +export async function paginatedFindWithAffiliateAddress( + affiliateAddress: string, + offset: number, + limit: number, + sortByPerRefereeEarning: boolean, + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + let baseQuery: + QueryBuilder = setupBaseQuery( + AffiliateRefereeStatsModel, + options, + ); + + baseQuery = baseQuery.where(AffiliateRefereeStatsColumns.affiliateAddress, affiliateAddress); + + if (sortByPerRefereeEarning || offset !== 0) { + baseQuery = baseQuery.orderBy(AffiliateRefereeStatsColumns.affiliateEarnings, Ordering.DESC) + .orderBy(AffiliateRefereeStatsColumns.refereeAddress, Ordering.ASC); + } + + // Apply pagination using offset and limit + baseQuery = baseQuery.offset(offset).limit(limit); + + return baseQuery.returning('*'); +} diff --git a/indexer/packages/postgres/src/types/affiliate-referee-stats-types.ts b/indexer/packages/postgres/src/types/affiliate-referee-stats-types.ts new file mode 100644 index 0000000000..7126ca4b4e --- /dev/null +++ b/indexer/packages/postgres/src/types/affiliate-referee-stats-types.ts @@ -0,0 +1,27 @@ +export interface AffiliateRefereeStatsCreateObject { + affiliateAddress: string, + refereeAddress: string, + affiliateEarnings: string, + referredMakerTrades: number, + referredTakerTrades: number, + referredTotalVolume: string, + referralBlockHeight: string, + referredTakerFees: string, + referredMakerFees: string, + referredMakerRebates: string, + referredLiquidationFees: string, +} + +export enum AffiliateRefereeStatsColumns { + affiliateAddress = 'affiliateAddress', + refereeAddress = 'refereeAddress', + affiliateEarnings = 'affiliateEarnings', + referredMakerTrades = 'referredMakerTrades', + referredTakerTrades = 'referredTakerTrades', + referredTotalVolume = 'referredTotalVolume', + referralBlockHeight = 'referralBlockHeight', + referredTakerFees = 'referredTakerFees', + referredMakerFees = 'referredMakerFees', + referredMakerRebates = 'referredMakerRebates', + referredLiquidationFees = 'referredLiquidationFees', +} diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index 9557be8ede..18a3af5d1d 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -283,6 +283,20 @@ export interface PersistentCacheFromDatabase { value: string, } +export interface AffiliateRefereeStatsFromDatabase { + affiliateAddress: string, + refereeAddress: string, + affiliateEarnings: string, + referredMakerTrades: number, + referredTakerTrades: number, + referredTotalVolume: string, + referralBlockHeight: string, + referredTakerFees: string, + referredMakerFees: string, + referredMakerRebates: string, + referredLiquidationFees: string, +} + export interface AffiliateInfoFromDatabase { address: string, affiliateEarnings: string, diff --git a/indexer/packages/postgres/src/types/index.ts b/indexer/packages/postgres/src/types/index.ts index 16c2c1ed9f..047bac1464 100644 --- a/indexer/packages/postgres/src/types/index.ts +++ b/indexer/packages/postgres/src/types/index.ts @@ -31,6 +31,7 @@ export * from './leaderboard-pnl-types'; export * from './affiliate-referred-users-types'; export * from './persistent-cache-types'; export * from './affiliate-info-types'; +export * from './affiliate-referee-stats-types'; export * from './firebase-notification-token-types'; export * from './vault-types'; export { PositionSide } from './position-types'; diff --git a/indexer/packages/postgres/src/types/query-types.ts b/indexer/packages/postgres/src/types/query-types.ts index 3b5cd025e2..b8280e20d3 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -341,6 +341,10 @@ export interface AffiliateInfoQueryConfig extends QueryConfig { [QueryableField.ADDRESS]?: string, } +export interface AffiliateRefereeStatsQueryConfig extends QueryConfig { + [QueryableField.AFFILIATE_ADDRESS]?: string, +} + export interface FirebaseNotificationTokenQueryConfig extends QueryConfig { [QueryableField.ADDRESS]?: string, [QueryableField.TOKEN]?: string,