From 18e4c6dee68a683e5a472682de78bd1bba3a9081 Mon Sep 17 00:00:00 2001 From: Michael Pretorius Date: Mon, 9 Sep 2024 10:12:00 +0200 Subject: [PATCH] feat: add ixoswap indexing --- .env.example | 3 + package.json | 1 + public/graphql/schema.graphql | 436 ++++++++++++++++++ src/app.ts | 14 + src/postgres/ixo_swap.ts | 225 +++++++++ .../migrations/20240829055329991_ixoSwap.sql | 32 ++ src/sync/sync_blocks.ts | 2 +- src/sync/sync_chain.ts | 16 +- .../event_data_sync_wasm_handler.ts | 160 ++++++- src/sync_handlers/event_sync_handler.ts | 2 +- src/util/helpers.ts | 74 ++- src/util/secrets.ts | 1 + tsconfig.json | 2 +- yarn.lock | 5 + 14 files changed, 940 insertions(+), 33 deletions(-) create mode 100644 src/postgres/ixo_swap.ts create mode 100644 src/postgres/migrations/20240829055329991_ixoSwap.sql diff --git a/.env.example b/.env.example index 38b059a..c2fcc99 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ IPFS_SERVICE_MAPPING="https://devnet-blocksync.ixo.earth/api/ipfs/" # entity module contract address to check cosmwasm events to index ENTITY_MODULE_CONTRACT_ADDRESS="ixo14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sqa3vn7" + +# if want to use a static chainId, so when wana skip the rpc call to get the chainId +STATIC_CHAIN_ID=pandora-8 \ No newline at end of file diff --git a/package.json b/package.json index 0e8c6c1..87e61f3 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "cors": "2.8.5", "cron": "2.3.1", "dataloader": "2.2.2", + "decimal.js": "^10.4.3", "dotenv": "16.0.3", "express": "4.18.2", "express-rate-limit": "6.7.0", diff --git a/public/graphql/schema.graphql b/public/graphql/schema.graphql index 92c500b..0d86494 100644 --- a/public/graphql/schema.graphql +++ b/public/graphql/schema.graphql @@ -867,6 +867,74 @@ type Query implements Node { filter: TransactionFilter ): TransactionsConnection + """Reads and enables pagination through a set of `IxoSwap`.""" + ixoSwaps( + """Only read the first `n` values of the set.""" + first: Int + + """Only read the last `n` values of the set.""" + last: Int + + """ + Skip the first `n` values from our `after` cursor, an alternative to cursor + based pagination. May not be used with `last`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """The method to use when ordering `IxoSwap`.""" + orderBy: [IxoSwapsOrderBy!] = [PRIMARY_KEY_ASC] + + """ + A condition to be used in determining which values should be returned by the collection. + """ + condition: IxoSwapCondition + + """ + A filter to be used in determining which values should be returned by the collection. + """ + filter: IxoSwapFilter + ): IxoSwapsConnection + + """Reads and enables pagination through a set of `IxoSwapPriceHistory`.""" + ixoSwapPriceHistories( + """Only read the first `n` values of the set.""" + first: Int + + """Only read the last `n` values of the set.""" + last: Int + + """ + Skip the first `n` values from our `after` cursor, an alternative to cursor + based pagination. May not be used with `last`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """The method to use when ordering `IxoSwapPriceHistory`.""" + orderBy: [IxoSwapPriceHistoriesOrderBy!] = [PRIMARY_KEY_ASC] + + """ + A condition to be used in determining which values should be returned by the collection. + """ + condition: IxoSwapPriceHistoryCondition + + """ + A filter to be used in determining which values should be returned by the collection. + """ + filter: IxoSwapPriceHistoryFilter + ): IxoSwapPriceHistoriesConnection + """Reads and enables pagination through a set of `Pgmigration`.""" pgmigrations( """Only read the first `n` values of the set.""" @@ -925,6 +993,9 @@ type Query implements Node { tokenTransaction(aid: Int!): TokenTransaction tokenomicsAccount(address: String!): TokenomicsAccount transaction(hash: String!): Transaction + ixoSwap(address: String!): IxoSwap + ixoSwapPriceHistory(id: Int!): IxoSwapPriceHistory + ixoSwapPriceHistoryByTimestampAndAddress(timestamp: Datetime!, address: String!): IxoSwapPriceHistory pgmigration(id: Int!): Pgmigration """Reads a single `Bond` using its globally unique `ID`.""" @@ -1101,6 +1172,20 @@ type Query implements Node { nodeId: ID! ): Transaction + """Reads a single `IxoSwap` using its globally unique `ID`.""" + ixoSwapByNodeId( + """The globally unique `ID` to be used in selecting a single `IxoSwap`.""" + nodeId: ID! + ): IxoSwap + + """Reads a single `IxoSwapPriceHistory` using its globally unique `ID`.""" + ixoSwapPriceHistoryByNodeId( + """ + The globally unique `ID` to be used in selecting a single `IxoSwapPriceHistory`. + """ + nodeId: ID! + ): IxoSwapPriceHistory + """Reads a single `Pgmigration` using its globally unique `ID`.""" pgmigrationByNodeId( """ @@ -7596,6 +7681,357 @@ input TransactionCondition { memo: String } +"""A connection to a list of `IxoSwap` values.""" +type IxoSwapsConnection { + """A list of `IxoSwap` objects.""" + nodes: [IxoSwap!]! + + """ + A list of edges which contains the `IxoSwap` and cursor to aid in pagination. + """ + edges: [IxoSwapsEdge!]! + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """The count of *all* `IxoSwap` you could get from the connection.""" + totalCount: Int! +} + +type IxoSwap implements Node { + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + address: String! + lpAddress: String! + token1155Denom: String! + token1155Reserve: BigInt! + token2Denom: String! + token2Reserve: BigInt! + protocolFeeRecipient: String! + protocolFeePercent: String! + lpFeePercent: String! + maxSlippagePercent: String! + frozen: Boolean! + owner: String! + pendingOwner: String + + """Reads and enables pagination through a set of `IxoSwapPriceHistory`.""" + ixoSwapPriceHistoriesByAddress( + """Only read the first `n` values of the set.""" + first: Int + + """Only read the last `n` values of the set.""" + last: Int + + """ + Skip the first `n` values from our `after` cursor, an alternative to cursor + based pagination. May not be used with `last`. + """ + offset: Int + + """Read all values in the set before (above) this cursor.""" + before: Cursor + + """Read all values in the set after (below) this cursor.""" + after: Cursor + + """The method to use when ordering `IxoSwapPriceHistory`.""" + orderBy: [IxoSwapPriceHistoriesOrderBy!] = [PRIMARY_KEY_ASC] + + """ + A condition to be used in determining which values should be returned by the collection. + """ + condition: IxoSwapPriceHistoryCondition + + """ + A filter to be used in determining which values should be returned by the collection. + """ + filter: IxoSwapPriceHistoryFilter + ): IxoSwapPriceHistoriesConnection! +} + +"""A connection to a list of `IxoSwapPriceHistory` values.""" +type IxoSwapPriceHistoriesConnection { + """A list of `IxoSwapPriceHistory` objects.""" + nodes: [IxoSwapPriceHistory!]! + + """ + A list of edges which contains the `IxoSwapPriceHistory` and cursor to aid in pagination. + """ + edges: [IxoSwapPriceHistoriesEdge!]! + + """Information to aid in pagination.""" + pageInfo: PageInfo! + + """ + The count of *all* `IxoSwapPriceHistory` you could get from the connection. + """ + totalCount: Int! +} + +type IxoSwapPriceHistory implements Node { + """ + A globally unique identifier. Can be used in various places throughout the system to identify this single value. + """ + nodeId: ID! + id: Int! + address: String! + timestamp: Datetime! + token1155Price: BigFloat! + token2Price: BigFloat! + + """ + Reads a single `IxoSwap` that is related to this `IxoSwapPriceHistory`. + """ + ixoSwapByAddress: IxoSwap +} + +"""A `IxoSwapPriceHistory` edge in the connection.""" +type IxoSwapPriceHistoriesEdge { + """A cursor for use in pagination.""" + cursor: Cursor + + """The `IxoSwapPriceHistory` at the end of the edge.""" + node: IxoSwapPriceHistory! +} + +"""Methods to use when ordering `IxoSwapPriceHistory`.""" +enum IxoSwapPriceHistoriesOrderBy { + NATURAL + ID_ASC + ID_DESC + ADDRESS_ASC + ADDRESS_DESC + TIMESTAMP_ASC + TIMESTAMP_DESC + TOKEN_1155_PRICE_ASC + TOKEN_1155_PRICE_DESC + TOKEN_2_PRICE_ASC + TOKEN_2_PRICE_DESC + PRIMARY_KEY_ASC + PRIMARY_KEY_DESC +} + +""" +A condition to be used against `IxoSwapPriceHistory` object types. All fields +are tested for equality and combined with a logical ‘and.’ +""" +input IxoSwapPriceHistoryCondition { + """Checks for equality with the object’s `id` field.""" + id: Int + + """Checks for equality with the object’s `address` field.""" + address: String + + """Checks for equality with the object’s `timestamp` field.""" + timestamp: Datetime + + """Checks for equality with the object’s `token1155Price` field.""" + token1155Price: BigFloat + + """Checks for equality with the object’s `token2Price` field.""" + token2Price: BigFloat +} + +""" +A filter to be used against `IxoSwapPriceHistory` object types. All fields are combined with a logical ‘and.’ +""" +input IxoSwapPriceHistoryFilter { + """Filter by the object’s `id` field.""" + id: IntFilter + + """Filter by the object’s `address` field.""" + address: StringFilter + + """Filter by the object’s `timestamp` field.""" + timestamp: DatetimeFilter + + """Filter by the object’s `token1155Price` field.""" + token1155Price: BigFloatFilter + + """Filter by the object’s `token2Price` field.""" + token2Price: BigFloatFilter + + """Filter by the object’s `ixoSwapByAddress` relation.""" + ixoSwapByAddress: IxoSwapFilter + + """Checks for all expressions in this list.""" + and: [IxoSwapPriceHistoryFilter!] + + """Checks for any expressions in this list.""" + or: [IxoSwapPriceHistoryFilter!] + + """Negates the expression.""" + not: IxoSwapPriceHistoryFilter +} + +""" +A filter to be used against `IxoSwap` object types. All fields are combined with a logical ‘and.’ +""" +input IxoSwapFilter { + """Filter by the object’s `address` field.""" + address: StringFilter + + """Filter by the object’s `lpAddress` field.""" + lpAddress: StringFilter + + """Filter by the object’s `token1155Denom` field.""" + token1155Denom: StringFilter + + """Filter by the object’s `token1155Reserve` field.""" + token1155Reserve: BigIntFilter + + """Filter by the object’s `token2Denom` field.""" + token2Denom: StringFilter + + """Filter by the object’s `token2Reserve` field.""" + token2Reserve: BigIntFilter + + """Filter by the object’s `protocolFeeRecipient` field.""" + protocolFeeRecipient: StringFilter + + """Filter by the object’s `protocolFeePercent` field.""" + protocolFeePercent: StringFilter + + """Filter by the object’s `lpFeePercent` field.""" + lpFeePercent: StringFilter + + """Filter by the object’s `maxSlippagePercent` field.""" + maxSlippagePercent: StringFilter + + """Filter by the object’s `frozen` field.""" + frozen: BooleanFilter + + """Filter by the object’s `owner` field.""" + owner: StringFilter + + """Filter by the object’s `pendingOwner` field.""" + pendingOwner: StringFilter + + """Filter by the object’s `ixoSwapPriceHistoriesByAddress` relation.""" + ixoSwapPriceHistoriesByAddress: IxoSwapToManyIxoSwapPriceHistoryFilter + + """Some related `ixoSwapPriceHistoriesByAddress` exist.""" + ixoSwapPriceHistoriesByAddressExist: Boolean + + """Checks for all expressions in this list.""" + and: [IxoSwapFilter!] + + """Checks for any expressions in this list.""" + or: [IxoSwapFilter!] + + """Negates the expression.""" + not: IxoSwapFilter +} + +""" +A filter to be used against many `IxoSwapPriceHistory` object types. All fields are combined with a logical ‘and.’ +""" +input IxoSwapToManyIxoSwapPriceHistoryFilter { + """ + Every related `IxoSwapPriceHistory` matches the filter criteria. All fields are combined with a logical ‘and.’ + """ + every: IxoSwapPriceHistoryFilter + + """ + Some related `IxoSwapPriceHistory` matches the filter criteria. All fields are combined with a logical ‘and.’ + """ + some: IxoSwapPriceHistoryFilter + + """ + No related `IxoSwapPriceHistory` matches the filter criteria. All fields are combined with a logical ‘and.’ + """ + none: IxoSwapPriceHistoryFilter +} + +"""A `IxoSwap` edge in the connection.""" +type IxoSwapsEdge { + """A cursor for use in pagination.""" + cursor: Cursor + + """The `IxoSwap` at the end of the edge.""" + node: IxoSwap! +} + +"""Methods to use when ordering `IxoSwap`.""" +enum IxoSwapsOrderBy { + NATURAL + ADDRESS_ASC + ADDRESS_DESC + LP_ADDRESS_ASC + LP_ADDRESS_DESC + TOKEN_1155_DENOM_ASC + TOKEN_1155_DENOM_DESC + TOKEN_1155_RESERVE_ASC + TOKEN_1155_RESERVE_DESC + TOKEN_2_DENOM_ASC + TOKEN_2_DENOM_DESC + TOKEN_2_RESERVE_ASC + TOKEN_2_RESERVE_DESC + PROTOCOL_FEE_RECIPIENT_ASC + PROTOCOL_FEE_RECIPIENT_DESC + PROTOCOL_FEE_PERCENT_ASC + PROTOCOL_FEE_PERCENT_DESC + LP_FEE_PERCENT_ASC + LP_FEE_PERCENT_DESC + MAX_SLIPPAGE_PERCENT_ASC + MAX_SLIPPAGE_PERCENT_DESC + FROZEN_ASC + FROZEN_DESC + OWNER_ASC + OWNER_DESC + PENDING_OWNER_ASC + PENDING_OWNER_DESC + PRIMARY_KEY_ASC + PRIMARY_KEY_DESC +} + +""" +A condition to be used against `IxoSwap` object types. All fields are tested for equality and combined with a logical ‘and.’ +""" +input IxoSwapCondition { + """Checks for equality with the object’s `address` field.""" + address: String + + """Checks for equality with the object’s `lpAddress` field.""" + lpAddress: String + + """Checks for equality with the object’s `token1155Denom` field.""" + token1155Denom: String + + """Checks for equality with the object’s `token1155Reserve` field.""" + token1155Reserve: BigInt + + """Checks for equality with the object’s `token2Denom` field.""" + token2Denom: String + + """Checks for equality with the object’s `token2Reserve` field.""" + token2Reserve: BigInt + + """Checks for equality with the object’s `protocolFeeRecipient` field.""" + protocolFeeRecipient: String + + """Checks for equality with the object’s `protocolFeePercent` field.""" + protocolFeePercent: String + + """Checks for equality with the object’s `lpFeePercent` field.""" + lpFeePercent: String + + """Checks for equality with the object’s `maxSlippagePercent` field.""" + maxSlippagePercent: String + + """Checks for equality with the object’s `frozen` field.""" + frozen: Boolean + + """Checks for equality with the object’s `owner` field.""" + owner: String + + """Checks for equality with the object’s `pendingOwner` field.""" + pendingOwner: String +} + """A connection to a list of `Pgmigration` values.""" type PgmigrationsConnection { """A list of `Pgmigration` objects.""" diff --git a/src/app.ts b/src/app.ts index 11aa125..d9851c1 100644 --- a/src/app.ts +++ b/src/app.ts @@ -15,6 +15,7 @@ import rateLimit from "express-rate-limit"; import { web3StorageRateLimiter } from "./util/rate-limiter"; import { Postgraphile } from "./postgraphile"; import swaggerFile from "./swagger.json"; +import { getCoreBlock } from "./postgres/blocksync_core/block"; const limiter = rateLimit({ windowMs: 1 * 1000, // 1 second @@ -120,6 +121,19 @@ app.get("/api/tokenomics/fetchAccounts", async (req, res, next) => { } }); +// ================================= +// Custom helpers for local development +// ================================= + +app.get("/api/development/getCoreBlock/:height", async (req, res, next) => { + try { + const result = await getCoreBlock(Number(req.params.height || 0)); + res.json(result); + } catch (error) { + res.status(500).send(error.message); + } +}); + // ================================= // CRON Jobs // ================================= diff --git a/src/postgres/ixo_swap.ts b/src/postgres/ixo_swap.ts new file mode 100644 index 0000000..978a7d3 --- /dev/null +++ b/src/postgres/ixo_swap.ts @@ -0,0 +1,225 @@ +import Decimal from "decimal.js"; +import { pool, withTransaction } from "./client"; + +export type IxoSwap = { + address: string; + lpAddress: string; + token1155Denom: string; + token1155Reserve: bigint; + token2Denom: string; + token2Reserve: bigint; + protocolFeeRecipient: string; + protocolFeePercent: string; + lpFeePercent: string; + maxSlippagePercent: string; + frozen: boolean; + owner: string; + pendingOwner?: string | null; +}; + +const getIxoSwapSql = ` +SELECT * FROM ixo_swap WHERE address = $1; +`; +export const getIxoSwap = async ( + address: string +): Promise => { + try { + const res = await pool.query(getIxoSwapSql, [address]); + return res.rows[0]; + } catch (error) { + throw error; + } +}; + +const createIxoSwapSql = ` +INSERT INTO ixo_swap ("address", "lp_address", "token_1155_denom", "token_1155_reserve", "token_2_denom", "token_2_reserve", "protocol_fee_recipient", "protocol_fee_percent", "lp_fee_percent", "max_slippage_percent", "frozen", "owner", "pending_owner") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13); +`; +export const createIxoSwap = async (t: IxoSwap): Promise => { + try { + await pool.query(createIxoSwapSql, [ + t.address, + t.lpAddress, + t.token1155Denom, + t.token1155Reserve, + t.token2Denom, + t.token2Reserve, + t.protocolFeeRecipient, + t.protocolFeePercent, + t.lpFeePercent, + t.maxSlippagePercent, + t.frozen, + t.owner, + t.pendingOwner, + ]); + } catch (error) { + throw error; + } +}; + +const updateIxoSwapLPAddressSql = ` +UPDATE ixo_swap SET lp_address = $2 WHERE address = $1; +`; +export const updateIxoSwapLPAddress = async (e: { + address: string; + lpAddress: string; +}): Promise => { + try { + await pool.query(updateIxoSwapLPAddressSql, [e.address, e.lpAddress]); + } catch (error) { + throw error; + } +}; + +const updateIxoSwapFrozenSql = ` +UPDATE ixo_swap SET frozen = $2 WHERE address = $1; +`; +export const updateIxoSwapFrozen = async (e: { + address: string; + frozen: boolean; +}): Promise => { + try { + await pool.query(updateIxoSwapFrozenSql, [e.address, e.frozen]); + } catch (error) { + throw error; + } +}; + +const updateIxoSwapNewOwnerSql = ` +UPDATE ixo_swap SET owner = $2, pending_owner = NULL WHERE address = $1; +`; +export const updateIxoSwapNewOwner = async (e: { + address: string; + owner: string; +}): Promise => { + try { + await pool.query(updateIxoSwapNewOwnerSql, [e.address, e.owner]); + } catch (error) { + throw error; + } +}; + +const updateIxoSwapPendingOwnerSql = ` +UPDATE ixo_swap SET pending_owner = $2 WHERE address = $1; +`; +export const updateIxoSwapPendingOwner = async (e: { + address: string; + pendingOwner: string; +}): Promise => { + try { + await pool.query(updateIxoSwapPendingOwnerSql, [e.address, e.pendingOwner]); + } catch (error) { + throw error; + } +}; + +const updateIxoSwapMaxSlippagePercentSql = ` +UPDATE ixo_swap SET max_slippage_percent = $2 WHERE address = $1; +`; +export const updateIxoSwapMaxSlippagePercent = async (e: { + address: string; + maxSlippagePercent: string; +}): Promise => { + try { + await pool.query(updateIxoSwapMaxSlippagePercentSql, [ + e.address, + e.maxSlippagePercent, + ]); + } catch (error) { + throw error; + } +}; + +const updateIxoSwapFeeSql = ` +UPDATE ixo_swap SET lp_fee_percent = $2, protocol_fee_percent = $3, protocol_fee_recipient = $4 WHERE address = $1; +`; +export const updateIxoSwapFee = async (e: { + address: string; + lpFeePercent: string; + protocolFeePercent: string; + protocolFeeRecipient: string; +}): Promise => { + try { + await pool.query(updateIxoSwapFeeSql, [ + e.address, + e.lpFeePercent, + e.protocolFeePercent, + e.protocolFeeRecipient, + ]); + } catch (error) { + throw error; + } +}; + +const updateIxoSwapReservesSql = ` +UPDATE ixo_swap SET token_1155_reserve = $2, token_2_reserve = $3 WHERE address = $1; +`; +export const updateIxoSwapReserves = async (e: { + address: string; + token1155Reserve: bigint; + token2Reserve: bigint; +}): Promise => { + try { + await pool.query(updateIxoSwapReservesSql, [ + e.address, + e.token1155Reserve, + e.token2Reserve, + ]); + } catch (error) { + throw error; + } +}; + +const insertIxoSwapPriceHistorySql = ` +INSERT INTO ixo_swap_price_history ("address", "timestamp", "token_1155_price", "token_2_price") +VALUES ($1, $2, $3, $4) +ON CONFLICT("timestamp", "address") DO UPDATE SET + "token_1155_price" = EXCLUDED."token_1155_price", + "token_2_price" = EXCLUDED."token_2_price" +WHERE ixo_swap_price_history."address" = EXCLUDED."address" AND ixo_swap_price_history."timestamp" = EXCLUDED."timestamp"; +`; +/** + * This function does 2 things: + * 1.1- Inserts a new row into ixo_swap_price_history table if no row with same timestamp exists + * 1.2- Updates the token_1155_price and token_2_price columns of the ixo_swap table if a row with same timestamp exists + * 2- Updates the ixo_swap table with the latest token_1155_reserve and token_2_reserve values + */ +const decimalZero = new Decimal(0); +export const insertIxoSwapPriceHistory = async (e: { + address: string; + timestamp: Date; + token1155Reserve: string; + token2Reserve: string; +}): Promise => { + try { + await withTransaction(async (client) => { + console.log("token1155Reserve: ", e.token1155Reserve); + console.log("token2Reserve: ", e.token2Reserve); + const token1155ReserveDecimal = new Decimal(e.token1155Reserve); + const token2ReserveDecimal = new Decimal(e.token2Reserve); + // safegaurd against divide by zero + const isEitherZero = + token1155ReserveDecimal.isZero() || token2ReserveDecimal.isZero(); + const token_1155_price = isEitherZero + ? decimalZero + : token2ReserveDecimal.div(token1155ReserveDecimal); + const token_2_price = isEitherZero + ? decimalZero + : token1155ReserveDecimal.div(token2ReserveDecimal); + console.log("token_1155_price: ", token_1155_price.toString()); + console.log("token_2_price: ", token_2_price.toString()); + await client.query(insertIxoSwapPriceHistorySql, [ + e.address, + e.timestamp, + token_1155_price.toString(), + token_2_price.toString(), + ]); + await client.query(updateIxoSwapReservesSql, [ + e.address, + e.token1155Reserve, + e.token2Reserve, + ]); + }); + } catch (error) { + throw error; + } +}; diff --git a/src/postgres/migrations/20240829055329991_ixoSwap.sql b/src/postgres/migrations/20240829055329991_ixoSwap.sql new file mode 100644 index 0000000..de40be3 --- /dev/null +++ b/src/postgres/migrations/20240829055329991_ixoSwap.sql @@ -0,0 +1,32 @@ +-- Up Migration + +-- CreateTable +CREATE TABLE ixo_swap ( + "address" TEXT NOT NULL PRIMARY KEY, + "lp_address" TEXT NOT NULL, + "token_1155_denom" TEXT NOT NULL, + "token_1155_reserve" BIGINT NOT NULL, + "token_2_denom" TEXT NOT NULL, + "token_2_reserve" BIGINT NOT NULL, + "protocol_fee_recipient" TEXT NOT NULL, + "protocol_fee_percent" TEXT NOT NULL, + "lp_fee_percent" TEXT NOT NULL, + "max_slippage_percent" TEXT NOT NULL, + "frozen" BOOLEAN NOT NULL, + "owner" TEXT NOT NULL, + "pending_owner" TEXT +); + +-- CreateTable +CREATE TABLE ixo_swap_price_history ( + "id" SERIAL NOT NULL PRIMARY KEY, + "address" TEXT NOT NULL REFERENCES ixo_swap(address), + "timestamp" TIMESTAMP(3) NOT NULL, + "token_1155_price" NUMERIC NOT NULL, -- price of token_1155 in terms of token_2 + "token_2_price" NUMERIC NOT NULL, -- price of token_2 in terms of token_1155 + UNIQUE ("timestamp", "address") +); + +CREATE INDEX idx_price_history_timestamp ON ixo_swap_price_history ("timestamp"); + +-- Down Migration diff --git a/src/sync/sync_blocks.ts b/src/sync/sync_blocks.ts index 5fe7243..9774640 100644 --- a/src/sync/sync_blocks.ts +++ b/src/sync/sync_blocks.ts @@ -23,7 +23,7 @@ export const startSync = async () => { let count = 0; if (logSync1000Time) console.time("sync"); while (syncing) { - // if (currentBlock === 10001) return; + // if (currentBlock === 460) return; try { if (logFetchTime) console.time("fetch"); // console.log("wait then get block:", currentBlock, getMemoryUsage().rss); diff --git a/src/sync/sync_chain.ts b/src/sync/sync_chain.ts index 603f09b..564be0a 100644 --- a/src/sync/sync_chain.ts +++ b/src/sync/sync_chain.ts @@ -1,6 +1,6 @@ import { createQueryClient, createRegistry } from "@ixo/impactxclient-sdk"; import * as Proto from "../util/proto"; -import { RPC } from "../util/secrets"; +import { RPC, STATIC_CHAIN_ID } from "../util/secrets"; import { sleep } from "../util/sleep"; import { ChainCore, getCoreChain } from "../postgres/blocksync_core/chain"; import { Chain, createChain, getChain } from "../postgres/chain"; @@ -11,12 +11,18 @@ export let registry: ReturnType; export const syncChain = async () => { try { - queryClient = await createQueryClient(RPC); registry = createRegistry(); - const res = await Proto.getLatestBlock(); - const chainId = res?.block?.header?.chainId || ""; - if (!chainId) throw new Error("No Chain Found on RPC Endpoint"); + let chainId: string; + if (STATIC_CHAIN_ID) { + // if want to run without needing above rpc endpoint, can use this instead and comment above + chainId = STATIC_CHAIN_ID; + } else { + queryClient = await createQueryClient(RPC); + const res = await Proto.getLatestBlock(); + chainId = res?.block?.header?.chainId || ""; + if (!chainId) throw new Error("No Chain Found on RPC Endpoint"); + } let coreChain: ChainCore | undefined; while (true) { diff --git a/src/sync_handlers/event_data_sync_wasm_handler.ts b/src/sync_handlers/event_data_sync_wasm_handler.ts index b392541..b21ff19 100644 --- a/src/sync_handlers/event_data_sync_wasm_handler.ts +++ b/src/sync_handlers/event_data_sync_wasm_handler.ts @@ -7,17 +7,39 @@ import { createTokenTransaction, getTokenClassContractAddress, } from "../postgres/token"; +import { + createIxoSwap, + getIxoSwap, + updateIxoSwapFee, + updateIxoSwapFrozen, + updateIxoSwapLPAddress, + updateIxoSwapMaxSlippagePercent, + updateIxoSwapNewOwner, + updateIxoSwapPendingOwner, + insertIxoSwapPriceHistory, +} from "../postgres/ixo_swap"; + +// General note for future, wasm contract initiations emit an event of type "instantiate" instead of "wasm" with the contract +// code id that was initiated might want to use this in future if want to index other smart contract like cw20 etc. + +// TODO: can optimise this by only getting the tokenClass and ixoSwap contract address at global state once and updating on additions +// so that we dont make db query on every wasm event +// TODO: re-design the whole getWasmAttr function and see if can maek into Map so dont need to filter whole array everytime looking for +// wasm action attributes export const syncWasmEventData = async ( - event: EventCore + event: EventCore, + timestamp: Date ): Promise => { try { const contractAddress = getWasmAttr(event.attributes, "_contract_address"); - const action = getWasmAttr(event.attributes, "action"); - // if it is a wasm execution on entity module contract address + // -------------------------------------------------------------------------------- + // Entity Module + // -------------------------------------------------------------------------------- + // wasm execution on entity module contract address, then do handling to set owner of entity if (contractAddress === ENTITY_MODULE_CONTRACT_ADDRESS) { - const tokenId = getWasmAttr(event.attributes, "token_id"); + const action = getWasmAttr(event.attributes, "action"); if (action === "mint") { // if action for entity contract address is mint it means it is a nft minting and since wasm events come before @@ -28,36 +50,49 @@ export const syncWasmEventData = async ( func: async () => { await updateEntityOwner({ owner: getWasmAttr(event.attributes, "owner"), - id: tokenId, + id: getWasmAttr(event.attributes, "token_id"), }); }, }; } else if (action === "transfer_nft") { - await updateEntityOwner({ - id: tokenId, + return await updateEntityOwner({ + id: getWasmAttr(event.attributes, "token_id"), owner: getWasmAttr(event.attributes, "recipient"), }); } - return; } - // if it is a wasm execution on a token smart contract + // -------------------------------------------------------------------------------- + // Token Module + // -------------------------------------------------------------------------------- + // token module smart contract handling const tokenClass = await getTokenClassContractAddress(contractAddress); if (tokenClass) { // split attributes by action as cosmwasm joins all attributes into one array const messages = splitAttributesByKeyValue(event.attributes as any); + // console.dir(messages); for (const message of messages) { + const from = getWasmAttr(message, "from"); + const to = getWasmAttr(message, "to"); + + // if no from and to it means it is anohter wasm action, like approve_all, so no token transaction + if (!from && !to) continue; + // if from and to are the same it means it is a transfer to self, no need to track it as TokenTransaction id for amounts + if (from === to) continue; + const tokenTransaction = { - from: getWasmAttr(message, "from"), - to: getWasmAttr(message, "to"), + from, + to, amount: BigInt(getWasmAttr(message, "amount") ?? 0), tokenId: getWasmAttr(message, "token_id"), }; - if (getWasmAttr(message, "from")) { + if (from) { await createTokenTransaction(tokenTransaction); } else { // if no from it means it is a token minting and since wasm events come before module events it means the token creation // event on token module didnt happen yet so we need to delay this function until the token creation event happens + // it is safe to return here already inside the for loop as 1155 wasm miont event will always be alone due to being followed + // by a ixo.token.v1beta1.TokenMintedEvent event, so wasm module cant batch minting tokens through token module return { skip: 1, func: async () => { @@ -66,6 +101,107 @@ export const syncWasmEventData = async ( }; } } + return; + } + + // -------------------------------------------------------------------------------- + // ixo-swap + // -------------------------------------------------------------------------------- + const action = getWasmAttr(event.attributes, "action"); + const ixoSwap = await getIxoSwap(contractAddress); + + // if ixo-swap exists, then ahndle it's different actions + if (ixoSwap) { + switch (action) { + case "instantiate-lp-token": + return await updateIxoSwapLPAddress({ + address: contractAddress, + lpAddress: getWasmAttr( + event.attributes, + "liquidity_pool_token_address" + ), + }); + case "freeze-deposits": + return await updateIxoSwapFrozen({ + address: contractAddress, + frozen: getWasmAttr(event.attributes, "frozen") === "true", + }); + case "transfer-ownership": + return await updateIxoSwapPendingOwner({ + address: contractAddress, + pendingOwner: getWasmAttr(event.attributes, "pending_owner"), + }); + case "claim-ownership": + return await updateIxoSwapNewOwner({ + address: contractAddress, + owner: getWasmAttr(event.attributes, "owner"), + }); + case "update-slippage": + return await updateIxoSwapMaxSlippagePercent({ + address: contractAddress, + maxSlippagePercent: getWasmAttr( + event.attributes, + "max_slippage_percent" + ), + }); + case "update-fee": + return await updateIxoSwapFee({ + address: contractAddress, + lpFeePercent: getWasmAttr(event.attributes, "lp_fee_percent"), + protocolFeePercent: getWasmAttr( + event.attributes, + "protocol_fee_percent" + ), + protocolFeeRecipient: getWasmAttr( + event.attributes, + "protocol_fee_recipient" + ), + }); + // for now we dont care about the distinctive attributes, only the reserves and the price history + case "add-liquidity": + case "remove-liquidity": + case "cross-contract-swap": + case "swap": + return await insertIxoSwapPriceHistory({ + address: contractAddress, + timestamp, + token1155Reserve: getWasmAttr( + event.attributes, + "token1155_reserve" + ), + token2Reserve: getWasmAttr(event.attributes, "token2_reserve"), + }); + default: + throw new Error("Unknown action for ixo-swap: " + action); + } + } + + // if ixo-swap instantiation, then save the new contract details to ixo_swap table + if (action === "instantiate-ixo-swap") { + return await createIxoSwap({ + address: contractAddress, + lpAddress: "", // set as empty string next event will be liquidity pool initialization + token1155Denom: getWasmAttr(event.attributes, "token_1155_denom"), + token1155Reserve: BigInt("0"), + token2Denom: getWasmAttr(event.attributes, "token_2_denom"), + token2Reserve: BigInt("0"), + protocolFeeRecipient: getWasmAttr( + event.attributes, + "protocol_fee_recipient" + ), + protocolFeePercent: getWasmAttr( + event.attributes, + "protocol_fee_percent" + ), + lpFeePercent: getWasmAttr(event.attributes, "lp_fee_percent"), + maxSlippagePercent: getWasmAttr( + event.attributes, + "max_slippage_percent" + ), + frozen: false, + owner: getWasmAttr(event.attributes, "owner"), + pendingOwner: null, + }); } } catch (error) { console.error("ERROR::syncWasmEventData:: ", error.message); diff --git a/src/sync_handlers/event_sync_handler.ts b/src/sync_handlers/event_sync_handler.ts index 643b9ab..d8064a1 100644 --- a/src/sync_handlers/event_sync_handler.ts +++ b/src/sync_handlers/event_sync_handler.ts @@ -20,7 +20,7 @@ export const syncEvents = async ( let res: any = null; try { if (event.type === "wasm") { - res = await syncWasmEventData(event); + res = await syncWasmEventData(event, timestamp); } else { await syncEventData(event, blockHeight, timestamp); } diff --git a/src/util/helpers.ts b/src/util/helpers.ts index c499449..bbe919d 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -27,6 +27,9 @@ export const getValueFromAttributes = ( : attributes.find((attr) => attr.key === key).value || ""; }; +/** + * Gets the value of the wasm event attribute with the given key + */ export const getWasmAttr = (attributes: any[], key: string): string => { return attributes.find((attr) => attr.key === key)?.value || ""; }; @@ -36,22 +39,67 @@ export const base64ToJson = (base64String: string) => { return JSON.parse(json); }; -export const splitAttributesByKeyValue = ( - array: Attribute[], - value = "action" -) => { - const result: Attribute[][] = []; - let currentGroup: Attribute[] = []; +/** + * Cosmwasm joins all messages events into one array + * This function splits the array, after checking if it is a sequential or alphabetical group + * It returns a list of message event arrays, to be able to index them by action as if each group was a message + */ +export const splitAttributesByKeyValue = (array: Attribute[]) => { + let result: Attribute[][] = []; + + // first attribute is always "_contract_address", so remove it + // this modifies the original array, which is fine as not used again, thus this more performant + array.splice(0, 1); + + // TODO: consider changing return of func to list of maps for performance + // currently there is 2 ways the attributes are grouped, + // 1- sequentially(by action), so action then its other attributes, then action again and so on: + // {"key": "action","value": "transfer"}, + // {"key": "amount","value": "5113774"}, + // {"key": "from","value": "ixo1ffdljtp6l6mr8f7aena8tl8u39y9epd5xgx0w4n9880ww6l8ch0s4ewrzd"}, + // {"key": "action","value": "transfer"}, + // {"key": "amount","value": "5113774"}, + // {"key": "from","value": "ixo1ffdljtp6l6mr8f7aena8tl8u39y9epd5xgx0w4n9880ww6l8ch0s4ewrzd"} + + // 2- alphabetically(by key), so all actions, then all other attributes groupd by key and alphabetically: + // {"key": "action","value": "transfer"}, + // {"key": "action","value": "transfer"}, + // {"key": "amount","value": "530"}, + // {"key": "amount","value": "530"}, + // {"key": "from","value": "ixo1n8yrmeatsk74dw0zs95ess9sgzptd6thgjgcj2"}, + // {"key": "from","value": "ixo1n8yrmeatsk74dw0zs95ess9sgzptd6thgjgcj2"} - for (let obj of array.filter((attr) => attr.key !== "_contract_address")) { - if (obj.key === value) { - if (currentGroup.length > 0) result.push(currentGroup); - currentGroup = [obj]; - } else { - currentGroup.push(obj); + // first check if first 2 attributes keys is both "action", to know if we need to group by sequentially or alphabetically + const isSequential = array[0].key === "action" && array[1].key !== "action"; + + // if sequential then group by splitting the array by action + if (isSequential) { + let currentGroup: Attribute[] = []; + for (let attr of array) { + if (attr.key === "action") { + if (currentGroup.length > 0) result.push(currentGroup); + currentGroup = [attr]; + } else { + currentGroup.push(attr); + } + } + if (currentGroup.length > 0) result.push(currentGroup); + } else { + // if not sequential then group by splitting the array into amount of 'action' attributes + // this assumes that all attributes will be for same action type, which is safe assumption for now + // and with this assumtion it means the amount of attirbutes will be equal per action + + // Count the number of 'action' keys + const actionCount = array.filter((attr) => attr.key === "action").length; + // Initialize result with empty arrays for each action group + result = Array(actionCount).fill([]); + // Distribute attributes across groups + for (let i = 0; i < array.length; i++) { + const groupIndex = i % actionCount; + result[groupIndex].push(array[i]); } } - if (currentGroup.length > 0) result.push(currentGroup); + return result; }; diff --git a/src/util/secrets.ts b/src/util/secrets.ts index e46c355..303caa1 100644 --- a/src/util/secrets.ts +++ b/src/util/secrets.ts @@ -11,3 +11,4 @@ export const ENTITY_MODULE_CONTRACT_ADDRESS = export const IPFS_SERVICE_MAPPING = process.env.IPFS_SERVICE_MAPPING || ""; export const DATABASE_USE_SSL = Number(process.env.DATABASE_USE_SSL ?? "0") || 0; +export const STATIC_CHAIN_ID = process.env.STATIC_CHAIN_ID; diff --git a/tsconfig.json b/tsconfig.json index b198ade..bb291ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "strict": false, "skipLibCheck": true }, - "exclude": ["node_modules", "build", "docs", "src/seed"] + "exclude": ["node_modules", "build", "docs", "src/seed", "test.js"] } diff --git a/yarn.lock b/yarn.lock index 4e4e361..2d65ff7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1958,6 +1958,11 @@ debug@4, "debug@>=3 <5", debug@^4.0.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4: dependencies: ms "2.1.2" +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"