import { Account, address, Address, Base64EncodedDataResponse, GetMultipleAccountsApi, Rpc } from '@solana/kit';
import Decimal from 'decimal.js';
import { Scope } from '@kamino-finance/scope-sdk';
import { OraclePrices } from '@kamino-finance/scope-sdk/dist/@codegen/scope/accounts/OraclePrices';
import { isNotNullPubkey } from './pubkey';
import { parseTokenSymbol, ReserveWithAddress } from '../classes';
import { Reserve } from '../lib';
import { batchFetch } from '@kamino-finance/kliquidity-sdk';
import BN from 'bn.js';
import { priceUpdateV2 } from '../@codegen/pyth_rec/accounts';
import { AggregatorAccountData } from '../@codegen/switchboard_v2/accounts';
import { ScopeConfiguration } from '../@codegen/klend/types';
import { Fraction } from '../classes/fraction';
import { Buffer } from 'buffer';
import { getLatestAggregatorValue } from './switchboard';
import { PROGRAM_ID as SWITCHBOARD_V2_PROGRAM_ID } from '../@codegen/switchboard_v2/programId';
import { Configuration } from '@kamino-finance/scope-sdk/dist/@codegen/scope/accounts/Configuration';
// validate price confidence - confidence/price ratio should be less than 2%
export const MAX_CONFIDENCE_PERCENTAGE: Decimal = new Decimal('2');
/// Confidence factor is used to scale the confidence value to a value that can be compared to the price.
export const CONFIDENCE_FACTOR: Decimal = new Decimal('100').div(MAX_CONFIDENCE_PERCENTAGE);
const getScopeAddress = () => {
return address('HFn8GnPADiny6XqUoWE8uRPPxb29ikn4yTuPa9MF2fWJ');
};
export type TokenOracleData = {
mintAddress: Address;
decimals: Decimal;
price: Decimal;
timestamp: bigint;
valid: boolean;
};
export type CandidatePrice = {
price: Decimal;
timestamp: bigint;
valid: boolean;
};
/**
* A single price source's readings: the spot price, and - when the source has one configured - its twap.
*/
export type CandidateFeed = {
spot: CandidatePrice;
twap?: CandidatePrice;
};
export type ScopePriceRefreshConfig = {
scope: Scope;
scopeConfigurations: [Address, Configuration][];
};
export function hasOracleConfigured(reserve: Reserve): boolean {
const scopeConfiguration = reserve.config.tokenInfo.scopeConfiguration;
return (
isNotNullPubkey(reserve.config.tokenInfo.pythConfiguration.price) ||
isNotNullPubkey(reserve.config.tokenInfo.switchboardConfiguration.priceAggregator) ||
(isNotNullPubkey(scopeConfiguration.priceFeed) && Scope.isScopeChainValid(scopeConfiguration.priceChain))
);
}
export function getUnconfiguredOracleReserveMessage(reserveAddress: Address, reserve: Reserve): string {
return `${parseTokenSymbol(reserve.config.tokenInfo.name) || 'unknown'} (${reserveAddress}) reserve in market ${
reserve.lendingMarket
}: reserve has no oracle configured`;
}
export function getTokenOracleDataSync(
allOracleAccounts: AllOracleAccounts,
reserves: ReserveWithAddress[]
): Array<[ReserveWithAddress, TokenOracleData | undefined]> {
const tokenOracleDataForReserves: Array<[ReserveWithAddress, TokenOracleData | undefined]> = [];
const pythCache = new Map
();
const switchboardCache = new Map();
const scopeCache = new Map();
for (const reserveWithAddress of reserves) {
const { address, state: reserve } = reserveWithAddress;
const tokenInfo = reserve.config.tokenInfo;
const twapEnabled = tokenInfo.maxTwapDivergenceBps.gtn(0);
const oracle = {
pythAddress: tokenInfo.pythConfiguration.price,
switchboardFeedAddress: tokenInfo.switchboardConfiguration.priceAggregator,
switchboardTwapAddress: tokenInfo.switchboardConfiguration.twapAggregator,
scopeOracleAddress: tokenInfo.scopeConfiguration.priceFeed,
};
const feeds: CandidateFeed[] = [];
if (isNotNullPubkey(oracle.pythAddress)) {
const pythPrices = cacheOrGetPythPrices(oracle.pythAddress, pythCache, allOracleAccounts);
if (pythPrices && pythPrices.spot) {
feeds.push({ spot: pythPrices.spot, twap: pythPrices.twap });
}
}
if (isNotNullPubkey(oracle.switchboardFeedAddress)) {
const switchboardFeed = getSwitchboardFeed(
oracle.switchboardFeedAddress,
// The program reads the twap feed only when the twap check is enabled for the token:
twapEnabled ? oracle.switchboardTwapAddress : undefined,
switchboardCache,
allOracleAccounts
);
if (switchboardFeed) {
feeds.push(switchboardFeed);
}
}
if (isNotNullPubkey(oracle.scopeOracleAddress)) {
const scopeFeed = cacheOrGetScopeFeed(
oracle.scopeOracleAddress,
scopeCache,
allOracleAccounts,
tokenInfo.scopeConfiguration
);
if (scopeFeed) {
feeds.push(scopeFeed);
}
}
let currentBest = selectBestOracleCandidate(feeds, twapEnabled);
if (currentBest === undefined && feeds.length > 0) {
// Every configured feed produced only zeroed readings - a refresh miss; keep the reserve's cached price,
// like the program does. A wholly-missing feed set (`feeds` empty) stays an error for the caller, though.
currentBest = cachedReservePriceCandidate(reserve);
}
if (!currentBest) {
if (hasOracleConfigured(reserve)) {
const reserveSymbol = parseTokenSymbol(reserve.config.tokenInfo.name);
console.error(
`No price found for reserve: ${reserveSymbol ?? 'unknown'} (${address}) in market: ${reserve.lendingMarket}`
);
}
tokenOracleDataForReserves.push([reserveWithAddress, undefined]);
continue;
}
const tokenOracleData: TokenOracleData = {
mintAddress: reserve.liquidity.mintPubkey,
decimals: Decimal.pow(10, reserve.liquidity.mintDecimals.toString()),
price: new Decimal(currentBest.price),
timestamp: currentBest.timestamp,
valid: currentBest.valid,
};
tokenOracleDataForReserves.push([reserveWithAddress, tokenOracleData]);
}
return tokenOracleDataForReserves;
}
// TODO: Add freshness of the latest price to match sc logic
export async function getTokenOracleData(
rpc: Rpc,
reserves: ReserveWithAddress[],
oracleAccounts?: AllOracleAccounts
): Promise> {
const allOracleAccounts =
oracleAccounts ??
(await getAllOracleAccounts(
rpc,
reserves.map((r) => r.state)
));
return getTokenOracleDataSync(allOracleAccounts, reserves);
}
export type AllOracleAccounts = Map>;
export async function getAllOracleAccounts(
rpc: Rpc,
reserves: Reserve[]
): Promise {
const allAccounts: Address[] = [];
reserves.forEach((reserve) => {
if (isNotNullPubkey(reserve.config.tokenInfo.pythConfiguration.price)) {
allAccounts.push(reserve.config.tokenInfo.pythConfiguration.price);
}
if (isNotNullPubkey(reserve.config.tokenInfo.switchboardConfiguration.priceAggregator)) {
allAccounts.push(reserve.config.tokenInfo.switchboardConfiguration.priceAggregator);
}
if (isNotNullPubkey(reserve.config.tokenInfo.switchboardConfiguration.twapAggregator)) {
allAccounts.push(reserve.config.tokenInfo.switchboardConfiguration.twapAggregator);
}
if (isNotNullPubkey(reserve.config.tokenInfo.scopeConfiguration.priceFeed)) {
allAccounts.push(reserve.config.tokenInfo.scopeConfiguration.priceFeed);
}
});
const allAccountsDeduped = dedupKeys(allAccounts);
const allAccs = await batchFetch(
allAccountsDeduped,
async (chunk) => (await rpc.getMultipleAccounts(chunk).send()).value
);
const allAccsMap = new Map>();
allAccs.forEach((acc, i) => {
if (acc !== null) {
allAccsMap.set(allAccountsDeduped[i], { ...acc, programAddress: acc.owner, address: allAccountsDeduped[i] });
}
});
return allAccsMap;
}
function dedupKeys(keys: Address[]): Address[] {
return [...new Set(keys)];
}
export type PythPrices = {
spot?: CandidatePrice;
twap?: CandidatePrice;
};
/**
* Get pyth price from cache or fetch if not available
* @param oracle oracle address
* @param cache pyth cache
* @param oracleAccounts all oracle accounts
*/
export function cacheOrGetPythPrices(
oracle: Address,
cache: Map,
oracleAccounts: AllOracleAccounts
): PythPrices | null {
const prices: PythPrices = {};
const cached = cache.get(oracle);
if (cached) {
return cached;
} else {
const result = oracleAccounts.get(oracle);
if (result) {
try {
const { priceMessage } = priceUpdateV2.decode(Buffer.from(result.data[0], 'base64'));
const { price, exponent, conf: confidence, publishTime: timestamp, emaPrice } = priceMessage;
if (price) {
const px = new Decimal(price.toString()).div(10 ** Math.abs(exponent));
const conf = new Decimal(confidence.toString());
prices.spot = {
price: px,
timestamp: BigInt(timestamp.toString()),
valid: validatePythPx(px, conf),
};
}
if (emaPrice !== undefined && emaPrice !== null) {
const emaPx = new Decimal(emaPrice.toString()).div(10 ** Math.abs(exponent));
prices.twap = {
price: emaPx,
timestamp: BigInt(timestamp.toString()),
valid: true,
};
}
if (prices.spot || prices.twap) {
cache.set(oracle, prices);
}
} catch (error) {
console.error(`Error parsing pyth price account ${oracle.toString()} data`, error);
return null;
}
} else {
return null;
}
}
return prices;
}
/**
* Get switchboard price from cache or fetch if not available
* @param oracle oracle address
* @param switchboardCache cache for oracle prices
* @param oracleAccounts all oracle accounts
*/
export function cacheOrGetSwitchboardPrice(
oracle: Address,
switchboardCache: Map,
oracleAccounts: AllOracleAccounts
): CandidatePrice | null {
const cached = switchboardCache.get(oracle);
if (cached) {
return cached;
} else {
const info = oracleAccounts.get(oracle);
if (info) {
if (info.programAddress === SWITCHBOARD_V2_PROGRAM_ID) {
const agg = AggregatorAccountData.decode(Buffer.from(info.data[0], 'base64'));
const result = getLatestAggregatorValue(agg);
if (result !== undefined && result !== null) {
const latestRoundTimestamp: BN = agg.latestConfirmedRound.roundOpenTimestamp;
const ts = BigInt(latestRoundTimestamp.toString());
return switchboardValueToCandidate(new Decimal(result.toString()), ts, validateSwitchboardV2Px(agg));
}
} else {
console.error('Unrecognized switchboard owner address: ', info.programAddress);
return null;
}
}
}
return null;
}
/**
* Interpret a raw Switchboard aggregator value as a price candidate.
*
* Like the program, a negative reading is rejected outright (the feed contributes nothing), while a zero reading is
* kept as a parsed candidate - the zeroed-feed exclusion belongs to {@link selectBestOracleCandidate}.
*/
export function switchboardValueToCandidate(value: Decimal, timestamp: bigint, valid: boolean): CandidatePrice | null {
if (value.isNegative()) {
console.error('Switchboard oracle price is negative which is not allowed');
return null;
}
return {
price: value,
timestamp,
valid,
};
}
/**
* Read a Switchboard feed: the spot aggregator, and - when `twapOracle` is given (i.e. the twap check is enabled for
* the token) - the twap aggregator next to it.
*
* Mirroring the program's `get_switchboard_price_and_twap()`: a failed spot read means no feed at all, and a fetched
* twap account which fails to parse (e.g. a negative reading) drops the whole feed, while a twap account that was not
* fetched at all just leaves the feed without a twap.
*/
function getSwitchboardFeed(
spotOracle: Address,
twapOracle: Address | undefined,
switchboardCache: Map,
oracleAccounts: AllOracleAccounts
): CandidateFeed | null {
const spot = cacheOrGetSwitchboardPrice(spotOracle, switchboardCache, oracleAccounts);
if (!spot) {
return null;
}
if (twapOracle !== undefined && isNotNullPubkey(twapOracle) && oracleAccounts.has(twapOracle)) {
const twap = cacheOrGetSwitchboardPrice(twapOracle, switchboardCache, oracleAccounts);
if (!twap) {
return null;
}
return { spot, twap };
}
return { spot };
}
/**
* Get scope price from cache or fetch if not available
* @param oracle oracle address
* @param scopeCache cache for oracle prices
* @param allOracleAccounts all oracle accounts
* @param chain scope chain
*/
export function cacheOrGetScopePrice(
oracle: Address,
scopeCache: Map,
allOracleAccounts: AllOracleAccounts,
chain: number[]
): CandidatePrice | null {
if (!chain || !Scope.isScopeChainValid(chain)) {
return null;
}
const scopePrices = cacheOrGetScopeOraclePrices(oracle, scopeCache, allOracleAccounts);
if (!scopePrices) {
return null;
}
return scopeChainToCandidatePrice(chain, scopePrices);
}
/**
* Read a Scope feed: the spot price chain, and - when one is configured - the twap chain next to it.
*/
function cacheOrGetScopeFeed(
oracle: Address,
scopeCache: Map,
allOracleAccounts: AllOracleAccounts,
scopeConfiguration: ScopeConfiguration
): CandidateFeed | null {
if (!Scope.isScopeChainValid(scopeConfiguration.priceChain)) {
return null;
}
const scopePrices = cacheOrGetScopeOraclePrices(oracle, scopeCache, allOracleAccounts);
if (!scopePrices) {
return null;
}
const spot = scopeChainToCandidatePrice(scopeConfiguration.priceChain, scopePrices);
// The raw chain is evaluated as configured - 0 is a valid price ID and only `U16_MAX` marks unused links; the
// validity check (not all-`U16_MAX`, not all-0) mirrors the program's `ScopeConfiguration::has_twap()`.
const twap = Scope.isScopeChainValid(scopeConfiguration.twapChain)
? scopeChainToCandidatePrice(scopeConfiguration.twapChain, scopePrices)
: undefined;
return { spot, twap };
}
function cacheOrGetScopeOraclePrices(
oracle: Address,
scopeCache: Map,
allOracleAccounts: AllOracleAccounts
): OraclePrices | null {
if (!isNotNullPubkey(oracle)) {
return null;
}
const scopePrices = scopeCache.get(oracle);
if (scopePrices) {
return scopePrices;
}
const info = allOracleAccounts.get(oracle);
if (info) {
const owner = info.programAddress;
if (owner === getScopeAddress()) {
try {
const prices = OraclePrices.decode(Buffer.from(info.data[0], 'base64'));
scopeCache.set(oracle, prices);
return prices;
} catch (error) {
console.debug(`Error parsing scope price account ${oracle.toString()} data`, error);
return null;
}
} else {
console.error('Unrecognized scope owner address: ', owner);
}
}
return null;
}
/**
* Select the price to use among the given sources' readings.
*
* This mirrors the program's `get_most_recent_price_and_twap()` (klend >= 1.25.0): a zeroed feed - a zero spot price,
* or a zero reading on a *present* twap when the twap check is enabled - takes no part in the selection, so that it
* cannot win over a healthy alternate source (while a twap missing altogether does not exclude its feed). Among the
* remaining candidates, the freshest valid one wins. `undefined` means no healthy feed exists - on such a refresh
* miss the program keeps the reserve's cached price, and so does the caller here (see `getTokenOracleDataSync()`).
*/
export function selectBestOracleCandidate(feeds: CandidateFeed[], twapEnabled: boolean): CandidatePrice | undefined {
let currentBest: CandidatePrice | undefined = undefined;
for (const feed of feeds) {
if (isSpotOrTwapZeroed(feed, twapEnabled)) {
continue;
}
currentBest = getBestPrice(currentBest, feed.spot);
}
return currentBest;
}
/**
* The reserve's cached price (the one refreshed on-chain by the last successful `refresh_reserve`), as a price
* candidate: the program keeps using it when every configured feed is zeroed, so the SDK does the same. It is zero
* only for a reserve which was never refreshed with a live price (e.g. right after `init_reserve`, before its
* Scope-computed feed's first crank) - flagged invalid then, since the program would refuse to *use* a zero price.
*/
function cachedReservePriceCandidate(reserve: Reserve): CandidatePrice {
const cachedPrice = new Fraction(reserve.liquidity.marketPriceSf).toDecimal();
return {
price: cachedPrice,
timestamp: BigInt(reserve.liquidity.marketPriceLastUpdatedTs.toString()),
valid: !cachedPrice.isZero(),
};
}
/**
* Whether the feed's spot price, or its twap when one is required, is zeroed - the program's `is_spot_or_twap_zeroed()`.
*/
function isSpotOrTwapZeroed(feed: CandidateFeed, twapEnabled: boolean): boolean {
return feed.spot.price.isZero() || (twapEnabled && feed.twap !== undefined && feed.twap.price.isZero());
}
function getBestPrice(current: CandidatePrice | undefined, next: CandidatePrice): CandidatePrice | undefined {
if (isBetterPrice(current, next)) {
return next;
}
return current;
}
function isBetterPrice(current: CandidatePrice | undefined, next: CandidatePrice): boolean {
if (!current) {
return true;
}
if (current.valid && !next.valid) {
return false;
}
if (!current.valid && next.valid) {
return true;
}
return next.timestamp > current.timestamp;
}
function validatePythPx(price: Decimal, confidence: Decimal): boolean {
const conf50x = confidence.mul(CONFIDENCE_FACTOR);
return !price.isZero() && price.gt(conf50x);
}
function validateSwitchboardV2Px(agg: AggregatorAccountData): boolean {
const pxMantissa = new Decimal(agg.latestConfirmedRound.result.mantissa.toString());
const pxScale = new Decimal(agg.latestConfirmedRound.result.scale.toString());
const stDevMantissa = new Decimal(agg.latestConfirmedRound.stdDeviation.mantissa.toString());
const stDevScale = new Decimal(agg.latestConfirmedRound.stdDeviation.scale.toString());
let conf50xScaled: Decimal;
if (pxScale.gte(stDevScale)) {
const scalingFactor = pxScale.sub(stDevScale);
const conf50x = stDevMantissa.mul(CONFIDENCE_FACTOR);
conf50xScaled = conf50x.mul(scalingFactor);
} else {
const scalingFactor = stDevScale.sub(pxScale);
const conf50x = stDevMantissa.mul(CONFIDENCE_FACTOR);
conf50xScaled = conf50x.div(scalingFactor);
}
return conf50xScaled.gte(pxMantissa);
}
function scopeChainToCandidatePrice(chain: number[], prices: OraclePrices): CandidatePrice {
const scopePx = Scope.getPriceFromScopeChain(chain, prices);
// Scope prices are pre-validated; a zeroed reading (e.g. a zero link anywhere in the chain zeroing the whole
// product) is instead excluded from the selection by `selectBestOracleCandidate()`, like the program does.
const valid = scopePx.timestamp.gt('0');
return {
price: scopePx.price,
timestamp: BigInt(scopePx.timestamp.toString()),
valid,
};
}