feat: multi price source implementation (#299)

This commit is contained in:
Yusuf Seyrek 2023-07-14 14:49:33 +03:00 committed by GitHub
parent 515036ac05
commit d949cc84b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 245 additions and 93 deletions

View File

@ -19,6 +19,8 @@ NEXT_PUBLIC_CANDLES_ENDPOINT="https://api.thegraph.com/subgraphs/name/{NAME}/{GR
CHARTING_LIBRARY_USERNAME="username_with_access_to_charting_library"
CHARTING_LIBRARY_ACCESS_TOKEN="access_token_with_access_to_charting_library"
CHARTING_LIBRARY_REPOSITORY="github.com/username/charting_library/"
NEXT_PUBLIC_PYTH_ENDPOINT=https://xc-mainnet.pyth.network/api/
NEXT_PUBLIC_MAINNET_REST=https://osmosis-node.marsprotocol.io/GGSFGSFGFG34/osmosis-lcd-front/
# MAINNET #
# NEXT_PUBLIC_NETWORK=mainnet
@ -40,3 +42,5 @@ CHARTING_LIBRARY_REPOSITORY="github.com/username/charting_library/"
# CHARTING_LIBRARY_USERNAME="username_with_access_to_charting_library"
# CHARTING_LIBRARY_ACCESS_TOKEN="access_token_with_access_to_charting_library"
# CHARTING_LIBRARY_REPOSITORY="username/charting_library"
# NEXT_PUBLIC_PYTH_ENDPOINT=https://xc-mainnet.pyth.network/api/
# NEXT_PUBLIC_MAINNET_REST=https://osmosis-node.marsprotocol.io/GGSFGSFGFG34/osmosis-lcd-front/

View File

@ -26,7 +26,7 @@ export default async function calculateAssetIncentivesApy(
const marsDecimals = 6,
priceFeedDecimals = 6
const assetPrice = BN(assetPriceResponse.price).shiftedBy(assetDecimals - priceFeedDecimals)
const assetPrice = BN(assetPriceResponse).shiftedBy(assetDecimals - priceFeedDecimals)
const marketLiquidityValue = BN(marketLiquidityAmount)
.shiftedBy(-assetDecimals)
.multipliedBy(assetPrice)

View File

@ -1,45 +1,14 @@
import getPrice from 'api/prices/getPrice'
import { BN } from 'utils/helpers'
const MARS_MAINNET_DENOM = 'ibc/573FCD90FACEE750F55A8864EF7D38265F07E5A9273FA0E8DAFD39951332B580'
const MARS_OSMO_POOL_URL = 'https://lcd-osmosis.blockapsis.com/osmosis/gamm/v1beta1/pools/907'
interface PoolToken {
denom: string
amount: string
}
interface PoolAsset {
token: PoolToken
weight: string
}
const findPoolAssetByTokenDenom = (assets: PoolAsset[], denom: string) =>
assets.find((a) => a.token.denom === denom)
import { ASSETS, MARS_MAINNET_DENOM } from 'constants/assets'
import { bySymbol } from 'utils/array'
import getPoolPrice from 'api/prices/getPoolPrice'
async function getMarsPrice() {
const marsOsmoRate = await getMarsOsmoRate()
const osmoPrice = await getPrice('uosmo')
const marsAsset = {
...(ASSETS.find(bySymbol('MARS')) as Asset),
denom: MARS_MAINNET_DENOM,
}
return marsOsmoRate.multipliedBy(osmoPrice.price)
}
const getMarsOsmoRate = async () => {
const resp = await fetch(MARS_OSMO_POOL_URL).then((res) => res.json())
const spotPrice = calculateSpotPrice(resp.pool.pool_assets)
return BN(1).dividedBy(spotPrice)
}
const calculateSpotPrice = (poolAssets: PoolAsset[]) => {
const assetIn = findPoolAssetByTokenDenom(poolAssets, MARS_MAINNET_DENOM) as PoolAsset
const assetOut = findPoolAssetByTokenDenom(poolAssets, 'uosmo') as PoolAsset
const numerator = BN(assetIn.token.amount).dividedBy(assetIn.weight)
const denominator = BN(assetOut.token.amount).dividedBy(assetOut.weight)
return numerator.dividedBy(denominator)
return await getPoolPrice(marsAsset)
}
export default getMarsPrice

View File

@ -0,0 +1,24 @@
import { getOracleQueryClient } from 'api/cosmwasm-client'
import { BNCoin } from 'types/classes/BNCoin'
import { BN } from 'utils/helpers'
export default async function getOraclePrices(...assets: Asset[]): Promise<BNCoin[]> {
try {
const baseDecimals = 6
const oracleQueryClient = await getOracleQueryClient()
const priceQueries = assets.map((asset) =>
oracleQueryClient.price({
denom: asset.denom,
}),
)
const priceResults = await Promise.all(priceQueries)
return priceResults.map(({ denom, price }, index) => {
const decimalDiff = assets[index].decimals - baseDecimals
return BNCoin.fromDenomAndBigNumber(denom, BN(price).shiftedBy(decimalDiff))
})
} catch (ex) {
throw ex
}
}

View File

@ -0,0 +1,49 @@
import { ENV } from 'constants/env'
import { byDenom, byTokenDenom, partition } from 'utils/array'
import { BN } from 'utils/helpers'
import getPrice from 'api/prices/getPrice'
import { BNCoin } from 'types/classes/BNCoin'
interface PoolToken {
denom: string
amount: string
}
interface PoolAsset {
token: PoolToken
weight: string
}
export default async function getPoolPrice(
asset: Asset,
lookupPricesForBaseAsset?: BNCoin[],
): Promise<BigNumber> {
if (!asset.poolId) throw 'given asset should have a poolId to fetch the price'
const [assetRate, baseAsset] = await getAssetRate(asset)
const baseAssetPrice =
(lookupPricesForBaseAsset &&
lookupPricesForBaseAsset.find(byDenom(baseAsset.token.denom))?.amount) ||
(await getPrice(baseAsset.token.denom))
if (!baseAssetPrice) throw 'base asset price must be available on Pyth or in Oracle contract'
return assetRate.multipliedBy(baseAssetPrice)
}
const getAssetRate = async (asset: Asset) => {
const url = `${ENV.MAINNET_REST_API}osmosis/gamm/v1beta1/pools/${asset.poolId}`
const response = await fetch(url).then((res) => res.json())
return calculateSpotPrice(response.pool.pool_assets, asset)
}
const calculateSpotPrice = (poolAssets: PoolAsset[], asset: Asset): [BigNumber, PoolAsset] => {
const [assetIn, assetOut] = partition(poolAssets, byTokenDenom(asset.denom)).flat()
const numerator = BN(assetIn.token.amount).dividedBy(assetIn.weight)
const denominator = BN(assetOut.token.amount).dividedBy(assetOut.weight)
const spotPrice = BN(1).dividedBy(numerator.dividedBy(denominator))
return [spotPrice, assetOut]
}

View File

@ -1,11 +1,30 @@
import { getOracleQueryClient } from 'api/cosmwasm-client'
import { PriceResponse } from 'types/generated/mars-mock-oracle/MarsMockOracle.types'
import { ASSETS } from 'constants/assets'
import { byDenom } from 'utils/array'
import getPythPrice from 'api/prices/getPythPrices'
import getPoolPrice from 'api/prices/getPoolPrice'
import { BN } from 'utils/helpers'
export default async function getPrice(denom: string): Promise<PriceResponse> {
export default async function getPrice(denom: string): Promise<BigNumber> {
try {
const oracleQueryClient = getOracleQueryClient()
const asset = ASSETS.find(byDenom(denom)) as Asset
return (await oracleQueryClient).price({ denom })
if (asset.pythPriceFeedId) {
return (await getPythPrice(asset.pythPriceFeedId))[0]
}
if (asset.hasOraclePrice) {
const oracleQueryClient = await getOracleQueryClient()
const priceResponse = await oracleQueryClient.price({ denom: asset.denom })
return BN(priceResponse.price)
}
if (asset.poolId) {
return await getPoolPrice(asset)
}
throw `could not fetch the price info for the given denom: ${denom}`
} catch (ex) {
throw ex
}

View File

@ -1,33 +1,58 @@
import { ASSETS } from 'constants/assets'
import { getEnabledMarketAssets } from 'utils/assets'
import { BN } from 'utils/helpers'
import { getOracleQueryClient } from 'api/cosmwasm-client'
import { getAssetsMustHavePriceInfo } from 'utils/assets'
import { partition } from 'utils/array'
import getPoolPrice from 'api/prices/getPoolPrice'
import fetchPythPrices from 'api/prices/getPythPrices'
import { BNCoin } from 'types/classes/BNCoin'
import getOraclePrices from 'api/prices/getOraclePrices'
export default async function getPrices(): Promise<Coin[]> {
export default async function getPrices(): Promise<BNCoin[]> {
try {
const enabledAssets = getEnabledMarketAssets()
const oracleQueryClient = await getOracleQueryClient()
const baseCurrency = ASSETS[0]
const assetsToFetchPrices = getAssetsMustHavePriceInfo()
const [assetsWithPythPriceFeedId, assetsWithOraclePrices, assetsWithPoolIds] =
separateAssetsByPriceSources(assetsToFetchPrices)
const priceQueries = enabledAssets.map((asset) =>
oracleQueryClient.price({
denom: asset.denom,
}),
)
const priceResults = await Promise.all(priceQueries)
const pythAndOraclePrices = (
await Promise.all([
requestPythPrices(assetsWithPythPriceFeedId),
getOraclePrices(...assetsWithOraclePrices),
])
).flat()
const poolPrices = await requestPoolPrices(assetsWithPoolIds, pythAndOraclePrices)
const assetPrices = priceResults.map(({ denom, price }, index) => {
const asset = enabledAssets[index]
const decimalDiff = asset.decimals - baseCurrency.decimals
return {
denom,
amount: BN(price).shiftedBy(decimalDiff).toString(),
}
})
return assetPrices
return [...pythAndOraclePrices, ...poolPrices]
} catch (ex) {
console.error(ex)
throw ex
}
}
async function requestPythPrices(assets: Asset[]): Promise<BNCoin[]> {
const priceFeedIds = assets.map((a) => a.pythPriceFeedId) as string[]
return await fetchPythPrices(...priceFeedIds).then(mapResponseToBnCoin(assets))
}
async function requestPoolPrices(assets: Asset[], lookupPrices: BNCoin[]): Promise<BNCoin[]> {
const requests = assets.map((asset) => getPoolPrice(asset, lookupPrices))
return await Promise.all(requests).then(mapResponseToBnCoin(assets))
}
const mapResponseToBnCoin = (assets: Asset[]) => (prices: BigNumber[]) =>
prices.map((price: BigNumber, index: number) =>
BNCoin.fromDenomAndBigNumber(assets[index].denom, price),
)
function separateAssetsByPriceSources(assets: Asset[]) {
const [assetsWithPythPriceFeedId, assetsWithoutPythPriceFeedId] = partition(
assets,
(asset) => !!asset.pythPriceFeedId,
)
const [assetsWithOraclePrice, assetsWithoutOraclePrice] = partition(
assetsWithoutPythPriceFeedId,
(asset) => asset.hasOraclePrice,
)
const assetsWithPoolId = assetsWithoutOraclePrice.filter((asset) => !!asset.poolId)
return [assetsWithPythPriceFeedId, assetsWithOraclePrice, assetsWithPoolId]
}

View File

@ -0,0 +1,15 @@
import { ENV } from 'constants/env'
import { BN } from 'utils/helpers'
export default async function fetchPythPrices(...priceFeedIds: string[]) {
try {
const pricesUrl = new URL(`${ENV.PYTH_API}/latest_price_feeds`)
priceFeedIds.forEach((id) => pricesUrl.searchParams.append('ids[]', id))
const pythResponse: PythPriceData[] = await fetch(pricesUrl).then((res) => res.json())
return pythResponse.map(({ price }) => BN(price.price).shiftedBy(price.expo))
} catch (ex) {
throw ex
}
}

View File

@ -129,8 +129,8 @@ async function getVaultValuesAndAmounts(
secondary: BN(secondaryLpToken.amount),
},
values: {
primary: BN(primaryLpToken.amount).multipliedBy(BN(primaryAsset.price)),
secondary: BN(secondaryLpToken.amount).multipliedBy(BN(secondaryAsset.price)),
primary: BN(primaryLpToken.amount).multipliedBy(primaryAsset),
secondary: BN(secondaryLpToken.amount).multipliedBy(secondaryAsset),
},
}
} catch (ex) {

View File

@ -1,5 +1,8 @@
import { IS_TESTNET } from 'constants/env'
export const MARS_MAINNET_DENOM =
'ibc/573FCD90FACEE750F55A8864EF7D38265F07E5A9273FA0E8DAFD39951332B580'
export const ASSETS: Asset[] = [
{
symbol: 'OSMO',
@ -14,6 +17,7 @@ export const ASSETS: Asset[] = [
isMarket: true,
isDisplayCurrency: true,
isAutoLendEnabled: true,
pythPriceFeedId: '5867f5683c757393a0670ef0f701490950fe93fdb006d181c8265a831ac0c5c6',
},
{
symbol: 'ATOM',
@ -31,6 +35,7 @@ export const ASSETS: Asset[] = [
isDisplayCurrency: true,
isAutoLendEnabled: true,
poolId: 1,
pythPriceFeedId: 'b00b60f88b03a6a625a8d1c048c3f66653edf217439983d037e7222c4e612819',
},
{
symbol: 'stATOM',
@ -40,11 +45,11 @@ export const ASSETS: Asset[] = [
color: '#9f1ab9',
logo: '/tokens/statom.svg',
decimals: 6,
hasOraclePrice: true,
poolId: 803,
hasOraclePrice: !IS_TESTNET,
isEnabled: !IS_TESTNET,
isMarket: !IS_TESTNET,
isDisplayCurrency: !IS_TESTNET,
poolId: 803,
},
{
symbol: 'WBTC.axl',
@ -59,6 +64,7 @@ export const ASSETS: Asset[] = [
isMarket: !IS_TESTNET,
isDisplayCurrency: !IS_TESTNET,
poolId: 712,
pythPriceFeedId: 'e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43',
},
{
symbol: 'WETH.axl',
@ -73,6 +79,7 @@ export const ASSETS: Asset[] = [
isMarket: !IS_TESTNET,
isDisplayCurrency: !IS_TESTNET,
poolId: 704,
pythPriceFeedId: 'ff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace',
},
{
symbol: 'MARS',
@ -80,14 +87,15 @@ export const ASSETS: Asset[] = [
id: 'MARS',
denom: IS_TESTNET
? 'ibc/DB9D326CF53EA07610C394D714D78F8BB4DC7E312D4213193791A9046BF45E20'
: 'ibc/573FCD90FACEE750F55A8864EF7D38265F07E5A9273FA0E8DAFD39951332B580',
: MARS_MAINNET_DENOM,
color: '#dd5b65',
logo: '/tokens/mars.svg',
decimals: 6,
hasOraclePrice: true,
poolId: 907,
hasOraclePrice: false,
isMarket: false,
isEnabled: true,
poolId: 907,
forceFetchPrice: true,
},
{
symbol: 'USDC.axl',
@ -105,6 +113,7 @@ export const ASSETS: Asset[] = [
isDisplayCurrency: true,
isStable: true,
poolId: 678,
pythPriceFeedId: 'eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a',
},
{
symbol: 'USDC.n',
@ -121,5 +130,6 @@ export const ASSETS: Asset[] = [
isMarket: IS_TESTNET,
isDisplayCurrency: IS_TESTNET,
isStable: true,
pythPriceFeedId: 'eaa020c61cc479712813461ce153894a96a6c00b21ed0cfc2798d1f9a9e9c94a',
},
]

View File

@ -16,6 +16,8 @@ interface EnvironmentVariables {
URL_API: string
URL_APOLLO_APR: string
WALLETS: string[]
PYTH_API: string
MAINNET_REST_API: string
}
export const ENV: EnvironmentVariables = {
@ -38,6 +40,8 @@ export const ENV: EnvironmentVariables = {
: process.env.NEXT_PUBLIC_API || '',
URL_APOLLO_APR: process.env.NEXT_PUBLIC_APOLLO_APR || '',
WALLETS: process.env.NEXT_PUBLIC_WALLETS?.split(',') || [],
PYTH_API: process.env.NEXT_PUBLIC_PYTH_API || '',
MAINNET_REST_API: process.env.NEXT_PUBLIC_MAINNET_REST || '',
}
export const VERCEL_BYPASS = process.env.NEXT_PUBLIC_BYPASS

View File

@ -30,16 +30,16 @@ function useDisplayCurrencyPrice() {
if (assetPrice && displayCurrencyPrice) {
return BN(assetPrice.amount).dividedBy(displayCurrencyPrice.amount)
} else {
throw 'Given denom or display currency price has not found'
}
return BN(0)
},
[prices, displayCurrency],
)
const convertAmount = useCallback(
(asset: Asset, amount: string | number | BigNumber) =>
getConversionRate(asset.denom).multipliedBy(BN(amount).shiftedBy(-asset.decimals)),
getConversionRate(asset.denom)?.multipliedBy(BN(amount).shiftedBy(-asset.decimals)) ?? BN(0),
[getConversionRate],
)

View File

@ -16,6 +16,9 @@ interface Asset {
isStable?: boolean
isFavorite?: boolean
isAutoLendEnabled?: boolean
pythPriceFeedId?: string
forceFetchPrice?: boolean
testnetDenom?: string
}
interface PseudoAsset {

12
src/types/interfaces/pyth.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
interface PythPriceData {
price: PythConfidenceData
ema_price: PythConfidenceData
id: string
}
interface PythConfidenceData {
conf: string
expo: number
price: string
publish_time: number
}

View File

@ -1,11 +1,12 @@
import BigNumber from 'bignumber.js'
import { BNCoin } from 'types/classes/BNCoin'
import { BN, getApproximateHourlyInterest } from 'utils/helpers'
import { getTokenValue } from 'utils/tokens'
export const calculateAccountBalance = (
account: Account | AccountChange,
prices: Coin[],
prices: BNCoin[],
): BigNumber => {
const totalDepositValue = calculateAccountDeposits(account, prices)
const totalDebtValue = calculateAccountDebt(account, prices)
@ -15,7 +16,7 @@ export const calculateAccountBalance = (
export const calculateAccountDeposits = (
account: Account | AccountChange,
prices: Coin[],
prices: BNCoin[],
): BigNumber => {
if (!account.deposits) return BN(0)
return account.deposits.reduce((acc, deposit) => {
@ -26,7 +27,7 @@ export const calculateAccountDeposits = (
}
export const calculateAccountDebt = (
account: Account | AccountChange,
prices: Coin[],
prices: BNCoin[],
): BigNumber => {
if (!account.debts) return BN(0)
return account.debts.reduce((acc, debt) => {
@ -39,21 +40,21 @@ export const calculateAccountDebt = (
export const calculateAccountPnL = (
account: Account | AccountChange,
prices: Coin[],
prices: BNCoin[],
): BigNumber => {
return BN(0)
}
export const calculateAccountApr = (
account: Account | AccountChange,
prices: Coin[],
prices: BNCoin[],
): BigNumber => {
return BN(0)
}
export const calculateAccountBorrowRate = (
account: Account | AccountChange,
prices: Coin[],
prices: BNCoin[],
): BigNumber => {
return BN(0)
}
@ -62,7 +63,7 @@ export function getAmount(denom: string, coins: Coin[]): BigNumber {
return BN(coins.find((asset) => asset.denom === denom)?.amount ?? 0)
}
export function getNetCollateralValue(account: Account, marketAssets: Market[], prices: Coin[]) {
export function getNetCollateralValue(account: Account, marketAssets: Market[], prices: BNCoin[]) {
const depositCollateralValue = account.deposits.reduce((acc, coin) => {
const asset = marketAssets.find((asset) => asset.denom === coin.denom)

View File

@ -1 +1,14 @@
export const byDenom = (denom: string) => (entity: any) => entity.denom === denom
export const bySymbol = (symbol: string) => (entity: any) => entity.symbol === symbol
export const byTokenDenom = (denom: string) => (entity: any) => entity.token.denom === denom
export function partition<T>(arr: Array<T>, predicate: (val: T) => boolean): [Array<T>, Array<T>] {
const partitioned: [Array<T>, Array<T>] = [[], []]
arr.forEach((val: T) => {
const partitionIndex: 0 | 1 = predicate(val) ? 0 : 1
partitioned[partitionIndex].push(val)
})
return partitioned
}

View File

@ -12,6 +12,10 @@ export function getEnabledMarketAssets(): Asset[] {
return ASSETS.filter((asset) => asset.isEnabled && asset.isMarket)
}
export function getAssetsMustHavePriceInfo(): Asset[] {
return ASSETS.filter((asset) => (asset.isEnabled && asset.isMarket) || asset.forceFetchPrice)
}
export function getBaseAsset() {
return ASSETS.find((asset) => asset.denom === 'uosmo')!
}

View File

@ -168,14 +168,14 @@ export function demagnify(amount: number | string | BigNumber, asset: Asset | Ps
return value.isZero() ? 0 : value.shiftedBy(-1 * asset.decimals).toNumber()
}
export function convertToDisplayAmount(coin: BNCoin, displayCurrency: string, prices: Coin[]) {
export function convertToDisplayAmount(coin: BNCoin, displayCurrency: string, prices: BNCoin[]) {
const price = prices.find((price) => price.denom === coin.denom)
const asset = getEnabledMarketAssets().find((asset) => asset.denom === coin.denom)
const displayPrice = prices.find((price) => price.denom === displayCurrency)
if (!price || !asset || !displayPrice) return BN(0)
return BN(coin.amount)
return coin.amount
.shiftedBy(-1 * asset.decimals)
.multipliedBy(price.amount)
.dividedBy(displayPrice.amount)

View File

@ -16,12 +16,12 @@ export const getTokenIcon = (denom: string, marketAssets: Asset[]) =>
export const getTokenInfo = (denom: string, marketAssets: Asset[]) =>
marketAssets.find((asset) => asset.denom.toLowerCase() === denom.toLowerCase()) || getBaseAsset()
export function getTokenValue(coin: BNCoin, prices: Coin[]): BigNumber {
export function getTokenValue(coin: BNCoin, prices: BNCoin[]): BigNumber {
const price = prices.find((price) => price.denom === coin.denom)?.amount || '0'
return BN(price).multipliedBy(coin.amount).decimalPlaces(0)
}
export function getTokenPrice(denom: string, prices: Coin[]): BigNumber {
export function getTokenPrice(denom: string, prices: BNCoin[]): BigNumber {
const price = prices.find((price) => price.denom === denom)?.amount || '0'
return BN(price)
}

View File

@ -15,7 +15,7 @@ export function getVaultMetaData(address: string) {
export function calculateMaxBorrowAmounts(
account: Account,
marketAssets: Market[],
prices: Coin[],
prices: BNCoin[],
denoms: string[],
): BNCoin[] {
const maxAmounts: BNCoin[] = []
@ -40,7 +40,7 @@ export function getVaultDepositCoinsAndValue(
vault: Vault,
deposits: BNCoin[],
borrowings: BNCoin[],
prices: Coin[],
prices: BNCoin[],
) {
const totalValue = [...deposits, ...borrowings].reduce((prev, bnCoin) => {
const price = prices.find((coin) => coin.denom === bnCoin.denom)?.amount
@ -76,7 +76,7 @@ export function getVaultSwapActions(
vault: Vault,
deposits: BNCoin[],
borrowings: BNCoin[],
prices: Coin[],
prices: BNCoin[],
slippage: number,
totalValue: BigNumber,
): Action[] {