From 66073c188dea76735f868abe6e747fb63712ed37 Mon Sep 17 00:00:00 2001
From: Christopher-Li <Christopher-Li@users.noreply.github.com>
Date: Thu, 30 Nov 2023 14:34:05 -0500
Subject: [PATCH] [IND-494]: Create trading_rewards postgres table (#822)

* [IND-494]: Create trading_rewards postgres table

* nit

* fix tests

* fix tests and linter

* fix tests

* fix foreign key

* lint
---
 .../postgres/__tests__/helpers/constants.ts   |  10 ++
 .../stores/trading-rewards-table.test.ts      |  60 ++++++++++
 ...1129153017_create_trading_rewards_table.ts |  25 ++++
 .../postgres/src/helpers/db-helpers.ts        |   1 +
 .../src/models/trading-reward-model.ts        |  75 ++++++++++++
 .../postgres/src/models/wallet-model.ts       |   4 +-
 .../src/stores/trading-reward-table.ts        | 108 ++++++++++++++++++
 .../postgres/src/types/db-model-types.ts      |   8 ++
 indexer/packages/postgres/src/types/index.ts  |   1 +
 .../postgres/src/types/query-types.ts         |   7 ++
 .../src/types/trading-reward-types.ts         |  16 +++
 11 files changed, 313 insertions(+), 2 deletions(-)
 create mode 100644 indexer/packages/postgres/__tests__/stores/trading-rewards-table.test.ts
 create mode 100644 indexer/packages/postgres/src/db/migrations/migration_files/20231129153017_create_trading_rewards_table.ts
 create mode 100644 indexer/packages/postgres/src/models/trading-reward-model.ts
 create mode 100644 indexer/packages/postgres/src/stores/trading-reward-table.ts
 create mode 100644 indexer/packages/postgres/src/types/trading-reward-types.ts

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<void> {
+  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<void> {
+  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<this>;
+
+  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<TradingRewardFromDatabase[]> {
+  verifyAllRequiredFields(
+    {
+      address,
+      blockHeight,
+      blockTimeBeforeOrAt,
+      limit,
+    } as QueryConfig,
+    requiredFields,
+  );
+
+  let baseQuery: QueryBuilder<TradingRewardModel> = setupBaseQuery<TradingRewardModel>(
+    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<TradingRewardFromDatabase> {
+  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<TradingRewardFromDatabase | undefined> {
+  const baseQuery: QueryBuilder<TradingRewardModel> = setupBaseQuery<TradingRewardModel>(
+    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',
+}