diff --git a/packages/database/lib/schema.ts b/packages/database/lib/schema.ts index 75ce04f4..f01e9031 100644 --- a/packages/database/lib/schema.ts +++ b/packages/database/lib/schema.ts @@ -1,5 +1,180 @@ -import { pgTable, text } from 'drizzle-orm/pg-core'; +import { + bigint, integer, smallint, smallserial, + index, pgTable, primaryKey, varchar, + timestamp, boolean +} from 'drizzle-orm/pg-core'; + +// Implementation discussed here https://github.com/metaDAOproject/futarchy-indexer/pull/1/files +// Incorporated ideas from 0xNallok's version +// https://github.com/R-K-H/openbook-v2-datastore/blob/master/timescale/models/001_tables.up.sql + +// https://docs.rs/solana-program/latest/src/solana_program/pubkey.rs.html#24 +const MAX_PUBKEY_B58_STR_LEN = 44; +const pubkey = (columnName: string) => varchar(columnName, {length: MAX_PUBKEY_B58_STR_LEN}); + +const MAX_TRANSACTION_B58_STR_LEN = 88; +const transaction = (columnName: string) => varchar(columnName, {length: MAX_TRANSACTION_B58_STR_LEN}); + +const tokenAmount = (columnName: string) => bigint(columnName, {mode: 'bigint'}); + +const block = (columnName: string) => bigint(columnName, {mode: 'bigint'}); +const slot = (columnName: string) => bigint(columnName, {mode: 'bigint'}); export const proposals = pgTable('proposals', { - id: text('id').primaryKey(), + proposalAcct: pubkey('proposal_acct').primaryKey(), + proposalNum: bigint('proposal_num', {mode: 'bigint'}).notNull(), + autocratVersion: smallint('autocrat_version').notNull() +}); + +export enum MarketType { + OPEN_BOOK = 'OPEN_BOOK', + METEORA = 'METEORA', + JOE_BUILD_AMM = 'JOE_BUILD_AMM' // MetaDAO's custom hybrid Clob/AMM impl (see proposal 4) +} + +type NonEmptyList = [E, ...E[]]; + +function pgEnum(columnName: string, enumObj: Record) { + return varchar(columnName, {enum: Object.values(enumObj) as NonEmptyList}); +} + +export const markets = pgTable('markets', { + marketAcct: pubkey('market_acct').primaryKey(), + // may be null as market might not be tied to any one proposal (ex: the META spot market) + proposalAcct: pubkey('proposal_acct').references(() => proposals.proposalAcct), + marketType: pgEnum('market_type', MarketType).notNull(), + createTxSig: transaction('create_tx_sig').notNull(), + + baseMintAcct: pubkey('base_mint_acct').references(() => tokens.mintAcct).notNull(), + quoteMintAcct: pubkey('quote_mint_acct').references(() => tokens.mintAcct).notNull(), + + baseLotSize: tokenAmount('base_lot_size').notNull(), + quoteLotSize: tokenAmount('quote_lot_size').notNull(), + quoteTickSize: tokenAmount('quote_tick_size').notNull(), + + // Monitoring the total supply on either side of the market + // (helpful in case of AMMs where LPs are not traked in the makes table) + bidsTokenAcct: pubkey('bids_token_acct').references(() => tokenAccts.tokenAcct).notNull(), + asksTokenAcct: pubkey('asks_token_acct').references(() => tokenAccts.tokenAcct).notNull(), + + // Fees are in bips + baseMakerFee: smallint('base_maker_fee').notNull(), + baseTakerFee: smallint('base_taker_fee').notNull(), + quoteMakerFee: smallint('quote_maker_fee').notNull(), + quoteTakerFee: smallint('quote_taker_fee').notNull(), + + // When market becomes active or inactive + activeSlot: slot('active_slot'), + inactiveSlot: slot('inactive_slot') }); + +// By tracking specific ATAs, we can track things like market liquidity over time +// or META circulating supply by taking total META supply minus the treasury +export const tokenAccts = pgTable('token_accts', { + // ATA PGA + tokenAcct: pubkey('token_acct').primaryKey(), + mintAcct: pubkey('mint_acct').references(() => tokens.mintAcct).notNull(), + ownerAcct: pubkey('owner_acct').notNull(), + amount: tokenAmount('amount').notNull(), + updatedAt: timestamp('updated_at').notNull() +}); + +export const tokens = pgTable('tokens', { + mintAcct: pubkey('mint_acct').primaryKey(), + name: varchar('name', {length: 30}).notNull(), + symbol: varchar('symbol', {length: 10}).notNull(), + supply: tokenAmount('supply').notNull(), + decimals: smallserial('decimals').notNull(), + updatedAt: timestamp('updated_at').notNull() +}); + +export enum OrderSide { + BID = 'BID', + ASK = 'ASK' +} + +// Can result in multiple makes and takes +export const orders = pgTable('orders', { + orderTxSig: transaction('order_tx_sig').primaryKey(), + marketAcct: pubkey('market_acct').references(() => markets.marketAcct).notNull(), + actorAcct: pubkey('actor_acct').notNull(), + side: pgEnum('side', OrderSide).notNull(), + updatedAt: timestamp('updated_at').notNull(), + // Starts true, switches to false on cancellation or full fill + isActive: boolean('is_active').notNull(), + + unfilledBaseAmount: tokenAmount('unfilled_base_amount').notNull(), + filledBaseAmount: tokenAmount('filled_base_amount').notNull(), + quotePrice: tokenAmount('quote_price').notNull(), + + orderBlock: block('order_block').notNull(), + orderTime: timestamp('order_time').notNull(), + + // Only present on order cancel + cancelTxSig: transaction('cancel_tx_sig'), + cancelBlock: block('cancel_block'), + cancelTime: timestamp('cancel_time'), +}, table => ({ + // For displaying user trade history + actorIdx: index('actor_index').on(table.marketAcct, table.actorAcct) +})); + +export const makes = pgTable('makes', { + orderTxSig: transaction('order_tx_sig').references(() => orders.orderTxSig).primaryKey(), + // Explicitly denormalizing order for improved querying speed directly on makes + marketAcct: pubkey('market_acct').references(() => markets.marketAcct).notNull(), + isActive: boolean('is_active').notNull(), + + // Represents unfilled volume + unfilledBaseAmount: tokenAmount('unfilled_base_amount').notNull(), + // Starts at 0, increases as more is filled + filledBaseAmount: tokenAmount('filled_base_amount').notNull(), + quotePrice: tokenAmount('quote_price').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}, table => ({ + // For displaying current order book + marketIdx: index('market_index').on(table.marketAcct) +})); + +// Potentially many takes for one taker order (if multiple makes are being matched) +export const takes = pgTable('takes', { + orderTxSig: transaction('order_tx_sig').references(() => orders.orderTxSig).primaryKey(), + baseAmount: tokenAmount('base_amount').notNull(), + quotePrice: tokenAmount('quote_price').notNull(), + takerBaseFee: tokenAmount('taker_base_fee').notNull(), + takerQuoteFee: tokenAmount('maker_quote_fee').notNull(), + + // Maker fields not relevant in pure AMMs + makerOrderTxSig: transaction('maker_order_tx_sig').references(() => makes.orderTxSig), + makerBaseFee: tokenAmount('maker_base_fee'), + makerQuoteFee: tokenAmount('maker_quote_fee'), + + // Explicitly denormalizing order for improved querying speed directly on takes + marketAcct: pubkey('market_acct').references(() => markets.marketAcct).notNull(), + orderBlock: block('order_block').notNull(), + orderTime: timestamp('order_time').notNull(), +}, table => ({ + // For aggregating into candles and showing lates trades + blockIdx: index('block_index').on(table.marketAcct, table.orderBlock), + timeIdx: index('time_index').on(table.marketAcct, table.orderTime), + // For finding all matches related to a maker order + makerIdx: index('maker_index').on(table.makerOrderTxSig) +})); + +export const candles = pgTable('candles', { + marketAcct: pubkey('market_acct').references(() => markets.marketAcct).notNull(), + // In seconds + candleDuration: integer('candle_duration').notNull(), + // Repeats every duration + timestamp: timestamp('timestamp').notNull(), + volume: tokenAmount('volume').notNull(), + // Nullable in case where there were no trades + open: tokenAmount('open'), + high: tokenAmount('high'), + low: tokenAmount('low'), + close: tokenAmount('close'), + // time-weighted average of the candle + average: tokenAmount('average'), +}, table => ({ + pk: primaryKey(table.marketAcct, table.candleDuration, table.timestamp) +}));