import Decimal from 'decimal.js'; import BN from 'bn.js'; import { AccountInfo, AddressLookupTableAccount, PublicKey } from '@solana/web3.js'; import { HUNDRED_PERCENT_BPS } from '../../constants'; import { Fraction, fractionToDecimal } from '../../layouts/fraction'; import { MAX_ACCOUNTS_PER_ORACLE, OracleAggregator, OracleData, OracleSettings, OracleType } from '../../layouts/oracle'; import { U32_MAX } from './constants'; import { PythOracle } from './pythOracle'; import { getNextTickArrayStartIndex, getPdaTickArrayAddress, getTickArrayStartIndexByTick, PoolState, RaydiumCLMMOracle } from './raydiumClmmOracle'; import { RaydiumCPMMOracle } from './raydiumCpmmOracle'; export class OraclePrice { price: Decimal; conf: Decimal; updateTime: number; validated?: boolean; constructor(price: Decimal, conf: Decimal, updateTime: number, validated?: boolean){ this.price = price; this.conf = conf; this.updateTime = updateTime; let zero_Decimal = new Decimal(0); if(this.price.lt(zero_Decimal)) throw new Error("price should be more than 0"); if(this.conf.lt(zero_Decimal)) throw new Error("confidence can't be negative"); if(this.updateTime < 0) throw new Error("update time should be more than 0"); this.validated = validated; } getMid(): Decimal { return this.price; } getLow(): Decimal { return this.price.sub(this.conf); } getHigh(): Decimal { return this.price.add(this.conf); } } export class PriceAggregator{ numOracles: number; oracles: OracleData[]; minConfBps: number; // u16 // maximum confidence ratio allowed (conf / price) confThreshBps: number; // u16 // minimum number of oracle results required for valid price minOraclesThresh: number; // u8 // factor to expand confidence bands (≥ 1.0) confMultiplier: Fraction; constructor(numOracles: number, oracles: OracleData[], minConfBps: number, confThreshBps: number, minOraclesThresh: number, confMultiplier: Fraction){ this.numOracles = numOracles; this.oracles = oracles; this.minConfBps = minConfBps; this.confThreshBps = confThreshBps; this.minOraclesThresh = minOraclesThresh; this.confMultiplier = confMultiplier; if(!(this.oracles.length > 0)) throw new Error(`At least one oracle is required`); if(!(0 <= this.minConfBps && this.minConfBps < 10_000)) throw new Error(`Minimum confidence must be between 0 and 10000`); if(!(0 <= this.confThreshBps && this.confThreshBps < 10_000)) throw new Error(`Confidence threshold must be between 0 and 10000`); if(!(0 <= this.minOraclesThresh && this.minOraclesThresh <= U32_MAX.toNumber())) throw new Error(`Minimum number of oracles must be between 0 and ${U32_MAX}`); } static fetch( oracleAggregator: OracleAggregator, lutAccounts: AddressLookupTableAccount[], accountInfoMap: Map | null>, solPrice: OraclePrice, usdPrice: OraclePrice, ): OraclePrice { let validated: boolean = true; const agg = oracleAggregator; const oracleResults: OraclePrice[] = []; for(let i = 0; i < agg.numOracles; i++) { const oracleData = agg.oracles[i]; const settings = oracleData.oracleSettings; let loadedAccounts: AccountInfo[] = []; for (let j = 0; j < settings.numRequiredAccounts; j++) { const lutId = oracleData.accountsToLoadLutIds[j]; const lutIdx = oracleData.accountsToLoadLutIndices[j]; const pubkey = lutAccounts[lutId].state.addresses[lutIdx]; const account = accountInfoMap.get(pubkey.toBase58()); // @ts-ignore loadedAccounts.push(account); } if (settings.oracleType === OracleType.RaydiumClmm) { // ensure tick arrays are appended in order: prev, current, next const poolPubkey = lutAccounts[oracleData.accountsToLoadLutIds[0]].state.addresses[oracleData.accountsToLoadLutIndices[0]]; const poolAi = accountInfoMap.get(poolPubkey.toBase58()); if (poolAi) { const [poolState] = PoolState.decode(poolAi.data, 8); const currStart = getTickArrayStartIndexByTick(poolState.tickCurrent, poolState.tickSpacing); const prevStart = getNextTickArrayStartIndex(currStart, poolState.tickSpacing, true); const nextStart = getNextTickArrayStartIndex(currStart, poolState.tickSpacing, false); const poolId = poolPubkey; [prevStart, currStart, nextStart].forEach(start => { const pk = getPdaTickArrayAddress(poolId, start); const ai = accountInfoMap.get(pk.toBase58()); if (ai) loadedAccounts.push(ai); }); } } let result = (() => { switch (settings.oracleType) { case OracleType.Pyth: return PythOracle.fetch(settings, loadedAccounts, solPrice, usdPrice); case OracleType.RaydiumClmm: return RaydiumCLMMOracle.fetch(settings, loadedAccounts, solPrice, usdPrice); case OracleType.RaydiumCpmm: return RaydiumCPMMOracle.fetch(settings, loadedAccounts, solPrice, usdPrice); default: return new OraclePrice(new Decimal(0), new Decimal(0), 0); } })(); // if oracle is required and price is 0, return 0 price, else skip oraclce if (result.getMid().eq(new Decimal(0))) { if(settings.isRequired) validated = false; continue; } if (!result.validated) { if(settings.isRequired) validated = false; } oracleResults.push(result); } // check this if(oracleResults.length < agg.minOraclesThresh) validated = false; let prices: { price: Decimal, weight: Decimal }[] = []; for (let i = 0; i < oracleResults.length; i++) { const pr = oracleResults[i]; const weight = new Decimal(agg.oracles[i].oracleSettings.weight); prices.push({ price: pr.getMid(), weight: weight }); prices.push({ price: pr.getLow(), weight: weight }); prices.push({ price: pr.getHigh(), weight: weight }); } const medianPrice = weightedPercentile(prices, 50); const p25Price = weightedPercentile(prices, 25); const p75Price = weightedPercentile(prices, 75); let first: Decimal = medianPrice.sub(p25Price); let second: Decimal = p75Price.sub(medianPrice); let conf = first.gte(second) ? first : second; // confidence should be strictly less than median price conf = conf.lt(medianPrice) ? conf : medianPrice; conf = conf.mul(fractionToDecimal(agg.confMultiplier)); if (conf.div(medianPrice).mul(new Decimal(HUNDRED_PERCENT_BPS)).lt(new Decimal(agg.minConfBps))) conf = medianPrice.mul(new Decimal(agg.minConfBps)).div(new Decimal(HUNDRED_PERCENT_BPS)); if (conf.div(medianPrice).mul(new Decimal(HUNDRED_PERCENT_BPS)).gt(new Decimal(agg.confThreshBps))) return new OraclePrice(new Decimal(0), new Decimal(0), 0); const oldest = Math.min(...oracleResults.map(p => p.updateTime)); return new OraclePrice(medianPrice, conf, oldest, validated); } } function weightedPercentile(prices: { price: Decimal, weight: Decimal }[], percentile: number): Decimal { if (percentile < 0 || percentile > 100) { throw new Error("Percentile must be between 0 and 100"); } // Sort by price ascending prices.sort((a, b) => a.price.sub(b.price).toNumber()); // Edge cases if (percentile === 0) return prices[0].price; if (percentile === 100) return prices[prices.length - 1].price; // Compute total weight const totalWeight = prices.reduce((sum, x) => sum.add(x.weight), new Decimal(0)); // Threshold = percentile% of total weight const thresh = totalWeight.mul(new Decimal(percentile)).div(new Decimal(100)); // Cumulative sum let cum = new Decimal(0); for (const { price, weight } of prices) { cum = cum.add(weight); if (cum.gte(thresh)) return price; // nearest-rank percentile } // fallback (should not happen, but safe) return prices[prices.length - 1].price; }