421 lines
13 KiB
TypeScript
421 lines
13 KiB
TypeScript
import { AnchorProvider, Program } from '@coral-xyz/anchor'
|
|
import { PythHttpClient } from '@pythnetwork/client'
|
|
import { notify } from 'utils/notifications'
|
|
import { MAINNET_PYTH_PROGRAM } from './constants'
|
|
import {
|
|
Bank,
|
|
Group,
|
|
I80F48,
|
|
OPENBOOK_PROGRAM_ID,
|
|
toUiDecimals,
|
|
toUiDecimalsForQuote,
|
|
} from '@blockworks-foundation/mango-v4'
|
|
import { Market } from '@project-serum/serum'
|
|
import { Connection, Keypair, PublicKey } from '@solana/web3.js'
|
|
import EmptyWallet from 'utils/wallet'
|
|
import dayjs from 'dayjs'
|
|
import {
|
|
LISTING_PRESETS_KEYS,
|
|
ListingPreset,
|
|
} from '@blockworks-foundation/mango-v4-settings/lib/helpers/listingTools'
|
|
|
|
export const getOracle = async ({
|
|
baseSymbol,
|
|
quoteSymbol,
|
|
connection,
|
|
tier,
|
|
}: {
|
|
baseSymbol: string
|
|
quoteSymbol: string
|
|
connection: Connection
|
|
tier: LISTING_PRESETS_KEYS
|
|
}) => {
|
|
try {
|
|
let oraclePk = ''
|
|
const pythOracle = await getPythOracle({
|
|
baseSymbol,
|
|
quoteSymbol,
|
|
connection,
|
|
})
|
|
if (pythOracle) {
|
|
oraclePk = pythOracle
|
|
} else {
|
|
const switchBoardOracle = await getSwitchBoardOracle({
|
|
baseSymbol,
|
|
quoteSymbol,
|
|
connection,
|
|
noLock: tier === 'UNTRUSTED',
|
|
})
|
|
oraclePk = switchBoardOracle
|
|
}
|
|
|
|
return { oraclePk, isPyth: !!pythOracle }
|
|
} catch (e) {
|
|
notify({
|
|
title: 'Oracle not found',
|
|
description: `${e}`,
|
|
type: 'error',
|
|
})
|
|
return { oraclePk: '', isPyth: false }
|
|
}
|
|
}
|
|
|
|
export const getPythOracle = async ({
|
|
baseSymbol,
|
|
quoteSymbol,
|
|
connection,
|
|
}: {
|
|
baseSymbol: string
|
|
quoteSymbol: string
|
|
connection: Connection
|
|
}) => {
|
|
try {
|
|
const pythClient = new PythHttpClient(connection, MAINNET_PYTH_PROGRAM)
|
|
const pythAccounts = await pythClient.getData()
|
|
const product = pythAccounts.products.find(
|
|
(x) =>
|
|
x.base === baseSymbol.toUpperCase() &&
|
|
x.quote_currency === quoteSymbol.toUpperCase(),
|
|
)
|
|
const isLive =
|
|
product &&
|
|
pythAccounts.productPrice.get(product.symbol)?.price !== undefined
|
|
|
|
return isLive && product?.price_account ? product.price_account : ''
|
|
} catch (e) {
|
|
notify({
|
|
title: 'Pyth oracle fetch error',
|
|
description: `${e}`,
|
|
type: 'error',
|
|
})
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export const getSwitchBoardOracle = async ({
|
|
baseSymbol,
|
|
quoteSymbol,
|
|
connection,
|
|
noLock,
|
|
}: {
|
|
baseSymbol: string
|
|
quoteSymbol: string
|
|
connection: Connection
|
|
noLock: boolean
|
|
}) => {
|
|
try {
|
|
const SWITCHBOARD_PROGRAM_ID = 'SW1TCH7qEPTdLsDHRgPuMQjbQxKdH2aBStViMFnt64f'
|
|
|
|
const options = AnchorProvider.defaultOptions()
|
|
const provider = new AnchorProvider(
|
|
connection,
|
|
new EmptyWallet(Keypair.generate()),
|
|
options,
|
|
)
|
|
const idl = await Program.fetchIdl(
|
|
new PublicKey(SWITCHBOARD_PROGRAM_ID),
|
|
provider,
|
|
)
|
|
const switchboardProgram = new Program(
|
|
idl!,
|
|
new PublicKey(SWITCHBOARD_PROGRAM_ID),
|
|
provider,
|
|
)
|
|
|
|
//get all feeds check if they are tried to fetch in last 24h
|
|
const allFeeds = (
|
|
await switchboardProgram.account.aggregatorAccountData.all()
|
|
).filter(
|
|
(x) =>
|
|
isWithinLastXHours(
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(x as any).account.currentRound.roundOpenTimestamp.toNumber(),
|
|
24,
|
|
) ||
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
isWithinLastXHours((x as any).account.creationTimestamp.toNumber(), 2),
|
|
)
|
|
|
|
//parse names of feeds
|
|
const feedNames = allFeeds.map((x) =>
|
|
String.fromCharCode(
|
|
...[...(x.account.name as number[])].filter((x) => x),
|
|
),
|
|
)
|
|
|
|
//find feeds that match base + quote
|
|
//base is checked to include followed by non alphabetic character e.g
|
|
//if base is kin it will match kin_usd, kin/USD, kin usd, but not king/usd
|
|
//looks like most feeds are using space, _ or /
|
|
const possibleFeedIndexes = feedNames.reduce(function (r, v, i) {
|
|
const isBaseMatch =
|
|
v.toLowerCase().includes(baseSymbol.toLowerCase()) &&
|
|
(() => {
|
|
const match = v.toLowerCase().match(baseSymbol.toLowerCase())
|
|
if (!match) return false
|
|
|
|
const idx = match!.index! + baseSymbol.length
|
|
const nextChar = v[idx]
|
|
return !nextChar || [' ', '/', '_'].includes(nextChar)
|
|
})()
|
|
|
|
const isQuoteMatch = v.toLowerCase().includes(quoteSymbol.toLowerCase())
|
|
|
|
return r.concat(isBaseMatch && isQuoteMatch ? i : [])
|
|
}, [] as number[])
|
|
|
|
//feeds sponsored by switchboard or solend
|
|
const trustedQuesKeys = [
|
|
//switchboard sponsored que
|
|
new PublicKey('3HBb2DQqDfuMdzWxNk1Eo9RTMkFYmuEAd32RiLKn9pAn'),
|
|
]
|
|
const sponsoredAuthKeys = [
|
|
//solend
|
|
new PublicKey('A4PzGUimdCMv8xvT5gK2fxonXqMMayDm3eSXRvXZhjzU'),
|
|
//switchboard
|
|
new PublicKey('31Sof5r1xi7dfcaz4x9Kuwm8J9ueAdDduMcme59sP8gc'),
|
|
]
|
|
|
|
const possibleFeeds = allFeeds
|
|
.filter((x, i) => possibleFeedIndexes.includes(i))
|
|
//unlocked feeds can be used only when noLock is true
|
|
//atm only for untrusted use
|
|
.filter((x) => (noLock ? true : x.account.isLocked))
|
|
.sort((x) => (x.account.isLocked ? -1 : 1))
|
|
|
|
const sponsoredFeeds = possibleFeeds.filter(
|
|
(x) =>
|
|
sponsoredAuthKeys.find((s) =>
|
|
s.equals(x.account.authority as PublicKey),
|
|
) ||
|
|
trustedQuesKeys.find((s) =>
|
|
s.equals(x.account.queuePubkey as PublicKey),
|
|
),
|
|
)
|
|
|
|
return sponsoredFeeds.length
|
|
? sponsoredFeeds[0].publicKey.toBase58()
|
|
: possibleFeeds.length
|
|
? possibleFeeds[0].publicKey.toBase58()
|
|
: ''
|
|
} catch (e) {
|
|
notify({
|
|
title: 'Switchboard oracle fetch error',
|
|
description: `${e}`,
|
|
type: 'error',
|
|
})
|
|
return ''
|
|
}
|
|
}
|
|
|
|
export const getBestMarket = async ({
|
|
baseMint,
|
|
quoteMint,
|
|
cluster,
|
|
connection,
|
|
}: {
|
|
baseMint: string
|
|
quoteMint: string
|
|
cluster: 'devnet' | 'mainnet-beta'
|
|
connection: Connection
|
|
}) => {
|
|
try {
|
|
const dexProgramPk = OPENBOOK_PROGRAM_ID[cluster]
|
|
|
|
const markets = await Market.findAccountsByMints(
|
|
connection,
|
|
new PublicKey(baseMint),
|
|
new PublicKey(quoteMint),
|
|
dexProgramPk,
|
|
)
|
|
|
|
if (!markets.length) {
|
|
return undefined
|
|
}
|
|
if (markets.length === 1) {
|
|
return markets[0].publicKey
|
|
}
|
|
const marketsDataJsons = await Promise.all([
|
|
...markets.map((x) =>
|
|
fetch(`/openSerumApi/market/${x.publicKey.toBase58()}`),
|
|
),
|
|
])
|
|
const marketsData = await Promise.all([
|
|
...marketsDataJsons.map((x) => x.json()),
|
|
])
|
|
const bestMarket = marketsData.sort((a, b) => b.volume24h - a.volume24h)
|
|
return bestMarket.length
|
|
? new PublicKey(bestMarket[0].id)
|
|
: markets[0].publicKey
|
|
} catch (e) {
|
|
notify({
|
|
title: 'Openbook market not found',
|
|
description: `${e}`,
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
export const getQuoteSymbol = (quoteTokenSymbol: string) => {
|
|
if (
|
|
quoteTokenSymbol.toLowerCase() === 'usdc' ||
|
|
quoteTokenSymbol.toLocaleLowerCase() === 'usdt'
|
|
) {
|
|
return 'usd'
|
|
}
|
|
return quoteTokenSymbol
|
|
}
|
|
|
|
export const formatSuggestedValues = (
|
|
suggestedParams:
|
|
| Record<string, never>
|
|
| Omit<
|
|
ListingPreset,
|
|
'name' | 'netBorrowLimitWindowSizeTs' | 'insuranceFound'
|
|
>,
|
|
) => {
|
|
return {
|
|
maxStalenessSlots: suggestedParams.maxStalenessSlots,
|
|
oracleConfFilter: (100 * suggestedParams.oracleConfFilter).toFixed(2),
|
|
adjustmentFactor: (suggestedParams.adjustmentFactor * 100).toFixed(2),
|
|
rate0: (100 * suggestedParams.rate0).toFixed(2),
|
|
util0: (100 * suggestedParams.util0).toFixed(),
|
|
rate1: (100 * suggestedParams.rate1).toFixed(2),
|
|
util1: (100 * suggestedParams.util1).toFixed(),
|
|
maxRate: (100 * suggestedParams.maxRate).toFixed(2),
|
|
loanFeeRate: (10000 * suggestedParams.loanFeeRate).toFixed(2),
|
|
loanOriginationFeeRate: (
|
|
10000 * suggestedParams.loanOriginationFeeRate
|
|
).toFixed(2),
|
|
maintAssetWeight: suggestedParams.maintAssetWeight.toFixed(2),
|
|
initAssetWeight: suggestedParams.initAssetWeight.toFixed(2),
|
|
maintLiabWeight: suggestedParams.maintLiabWeight.toFixed(2),
|
|
initLiabWeight: suggestedParams.initLiabWeight.toFixed(2),
|
|
liquidationFee: (suggestedParams.liquidationFee * 100).toFixed(2),
|
|
minVaultToDepositsRatio: suggestedParams.minVaultToDepositsRatio * 100,
|
|
netBorrowLimitPerWindowQuote: toUiDecimals(
|
|
suggestedParams.netBorrowLimitPerWindowQuote,
|
|
6,
|
|
),
|
|
borrowWeightScale: toUiDecimals(suggestedParams.borrowWeightScale, 6),
|
|
depositWeightScale: toUiDecimals(suggestedParams.depositWeightScale, 6),
|
|
}
|
|
}
|
|
|
|
export const getFormattedBankValues = (group: Group, bank: Bank) => {
|
|
return {
|
|
...bank,
|
|
publicKey: bank.publicKey.toBase58(),
|
|
vault: bank.vault.toBase58(),
|
|
oracle: bank.oracle.toBase58(),
|
|
stablePrice: group.toUiPrice(
|
|
I80F48.fromNumber(bank.stablePriceModel.stablePrice),
|
|
bank.mintDecimals,
|
|
),
|
|
maxStalenessSlots: bank.oracleConfig.maxStalenessSlots.toNumber(),
|
|
lastStablePriceUpdated: new Date(
|
|
1000 * bank.stablePriceModel.lastUpdateTimestamp.toNumber(),
|
|
).toUTCString(),
|
|
stablePriceGrowthLimitsDelay: (
|
|
100 * bank.stablePriceModel.delayGrowthLimit
|
|
).toFixed(2),
|
|
stablePriceGrowthLimitsStable: (
|
|
100 * bank.stablePriceModel.stableGrowthLimit
|
|
).toFixed(2),
|
|
loanFeeRate: (10000 * bank.loanFeeRate.toNumber()).toFixed(2),
|
|
loanOriginationFeeRate: (
|
|
10000 * bank.loanOriginationFeeRate.toNumber()
|
|
).toFixed(2),
|
|
collectedFeesNative: toUiDecimals(
|
|
bank.collectedFeesNative.toNumber(),
|
|
bank.mintDecimals,
|
|
).toFixed(2),
|
|
collectedFeesNativePrice: (
|
|
toUiDecimals(bank.collectedFeesNative.toNumber(), bank.mintDecimals) *
|
|
bank.uiPrice
|
|
).toFixed(2),
|
|
dust: bank.dust.toNumber(),
|
|
deposits: toUiDecimals(
|
|
bank.indexedDeposits.mul(bank.depositIndex).toNumber(),
|
|
bank.mintDecimals,
|
|
),
|
|
depositsPrice: (
|
|
toUiDecimals(
|
|
bank.indexedDeposits.mul(bank.depositIndex).toNumber(),
|
|
bank.mintDecimals,
|
|
) * bank.uiPrice
|
|
).toFixed(2),
|
|
borrows: toUiDecimals(
|
|
bank.indexedBorrows.mul(bank.borrowIndex).toNumber(),
|
|
bank.mintDecimals,
|
|
),
|
|
borrowsPrice: (
|
|
toUiDecimals(
|
|
bank.indexedBorrows.mul(bank.borrowIndex).toNumber(),
|
|
bank.mintDecimals,
|
|
) * bank.uiPrice
|
|
).toFixed(2),
|
|
avgUtilization: bank.avgUtilization.toNumber() * 100,
|
|
maintAssetWeight: bank.maintAssetWeight.toFixed(2),
|
|
maintLiabWeight: bank.maintLiabWeight.toFixed(2),
|
|
initAssetWeight: bank.initAssetWeight.toFixed(2),
|
|
initLiabWeight: bank.initLiabWeight.toFixed(2),
|
|
scaledInitAssetWeight: bank.scaledInitAssetWeight(bank.price).toFixed(2),
|
|
scaledInitLiabWeight: bank.scaledInitLiabWeight(bank.price).toFixed(2),
|
|
depositWeightScale: toUiDecimalsForQuote(bank.depositWeightScaleStartQuote),
|
|
borrowWeightScale: toUiDecimalsForQuote(bank.borrowWeightScaleStartQuote),
|
|
rate0: (100 * bank.rate0.toNumber()).toFixed(2),
|
|
util0: (100 * bank.util0.toNumber()).toFixed(),
|
|
rate1: (100 * bank.rate1.toNumber()).toFixed(2),
|
|
util1: (100 * bank.util1.toNumber()).toFixed(),
|
|
maxRate: (100 * bank.maxRate.toNumber()).toFixed(2),
|
|
adjustmentFactor: (bank.adjustmentFactor.toNumber() * 100).toFixed(2),
|
|
depositRate: bank.getDepositRateUi(),
|
|
borrowRate: bank.getBorrowRateUi(),
|
|
lastIndexUpdate: new Date(
|
|
1000 * bank.indexLastUpdated.toNumber(),
|
|
).toUTCString(),
|
|
lastRatesUpdate: new Date(
|
|
1000 * bank.bankRateLastUpdated.toNumber(),
|
|
).toUTCString(),
|
|
oracleConfFilter: (100 * bank.oracleConfig.confFilter.toNumber()).toFixed(
|
|
2,
|
|
),
|
|
minVaultToDepositsRatio: bank.minVaultToDepositsRatio * 100,
|
|
netBorrowsInWindow: toUiDecimalsForQuote(
|
|
I80F48.fromI64(bank.netBorrowsInWindow).mul(bank.price),
|
|
).toFixed(2),
|
|
netBorrowLimitPerWindowQuote: toUiDecimals(
|
|
bank.netBorrowLimitPerWindowQuote,
|
|
6,
|
|
),
|
|
liquidationFee: (bank.liquidationFee.toNumber() * 100).toFixed(2),
|
|
}
|
|
}
|
|
|
|
function isWithinLastXHours(timestampInSeconds: number, hoursNumber: number) {
|
|
const now = dayjs()
|
|
const inputDate = dayjs.unix(timestampInSeconds) // Convert seconds to dayjs object
|
|
|
|
const differenceInHours = now.diff(inputDate, 'hour')
|
|
|
|
return differenceInHours < hoursNumber
|
|
}
|
|
|
|
export type PriceImpactResp = {
|
|
avg_price_impact_percent: number
|
|
side: 'ask' | 'bid'
|
|
target_amount: number
|
|
symbol: string
|
|
//there is more fileds they are just not used on ui
|
|
}
|
|
export type PriceImpactRespWithoutSide = Omit<PriceImpactResp, 'side'>
|
|
|
|
export const getPriceImpacts = async () => {
|
|
const resp = await fetch(
|
|
'https://api.mngo.cloud/data/v4/risk/listed-tokens-one-week-price-impacts',
|
|
)
|
|
const jsonReps = (await resp.json()) as PriceImpactResp[]
|
|
return jsonReps
|
|
}
|