diff --git a/indexer/packages/postgres/__tests__/helpers/mock-generators.ts b/indexer/packages/postgres/__tests__/helpers/mock-generators.ts index 86d6f6e3b74..2e3f57dd2d8 100644 --- a/indexer/packages/postgres/__tests__/helpers/mock-generators.ts +++ b/indexer/packages/postgres/__tests__/helpers/mock-generators.ts @@ -5,6 +5,7 @@ import * as MarketTable from '../../src/stores/market-table'; import * as PerpetualMarketTable from '../../src/stores/perpetual-market-table'; import * as SubaccountTable from '../../src/stores/subaccount-table'; import * as TendermintEventTable from '../../src/stores/tendermint-event-table'; +import * as TokenTable from '../../src/stores/token-table'; import * as WalletTable from '../../src/stores/wallet-table'; import { defaultAsset, @@ -26,6 +27,7 @@ import { defaultTendermintEvent2, defaultTendermintEvent3, defaultTendermintEvent4, + defaultToken, defaultWallet, isolatedMarket, isolatedMarket2, @@ -78,4 +80,7 @@ export async function seedData() { await Promise.all([ WalletTable.create(defaultWallet), ]); + await Promise.all([ + TokenTable.create(defaultToken), + ]); } diff --git a/indexer/packages/postgres/__tests__/stores/token-table.test.ts b/indexer/packages/postgres/__tests__/stores/token-table.test.ts new file mode 100644 index 00000000000..bb4c3062b29 --- /dev/null +++ b/indexer/packages/postgres/__tests__/stores/token-table.test.ts @@ -0,0 +1,82 @@ +import { TokenFromDatabase } from '../../src/types'; +import { clearData, migrate, teardown } from '../../src/helpers/db-helpers'; +import { defaultAddress2, defaultToken, defaultWallet } from '../helpers/constants'; +import * as TokenTable from '../../src/stores/token-table'; +import * as WalletTable from '../../src/stores/wallet-table'; + +describe('Token store', () => { + beforeAll(async () => { + await migrate(); + }); + + beforeEach(async () => { + // Default wallet is required in the DB for token creation + // As token has a foreign key constraint on wallet + await WalletTable.create(defaultWallet); + }); + + afterEach(async () => { + await clearData(); + }); + + afterAll(async () => { + await teardown(); + }); + + it('Successfully creates a Token', async () => { + await TokenTable.create(defaultToken); + const token = await TokenTable.findByToken(defaultToken.token); + expect(token).toEqual(expect.objectContaining(defaultToken)); + }); + + it('Successfully upserts a Token multiple times', async () => { + await TokenTable.upsert(defaultToken); + let token: TokenFromDatabase | undefined = await TokenTable.findByToken( + defaultToken.token, + ); + + expect(token).toEqual(expect.objectContaining(defaultToken)); + + // Upsert again to test update functionality + const updatedToken = { ...defaultToken, updatedAt: new Date().toISOString(), language: 'es' }; + await TokenTable.upsert(updatedToken); + token = await TokenTable.findByToken(defaultToken.token); + + expect(token).toEqual(expect.objectContaining(updatedToken)); + }); + + it('Successfully finds all Tokens', async () => { + await WalletTable.create({ ...defaultWallet, address: defaultAddress2 }); + const additionalToken = { + token: 'fake_token', + address: defaultAddress2, + language: 'en', + updatedAt: new Date().toISOString(), + }; + + await Promise.all([ + TokenTable.create(defaultToken), + TokenTable.create(additionalToken), + ]); + + const tokens: TokenFromDatabase[] = await TokenTable.findAll( + {}, + [], + { readReplica: true }, + ); + + expect(tokens.length).toEqual(2); + expect(tokens[0]).toEqual(expect.objectContaining(defaultToken)); + expect(tokens[1]).toEqual(expect.objectContaining(additionalToken)); + }); + + it('Successfully finds a Token by token', async () => { + await TokenTable.create(defaultToken); + + const token: TokenFromDatabase | undefined = await TokenTable.findByToken( + defaultToken.token, + ); + + expect(token).toEqual(expect.objectContaining(defaultToken)); + }); +}); diff --git a/indexer/packages/postgres/src/db/migrations/migration_files/20240809153326_create_tokens_table.ts b/indexer/packages/postgres/src/db/migrations/migration_files/20240809153326_create_tokens_table.ts new file mode 100644 index 00000000000..045df1e1e0e --- /dev/null +++ b/indexer/packages/postgres/src/db/migrations/migration_files/20240809153326_create_tokens_table.ts @@ -0,0 +1,16 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('tokens', (table) => { + table.increments('id').primary(); + table.string('token').notNullable().unique(); + table.string('address').notNullable(); + table.foreign('address').references('wallets.address').onDelete('CASCADE'); + table.string('language').notNullable(); + table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now()); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('tokens'); +} diff --git a/indexer/packages/postgres/src/index.ts b/indexer/packages/postgres/src/index.ts index 34979ef0c60..0188bad7d83 100644 --- a/indexer/packages/postgres/src/index.ts +++ b/indexer/packages/postgres/src/index.ts @@ -45,6 +45,7 @@ export * as TradingRewardAggregationTable from './stores/trading-reward-aggregat export * as LeaderboardPnlTable from './stores/leaderboard-pnl-table'; export * as SubaccountUsernamesTable from './stores/subaccount-usernames-table'; export * as PersistentCacheTable from './stores/persistent-cache-table'; +export * as TokenTable from './stores/token-table'; export * as perpetualMarketRefresher from './loops/perpetual-market-refresher'; export * as assetRefresher from './loops/asset-refresher'; diff --git a/indexer/packages/postgres/src/models/token-model.ts b/indexer/packages/postgres/src/models/token-model.ts new file mode 100644 index 00000000000..b1d63fd14af --- /dev/null +++ b/indexer/packages/postgres/src/models/token-model.ts @@ -0,0 +1,27 @@ +import { Model } from 'objection'; + +import { IsoString } from '../types'; +import WalletModel from './wallet-model'; + +class TokenModel extends Model { + static tableName = 'tokens'; + + id!: number; + token!: string; + address!: string; + updatedAt!: IsoString; + language!: string; + + static relationMappings = { + wallet: { + relation: Model.BelongsToOneRelation, + modelClass: WalletModel, + join: { + from: 'tokens.address', + to: 'wallets.address', + }, + }, + }; +} + +export default TokenModel; diff --git a/indexer/packages/postgres/src/stores/token-table.ts b/indexer/packages/postgres/src/stores/token-table.ts new file mode 100644 index 00000000000..f2881e1cb38 --- /dev/null +++ b/indexer/packages/postgres/src/stores/token-table.ts @@ -0,0 +1,132 @@ +import { DateTime } from 'luxon'; +import { PartialModelObject, QueryBuilder } from 'objection'; + +import { DEFAULT_POSTGRES_OPTIONS } from '../constants'; +import { setupBaseQuery, verifyAllRequiredFields } from '../helpers/stores-helpers'; +import Transaction from '../helpers/transaction'; +import TokenModel from '../models/token-model'; +import { + Options, + Ordering, + QueryableField, + QueryConfig, + TokenColumns, + TokenCreateObject, + TokenFromDatabase, + TokenQueryConfig, + TokenUpdateObject, +} from '../types'; + +export async function findAll( + { + address, + limit, + }: TokenQueryConfig, + requiredFields: QueryableField[], + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + verifyAllRequiredFields( + { + address, + limit, + } as QueryConfig, + requiredFields, + ); + + let baseQuery: QueryBuilder = setupBaseQuery( + TokenModel, + options, + ); + + if (address) { + baseQuery = baseQuery.where(TokenColumns.address, address); + } + + if (options.orderBy !== undefined) { + for (const [column, order] of options.orderBy) { + baseQuery = baseQuery.orderBy( + column, + order, + ); + } + } else { + baseQuery = baseQuery.orderBy( + TokenColumns.updatedAt, + Ordering.ASC, + ); + } + + if (limit) { + baseQuery = baseQuery.limit(limit); + } + + return baseQuery.returning('*'); +} + +export async function create( + tokenToCreate: TokenCreateObject, + options: Options = { txId: undefined }, +): Promise { + return TokenModel.query( + Transaction.get(options.txId), + ).insert(tokenToCreate).returning('*'); +} + +export async function update( + { + token, + ...fields + }: TokenUpdateObject, + options: Options = { txId: undefined }, +): Promise { + const existingToken = await TokenModel.query( + Transaction.get(options.txId), + ).findOne({ token }); + const updatedToken = await existingToken.$query().patch(fields as PartialModelObject).returning('*'); + return updatedToken as unknown as TokenFromDatabase; +} + +export async function upsert( + tokenToUpsert: TokenCreateObject, + options: Options = { txId: undefined }, +): Promise { + const existingToken = await TokenModel.query( + Transaction.get(options.txId), + ).findOne({ token: tokenToUpsert.token }); + + if (existingToken) { + return update(tokenToUpsert, options); + } else { + return create(tokenToUpsert, options); + } +} + +export async function findByToken( + token: string, + options: Options = DEFAULT_POSTGRES_OPTIONS, +): Promise { + const baseQuery: QueryBuilder = setupBaseQuery( + TokenModel, + options, + ); + return baseQuery + .findOne({ token }) + .returning('*'); +} + +export async function registerToken( + token: string, + address: string, + language: string, + options: Options = { txId: undefined }, +): Promise { + return upsert( + { + token, + address, + updatedAt: DateTime.now().toISO(), + language, + }, + options, + ); +} diff --git a/indexer/packages/postgres/src/types/db-model-types.ts b/indexer/packages/postgres/src/types/db-model-types.ts index a81ee86d992..a8c1f55c895 100644 --- a/indexer/packages/postgres/src/types/db-model-types.ts +++ b/indexer/packages/postgres/src/types/db-model-types.ts @@ -295,6 +295,13 @@ export interface AffiliateReferredUserFromDatabase { referredAtBlock: string, } +export interface TokenFromDatabase { + address: WalletFromDatabase['address'], + token: string, + updatedAt: IsoString, + language: 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 8c81f46fff6..18a372fb6d8 100644 --- a/indexer/packages/postgres/src/types/index.ts +++ b/indexer/packages/postgres/src/types/index.ts @@ -31,4 +31,5 @@ export * from './leaderboard-pnl-types'; export * from './affiliate-referred-users-types'; export * from './persistent-cache-types'; export * from './affiliate-info-types'; +export * from './token-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 f3435ff0b94..7a3ce12b7f6 100644 --- a/indexer/packages/postgres/src/types/query-types.ts +++ b/indexer/packages/postgres/src/types/query-types.ts @@ -92,6 +92,7 @@ export enum QueryableField { REFEREE_ADDRESS = 'refereeAddress', KEY = 'key', IS_WHITELIST_AFFILIATE = 'isWhitelistAffiliate', + TOKEN = 'token', } export interface QueryConfig { @@ -339,3 +340,8 @@ export interface PersistentCacheQueryConfig extends QueryConfig { export interface AffiliateInfoQueryConfig extends QueryConfig { [QueryableField.ADDRESS]?: string, } + +export interface TokenQueryConfig extends QueryConfig { + [QueryableField.ADDRESS]?: string; + [QueryableField.TOKEN]?: string; +} diff --git a/indexer/packages/postgres/src/types/token-types.ts b/indexer/packages/postgres/src/types/token-types.ts new file mode 100644 index 00000000000..041742358f9 --- /dev/null +++ b/indexer/packages/postgres/src/types/token-types.ts @@ -0,0 +1,24 @@ +/* ------- TOKEN TYPES ------- */ + +type IsoString = string; + +export interface TokenCreateObject { + token: string, + address: string, + language: string, + updatedAt: IsoString, +} + +export interface TokenUpdateObject { + token: string, + address: string, + language: string, + updatedAt: IsoString, +} + +export enum TokenColumns { + token = 'token', + address = 'address', + language = 'language', + updatedAt = 'updatedAt', +}