diff --git a/indexer/packages/postgres/__tests__/helpers/constants.ts b/indexer/packages/postgres/__tests__/helpers/constants.ts index 49df378318..9acd0fad96 100644 --- a/indexer/packages/postgres/__tests__/helpers/constants.ts +++ b/indexer/packages/postgres/__tests__/helpers/constants.ts @@ -43,6 +43,7 @@ import { SubaccountCreateObject, TendermintEventCreateObject, TimeInForce, + TradingRewardCreateObject, TransactionCreateObject, TransferCreateObject, WalletCreateObject, @@ -581,3 +582,12 @@ export const nonBlockedComplianceData: ComplianceDataCreateObject = { riskScore: '10.00', updatedAt: createdDateTime.plus(1).toISO(), }; + +// ========= Trading Reward Data ========== + +export const defaultTradingReward: TradingRewardCreateObject = { + address: defaultAddress, + blockHeight: createdHeight, + blockTime: createdDateTime.toISO(), + amount: '1.00', +}; diff --git a/indexer/packages/postgres/__tests__/stores/trading-rewards-table.test.ts b/indexer/packages/postgres/__tests__/stores/trading-rewards-table.test.ts new file mode 100644 index 0000000000..03504cbdd0 --- /dev/null +++ b/indexer/packages/postgres/__tests__/stores/trading-rewards-table.test.ts @@ -0,0 +1,60 @@ +import { TradingRewardFromDatabase } from '../../src/types'; +import { clearData, migrate, teardown } from '../../src/helpers/db-helpers'; +import { defaultTradingReward, defaultWallet } from '../helpers/constants'; +import * as TradingRewardTable from '../../src/stores/trading-reward-table'; +import { WalletTable } from '../../src'; + +describe('TradingReward store', () => { + beforeAll(async () => { + await migrate(); + }); + + beforeEach(async () => { + await WalletTable.create(defaultWallet); + }); + + afterEach(async () => { + await clearData(); + }); + + afterAll(async () => { + await teardown(); + }); + + it('Successfully creates a TradingReward', async () => { + await TradingRewardTable.create(defaultTradingReward); + }); + + it('Successfully finds all TradingRewards', async () => { + await Promise.all([ + TradingRewardTable.create(defaultTradingReward), + TradingRewardTable.create({ + ...defaultTradingReward, + blockHeight: '20', + }), + ]); + + const tradingRewards: TradingRewardFromDatabase[] = await TradingRewardTable.findAll( + {}, + [], + { readReplica: true }, + ); + + expect(tradingRewards.length).toEqual(2); + expect(tradingRewards[0]).toEqual(expect.objectContaining({ + ...defaultTradingReward, + blockHeight: '20', + })); + expect(tradingRewards[1]).toEqual(expect.objectContaining(defaultTradingReward)); + }); + + it('Successfully finds a TradingReward', async () => { + await TradingRewardTable.create(defaultTradingReward); + + const tradingReward: TradingRewardFromDatabase | undefined = await TradingRewardTable.findById( + TradingRewardTable.uuid(defaultTradingReward.address, defaultTradingReward.blockHeight), + ); + + expect(tradingReward).toEqual(expect.objectContaining(defaultTradingReward)); + }); +}); diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20231129153017_create_trading_rewards_table.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20231129153017_create_trading_rewards_table.ts new file mode 100644 index 0000000000..26aa229bc7 --- /dev/null +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20231129153017_create_trading_rewards_table.ts @@ -0,0 +1,25 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + return knex + .schema + .createTable('trading_rewards', (table) => { + table.uuid('id').primary(); + table.string('address').notNullable(); + table.timestamp('blockTime').notNullable(); + table.bigInteger('blockHeight').notNullable(); + table.decimal('amount').notNullable(); + + // Foreign + table.foreign('address').references('wallets.address'); + + // Indices + table.index(['address']); + table.index(['blockTime']); + table.index(['blockHeight']); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTableIfExists('trading_rewards'); +} diff --git a/indexer/packages/postgres/src/helpers/db-helpers.ts b/indexer/packages/postgres/src/helpers/db-helpers.ts index a70f4ada0b..43cafa4e67 100644 --- a/indexer/packages/postgres/src/helpers/db-helpers.ts +++ b/indexer/packages/postgres/src/helpers/db-helpers.ts @@ -23,6 +23,7 @@ const layer1Tables = [ 'liquidity_tiers', 'wallets', 'compliance_data', + 'trading_rewards', ]; /** diff --git a/indexer/packages/postgres/src/models/trading-reward-model.ts b/indexer/packages/postgres/src/models/trading-reward-model.ts new file mode 100644 index 0000000000..15a33a27a2 --- /dev/null +++ b/indexer/packages/postgres/src/models/trading-reward-model.ts @@ -0,0 +1,75 @@ +import path from 'path'; + +import { Model } from 'objection'; + +import { IntegerPattern, NonNegativeNumericPattern } from '../lib/validators'; +import UpsertQueryBuilder from '../query-builders/upsert'; + +export default class TradingRewardModel extends Model { + static get tableName() { + return 'trading_rewards'; + } + + static get idColumn() { + return 'id'; + } + + static relationMappings = { + wallet: { + relation: Model.BelongsToOneRelation, + modelClass: path.join(__dirname, 'wallet-model'), + join: { + from: 'trading_rewards.address', + to: 'wallets.address', + }, + }, + }; + + static get jsonSchema() { + return { + type: 'object', + required: [ + 'id', + 'address', + 'blockTime', + 'blockHeight', + 'amount', + ], + properties: { + id: { type: 'string', format: 'uuid' }, + address: { type: 'string' }, + blockTime: { type: 'string', format: 'date-time' }, + blockHeight: { type: 'string', pattern: IntegerPattern }, + 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', + blockTime: 'date-time', + blockHeight: 'string', + amount: 'string', + }; + } + + QueryBuilderType!: UpsertQueryBuilder; + + id!: string; + + address!: string; + + blockTime!: string; + + blockHeight!: string; + + amount!: string; +} diff --git a/indexer/packages/postgres/src/models/wallet-model.ts b/indexer/packages/postgres/src/models/wallet-model.ts index e4661abbcb..09561b0f38 100644 --- a/indexer/packages/postgres/src/models/wallet-model.ts +++ b/indexer/packages/postgres/src/models/wallet-model.ts @@ -1,4 +1,4 @@ -import { NumericPattern } from '../lib/validators'; +import { NonNegativeNumericPattern } from '../lib/validators'; import UpsertQueryBuilder from '../query-builders/upsert'; import BaseModel from './base-model'; @@ -22,7 +22,7 @@ export default class WalletModel extends BaseModel { ], properties: { address: { type: 'string' }, - totalTradingRewards: { type: 'string', pattern: NumericPattern }, + totalTradingRewards: { type: 'string', pattern: NonNegativeNumericPattern }, }, }; } diff --git a/indexer/packages/postgres/src/stores/trading-reward-table.ts b/indexer/packages/postgres/src/stores/trading-reward-table.ts new file mode 100644 index 0000000000..50aedbfffb --- /dev/null +++ b/indexer/packages/postgres/src/stores/trading-reward-table.ts @@ -0,0 +1,108 @@ +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 TradingRewardModel from '../models/trading-reward-model'; +import { + Options, + Ordering, + QueryableField, + QueryConfig, + TradingRewardColumns, + TradingRewardCreateObject, + TradingRewardFromDatabase, + TradingRewardQueryConfig, +} from '../types'; + +export function uuid(address: string, blockHeight: string): string { + // TODO(IND-483): Fix all uuid string substitutions to use Array.join. + return getUuid(Buffer.from(`${address}-${blockHeight}`, BUFFER_ENCODING_UTF_8)); +} + +export async function findAll( + { + address, + blockHeight, + blockTimeBeforeOrAt, + limit, + }: TradingRewardQueryConfig, + requiredFields: QueryableField[], + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + verifyAllRequiredFields( + { + address, + blockHeight, + blockTimeBeforeOrAt, + limit, + } as QueryConfig, + requiredFields, + ); + + let baseQuery: QueryBuilder = setupBaseQuery( + TradingRewardModel, + options, + ); + + if (address) { + baseQuery = baseQuery.where(TradingRewardColumns.address, address); + } + + if (blockHeight) { + baseQuery = baseQuery.where(TradingRewardColumns.blockHeight, blockHeight); + } + + if (blockTimeBeforeOrAt) { + baseQuery = baseQuery.where(TradingRewardColumns.blockTime, '<=', blockTimeBeforeOrAt); + } + + if (options.orderBy !== undefined) { + for (const [column, order] of options.orderBy) { + baseQuery = baseQuery.orderBy( + column, + order, + ); + } + } else { + baseQuery = baseQuery.orderBy( + TradingRewardColumns.blockHeight, + Ordering.DESC, + ).orderBy( + TradingRewardColumns.address, + Ordering.DESC, + ); + } + + if (limit) { + baseQuery = baseQuery.limit(limit); + } + + return baseQuery.returning('*'); +} + +export async function create( + tradingRewardToCreate: TradingRewardCreateObject, + options: Options = { txId: undefined }, +): Promise { + return TradingRewardModel.query( + Transaction.get(options.txId), + ).insert({ + id: uuid(tradingRewardToCreate.address, tradingRewardToCreate.blockHeight), + ...tradingRewardToCreate, + }).returning('*'); +} + +export async function findById( + address: string, + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + const baseQuery: QueryBuilder = setupBaseQuery( + TradingRewardModel, + options, + ); + return baseQuery + .findById(address) + .returning('*'); +} diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index fff59d251c..d7b0fea6d6 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -223,6 +223,14 @@ export interface ComplianceDataFromDatabase { updatedAt: string; } +export interface TradingRewardFromDatabase { + id: string; + address: string; + blockTime: IsoString; + blockHeight: string; + 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 18452d6304..f6c3646921 100644 --- a/indexer/packages/postgres/src/types/index.ts +++ b/indexer/packages/postgres/src/types/index.ts @@ -22,4 +22,5 @@ export * from './funding-index-updates-types'; export * from './liquidity-tiers-types'; export * from './wallet-types'; export * from './compliance-data-types'; +export * from './trading-reward-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 10b61c77ca..423229203c 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -71,6 +71,7 @@ export enum QueryableField { UPDATED_ON_OR_AFTER = 'updatedOnOrAfter', PROVIDER = 'provider', BLOCKED = 'blocked', + BLOCK_TIME_BEFORE_OR_AT = 'blockTimeBeforeOrAt', } export interface QueryConfig { @@ -263,3 +264,9 @@ export interface ComplianceDataQueryConfig extends QueryConfig { [QueryableField.PROVIDER]?: string; [QueryableField.BLOCKED]?: boolean; } + +export interface TradingRewardQueryConfig extends QueryConfig { + [QueryableField.ADDRESS]?: string; + [QueryableField.BLOCK_HEIGHT]?: string; + [QueryableField.BLOCK_TIME_BEFORE_OR_AT]?: IsoString; +} diff --git a/indexer/packages/postgres/src/types/trading-reward-types.ts b/indexer/packages/postgres/src/types/trading-reward-types.ts new file mode 100644 index 0000000000..da1dc81676 --- /dev/null +++ b/indexer/packages/postgres/src/types/trading-reward-types.ts @@ -0,0 +1,16 @@ +import { IsoString } from './utility-types'; + +export interface TradingRewardCreateObject { + address: string; + blockTime: IsoString; + blockHeight: string; + amount: string; +} + +export enum TradingRewardColumns { + id = 'id', + address = 'address', + blockTime = 'blockTime', + blockHeight = 'blockHeight', + amount = 'amount', +}