UNPKG

@marinade.finance/kamino-sdk

Version:
401 lines (356 loc) 13.3 kB
import { Connection, PublicKey } from '@solana/web3.js'; import Decimal from 'decimal.js'; import { estimateAprsForPriceRange, OrcaNetwork, OrcaWhirlpoolClient, getNearestValidTickIndexFromTickIndex, priceToTickIndex, PoolData, } from '@orca-so/whirlpool-sdk'; import axios from 'axios'; import { OrcaWhirlpoolsResponse, Whirlpool } from './OrcaWhirlpoolsResponse'; import { SolanaCluster } from '@hubbleprotocol/hubble-config'; import { CollateralInfos, GlobalConfig, WhirlpoolStrategy } from '../kamino-client/accounts'; import { OraclePrices, Scope, ScopeToken } from '@hubbleprotocol/scope-sdk'; import { Position } from '../whirpools-client'; import { WhirlpoolAprApy } from './WhirlpoolAprApy'; import { aprToApy, GenericPoolInfo, getStrategyPriceRangeOrca, LiquidityDistribution, LiquidityForPrice, ZERO, } from '../utils'; import { WHIRLPOOL_PROGRAM_ID } from '../whirpools-client/programId'; import { CollateralInfo } from '../kamino-client/types'; import { StrategyPrices } from '../models/StrategyPrices'; export class OrcaService { private readonly _connection: Connection; private readonly _cluster: SolanaCluster; private readonly _orcaNetwork: OrcaNetwork; private readonly _orcaApiUrl: string; private readonly _globalConfig: PublicKey; constructor(connection: Connection, cluster: SolanaCluster, globalConfig: PublicKey) { this._connection = connection; this._cluster = cluster; this._globalConfig = globalConfig; this._orcaNetwork = cluster === 'mainnet-beta' ? OrcaNetwork.MAINNET : OrcaNetwork.DEVNET; this._orcaApiUrl = `https://api.${cluster === 'mainnet-beta' ? 'mainnet' : 'devnet'}.orca.so`; } async getOrcaWhirlpools() { return (await axios.get<OrcaWhirlpoolsResponse>(`${this._orcaApiUrl}/v1/whirlpool/list`)).data; } /** * Get token prices for a strategy - for use with orca sdk * @param strategy * @param prices * @param collateralInfos * @returns {Record<string, Decimal>} - token prices by mint string * @private */ private getTokenPrices( strategy: WhirlpoolStrategy, prices: OraclePrices, collateralInfos: CollateralInfo[] ): Record<string, Decimal> { const tokensPrices: Record<string, Decimal> = {}; const tokenA = collateralInfos[strategy.tokenACollateralId.toNumber()]; const tokenB = collateralInfos[strategy.tokenBCollateralId.toNumber()]; const rewardToken0 = collateralInfos[strategy.reward0CollateralId.toNumber()]; const rewardToken1 = collateralInfos[strategy.reward1CollateralId.toNumber()]; const rewardToken2 = collateralInfos[strategy.reward2CollateralId.toNumber()]; const aPrice = Scope.getPriceFromScopeChain(tokenA.scopePriceChain, prices); const bPrice = Scope.getPriceFromScopeChain(tokenB.scopePriceChain, prices); const reward0Price = strategy.reward0Decimals.toNumber() !== 0 ? Scope.getPriceFromScopeChain(rewardToken0.scopePriceChain, prices) : null; const reward1Price = strategy.reward1Decimals.toNumber() !== 0 ? Scope.getPriceFromScopeChain(rewardToken1.scopePriceChain, prices) : null; const reward2Price = strategy.reward2Decimals.toNumber() !== 0 ? Scope.getPriceFromScopeChain(rewardToken2.scopePriceChain, prices) : null; const [mintA, mintB] = [strategy.tokenAMint.toString(), strategy.tokenBMint.toString()]; const reward0 = collateralInfos[strategy.reward0CollateralId.toNumber()]?.mint?.toString(); const reward1 = collateralInfos[strategy.reward1CollateralId.toNumber()]?.mint?.toString(); const reward2 = collateralInfos[strategy.reward2CollateralId.toNumber()]?.mint?.toString(); tokensPrices[mintA] = aPrice; tokensPrices[mintB] = bPrice; if (reward0Price !== null) { tokensPrices[reward0] = reward0Price; } if (reward1Price !== null) { tokensPrices[reward1] = reward1Price; } if (reward2Price !== null) { tokensPrices[reward2] = reward2Price; } return tokensPrices; } private getPoolTokensPrices(pool: PoolData, prices: ScopeToken[]) { const tokensPrices: Record<string, Decimal> = {}; const tokens = [ pool.tokenMintA.toString(), pool.tokenMintB.toString(), pool.rewards[0].mint.toString(), pool.rewards[1].mint.toString(), pool.rewards[2].mint.toString(), ]; for (const mint of tokens) { if (mint) { const price = prices.find((x) => x.mint?.toString() === mint)?.price; if (!price) { throw new Error(`Could not get token ${mint} price`); } tokensPrices[mint] = price; } } return tokensPrices; } async getPool(poolAddress: PublicKey) { const orca = new OrcaWhirlpoolClient({ connection: this._connection, network: this._orcaNetwork, }); return await orca.getPool(poolAddress); } async getStrategyWhirlpoolPoolAprApy( strategy: WhirlpoolStrategy, whirlpools?: Whirlpool[], prices?: OraclePrices ): Promise<WhirlpoolAprApy> { const orca = new OrcaWhirlpoolClient({ connection: this._connection, network: this._orcaNetwork, }); const scope = new Scope(this._cluster, this._connection); if (!prices) { prices = await scope.getOraclePrices(); } const position = await Position.fetch(this._connection, strategy.position); if (!position) { throw new Error(`Position ${strategy.position} does not exist`); } const pool = await orca.getPool(strategy.pool); if (!whirlpools) { ({ whirlpools } = await this.getOrcaWhirlpools()); } const whirlpool = whirlpools?.find((x) => x.address === strategy.pool.toString()); if (!pool || !whirlpool) { throw Error(`Could not get orca pool data for ${strategy.pool}`); } const priceRange = getStrategyPriceRangeOrca( position.tickLowerIndex, position.tickUpperIndex, strategy, new Decimal(pool.price.toString()) ); if (priceRange.strategyOutOfRange) { return { ...priceRange, rewardsApy: [], rewardsApr: [], feeApy: ZERO, feeApr: ZERO, totalApy: ZERO, totalApr: ZERO, }; } const lpFeeRate = pool.feePercentage; const volume24hUsd = whirlpool?.volume?.day ?? new Decimal(0); const fee24Usd = new Decimal(volume24hUsd).mul(lpFeeRate).toNumber(); const config = await GlobalConfig.fetch(this._connection, this._globalConfig); if (!config) { throw Error(`Could not fetch globalConfig with pubkey ${this._globalConfig}`); } const collateralInfos = await CollateralInfos.fetch(this._connection, config.tokenInfos); if (!collateralInfos) { throw Error('Could not fetch collateral infos'); } const tokensPrices = this.getTokenPrices(strategy, prices, collateralInfos.infos); const apr = estimateAprsForPriceRange( pool, tokensPrices, fee24Usd, position.tickLowerIndex, position.tickUpperIndex ); const totalApr = new Decimal(apr.fee).add(apr.rewards[0]).add(apr.rewards[1]).add(apr.rewards[2]); const feeApr = new Decimal(apr.fee); const rewardsApr = apr.rewards.map((r) => new Decimal(r)); return { totalApr, totalApy: aprToApy(totalApr, 365), feeApr, feeApy: aprToApy(feeApr, 365), rewardsApr, rewardsApy: rewardsApr.map((x) => aprToApy(x, 365)), ...priceRange, }; } // strongly recommended to pass lowestTick and highestTick because fetching the lowest and highest existent takes very long async getWhirlpoolLiquidityDistribution( pool: PublicKey, keepOrder: boolean = true, lowestTick?: number, highestTick?: number ): Promise<LiquidityDistribution> { const orca = new OrcaWhirlpoolClient({ connection: this._connection, network: this._orcaNetwork, }); const poolData = await orca.getPool(pool); if (!poolData) { throw new Error(`Could not get pool data for Whirlpool ${pool}`); } let lowestInitializedTick: number; if (lowestTick) { lowestInitializedTick = lowestTick; } else { lowestInitializedTick = await orca.pool.getLowestInitializedTickArrayTickIndex(pool, poolData.tickSpacing); } let highestInitializedTick: number; if (highestTick) { highestInitializedTick = highestTick; } else { highestInitializedTick = await orca.pool.getHighestInitializedTickArrayTickIndex(pool, poolData.tickSpacing); } const orcaLiqDistribution = await orca.pool.getLiquidityDistribution( pool, lowestInitializedTick, highestInitializedTick ); let liqDistribution: LiquidityDistribution = { currentPrice: poolData.price, currentTickIndex: poolData.tickCurrentIndex, distribution: [], }; orcaLiqDistribution.datapoints.forEach((entry) => { let priceWithOrder = new Decimal(entry.price); if (!keepOrder) { priceWithOrder = new Decimal(1).div(priceWithOrder); } const liq: LiquidityForPrice = { price: priceWithOrder, liquidity: entry.liquidity, tickIndex: entry.tickIndex, }; liqDistribution.distribution.push(liq); }); return liqDistribution; } async getWhirlpoolPositionAprApy( poolPubkey: PublicKey, priceLower: Decimal, priceUpper: Decimal, whirlpools?: Whirlpool[], prices?: ScopeToken[] ): Promise<WhirlpoolAprApy> { const orca = new OrcaWhirlpoolClient({ connection: this._connection, network: this._orcaNetwork, }); const scope = new Scope(this._cluster, this._connection); if (!prices) { prices = await scope.getAllPrices(); } const pool = await orca.getPool(poolPubkey); if (!whirlpools) { ({ whirlpools } = await this.getOrcaWhirlpools()); } const whirlpool = whirlpools?.find((x) => x.address === poolPubkey.toString()); if (!pool || !whirlpool) { throw Error(`Could not get orca pool data for ${poolPubkey}`); } let strategyOutOfRange = false; if (priceLower.gt(pool.price) || priceUpper.lt(pool.price)) { strategyOutOfRange = true; } if (strategyOutOfRange) { return { priceLower, priceUpper, strategyOutOfRange, poolPrice: pool.price, rewardsApy: [], rewardsApr: [], feeApy: ZERO, feeApr: ZERO, totalApy: ZERO, totalApr: ZERO, }; } const lpFeeRate = pool.feePercentage; const volume24hUsd = whirlpool?.volume?.day ?? new Decimal(0); const fee24Usd = new Decimal(volume24hUsd).mul(lpFeeRate).toNumber(); let tokensPrices = this.getPoolTokensPrices(pool, prices); const tickLowerIndex = getNearestValidTickIndexFromTickIndex( priceToTickIndex(priceLower, pool.tokenDecimalsA, pool.tokenDecimalsB), whirlpool.tickSpacing ); const tickUpperIndex = getNearestValidTickIndexFromTickIndex( priceToTickIndex(priceUpper, pool.tokenDecimalsA, pool.tokenDecimalsB), whirlpool.tickSpacing ); const apr = estimateAprsForPriceRange(pool, tokensPrices, fee24Usd, tickLowerIndex, tickUpperIndex); const totalApr = new Decimal(apr.fee).add(apr.rewards[0]).add(apr.rewards[1]).add(apr.rewards[2]); const feeApr = new Decimal(apr.fee); const rewardsApr = apr.rewards.map((r) => new Decimal(r)); return { totalApr, totalApy: aprToApy(totalApr, 365), feeApr, feeApy: aprToApy(feeApr, 365), rewardsApr, rewardsApy: rewardsApr.map((x) => aprToApy(x, 365)), priceLower, priceUpper, poolPrice: pool.price, strategyOutOfRange, }; } async getGenericPoolInfo(poolPubkey: PublicKey, whirlpools?: Whirlpool[]) { const orca = new OrcaWhirlpoolClient({ connection: this._connection, network: this._orcaNetwork, }); const pool = await orca.getPool(poolPubkey); if (!whirlpools) { ({ whirlpools } = await this.getOrcaWhirlpools()); } const whirlpool = whirlpools?.find((x) => x.address === poolPubkey.toString()); if (!pool || !whirlpool) { throw Error(`Could not get orca pool data for ${poolPubkey}`); } let poolInfo: GenericPoolInfo = { dex: 'ORCA', address: new PublicKey(poolPubkey), tokenMintA: pool.tokenMintA, tokenMintB: pool.tokenMintB, price: pool.price, feeRate: pool.feePercentage, volumeOnLast7d: whirlpool.volume ? new Decimal(whirlpool.volume?.week) : undefined, tvl: whirlpool.tvl ? new Decimal(whirlpool.tvl) : undefined, tickSpacing: new Decimal(pool.tickSpacing), // todo(Silviu): get real amount of positions positions: new Decimal(0), }; return poolInfo; } async getPositionsCountByPool(pool: PublicKey): Promise<number> { const rawPositions = await this._connection.getProgramAccounts(WHIRLPOOL_PROGRAM_ID, { commitment: 'confirmed', filters: [ // account LAYOUT: https://github.com/orca-so/whirlpools/blob/main/programs/whirlpool/src/state/position.rs#L20 { dataSize: 216 }, { memcmp: { bytes: pool.toBase58(), offset: 8 } }, ], }); return rawPositions.length; } }