import Decimal from 'decimal.js'; import BN from 'bn.js'; import { AccountInfo, PublicKey } from '@solana/web3.js'; import { HUNDRED_PERCENT_BPS } from '../../constants'; import { OracleSettings, Quote, Side } from '../../layouts/oracle'; import { OraclePrice } from './oracle'; const CPMM_PROGRAM_ID = new PublicKey("CPMMoo8L3F4NbTegBCKVNunggL7H1ZpdTHKxQB5qKP1C"); const DEV_CPMM_PROGRAM_ID = new PublicKey("DRaycpLY18LhpbydsBWbVJtxpNv9oXPgjRSfpF2bWpYb"); class Observation { timestamp: BN; // u64 cumT0Price: BN; // u128 cumT1Price: BN; // u128 constructor(params: {timestamp: BN, cumT0Price: BN, cumT1Price: BN}){ this.timestamp = params.timestamp; this.cumT0Price = params.cumT0Price; this.cumT1Price = params.cumT1Price; } static decode(data: Buffer, offset = 0): [Observation, number] { const timestamp = new BN( data.subarray(offset, offset+8), "le" ); const cumT0Price = new BN( data.subarray(offset + 8, offset + 24), 'le' ); const cumT1Price = new BN( data.subarray(offset + 24, offset + 40), 'le' ); return [ new Observation({ timestamp, cumT0Price, cumT1Price }), offset + 40 ]; } sub(other: Observation): Observation { if (!(other instanceof Observation)) { throw new TypeError("Subtraction is only supported between Observation instances"); } return new Observation({ timestamp: this.timestamp.sub(other.timestamp), cumT0Price: this.cumT0Price.sub(other.cumT0Price), cumT1Price: this.cumT1Price.sub(other.cumT1Price), }); } add(other: Observation): Observation { if (!(other instanceof Observation)) { throw new TypeError("Addition is only supported between Observation instances"); } return new Observation({ timestamp: this.timestamp.add(other.timestamp), cumT0Price: this.cumT0Price.add(other.cumT0Price), cumT1Price: this.cumT1Price.add(other.cumT1Price), }); } getWeightedObservation(time: BN): Observation { const cumT0Price = this.cumT0Price.mul(time).div(this.timestamp); const cumT1Price = this.cumT1Price.mul(time).div(this.timestamp); return new Observation({ timestamp: time, cumT0Price, cumT1Price }); } adjustToTimestamp(targetTimestamp: BN, observationPrev: Observation): Observation { // delta = self.sub(prevPbservation) const delta = this.sub(observationPrev); // weightedDelta = delta.getWeightedObservation(targetTimestamp - self.blockTimestamp) const timeForWeighted = targetTimestamp.sub(this.timestamp); const weightedDelta = delta.getWeightedObservation(timeForWeighted); // return self.add(weightedDelta) return this.add(weightedDelta); } public getTwap(side: Side): BN { let cumPrice = new BN(0); if(side === Side.Base) cumPrice = this.cumT0Price; else cumPrice = this.cumT1Price; return cumPrice.div(this.timestamp); } } class VaultState { mint: PublicKey; owner: PublicKey; amount: string; constructor(params: {mint: PublicKey, owner: PublicKey, amount: string}) { this.mint = params.mint; this.owner = params.owner; this.amount = params.amount; } static decode(data: Buffer, offset: number = 0): [VaultState, number] { // mint (32) const mint = new PublicKey(data.slice(offset, offset + 32)); offset += 32; // owner (32) const owner = new PublicKey(data.slice(offset, offset + 32)); offset += 32; // amount u64 (8) const amount = (new BN(data.subarray(offset, offset + 8), 'le')).toString(); offset += 8; return [ new VaultState({ mint, owner, amount }), offset ]; } } class ObservationState { initialized : boolean; observationIndex: number; //u16 poolId: PublicKey; observations: Observation[]; padding: BN[]; // [u64; 3] constructor(params: { initialized: boolean, observationIndex: number, poolId: PublicKey, observations: Observation[], padding: BN[] }){ this.initialized = params.initialized; this.observationIndex = params.observationIndex; this.poolId = params.poolId; this.observations = params.observations; this.padding = params.padding; } static decode(data: Buffer, offset = 8): [ObservationState, number] { let cursor = offset; const initialized = data.readUInt8(cursor) !== 0; cursor += 1; const observationIndex = data.readUInt16LE(cursor); cursor += 2; const poolId = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const observations: Observation[] = []; for (let i = 0; i < 100; i++) { let obs: Observation; [obs, cursor] = Observation.decode(data, cursor); observations.push(obs); } const padding: BN[] = []; for (let i = 0; i < 4; i++) { padding.push(new BN(data.subarray(cursor, cursor + 8), "le")); cursor += 8; } return [ new ObservationState({ initialized, observationIndex, poolId, observations, padding }), cursor ] } } class PoolState { ammConfig: PublicKey; poolCreator: PublicKey; token0Vault: PublicKey; token1Vault: PublicKey; lpMint: PublicKey; token0Mint: PublicKey; token1Mint: PublicKey; token0Program: PublicKey; token1Program: PublicKey; observationKey: PublicKey; authBump: number; // u8 status: number; // u8 lpMintDecimals: number; // u8 mint0Decimals: number; // u8 mint1Decimals: number; // u8 lpSupply: BN; // u64 protocolFeesToken0: BN; // u64 protocolFeesToken1: BN; // u64 fundFeesToken0: BN; // u64 fundFeesToken1: BN; // u64 openTime: BN; // u64 recentEpoch: BN; // u64 creatorFeeOn: number; //u8 enableCreatorFee: boolean; padding1: number[]; // [u8; 6] creatorFeesToken0: BN; // u64 creatorFeesToken1: BN; // u64 padding: BN[]; // [u64; 28] constructor( params: { ammConfig: PublicKey; poolCreator: PublicKey; token0Vault: PublicKey; token1Vault: PublicKey; lpMint: PublicKey; token0Mint: PublicKey; token1Mint: PublicKey; token0Program: PublicKey; token1Program: PublicKey; observationKey: PublicKey; authBump: number; // u8 status: number; // u8 lpMintDecimals: number; // u8 mint0Decimals: number; // u8 mint1Decimals: number; // u8 lpSupply: BN; // u64 protocolFeesToken0: BN; // u64 protocolFeesToken1: BN; // u64 fundFeesToken0: BN; // u64 fundFeesToken1: BN; // u64 openTime: BN; // u64 recentEpoch: BN; // u64 creatorFeeOn: number; //u8 enableCreatorFee: boolean; padding1: number[]; // [u8; 6] creatorFeesToken0: BN; // u64 creatorFeesToken1: BN; // u64 padding: BN[]; // [u64; 28] }){ this.ammConfig = params.ammConfig; this.poolCreator = params.poolCreator; this.token0Vault = params.token0Vault; this.token1Vault = params.token1Vault; this.lpMint = params.lpMint; this.token0Mint = params.token0Mint; this.token1Mint = params.token1Mint; this.token0Program = params.token0Program; this.token1Program = params.token1Program; this.observationKey = params.observationKey; this.authBump = params.authBump; // u8 this.status = params.status; // u8 this.lpMintDecimals = params.lpMintDecimals; // u8 this.mint0Decimals = params.mint0Decimals; // u8 this.mint1Decimals = params.mint1Decimals; // u8 this.lpSupply = params.lpSupply; // u64 this.protocolFeesToken0 = params.protocolFeesToken0; // u64 this.protocolFeesToken1 = params.protocolFeesToken1; // u64 this.fundFeesToken0 = params.fundFeesToken0; // u64 this.fundFeesToken1 = params.fundFeesToken1; // u64 this.openTime = params.openTime; // u64 this.recentEpoch = params.recentEpoch; // u64 this.creatorFeeOn = params.creatorFeeOn; //u8 this.enableCreatorFee = params.enableCreatorFee; this.padding1 = params.padding1; // [u8; 6] this.creatorFeesToken0 = params.creatorFeesToken0; // u64 this.creatorFeesToken1 = params.creatorFeesToken1; // u64 this.padding = params.padding // [u64; 28] } static decode(data: Buffer, offset: number = 8): [PoolState, number] { let cursor = offset; const ammConfig = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const poolCreator = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const token0Vault = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const token1Vault = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const lpMint = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const token0Mint = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const token1Mint = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const token0Program = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const token1Program = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const observationKey = new PublicKey(data.subarray(cursor, cursor + 32)); cursor += 32; const authBump = data.readUInt8(cursor); cursor += 1; const status = data.readUInt8(cursor); cursor += 1; const lpMintDecimals = data.readUInt8(cursor); cursor += 1; const mint0Decimals = data.readUInt8(cursor); cursor += 1; const mint1Decimals = data.readUInt8(cursor); cursor += 1; const lpSupply = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const protocolFeesToken0 = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const protocolFeesToken1 = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const fundFeesToken0 = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const fundFeesToken1 = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const openTime = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const recentEpoch = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const creatorFeeOn = data.readUInt8(cursor); cursor += 1; const enableCreatorFee = !!data.readUInt8(cursor); cursor += 1; const padding1: number[] = []; for (let i = 0; i < 6; i++) { padding1.push(data.readUInt8(cursor)); cursor += 1; } const creatorFeesToken0 = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const creatorFeesToken1 = new BN(data.subarray(cursor, cursor + 8), "le"); cursor += 8; const padding: BN[] = []; for (let i = 0; i < 28; i++) { padding.push(new BN(data.subarray(cursor, cursor + 8), "le")); cursor += 8; } return [ new PoolState({ ammConfig, poolCreator, token0Vault, token1Vault, lpMint, token0Mint, token1Mint, token0Program, token1Program, observationKey, authBump, status, lpMintDecimals, mint0Decimals, mint1Decimals, lpSupply, protocolFeesToken0, protocolFeesToken1, fundFeesToken0, fundFeesToken1, openTime, recentEpoch, creatorFeeOn, enableCreatorFee, padding1, creatorFeesToken0, creatorFeesToken1, padding, }), cursor, ]; } } export class RaydiumCPMMOracle { // poolId: PublicKey; // poolState: PoolState; // observationState: ObservationState; // vault0: VaultState; // vault1: VaultState; // constructor( // oracleParams: OracleSettings, // cpmmParams: { // poolId: PublicKey; // poolState: PoolState; // observationState: ObservationState, // vault0: VaultState, // vault1: VaultState, // }){ // super( // oracleParams // ); // this.poolId = cpmmParams.poolId; // this.poolState = cpmmParams.poolState; // this.observationState = cpmmParams.observationState; // this.vault0 = cpmmParams.vault0; // this.vault1 = cpmmParams.vault1; // } static deriveObservationKey(poolId: PublicKey): [PublicKey, number] { const seeds = [ Buffer.from("observation"), poolId.toBuffer(), ]; return PublicKey.findProgramAddressSync(seeds, CPMM_PROGRAM_ID); }; static getObservationAtIndex(observations: Observation[], index: number) { return observations[index]; } static getObservationAtTimestamp(timestamp: BN, observations: Observation[], startIndex: number): Observation { let observationCurrent = this.getObservationAtIndex(observations, startIndex); let index = startIndex; // Loop backwards until we find the observation <= timestamp while (observationCurrent.timestamp.gt(timestamp)) { index = (index - 1 + 100) % 100; observationCurrent = this.getObservationAtIndex(observations, index); if (index === startIndex) { throw Error("Observations do not go back far enough in time"); } } // Previous index for interpolation const prevIndex = (index - 1 + 100) % 100; const observationPrev = this.getObservationAtIndex(observations, prevIndex); // Adjust current observation to the target timestamp const result = observationCurrent.adjustToTimestamp(timestamp, observationPrev); return result; } static getDeltaObservations(currentTime: BN, observations: Observation[], observationIndex: number, primarySeconds: BN, secondarySeconds: BN): Observation[] { const obsIndex = observationIndex; const observationCurrent = this.getObservationAtTimestamp(currentTime, observations, obsIndex); const observationPrimary = this.getObservationAtTimestamp(currentTime.sub(primarySeconds), observations, obsIndex); const deltaPrimary = observationCurrent.sub(observationPrimary); const observationSecondary = this.getObservationAtTimestamp(currentTime.sub(secondarySeconds), observations, obsIndex); const deltaSecondary = observationCurrent.sub(observationSecondary); return [deltaPrimary, deltaSecondary]; }; static getDecimals(side : Side, mint0Decimals: number, mint1Decimals: number): number { let dec; if(side === Side.Base) dec = mint0Decimals - mint1Decimals; else dec = mint1Decimals - mint0Decimals; return dec; } // static getSpotPrice( // baseAmount: BN, quoteAmount: BN, protocolFeesToken0: BN, protocolFeesToken1: BN, // fundFeesToken0: BN, fundFeesToken1: BN, mint0Decimals: number, mint1Decimals: number, side: Side // ): Decimal { // let decimals = this.getDecimals(side, mint0Decimals, mint1Decimals); // let fees0 = protocolFeesToken0.add(fundFeesToken0); // let fees1 = protocolFeesToken1.add(fundFeesToken1); // if(side === Side.Base) { // baseAmount = baseAmount.sub(fees0); // quoteAmount = quoteAmount.sub(fees1); // } // else { // baseAmount = baseAmount.sub(fees1); // quoteAmount = quoteAmount.sub(fees0); // } // let price_scaled = new Decimal(baseAmount.toString()).div(quoteAmount.toString()); // return price_scaled.div(10 ** decimals); // } /** * Calculate spot price from pool reserves after subtracting all fees. * Returns: { netBase, netQuote, spotPrice } where spotPrice is in USD per base token. */ static getSpotPriceWithReserves( poolState: PoolState, vault0State: VaultState, vault1State: VaultState, side: Side, quotePrice: OraclePrice, // wsol or usdc oracle price ): { netBase: Decimal; netQuote: Decimal; spotPrice: Decimal } { // Total fees per token (protocol + fund + creator) let fees0 = new Decimal(poolState.protocolFeesToken0.toString()) .add(new Decimal(poolState.fundFeesToken0.toString())) .add(new Decimal(poolState.creatorFeesToken0.toString())); let fees1 = new Decimal(poolState.protocolFeesToken1.toString()) .add(new Decimal(poolState.fundFeesToken1.toString())) .add(new Decimal(poolState.creatorFeesToken1.toString())); // Swap fees if side is Quote if (side === Side.Quote) { [fees0, fees1] = [fees1, fees0]; } // Get vault balances const baseBalance = new Decimal(vault0State.amount); const quoteBalance = new Decimal(vault1State.amount); // Net reserves after subtracting fees const netBase = baseBalance.sub(fees0); const netQuote = quoteBalance.sub(fees1); // Guard against zero or negative reserves if (netBase.lte(0) || netQuote.lte(0)) { return { netBase: new Decimal(0), netQuote: new Decimal(0), spotPrice: new Decimal(0) }; } // Relative price = netQuote / netBase (quote tokens per 1 base token) // Example: 10,000 USDC / 100 SOL = 100 USDC per SOL const relativePrice = netQuote.div(netBase); // Convert to USD: spotPrice = relativePrice * quotePrice.price // If quote is USDC and quotePrice.price ≈ 1, spotPrice ≈ relativePrice // If quote is WSOL and quotePrice.price = $150, spotPrice = relativePrice * 150 const spotPrice = relativePrice.mul(quotePrice.price); return { netBase, netQuote, spotPrice }; } static getTwapPrimary( currentTime: BN, observations: Observation[], observationIndex: number, side: Side, primarySeconds: BN, secondarySeconds: BN, mint0Decimals: number, mint1Decimals: number ): Decimal { let observation = this.getDeltaObservations(currentTime, observations, observationIndex, primarySeconds, secondarySeconds)[0]; // TODO: use constant for 2**32 number is not safe let twap_scaled = new Decimal(observation.getTwap(side).toString()).div(2**32); let twap = new Decimal(twap_scaled.toString()).div(10**this.getDecimals(side, mint0Decimals, mint1Decimals)); return twap; } static getTwapSecondary( currentTime: BN, observations: Observation[], observationIndex: number, side: Side, primarySeconds: BN, secondarySeconds: BN, mint0Decimals: number, mint1Decimals: number ): Decimal { let observation = this.getDeltaObservations(currentTime, observations, observationIndex, primarySeconds, secondarySeconds)[1]; // TODO: use constant for 2**32 number is not safe let twap_scaled = new Decimal(observation.getTwap(side).toString()).div(2**32); let twap = new Decimal(twap_scaled.toString()).div(10**this.getDecimals(side, mint0Decimals, mint1Decimals)); return twap; } /** * Calculate max price impact for minLiquidity trade in both directions (buy and sell). * Uses constant-product AMM formulas: * - BUY: delta_base_out = netBase * delta_q / (netQuote + delta_q) * - SELL: delta_quote_out = netQuote * delta_base / (netBase + delta_base) */ static calculateMaxPriceImpactForMinLiquidity( oracleParams: OracleSettings, netBase: Decimal, netQuote: Decimal, spotPriceUsd: Decimal, quotePrice: OraclePrice, ): Decimal { const minLiquidityUsd = new Decimal(oracleParams.minLiquidity.toString()); if (spotPriceUsd.lte(0)) { return new Decimal(10000); // 100% impact - invalid } // Convert minLiquidity USD to quote tokens // delta_q_tokens = minLiquidityUsd / quotePrice.price const deltaQTokens = minLiquidityUsd.div(quotePrice.price); // Require pool has at least that many quote tokens if (netQuote.lt(deltaQTokens)) { // Not enough liquidity - return max impact (will fail validation) return new Decimal(10000); // 100% impact } // === BUY simulation (user spends delta_q quote to receive base) === // delta_base_buy = netBase * delta_q / (netQuote + delta_q) const denomBuy = netQuote.add(deltaQTokens); const deltaBaseBuy = netBase.mul(deltaQTokens).div(denomBuy); let impactBuy = new Decimal(0); if (deltaBaseBuy.gt(0)) { // quote spent in USD = delta_q * quotePrice const quoteUsd = deltaQTokens.mul(quotePrice.price); // execution price in USD per base = quoteUsd / deltaBaseBuy const execPriceBuy = quoteUsd.div(deltaBaseBuy); // impact = |execPrice - spotPrice| / spotPrice const diffBuy = execPriceBuy.sub(spotPriceUsd).abs(); impactBuy = diffBuy.div(spotPriceUsd); } // === SELL simulation (user sells base roughly equivalent to minLiquidity USD) === // Approximate base amount to sell: delta_base_sell = delta_q * netBase / netQuote let deltaBaseSell = new Decimal(0); if (netQuote.gt(0)) { deltaBaseSell = deltaQTokens.mul(netBase).div(netQuote); } let impactSell = new Decimal(0); if (deltaBaseSell.gt(0)) { // delta_quote_out = netQuote * delta_base_sell / (netBase + delta_base_sell) const denomSell = netBase.add(deltaBaseSell); const deltaQuoteOut = netQuote.mul(deltaBaseSell).div(denomSell); // quote out in USD const quoteOutUsd = deltaQuoteOut.mul(quotePrice.price); // execution price in USD per base = quoteOutUsd / deltaBaseSell const execPriceSell = quoteOutUsd.div(deltaBaseSell); // impact = |execPrice - spotPrice| / spotPrice const diffSell = execPriceSell.sub(spotPriceUsd).abs(); impactSell = diffSell.div(spotPriceUsd); } // Take max impact of both sides and convert to bps const maxImpact = Decimal.max(impactBuy, impactSell); return maxImpact.mul(new Decimal(10000)); // Convert to bps } static fetch( oracleParams: OracleSettings, accountInfos: AccountInfo[], solPrice: OraclePrice, usdPrice: OraclePrice, ): OraclePrice { const poolStateInfo = accountInfos[0]; const vault0Info = accountInfos[1]; const vault1Info = accountInfos[2]; const observationStateInfo = accountInfos[3]; const [decodedPoolState, _] = PoolState.decode(poolStateInfo.data, 8); const [decodedVault0State, __] = VaultState.decode(vault0Info.data, 0); const [decodedVault1State, ___] = VaultState.decode(vault1Info.data, 0); const [decodedObservationState, ____] = ObservationState.decode(observationStateInfo.data, 8); const currentTime = new BN(Math.floor(Date.now() / 1000)); // Determine quote oracle const quotePrice = oracleParams.quote === Quote.Usdc ? usdPrice : solPrice; // Get spot price with reserves const { netBase, netQuote, spotPrice } = this.getSpotPriceWithReserves( decodedPoolState, decodedVault0State, decodedVault1State, oracleParams.side, quotePrice, ); // Get TWAP prices let primaryPrice = RaydiumCPMMOracle.getTwapPrimary( currentTime, decodedObservationState.observations, decodedObservationState.observationIndex, oracleParams.side, oracleParams.twapSecondsAgo, oracleParams.twapSecondarySecondsAgo, decodedPoolState.mint0Decimals, decodedPoolState.mint1Decimals ); let secondaryPrice = RaydiumCPMMOracle.getTwapSecondary( currentTime, decodedObservationState.observations, decodedObservationState.observationIndex, oracleParams.side, oracleParams.twapSecondsAgo, oracleParams.twapSecondarySecondsAgo, decodedPoolState.mint0Decimals, decodedPoolState.mint1Decimals ); // Convert TWAP prices to USD const primaryPriceUsd = primaryPrice.mul(quotePrice.price); const secondaryPriceUsd = secondaryPrice.mul(quotePrice.price); // Validate primary TWAP is not zero if (primaryPriceUsd.eq(0)) { return new OraclePrice(new Decimal(0), new Decimal(0), 0); } // === Min liquidity and price impact validation === if (oracleParams.minLiquidity.gt(new BN(0))) { const impactBps = this.calculateMaxPriceImpactForMinLiquidity( oracleParams, netBase, netQuote, spotPrice, quotePrice, ); if (oracleParams.maxSlippageBps > 0) { if (impactBps.gt(new Decimal(oracleParams.maxSlippageBps))) { return new OraclePrice(new Decimal(0), new Decimal(0), 0); } } } // === Confidence calculation === const maxPrice = Decimal.max(primaryPriceUsd, secondaryPriceUsd, spotPrice); const minPrice = Decimal.min(primaryPriceUsd, secondaryPriceUsd, spotPrice); let confidence = maxPrice.gt(minPrice) ? maxPrice.sub(minPrice) : new Decimal(0); // Get last update timestamp from latest observation const lastUpdateTimestamp = decodedObservationState.observations[decodedObservationState.observationIndex].timestamp; // === Inflate confidence by staleness === // confidence = confidence * (1 + delta_t * stalenessConfRateBps / 10_000) const deltaSecondsBN = currentTime.sub(lastUpdateTimestamp); const deltaSeconds = new Decimal(deltaSecondsBN.toString()); if (oracleParams.stalenessConfRateBps > 0 && deltaSeconds.gt(0)) { const stalenessRate = new Decimal(oracleParams.stalenessConfRateBps).div(new Decimal(10000)); const inflateFactor = new Decimal(1).add(deltaSeconds.mul(stalenessRate)); confidence = confidence.mul(inflateFactor); } // === Validate confidence threshold === // confidence / primaryPrice * 10_000 < confThreshBps const confRatioBps = confidence.div(primaryPriceUsd).mul(new Decimal(10000)); if (confRatioBps.gt(new Decimal(oracleParams.confThreshBps))) { return new OraclePrice(new Decimal(0), new Decimal(0), 0); } // === Validate staleness threshold === if (oracleParams.stalenessThresh.gt(new BN(0))) { if (currentTime.sub(lastUpdateTimestamp).gt(oracleParams.stalenessThresh)) { return new OraclePrice(new Decimal(0), new Decimal(0), 0); } } // === Validate volatility threshold === // (maxPrice - minPrice) / minPrice * 10_000 <= volatilityThreshBps if (!minPrice.eq(0)) { const volRatioBps = maxPrice.sub(minPrice).div(minPrice).mul(new Decimal(10000)); if (volRatioBps.gt(new Decimal(oracleParams.volatilityThreshBps))) { return new OraclePrice(new Decimal(0), new Decimal(0), 0); } } else { // minPrice == 0 is invalid return new OraclePrice(new Decimal(0), new Decimal(0), 0); } return new OraclePrice(primaryPriceUsd, confidence, currentTime.toNumber()); } }