From b5d4e8a7c5cc48c460731b21c47f22eabef8b2b7 Mon Sep 17 00:00:00 2001 From: Christopher-Li Date: Thu, 30 Nov 2023 17:09:43 -0500 Subject: [PATCH] [IND-496]: Create trading_reward_aggregations postgres table (#825) * [IND-496]: Create trading_reward_aggregations postgres table * rabbit recommendations * nit --- .../postgres/__tests__/helpers/constants.ts | 18 +++ .../trading-reward-aggregation-table.test.ts | 103 +++++++++++++ ...e.test.ts => trading-reward-table.test.ts} | 0 ...reate_trading_reward_aggregations_table.ts | 35 +++++ .../postgres/src/helpers/db-helpers.ts | 1 + indexer/packages/postgres/src/index.ts | 2 + .../postgres/src/models/block-model.ts | 8 + .../trading-reward-aggregation-model.ts | 98 +++++++++++++ .../src/models/trading-reward-model.ts | 3 +- .../postgres/src/models/wallet-model.ts | 23 ++- .../trading-reward-aggregation-table.ts | 138 ++++++++++++++++++ .../postgres/src/types/db-model-types.ts | 12 ++ indexer/packages/postgres/src/types/index.ts | 1 + .../postgres/src/types/query-types.ts | 9 ++ .../types/trading-reward-aggregation-types.ts | 35 +++++ 15 files changed, 484 insertions(+), 2 deletions(-) create mode 100644 indexer/packages/postgres/__tests__/stores/trading-reward-aggregation-table.test.ts rename indexer/packages/postgres/__tests__/stores/{trading-rewards-table.test.ts => trading-reward-table.test.ts} (100%) create mode 100644 indexer/packages/postgres/src/db/migrations/migration_files/20231130153800_create_trading_reward_aggregations_table.ts create mode 100644 indexer/packages/postgres/src/models/trading-reward-aggregation-model.ts create mode 100644 indexer/packages/postgres/src/stores/trading-reward-aggregation-table.ts create mode 100644 indexer/packages/postgres/src/types/trading-reward-aggregation-types.ts diff --git a/indexer/packages/postgres/__tests__/helpers/constants.ts b/indexer/packages/postgres/__tests__/helpers/constants.ts index 9acd0fad96..e3f83a98b2 100644 --- a/indexer/packages/postgres/__tests__/helpers/constants.ts +++ b/indexer/packages/postgres/__tests__/helpers/constants.ts @@ -13,6 +13,7 @@ import * as OrderTable from '../../src/stores/order-table'; import * as PerpetualPositionTable from '../../src/stores/perpetual-position-table'; import * as SubaccountTable from '../../src/stores/subaccount-table'; import * as TendermintEventTable from '../../src/stores/tendermint-event-table'; +import * as TradingRewardAggregationTable from '../../src/stores/trading-reward-aggregation-table'; import * as TransactionTable from '../../src/stores/transaction-table'; import * as TransferTable from '../../src/stores/transfer-table'; import { @@ -43,6 +44,8 @@ import { SubaccountCreateObject, TendermintEventCreateObject, TimeInForce, + TradingRewardAggregationCreateObject, + TradingRewardAggregationPeriod, TradingRewardCreateObject, TransactionCreateObject, TransferCreateObject, @@ -591,3 +594,18 @@ export const defaultTradingReward: TradingRewardCreateObject = { blockTime: createdDateTime.toISO(), amount: '1.00', }; + +// ========= Trading Reward Aggregation Data ========== + +export const defaultTradingRewardAggregation: TradingRewardAggregationCreateObject = { + address: defaultAddress, + startedAtHeight: createdHeight, + startedAt: createdDateTime.toISO(), + period: TradingRewardAggregationPeriod.DAILY, + amount: '1.00', +}; +export const defaultTradingRewardAggregationId: string = TradingRewardAggregationTable.uuid( + defaultTradingRewardAggregation.address, + defaultTradingRewardAggregation.period, + defaultTradingRewardAggregation.startedAtHeight, +); diff --git a/indexer/packages/postgres/__tests__/stores/trading-reward-aggregation-table.test.ts b/indexer/packages/postgres/__tests__/stores/trading-reward-aggregation-table.test.ts new file mode 100644 index 0000000000..a6ffa90a92 --- /dev/null +++ b/indexer/packages/postgres/__tests__/stores/trading-reward-aggregation-table.test.ts @@ -0,0 +1,103 @@ +import { TradingRewardAggregationFromDatabase } from '../../src/types'; +import { clearData, migrate, teardown } from '../../src/helpers/db-helpers'; +import { + defaultSubaccountId, + defaultTradingRewardAggregation, + defaultTradingRewardAggregationId, + defaultWallet, +} from '../helpers/constants'; +import * as TradingRewardAggregationTable from '../../src/stores/trading-reward-aggregation-table'; +import { WalletTable } from '../../src'; +import { seedData } from '../helpers/mock-generators'; + +describe('TradingRewardAggregation store', () => { + beforeAll(async () => { + await migrate(); + }); + + beforeEach(async () => { + await seedData(); + await WalletTable.create(defaultWallet); + }); + + afterEach(async () => { + await clearData(); + }); + + afterAll(async () => { + await teardown(); + }); + + it('Successfully creates a TradingRewardAggregation', async () => { + await TradingRewardAggregationTable.create(defaultTradingRewardAggregation); + }); + + it('Successfully finds all TradingRewardAggregations', async () => { + await Promise.all([ + TradingRewardAggregationTable.create(defaultTradingRewardAggregation), + TradingRewardAggregationTable.create({ + ...defaultTradingRewardAggregation, + startedAtHeight: '1', + }), + ]); + + const tradingRewardAggregations: + TradingRewardAggregationFromDatabase[] = await TradingRewardAggregationTable.findAll( + {}, + [], + { readReplica: true }, + ); + + expect(tradingRewardAggregations.length).toEqual(2); + expect(tradingRewardAggregations[0]).toEqual(expect.objectContaining({ + ...defaultTradingRewardAggregation, + startedAtHeight: '1', + })); + expect(tradingRewardAggregations[1]).toEqual( + expect.objectContaining(defaultTradingRewardAggregation), + ); + }); + + it('Successfully finds a TradingRewardAggregation', async () => { + await TradingRewardAggregationTable.create(defaultTradingRewardAggregation); + + const tradingRewardAggregation: + TradingRewardAggregationFromDatabase | undefined = await TradingRewardAggregationTable.findById( + defaultTradingRewardAggregationId, + ); + + expect(tradingRewardAggregation).toEqual( + expect.objectContaining(defaultTradingRewardAggregation), + ); + }); + + it('Successfully returns undefined when updating a nonexistent TradingRewardAggregation', async () => { + const fakeUpdate: + TradingRewardAggregationFromDatabase | undefined = await TradingRewardAggregationTable.update({ + id: defaultSubaccountId, + }); + expect(fakeUpdate).toBeUndefined(); + }); + + it('Successfully updates an existing TradingRewardAggregation', async () => { + await TradingRewardAggregationTable.create(defaultTradingRewardAggregation); + + const amount: string = '100000.00'; + const endedAt: string = '2021-01-01T00:00:00.000Z'; + const endedAtHeight: string = '1000'; + const update: + TradingRewardAggregationFromDatabase | undefined = await TradingRewardAggregationTable.update({ + id: defaultTradingRewardAggregationId, + endedAt, + endedAtHeight, + amount, + }); + expect(update).toEqual({ + ...defaultTradingRewardAggregation, + id: defaultTradingRewardAggregationId, + endedAt, + endedAtHeight, + amount, + }); + }); +}); diff --git a/indexer/packages/postgres/__tests__/stores/trading-rewards-table.test.ts b/indexer/packages/postgres/__tests__/stores/trading-reward-table.test.ts similarity index 100% rename from indexer/packages/postgres/__tests__/stores/trading-rewards-table.test.ts rename to indexer/packages/postgres/__tests__/stores/trading-reward-table.test.ts diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20231130153800_create_trading_reward_aggregations_table.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20231130153800_create_trading_reward_aggregations_table.ts new file mode 100644 index 0000000000..4826e4704a --- /dev/null +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20231130153800_create_trading_reward_aggregations_table.ts @@ -0,0 +1,35 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + return knex + .schema + .createTable('trading_reward_aggregations', (table) => { + table.uuid('id').primary(); + table.string('address').notNullable(); + table.timestamp('startedAt').notNullable(); + table.bigInteger('startedAtHeight').notNullable(); + table.timestamp('endedAt').nullable(); + table.bigInteger('endedAtHeight').nullable(); + table.enum( + 'period', + [ + 'DAILY', + 'WEEKLY', + 'MONTHLY', + ], + ).notNullable(); + table.decimal('amount').notNullable(); + + // Foreign + table.foreign('address').references('wallets.address'); + table.foreign('startedAtHeight').references('blocks.blockHeight'); + + // Indices + table.index(['address', 'startedAtHeight']); + table.index(['period', 'startedAtHeight']); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTableIfExists('trading_reward_aggregations'); +} diff --git a/indexer/packages/postgres/src/helpers/db-helpers.ts b/indexer/packages/postgres/src/helpers/db-helpers.ts index 43cafa4e67..c360b5e48e 100644 --- a/indexer/packages/postgres/src/helpers/db-helpers.ts +++ b/indexer/packages/postgres/src/helpers/db-helpers.ts @@ -24,6 +24,7 @@ const layer1Tables = [ 'wallets', 'compliance_data', 'trading_rewards', + 'trading_reward_aggregations', ]; /** diff --git a/indexer/packages/postgres/src/index.ts b/indexer/packages/postgres/src/index.ts index ed38fd30ae..19bc914f04 100644 --- a/indexer/packages/postgres/src/index.ts +++ b/indexer/packages/postgres/src/index.ts @@ -33,6 +33,8 @@ export * as FundingIndexUpdatesTable from './stores/funding-index-updates-table' export * as LiquidityTiersTable from './stores/liquidity-tiers-table'; export * as WalletTable from './stores/wallet-table'; export * as ComplianceTable from './stores/compliance-table'; +export * as TradingRewardTable from './stores/trading-reward-table'; +export * as TradingRewardAggregationTable from './stores/trading-reward-aggregation-table'; export * as perpetualMarketRefresher from './loops/perpetual-market-refresher'; export * as assetRefresher from './loops/asset-refresher'; diff --git a/indexer/packages/postgres/src/models/block-model.ts b/indexer/packages/postgres/src/models/block-model.ts index 4b5ea237c5..f796f25063 100644 --- a/indexer/packages/postgres/src/models/block-model.ts +++ b/indexer/packages/postgres/src/models/block-model.ts @@ -47,6 +47,14 @@ export default class BlockModel extends Model { to: 'oracle_prices.effectiveAtHeight', }, }, + tradingRewardAggregations: { + relation: Model.HasManyRelation, + modelClass: path.join(__dirname, 'trading-reward-aggregation-model'), + join: { + from: 'blocks.blockHeight', + to: 'trading_reward_aggregations.startedAtHeight', + }, + }, }; static get jsonSchema() { diff --git a/indexer/packages/postgres/src/models/trading-reward-aggregation-model.ts b/indexer/packages/postgres/src/models/trading-reward-aggregation-model.ts new file mode 100644 index 0000000000..fcca5ce6c8 --- /dev/null +++ b/indexer/packages/postgres/src/models/trading-reward-aggregation-model.ts @@ -0,0 +1,98 @@ +import path from 'path'; + +import { Model } from 'objection'; + +import { IntegerPattern, NonNegativeNumericPattern } from '../lib/validators'; +import UpsertQueryBuilder from '../query-builders/upsert'; +import { IsoString } from '../types'; +import { TradingRewardAggregationPeriod } from '../types/trading-reward-aggregation-types'; + +export default class TradingRewardAggregationModel extends Model { + static get tableName() { + return 'trading_reward_aggregations'; + } + + static get idColumn() { + return 'id'; + } + + static relationMappings = { + wallets: { + relation: Model.BelongsToOneRelation, + modelClass: path.join(__dirname, 'wallet-model'), + join: { + from: 'trading_reward_aggregations.address', + to: 'wallets.address', + }, + }, + blocks: { + relation: Model.BelongsToOneRelation, + modelClass: path.join(__dirname, 'block-model'), + join: { + from: 'trading_reward_aggregations.startedAtHeight', + to: 'blocks.height', + }, + }, + }; + + static get jsonSchema() { + return { + type: 'object', + required: [ + 'id', // Generated from `address` and `startedAt` and `period` + 'address', + 'startedAt', + 'startedAtHeight', + 'period', + 'amount', // amount of token rewards earned by address in the period starting with startedAt + ], + properties: { + id: { type: 'string', format: 'uuid' }, + address: { type: 'string' }, + startedAt: { type: 'string', format: 'date-time' }, // Inclusive + startedAtHeight: { type: 'string', pattern: IntegerPattern }, // Inclusive + endedAt: { type: ['string', 'null'], format: 'date-time' }, // Inclusive + endedAtHeight: { type: ['string', 'null'], pattern: IntegerPattern }, // Inclusive + period: { type: 'string', enum: [...Object.values(TradingRewardAggregationPeriod)] }, + amount: { 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 { + id: 'string', + address: 'string', + startedAt: 'date-time', + startedAtHeight: 'string', + endedAt: 'date-time', + endedAtHeight: 'string', + period: 'string', + amount: 'string', + }; + } + + QueryBuilderType!: UpsertQueryBuilder; + + id!: string; + + address!: string; + + startedAt!: IsoString; + + startedAtHeight!: string; + + endedAt!: IsoString; + + endedAtHeight!: string; + + period!: TradingRewardAggregationPeriod; + + amount!: string; +} diff --git a/indexer/packages/postgres/src/models/trading-reward-model.ts b/indexer/packages/postgres/src/models/trading-reward-model.ts index 15a33a27a2..cb46597cce 100644 --- a/indexer/packages/postgres/src/models/trading-reward-model.ts +++ b/indexer/packages/postgres/src/models/trading-reward-model.ts @@ -4,6 +4,7 @@ import { Model } from 'objection'; import { IntegerPattern, NonNegativeNumericPattern } from '../lib/validators'; import UpsertQueryBuilder from '../query-builders/upsert'; +import { IsoString } from '../types'; export default class TradingRewardModel extends Model { static get tableName() { @@ -67,7 +68,7 @@ export default class TradingRewardModel extends Model { address!: string; - blockTime!: string; + blockTime!: IsoString; blockHeight!: string; diff --git a/indexer/packages/postgres/src/models/wallet-model.ts b/indexer/packages/postgres/src/models/wallet-model.ts index 09561b0f38..a588a2d275 100644 --- a/indexer/packages/postgres/src/models/wallet-model.ts +++ b/indexer/packages/postgres/src/models/wallet-model.ts @@ -1,3 +1,7 @@ +import path from 'path'; + +import { Model } from 'objection'; + import { NonNegativeNumericPattern } from '../lib/validators'; import UpsertQueryBuilder from '../query-builders/upsert'; import BaseModel from './base-model'; @@ -11,7 +15,24 @@ export default class WalletModel extends BaseModel { return 'address'; } - static relationMappings = {}; + static relationMappings = { + tradingRewardAggregations: { + relation: Model.HasManyRelation, + modelClass: path.join(__dirname, 'trading-reward-aggregation-model'), + join: { + from: 'wallets.address', + to: 'trading_reward_aggregations.address', + }, + }, + tradingRewards: { + relation: Model.HasManyRelation, + modelClass: path.join(__dirname, 'trading-reward-model'), + join: { + from: 'wallets.address', + to: 'trading_rewards.address', + }, + }, + }; static get jsonSchema() { return { diff --git a/indexer/packages/postgres/src/stores/trading-reward-aggregation-table.ts b/indexer/packages/postgres/src/stores/trading-reward-aggregation-table.ts new file mode 100644 index 0000000000..fd8de8d2ba --- /dev/null +++ b/indexer/packages/postgres/src/stores/trading-reward-aggregation-table.ts @@ -0,0 +1,138 @@ +import { QueryBuilder } from 'objection'; + +import { BUFFER_ENCODING_UTF_8, DEFAULT_POSTGRES_OPTIONS } from '../constants'; +import { setupBaseQuery, verifyAllRequiredFields } from '../helpers/stores-helpers'; +import Transaction from '../helpers/transaction'; +import { getUuid } from '../helpers/uuid'; +import TradingRewardAggregationModel from '../models/trading-reward-aggregation-model'; +import { + Options, + Ordering, + QueryableField, + QueryConfig, + TradingRewardAggregationColumns, + TradingRewardAggregationCreateObject, + TradingRewardAggregationFromDatabase, + TradingRewardAggregationPeriod, + TradingRewardAggregationQueryConfig, + TradingRewardAggregationUpdateObject, +} from '../types'; + +export function uuid( + address: string, + period: TradingRewardAggregationPeriod, + startedAtHeight: string, +): string { + // TODO(IND-483): Fix all uuid string substitutions to use Array.join. + return getUuid(Buffer.from(`${address}-${period}-${startedAtHeight}`, BUFFER_ENCODING_UTF_8)); +} + +export async function findAll( + { + address, + startedAtHeight, + period, + limit, + }: TradingRewardAggregationQueryConfig, + requiredFields: QueryableField[], + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + verifyAllRequiredFields( + { + address, + startedAtHeight, + period, + limit, + } as QueryConfig, + requiredFields, + ); + + let baseQuery: + QueryBuilder = setupBaseQuery( + TradingRewardAggregationModel, + options, + ); + + if (address) { + baseQuery = baseQuery.where(TradingRewardAggregationColumns.address, address); + } + + if (startedAtHeight) { + baseQuery = baseQuery.where(TradingRewardAggregationColumns.startedAtHeight, startedAtHeight); + } + + if (period) { + baseQuery = baseQuery.where(TradingRewardAggregationColumns.period, period); + } + + if (options.orderBy !== undefined) { + for (const [column, order] of options.orderBy) { + baseQuery = baseQuery.orderBy( + column, + order, + ); + } + } else { + baseQuery = baseQuery.orderBy( + TradingRewardAggregationColumns.period, + Ordering.ASC, + ).orderBy( + TradingRewardAggregationColumns.startedAtHeight, + Ordering.ASC, + ).orderBy( + TradingRewardAggregationColumns.address, + Ordering.ASC, + ); + } + + if (limit) { + baseQuery = baseQuery.limit(limit); + } + + return baseQuery.returning('*'); +} + +export async function create( + aggregationToCreate: TradingRewardAggregationCreateObject, + options: Options = { txId: undefined }, +): Promise { + return TradingRewardAggregationModel.query( + Transaction.get(options.txId), + ).insert({ + id: uuid( + aggregationToCreate.address, + aggregationToCreate.period, + aggregationToCreate.startedAtHeight, + ), + ...aggregationToCreate, + }).returning('*'); +} + +export async function findById( + address: string, + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + const baseQuery: + QueryBuilder = setupBaseQuery( + TradingRewardAggregationModel, + options, + ); + return baseQuery + .findById(address) + .returning('*'); +} + +export async function update( + { + ...fields + }: TradingRewardAggregationUpdateObject, + options: Options = { txId: undefined }, +): Promise { + const aggregation = await TradingRewardAggregationModel.query( + Transaction.get(options.txId), + // TODO fix expression typing so we dont have to use any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ).findById(fields.id).patch(fields as any).returning('*'); + // The objection types mistakenly think the query returns an array of orders. + return aggregation as unknown as (TradingRewardAggregationFromDatabase | undefined); +} diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index d7b0fea6d6..3e1c52d8eb 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -9,6 +9,7 @@ import { import { PerpetualMarketStatus } from './perpetual-market-types'; import { PerpetualPositionStatus } from './perpetual-position-types'; import { PositionSide } from './position-types'; +import { TradingRewardAggregationPeriod } from './trading-reward-aggregation-types'; type IsoString = string; @@ -231,6 +232,17 @@ export interface TradingRewardFromDatabase { amount: string; } +export interface TradingRewardAggregationFromDatabase { + id: string; + address: string; + startedAt: IsoString; + startedAtHeight: string; + endedAt?: IsoString; + endedAtHeight?: string; + period: TradingRewardAggregationPeriod; + amount: string; +} + export type SubaccountAssetNetTransferMap = { [subaccountId: string]: { [assetId: string]: string } }; export type SubaccountToPerpetualPositionsMap = { [subaccountId: string]: diff --git a/indexer/packages/postgres/src/types/index.ts b/indexer/packages/postgres/src/types/index.ts index f6c3646921..8a6c3f7ea6 100644 --- a/indexer/packages/postgres/src/types/index.ts +++ b/indexer/packages/postgres/src/types/index.ts @@ -23,4 +23,5 @@ export * from './liquidity-tiers-types'; export * from './wallet-types'; export * from './compliance-data-types'; export * from './trading-reward-types'; +export * from './trading-reward-aggregation-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 423229203c..eb9a2427bc 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -5,6 +5,7 @@ import { Liquidity } from './fill-types'; import { OrderSide, OrderStatus, OrderType } from './order-types'; import { PerpetualPositionStatus } from './perpetual-position-types'; import { PositionSide } from './position-types'; +import { TradingRewardAggregationPeriod } from './trading-reward-aggregation-types'; import { IsoString } from './utility-types'; export enum QueryableField { @@ -72,6 +73,8 @@ export enum QueryableField { PROVIDER = 'provider', BLOCKED = 'blocked', BLOCK_TIME_BEFORE_OR_AT = 'blockTimeBeforeOrAt', + STARTED_AT_HEIGHT = 'startedAtHeight', + PERIOD = 'period', } export interface QueryConfig { @@ -270,3 +273,9 @@ export interface TradingRewardQueryConfig extends QueryConfig { [QueryableField.BLOCK_HEIGHT]?: string; [QueryableField.BLOCK_TIME_BEFORE_OR_AT]?: IsoString; } + +export interface TradingRewardAggregationQueryConfig extends QueryConfig { + [QueryableField.ADDRESS]?: string; + [QueryableField.STARTED_AT_HEIGHT]?: string; + [QueryableField.PERIOD]?: TradingRewardAggregationPeriod; +} diff --git a/indexer/packages/postgres/src/types/trading-reward-aggregation-types.ts b/indexer/packages/postgres/src/types/trading-reward-aggregation-types.ts new file mode 100644 index 0000000000..c0244bed98 --- /dev/null +++ b/indexer/packages/postgres/src/types/trading-reward-aggregation-types.ts @@ -0,0 +1,35 @@ +import { IsoString } from './utility-types'; + +export enum TradingRewardAggregationPeriod { + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', +} + +export interface TradingRewardAggregationCreateObject { + address: string; + startedAt: IsoString; + startedAtHeight: string; + endedAt?: IsoString; + endedAtHeight?: string; + period: TradingRewardAggregationPeriod; + amount: string; +} + +export interface TradingRewardAggregationUpdateObject { + id: string; + endedAt?: IsoString; + endedAtHeight?: string; + amount?: string; +} + +export enum TradingRewardAggregationColumns { + id = 'id', + address = 'address', + startedAt = 'startedAt', + startedAtHeight = 'startedAtHeight', + endedAt = 'endedAt', + endedAtHeight = 'endedAtHeight', + period = 'period', + amount = 'amount', +}