diff --git a/package.json b/package.json index aef5ac0a..6d127b0e 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "test": "jest", "docs": "typedoc --entryPointStrategy expand --name 'Oraidex SDK' --readme none --tsconfig packages/contracts-sdk/tsconfig.json packages/contracts-sdk/src", "build": "tsc -p", - "deploy": "yarn publish --access public --patch", - "start:server": "yarn build packages/oraidex-sync/ && npx ts-node packages/oraidex-server/src/index.ts" + "deploy": "yarn publish --access public", + "start:server": "yarn build packages/oraidex-sync/ && npx ts-node-dev packages/oraidex-server/src/index.ts" }, "workspaces": [ "packages/*" diff --git a/packages/oraidex-server/src/index.ts b/packages/oraidex-server/src/index.ts index 20e62c04..7a478b42 100644 --- a/packages/oraidex-server/src/index.ts +++ b/packages/oraidex-server/src/index.ts @@ -1,28 +1,32 @@ #!/usr/bin/env node -import "dotenv/config"; -import express, { Request } from "express"; +import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate"; +import { OraiswapRouterQueryClient } from "@oraichain/oraidex-contracts-sdk"; import { DuckDb, + GetCandlesQuery, + ORAI, + OraiDexSync, + PairInfoDataResponse, TickerInfo, + VolumeRange, + findPairAddress, + getAllFees, + getAllVolume24h, + getPairLiquidity, + oraiUsdtPairOnlyDenom, pairs, + pairsOnlyDenom, parseAssetInfoOnlyDenom, - findPairAddress, - toDisplay, - OraiDexSync, simulateSwapPrice, - pairsOnlyDenom, - VolumeRange, - oraiUsdtPairOnlyDenom, - ORAI + toDisplay } from "@oraichain/oraidex-sync"; import cors from "cors"; -import { CosmWasmClient } from "@cosmjs/cosmwasm-stargate"; -import { OraiswapRouterQueryClient } from "@oraichain/oraidex-contracts-sdk"; -import { getDate24hBeforeNow, getSpecificDateBeforeNow, pairToString, parseSymbolsToTickerId } from "./helper"; -import { GetCandlesQuery } from "@oraichain/oraidex-sync"; +import "dotenv/config"; +import express, { Request } from "express"; import fs from "fs"; import path from "path"; +import { getDate24hBeforeNow, getSpecificDateBeforeNow, pairToString, parseSymbolsToTickerId } from "./helper"; const app = express(); app.use(cors()); @@ -79,7 +83,6 @@ app.get("/tickers", async (req, res) => { const symbols = pair.symbols; const pairAddr = findPairAddress(pairInfos, pair.asset_infos); const tickerId = parseSymbolsToTickerId(symbols); - // const { baseIndex, targetIndex, target } = findUsdOraiInPair(pair.asset_infos); const baseIndex = 0; const targetIndex = 1; console.log(latestTimestamp, then); @@ -215,7 +218,7 @@ app.get("/volume/v2/historical/chart", async (req, res) => { // console.log("prefix sum: ", prefixSum); // res.status(200).send("hello world"); // } catch (error) { -// console.log("server error /liquidity/v2/historical/chart: ", error); +// console.log("server error /liquidity/v2/historical/chart: ", error); // res.status(500).send(JSON.stringify(error)); // } finally { // return; @@ -231,10 +234,37 @@ app.get("/v1/candles/", async (req: Request<{}, {}, {}, GetCandlesQuery>, res) = } }); +app.get("/v1/pools/", async (_req, res) => { + try { + const [volumes, allFee7Days, pools, allPoolApr] = await Promise.all([ + getAllVolume24h(), + getAllFees(), + duckDb.getPools(), + duckDb.getApr() + ]); + const allLiquidities = await Promise.all(pools.map((pair) => getPairLiquidity(pair))); + + res.status(200).send( + pools.map((pool, index) => { + const poolApr = allPoolApr.find((item) => item.pairAddr === pool.pairAddr); + return { + ...pool, + volume24Hour: volumes[index]?.toString() ?? "0", + fee7Days: allFee7Days[index]?.toString() ?? "0", + apr: poolApr?.apr ?? 0, + totalLiquidity: allLiquidities[index] + } as PairInfoDataResponse; + }) + ); + } catch (error) { + console.log({ error }); + res.status(500).send(error.message); + } +}); + app.listen(port, hostname, async () => { // sync data for the service to read - // console.dir(pairInfos, { depth: null }); - duckDb = await DuckDb.create(process.env.DUCKDB_PROD_FILENAME || "oraidex-sync-data"); + duckDb = await DuckDb.create(process.env.DUCKDB_PROD_FILENAME); const oraidexSync = await OraiDexSync.create( duckDb, process.env.RPC_URL || "https://rpc.orai.io", diff --git a/packages/oraidex-sync/src/constants.ts b/packages/oraidex-sync/src/constants.ts index 69012d9d..f92b0706 100644 --- a/packages/oraidex-sync/src/constants.ts +++ b/packages/oraidex-sync/src/constants.ts @@ -1,3 +1,5 @@ +import { AssetInfo } from "@oraichain/common-contracts-sdk"; + export const ORAI = "orai"; export const airiCw20Adress = "orai10ldgzued6zjp0mkqwsv2mux3ml50l97c74x8sg"; export const oraixCw20Address = "orai1lus0f0rhx8s03gdllx2n6vhkmf0536dv57wfge"; @@ -13,3 +15,19 @@ export const osmosisIbcDenom = "ibc/9C4DCD21B48231D0BC2AC3D1B74A864746B37E429269 export const tenAmountInDecimalSix = 10000000; export const truncDecimals = 6; export const atomic = 10 ** truncDecimals; +export const oraiInfo: AssetInfo = { native_token: { denom: ORAI } }; +export const usdtInfo: AssetInfo = { token: { contract_addr: usdtCw20Address } }; +export const ORAIXOCH_INFO: AssetInfo = { + token: { + contract_addr: "orai1lplapmgqnelqn253stz6kmvm3ulgdaytn89a8mz9y85xq8wd684s6xl3lt" + } +}; + +export const SEC_PER_YEAR = 60 * 60 * 24 * 365; +export const network = { + factory: process.env.FACTORY_CONTACT_ADDRESS_V1, + factory_v2: process.env.FACTORY_CONTACT_ADDRESS_V2, + router: process.env.ROUTER_CONTRACT_ADDRESS, + staking: process.env.STAKING_CONTRACT, + multicall: process.env.MULTICALL_CONTRACT_ADDRESS +}; diff --git a/packages/oraidex-sync/src/db.ts b/packages/oraidex-sync/src/db.ts index b2cd90ab..a25fb421 100644 --- a/packages/oraidex-sync/src/db.ts +++ b/packages/oraidex-sync/src/db.ts @@ -1,28 +1,39 @@ -import { Database, Connection } from "duckdb-async"; +import { AssetInfo } from "@oraichain/oraidex-contracts-sdk"; +import { Connection, Database } from "duckdb-async"; +import fs from "fs"; +import { isoToTimestampNumber, parseAssetInfo, renameKey, replaceAllNonAlphaBetChar, toObject } from "./helper"; import { + GetCandlesQuery, + GetFeeSwap, + GetVolumeQuery, Ohlcv, PairInfoData, + PoolAmountHistory, + PoolApr, PriceInfo, SwapOperationData, TokenVolumeData, TotalLiquidity, VolumeData, VolumeRange, - WithdrawLiquidityOperationData, - GetCandlesQuery + WithdrawLiquidityOperationData } from "./types"; -import fs, { rename } from "fs"; -import { isoToTimestampNumber, renameKey, replaceAllNonAlphaBetChar, toObject } from "./helper"; export class DuckDb { + static instances: DuckDb; protected constructor(public readonly conn: Connection, private db: Database) {} - static async create(fileName?: string): Promise { - let db = await Database.create(fileName ?? "data"); - await db.close(); // close to flush WAL file - db = await Database.create(fileName ?? "data"); - const conn = await db.connect(); - return new DuckDb(conn, db); + static async create(fileName: string): Promise { + if (!fileName) throw new Error("Filename is not provided!"); + if (!DuckDb.instances) { + let db = await Database.create(fileName); + await db.close(); // close to flush WAL file + db = await Database.create(fileName); + const conn = await db.connect(); + DuckDb.instances = new DuckDb(conn, db); + } + + return DuckDb.instances; } async closeDb() { @@ -105,7 +116,8 @@ export class DuckDb { timestamp UINTEGER, txCreator VARCHAR, txhash VARCHAR, - txheight UINTEGER)` + txheight UINTEGER, + taxRate UBIGINT)` ); } @@ -123,6 +135,9 @@ export class DuckDb { pairAddr VARCHAR, liquidityAddr VARCHAR, oracleAddr VARCHAR, + symbols VARCHAR, + fromIconUrl VARCHAR, + toIconUrl VARCHAR, PRIMARY KEY (pairAddr) )` ); } @@ -131,16 +146,6 @@ export class DuckDb { await this.insertBulkData(ops, "pair_infos", true); } - async createPriceInfoTable() { - await this.conn.exec( - `CREATE TABLE IF NOT EXISTS price_infos ( - txheight UINTEGER, - timestamp UINTEGER, - assetInfo VARCHAR, - price UINTEGER)` - ); - } - async insertPriceInfos(ops: PriceInfo[]) { await this.insertBulkData(ops, "price_infos", false, `price_infos-${Math.random() * 1000}`); } @@ -363,4 +368,195 @@ export class DuckDb { time: new Date(res.time * tf * 1000).toISOString() })) as VolumeRange[]; } + + async getPools(): Promise { + return (await this.conn.all("SELECT * from pair_infos")).map((data) => data as PairInfoData); + } + + async getPoolByAssetInfos(assetInfos: [AssetInfo, AssetInfo]): Promise { + const firstAssetInfo = parseAssetInfo(assetInfos[0]); + const secondAssetInfo = parseAssetInfo(assetInfos[1]); + return ( + await this.conn.all( + `SELECT * from pair_infos WHERE firstAssetInfo = ? AND secondAssetInfo = ?`, + firstAssetInfo, + secondAssetInfo + ) + ).map((data) => data as PairInfoData)[0]; + } + + async getFeeSwap(payload: GetFeeSwap): Promise { + const { offerDenom, askDenom, startTime, endTime } = payload; + const [feeRightDirection, feeReverseDirection] = await Promise.all([ + this.conn.all( + ` + SELECT + sum(commissionAmount + taxAmount) as totalFee, + FROM swap_ops_data + WHERE timestamp >= ? + AND timestamp <= ? + AND offerDenom = ? + AND askDenom = ? + `, + startTime, + endTime, + offerDenom, + askDenom + ), + this.conn.all( + ` + SELECT + sum(commissionAmount + taxAmount) as totalFee, + FROM swap_ops_data + WHERE timestamp >= ? + AND timestamp <= ? + AND offerDenom = ? + AND askDenom = ? + `, + startTime, + endTime, + askDenom, + offerDenom + ) + ]); + return BigInt(feeRightDirection[0]?.totalFee + feeReverseDirection[0]?.totalFee); + } + + async getFeeLiquidity(payload: GetFeeSwap): Promise { + const { offerDenom, askDenom, startTime, endTime } = payload; + const result = await this.conn.all( + ` + SELECT + sum(taxRate) as totalFee, + FROM lp_ops_data + WHERE timestamp >= ? + AND timestamp <= ? + AND baseTokenDenom = ? + AND quoteTokenDenom = ? + `, + startTime, + endTime, + offerDenom, + askDenom + ); + return BigInt(result[0]?.totalFee ?? 0); + } + + async getVolumeSwap(payload: GetVolumeQuery): Promise { + const { pair, startTime, endTime } = payload; + const result = await this.conn.all( + ` + SELECT + sum(volume) as totalVolume, + FROM swap_ohlcv + WHERE timestamp >= ? + AND timestamp <= ? + AND pair = ? + `, + startTime, + endTime, + pair + ); + return BigInt(result[0]?.totalVolume ?? 0); + } + + async getVolumeLiquidity(payload: GetFeeSwap): Promise { + const { offerDenom, askDenom, startTime, endTime } = payload; + const result = await this.conn.all( + ` + SELECT + sum(baseTokenAmount) as totalVolume, + FROM lp_ops_data + WHERE timestamp >= ? + AND timestamp <= ? + AND baseTokenDenom = ? + AND quoteTokenDenom = ? + `, + startTime, + endTime, + offerDenom, + askDenom + ); + return BigInt(result[0]?.totalVolume ?? 0); + } + + async createLpAmountHistoryTable() { + await this.conn.exec( + `CREATE TABLE IF NOT EXISTS lp_amount_history ( + offerPoolAmount ubigint, + askPoolAmount ubigint, + height uinteger, + timestamp uinteger, + pairAddr varchar, + uniqueKey varchar UNIQUE) + ` + ); + } + + async getLatestLpPoolAmount(pairAddr: string) { + const result = await this.conn.all( + ` + SELECT * FROM lp_amount_history + WHERE pairAddr = ? + ORDER BY height DESC + LIMIT 1 + `, + pairAddr + ); + return result[0] as PoolAmountHistory; + } + + async insertPoolAmountHistory(ops: PoolAmountHistory[]) { + await this.insertBulkData(ops, "lp_amount_history"); + } + + async createAprInfoPair() { + await this.conn.exec( + `CREATE TABLE IF NOT EXISTS pool_apr ( + uniqueKey varchar UNIQUE, + pairAddr varchar, + height uinteger, + totalSupply varchar, + totalBondAmount varchar, + rewardPerSec varchar, + apr double, + ) + ` + ); + } + + async insertPoolAprs(poolAprs: PoolApr[]) { + await this.insertBulkData(poolAprs, "pool_apr"); + } + + async getLatestPoolApr(pairAddr: string): Promise { + const result = await this.conn.all( + ` + SELECT * FROM pool_apr + WHERE pairAddr = ? + ORDER BY height DESC + LIMIT 1 + `, + pairAddr + ); + + return result[0] as PoolApr; + } + + async getApr() { + const result = await this.conn.all( + ` + SELECT p.pairAddr, p.apr + FROM pool_apr p + JOIN ( + SELECT pairAddr, MAX(height) AS max_height + FROM pool_apr + GROUP BY pairAddr + ) max_heights + ON p.pairAddr = max_heights.pairAddr AND p.height = max_heights.max_height + ORDER BY p.height DESC + ` + ); + return result as Pick[]; + } } diff --git a/packages/oraidex-sync/src/helper.ts b/packages/oraidex-sync/src/helper.ts index 84ff99f2..173a9b17 100644 --- a/packages/oraidex-sync/src/helper.ts +++ b/packages/oraidex-sync/src/helper.ts @@ -1,17 +1,19 @@ -import { AssetInfo, SwapOperation } from "@oraichain/oraidex-contracts-sdk"; +import { AssetInfo, CosmWasmClient, OraiswapPairTypes, SwapOperation } from "@oraichain/oraidex-contracts-sdk"; +import { PoolResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapPair.types"; +import { isEqual, maxBy, minBy } from "lodash"; +import { ORAI, atomic, oraiInfo, tenAmountInDecimalSix, truncDecimals, usdtInfo } from "./constants"; +import { DuckDb } from "./db"; import { pairs, pairsOnlyDenom } from "./pairs"; -import { ORAI, atomic, tenAmountInDecimalSix, truncDecimals, usdtCw20Address } from "./constants"; +import { getPriceAssetByUsdt } from "./pool-helper"; import { + LpOpsData, Ohlcv, OraiDexType, PairInfoData, - ProvideLiquidityOperationData, + PoolAmountHistory, SwapDirection, - SwapOperationData, - WithdrawLiquidityOperationData + SwapOperationData } from "./types"; -import { PoolResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapPair.types"; -import { minBy, maxBy } from "lodash"; export function toObject(data: any) { return JSON.parse( @@ -74,9 +76,24 @@ export function concatDataToUniqueKey(data: { return `${data.txheight}-${data.firstDenom}-${data.firstAmount}-${data.secondDenom}-${data.secondAmount}`; } -export function concatOhlcvToUniqueKey(data: { timestamp: number; pair: string; volume: bigint }): string { +export const concatOhlcvToUniqueKey = (data: { timestamp: number; pair: string; volume: bigint }): string => { return `${data.timestamp}-${data.pair}-${data.volume.toString()}`; -} +}; + +export const concatLpHistoryToUniqueKey = (data: { timestamp: number; pairAddr: string }): string => { + return `${data.timestamp}-${data.pairAddr}`; +}; + +export const concatAprHistoryToUniqueKey = (data: { + timestamp: number; + supply: string; + bond: string; + reward: string; + apr: number; + pairAddr: string; +}): string => { + return `${data.timestamp}-${data.pairAddr}-${data.supply}-${data.bond}-${data.reward}-${data.apr}`; +}; export function isoToTimestampNumber(time: string) { return Math.floor(new Date(time).getTime() / 1000); @@ -99,8 +116,6 @@ export function replaceAllNonAlphaBetChar(columnName: string): string { } function parseAssetInfo(info: AssetInfo): string { - // if ("native_token" in info) return info.native_token.denom; - // return info.token.contract_addr; return JSON.stringify(info); } @@ -130,8 +145,6 @@ function findAssetInfoPathToUsdt(info: AssetInfo): AssetInfo[] { // first, check usdt mapped target infos because if we the info pairs with usdt directly then we can easily calculate its price // otherwise, we find orai mapped target infos, which can lead to usdt. // finally, if not paired with orai, then we find recusirvely to find a path leading to usdt token - const usdtInfo = { token: { contract_addr: usdtCw20Address } }; - const oraiInfo = { native_token: { denom: ORAI } }; if (parseAssetInfo(info) === parseAssetInfo(usdtInfo)) return [info]; // means there's no path, the price should be 1 const mappedUsdtInfoList = findMappedTargetedAssetInfo(usdtInfo); if (mappedUsdtInfoList.find((assetInfo) => parseAssetInfo(assetInfo) === parseAssetInfo(info))) @@ -160,18 +173,18 @@ function findPairAddress(pairInfos: PairInfoData[], infos: [AssetInfo, AssetInfo )?.pairAddr; } -function calculatePriceByPool( - basePool: bigint, - quotePool: bigint, +export const calculatePriceByPool = ( + offerPool: bigint, + askPool: bigint, commissionRate?: number, offerAmount?: number -): number { +): number => { const finalOfferAmount = offerAmount || tenAmountInDecimalSix; - let bigIntAmount = Number( - (basePool - (quotePool * basePool) / (quotePool + BigInt(finalOfferAmount))) * BigInt(1 - commissionRate || 0) - ); + let bigIntAmount = + Number(offerPool - (askPool * offerPool) / (askPool + BigInt(finalOfferAmount))) * (1 - commissionRate || 0); + return bigIntAmount / finalOfferAmount; -} +}; export function groupDataByTime(data: any[], timeframe?: number): { [key: string]: any[] } { let ops: { [k: number]: any[] } = {}; @@ -208,26 +221,21 @@ export function roundTime(timeIn: number, timeframe: number): number { } export function isAssetInfoPairReverse(assetInfos: AssetInfo[]): boolean { - if (pairs.find((pair) => JSON.stringify(pair.asset_infos) === JSON.stringify(assetInfos.reverse()))) return true; - return false; + if (pairs.find((pair) => JSON.stringify(pair.asset_infos) === JSON.stringify(assetInfos))) return false; + return true; } /** - * This function will accumulate the lp amount and modify the parameter - * @param data - lp ops. This param will be mutated. + * This function will accumulate the lp amount + * @param data - lp ops & swap ops. * @param poolInfos - pool info data for initial lp accumulation + * @param pairInfos - pool info data from db */ -// TODO: write test cases for this function -export function collectAccumulateLpData( - data: ProvideLiquidityOperationData[] | WithdrawLiquidityOperationData[], - poolInfos: PoolResponse[] -) { +export const collectAccumulateLpAndSwapData = async (data: LpOpsData[], poolInfos: PoolResponse[]) => { let accumulateData: { - [key: string]: { - baseTokenAmount: bigint; - quoteTokenAmount: bigint; - }; + [key: string]: Omit; } = {}; + const duckDb = DuckDb.instances; for (let op of data) { const pool = poolInfos.find( (info) => @@ -235,33 +243,47 @@ export function collectAccumulateLpData( info.assets.some((assetInfo) => parseAssetInfoOnlyDenom(assetInfo.info) === op.quoteTokenDenom) ); if (!pool) continue; - if (op.opType === "withdraw") { + + let baseAmount = BigInt(op.baseTokenAmount); + let quoteAmount = BigInt(op.quoteTokenAmount); + if (op.opType === "withdraw" || op.direction === "Buy") { // reverse sign since withdraw means lp decreases - op.baseTokenReserve = -BigInt(op.baseTokenReserve); - op.quoteTokenReserve = -BigInt(op.quoteTokenReserve); + baseAmount = -baseAmount; + quoteAmount = -quoteAmount; } - const denom = `${op.baseTokenDenom}-${op.quoteTokenDenom}`; - if (!accumulateData[denom]) { + + let assetInfos = pool.assets.map((asset) => asset.info) as [AssetInfo, AssetInfo]; + if (isAssetInfoPairReverse(assetInfos)) assetInfos.reverse(); + const pairInfo = await duckDb.getPoolByAssetInfos(assetInfos); + if (!pairInfo) throw new Error("cannot find pair info when collectAccumulateLpAndSwapData"); + const { pairAddr } = pairInfo; + + if (!accumulateData[pairAddr]) { const initialFirstTokenAmount = parseInt( - pool.assets.find((info) => parseAssetInfoOnlyDenom(info.info) === op.baseTokenDenom).amount + pool.assets.find((asset) => parseAssetInfoOnlyDenom(asset.info) === parseAssetInfoOnlyDenom(assetInfos[0])) + .amount ); const initialSecondTokenAmount = parseInt( - pool.assets.find((info) => parseAssetInfoOnlyDenom(info.info) === op.quoteTokenDenom).amount + pool.assets.find((asset) => parseAssetInfoOnlyDenom(asset.info) === parseAssetInfoOnlyDenom(assetInfos[1])) + .amount ); - accumulateData[denom] = { - baseTokenAmount: BigInt(initialFirstTokenAmount) + BigInt(op.baseTokenReserve), - quoteTokenAmount: BigInt(initialSecondTokenAmount) + BigInt(op.quoteTokenReserve) + + accumulateData[pairAddr] = { + offerPoolAmount: BigInt(initialFirstTokenAmount) + baseAmount, + askPoolAmount: BigInt(initialSecondTokenAmount) + quoteAmount, + height: op.height, + timestamp: op.timestamp }; - op.baseTokenReserve = accumulateData[denom].baseTokenAmount; - op.quoteTokenReserve = accumulateData[denom].quoteTokenAmount; - continue; + } else { + accumulateData[pairAddr].offerPoolAmount += baseAmount; + accumulateData[pairAddr].askPoolAmount += quoteAmount; + accumulateData[pairAddr].height = op.height; + accumulateData[pairAddr].timestamp = op.timestamp; } - accumulateData[denom].baseTokenAmount += BigInt(op.baseTokenReserve); - accumulateData[denom].quoteTokenAmount += BigInt(op.quoteTokenReserve); - op.baseTokenReserve = accumulateData[denom].baseTokenAmount; - op.quoteTokenReserve = accumulateData[denom].quoteTokenAmount; } -} + + return accumulateData; +}; export function removeOpsDuplication(ops: OraiDexType[]): OraiDexType[] { let newOps: OraiDexType[] = []; @@ -276,7 +298,7 @@ export function removeOpsDuplication(ops: OraiDexType[]): OraiDexType[] { * @param swapOps * @returns */ -export function groupSwapOpsByPair(ops: SwapOperationData[]): { [key: string]: SwapOperationData[] } { +export const groupSwapOpsByPair = (ops: SwapOperationData[]): { [key: string]: SwapOperationData[] } => { let opsByPair = {}; for (const op of ops) { const pairIndex = findPairIndexFromDenoms(op.offerDenom, op.askDenom); @@ -289,9 +311,9 @@ export function groupSwapOpsByPair(ops: SwapOperationData[]): { [key: string]: S opsByPair[pair].push(op); } return opsByPair; -} +}; -export function calculateSwapOhlcv(ops: SwapOperationData[], pair: string): Ohlcv { +export const calculateSwapOhlcv = (ops: SwapOperationData[], pair: string): Ohlcv => { const timestamp = ops[0].timestamp; const prices = ops.map((op) => calculateBasePriceFromSwapOp(op)); const open = prices[0]; @@ -315,7 +337,7 @@ export function calculateSwapOhlcv(ops: SwapOperationData[], pair: string): Ohlc low, high }; -} +}; export function buildOhlcv(ops: SwapOperationData[]): Ohlcv[] { let ohlcv: Ohlcv[] = []; @@ -327,14 +349,14 @@ export function buildOhlcv(ops: SwapOperationData[]): Ohlcv[] { return ohlcv; } -export function calculateBasePriceFromSwapOp(op: SwapOperationData): number { +export const calculateBasePriceFromSwapOp = (op: SwapOperationData): number => { if (!op || !op.offerAmount || !op.returnAmount) { return 0; } const offerAmount = op.offerAmount; const askAmount = op.returnAmount; return op.direction === "Buy" ? Number(offerAmount) / Number(askAmount) : Number(askAmount) / Number(offerAmount); -} +}; export function getSwapDirection(offerDenom: string, askDenom: string): SwapDirection { const pair = pairsOnlyDenom.find((pair) => { @@ -343,7 +365,6 @@ export function getSwapDirection(offerDenom: string, askDenom: string): SwapDire if (!pair) { console.error("Cannot find asset infos in list of pairs"); return; - // throw new Error("Cannot find asset infos in list of pairs"); } const assetInfos = pair.asset_infos; // use quote denom as offer then its buy. Quote denom in pairs is the 2nd index in the array @@ -357,34 +378,165 @@ export function findPairIndexFromDenoms(offerDenom: string, askDenom: string): n ); } -// /** -// * -// * @param infos -// * @returns -// */ -// function findUsdOraiInPair(infos: [AssetInfo, AssetInfo]): { -// baseIndex: number; -// targetIndex: number; -// target: AssetInfo; -// } { -// const firstInfo = parseAssetInfoOnlyDenom(infos[0]); -// const secondInfo = parseAssetInfoOnlyDenom(infos[1]); -// if (firstInfo === usdtCw20Address || firstInfo === usdcCw20Address) -// return { baseIndex: 0, targetIndex: 1, target: infos[1] }; -// if (secondInfo === usdtCw20Address || secondInfo === usdcCw20Address) -// return { baseIndex: 1, targetIndex: 0, target: infos[0] }; -// if (firstInfo === ORAI) return { baseIndex: 0, targetIndex: 1, target: infos[1] }; -// if (secondInfo === ORAI) return { baseIndex: 1, targetIndex: 0, target: infos[0] }; -// return { baseIndex: 1, targetIndex: 0, target: infos[0] }; // default we calculate the first info in the asset info list -// } +function getSymbolFromAsset(asset_infos: [AssetInfo, AssetInfo]): string { + const findedPair = pairs.find( + (p) => + p.asset_infos.some( + (assetInfo) => parseAssetInfoOnlyDenom(assetInfo) === parseAssetInfoOnlyDenom(asset_infos[0]) + ) && + p.asset_infos.some((assetInfo) => parseAssetInfoOnlyDenom(assetInfo) === parseAssetInfoOnlyDenom(asset_infos[1])) + ); + if (!findedPair) { + throw new Error(`cannot found pair with asset_infos: ${JSON.stringify(asset_infos)}`); + } + return findedPair.symbols.join("/"); +} + +async function getCosmwasmClient(): Promise { + const rpcUrl = process.env.RPC_URL || "https://rpc.orai.io"; + const client = await CosmWasmClient.connect(rpcUrl); + return client; +} + +export const parsePoolAmount = (poolInfo: OraiswapPairTypes.PoolResponse, trueAsset: AssetInfo): bigint => { + return BigInt(poolInfo.assets.find((asset) => isEqual(asset.info, trueAsset))?.amount || "0"); +}; + +// get liquidity of pair from assetInfos +export const getPairLiquidity = async (poolInfo: PairInfoData): Promise => { + const duckDb = DuckDb.instances; + const poolAmount = await duckDb.getLatestLpPoolAmount(poolInfo.pairAddr); + if (!poolAmount || !poolAmount.askPoolAmount || !poolAmount.offerPoolAmount) return 0; + + const baseAssetInfo = JSON.parse(poolInfo.firstAssetInfo); + const priceBaseAssetInUsdt = await getPriceAssetByUsdt(baseAssetInfo); + return priceBaseAssetInUsdt * Number(poolAmount.offerPoolAmount) * 2; +}; + +/** + * + * @param time + * @param tf in seconds + * @returns + */ +function getSpecificDateBeforeNow(time: Date, tf: number) { + const timeInMs = tf * 1000; + const dateBeforeNow = new Date(time.getTime() - timeInMs); + return dateBeforeNow; +} + +function convertDateToSecond(date: Date): number { + return Math.round(date.valueOf() / 1000); +} + +// <===== start get volume pairs ===== +export const getVolumePairByAsset = async ( + [baseDenom, quoteDenom]: [string, string], + startTime: Date, + endTime: Date +): Promise => { + const duckDb = DuckDb.instances; + const pair = `${baseDenom}-${quoteDenom}`; + const [volumeSwapPairInBaseAsset, volumeLiquidityPairInBaseAsset] = await Promise.all([ + duckDb.getVolumeSwap({ + pair, + startTime: convertDateToSecond(startTime), + endTime: convertDateToSecond(endTime) + }), + duckDb.getVolumeLiquidity({ + offerDenom: baseDenom, + askDenom: quoteDenom, + startTime: convertDateToSecond(startTime), + endTime: convertDateToSecond(endTime) + }) + ]); + return volumeSwapPairInBaseAsset + volumeLiquidityPairInBaseAsset; +}; + +export const getVolumePairByUsdt = async ( + [baseAssetInfo, quoteAssetInfo]: [AssetInfo, AssetInfo], + startTime: Date, + endTime: Date +): Promise => { + const [baseDenom, quoteDenom] = [parseAssetInfoOnlyDenom(baseAssetInfo), parseAssetInfoOnlyDenom(quoteAssetInfo)]; + const volumePairInBaseAsset = await getVolumePairByAsset([baseDenom, quoteDenom], startTime, endTime); + const priceBaseAssetInUsdt = await getPriceAssetByUsdt(baseAssetInfo); + const volumeInUsdt = priceBaseAssetInUsdt * Number(volumePairInBaseAsset); + return BigInt(Math.round(volumeInUsdt)); +}; + +async function getAllVolume24h(): Promise { + const tf = 24 * 60 * 60; // second of 24h + const currentDate = new Date(); + const oneDayBeforeNow = getSpecificDateBeforeNow(new Date(), tf); + const allVolumes = await Promise.all( + pairs.map((pair) => getVolumePairByUsdt(pair.asset_infos, oneDayBeforeNow, currentDate)) + ); + return allVolumes; +} +// ===== end get volume pairs =====> + +// <==== start get fee pair ==== +export const getFeePair = async ( + asset_infos: [AssetInfo, AssetInfo], + startTime: Date, + endTime: Date +): Promise => { + const duckDb = DuckDb.instances; + const [swapFee, liquidityFee] = await Promise.all([ + duckDb.getFeeSwap({ + offerDenom: parseAssetInfoOnlyDenom(asset_infos[0]), + askDenom: parseAssetInfoOnlyDenom(asset_infos[1]), + startTime: convertDateToSecond(startTime), + endTime: convertDateToSecond(endTime) + }), + duckDb.getFeeLiquidity({ + offerDenom: parseAssetInfoOnlyDenom(asset_infos[0]), + askDenom: parseAssetInfoOnlyDenom(asset_infos[1]), + startTime: convertDateToSecond(startTime), + endTime: convertDateToSecond(endTime) + }) + ]); + return swapFee + liquidityFee; +}; + +async function getAllFees(): Promise { + const tf = 7 * 24 * 60 * 60; // second of 7 days + const currentDate = new Date(); + const oneWeekBeforeNow = getSpecificDateBeforeNow(new Date(), tf); + const allFees = await Promise.all(pairs.map((pair) => getFeePair(pair.asset_infos, oneWeekBeforeNow, currentDate))); + return allFees; +} +// ==== end get fee pair ====> + +export const parsePairDenomToAssetInfo = ([baseDenom, quoteDenom]: [string, string]): [AssetInfo, AssetInfo] => { + const pair = pairs.find( + (pair) => + parseAssetInfoOnlyDenom(pair.asset_infos[0]) === baseDenom && + parseAssetInfoOnlyDenom(pair.asset_infos[1]) === quoteDenom + ); + if (!pair) throw new Error(`cannot find pair for ${baseDenom}-$${quoteDenom}`); + return pair.asset_infos; +}; + +export function getDate24hBeforeNow(time: Date) { + const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000; // 24 hours in milliseconds + const date24hBeforeNow = new Date(time.getTime() - twentyFourHoursInMilliseconds); + return date24hBeforeNow; +} export { - findMappedTargetedAssetInfo, + convertDateToSecond, + delay, findAssetInfoPathToUsdt, + findMappedTargetedAssetInfo, + findPairAddress, generateSwapOperations, + getAllFees, + getAllVolume24h, + getCosmwasmClient, + getSpecificDateBeforeNow, + getSymbolFromAsset, parseAssetInfo, - parseAssetInfoOnlyDenom, - delay, - findPairAddress, - calculatePriceByPool + parseAssetInfoOnlyDenom }; diff --git a/packages/oraidex-sync/src/index.ts b/packages/oraidex-sync/src/index.ts index b5e6897b..39a7a002 100644 --- a/packages/oraidex-sync/src/index.ts +++ b/packages/oraidex-sync/src/index.ts @@ -1,26 +1,36 @@ +import { SyncData, Txs, WriteData } from "@oraichain/cosmos-rpc-sync"; import "dotenv/config"; -import { parseAssetInfo, parseTxs } from "./tx-parsing"; import { DuckDb } from "./db"; -import { WriteData, SyncData, Txs } from "@oraichain/cosmos-rpc-sync"; -import { CosmWasmClient, OraiswapFactoryQueryClient, PairInfo } from "@oraichain/oraidex-contracts-sdk"; import { - ProvideLiquidityOperationData, - TxAnlysisResult, - WithdrawLiquidityOperationData, + collectAccumulateLpAndSwapData, + concatLpHistoryToUniqueKey, + getPairLiquidity, + getSymbolFromAsset +} from "./helper"; +import { + calculateAprResult, + fetchAprResult, + getAllPairInfos, + getPairByAssetInfos, + getPoolInfos, + handleEventApr +} from "./pool-helper"; +import { parseAssetInfo, parseTxs } from "./tx-parsing"; +import { + Env, InitialData, + LpOpsData, PairInfoData, - Env + PoolApr, + ProvideLiquidityOperationData, + SwapOperationData, + TxAnlysisResult, + WithdrawLiquidityOperationData } from "./types"; -import { MulticallQueryClient } from "@oraichain/common-contracts-sdk"; -import { PoolResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapPair.types"; -import { getAllPairInfos, getPoolInfos } from "./query"; -import { collectAccumulateLpData } from "./helper"; class WriteOrders extends WriteData { - private firstWrite: boolean; constructor(private duckDb: DuckDb, private rpcUrl: string, private env: Env, private initialData: InitialData) { super(); - this.firstWrite = true; } private async insertParsedTxs(txs: TxAnlysisResult) { @@ -33,59 +43,79 @@ class WriteOrders extends WriteData { await this.duckDb.insertLpOps(txs.withdrawLiquidityOpsData); } - private async getPoolInfos(pairAddrs: string[], wantedHeight?: number): Promise { - // adjust the query height to get data from the past - const cosmwasmClient = await CosmWasmClient.connect(this.rpcUrl); - cosmwasmClient.setQueryClientWithHeight(wantedHeight); - const multicall = new MulticallQueryClient( - cosmwasmClient, - this.env.MULTICALL_CONTRACT_ADDRESS || "orai1q7x644gmf7h8u8y6y8t9z9nnwl8djkmspypr6mxavsk9ual7dj0sxpmgwd" - ); - const res = await getPoolInfos(pairAddrs, multicall); - // reset query client to latest for other functions to call - return res; - } + private async accumulatePoolAmount( + lpData: ProvideLiquidityOperationData[] | WithdrawLiquidityOperationData[], + swapData: SwapOperationData[] + ) { + if (lpData.length === 0 && swapData.length === 0) return; - private async accumulatePoolAmount(data: ProvideLiquidityOperationData[] | WithdrawLiquidityOperationData[]) { - if (data.length === 0) return; // guard. If theres no data then we wont process anything const pairInfos = await this.duckDb.queryPairInfos(); - const poolInfos = await this.getPoolInfos( + const minSwapTxHeight = swapData[0]?.txheight; + const minLpTxHeight = lpData[0]?.txheight; + let minTxHeight; + if (minSwapTxHeight && minLpTxHeight) { + minTxHeight = Math.min(minSwapTxHeight, minLpTxHeight); + } else minTxHeight = minSwapTxHeight ?? minLpTxHeight; + + const poolInfos = await getPoolInfos( pairInfos.map((pair) => pair.pairAddr), - data[0].txheight // assume data is sorted by height and timestamp + minTxHeight // assume data is sorted by height and timestamp ); - collectAccumulateLpData(data, poolInfos); + const lpOpsData: LpOpsData[] = [ + ...lpData.map((item) => { + return { + baseTokenAmount: item.baseTokenAmount, + baseTokenDenom: item.baseTokenDenom, + quoteTokenAmount: item.quoteTokenAmount, + quoteTokenDenom: item.quoteTokenDenom, + opType: item.opType, + timestamp: item.timestamp, + height: item.txheight + } as LpOpsData; + }), + ...swapData.map((item) => { + return { + baseTokenAmount: item.offerAmount, + baseTokenDenom: item.offerDenom, + quoteTokenAmount: -item.returnAmount, // reverse sign because we assume first case is sell, check buy later. + quoteTokenDenom: item.askDenom, + direction: item.direction, + height: item.txheight, + timestamp: item.timestamp + } as LpOpsData; + }) + ]; + + const accumulatedData = await collectAccumulateLpAndSwapData(lpOpsData, poolInfos); + const poolAmountHitories = pairInfos.reduce((accumulator, { pairAddr }) => { + if (accumulatedData[pairAddr]) { + accumulator.push({ + ...accumulatedData[pairAddr], + pairAddr, + uniqueKey: concatLpHistoryToUniqueKey({ + timestamp: accumulatedData[pairAddr].timestamp, + pairAddr + }) + }); + } + return accumulator; + }, []); + await this.duckDb.insertPoolAmountHistory(poolAmountHitories); } async process(chunk: any): Promise { try { - // // first time calling of the application then we query past data and be ready to store them into the db for prefix sum - // // this helps the flow go smoothly and remove dependency between different streams - // if (this.firstWrite) { - // console.log("initial data: ", this.initialData); - // const { height, time } = this.initialData.blockHeader; - // await this.duckDb.insertPriceInfos( - // this.initialData.tokenPrices.map( - // (tokenPrice) => - // ({ - // txheight: height, - // timestamp: time, - // assetInfo: parseAssetInfo(tokenPrice.info), - // price: parseInt(tokenPrice.amount) - // } as PriceInfo) - // ) - // ); - // this.firstWrite = false; - // } const { txs, offset: newOffset } = chunk as Txs; const currentOffset = await this.duckDb.loadHeightSnapshot(); // edge case. If no new block has been found, then we skip processing to prevent duplication handling if (currentOffset === newOffset) return true; - let result = parseTxs(txs); + let result = await parseTxs(txs); - // accumulate liquidity pool amount - await this.accumulatePoolAmount([...result.provideLiquidityOpsData, ...result.withdrawLiquidityOpsData]); - // process volume infos to insert price - // result.volumeInfos = insertVolumeInfos(result.swapOpsData); + const lpOpsData = [...result.provideLiquidityOpsData, ...result.withdrawLiquidityOpsData]; + // accumulate liquidity pool amount via provide/withdraw liquidity and swap ops + await this.accumulatePoolAmount(lpOpsData, [...result.swapOpsData]); + + await handleEventApr(txs, lpOpsData, newOffset); // collect the latest offer & ask volume to accumulate the results // insert txs @@ -106,45 +136,62 @@ class WriteOrders extends WriteData { } class OraiDexSync { - protected constructor( - private readonly duckDb: DuckDb, - private readonly rpcUrl: string, - private cosmwasmClient: CosmWasmClient, - private readonly env: Env - ) {} + protected constructor(private readonly duckDb: DuckDb, private readonly rpcUrl: string, private readonly env: Env) {} public static async create(duckDb: DuckDb, rpcUrl: string, env: Env): Promise { - const cosmwasmClient = await CosmWasmClient.connect(rpcUrl); - return new OraiDexSync(duckDb, rpcUrl, cosmwasmClient, env); - } - - private async getAllPairInfos(): Promise { - const firstFactoryClient = new OraiswapFactoryQueryClient( - this.cosmwasmClient, - this.env.FACTORY_CONTACT_ADDRESS_V1 || "orai1hemdkz4xx9kukgrunxu3yw0nvpyxf34v82d2c8" - ); - const secondFactoryClient = new OraiswapFactoryQueryClient( - this.cosmwasmClient, - this.env.FACTORY_CONTACT_ADDRESS_V2 || "orai167r4ut7avvgpp3rlzksz6vw5spmykluzagvmj3ht845fjschwugqjsqhst" - ); - return getAllPairInfos(firstFactoryClient, secondFactoryClient); + return new OraiDexSync(duckDb, rpcUrl, env); } private async updateLatestPairInfos() { - const pairInfos = await this.getAllPairInfos(); - await this.duckDb.insertPairInfos( - pairInfos.map( - (pair) => - ({ - firstAssetInfo: parseAssetInfo(pair.asset_infos[0]), - secondAssetInfo: parseAssetInfo(pair.asset_infos[1]), + try { + console.time("timer-updateLatestPairInfos"); + const pairInfos = await getAllPairInfos(); + + const allPools = await this.duckDb.getPools(); + if (allPools.length > 0) return; + await this.duckDb.insertPairInfos( + pairInfos.map((pair, index) => { + const symbols = getSymbolFromAsset(pair.asset_infos); + const pairMapping = getPairByAssetInfos(pair.asset_infos); + return { + firstAssetInfo: parseAssetInfo(pairMapping.asset_infos[0]), + secondAssetInfo: parseAssetInfo(pairMapping.asset_infos[1]), commissionRate: pair.commission_rate, pairAddr: pair.contract_addr, liquidityAddr: pair.liquidity_token, - oracleAddr: pair.oracle_addr - } as PairInfoData) - ) - ); + oracleAddr: pair.oracle_addr, + symbols, + fromIconUrl: "url1", + toIconUrl: "url2" + } as PairInfoData; + }) + ); + console.timeEnd("timer-updateLatestPairInfos"); + } catch (error) { + console.log("error in updateLatestPairInfos: ", error); + } + } + + private async updateLatestPoolApr(height: number) { + const pools = await this.duckDb.getPools(); + const allLiquidities = (await Promise.allSettled(pools.map((pair) => getPairLiquidity(pair)))).map((result) => { + if (result.status === "fulfilled") return result.value; + else console.error("error get allLiquidities: ", result.reason); + }); + const { allAprs, allTotalSupplies, allBondAmounts, allRewardPerSec } = await fetchAprResult(pools, allLiquidities); + + const poolAprs = allAprs.map((apr, index) => { + return { + uniqueKey: concatLpHistoryToUniqueKey({ timestamp: height, pairAddr: pools[index].pairAddr }), + pairAddr: pools[index].pairAddr, + height, + totalSupply: allTotalSupplies[index], + totalBondAmount: allBondAmounts[index], + rewardPerSec: JSON.stringify(allRewardPerSec[index]), + apr + } as PoolApr; + }); + await this.duckDb.insertPoolAprs(poolAprs); } public async sync() { @@ -154,30 +201,28 @@ class OraiDexSync { this.duckDb.createLiquidityOpsTable(), this.duckDb.createSwapOpsTable(), this.duckDb.createPairInfosTable(), - // this.duckDb.createPriceInfoTable(), - this.duckDb.createSwapOhlcv() + this.duckDb.createSwapOhlcv(), + this.duckDb.createLpAmountHistoryTable(), + this.duckDb.createAprInfoPair() ]); let currentInd = await this.duckDb.loadHeightSnapshot(); let initialData: InitialData = { tokenPrices: [], blockHeader: undefined }; const initialSyncHeight = parseInt(process.env.INITIAL_SYNC_HEIGHT) || 12388825; - // // if its' the first time, then we use the height 12388825 since its the safe height for the rpc nodes to include timestamp & new indexing logic + // if its' the first time, then we use the height 12388825 since its the safe height for the rpc nodes to include timestamp & new indexing logic if (currentInd <= initialSyncHeight) { currentInd = initialSyncHeight; } console.log("current ind: ", currentInd); - - // const tokenPrices = await Promise.all( - // extractUniqueAndFlatten(pairs).map((info) => this.simulateSwapPrice(info, currentInd)) - // ); - // const initialBlockHeader = (await this.cosmwasmClient.getBlock(currentInd)).header; - // initialData.tokenPrices = tokenPrices; - // initialData.blockHeader = initialBlockHeader; await this.updateLatestPairInfos(); + + // update apr in the first time + await this.updateLatestPoolApr(currentInd); + new SyncData({ offset: currentInd, rpcUrl: this.rpcUrl, queryTags: [], - limit: parseInt(process.env.LIMIT) || 100, + limit: 1000, maxThreadLevel: parseInt(process.env.MAX_THREAD_LEVEL) || 3, interval: 5000 }).pipe(new WriteOrders(this.duckDb, this.rpcUrl, this.env, initialData)); @@ -188,21 +233,19 @@ class OraiDexSync { } // async function initSync() { -// const duckDb = await DuckDb.create(process.env.DUCKDB_PROD_FILENAME || "oraidex-sync-data"); -// const oraidexSync = await OraiDexSync.create( -// duckDb, -// process.env.RPC_URL || "https://rpc.orai.io", -// process.env as any -// ); +// const duckDb = await DuckDb.create("oraidex-only-sync-data"); +// const oraidexSync = await OraiDexSync.create(duckDb, "http://35.237.59.125:26657", process.env as any); // oraidexSync.sync(); // } // initSync(); + export { OraiDexSync }; -export * from "./types"; -export * from "./query"; -export * from "./helper"; +export * from "./constants"; export * from "./db"; +export * from "./helper"; export * from "./pairs"; -export * from "./constants"; +export * from "./pool-helper"; +export * from "./query"; +export * from "./types"; diff --git a/packages/oraidex-sync/src/pairs.ts b/packages/oraidex-sync/src/pairs.ts index 0226e824..6d6cdaee 100644 --- a/packages/oraidex-sync/src/pairs.ts +++ b/packages/oraidex-sync/src/pairs.ts @@ -21,11 +21,13 @@ import { PairMapping } from "./types"; export const pairs: PairMapping[] = [ { asset_infos: [{ token: { contract_addr: airiCw20Adress } }, { native_token: { denom: ORAI } }], - symbols: ["AIRI", "ORAI"] + symbols: ["AIRI", "ORAI"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: oraixCw20Address } }, { native_token: { denom: ORAI } }], - symbols: ["ORAIX", "ORAI"] + symbols: ["ORAIX", "ORAI"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: scOraiCw20Address } }, { native_token: { denom: ORAI } }], @@ -33,15 +35,18 @@ export const pairs: PairMapping[] = [ }, { asset_infos: [{ native_token: { denom: ORAI } }, { native_token: { denom: atomIbcDenom } }], - symbols: ["ORAI", "ATOM"] + symbols: ["ORAI", "ATOM"], + factoryV1: true }, { asset_infos: [{ native_token: { denom: ORAI } }, { token: { contract_addr: usdtCw20Address } }], - symbols: ["ORAI", "USDT"] + symbols: ["ORAI", "USDT"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: kwtCw20Address } }, { native_token: { denom: ORAI } }], - symbols: ["KWT", "ORAI"] + symbols: ["KWT", "ORAI"], + factoryV1: true }, { asset_infos: [ @@ -50,11 +55,13 @@ export const pairs: PairMapping[] = [ native_token: { denom: osmosisIbcDenom } } ], - symbols: ["ORAI", "OSMO"] + symbols: ["ORAI", "OSMO"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: milkyCw20Address } }, { token: { contract_addr: usdtCw20Address } }], - symbols: ["MILKY", "USDT"] + symbols: ["MILKY", "USDT"], + factoryV1: true }, { asset_infos: [{ native_token: { denom: ORAI } }, { token: { contract_addr: usdcCw20Address } }], @@ -101,3 +108,19 @@ export const uniqueInfos = extractUniqueAndFlatten(pairs); export const oraiUsdtPairOnlyDenom = pairsOnlyDenom.find( (pair) => JSON.stringify(pair.asset_infos) === JSON.stringify([ORAI, usdtCw20Address]) ).asset_infos; + +function parseAssetInfoOnlyDenom1(info: AssetInfo): string { + if ("native_token" in info) return info.native_token.denom; + return info.token.contract_addr; +} + +const getStakingAssetInfo = (assetInfos: AssetInfo[]): AssetInfo => { + return parseAssetInfoOnlyDenom1(assetInfos[0]) === ORAI ? assetInfos[1] : assetInfos[0]; +}; + +export const pairWithStakingAsset = pairs.map((pair) => { + return { + ...pair, + stakingAssetInfo: getStakingAssetInfo(pair.asset_infos) + }; +}); diff --git a/packages/oraidex-sync/src/pool-helper.ts b/packages/oraidex-sync/src/pool-helper.ts new file mode 100644 index 00000000..5234fb62 --- /dev/null +++ b/packages/oraidex-sync/src/pool-helper.ts @@ -0,0 +1,427 @@ +import { MulticallQueryClient } from "@oraichain/common-contracts-sdk"; +import { Tx } from "@oraichain/cosmos-rpc-sync"; +import { + Asset, + AssetInfo, + OraiswapFactoryQueryClient, + OraiswapPairQueryClient, + OraiswapStakingTypes, + PairInfo +} from "@oraichain/oraidex-contracts-sdk"; +import { PoolResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapPair.types"; +import { isEqual } from "lodash"; +import { ORAI, ORAIXOCH_INFO, SEC_PER_YEAR, atomic, network, oraiInfo, usdtInfo } from "./constants"; +import { + calculatePriceByPool, + getCosmwasmClient, + isAssetInfoPairReverse, + parseAssetInfoOnlyDenom, + validateNumber +} from "./helper"; +import { DuckDb, concatAprHistoryToUniqueKey, getPairLiquidity, parsePairDenomToAssetInfo } from "./index"; +import { pairWithStakingAsset, pairs } from "./pairs"; +import { + fetchAllRewardPerSecInfos, + fetchAllTokenAssetPools, + fetchTokenInfos, + queryAllPairInfos, + queryPoolInfos +} from "./query"; +import { processEventApr } from "./tx-parsing"; +import { PairInfoData, PairMapping, ProvideLiquidityOperationData } from "./types"; +// use this type to determine the ratio of price of base to the quote or vice versa +export type RatioDirection = "base_in_quote" | "quote_in_base"; + +/** + * Check pool if has native token is not ORAI -> has fee + * @returns boolean + */ +export const isPoolHasFee = (assetInfos: [AssetInfo, AssetInfo]): boolean => { + let hasNative = false; + for (const asset of assetInfos) { + if ("native_token" in asset) { + hasNative = true; + if (asset.native_token.denom === "orai") { + return false; + } + } + } + if (hasNative) return true; + return false; +}; + +export const getPoolInfos = async (pairAddrs: string[], wantedHeight?: number): Promise => { + // adjust the query height to get data from the past + const cosmwasmClient = await getCosmwasmClient(); + cosmwasmClient.setQueryClientWithHeight(wantedHeight); + const multicall = new MulticallQueryClient(cosmwasmClient, network.multicall); + const res = await queryPoolInfos(pairAddrs, multicall); + return res; +}; + +export const getPairByAssetInfos = (assetInfos: [AssetInfo, AssetInfo]): PairMapping => { + return pairs.find((pair) => { + const [baseAsset, quoteAsset] = pair.asset_infos; + const denoms = [parseAssetInfoOnlyDenom(baseAsset), parseAssetInfoOnlyDenom(quoteAsset)]; + return ( + denoms.some((denom) => denom === parseAssetInfoOnlyDenom(assetInfos[0])) && + denoms.some((denom) => denom === parseAssetInfoOnlyDenom(assetInfos[1])) + ); + }); +}; + +// get price ORAI in USDT base on ORAI/USDT pool. +// async function getOraiPrice(): Promise { +export const getOraiPrice = async (): Promise => { + const oraiUsdtPair = getPairByAssetInfos([oraiInfo, usdtInfo]); + const ratioDirection: RatioDirection = + parseAssetInfoOnlyDenom(oraiUsdtPair.asset_infos[0]) === ORAI ? "base_in_quote" : "quote_in_base"; + return getPriceByAsset([oraiInfo, usdtInfo], ratioDirection); +}; + +export const getPriceByAsset = async ( + assetInfos: [AssetInfo, AssetInfo], + ratioDirection: RatioDirection +): Promise => { + const duckDb = DuckDb.instances; + const poolInfo = await duckDb.getPoolByAssetInfos(assetInfos); + if (!poolInfo) return 0; + const poolAmount = await duckDb.getLatestLpPoolAmount(poolInfo.pairAddr); + if (!poolAmount || !poolAmount.askPoolAmount || !poolAmount.offerPoolAmount) return 0; + // offer: orai, ask: usdt -> price offer in ask = calculatePriceByPool([ask, offer]) + // offer: orai, ask: atom -> price ask in offer = calculatePriceByPool([offer, ask]) + const basePrice = calculatePriceByPool( + BigInt(poolAmount.askPoolAmount), + BigInt(poolAmount.offerPoolAmount), + +poolInfo.commissionRate + ); + return ratioDirection === "base_in_quote" ? basePrice : 1 / basePrice; +}; + +/** + * @param asset + * asset is: + * 1, usdt=1, + * 2, orai=getOraiPrice, + * 3, pair with usdt: getPriceByAsset, + * 4, pair with orai: get price in orai * price orai in usdt, + * 5, otherwise, pair with orai || usdt: find pair of input asset vs other asset that mapped with: + * 5.1, orai (ex: scAtom -> scAtom/Atom -> Atom/orai -> step 4) + * 5.2, usdt: this case does not occurs. + * @returns price asset by USDT + */ +export const getPriceAssetByUsdt = async (asset: AssetInfo): Promise => { + if (parseAssetInfoOnlyDenom(asset) === parseAssetInfoOnlyDenom(usdtInfo)) return 1; + if (parseAssetInfoOnlyDenom(asset) === parseAssetInfoOnlyDenom(oraiInfo)) return await getOraiPrice(); + let foundPair: PairMapping; + + // find pair map with usdt + foundPair = getPairByAssetInfos([asset, usdtInfo]); + if (foundPair) { + // assume asset mapped with usdt should be base asset + return await getPriceByAsset(foundPair.asset_infos, "base_in_quote"); + } + + // find pair map with orai + let priceInOrai = 0; + foundPair = getPairByAssetInfos([asset, oraiInfo]); + if (foundPair) { + const ratioDirection: RatioDirection = + parseAssetInfoOnlyDenom(foundPair.asset_infos[0]) === ORAI ? "quote_in_base" : "base_in_quote"; + priceInOrai = await getPriceByAsset(foundPair.asset_infos, ratioDirection); + } else { + // case 5.1 + const pairWithAsset = pairs.find((pair) => + pair.asset_infos.some((info) => parseAssetInfoOnlyDenom(info) === parseAssetInfoOnlyDenom(asset)) + ); + const otherAssetIndex = pairWithAsset.asset_infos.findIndex( + (item) => parseAssetInfoOnlyDenom(item) !== parseAssetInfoOnlyDenom(asset) + ); + const priceAssetVsOtherAsset = await getPriceByAsset( + pairWithAsset.asset_infos, + otherAssetIndex === 1 ? "base_in_quote" : "quote_in_base" + ); + const pairOtherAssetVsOrai = getPairByAssetInfos([pairWithAsset.asset_infos[otherAssetIndex], oraiInfo]); + const ratioDirection: RatioDirection = + parseAssetInfoOnlyDenom(pairOtherAssetVsOrai.asset_infos[0]) === ORAI ? "quote_in_base" : "base_in_quote"; + priceInOrai = priceAssetVsOtherAsset * (await getPriceByAsset(pairOtherAssetVsOrai.asset_infos, ratioDirection)); + } + + const priceOraiInUsdt = await getOraiPrice(); + return priceInOrai * priceOraiInUsdt; +}; + +export const convertFeeAssetToUsdt = async (fee: Asset | null): Promise => { + if (!fee) return 0; + const priceInUsdt = await getPriceAssetByUsdt(fee.info); + return priceInUsdt * +fee.amount; +}; + +export const calculateFeeByAsset = (asset: Asset, shareRatio: number): Asset => { + const TAX_CAP = 10 ** 6; + const TAX_RATE = 0.3; + // just native_token not ORAI has fee + if (!("native_token" in asset.info)) return null; + const amount = +asset.amount; + const refundAmount = amount * shareRatio; + const fee = Math.min(refundAmount - (refundAmount * 1) / (TAX_RATE + 1), TAX_CAP); + return { + amount: fee.toString(), + info: asset.info + }; +}; + +/** + * First, calculate fee by offer asset & ask asset + * then, calculate fee of those asset to ORAI + * finally, convert this fee in ORAI to USDT. + * @param pair + * @param txHeight + * @param withdrawnShare + * @returns fee in USDT + */ +export const calculateLiquidityFee = async ( + pair: PairInfoData, + txHeight: number, + withdrawnShare: number +): Promise => { + const cosmwasmClient = await getCosmwasmClient(); + cosmwasmClient.setQueryClientWithHeight(txHeight); + + const pairContract = new OraiswapPairQueryClient(cosmwasmClient, pair.pairAddr); + const poolInfo = await pairContract.pool(); + const totalShare = +poolInfo.total_share; + const shareRatio = withdrawnShare / totalShare; + + const [feeByAssetFrom, feeByAssetTo] = [ + calculateFeeByAsset(poolInfo.assets[0], shareRatio), + calculateFeeByAsset(poolInfo.assets[1], shareRatio) + ]; + + const feeByUsdt = (await convertFeeAssetToUsdt(feeByAssetFrom)) + (await convertFeeAssetToUsdt(feeByAssetTo)); + return BigInt(Math.round(feeByUsdt)); +}; + +// <==== calculate APR ==== +export const calculateAprResult = async ( + allLiquidities: number[], + allTotalSupplies: string[], + allBondAmounts: string[], + allRewardPerSec: OraiswapStakingTypes.RewardsPerSecResponse[] +): Promise => { + let aprResult = []; + let ind = 0; + for (const _pair of pairs) { + const liquidityAmount = allLiquidities[ind] * Math.pow(10, -6); + const totalBondAmount = allBondAmounts[ind]; + const tokenSupply = allTotalSupplies[ind]; + const rewardsPerSecData = allRewardPerSec[ind]; + if (!totalBondAmount || !tokenSupply || !rewardsPerSecData) continue; + + const bondValue = (validateNumber(totalBondAmount) * liquidityAmount) / validateNumber(tokenSupply); + + let rewardsPerYearValue = 0; + for (const { amount, info } of rewardsPerSecData.assets) { + // NOTE: current hardcode price token xOCH: $0.4 + const priceAssetInUsdt = isEqual(info, ORAIXOCH_INFO) ? 0.4 : await getPriceAssetByUsdt(info); + rewardsPerYearValue += (SEC_PER_YEAR * validateNumber(amount) * priceAssetInUsdt) / atomic; + } + aprResult[ind] = (100 * rewardsPerYearValue) / bondValue || 0; + ind += 1; + } + return aprResult; +}; + +export const getStakingAssetInfo = (assetInfos: AssetInfo[]): AssetInfo => { + if (isAssetInfoPairReverse(assetInfos)) assetInfos.reverse(); + return parseAssetInfoOnlyDenom(assetInfos[0]) === ORAI ? assetInfos[1] : assetInfos[0]; +}; + +export const fetchAprResult = async (pairInfos: PairInfoData[], allLiquidities: number[]) => { + const assetTokens = pairInfos.map((pair) => + getStakingAssetInfo([JSON.parse(pair.firstAssetInfo), JSON.parse(pair.secondAssetInfo)]) + ); + const liquidityAddrs = pairInfos.map((pair) => pair.liquidityAddr); + try { + const [allTokenInfo, allLpTokenAsset, allRewardPerSec] = await Promise.all([ + fetchTokenInfos(liquidityAddrs), + fetchAllTokenAssetPools(assetTokens), + fetchAllRewardPerSecInfos(assetTokens) + ]); + const allTotalSupplies = allTokenInfo.map((info) => info.total_supply); + const allBondAmounts = allLpTokenAsset.map((info) => info.total_bond_amount); + const allAprs = await calculateAprResult(allLiquidities, allTotalSupplies, allBondAmounts, allRewardPerSec); + return { + allTotalSupplies, + allBondAmounts, + allRewardPerSec, + allAprs + }; + } catch (error) { + console.log({ errorFetchAprResult: error }); + } +}; + +// ==== end of calculate APR ====> + +export const getAllPairInfos = async (): Promise => { + const cosmwasmClient = await getCosmwasmClient(); + const firstFactoryClient = new OraiswapFactoryQueryClient(cosmwasmClient, network.factory); + const secondFactoryClient = new OraiswapFactoryQueryClient(cosmwasmClient, network.factory_v2); + return queryAllPairInfos(firstFactoryClient, secondFactoryClient); +}; + +export const triggerCalculateApr = async (assetInfos: [AssetInfo, AssetInfo][], newOffset: number) => { + // get all infos relate to apr in duckdb from apr table -> call to calculateAprResult + if (assetInfos.length === 0) return; + const duckDb = DuckDb.instances; + const pools = await Promise.all(assetInfos.map((infos) => duckDb.getPoolByAssetInfos(infos))); + + const allLiquidities = (await Promise.allSettled(pools.map((pair) => getPairLiquidity(pair)))).map((result) => { + if (result.status === "fulfilled") return result.value; + else console.error("error get allLiquidities: ", result.reason); + }); + + const poolAprInfos = await Promise.all(pools.map((pool) => duckDb.getLatestPoolApr(pool.pairAddr))); + const allTotalSupplies = poolAprInfos.map((item) => item.totalSupply); + const allBondAmounts = poolAprInfos.map((info) => info.totalBondAmount); + const allRewardPerSecs = poolAprInfos.map((info) => (info.rewardPerSec ? JSON.parse(info.rewardPerSec) : null)); + + const APRs = await calculateAprResult(allLiquidities, allTotalSupplies, allBondAmounts, allRewardPerSecs); + console.dir({ APRs }, { depth: null }); + const newPoolAprs = poolAprInfos.map((poolApr, index) => { + return { + ...poolApr, + height: newOffset, + apr: APRs[index], + uniqueKey: concatAprHistoryToUniqueKey({ + timestamp: Date.now(), + supply: allTotalSupplies[index], + bond: allBondAmounts[index], + reward: allRewardPerSecs[index], + apr: APRs[index], + pairAddr: pools[index].pairAddr + }) + }; + }); + await duckDb.insertPoolAprs(newPoolAprs); +}; + +export type TypeInfoRelatedApr = "totalSupply" | "totalBondAmount" | "rewardPerSec"; +export const refetchInfoApr = async ( + type: TypeInfoRelatedApr, + assetInfos: [AssetInfo, AssetInfo][], + height: number +) => { + if (assetInfos.length === 0) return; + const duckDb = DuckDb.instances; + const pools = await Promise.all(assetInfos.map((assetInfo) => duckDb.getPoolByAssetInfos(assetInfo))); + const stakingAssetInfo = pools.map((pair) => + getStakingAssetInfo([JSON.parse(pair.firstAssetInfo), JSON.parse(pair.secondAssetInfo)]) + ); + let newInfos; + switch (type) { + case "totalSupply": { + newInfos = await refetchTotalSupplies(pools); + break; + } + case "totalBondAmount": { + newInfos = await refetchTotalBond(stakingAssetInfo); + break; + } + case "rewardPerSec": { + newInfos = await refetchRewardPerSecInfos(stakingAssetInfo); + break; + } + default: + break; + } + + const latestPoolAprs = await Promise.all(pools.map((pool) => duckDb.getLatestPoolApr(pool.pairAddr))); + const newPoolAprs = latestPoolAprs.map((poolApr, index) => { + return { + ...poolApr, + height, + [type]: newInfos[index] + }; + }); + await duckDb.insertPoolAprs(newPoolAprs); +}; + +export const refetchTotalSupplies = async (pools: PairInfoData[]): Promise => { + const liquidityAddrs = pools.map((pair) => pair.liquidityAddr); + const tokenInfos = await fetchTokenInfos(liquidityAddrs); + const totalSupplies = tokenInfos.map((info) => info.total_supply); + return totalSupplies; +}; + +export const refetchTotalBond = async (stakingAssetInfo: AssetInfo[]): Promise => { + const tokenAssetPools = await fetchAllTokenAssetPools(stakingAssetInfo); + const totalBondAmounts = tokenAssetPools.map((info) => info.total_bond_amount); + return totalBondAmounts; +}; + +export const refetchRewardPerSecInfos = async (stakingAssetInfo: AssetInfo[]) => { + const rewardPerSecInfos = await fetchAllRewardPerSecInfos(stakingAssetInfo); + return rewardPerSecInfos.map((item) => JSON.stringify(item)); +}; + +export const getListAssetInfoShouldRefetchApr = async (txs: Tx[], lpOps: ProvideLiquidityOperationData[]) => { + let listAssetInfosPoolShouldRefetch = new Set<[AssetInfo, AssetInfo]>(); + // mint/burn trigger update total supply + const assetInfosTriggerTotalSupplies = Array.from( + lpOps + .map((op) => [op.baseTokenDenom, op.quoteTokenDenom] as [string, string]) + .reduce((accumulator, tokenDenoms) => { + const assetInfo = parsePairDenomToAssetInfo(tokenDenoms); + accumulator.add(assetInfo); + return accumulator; + }, new Set<[AssetInfo, AssetInfo]>()) + ); + assetInfosTriggerTotalSupplies.forEach((item) => listAssetInfosPoolShouldRefetch.add(item)); + + const { infoTokenAssetPools, isTriggerRewardPerSec } = processEventApr(txs); + // bond/unbond trigger refetch info token asset pools + const assetInfosTriggerTotalBond = Array.from(infoTokenAssetPools) + .map((stakingDenom) => { + return pairWithStakingAsset.find((pair) => parseAssetInfoOnlyDenom(pair.stakingAssetInfo) === stakingDenom) + ?.asset_infos; + }) + .filter(Boolean); + + if (isTriggerRewardPerSec) { + // update_reward_per_sec trigger refetch all info + listAssetInfosPoolShouldRefetch.clear(); + pairs.map((pair) => pair.asset_infos).forEach((assetInfos) => listAssetInfosPoolShouldRefetch.add(assetInfos)); + } else { + assetInfosTriggerTotalBond.forEach((assetInfo) => listAssetInfosPoolShouldRefetch.add(assetInfo)); + } + + return { + assetInfosTriggerTotalSupplies, + listAssetInfosPoolShouldRefetch: Array.from(listAssetInfosPoolShouldRefetch), + assetInfosTriggerTotalBond, + assetInfosTriggerRewardPerSec: isTriggerRewardPerSec ? Array.from(listAssetInfosPoolShouldRefetch) : [] + }; +}; + +export const handleEventApr = async ( + txs: Tx[], + result: ProvideLiquidityOperationData[], + newOffset: number +): Promise => { + const { + assetInfosTriggerTotalSupplies, + listAssetInfosPoolShouldRefetch, + assetInfosTriggerTotalBond, + assetInfosTriggerRewardPerSec + } = await getListAssetInfoShouldRefetchApr(txs, result); + + await Promise.allSettled([ + refetchInfoApr("totalSupply", assetInfosTriggerTotalSupplies, newOffset), + refetchInfoApr("rewardPerSec", assetInfosTriggerRewardPerSec, newOffset), + refetchInfoApr("totalBondAmount", assetInfosTriggerTotalBond, newOffset) + ]); + + await triggerCalculateApr(Array.from(listAssetInfosPoolShouldRefetch), newOffset); +}; diff --git a/packages/oraidex-sync/src/query.ts b/packages/oraidex-sync/src/query.ts index 68fc1f28..0a7537da 100644 --- a/packages/oraidex-sync/src/query.ts +++ b/packages/oraidex-sync/src/query.ts @@ -1,17 +1,27 @@ import { OraiswapFactoryReadOnlyInterface, OraiswapRouterReadOnlyInterface, + OraiswapStakingTypes, + OraiswapTokenTypes, PairInfo } from "@oraichain/oraidex-contracts-sdk"; import { PoolResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapPair.types"; import { Asset, AssetInfo } from "@oraichain/oraidex-contracts-sdk"; -import { MulticallReadOnlyInterface } from "@oraichain/common-contracts-sdk"; +import { Addr, Call, MulticallQueryClient, MulticallReadOnlyInterface } from "@oraichain/common-contracts-sdk"; import { fromBinary, toBinary } from "@cosmjs/cosmwasm-stargate"; import { pairs } from "./pairs"; -import { findAssetInfoPathToUsdt, generateSwapOperations, parseAssetInfoOnlyDenom, toDisplay } from "./helper"; -import { tenAmountInDecimalSix, usdtCw20Address } from "./constants"; +import { + findAssetInfoPathToUsdt, + generateSwapOperations, + getCosmwasmClient, + parseAssetInfoOnlyDenom, + toDisplay +} from "./helper"; +import { network, tenAmountInDecimalSix, usdtCw20Address } from "./constants"; +import { TokenInfoResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapToken.types"; +import { PairInfoData } from "./types"; -async function getPoolInfos(pairAddrs: string[], multicall: MulticallReadOnlyInterface): Promise { +async function queryPoolInfos(pairAddrs: string[], multicall: MulticallReadOnlyInterface): Promise { // adjust the query height to get data from the past const res = await multicall.tryAggregate({ queries: pairAddrs.map((pair) => { @@ -27,7 +37,7 @@ async function getPoolInfos(pairAddrs: string[], multicall: MulticallReadOnlyInt return res.return_data.map((data) => (data.success ? fromBinary(data.data) : undefined)).filter((data) => data); // remove undefined items } -async function getAllPairInfos( +async function queryAllPairInfos( factoryV1: OraiswapFactoryReadOnlyInterface, factoryV2: OraiswapFactoryReadOnlyInterface ): Promise { @@ -57,7 +67,7 @@ async function simulateSwapPriceWithUsdt(info: AssetInfo, router: OraiswapRouter * Simulate price for pair[0]/pair[pair.length - 1] where the amount of pair[0] is 10^7. This is a multihop simulate swap function. The asset infos in between of the array are for hopping * @param pairPath - the path starting from the offer asset info to the ask asset info * @param router - router contract - * @returns - pricea fter simulating + * @returns - price after simulating */ async function simulateSwapPrice(pairPath: AssetInfo[], router: OraiswapRouterReadOnlyInterface): Promise { // usdt case, price is always 1 @@ -75,4 +85,61 @@ async function simulateSwapPrice(pairPath: AssetInfo[], router: OraiswapRouterRe } } -export { getAllPairInfos, getPoolInfos, simulateSwapPriceWithUsdt, simulateSwapPrice }; +async function aggregateMulticall(queries: Call[]) { + const client = await getCosmwasmClient(); + const multicall = new MulticallQueryClient(client, network.multicall); + const res = await multicall.aggregate({ queries }); + return res.return_data.map((data) => (data.success ? fromBinary(data.data) : undefined)); +} + +async function fetchTokenInfos(liquidityAddrs: Addr[]): Promise { + const queries = liquidityAddrs.map((address) => ({ + address, + data: toBinary({ + token_info: {} + } as OraiswapTokenTypes.QueryMsg) + })); + return await aggregateMulticall(queries); +} + +async function fetchAllTokenAssetPools(assetInfos: AssetInfo[]): Promise { + const queries = assetInfos.map((assetInfo) => { + return { + address: network.staking, + data: toBinary({ + pool_info: { + asset_info: assetInfo + } + } as OraiswapStakingTypes.QueryMsg) + }; + }); + + return await aggregateMulticall(queries); +} + +async function fetchAllRewardPerSecInfos( + assetInfos: AssetInfo[] +): Promise { + const queries = assetInfos.map((assetInfo) => { + return { + address: network.staking, + data: toBinary({ + rewards_per_sec: { + asset_info: assetInfo + } + } as OraiswapStakingTypes.QueryMsg) + }; + }); + return await aggregateMulticall(queries); +} + +export { + queryAllPairInfos, + queryPoolInfos, + simulateSwapPriceWithUsdt, + simulateSwapPrice, + aggregateMulticall, + fetchTokenInfos, + fetchAllTokenAssetPools, + fetchAllRewardPerSecInfos +}; diff --git a/packages/oraidex-sync/src/test-db.ts b/packages/oraidex-sync/src/test-db.ts deleted file mode 100644 index b7d20c03..00000000 --- a/packages/oraidex-sync/src/test-db.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { CosmWasmClient, OraiswapRouterQueryClient, SwapOperation } from "@oraichain/oraidex-contracts-sdk"; -import { DuckDb } from "./db"; -import { SwapOperationData } from "./types"; -import { pairs, uniqueInfos } from "./pairs"; -import { parseAssetInfoOnlyDenom } from "./helper"; -import { simulateSwapPriceWithUsdt } from "./query"; -import "dotenv/config"; - -export function getDate24hBeforeNow(time: Date) { - const twentyFourHoursInMilliseconds = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - const date24hBeforeNow = new Date(time.getTime() - twentyFourHoursInMilliseconds); - return date24hBeforeNow; -} - -const start = async () => { - const duckdb = await DuckDb.create("oraidex-sync-data"); - const tf = 86400; - const now = new Date(); - const then = getDate24hBeforeNow(now); - const firstTokenResult = await duckdb.conn.all("select * from swap_ohlcv limit 5"); - console.log(firstTokenResult); - - // let swapTokenMap = []; - // const baseVolume = 1000000000; - // for (let i = 0; i < swapOps.length; i++) { - // const indexOf = swapTokenMap.findIndex( - // (swapMap) => new Date(swapMap.timestamp).toISOString() === new Date(swapOps[i].timestamp).toISOString() - // ); - // console.log("index of: ", indexOf); - // if (indexOf === -1) { - // swapTokenMap.push({ - // timestamp: swapOps[i].timestamp, - // tokenData: uniqueInfos.map((info) => { - // if (parseAssetInfoOnlyDenom(info) === swapOps[i].offerDenom) { - // return { denom: swapOps[i].offerDenom, amount: swapOps[i].offerAmount }; - // } - // if (parseAssetInfoOnlyDenom(info) === swapOps[i].askDenom) { - // return { denom: swapOps[i].askDenom, amount: swapOps[i].returnAmount }; - // } - // return { denom: parseAssetInfoOnlyDenom(info), amount: 0 }; - // }) - // }); - // } else { - // swapTokenMap[indexOf] = { - // ...swapTokenMap[indexOf], - // tokenData: swapTokenMap[indexOf].tokenData.map((tokenData) => { - // if (tokenData.denom === swapOps[i].offerDenom) - // return { ...tokenData, amount: tokenData.amount + swapOps[i].offerAmount }; - // if (tokenData.denom === swapOps[i].askDenom) - // return { ...tokenData, amount: tokenData.amount + swapOps[i].returnAmount }; - // return tokenData; - // }) - // }; - // } - // } - // console.log( - // swapTokenMap.map((tokenMap) => ({ - // ...tokenMap, - // tokenData: tokenMap.tokenData.reduce((acc, item) => { - // acc[item.denom] = item.amount; - // return acc; - // }, {}) - // })) - // ); - // const newData = calculatePrefixSum( - // 100000000000, - // result.map((res) => ({ denom: "", amount: res.liquidity })) - // ); - // console.log("new data: ", newData); -}; - -start(); diff --git a/packages/oraidex-sync/src/tx-parsing.ts b/packages/oraidex-sync/src/tx-parsing.ts index e3124d12..2edecb34 100644 --- a/packages/oraidex-sync/src/tx-parsing.ts +++ b/packages/oraidex-sync/src/tx-parsing.ts @@ -1,22 +1,10 @@ import { Attribute, Event } from "@cosmjs/stargate"; -import { isEqual } from "lodash"; +import { Log } from "@cosmjs/stargate/build/logs"; import { Tx } from "@oraichain/cosmos-rpc-sync"; -import { MsgExecuteContract } from "cosmjs-types/cosmwasm/wasm/v1/tx"; import { Tx as CosmosTx } from "cosmjs-types/cosmos/tx/v1beta1/tx"; -import { - AccountTx, - BasicTxData, - ModifiedMsgExecuteContract, - MsgExecuteContractWithLogs, - MsgType, - OraiswapPairCw20HookMsg, - OraiswapRouterCw20HookMsg, - ProvideLiquidityOperationData, - SwapOperationData, - TxAnlysisResult, - WithdrawLiquidityOperationData -} from "./types"; -import { Log } from "@cosmjs/stargate/build/logs"; +import { MsgExecuteContract } from "cosmjs-types/cosmwasm/wasm/v1/tx"; +import { isEqual } from "lodash"; +import { DuckDb } from "./db"; import { buildOhlcv, calculatePriceByPool, @@ -30,6 +18,21 @@ import { removeOpsDuplication } from "./helper"; import { pairs } from "./pairs"; +import { calculateLiquidityFee, isPoolHasFee } from "./pool-helper"; +import { + AccountTx, + BasicTxData, + LiquidityOpType, + ModifiedMsgExecuteContract, + MsgExecuteContractWithLogs, + MsgType, + OraiswapPairCw20HookMsg, + OraiswapRouterCw20HookMsg, + ProvideLiquidityOperationData, + SwapOperationData, + TxAnlysisResult, + WithdrawLiquidityOperationData +} from "./types"; function parseWasmEvents(events: readonly Event[]): (readonly Attribute[])[] { return events.filter((event) => event.type === "wasm").map((event) => event.attributes); @@ -116,46 +119,97 @@ function extractSwapOperations(txData: BasicTxData, wasmAttributes: (readonly At return swapData; } -function extractMsgProvideLiquidity( +async function getFeeLiquidity( + [baseDenom, quoteDenom]: [string, string], + opType: LiquidityOpType, + attrs: readonly Attribute[], + txheight: number +): Promise { + // we only have one pair order. If the order is reversed then we also reverse the order + let findedPair = pairs.find((pair) => + isEqual( + pair.asset_infos.map((info) => parseAssetInfoOnlyDenom(info)), + [quoteDenom, baseDenom] + ) + ); + if (findedPair) { + [baseDenom, quoteDenom] = [quoteDenom, baseDenom]; + } else { + // otherwise find in reverse order + findedPair = pairs.find((pair) => + isEqual( + pair.asset_infos.map((info) => parseAssetInfoOnlyDenom(info)), + [baseDenom, quoteDenom] + ) + ); + } + let fee = 0n; + const isHasFee = isPoolHasFee(findedPair.asset_infos); + if (isHasFee) { + let lpShare = + opType === "provide" + ? attrs.find((attr) => attr.key === "share").value + : attrs.find((attr) => attr.key === "withdrawn_share").value; + const duckDb = DuckDb.instances; + const pair = await duckDb.getPoolByAssetInfos(findedPair.asset_infos); + fee = await calculateLiquidityFee(pair, txheight, +lpShare); + console.log(`fee ${opType} liquidity: $${fee}`); + } + return fee; +} + +async function extractMsgProvideLiquidity( txData: BasicTxData, msg: MsgType, - txCreator: string -): ProvideLiquidityOperationData | undefined { + txCreator: string, + wasmAttributes: (readonly Attribute[])[] +): Promise { if ("provide_liquidity" in msg) { - const assetInfos = msg.provide_liquidity.assets.map((asset) => asset.info); - let baseAsset = msg.provide_liquidity.assets[0]; - let quoteAsset = msg.provide_liquidity.assets[1]; - if (isAssetInfoPairReverse(assetInfos)) { - baseAsset = msg.provide_liquidity.assets[1]; - quoteAsset = msg.provide_liquidity.assets[0]; - } - const firstDenom = parseAssetInfoOnlyDenom(baseAsset.info); - const secDenom = parseAssetInfoOnlyDenom(quoteAsset.info); - const firstAmount = parseInt(baseAsset.amount); - const secAmount = parseInt(quoteAsset.amount); + for (let attrs of wasmAttributes) { + const assetInfos = msg.provide_liquidity.assets.map((asset) => asset.info); - return { - basePrice: calculatePriceByPool(BigInt(firstAmount), BigInt(secAmount)), - baseTokenAmount: firstAmount, - baseTokenDenom: firstDenom, - baseTokenReserve: firstAmount, - opType: "provide", - uniqueKey: concatDataToUniqueKey({ + let baseAsset = msg.provide_liquidity.assets[0]; + let quoteAsset = msg.provide_liquidity.assets[1]; + if (isAssetInfoPairReverse(assetInfos)) { + baseAsset = msg.provide_liquidity.assets[1]; + quoteAsset = msg.provide_liquidity.assets[0]; + } + const firstDenom = parseAssetInfoOnlyDenom(baseAsset.info); + const secDenom = parseAssetInfoOnlyDenom(quoteAsset.info); + const firstAmount = parseInt(baseAsset.amount); + const secAmount = parseInt(quoteAsset.amount); + + const fee = await getFeeLiquidity( + [parseAssetInfoOnlyDenom(baseAsset.info), parseAssetInfoOnlyDenom(quoteAsset.info)], + "provide", + attrs, + txData.txheight + ); + return { + basePrice: calculatePriceByPool(BigInt(firstAmount), BigInt(secAmount)), + baseTokenAmount: firstAmount, + baseTokenDenom: firstDenom, + baseTokenReserve: firstAmount, + opType: "provide", + uniqueKey: concatDataToUniqueKey({ + txheight: txData.txheight, + firstAmount, + firstDenom, + secondAmount: secAmount, + secondDenom: secDenom + }), + quoteTokenAmount: secAmount, + quoteTokenDenom: secDenom, + quoteTokenReserve: secAmount, + timestamp: txData.timestamp, + txCreator, + txhash: txData.txhash, txheight: txData.txheight, - firstAmount, - firstDenom, - secondAmount: secAmount, - secondDenom: secDenom - }), - quoteTokenAmount: secAmount, - quoteTokenDenom: secDenom, - quoteTokenReserve: secAmount, - timestamp: txData.timestamp, - txCreator, - txhash: txData.txhash, - txheight: txData.txheight - }; + taxRate: fee + }; + } } + return undefined; } @@ -167,11 +221,11 @@ function parseWithdrawLiquidityAssets(assets: string): string[] { return matches.slice(1, 5); } -function extractMsgWithdrawLiquidity( +async function extractMsgWithdrawLiquidity( txData: BasicTxData, wasmAttributes: (readonly Attribute[])[], txCreator: string -): WithdrawLiquidityOperationData[] { +): Promise { const withdrawData: WithdrawLiquidityOperationData[] = []; for (let attrs of wasmAttributes) { @@ -185,22 +239,24 @@ function extractMsgWithdrawLiquidity( let quoteAsset = assets[3]; let quoteAssetAmount = parseInt(assets[2]); // we only have one pair order. If the order is reversed then we also reverse the order - if ( - pairs.find((pair) => - isEqual( - pair.asset_infos.map((info) => parseAssetInfoOnlyDenom(info)), - [quoteAsset, baseAsset] - ) + let findedPair = pairs.find((pair) => + isEqual( + pair.asset_infos.map((info) => parseAssetInfoOnlyDenom(info)), + [quoteAsset, baseAsset] ) - ) { - baseAsset = assets[3]; - quoteAsset = assets[1]; + ); + if (findedPair) { + [baseAsset, quoteAsset] = [quoteAsset, baseAsset]; + [baseAssetAmount, quoteAssetAmount] = [quoteAssetAmount, baseAssetAmount]; } if (assets.length !== 4) continue; + + const fee = await getFeeLiquidity([baseAsset, quoteAsset], "withdraw", attrs, txData.txheight); + withdrawData.push({ basePrice: calculatePriceByPool(BigInt(baseAssetAmount), BigInt(quoteAssetAmount)), baseTokenAmount: baseAssetAmount, - baseTokenDenom: assets[1], + baseTokenDenom: baseAsset, baseTokenReserve: baseAssetAmount, opType: "withdraw", uniqueKey: concatDataToUniqueKey({ @@ -216,7 +272,8 @@ function extractMsgWithdrawLiquidity( timestamp: txData.timestamp, txCreator, txhash: txData.txhash, - txheight: txData.txheight + txheight: txData.txheight, + taxRate: fee }); } return withdrawData; @@ -231,14 +288,30 @@ function parseExecuteContractToOraidexMsgs(msgs: MsgExecuteContractWithLogs[]): msg: JSON.parse(Buffer.from(msg.msg).toString("utf-8")) }; // Should be provide, remove liquidity, swap, or other oraidex related types - if ("provide_liquidity" in obj.msg || "execute_swap_operations" in obj.msg || "execute_swap_operation" in obj.msg) + if ( + "provide_liquidity" in obj.msg || + "execute_swap_operations" in obj.msg || + "execute_swap_operation" in obj.msg || + "bond" in obj.msg || + "unbond" in obj.msg || + "mint" in obj.msg || + "burn" in obj.msg || + ("execute" in obj.msg && typeof obj.msg.execute === "object" && "proposal_id" in obj.msg.execute) + ) objs.push(obj); if ("send" in obj.msg) { try { const contractSendMsg: OraiswapPairCw20HookMsg | OraiswapRouterCw20HookMsg = JSON.parse( Buffer.from(obj.msg.send.msg, "base64").toString("utf-8") ); - if ("execute_swap_operations" in contractSendMsg || "withdraw_liquidity" in contractSendMsg) { + if ( + "execute_swap_operations" in contractSendMsg || + "withdraw_liquidity" in contractSendMsg || + "bond" in contractSendMsg || + "unbond" in contractSendMsg || + "mint" in contractSendMsg || + "burn" in contractSendMsg + ) { objs.push({ ...msg, msg: contractSendMsg }); } } catch (error) { @@ -253,7 +326,7 @@ function parseExecuteContractToOraidexMsgs(msgs: MsgExecuteContractWithLogs[]): return objs; } -function parseTxs(txs: Tx[]): TxAnlysisResult { +async function parseTxs(txs: Tx[]): Promise { let transactions: Tx[] = []; let swapOpsData: SwapOperationData[] = []; let accountTxs: AccountTx[] = []; @@ -268,20 +341,21 @@ function parseTxs(txs: Tx[]): TxAnlysisResult { txhash: tx.hash, txheight: tx.height }; + for (let msg of msgs) { const sender = msg.sender; const wasmAttributes = parseWasmEvents(msg.logs.events); + swapOpsData.push(...extractSwapOperations(basicTxData, wasmAttributes)); - const provideLiquidityData = extractMsgProvideLiquidity(basicTxData, msg.msg, sender); + const provideLiquidityData = await extractMsgProvideLiquidity(basicTxData, msg.msg, sender, wasmAttributes); if (provideLiquidityData) provideLiquidityOpsData.push(provideLiquidityData); - withdrawLiquidityOpsData.push(...extractMsgWithdrawLiquidity(basicTxData, wasmAttributes, sender)); + withdrawLiquidityOpsData.push(...(await extractMsgWithdrawLiquidity(basicTxData, wasmAttributes, sender))); accountTxs.push({ txhash: basicTxData.txhash, accountAddress: sender }); } } swapOpsData = swapOpsData.filter((i) => i.direction); swapOpsData = removeOpsDuplication(swapOpsData) as SwapOperationData[]; return { - // transactions: txs, swapOpsData: groupByTime(swapOpsData) as SwapOperationData[], ohlcv: buildOhlcv(swapOpsData), accountTxs, @@ -289,9 +363,38 @@ function parseTxs(txs: Tx[]): TxAnlysisResult { removeOpsDuplication(provideLiquidityOpsData) ) as ProvideLiquidityOperationData[], withdrawLiquidityOpsData: groupByTime( - removeOpsDuplication(provideLiquidityOpsData) + removeOpsDuplication(withdrawLiquidityOpsData) ) as WithdrawLiquidityOperationData[] }; } -export { parseAssetInfo, parseWasmEvents, parseTxs, parseWithdrawLiquidityAssets, parseTxToMsgExecuteContractMsgs }; +export const processEventApr = (txs: Tx[]) => { + const assets = { + infoTokenAssetPools: new Set(), + isTriggerRewardPerSec: false + }; + for (let tx of txs) { + // guard code. Should refetch all token info if match event update_rewards_per_sec or length ofstaking asset equal to pairs length. + if (assets.isTriggerRewardPerSec || assets.infoTokenAssetPools.size === pairs.length) break; + + const msgExecuteContracts = parseTxToMsgExecuteContractMsgs(tx); + const msgs = parseExecuteContractToOraidexMsgs(msgExecuteContracts); + for (let msg of msgs) { + const wasmAttributes = parseWasmEvents(msg.logs.events); + for (let attrs of wasmAttributes) { + if (attrs.find((attr) => attr.key === "action" && (attr.value === "bond" || attr.value === "unbond"))) { + const stakingAssetDenom = attrs.find((attr) => attr.key === "asset_info")?.value; + assets.infoTokenAssetPools.add(stakingAssetDenom); + } + + if (attrs.find((attr) => attr.key === "action" && attr.value === "update_rewards_per_sec")) { + assets.isTriggerRewardPerSec = true; + break; + } + } + } + } + return assets; +}; + +export { parseAssetInfo, parseTxToMsgExecuteContractMsgs, parseTxs, parseWasmEvents, parseWithdrawLiquidityAssets }; diff --git a/packages/oraidex-sync/src/types.ts b/packages/oraidex-sync/src/types.ts index d6e3cfd4..64248d39 100644 --- a/packages/oraidex-sync/src/types.ts +++ b/packages/oraidex-sync/src/types.ts @@ -41,6 +41,16 @@ export type PairInfoData = { pairAddr: string; liquidityAddr: string; oracleAddr: string; + symbols: string; + fromIconUrl: string; + toIconUrl: string; +}; + +export type PairInfoDataResponse = PairInfoData & { + apr: number; + totalLiquidity: number; + volume24Hour: string; + fee7Days: string; }; export type PriceInfo = { @@ -68,12 +78,24 @@ export type ProvideLiquidityOperationData = { opType: LiquidityOpType; uniqueKey: string; // concat of first, second denom, amount, and timestamp => should be unique. unique key is used to override duplication only. txCreator: string; + taxRate: number | bigint; } & BasicTxData; export type WithdrawLiquidityOperationData = ProvideLiquidityOperationData; export type OraiDexType = SwapOperationData | ProvideLiquidityOperationData | WithdrawLiquidityOperationData | Ohlcv; +export type LpOpsData = { + baseTokenAmount: number; + baseTokenDenom: string; // eg: orai, orai1234... + quoteTokenAmount: number; + quoteTokenDenom: string; + opType?: LiquidityOpType; + direction?: SwapDirection; + height: number; + timestamp: number; +}; + export type TxAnlysisResult = { // transactions: Tx[]; swapOpsData: SwapOperationData[]; @@ -124,6 +146,7 @@ export type OraiswapPairCw20HookMsg = { export type PairMapping = { asset_infos: [AssetInfo, AssetInfo]; symbols: [string, string]; + factoryV1?: boolean; }; export type InitialData = { @@ -188,3 +211,34 @@ export type GetCandlesQuery = { startTime: number; endTime: number; }; + +export type GetFeeSwap = { + offerDenom: string; + askDenom: string; + startTime: number; + endTime: number; +}; + +export type GetVolumeQuery = Omit; + +export type PoolInfo = { + offerPoolAmount: bigint; + askPoolAmount: bigint; +}; + +export type PoolAmountHistory = { + timestamp: number; + height: number; + pairAddr: string; + uniqueKey: string; +} & PoolInfo; + +export type PoolApr = { + uniqueKey: string; + pairAddr: string; + height: number; + totalSupply: string; + totalBondAmount: string; + rewardPerSec: string; + apr: number; +}; diff --git a/packages/oraidex-sync/tests/db.spec.ts b/packages/oraidex-sync/tests/db.spec.ts index 3f14528d..12b78c24 100644 --- a/packages/oraidex-sync/tests/db.spec.ts +++ b/packages/oraidex-sync/tests/db.spec.ts @@ -1,9 +1,15 @@ import { DuckDb } from "../src/db"; import { isoToTimestampNumber } from "../src/helper"; -import { ProvideLiquidityOperationData } from "../src/types"; - +import { GetFeeSwap, GetVolumeQuery, ProvideLiquidityOperationData } from "../src/types"; describe("test-duckdb", () => { let duckDb: DuckDb; + afterAll(jest.resetModules); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.resetAllMocks(); + }); it.each<[string[], number[]]>([ [ @@ -150,7 +156,6 @@ describe("test-duckdb", () => { isoToTimestampNumber("2023-07-16T16:07:48.000Z"), isoToTimestampNumber("2023-07-17T16:07:48.000Z") ); - console.log("result: ", queryResult); expect(queryResult.volume["orai"]).toEqual(110); expect(queryResult.volume["atom"]).toEqual(10001); @@ -184,7 +189,8 @@ describe("test-duckdb", () => { quoteTokenDenom: "atom", txCreator: "foobar", opType: "provide", - txheight: 1 + txheight: 1, + taxRate: 1n } ]) ).rejects.toThrow(); @@ -193,7 +199,7 @@ describe("test-duckdb", () => { it("test-duckdb-insert-bulk-should-pass-and-can-query", async () => { //setup duckDb = await DuckDb.create(":memory:"); - await Promise.all([duckDb.createHeightSnapshot(), duckDb.createLiquidityOpsTable(), duckDb.createSwapOpsTable()]); + await duckDb.createLiquidityOpsTable(); // act & test const newDate = 1689610068000 / 1000; const data: ProvideLiquidityOperationData[] = [ @@ -210,19 +216,21 @@ describe("test-duckdb", () => { timestamp: newDate, txCreator: "foobar", txhash: "foo", - txheight: 1 + txheight: 1, + taxRate: 1 } ]; + await duckDb.insertLpOps(data); let queryResult = await duckDb.queryLpOps(); queryResult[0].timestamp = queryResult[0].timestamp; expect(queryResult[0]).toEqual(data[0]); }); - it("test-insert-same-unique-key-should-replace-data", async () => { + test("test-insert-same-unique-key-should-replace-data", async () => { // setup duckDb = await DuckDb.create(":memory:"); - await Promise.all([duckDb.createHeightSnapshot(), duckDb.createLiquidityOpsTable(), duckDb.createSwapOpsTable()]); + await duckDb.createLiquidityOpsTable(); const currentTimeStamp = Math.round(new Date().getTime() / 1000); let data: ProvideLiquidityOperationData[] = [ { @@ -238,7 +246,8 @@ describe("test-duckdb", () => { timestamp: currentTimeStamp, txCreator: "foobar", txhash: "foo", - txheight: 1 + txheight: 1, + taxRate: 1 } ]; await duckDb.insertLpOps(data); @@ -259,4 +268,253 @@ describe("test-duckdb", () => { queryResult = await duckDb.queryLpOps(); expect(queryResult.length).toEqual(2); }); + + it("test-getFeeSwap-should-return-correctly-fee-in-USDT", async () => { + // setup + duckDb = await DuckDb.create(":memory:"); + await duckDb.createSwapOpsTable(); + await duckDb.insertSwapOps([ + { + askDenom: "orai", + commissionAmount: 1e6, + direction: "Buy", + offerAmount: 10, + offerDenom: "atom", + uniqueKey: "2", + returnAmount: 1, + spreadAmount: 0, + taxAmount: 0, + timestamp: 1589610068000 / 1000, + txhash: "foo", + txheight: 1 + }, + { + askDenom: "atom", + commissionAmount: 1e6, + direction: "Sell", + offerAmount: 10, + offerDenom: "orai", + uniqueKey: "3", + returnAmount: 1, + spreadAmount: 0, + taxAmount: 0, + timestamp: 1589610068000 / 1000, + txhash: "foo", + txheight: 1 + } + ]); + const payload: GetFeeSwap = { + offerDenom: "orai", + askDenom: "atom", + startTime: 1589610068000 / 1000, + endTime: 1689610068000 / 1000 + }; + + // act + const feeSwap = await duckDb.getFeeSwap(payload); + + // assertion + expect(feeSwap).toEqual(2000000n); + }); + + it.each([ + ["invalid-pair", 1, 3, 0n], + ["orai-usdt", 1, 3, 2n], + ["orai-usdt", 1, 5, 3n] + ])( + "test-getVolumeSwap-should-return-correctly-volume-in-base-asset", + async (pair: string, startTime: number, endTime: number, expectedResult: bigint) => { + // setup + duckDb = await DuckDb.create(":memory:"); + await duckDb.createSwapOhlcv(); + await duckDb.insertOhlcv([ + { + uniqueKey: "1", + timestamp: 1, + pair: "orai-usdt", + volume: 1n, // base volume + open: 2, + close: 2, // base price + low: 2, + high: 2 + }, + { + uniqueKey: "2", + timestamp: 3, + pair: "orai-usdt", + volume: 1n, // base volume + open: 2, + close: 2, // base price + low: 2, + high: 2 + }, + { + uniqueKey: "3", + timestamp: 5, + pair: "orai-usdt", + volume: 1n, // base volume + open: 2, + close: 2, // base price + low: 2, + high: 2 + } + ]); + + const payload: GetVolumeQuery = { + pair, + startTime, + endTime + }; + + // act + const volumeSwap = await duckDb.getVolumeSwap(payload); + + // assertion + expect(volumeSwap).toEqual(expectedResult); + } + ); + + describe("test-get-fee-&-volume-liquidity", () => { + // setup + beforeAll(async () => { + duckDb = await DuckDb.create(":memory:"); + await duckDb.createLiquidityOpsTable(); + await duckDb.insertLpOps([ + { + basePrice: 1, + baseTokenAmount: 1, + baseTokenDenom: "orai", + baseTokenReserve: 0, + opType: "withdraw", + uniqueKey: "1", + quoteTokenAmount: 2, + quoteTokenDenom: "atom", + quoteTokenReserve: 0, + timestamp: 1589610068000 / 1000, + txCreator: "foobar", + txhash: "foo", + txheight: 1, + taxRate: 1 + }, + { + basePrice: 1, + baseTokenAmount: 1, + baseTokenDenom: "orai", + baseTokenReserve: 0, + opType: "provide", + uniqueKey: "2", + quoteTokenAmount: 2, + quoteTokenDenom: "atom", + quoteTokenReserve: 0, + timestamp: 1589610068000 / 1000, + txCreator: "foobar", + txhash: "foo", + txheight: 1, + taxRate: 2 + } + ]); + }); + + it("test-getFeeLiquidity-should-return-correctly-fee-in-USDT", async () => { + const payload: GetFeeSwap = { + offerDenom: "orai", + askDenom: "atom", + startTime: 1589610068000 / 1000, + endTime: 1689610068000 / 1000 + }; + + // act + const feeSwap = await duckDb.getFeeLiquidity(payload); + + // assertion + expect(feeSwap).toEqual(3n); + }); + + it("test-getVolumeLiquidity-should-return-correctly-volume-liquidity-in-base-asset", async () => { + // act + const payload: GetFeeSwap = { + offerDenom: "orai", + askDenom: "atom", + startTime: 1589610068000 / 1000, + endTime: 1689610068000 / 1000 + }; + const volumeByBaseAsset = await duckDb.getVolumeLiquidity(payload); + + // assertion + expect(volumeByBaseAsset).toEqual(2n); + }); + }); + + describe("test-apr", () => { + beforeEach(async () => { + // setup + duckDb = await DuckDb.create(":memory:"); + await duckDb.createAprInfoPair(); + await duckDb.insertPoolAprs([ + { + uniqueKey: "orai_usdt_2", + pairAddr: "orai_usdt", + height: 2, + totalSupply: "1", + totalBondAmount: "1", + rewardPerSec: "1", + apr: 2 + }, + { + uniqueKey: "orai_usdt_4", + pairAddr: "orai_usdt", + height: 4, + totalSupply: "1", + totalBondAmount: "1", + rewardPerSec: "1", + apr: 4 + }, + { + uniqueKey: "orai_usdt_3", + pairAddr: "orai_usdt", + height: 3, + totalSupply: "1", + totalBondAmount: "1", + rewardPerSec: "1", + apr: 3 + }, + { + uniqueKey: "orai_atom", + pairAddr: "orai_atom", + height: 2, + totalSupply: "1", + totalBondAmount: "1", + rewardPerSec: "1", + apr: 2 + } + ]); + }); + + it("test-getApr-should-return-correctly-apr-for-all-pair", async () => { + // act + const apr = await duckDb.getApr(); + + // assertion + expect(apr).toEqual([ + { pairAddr: "orai_usdt", apr: 4 }, + { pairAddr: "orai_atom", apr: 2 } + ]); + }); + + it("test-getLatestPoolApr-should-return-latest-pool-apr", async () => { + // act + const result = await duckDb.getLatestPoolApr("orai_usdt"); + + // assertion + expect(result).toMatchObject({ + uniqueKey: "orai_usdt_4", + pairAddr: "orai_usdt", + height: 4, + totalSupply: "1", + totalBondAmount: "1", + rewardPerSec: "1", + apr: 4 + }); + }); + }); }); diff --git a/packages/oraidex-sync/tests/helper.spec.ts b/packages/oraidex-sync/tests/helper.spec.ts index 1de1cc18..24b1046e 100644 --- a/packages/oraidex-sync/tests/helper.spec.ts +++ b/packages/oraidex-sync/tests/helper.spec.ts @@ -1,42 +1,52 @@ -import { AssetInfo } from "@oraichain/oraidex-contracts-sdk"; -import { - findAssetInfoPathToUsdt, - findMappedTargetedAssetInfo, - findPairAddress, - calculatePriceByPool, - toAmount, - toDisplay, - toDecimal, - roundTime, - groupByTime, - collectAccumulateLpData, - concatDataToUniqueKey, - removeOpsDuplication, - calculateBasePriceFromSwapOp, - getSwapDirection, - findPairIndexFromDenoms, - toObject, - calculateSwapOhlcv -} from "../src/helper"; -import { extractUniqueAndFlatten, pairs } from "../src/pairs"; +import { AssetInfo, SwapOperation } from "@oraichain/oraidex-contracts-sdk"; +import { PoolResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapPair.types"; import { ORAI, airiCw20Adress, atomIbcDenom, kwtCw20Address, milkyCw20Address, + oraiInfo, oraixCw20Address, osmosisIbcDenom, scAtomCw20Address, scOraiCw20Address, tronCw20Address, usdcCw20Address, - usdtCw20Address + usdtCw20Address, + usdtInfo } from "../src/constants"; -import { PairInfoData, ProvideLiquidityOperationData, SwapDirection, SwapOperationData } from "../src/types"; -import { PoolResponse } from "@oraichain/oraidex-contracts-sdk/build/OraiswapPair.types"; +import { + calculateBasePriceFromSwapOp, + calculatePriceByPool, + concatDataToUniqueKey, + findAssetInfoPathToUsdt, + findMappedTargetedAssetInfo, + findPairAddress, + findPairIndexFromDenoms, + getSwapDirection, + groupByTime, + removeOpsDuplication, + roundTime, + toAmount, + toDecimal, + toDisplay +} from "../src/helper"; +import { extractUniqueAndFlatten, pairs } from "../src/pairs"; +import { LpOpsData, PairInfoData, ProvideLiquidityOperationData, SwapDirection, SwapOperationData } from "../src/types"; +import { DuckDb, collectAccumulateLpAndSwapData, getVolumePairByAsset, getVolumePairByUsdt } from "../src"; +import * as poolHelper from "../src/pool-helper"; +import * as helper from "../src/helper"; describe("test-helper", () => { + let duckDb: DuckDb; + + afterAll(jest.resetModules); + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + jest.resetAllMocks(); + }); describe("bigint", () => { describe("toAmount", () => { it("toAmount-percent", () => { @@ -189,7 +199,10 @@ describe("test-helper", () => { commissionRate: "", pairAddr: "orai1c5s03c3l336dgesne7dylnmhszw8554tsyy9yt", liquidityAddr: "", - oracleAddr: "" + oracleAddr: "", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" } ]; let assetInfos: [AssetInfo, AssetInfo] = [{ native_token: { denom: ORAI } }, assetInfo]; @@ -206,11 +219,13 @@ describe("test-helper", () => { expect(pairs).toEqual([ { asset_infos: [{ token: { contract_addr: airiCw20Adress } }, { native_token: { denom: ORAI } }], - symbols: ["AIRI", "ORAI"] + symbols: ["AIRI", "ORAI"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: oraixCw20Address } }, { native_token: { denom: ORAI } }], - symbols: ["ORAIX", "ORAI"] + symbols: ["ORAIX", "ORAI"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: scOraiCw20Address } }, { native_token: { denom: ORAI } }], @@ -218,15 +233,18 @@ describe("test-helper", () => { }, { asset_infos: [{ native_token: { denom: ORAI } }, { native_token: { denom: atomIbcDenom } }], - symbols: ["ORAI", "ATOM"] + symbols: ["ORAI", "ATOM"], + factoryV1: true }, { asset_infos: [{ native_token: { denom: ORAI } }, { token: { contract_addr: usdtCw20Address } }], - symbols: ["ORAI", "USDT"] + symbols: ["ORAI", "USDT"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: kwtCw20Address } }, { native_token: { denom: ORAI } }], - symbols: ["KWT", "ORAI"] + symbols: ["KWT", "ORAI"], + factoryV1: true }, { asset_infos: [ @@ -235,11 +253,13 @@ describe("test-helper", () => { native_token: { denom: osmosisIbcDenom } } ], - symbols: ["ORAI", "OSMO"] + symbols: ["ORAI", "OSMO"], + factoryV1: true }, { asset_infos: [{ token: { contract_addr: milkyCw20Address } }, { token: { contract_addr: usdtCw20Address } }], - symbols: ["MILKY", "USDT"] + symbols: ["MILKY", "USDT"], + factoryV1: true }, { asset_infos: [{ native_token: { denom: ORAI } }, { token: { contract_addr: usdcCw20Address } }], @@ -346,84 +366,120 @@ describe("test-helper", () => { ]); }); - it("test-calculatePriceByPool-ORAI/USDT-pool-when-1ORAI=2.74USDT", () => { - // base denom is ORAI, quote denom is USDT => base pool is ORAI, quote pool is USDT. - const result = calculatePriceByPool(BigInt(639997269712), BigInt(232967274783), 0, 10 ** 6); - expect(result.toString()).toEqual("2.747144"); - }); + it.each([ + [0, "2.747144"], + [0.003, "2.738902568"] + ])( + "test-calculatePriceByPool-ORAI/USDT-pool-with-commision-rate=%s-should-return-price-%s-USDT", + (commisionRate, expectedPrice) => { + // base denom is ORAI, quote denom is USDT => base pool is ORAI, quote pool is USDT. + const result = calculatePriceByPool(BigInt(639997269712), BigInt(232967274783), commisionRate, 10 ** 6); + expect(result.toString()).toEqual(expectedPrice); + } + ); - it("test-collectAccumulateLpData-should-aggregate-ops-with-same-pairs", () => { + it("test-collectAccumulateLpAndSwapData-should-aggregate-ops-with-same-pairs", async () => { + // setup, test with orai/usdt & orai/atom pair const poolResponses: PoolResponse[] = [ { assets: [ - { info: { native_token: { denom: ORAI } }, amount: "1" }, - { info: { token: { contract_addr: usdtCw20Address } }, amount: "1" } + { info: oraiInfo, amount: "1" }, + { info: usdtInfo, amount: "1" } ], - total_share: "2" + total_share: "1" }, { assets: [ - { info: { native_token: { denom: ORAI } }, amount: "4" }, - { info: { token: { contract_addr: atomIbcDenom } }, amount: "4" } + { info: oraiInfo, amount: "4" }, + { info: { native_token: { denom: atomIbcDenom } }, amount: "4" } ], total_share: "8" } ]; - const ops: ProvideLiquidityOperationData[] = [ + + const lpOpsData: LpOpsData[] = [ { - basePrice: 1, baseTokenAmount: 1, baseTokenDenom: ORAI, quoteTokenAmount: 1, quoteTokenDenom: usdtCw20Address, - baseTokenReserve: 1, - quoteTokenReserve: 1, + opType: "withdraw", + height: 1, + timestamp: 1 + }, + { + baseTokenAmount: 2, + baseTokenDenom: ORAI, + quoteTokenAmount: 2, + quoteTokenDenom: usdtCw20Address, opType: "provide", - uniqueKey: "1", - timestamp: 1, - txCreator: "a", - txhash: "a", - txheight: 1 + height: 1, + timestamp: 1 }, { - basePrice: 1, baseTokenAmount: 1, baseTokenDenom: ORAI, - quoteTokenAmount: 1, + quoteTokenAmount: -1, quoteTokenDenom: usdtCw20Address, - baseTokenReserve: 1, - quoteTokenReserve: 1, - opType: "withdraw", - uniqueKey: "2", - timestamp: 1, - txCreator: "a", - txhash: "a", - txheight: 1 + direction: "Buy", + height: 1, + timestamp: 1 }, { - basePrice: 1, baseTokenAmount: 1, baseTokenDenom: ORAI, - quoteTokenAmount: 1, + quoteTokenAmount: -1, + quoteTokenDenom: usdtCw20Address, + direction: "Sell", + height: 1, + timestamp: 1 + }, + { + baseTokenAmount: 1, + baseTokenDenom: ORAI, + quoteTokenAmount: -1, quoteTokenDenom: atomIbcDenom, - baseTokenReserve: 1, - quoteTokenReserve: 1, - opType: "withdraw", - uniqueKey: "3", - timestamp: 1, - txCreator: "a", - txhash: "a", - txheight: 1 + direction: "Sell", + height: 1, + timestamp: 1 } ]; + duckDb = await DuckDb.create(":memory:"); + await duckDb.createPairInfosTable(); - collectAccumulateLpData(ops, poolResponses); - expect(ops[0].baseTokenReserve.toString()).toEqual("2"); - expect(ops[0].quoteTokenReserve.toString()).toEqual("2"); - expect(ops[1].baseTokenReserve.toString()).toEqual("1"); - expect(ops[1].quoteTokenReserve.toString()).toEqual("1"); - expect(ops[2].baseTokenReserve.toString()).toEqual("3"); - expect(ops[2].quoteTokenReserve.toString()).toEqual("3"); + await duckDb.insertPairInfos([ + { + firstAssetInfo: JSON.stringify(oraiInfo), + secondAssetInfo: JSON.stringify(usdtInfo), + commissionRate: "", + pairAddr: "oraiUsdtPairAddr", + liquidityAddr: "", + oracleAddr: "", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" + }, + { + firstAssetInfo: JSON.stringify(oraiInfo), + secondAssetInfo: JSON.stringify({ native_token: { denom: atomIbcDenom } }), + commissionRate: "", + pairAddr: "oraiAtomPairAddr", + liquidityAddr: "", + oracleAddr: "", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" + } + ]); + + // act + const accumulatedData = await collectAccumulateLpAndSwapData(lpOpsData, poolResponses); + + // assertion + expect(accumulatedData).toStrictEqual({ + oraiUsdtPairAddr: { askPoolAmount: 2n, height: 1, offerPoolAmount: 2n, timestamp: 1 }, + oraiAtomPairAddr: { askPoolAmount: 3n, height: 1, offerPoolAmount: 5n, timestamp: 1 } + }); }); it("test-concatDataToUniqueKey-should-return-unique-key-in-correct-order-from-timestamp-to-first-to-second-amount-and-denom", () => { @@ -456,7 +512,8 @@ describe("test-helper", () => { timestamp: 1, txCreator: "a", txhash: "a", - txheight: 1 + txheight: 1, + taxRate: 1n }, { basePrice: 1, @@ -471,7 +528,8 @@ describe("test-helper", () => { timestamp: 1, txCreator: "a", txhash: "a", - txheight: 1 + txheight: 1, + taxRate: 1n }, { basePrice: 1, @@ -486,7 +544,8 @@ describe("test-helper", () => { timestamp: 1, txCreator: "a", txhash: "a", - txheight: 1 + txheight: 1, + taxRate: 1n } ]; const newOps = removeOpsDuplication(ops); @@ -494,70 +553,95 @@ describe("test-helper", () => { expect(newOps[1].uniqueKey).toEqual("2"); }); - // it.each<[[AssetInfo, AssetInfo], AssetInfo, number]>([ - // [ - // [{ native_token: { denom: ORAI } }, { native_token: { denom: atomIbcDenom } }], - // { native_token: { denom: atomIbcDenom } }, - // 0 - // ], - // [ - // [{ native_token: { denom: ORAI } }, { token: { contract_addr: usdtCw20Address } }], - // { native_token: { denom: ORAI } }, - // 1 - // ], - // [ - // [{ native_token: { denom: ORAI } }, { token: { contract_addr: usdcCw20Address } }], - // { native_token: { denom: ORAI } }, - // 1 - // ], - // [ - // [{ token: { contract_addr: tronCw20Address } }, { native_token: { denom: atomIbcDenom } }], - // { token: { contract_addr: tronCw20Address } }, - // 1 - // ] - // ])("test-findUsdOraiInPair", (infos, expectedInfo, expectedBase) => { - // // act - // const result = findUsdOraiInPair(infos); - // // assert - // expect(result.target).toEqual(expectedInfo); - // expect(result.baseIndex).toEqual(expectedBase); - // }); + describe("test-ohlcv-calculation", () => { + // setup + const ops: SwapOperationData[] = [ + { + offerAmount: 2, + offerDenom: ORAI, + returnAmount: 1, + askDenom: usdtCw20Address, + direction: "Buy", + uniqueKey: "1", + timestamp: 1, + txCreator: "a", + txhash: "a", + txheight: 1, + spreadAmount: 1, + taxAmount: 1, + commissionAmount: 1 + } as SwapOperationData, + { + offerAmount: 2, + offerDenom: ORAI, + returnAmount: 1, + askDenom: usdtCw20Address, + direction: "Sell", + uniqueKey: "1", + timestamp: 1, + txCreator: "a", + txhash: "a", + txheight: 1, + spreadAmount: 1, + taxAmount: 1, + commissionAmount: 1 + } as SwapOperationData, + { + offerAmount: 2, + offerDenom: ORAI, + returnAmount: 1, + askDenom: atomIbcDenom, + direction: "Sell", + uniqueKey: "1", + timestamp: 1, + txCreator: "a", + txhash: "a", + txheight: 1, + spreadAmount: 1, + taxAmount: 1, + commissionAmount: 1 + } as SwapOperationData + ]; + const opsByPair = ops.slice(0, 2); + + it("test-calculateSwapOhlcv-should-return-correctly-swap-ohlcv", () => { + // setup + const pair = "orai-usdt"; + jest.spyOn(helper, "calculateBasePriceFromSwapOp").mockReturnValue(1); + jest.spyOn(helper, "concatOhlcvToUniqueKey").mockReturnValue("orai-usdt-unique-key"); - // it.each([ - // [ - // [ - // { - // timestamp: 60000, - // pair: "orai-usdt", - // price: 1, - // volume: 100n - // }, - // { - // timestamp: 60000, - // pair: "orai-usdt", - // price: 2, - // volume: 100n - // } - // ], - // { - // open: 1, - // close: 2, - // low: 1, - // high: 2, - // volume: 200n, - // timestamp: 60000, - // pair: "orai-usdt" - // } - // ] - // ])("test-calculateOhlcv", (ops, expectedOhlcv) => { - // const ohlcv = calculateSwapOhlcv(ops); - // expect(toObject(ohlcv)).toEqual(toObject(expectedOhlcv)); - // }); + // act + const swapOhlcv = helper.calculateSwapOhlcv(opsByPair, pair); + + // assertion + expect(swapOhlcv).toStrictEqual({ + uniqueKey: "orai-usdt-unique-key", + timestamp: 1, + pair, + volume: 3n, + open: 1, + close: 1, + low: 1, + high: 1 + }); + }); + + it("test-groupSwapOpsByPair-should-return-correctly-group-swap-ops-by-pair", () => { + // act + const result = helper.groupSwapOpsByPair(ops); + + // assertion + expect(result[`${ORAI}-${usdtCw20Address}`].length).toEqual(opsByPair.length); + expect(result[`${ORAI}-${usdtCw20Address}`][0]).toStrictEqual(opsByPair[0]); + expect(result[`${ORAI}-${usdtCw20Address}`][1]).toStrictEqual(opsByPair[1]); + expect(result[`${ORAI}-${atomIbcDenom}`][0]).toStrictEqual(ops[2]); + }); + }); it.each([ ["Buy" as SwapDirection, 2], ["Sell" as SwapDirection, 0.5] - ])("test-calculatePriceFromOrder", (direction: SwapDirection, expectedPrice: number) => { + ])("test-calculateBasePriceFromSwapOp", (direction: SwapDirection, expectedPrice: number) => { const swapOp = { offerAmount: 2, offerDenom: ORAI, @@ -582,15 +666,10 @@ describe("test-helper", () => { it.each([ [usdtCw20Address, "orai", "Buy" as SwapDirection], - ["orai", usdtCw20Address, "Sell" as SwapDirection] - ])("test-getSwapDirection", (offerDenom: string, askDenom: string, expectedDirection: SwapDirection) => { + ["orai", usdtCw20Address, "Sell" as SwapDirection], + ["foo", "bar", undefined] + ])("test-getSwapDirection", (offerDenom: string, askDenom: string, expectedDirection: SwapDirection | undefined) => { // execute - // throw error case when offer & ask not in pair - try { - getSwapDirection("foo", "bar"); - } catch (error) { - expect(error).toEqual(new Error("Cannot find asset infos in list of pairs")); - } const result = getSwapDirection(offerDenom, askDenom); expect(result).toEqual(expectedDirection); }); @@ -607,4 +686,206 @@ describe("test-helper", () => { expect(result).toEqual(expectedIndex); } ); + + // it.each([ + // ["case-asset-info-pairs-is-NOT-reversed", [oraiInfo, usdtInfo], false], + // ["case-asset-info-pairs-is-reversed", [usdtInfo, oraiInfo], true] + // ])( + // "test-isAssetInfoPairReverse-should-return-correctly", + // (_caseName: string, assetInfos: AssetInfo[], expectedResult: boolean) => { + // const result = helper.isAssetInfoPairReverse(assetInfos); + // expect(result).toBe(expectedResult); + // } + // ); + + it("test-getSymbolFromAsset-should-throw-error-for-assetInfos-not-valid", () => { + const asset_infos = [oraiInfo, { token: { contract_addr: "invalid-token" } }] as [AssetInfo, AssetInfo]; + expect(() => helper.getSymbolFromAsset(asset_infos)).toThrowError( + `cannot found pair with asset_infos: ${JSON.stringify(asset_infos)}` + ); + }); + + it("test-getSymbolFromAsset-should-return-correctly-symbol-of-pair-for-valid-assetInfos", () => { + const asset_infos = [oraiInfo, usdtInfo] as [AssetInfo, AssetInfo]; + expect(helper.getSymbolFromAsset(asset_infos)).toEqual("ORAI/USDT"); + }); + + it.each([ + [oraiInfo, 1n], + [{ native_token: { denom: atomIbcDenom } }, 0n] + ])("test-parsePoolAmount-given-trueAsset-%p-should-return-%p", (assetInfo: AssetInfo, expectedResult: bigint) => { + // setup + const poolInfo: PoolResponse = { + assets: [ + { + info: oraiInfo, + amount: "1" + }, + { + info: usdtInfo, + amount: "1" + } + ], + total_share: "5" + }; + + // act + const result = helper.parsePoolAmount(poolInfo, assetInfo); + + // assertion + expect(result).toEqual(expectedResult); + }); + + describe("test-get-pair-liquidity", () => { + beforeEach(async () => { + duckDb = await DuckDb.create(":memory:"); + }); + + it.each([ + [0n, 0n, 0], + [1n, 1n, 4] + ])( + "test-getPairLiquidity-should-return-correctly-liquidity-by-USDT", + async (offerPoolAmount: bigint, askPoolAmount: bigint, expectedResult: number) => { + // setup + await duckDb.createLpAmountHistoryTable(); + await duckDb.insertPoolAmountHistory([ + { + offerPoolAmount, + askPoolAmount, + timestamp: 1, + height: 1, + pairAddr: "oraiUsdtPairAddr", + uniqueKey: "1" + } + ]); + const poolInfo: PairInfoData = { + firstAssetInfo: JSON.stringify(oraiInfo), + secondAssetInfo: JSON.stringify(usdtInfo), + commissionRate: "", + pairAddr: "oraiUsdtPairAddr", + liquidityAddr: "", + oracleAddr: "", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" + }; + jest.spyOn(poolHelper, "getPriceAssetByUsdt").mockResolvedValue(2); + + // act + const result = await helper.getPairLiquidity(poolInfo); + + // assertion + expect(result).toEqual(expectedResult); + } + ); + }); + + describe("test-get-volume-pairs", () => { + it("test-getVolumePairByAsset-should-return-correctly-sum-volume-swap-&-liquidity", async () => { + //setup mock + duckDb = await DuckDb.create(":memory:"); + jest.spyOn(duckDb, "getVolumeSwap").mockResolvedValue(1n); + jest.spyOn(duckDb, "getVolumeLiquidity").mockResolvedValue(1n); + + // act + const result = await getVolumePairByAsset(["orai", "usdt"], new Date(1693394183), new Date(1693394183)); + + // assert + expect(result).toEqual(2n); + }); + + it("test-getVolumePairByUsdt-should-return-correctly-volume-pair-in-USDT", async () => { + //setup + const [baseAssetInfo, quoteAssetInfo] = [oraiInfo, usdtInfo]; + jest.spyOn(helper, "getVolumePairByAsset").mockResolvedValue(1n); + jest.spyOn(poolHelper, "getPriceAssetByUsdt").mockResolvedValue(2); + + // act + const result = await getVolumePairByUsdt( + [baseAssetInfo, quoteAssetInfo], + new Date(1693394183), + new Date(1693394183) + ); + + // assert + expect(result).toEqual(2n); + }); + + it("test-getAllVolume24h-should-return-correctly-volume-all-pair", async () => { + //setup mock + jest.spyOn(helper, "getVolumePairByUsdt").mockResolvedValue(1n); + + // act + const result = await helper.getAllVolume24h(); + + // assert + expect(result.length).toEqual(pairs.length); + expect(result.every((value) => value === 1n)); + }); + }); + + describe("test-get-fee-pair", () => { + it("test-getFeePair-should-return-correctly-sum-fee-swap-&-liquidity", async () => { + //setup mock + duckDb = await DuckDb.create(":memory:"); + jest.spyOn(duckDb, "getFeeSwap").mockResolvedValue(1n); + jest.spyOn(duckDb, "getFeeLiquidity").mockResolvedValue(1n); + + // act + const result = await helper.getFeePair([oraiInfo, usdtInfo], new Date(1693394183), new Date(1693394183)); + + // assert + expect(result).toEqual(2n); + }); + + it("test-getAllFees-should-return-correctly-fee-all-pair", async () => { + //setup mock + jest.spyOn(helper, "getFeePair").mockResolvedValue(1n); + + // act + const result = await helper.getAllFees(); + + // assert + expect(result.length).toEqual(pairs.length); + expect(result.every((value) => value === 1n)); + }); + }); + + it.each([ + [ + [oraiInfo, usdtInfo], + [ + { + orai_swap: { + offer_asset_info: oraiInfo, + ask_asset_info: usdtInfo + } + } + ] + ], + [ + [oraiInfo, usdtInfo, { native_token: { denom: atomIbcDenom } }], + [ + { + orai_swap: { + offer_asset_info: oraiInfo, + ask_asset_info: usdtInfo + } + }, + { + orai_swap: { + offer_asset_info: usdtInfo, + ask_asset_info: { native_token: { denom: atomIbcDenom } } + } + } + ] + ] + ])( + "test-generateSwapOperations-should-return-correctly-swap-ops", + (infoPath: AssetInfo[], expectedResult: SwapOperation[]) => { + const result = helper.generateSwapOperations(infoPath); + expect(result).toStrictEqual(expectedResult); + } + ); }); diff --git a/packages/oraidex-sync/tests/pool-helper.spec.ts b/packages/oraidex-sync/tests/pool-helper.spec.ts new file mode 100644 index 00000000..ffadf021 --- /dev/null +++ b/packages/oraidex-sync/tests/pool-helper.spec.ts @@ -0,0 +1,504 @@ +import { Asset, AssetInfo, OraiswapStakingTypes } from "@oraichain/oraidex-contracts-sdk"; +import { + ORAI, + airiCw20Adress, + atomIbcDenom, + milkyCw20Address, + oraiInfo, + scAtomCw20Address, + usdtCw20Address, + usdtInfo +} from "../src/constants"; + +import * as helper from "../src/helper"; +import { DuckDb, pairs } from "../src/index"; +import * as poolHelper from "../src/pool-helper"; +import { PairInfoData, PairMapping, ProvideLiquidityOperationData } from "../src/types"; +import { Tx } from "@oraichain/cosmos-rpc-sync"; +import { Tx as CosmosTx } from "cosmjs-types/cosmos/tx/v1beta1/tx"; +import * as txParsing from "../src/tx-parsing"; + +describe("test-pool-helper", () => { + let duckDb: DuckDb; + + afterAll(jest.resetModules); + afterEach(jest.resetModules); + afterEach(jest.restoreAllMocks); + + it.each<[string, [AssetInfo, AssetInfo], boolean]>([ + [ + "has-both-native-token-that-contain-ORAI-should-return: false", + [oraiInfo, { native_token: { denom: atomIbcDenom } }], + false + ], + // [ + // // NOTE: currently this case not exist, but in future maybe + // "has-both-native-token-that-NOT-contain-ORAI-should-return: true", + // [osmosisIbcDenom, atomIbcDenom], + // true + // ], + [ + "has-one-native-token-that-NOT-contain-ORAI-should-return: true", + [ + { native_token: { denom: atomIbcDenom } }, + { + token: { + contract_addr: scAtomCw20Address + } + } + ], + true + ], + [ + "NOT-has-native-token-should-return-is-has-fee: false", + [ + { + token: { + contract_addr: milkyCw20Address + } + }, + usdtInfo + ], + false + ] + ])("test-isPoolHasFee-with-pool-%s", (_caseName, assetInfos, expectIsHasFee) => { + const result = poolHelper.isPoolHasFee(assetInfos); + expect(result).toBe(expectIsHasFee); + }); + + it.each<[string, [AssetInfo, AssetInfo], PairMapping | undefined]>([ + [ + "assetInfos-valid-in-list-pairs", + [usdtInfo, oraiInfo], + { + asset_infos: [oraiInfo, usdtInfo], + symbols: ["ORAI", "USDT"], + factoryV1: true + } + ], + [ + "assetInfos-invalid-in-list-pairs", + [ + { + token: { + contract_addr: "invalid" + } + }, + { + native_token: { + denom: atomIbcDenom + } + } + ], + undefined + ] + ])( + "test-getPairByAssetInfos-with-%s-should-return-correctly-pair", + (_caseName: string, assetInfos: [AssetInfo, AssetInfo], expectedPair: PairMapping | undefined) => { + const result = poolHelper.getPairByAssetInfos(assetInfos); + expect(result).toStrictEqual(expectedPair); + } + ); + + describe("test-calculate-price-group-funcs", () => { + // use orai/usdt in this test suite + // it("test-getPriceByAsset-when-duckdb-empty-should-return-0", async () => { + // // setup + // duckDb = await DuckDb.create(":memory:"); + // await Promise.all([duckDb.createPairInfosTable()]); + + // // act & assertion + // const result = await poolHelper.getPriceByAsset([oraiInfo, usdtInfo], "base_in_quote"); + // expect(result).toEqual(0); + // }); + + it.each<[[AssetInfo, AssetInfo], poolHelper.RatioDirection, number]>([ + [[oraiInfo, { token: { contract_addr: "invalid-token" } }], "base_in_quote", 0], + [[oraiInfo, usdtInfo], "base_in_quote", 0.5], + [[oraiInfo, usdtInfo], "quote_in_base", 2] + ])("test-getPriceByAsset-should-return-correctly-price", async (assetInfos, ratioDirection, expectedPrice) => { + // setup + duckDb = await DuckDb.create(":memory:"); + await Promise.all([duckDb.createPairInfosTable(), duckDb.createLpAmountHistoryTable()]); + + const pairAddr = "orai1c5s03c3l336dgesne7dylnmhszw8554tsyy9yt"; + let pairInfoData: PairInfoData[] = [ + { + firstAssetInfo: JSON.stringify({ native_token: { denom: ORAI } } as AssetInfo), + secondAssetInfo: JSON.stringify({ token: { contract_addr: usdtCw20Address } } as AssetInfo), + commissionRate: "", + pairAddr, + liquidityAddr: "", + oracleAddr: "", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" + } + ]; + await duckDb.insertPairInfos(pairInfoData); + await duckDb.insertPoolAmountHistory([ + { + offerPoolAmount: 1n, + askPoolAmount: 1n, + height: 1, + timestamp: 1, + pairAddr, + uniqueKey: "1" + } + ]); + + jest.spyOn(helper, "calculatePriceByPool").mockReturnValue(0.5); + + // assert + const result = await poolHelper.getPriceByAsset(assetInfos, ratioDirection); + expect(result).toEqual(expectedPrice); + }); + + it.each([ + ["asset-is-cw20-USDT", usdtInfo, 1], + [ + "asset-is-MILKY-that-mapped-with-USDT", + { + token: { + contract_addr: milkyCw20Address + } + }, + 0.5 + ], + ["asset-is-ORAI", oraiInfo, 2], + [ + "asset-is-pair-with-ORAI", + { + native_token: { + denom: atomIbcDenom + } + }, + 1 + ], + [ + "asset-is-NOT-pair-with-ORAI", + { + token: { + contract_addr: scAtomCw20Address + } + }, + 0.5 + ] + ])( + "test-getPriceAssetByUsdt-with-%p-should-return-correctly-price-of-asset-in-USDT", + async (_caseName: string, assetInfo: AssetInfo, expectedPrice: number) => { + // setup & mock + duckDb = await DuckDb.create(":memory:"); + await Promise.all([duckDb.createPairInfosTable(), duckDb.createLiquidityOpsTable()]); + const pairInfoData: PairInfoData[] = [ + { + firstAssetInfo: JSON.stringify(oraiInfo as AssetInfo), + secondAssetInfo: JSON.stringify(usdtInfo as AssetInfo), + commissionRate: "", + pairAddr: "orai1c5s03c3l336dgesne7dylnmhszw8554tsyy9yt", + liquidityAddr: "", + oracleAddr: "", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" + }, + { + firstAssetInfo: JSON.stringify(oraiInfo as AssetInfo), + secondAssetInfo: JSON.stringify({ native_token: { denom: atomIbcDenom } } as AssetInfo), + commissionRate: "", + pairAddr: "orai/atom", + liquidityAddr: "", + oracleAddr: "", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" + } + ]; + await duckDb.insertPairInfos(pairInfoData); + const lpOpsData: ProvideLiquidityOperationData[] = [ + { + basePrice: 1, + baseTokenAmount: 1, + baseTokenDenom: "orai", + baseTokenReserve: 1000000000 / 2, + opType: "withdraw", + uniqueKey: "2", + quoteTokenAmount: 2, + quoteTokenDenom: usdtCw20Address, + quoteTokenReserve: 1000000000, + timestamp: 1, + txCreator: "foobar", + txhash: "foo", + txheight: 1, + taxRate: 1n + } + ]; + await duckDb.insertLpOps(lpOpsData); + jest.spyOn(poolHelper, "getOraiPrice").mockResolvedValue(2); + jest.spyOn(poolHelper, "getPriceByAsset").mockResolvedValue(0.5); + + // act + const result = await poolHelper.getPriceAssetByUsdt(assetInfo); + + // assertion + expect(result).toEqual(expectedPrice); + } + ); + }); + + describe("test-calculate-fee-of-pools", () => { + it.each([ + [ + "with-case-asset-is-cw20-token-should-return-null", + { + info: { + token: { + contract_addr: airiCw20Adress + } + }, + amount: "100" + }, + null + ], + [ + "with-case-asset-is-native-token-should-return-correctly-fee", + { + info: { + native_token: { + denom: atomIbcDenom + } + }, + amount: "100" + }, + { + amount: "11.53846153846154", + info: { native_token: { denom: atomIbcDenom } } + } + ] + ])("test-calculateFeeByAsset-%s", (_caseName: string, inputAsset: Asset, expectedFee: Asset | null) => { + const shareRatio = 0.5; + const result = poolHelper.calculateFeeByAsset(inputAsset, shareRatio); + expect(result).toStrictEqual(expectedFee); + }); + + it("test-calculateLiquidityFee-should-return-correctly-fee-in-USDT", async () => { + // mock + jest.spyOn(poolHelper, "convertFeeAssetToUsdt").mockResolvedValue(1e6); + + // act + const liquidityFee = await poolHelper.calculateLiquidityFee( + { + firstAssetInfo: "1", + secondAssetInfo: "1", + commissionRate: "1", + pairAddr: "orai1c5s03c3l336dgesne7dylnmhszw8554tsyy9yt", + liquidityAddr: "1", + oracleAddr: "1", + symbols: "1", + fromIconUrl: "1", + toIconUrl: "1" + }, + 13344890, + 1 + ); + + // assertion + expect(liquidityFee).toEqual(2000000n); + }); + + it.each([ + ["test-convertFeeAssetToUsdt-with-asset-NULL-should-return-fee-is-0", null, 0], + [ + "test-convertFeeAssetToUsdt-with-asset-native-should-return-correctly-fee", + { + info: oraiInfo, + amount: "1" + }, + 2 + ] + ])("%s", async (_caseName: string, assetFee: Asset | null, expectedResult: number) => { + // mock + jest.spyOn(poolHelper, "getPriceAssetByUsdt").mockResolvedValueOnce(2); + + // act + const result = await poolHelper.convertFeeAssetToUsdt(assetFee); + + // assert + expect(result).toEqual(expectedResult); + }); + }); + + describe("test-calculate-APR-pool", () => { + it.each<[string, AssetInfo[], AssetInfo]>([ + [ + "case-asset-info-pairs-is-NOT-reversed-and-base-asset-NOT-ORAI", + [ + { + token: { + contract_addr: scAtomCw20Address + } + }, + { + native_token: { + denom: atomIbcDenom + } + } + ], + { + token: { + contract_addr: scAtomCw20Address + } + } + ], + ["case-asset-info-pairs-is-NOT-reversed-and-base-asset-is-ORAI", [oraiInfo, usdtInfo], usdtInfo], + [ + "case-asset-info-pairs-is-reversed-and-base-asset-NOT-ORAI", + [ + { + native_token: { + denom: atomIbcDenom + } + }, + { + token: { + contract_addr: scAtomCw20Address + } + } + ], + { + token: { + contract_addr: scAtomCw20Address + } + } + ], + ["case-asset-info-pairs-is-reversed-and-base-asset-is-ORAI", [usdtInfo, oraiInfo], usdtInfo] + ])( + "test-getStakingAssetInfo-with-%p-should-return-correctly-staking-asset-info", + (_caseName: string, assetInfos: AssetInfo[], expectedStakingAssetInfo: AssetInfo) => { + const result = poolHelper.getStakingAssetInfo(assetInfos); + expect(result).toStrictEqual(expectedStakingAssetInfo); + } + ); + }); + + it("test-calculateAprResult-should-return-correctly-APR", async () => { + // setup + const allLiquidities = Array(pairs.length).fill(1e6); + const allTotalSupplies = Array(pairs.length).fill("100000"); + const allBondAmounts = Array(pairs.length).fill("1"); + const allRewardPerSec: OraiswapStakingTypes.RewardsPerSecResponse[] = Array(pairs.length).fill({ + assets: [ + { + amount: "1", + info: oraiInfo + } + ] + }); + jest.spyOn(poolHelper, "getPriceAssetByUsdt").mockResolvedValue(1); + + // act + const result = await poolHelper.calculateAprResult( + allLiquidities, + allTotalSupplies, + allBondAmounts, + allRewardPerSec + ); + + // assertion + expect(result.length).toEqual(pairs.length); + expect(result).toStrictEqual(Array(pairs.length).fill(315360000)); + }); + + it.each([ + [true, pairs.length, pairs.length], + [false, 4, 0] + ])( + "test-getListAssetInfoShouldRefetchApr-with-is-isTriggerRewardPerSec-%p-shoud-return-listAssetInfosPoolShouldRefetch-length-%p-and-assetInfosTriggerRewardPerSec-length-%p", + async ( + isTriggerRewardPerSec: boolean, + expectedListAssetInfosPoolShouldRefetch: number, + expectedAssetInfosTriggerRewardPerSec: number + ) => { + // setup + const cosmosTx = CosmosTx.encode( + CosmosTx.fromPartial({ body: { messages: [{ typeUrl: "/cosmwasm.wasm.v1.MsgExecuteContract" }] } }) + ).finish(); + const txs: Tx[] = [ + { + hash: "", + height: 1, + code: 1, + txIndex: 0, + tx: cosmosTx, + timestamp: new Date().toISOString(), + rawLog: JSON.stringify({ events: [] }), + events: [], + msgResponses: [{ typeUrl: "", value: Buffer.from("") }], + gasUsed: 1, + gasWanted: 1 + } + ]; + + const ops: ProvideLiquidityOperationData[] = [ + { + basePrice: 1, + baseTokenAmount: 1, + baseTokenDenom: ORAI, + quoteTokenAmount: 1, + quoteTokenDenom: usdtCw20Address, + baseTokenReserve: 1, + quoteTokenReserve: 1, + opType: "provide", + uniqueKey: "1", + timestamp: 1, + txCreator: "a", + txhash: "a", + txheight: 1, + taxRate: 1n + }, + { + basePrice: 1, + baseTokenAmount: 1, + baseTokenDenom: ORAI, + quoteTokenAmount: 1, + quoteTokenDenom: usdtCw20Address, + baseTokenReserve: 1, + quoteTokenReserve: 1, + opType: "withdraw", + uniqueKey: "2", + timestamp: 1, + txCreator: "a", + txhash: "a", + txheight: 1, + taxRate: 1n + }, + { + basePrice: 1, + baseTokenAmount: 1, + baseTokenDenom: ORAI, + quoteTokenAmount: 1, + quoteTokenDenom: atomIbcDenom, + baseTokenReserve: 1, + quoteTokenReserve: 1, + opType: "withdraw", + uniqueKey: "1", + timestamp: 1, + txCreator: "a", + txhash: "a", + txheight: 1, + taxRate: 1n + } + ]; + jest.spyOn(txParsing, "processEventApr").mockReturnValue({ + isTriggerRewardPerSec, + infoTokenAssetPools: new Set([airiCw20Adress, scAtomCw20Address]) + }); + + const result = await poolHelper.getListAssetInfoShouldRefetchApr(txs, ops); + expect(result.assetInfosTriggerTotalSupplies.length).toEqual(2); + expect(result.assetInfosTriggerTotalBond.length).toEqual(2); + expect(result.listAssetInfosPoolShouldRefetch.length).toEqual(expectedListAssetInfosPoolShouldRefetch); + expect(result.assetInfosTriggerRewardPerSec.length).toEqual(expectedAssetInfosTriggerRewardPerSec); + } + ); +});