Guardian's review of Price Feed for Olympus, published May 2026. The report records 59 findings across 2 review rounds, including 1 high and 15 medium.
- Published
- Review window
- March 30 to May 8, 2026
- Rounds
- Main Review, Remediation Review
- Language
- Solidity
- Chains
- Ethereum, Arbitrum, Optimism, Base, Berachain
- Sector
- Tokens
- 0 Critical
- 1 High
- 15 Medium
- 24 Low
- 19 Informational
Scope
30 files in scope · 3,188 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/proposals/OracleProposal.sol | 240 | 297 |
src/interfaces/IPyth.sol | 9 | 26 |
src/policies/price/BaseOracleFactory.sol | 221 | 389 |
src/policies/price/ChainlinkOracleCloneable.sol | 130 | 297 |
src/policies/price/ChainlinkOracleFactory.sol | 32 | 71 |
src/policies/price/ERC7726OracleCloneable.sol | 90 | 161 |
src/policies/price/ERC7726OracleFactory.sol | 183 | 280 |
src/policies/price/MorphoOracleCloneable.sol | 90 | 196 |
src/policies/price/MorphoOracleFactory.sol | 50 | 110 |
src/policies/price/PriceConfig.v2.sol | 137 | 209 |
src/modules/PRICE/IPRICE.v2.sol | 95 | 304 |
src/modules/PRICE/OlympusPrice.v1_2.sol | 78 | 186 |
src/modules/PRICE/OlympusPrice.v2.sol | 401 | 851 |
src/modules/PRICE/PRICE.v2.sol | 30 | 67 |
src/scripts/ops/batches/ConfigureOracles.sol | 50 | 103 |
src/scripts/ops/batches/ConfigurePriceV1_2.sol | 412 | 621 |
src/policies/interfaces/price/IChainlinkOracle.sol | 7 | 29 |
src/policies/interfaces/price/IERC7726Oracle.sol | 7 | 27 |
src/policies/interfaces/price/IERC7726OracleFactory.sol | 19 | 82 |
src/policies/interfaces/price/IERC7726OraclePriceCache.sol | 3 | 14 |
src/policies/interfaces/price/IMorphoOracle.sol | 7 | 32 |
src/policies/interfaces/price/IOracleFactory.sol | 24 | 103 |
src/policies/interfaces/price/IOraclePriceCache.sol | 3 | 11 |
src/policies/interfaces/price/IPriceOracle.sol | 3 | 11 |
src/modules/PRICE/submodules/strategies/ISimplePriceFeedStrategy.sol | 9 | 37 |
src/modules/PRICE/submodules/strategies/SimplePriceFeedStrategy.sol | 278 | 688 |
src/modules/PRICE/submodules/feeds/ChainlinkPriceFeeds.sol | 189 | 365 |
src/modules/PRICE/submodules/feeds/ERC4626Price.sol | 54 | 145 |
src/modules/PRICE/submodules/feeds/PythPriceFeeds.sol | 207 | 440 |
src/modules/PRICE/submodules/feeds/UniswapV3Price.sol | 130 | 309 |
Findings 59
Main Review
47 findings · March 30 to April 9, 2026-
H-01 High Public cache refresh can brick pair oracles DoS Resolved
Description
PriceConfigv2.cachePrice()is permissionless once the policy is enabled, but the pair adapters readVariant.LASTfor each asset and require the two cache timestamps to match exactly.ChainlinkOracleCloneable.latestRoundData()reverts on any mismatch, and the same pattern is repeated inMorphoOracleCloneable.price()andERC7726OracleCloneable.getQuote().That makes the pair oracles incompatible with one-sided cache updates. A caller can refresh
OHMwithout refreshingUSDS, which moves only oneLAST.cachedAtforward and causes everyOHM/USDSadapter read to revert with an inconsistent-timestamp error until both assets are cached again at the same timestamp. This is not limited to the public policy path. The generic ERC7726 clone can also be used as a public cache writer becauseERC7726OracleFactory.cachePrices()does not bindbase_andquote_to any immutable pair, so a caller can routecachePrices(asset, asset)through the deployed clone and refresh a single asset that way as well.The result is a cheap, repeatable DoS on the newly deployed oracle surfaces. Any integration that depends on the OHM/USDS Chainlink, Morpho, or ERC7726 adapters can be forced to revert until a repair transaction re-synchronizes the two cache entries.
Recommendation
Do not combine exact timestamp equality with permissionless single-asset cache writes. The safest fix is to make the pair adapters validate freshness per asset instead of requiring
baseTimestamp == quoteTimestamp, and then usemin(baseTimestamp, quoteTimestamp)as the reported timestamp. If exact equality must be preserved, remove the public single-asset cache path for assets used by pair oracles and add pair validation toERC7726OracleFactory.cachePrices()so the generic clone cannot refresh arbitrary assets. -
M-01 Medium OHM target price is reset from deployment seed Upgradeability Resolved
Description
ConfigurePriceV1_2._configureOhm()does not initialize OHM's moving-average state from the live PRICE v1.1 module. Instead, it reads a singleohmInitialPricefrom the batch arguments, fills all 21 entries of the seven-day observation window with that value, and stores the seeded array as the initial OHM moving-average history for PRICE v1.2.That means the upgrade does not preserve the live target-price state. It replaces it with an operator-chosen deployment input. The only guard on that input is a broad sanity range check between
17e18and22e18, which is not tied to the actual live OHM target at the time of execution.This directly affects the compatibility path exposed by
OlympusPricev1_2.getTargetPrice()returnsmax(movingAverage, minimumTargetPrice), so any seeded moving average above the live target immediately becomes the new protocol target price even though no new market observations justified the change. On a mainnet fork at block24,792,420, the live PRICE v1.1 target price was16.522383181232041915e18. Replaying the PR rollout with the batch logic from this PR set the upgraded PRICE v1.2 target price to exactly20e18.The impact is not limited to read-only oracle drift.
OperatorconsumesPRICE.getTargetPrice()in both_updateRangePrices()and_addObservation(). As a result, the seeded value propagates into RANGE pricing and regeneration logic as soon as the upgrade is live. The protocol starts operating from an inflated OHM target instead of the real pre-upgrade state.The issue also persists longer than a single block. The batch seeds a full seven-day window using 21 synthetic observations. Even if later heartbeats store real prices, the inflated moving average decays only as those seeded observations age out. For roughly one moving-average window, the protocol can remain anchored to deployment-time input rather than market-derived history.
Recommendation
Do not bootstrap OHM's moving average from a free-form deployment argument. The upgrade should migrate the live PRICE v1.1 state into PRICE v1.2, or reconstruct the initial observation window from live on-chain data at execution time.
If full migration is too heavy, the minimum safe fallback is to derive the seed from the old module's live OHM target or recent stored observations and reject any proposed seed that deviates materially from that value. The rollout should also include a post-upgrade check that compares the new
getTargetPrice()against the pre-upgrade value and reverts if the drift exceeds a narrow tolerance. -
M-02 Medium Single OHM feed outage stalls heartbeat DoS Resolved
Description
The PR configures OHM to use
SimplePriceFeedStrategy.getAveragePrice()in strict mode across exactly two live feeds: theOHM/WETHUniswap V3 TWAP and theOHM/sUSDSUniswap V3 TWAP.That combination leaves no tolerance for a single feed outage. In strict mode,
getAveragePrice()reverts when fewer than two non-zero prices are available. So if either OHM path fails, reverts, or returns zero, OHM no longer has a current price even though the other path is still healthy.This becomes a protocol-availability issue because
Heart.beat()begins by callingPRICE.updateMovingAverage(). Updating the moving average for OHM requires a fresh current OHM price. When one OHM feed path is missing, PRICE reverts withPRICE_StrategyFailed(OHM, SimpleStrategy_PriceCountInvalid(1,2)), and the heartbeat aborts before rebases or periodic tasks execute.The impact is broader than OHM spot-price availability. A stalled heartbeat blocks the protocol’s scheduled maintenance flow, including rebases, keeper rewards, and downstream periodic-task execution. In effect, one failed OHM path can halt the whole heartbeat loop until the missing path is restored.
Recommendation
Do not run OHM in strict mode with only two feeds.
Safer options are:
- Add a third independent OHM source so one failure still leaves two prices.
- Disable strict mode so OHM can continue using the surviving path during a single-feed outage.
- If heartbeat continuity is the priority, add a bounded fallback to the cached price or moving average when one live path is unavailable.
-
M-03 Medium Chainlink clone serves stale cached prices Validation Resolved
Description
ChainlinkOracleCloneable.latestRoundData()readsPRICE.getPrice(..., Variant.LAST)for both assets and never enforcesmaxAge()before returning the answer. Once a value is cached, the adapter keeps serving that cached ratio even after the configured freshness window has expired.This differs from the Morpho and ERC7726 adapters in the same PR. Those adapters explicitly revert when the shared cached timestamp is older than
maxAge(). The Chainlink clone does not. The test suite confirms the behavior: after warping pastmaxAge,latestRoundData()still returns the old cached answer instead of rejecting it.That turns a short-lived bad cache into a longer-lived integration risk. If OHM is cached while one of its Uniswap TWAP inputs is manipulated, the OHM/USDS Chainlink clone can continue serving that stale manipulated answer until some caller refreshes the cache. Integrations that treat the adapter like a normal Chainlink feed but do not independently enforce a heartbeat may keep consuming an outdated price long after the intended freshness window has passed.
A bad cached round can survive beyond the configured
maxAgeboundary.Recommendation
Make
latestRoundData()enforce the same freshness rule as the other clone adapters.The smallest safe change is to reuse the existing staleness check and revert when the shared cache timestamp is older than
maxAge(). If Chainlink compatibility requires stale rounds to remain readable, then this adapter should not be used as a safety boundary on its own and the documentation and deployment scripts should require downstream consumers to reject oldupdatedAtvalues explicitly. -
M-04 Medium Public cache writes bypass Operator stale check Unexpected Behavior Resolved
Description
OlympusPricev1_2.lastObservationTime()no longer returns the timestamp of the last moving-average observation. It returns the timestamp of OHMVariant.LAST, which is the generic cache entry.That cache can be updated without storing a new observation.
PriceConfigv2.cachePrice()is public and callsPRICE.cachePrice(asset_), so any caller can refresh OHM's cached timestamp without advancing the moving-average ring buffer.This breaks a real legacy assumption in
Operator.Operator._onlyWhileActive()treatsPRICE.lastObservationTime()as the freshness check for whether RANGE data is still based on a recent observation. Under v1.2, a publiccachePrice(OHM)call can make that check look fresh even though no new observation was stored andHeart.beat()never ran. The stale-data guard can therefore be bypassed, leaving swaps and wall activity enabled against stale RANGE state.Recommendation
Keep
lastObservationTime()backward-compatible and return the actual OHM observation timestamp, not the generic cache timestamp. The simplest fix is to read OHM's stored moving-average state directly.If that compatibility cannot be preserved,
Operatorshould stop usingPRICE.lastObservationTime()as its stale gate and instead read an explicit observation timestamp that public cache writes cannot refresh. -
M-05 Medium Public OHM cache commits make price writable Oracle Partially resolved
Description
The OHM rollout is configured so the live OHM price comes only from two Uniswap V3 TWAP paths,
OHM/WETHandOHM/sUSDS, resolved withSimplePriceFeedStrategy.getAveragePrice()in strict mode. The batch also seeds a seven-day moving average, but setsstoreMovingAverage = trueanduseMovingAverage = false, so that moving average is stored and updated without being used in the live OHM pricing path.That matters because
OlympusPricev2.cachePrice()snapshots_getCurrentPrice()intoVariant.LASTand the public policy surface exposes that write throughPriceConfigv2.cachePrice(). The clone adapters also expose public cache entrypoints that route back to the same cache write path. In other words, once OHM is configured this way, anyone can choose when the current OHM DEX-TWAP path is committed into the canonical cached value used by downstream adapters.UniswapV3Price.getTokenTWAP()already warns that Uniswap V3 TWAPs can be manipulated with multi-block MEV. The chosen OHM strategy adds no divergence filter. With exactly two live OHM feeds,getAveragePrice()only checks that both are non-zero and then averages them. So if an attacker can bias either OHM pool for the configured TWAP window, the manipulated leg becomes half of the resolved OHM price instead of being excluded as an outlier.The main impact is that the OHM oracle path is not only manipulable, it is writable. An attacker can first bias the OHM TWAP inputs and then immediately call a public cache entrypoint to promote that manipulated live value into
LAST. The OHM/USDS Chainlink, Morpho and ERC7726 adapters all read cachedLASTprices, so the committed value becomes the quote surface seen by lending integrations. For Morpho and ERC7726 this persists until the cache ages out or someone refreshes it. For the Chainlink clone, standard reads do not enforcemaxAge, so a stale manipulated round can survive even longer if the consumer does not reject it onupdatedAt.This can misprice OHM collateral or debt in downstream integrations and enable bad borrows, bad liquidations, or insolvency if the manipulated cached price is accepted.
Current deployment artifacts in this repository only show this OHM price path on Ethereum mainnet. Using live mainnet pool state from April 2, 2026 at block 24794064, the cheapest manipulation route is the
OHM/WETHpool. A one-path manipulation held for the full 30-minute TWAP window needs roughly 46.9 WETH (about $96.6k) to make the final OHM oracle read about 5% high, or 91.7 WETH (about $189k) to make it read about 10% high. TheOHM/sUSDSpool is materially more expensive at roughly $444k and $868k for those same outcomes. If the attacker can only hold the distortion for 10 minutes of the 30-minute window, theOHM/WETHpath rises to roughly $304k for a 5% final skew and roughly $623k for a 10% final skew.These figures show that the issue is not a cheap arbitrary-write primitive today. The expensive step is economically holding the OHM pool off-market long enough for the TWAP to move, after which the cache commit is trivial. In practice, exploitability depends on whether the value extractable from a downstream lending integration exceeds the capital, arbitrage leakage and operational risk needed to sustain that 30-minute distortion.
Recommendation
Do not expose permissionless cache commits for a lending-facing OHM price path that is derived only from DEX TWAPs.
The safest fix is to harden the OHM price source and the cache-commit surface together. Use the stored moving average in the live OHM path, add a non-DEX OHM source, or switch the resolver to a deviation-filtering strategy such as
getAveragePriceExcludingDeviations()so a single manipulated pool cannot be committed directly intoLAST. If the protocol wants to keep a DEX-heavy OHM path, the permissionless cache entrypoints should not be allowed to write that value directly into the lending-facing cached surface. -
M-06 Medium Deviation exclusion runs only one filter pass Unexpected Behavior Resolved
Description
getAveragePriceExcludingDeviations()computes one median benchmark from the full input set, calls_filterByDeviation()once, and immediately averages the survivors. It never recomputes the benchmark after removing outliers.getMedianPriceExcludingDeviations()repeats the same pattern atSimplePriceFeedStrategy.sol:620-650.This is weaker than the selector's documented consensus behavior. A more extreme outlier can pull the initial median far enough that a second weaker outlier survives the first pass even though it no longer fits the benchmark formed by the remaining prices. For example, at a
200bps threshold the set[10000, 10000, 10400, 11000]leaves[10000, 10000, 10400]after one pass andgetAveragePriceExcludingDeviations()returns10133. Recomputing the median on the survivors gives10000, so an iterative filter would drop10400and return10000instead.This matters because the repository's v1.2 rollout config uses
getAveragePriceExcludingDeviations()for WETH with four feeds and a200bps threshold. Therefore one bad feed can still bias the resolved price if another, more extreme outlier is present in the same sample. The issue does not require the surviving outlier to pass the tighter post-filter consensus. It only needs to survive the first benchmark.Recommendation
Recompute the benchmark after each exclusion round and continue filtering until the survivor set stops changing or the minimum required count is no longer met. The smallest safe change is to wrap the current median-based filter in a loop and reuse the reduced array as the next input. If iterative consensus is not intended, the selector comments and interface docs should be corrected so integrators do not rely on a stronger guarantee than the code provides.
-
M-07 Medium Max-age reads accept stale moving-average cache Unexpected Behavior Resolved
Description
getPrice(asset_, maxAge_)treats cache freshness as if it were full price freshness. That is not true for assets whose strategy uses the stored moving average.When
cachePrice()orstoreObservation()writesVariant.LAST, the cached value can include the moving average._getCurrentPrice()checkslastObservationTimeonly at the moment the cache entry is created. After that,getPrice(asset_, maxAge_)returns the cached value whenevercachedAtis withinmaxAge_. It never checks whether the moving average inside that cached price has since gone stale.As a result, a caller can cache an MA-backed asset shortly before
observationFrequencyexpires, wait until the moving average is stale, and still read the old inclusive price throughgetPrice(asset_, maxAge_)as long as the cache timestamp is recent enough. I confirmed this with a targeted local repro: after cachingONEMAwhile its moving average was fresh,Variant.CURRENTreverted withPRICE_MovingAverageStaleafter the observation window elapsed, butgetPrice(ONEMA, 2 * observationFrequency)still returned the cached inclusive value.This weakens the freshness guarantee that callers are likely to infer from
maxAge_. The result is not just an old spot price. It can be a price that still embeds an expired moving average. Any integration that relies ongetPrice(asset_, maxAge_)orgetPriceIn(..., maxAge_)as its freshness boundary can accept MA-backed prices longer than the module's moving-average staleness rule intends.A realistic case looks like this:
observationFrequency = 8 hours- an oracle or integration uses
maxAge = 1 hour - at
15:59, while the moving average is still fresh, someone callscachePrice(asset_) - the
16:00heartbeat is missed - after
16:00, a fresh read throughVariant.CURRENTwould revert withPRICE_MovingAverageStale - but until
16:59,getPrice(asset_, 1 hours)can still return the cached inclusive value because it only checks the cache timestamp, not whether the embedded moving average has expired
That means the caller can receive a price that appears fresh under the
maxAge_rule even though the moving-average component inside it is already stale.Recommendation
When an asset uses the stored moving average,
getPrice(asset_, maxAge_)should rejectVariant.LASTonce the underlying moving average is stale, even ifcachedAtis still withinmaxAge_.The smallest safe change is to add a second check before returning the cached value:
if ( asset.useMovingAverage && asset.lastObservationTime + _observationFrequency <= block.timestamp ) { (uint256 price, , ) = _getCurrentPrice(asset_, true); return price; }_getPriceStale()and thegetPriceIn(..., maxAge_)helpers should enforce the same rule so all max-age reads treat moving-average freshness consistently. -
M-08 Medium getQuote truncation amplification Rounding Resolved
Description
ERC7726OracleCloneable._getQuoteInternal()splits the price conversion into two sequentialFullMath.mulDivcalls. Step 1 divides byquotePriceUsd, truncating the intermediate result. Step 2 multiplies that truncated value byquoteTokenScale / baseTokenScale, amplifying the truncation error.uint256 intermediate = inAmount_.mulDiv(basePriceUsd, quotePriceUsd); // truncates outAmount_ = intermediate.mulDiv(quoteTokenScale, baseTokenScale); // amplifiesWhen the quote token has more decimals than the base token, the amplification factor is
quoteTokenScale / baseTokenScale. For base=6 dec and quote=18 dec, the factor is 1e12. For base=0 dec and quote=18 dec, the factor is 1e18.In the worst case (base=0 dec, quote=18 dec, price ratio 1:3), step 1 computes
1 * 1e18 / 3e18 = 0via floor division. Step 2 cannot recover from a zero intermediate and returns0— a 100% loss for a valid non-zero input.Recommendation
Fuse the two
mulDivcalls so thatquoteTokenScaleis multiplied before dividing byquotePriceUsd, eliminating the amplification path:outAmount_ = FullMath.mulDiv( inAmount_.mulDiv(basePriceUsd, baseTokenScale), quoteTokenScale, quotePriceUsd );This ensures truncation only occurs once at the end, after the full numerator is assembled.
-
M-09 Medium Stale MA silently used as target price Validation Resolved
Description
getTargetPrice()callsgetPrice(OHM, Variant.MOVINGAVERAGE), which returnscumulativeObs / numObservationswith no staleness check onlastObservationTime. In contrast,_getCurrentPrice()explicitly checkslastObservationTime + observationFrequency <= block.timestampand reverts withPRICE_MovingAverageStalewhen the MA is outdated.After a heartbeat gap (e.g., caused by a feed outage),
getCurrentPrice()correctly reverts, butgetTargetPrice()silently returns the stale MA value.Operator.beat()consumesgetTargetPrice()to set RBS range prices via_updateRangePrices(), so the protocol resumes operating against a target that no longer reflects market conditions.This is distinct from the single-feed-outage heartbeat stall. This finding covers what happens after resumption: stale MA data is consumed without any error signal.
Recommendation
Add a staleness check to the
Variant.MOVINGAVERAGEpath inOlympusPrice.v2.sol(lines 159-163), mirroring the guard in_getCurrentPrice():if (asset.lastObservationTime + _observationFrequency <= block.timestamp) revert PRICE_MovingAverageStale(asset_, asset.lastObservationTime);Alternatively, add the check directly in
getTargetPrice()before returning the MA value. -
M-10 Medium Insufficient Pool Cardinality DoS Resolved
Description
UniswapV3Price.getTokenTWAP()callspool.observe([1800, 0])to read a 30-minute TWAP. UniV3 stores observations in a circular buffer and writes at most one entry per block. If swap activity on a pool sustains one swap per block for ~26 minutes, all 128 buffer slots get overwritten with observations from the last 25.6 minutes (128 × 12s). The 30-minute lookback then has no observation old enough andobserve()reverts withOLD.Both OHM pools have
observationCardinality = 128on mainnet. The pool's cardinality is an external property that Olympus does not control and that can become insufficient at any time as trading patterns change.Recommendation
Validate at configuration time that
pool.observationCardinality >= observationWindowSeconds / 12for the target pool. Consider also monitoring cardinality as an operational concern. -
M-11 Medium 2-price path bricks WETH pricing DoS Resolved
Description
getAveragePriceExcludingDeviationshas two code paths: for exactly 2 non-zero prices it uses their average as the deviation benchmark (inline, lines 478-504); for 3+ prices it uses the median via_filterByDeviation.The average benchmark is strictly more sensitive than the median. When two prices diverge, each deviates equally from their midpoint, so a spread exceeding
2 × deviationBpsexcludes both prices simultaneously and reverts.WETH is configured with 4 feeds at 200 bps (2%). If exactly 2 feeds fail (e.g., Pyth stale + RedStone offline), the 2 survivors enter the inline path. A spread >4% between them — realistic when the derived ETH/BTC×BTC/USD feed lags behind direct Chainlink ETH/USD during volatile markets — causes both to be excluded and WETH pricing reverts with
SimpleStrategy_PriceCountInvalid(0, 2).The same spread survives the 3-price median path because the median anchors on a middle value that doesn't shift symmetrically. This creates a non-monotonic DoS: losing 2-of-4 feeds is worse than losing 1-of-4.
At the fork block ETH price of ~$1962, the bricking threshold is a ~$78 spread between the two survivors.
Recommendation
Use median as the benchmark for the 2-price case as well, or unify all input counts into a single filtering path via
_filterByDeviation. Alternatively, increase WETH'sdeviationBpsto tolerate realistic inter-feed divergence during volatile markets. -
M-12 Medium Dead OHM/sUSDS leg can stall OHM pricing Oracle Partially resolved
Description
configurePriceV1_2()hard-codes OHM to use a strict two-feed average overOHM/WETHandOHM/sUSDS. The second feed callsUniswapV3Price.getTokenTWAP(), which accepts the pool as long asobserve()succeeds and the quote token can be priced. It does not reject a pool with zero in-range liquidity or a boundary tick. Consequently, a deadOHM/sUSDSfeed is still treated as valid until the strategy sees its0result.That state already occurred on mainnet for pool
0x0858e2B0F9D75f7300B38D64482aC2C8DF06a755. At block24,862,000on April 12, 2026,slot0.tick = -887272,liquidity() = 0andobserve([1800,0])still succeeded even though the newest initialized observation was about4.31days old. This happens because Uniswap V3observe()counterfactually extends the last observation using the current tick and liquidity. WithOHM.decimals() = 9andsUSDS.decimals() = 18, a TWAP tick of-887272makesOracleLibrary.getQuoteAtTick()return less than1 weiofsUSDSfor1 OHM, so theOHM/sUSDSleg floors to0.This was not a theoretical edge case. The pool died in tx
0xeaa0a57e04a9cb086d55a59d0eb7a29912e1957677c498b0664b4afe01dc20abat block24,831,090on April 7, 2026. That swap sold about0.3913027 OHMfor about5.872525384784232 sUSDSand moved the pool onto the minimum tick with zero active liquidity. The pool stayed there until tx0x55c00482f91fa7bfc4036343e9e02c731149eaf7a4fdf68be81699a84df53e97at block24,877,959on April 14, 2026, where a swap of about138.09596400040314 sUSDSfor about9.146450539 OHMmoved price back into range and restored active liquidity. This shows the configured pool is thin enough to go economically dead under normal trading.Because the rollout sets
ohmStrictMode = true, the final OHM strategy reverts once only one non-zero price remains. On a replayed fork this exact case producesOHM/sUSDS = 0,OHM/WETH > 0andSimpleStrategy_PriceCountInvalid(1, 2). Consequently, OHM configuration fails if the upgrade is executed during the dead window.This dependency remains live after the upgrade. Any fresh OHM recomputation, including
getPrice(OHM)once the cache is stale,getPrice(OHM, CURRENT),cachePrice(OHM),storeObservation(OHM)andstoreObservations(), will revert while the pool remains out of range. SinceHeart.beat()begins by updating PRICE moving averages, the same condition can stall heartbeat execution before the rebase and other periodic tasks run.Recommendation
Do not make this
OHM/sUSDSpool a mandatory OHM oracle input. Consider removing the strict 2-of-2 dependence for OHM by settingohmStrictModetofalseuntil a third independent source is added. -
M-13 Medium Same-block mixed caches can pass validation Oracle Resolved
Description
ChainlinkOracleCloneablebuilds the quote from two independentPRICE.getPrice(..., Variant.LAST)reads and only rejects the result when the cached timestamps differ. That check does not prove both legs came from one coherent observation.PriceConfigv2.cachePrice()is public andPRICE.cachePrice()writescachedAt = block.timestamp.Therefore a block builder can include one transaction that refreshes the base asset early in a block and another that refreshes the quote asset later in the same block after the market moved. Both cache entries then share the same timestamp even though they represent different states. The adapter will return the mixed ratio and mark it as a valid Chainlink round by setting
roundId,updatedAtandansweredInRoundto that timestamp. Integrators that only checkanswer > 0, freshness andansweredInRound == roundIdwill accept an economically wrong price.For example, imagine the wrapper starts a block with coherent cached prices of
OHM = 24.00 USDandUSDS = 1.0000 USD, so the correct pair price is24.0000USDS per OHM. A public caller refreshes only theOHMleg early in the block. Later in the same block, after OHM has sold off andUSDShas drifted slightly off peg, the new coherent live state isOHM = 23.10 USDandUSDS = 0.9980 USD, so the correct pair price at that point is about23.146292585170340681. Another public refresh updates only theUSDSleg. The wrapper now readsOHM = 24.00 USD @ TandUSDS = 0.9980 USD @ T, sees matching timestamps, and returns about24.048096192384769539e18as a fresh valid round. That value did not correspond to either coherent state in the block: the pair was24.0000before the move and about23.1463after it.Recommendation
Do not treat timestamp equality as proof that the pair snapshot is coherent. The safest fix is to refresh both assets through one atomic pair snapshot and have the adapter read that shared record. If the protocol keeps per-asset caches, pair adapters should track a factory-controlled round counter or pair observation id that changes only when both legs are refreshed together. Public single-asset refreshes should not be able to create apparently valid pair rounds for assets used by the Chainlink wrapper.
-
L-01 Low Oracle validation checks base USD price Logical Error Resolved
Description
DeployOracles.validateOraclePrice()says it validates the deployed base/quote oracle against the configuredminPriceandmaxPricebounds, but it never reads the oracle price or derives the pair price. After checking that the oracle exists and is enabled, it callsIPRICEv2(_priceModule).getPrice(_baseToken), which is only the base token's USD price.That means the validation compares the wrong value whenever the quote token is not exactly one USD. A correct pair oracle can be rejected because the script is looking at the base token's USD price instead of the base/quote price. The reverse is also true: a wrong pair oracle can pass if the base token's USD price happens to sit inside the configured bounds.
The scripted test harness reproduces both cases with a simple setup where the base token is worth
2e18USD and the quote token is worth4e18USD. The true pair price is0.5e18, butvalidateOraclePrice()still passes for1.5e18-2.5e18bounds and reverts for the correct0.4e18-0.6e18bounds.This is not just a logging mistake as
BatchScriptV2runs post-batch validation during simulation before it proposes or executes the batch, so the bug can block a correct deployment or give operators false confidence that a mispriced deployment was validated successfully.Recommendation
Validate the deployed oracle's pair price instead of
PRICE.getPrice(baseToken).The safest fix is to query the oracle returned by the factory and compare its quote-denominated output against
minPriceandmaxPrice. If the script needs to stay generic and cannot read every oracle type directly, derive the expected pair price from the PRICE module by dividing the base token USD price by the quote token USD price and validate that value instead. The args file and docs should also make it explicit that the bounds are pair-price bounds, not base-token USD bounds. -
L-02 Low AverageIfDeviation returns min, not first Logical Error Resolved
Description
getAveragePriceIfDeviation()says it returns the first non-zero price when no deviation is detected, but it sortsnonZeroPricesbefore that branch and then returnsnonZeroPrices[0].QuickSort.sort()mutates the array in place, sononZeroPrices[0]is no longer the first configured feed. It is the minimum surviving price.That silently changes the selector's behavior from feed-priority semantics to min-price semantics whenever the inputs are close enough to avoid the deviation path. Any asset that relies on this selector to prefer a specific feed ordering will resolve to the lowest feed instead.
Recommendation
Cache the original first non-zero value before sorting and return that saved value on the no-deviation branch.
getMedianPriceIfDeviation()already follows this pattern withfirstNonZeroPrice. -
L-03 Low ERC4626 oracle rejects valid decimal offsets Compatibility Resolved
Description
ERC4626Pricereverts unless the share token and the underlying token report the same decimals. ERC-4626 does not require that invariant. A compliant vault can use different share decimals or a decimal offset and still return correct conversions throughconvertToAssets(). This hard check therefore rejects valid vaults that the adapter should be able to price.Once such a vault is configured, price resolution fails even though the vault itself may be well formed. Consequently, the listed asset becomes unreadable through PRICE, and any downstream oracle or dependent asset that relies on the same feed can fail as well. The current
sUSDSdeployment appears to use matching 18-decimal tokens, but the generic adapter remains incompatible with a valid class of ERC4626 vaults.Recommendation
Consider removing the equality check and normalize across both scales instead. Query
convertToAssets(10 ** shareDecimals)for one whole share, then divide by10 ** underlyingDecimalswhen translating the underlying USD price into a whole-share USD price. -
L-04 Low Feed failure blocks asset reconfiguration Configuration Acknowledged
Description
addAsset()andupdateAsset()both validate the final configuration with_getCurrentPrice(asset_, true)and then revert ifsuccessAllFeedsis false. This requires every configured feed in the final configured feed set to succeed during reconfiguration, even when the runtime strategy can still resolve a valid price from the remaining feeds.For example, WETH is configured with 4 feeds. If RedStone becomes stale while the other 3 feeds are still live, runtime WETH pricing continues to work because the strategy drops the stale leg. However,
updateAsset()still reverts withPRICE_PriceFeedCallFailed(WETH)if the final updated feed set still includes that stale RedStone leg.This creates a reconfiguration failure mode during deployment and maintenance: a single stale or failing feed can cause validation to revert even though the asset remains priceable in normal runtime paths.
Recommendation
Do not require
successAllFeeds == truefor everyaddAsset()/updateAsset()path. Allow configuration to succeed when the final feed set and strategy can still resolve a valid aggregate price, even if excluded failed feeds would not. -
L-05 Low UniswapV3Price accepts counterfeit pools Validation Partially resolved
Description
UniswapV3Pricedoes not verify that the configured pool belongs to the canonical Uniswap V3 factory._checkPoolAndTokenParams()only checks that the target responds toslot0(),token0(), andtoken1(), and thatlookupToken_is one of the reported pool tokens.getTokenTWAP()then trustsobserve()on the same address to supply the TWAP inputs.Consequently, any contract that mimics the Uniswap V3 pool surface can be accepted as a valid feed if it reports the expected token addresses and returns plausible oracle data.
PRICE.addAsset()would still approve the configuration because its validation only asks the feed for one successful price. The rollout scripts make this easier to misconfigure because they read raw pool addresses from arguments and pass them straight through toUniswapV3Price.UniswapV3Paramswithout checking the pool factory first.We checked the two currently configured Ethereum mainnet OHM pools on April 3, 2026, and both report the canonical Uniswap V3 factory
0x1F98431c8aD98523631AE4a59f267346ea31F984. The contract itself still leaves that invariant unenforced, so a bad governance input or a compromised deployment script can turn the feed into an arbitrary price source while it still looks structurally valid.Recommendation
Verify the pool against the canonical Uniswap V3 factory before accepting it as a feed. The safest fix is to read
factory()from the configured pool, compare it to the expected factory for the deployment, and reject any mismatch. If governance always knows the intended token pair and fee tier, an even stronger fix is to derive the expected pool address from the canonical factory and compare it to the supplied address during configuration. -
L-06 Low Pyth two-feed prices ignore derived confidence Validation Resolved
Description
getTwoFeedPriceDiv()andgetTwoFeedPriceMul()validate the confidence interval of each input feed independently through_getFeedPrice(), then combine the midpoint prices into a synthetic result. They never derive or validate the confidence interval of the synthetic price itself.That means the contract enforces the wrong uncertainty bound for composed prices. A two-leg cross-rate can have materially wider uncertainty than either leg on its own, especially during stressed markets. For example, if both legs are allowed to pass with roughly 1% relative uncertainty, the derived product or ratio can be close to 2% wide while still passing both per-feed checks. Pyth's own cross-rate guidance warns that confidence intervals are not derived automatically and must be handled manually when a protocol needs them.
The current Olympus rollout does not use these two-feed Pyth helpers, so this is not an immediate production issue. It is still a real adapter bug because the contract advertises generic Pyth cross-rate helpers while enforcing only per-leg confidence caps, not a cap on the derived output that callers actually consume.
Recommendation
Derive and validate confidence on the synthetic result instead of only validating each leg independently. Consider computing a conservative confidence bound for the product or ratio and compare that derived bound against a dedicated output-level threshold.
-
L-07 Low PriceConfigv2 cannot update v1.2 target floor Compatibility Acknowledged
Description
PriceConfigv2explicitly accepts aPRICEmodule at version1.2or higher, but its permission set only covers the v2 asset, submodule, observation, and cache entrypoints. It never requests or exposeschangeMinimumTargetPrice(), even thoughOlympusPricev1_2still keepsminimumTargetPriceas live state and still uses it insidegetTargetPrice().That leaves the v1.2 compatibility floor stranded unless governance also keeps the deprecated
OlympusPriceConfigpolicy deployed and active. The repository already shows that this is not guaranteed. The checked-insrc/scripts/deploy/savedDeployments/price_v1_2_deploy.jsondeployment sequence includesOlympusPriceConfigV2, but does not include the legacyOlympusPriceConfigpolicy that is the only policy in this codebase which can callchangeMinimumTargetPrice().This matters because
Operatorstill consumesPRICE.getTargetPrice()when updating RANGE prices and regeneration observations. If the protocol treatsPriceConfigv2as the replacement admin surface for a v1.2 module, the OHM target floor becomes fixed at its deployment-time seed. The protocol then loses the ability to adjust that floor as backing assumptions change.Recommendation
Do not advertise
PriceConfigv2as a complete admin policy forOlympusPricev1_2unless it can manage every live v1.2 control.Consider adding a v1.2-only wrapper for
changeMinimumTargetPrice()and request that permission when the bound module isOlympusPricev1_2. If the intent is to keep using the legacyOlympusPriceConfigfor that one control, make that dependency explicit in deployment scripts and rollout checks and fail deployment if the old policy is not present and active. -
L-08 Low Pyth confidence conversion overflow bricks feed Math Resolved
Description
PythPriceFeeds._getFeedPrice()convertsmaxConfidencefrom output-decimal scale to Pyth scale viaSafeCast.encodeUInt64(). The conversion computesmaxConfidence * 10^|expo| / 10^outputDecimals. When|expo| >= 22andmaxConfidenceis a standard 1% threshold (1e16at 18 decimals), the result exceedstype(uint64).max(~1.8e19) andSafeCastreverts.This permanently bricks the feed for that asset — every
getOneFeedPricecall reverts regardless of actual price data. The revert occurs during the admin-suppliedmaxConfidenceconversion, before any comparison with the feed's actual confidence value.The code allows exponents down to -30 (
MAX_NEGATIVE_EXPONENT), so configurations with expo in [-22, -29] combined with standard confidence thresholds are valid but permanently broken at runtime. The boundary is precise: expo=-21 withmaxConfidence=1e16produces 1e19 (fits), expo=-22 produces 1e20 (overflows).Recommendation
Validate at configuration time that
maxConfidence * 10^|expo| / 10^outputDecimalsfits inuint64. Alternatively, widen the accumulator touint256for the comparison and clamp totype(uint64).maxinstead of reverting. -
L-09 Low Ignored Field Alters Cache Configuration Resolved
Description
IPRICEv2.UpdateAssetParamsdocumentsuseMovingAverageas a field that is only read whenupdateStrategy=true.OlympusPrice.v2.updateAsset()initially follows that contract by resolvingfinalUseMAfrom the existing asset state wheneverupdateStrategy=false. However, the later cache-selection branch does not usefinalUseMA. It reads rawparams_.useMovingAverageinstead.As a result, a caller can submit an update that claims not to touch strategy configuration, yet still change whether a single supplied observation becomes the cached
Variant.LASTprice/timestamp. Two otherwise identical updates can produce different cached state solely because of a field the interface says should be ignored.Recommendation
Use the resolved final state (
finalUseMA) instead of rawparams_.useMovingAveragein the cache-selection logic. That makes the implementation match the documented API contract and ensures ignored fields truly have no effect unless their corresponding update flag is enabled. -
L-10 Low Non-MA clones have no cache refresh DoS Resolved
Description
heart.beat()callsstoreObservations()which only processes assets withstoreMovingAverage=true(only OHM in the current config). WETH, USDS, and sUSDS havestoreMovingAverage=false, so theirVariant.LASTcaches are set once ataddAssetand never refreshed by the heartbeat.All three oracle clone types (Chainlink, Morpho, ERC7726) read
Variant.LASTexclusively. After the initial cache ages pastmaxAge, Morpho and ERC7726 clones revert (DoS), while the Chainlink clone silently serves stale data indefinitely.On a mainnet fork after one heartbeat: OHM cache advances to
block.timestamp, but USDS/sUSDS/WETH cache timestamps remain frozen at their initial values. After 3 heartbeats (~24h), the non-MA caches are over 81,000 seconds stale.The only refresh mechanism is
PriceConfig.cachePrice(asset), which is permissionless but requires an off-chain keeper. There is no on-chain, heartbeat-integrated cache path for non-MA assets.Recommendation
Extend
storeObservations()orupdateMovingAverage()to also callcachePrice()for all approved assets, not just MA-tracking ones. Alternatively, document the keeper requirement explicitly and ensure a keeper is deployed before oracle clones go live. -
L-11 Low No upper bound on minimumTargetPrice Validation Acknowledged
Description
changeMinimumTargetPrice()accepts anyuint256with no upper bound. Setting it to an extreme value (e.g.,type(uint256).max) makesgetTargetPrice()always return that value, which propagates intoOperator._updateRangePrices()and_addObservation(), distorting RBS wall/cushion pricing and regeneration logic.This is an admin-only function gated by the
permissionedmodifier, but there is no sanity guard preventing a governance misconfiguration from bricking the RBS system.Recommendation
Cap
minimumTargetPriceto a reasonable maximum (e.g., 2x the current moving average) or validate it against the current MA value before accepting. -
L-12 Low Derived feed threshold exceeds heartbeat Configuration Resolved
Description
_configureWeth()setswethUpdateThreshold=3600(1h) for all 4 WETH feeds including the derived ETH/BTC × BTC/USD feed. The Chainlink ETH/BTC aggregator uses a deviation-triggered heartbeat (updates on >1% moves, up to 24h max). On-chain analysis of the last 20 ETH/BTC rounds at block 24582001 shows 16/20 round gaps exceed the 1h threshold (gaps of 3612–3648s). Each gap creates a window whereChainlinkPriceFeeds._getFeedPrice()reverts withChainlink_FeedRoundStale, silently dropping the derived feed. In quiet markets where ETH/BTC moves less, gaps can extend to hours. TheTwoFeedParamsstruct already supports per-leg thresholds, but the config applies the same value to both.Recommendation
Set
firstUpdateThresholdto 86400 for the ETH/BTC leg to match its actual heartbeat. KeepsecondUpdateThresholdat 3600 for BTC/USD. -
L-13 Low Wrong oracle source passes validation Validation Resolved
Description
addAsset()andupdateAsset()validate feed configurations by calling_getCurrentPrice(asset_, true)and checking that the aggregate price is non-zero. This proves the configured feeds are callable, but does not prove each feed is semantically correct for the asset.For example, wiring a BTC/USD Pyth feed ID into WETH pricing still passes
addAssetbecause the Pyth contract returns a valid non-zero price for any registered feed ID. The deviation-excluding strategy then drops the ~$80k BTC outlier and resolves WETH from the remaining 3 Chainlink feeds.The asset looks healthy even though one source is wrong. The mistake may only surface later if other feeds fail and the wrong feed survives filtering — particularly dangerous with the 2-price path (F-18) where the average benchmark replaces the median, potentially accepting a wrong-source price that would have been excluded with more feeds.
At the fork block (24582001), this was confirmed: configuring WETH with BTC/USD instead of ETH/USD passes addAsset and returns a correct-looking price from the remaining feeds.
Recommendation
Strengthen configuration validation so each feed is checked against asset-specific expectations instead of only validating the final aggregate result. At minimum, add explicit source-level assertions in deployment/config scripts for assets that rely on multi-feed masking.
-
L-14 Low CURRENT reports block time for degraded inputs Oracle Acknowledged
Description
_getCurrentPrice()always returnsuint48(block.timestamp)as the quote timestamp. It does this even when the result depends on an older moving-average sample or on a reduced set of surviving feeds after one or more feed calls failed.This gives callers a freshness signal that is stronger than the underlying data. A consumer that trusts the returned timestamp can treat a degraded or partially historical value as if every input was observed at the current block. The module does compute
successAllFeeds, but the public price getters discard that signal and still expose the current block time.Recommendation
Do not use
block.timestampas the generic quote timestamp. Return the oldest freshness input used to build the value, or expose separate fields for cache time, last observation time and feed-success status so callers can enforce the freshness model they actually need. -
L-15 Low Runtime writes persist degraded fallback prices Warning Acknowledged
Description
cachePrice()and_storeObservation()write whatever_getCurrentPrice()or_aggregate()returns, even when one or more configured feeds failed._getFeedPrices()does track this condition throughsuccessAllFeeds, but the runtime write functions ignore it and still updateLAST,obsandcumulativeObs.The same module rejects partial feed failure during
addAsset()andupdateAsset(). At runtime, however, a multi-feed asset can silently degrade into a single surviving feed or a moving-average fallback if the strategy is configured in best-effort mode. The degraded result is then persisted as shared state and presented as the latest cached or observed price for later readers.Recommendation
Make the runtime write functions enforce the same feed-success requirement as configuration changes.
cachePrice()should revert whensuccessAllFeedsis false._storeObservation()should do the same before mutating observations or cache, unless the protocol explicitly opts an asset into degraded-write behavior through a separate configuration flag. -
L-16 Low Batch can activate a factory from another kernel Validation Resolved
Description
ConfigureOraclestrusts the three factory addresses loaded fromsrc/scripts/env.jsonand immediately schedulesActivatePolicyfor each of them. It never checks that a factory was deployed against the sameKerneladdress that the batch is targeting.This matters because policy activation calls
configureDependencies()and each factory resolvesPRICEandROLESthrough its own storedkernel. If the env file points at a factory from another deployment, the batch can still mark that contract active in the selected kernel andvalidateOraclesConfigured()will still pass. The factory will then keep reading modules from the other kernel. Laterenable()orcreateOracle()calls can revert when they hit the wrongROLESorPRICEmodule, or worse, create clones that read prices from the wrong deployment.OracleProposalmakes this easier to miss because it uses a separate address registry for the same factories and does not cross-check that both stages point at the same contracts.Recommendation
Before adding an activation to the batch, assert that each factory is bound to the target kernel. After activation, assert that the resolved
PRICEmodule is the one associated with that same deployment. The batch and proposal should also share one address source, or explicitly compare both registries and revert on any mismatch. -
L-17 Low Correlated Chainlink routes overstate quorum Warning Acknowledged
Description
The v1.2 batch config gives
USDSandWETHthe same deviation strategy,getAveragePriceExcludingDeviations(..., revertOnInsufficientCount=true), but the configured feed sets are not independent vote sets.USDSis configured from:- Chainlink
USDS / USD - Chainlink
DAI / USD - Pyth
USDS / USD
WETHis configured from:- Chainlink
ETH / USD - RedStone
ETH / USD - Pyth
ETH / USD - derived Chainlink
ETH / BTC x BTC / USD
The strategy counts raw feed slots, not provider groups. Once there are 3 or more non-zero prices it sorts the whole array, chooses the median as the benchmark and filters by deviation from that benchmark. That means correlated Chainlink-family legs can control the benchmark even though they do not add a new independent trust domain.
This is not theoretical. Using the live rollout config and a direct fork execution of
SimplePriceFeedStrategy:getAveragePriceExcludingDeviations([1.05e18, 1.05e18, 1.00e18], 100 bps, strict)returns1.05e18- the two Chainlink-family
USDSslots outvote the honest minority and the bad subset is accepted
- the two Chainlink-family
getAveragePriceExcludingDeviations([2200e18, 1800e18, 1800e18, 2200e18], 200 bps, strict)reverts withSimpleStrategy_PriceCountInvalid(0, 2)- the two Chainlink-family
WETHslots pull the median benchmark to2000e18, which excludes every quote and bricks strict pricing under a 2-vs-2 split
- the two Chainlink-family
Because
sUSDSis a single ERC4626 wrapper overUSDS, the acceptedUSDSsubset propagates directly intosUSDSpricing as well.The practical result is that the rollout overstates quorum quality. A single provider family malfunction can:
- make
USDSaccept a manipulated majority subset, - push
sUSDSwith it, - or make
WETHrevert even while two honest independent feeds still agree.
Recommendation
Do not count correlated provider legs as separate quorum votes. The smallest safe change is to replace the duplicate Chainlink-family slots with genuinely independent sources, or group feeds by provider and benchmark across groups instead of raw feed count.
For this rollout specifically:
- remove the Chainlink
DAI / USDproxy from theUSDSquorum unless it is treated as a fallback-only signal - do not count
ETH / BTC x BTC / USDas an independentWETHvote alongside direct ChainlinkETH / USD - document an explicit minimum number of independent providers required for
USDS,sUSDSandWETH
- Chainlink
-
L-18 Low Factory re-enable can expose stale oracle clones Warning Resolved
Description
Factory-wide
enable("")only calls_enable(enableData_), flipsisEnabledand emitsEnabled. NeitherBaseOracleFactorynorERC7726OracleFactoryoverrides_enable()to refresh stale or mismatchedPRICEcache entries before clones become live again.This matters because the clone wrappers trust the factory-level enabled flag and then read shared
PRICEVariant.LASTstate. ForBaseOracleFactory, this affects bothMorphoOracleCloneable.price()andChainlinkOracleCloneable.latestRoundData(). ForERC7726OracleFactory, this affectsERC7726OracleCloneable.getQuote(). If the factory stays disabled longer than a clone'smaxAge, or if one leg was refreshed before the pause, the clones come back enabled but still revert until someone separately repairs the cache.Recommendation
Override
_enable()in the factory layer and refresh each oracle pair that will become live again beforeisEnabledflips totrue. If iterating every deployed oracle is too expensive, decode a bounded list of oracle addresses or token pairs fromenableData_and recache them during the re-enable transaction. The key requirement is that factory-wide re-enable must not expose clones that still depend on stale or mismatchedLASTtimestamps. -
L-19 Low Disabling MA leaves stale asset metadata Unexpected Behavior Resolved
Description
_updateAssetMovingAverage()clearsobsandcumulativeObswhenstoreMovingAverage_is set tofalse, but it does not clearmovingAverageDuration,nextObsIndex,numObservations, orlastObservationTime. Those fields are only reassigned inside thestoreMovingAverage_ == truebranch.As a result, an asset that previously stored a moving average can report stale moving-average metadata through
getAssetData()after moving-average storage has been disabled. The pricing functions do key offstoreMovingAverage, so this does not immediately corrupt on-chain price resolution. It does leave inconsistent lifecycle state for monitoring, migrations, and admin tooling that reads the raw asset struct.Recommendation
Zero the remaining moving-average fields when storage is disabled. Clearing
movingAverageDuration,nextObsIndex,numObservations, andlastObservationTimealongsideobsandcumulativeObskeepsgetAssetData()consistent with the asset's active configuration. -
I-01 Informational ERC4626 oracle uses idealized share value Warning Resolved
Description
ERC4626Pricevalues one vault share withconvertToAssets(10 ** shareDecimals). ERC-4626 definesconvertToAssets()as an idealized average-user conversion. It excludes fees and does not account for slippage or other execution conditions. Consequently, this adapter can overstate share value for vaults that charge withdrawal fees, gate redemptions, or otherwise make actual exits worse than the idealized rate.That matters because PRICE treats this value as the asset's USD price. Any later integration that relies on this adapter for collateral valuation or as an input to another oracle can inherit an optimistic price. The current
sUSDSdeployment does not appear to charge fees, but the submodule is generic and the issue can be triggered in future ERC4626 listings.Recommendation
Merely informative. Ensure this behaviour is documented.
-
I-02 Informational ConfigureOracles uses missing env keys Configuration Resolved
Description
ConfigureOraclesloads the kernel and all three factory addresses through_envAddressNotZero()using the keysolympus.policies.ChainlinkOracleFactory,olympus.policies.MorphoOracleFactoryandolympus.policies.ERC7726OracleFactory.That is not compatible with the checked-in address registry. The repository's
src/scripts/env.jsondoes not define any of those policy keys on mainnet, sepolia, or goerli, so_envAddressNotZero()turns the lookup into an immediate revert instead of building the batch. The same rollout stack has a second naming mismatch inBatchScriptV2._validateHeartBeat(), which requiresolympus.policies.OlympusPriceConfigV2even though the registry only definesOlympusPriceConfig. Consequently, this script cannot be simulated, signed, or proposed from the repository state as checked in.Recommendation
Make the rollout scripts consume the canonical keys that already exist in
src/scripts/env.json, or add the missing keys to the registry and keep the naming consistent acrossConfigureOracles,DeployOracles,ConfigurePriceV1_2, andBatchScriptV2.The safest fix is to add a small preflight check that asserts every required env key exists before simulation begins, with an error that prints the missing key names directly.
-
I-03 Informational OracleProposal missing factory activation Configuration Resolved
Description
OracleProposal._build()callsIEnabler.enable()andcreateOracle()on the oracle factories but never pushesKernel.executeAction(Actions.ActivatePolicy)for them. Without Kernel activation,configureDependencies()never runs, soROLESandPRICEreferences areaddress(0). Theenable()call reverts because it queriesROLES.hasRole()on a zero address.The proposal depends on a separate prior batch (
ConfigureOracles) to activate the factories first, but this dependency is not documented or enforced. The proposal's_validate()also does not checkKernel.isPolicyActive().Recommendation
Either include
ActivatePolicyactions in the proposal so it is self-contained, or add a pre-condition check that verifies the factories are already Kernel-active before attempting to enable them. -
I-04 Informational DeactivatePolicy does not stop clones Warning Resolved
Description
Oracle clones check liveness via
factory.isOracleEnabled(), which includes the factory'sPolicyEnabler.isEnabledflag.Kernel.executeAction(DeactivatePolicy, factory)revokes the factory's kernel permissions (PRICE module access, role grants), but it does not callfactory.disable(). BecauseisEnabledremains true, all existing oracle clones continue to serve cached price reads after deactivation.The actual kill switch for oracle consumption is
factory.disable(""), which flipsisEnabled = falseand causes every clone's_checkEnabled()to revert.This matters for emergency response: if a compromised or faulty price feed is detected and the team deactivates the factory via Kernel governance, downstream Chainlink/Morpho/ERC7726 oracle clones will keep returning prices to external consumers. The Chainlink clone is especially affected because it lacks per-read
maxAgeenforcement (unlike Morpho and ERC7726 clones), so it will serve indefinitely stale cached data.Recommendation
Document in the operational runbook that
DeactivatePolicyremoves module permissions but does not halt oracle clone reads. The emergency stop action isfactory.disable(""). Consider adding aKernel-triggered callback or overridingonDeactivate()to automatically disable the factory when the policy is deactivated. -
I-05 Informational Double storeObservation corrupts MA Logical Error Resolved
Description
_storeObservation()has no same-block guard. A permissioned caller can callstoreObservation()twice in the same block, advancing the circular buffer index twice and replacing two historical observations with the same spot price. ThelastObservationTimeis set toblock.timestampon both calls, so the second call overwrites the observation stored by the first.With a 3-observation buffer, a double-call produces a 17% MA deviation from the expected value. The corrupted MA feeds into
getTargetPrice()and downstream RBS range calculations viaOperator._updateRangePrices().The function documents that it does not enforce a minimum frequency between observations, pushing scheduling responsibility to the caller. However, the lack of even a same-block guard means a single transaction can corrupt the MA without detection.
Recommendation
Add
if (asset.lastObservationTime == uint48(block.timestamp)) revertto prevent same-block double writes. The caller (Heart) already enforces per-epoch timing, but the module itself should not rely on external scheduling for correctness. -
I-06 Informational Static price bounds block deployment Configuration Resolved
Description
ConfigurePriceV1_2hardcodes price validation bounds as constants (ETH: 1500–2100, OHM: 17–22, USDS: 0.99–1.01, sUSDS: 1.06–1.10). These are enforced invalidatePricesAreSane()(line 602), which runs as post-batch validation via_setPostBatchValidateSelector. If market prices move outside these ranges before the governance batch executes, the entire PRICE v1.2 upgrade reverts — even when the PRICE configuration itself is correct. The script already contains aTODOcomment at line 48 acknowledging the bounds need adjustment at deployment time, but the values are compile-time constants rather than configurable parameters. A volatile market day or delayed governance execution window could force a recompile and redeployment of the batch script.Recommendation
Make the price validation bounds configurable at execution time by loading them from the args JSON file (alongside other deployment parameters) instead of hardcoding them as constants. Alternatively, derive bounds dynamically from live PRICE module state with a configurable tolerance percentage.
-
I-07 Informational Variant.LAST semantic changed by refactor Logical Error Resolved
Description
The
_storeObservation()refactor changed whatVariant.LASTreturns for assets withuseMovingAverage=true. Previously, the cache stored the raw feed-aggregated price (MA excluded). Now it stores the MA-inclusive aggregated price computed via_aggregate(_getInclusivePrices(feedPrices)). Additionally, the cache uses the post-update MA (cumulativeObsis updated at line 430 before the cache is computed at line 439), creating a forward bias where the new observation feeds into its own cached price.With Feed1=$10, Feed2=$12, MA=$10: old LAST=$11 (raw avg), new LAST=$10.833 (MA-inclusive with post-update MA=$10.5). Oracle clones reading
Variant.LASTsilently receive a different value than before the refactor.Current deployment is unaffected because OHM has
useMovingAverage=false, but any future asset configured withuseMovingAverage=truewill exhibit this semantic change.Recommendation
If the old behavior (raw feed price in cache) was intended, change
_storeObservationto always cacheobsPrice(the MA-excluded aggregate) regardless ofuseMovingAverage. If the new behavior is intentional, document the semantic change clearly so oracle clone consumers understand whatVariant.LASTnow represents. -
I-08 Informational updateAsset locked by stale MA DoS Resolved
Description
updateAsset()concludes with_getCurrentPrice(asset_, true)for validation. Thetrueargument forces MA inclusion, which checkslastObservationTime + observationFrequency <= block.timestampand reverts withPRICE_MovingAverageStaleif the heartbeat was missed. This blocks ALL governance reconfiguration for assets withuseMovingAverage=true— even feed-only updates unrelated to the MA. The lockout occurs during exactly the degraded state when reconfiguration is most needed. A workaround exists: passingupdateStrategy=true, useMovingAverage=falsedisables the MA before validation, but this changes pricing behavior as a side effect. Current deployment is unaffected (OHM hasuseMovingAverage=false).Recommendation
Pass
includeMovingAverage_=falsein the validation call at line 881, or add a separate validation path that skips the MA staleness check during asset reconfiguration. -
I-09 Informational Split PRICE upgrade leaves OHM unapproved Upgradeability Resolved
Description
OlympusPricev1_2is presented as a backward-compatiblePRICEv1replacement, but its constructor only storesOHM,observationFrequencyandminimumTargetPrice. It does not register OHM as a v2 asset or migrate any of the old module's live pricing state.That becomes dangerous during an actual module upgrade because
Kernel._upgradeModule()swaps the module immediately and then rebinds dependent policies to the new address. If governance upgradesPRICEin one transaction and configures OHM in a later transaction, every legacy OHM lookup on the new module reverts withPRICE_AssetNotApproved(OHM)in the gap between those steps.Because of this issue, a mis-sequenced rollout can take the live price module offline until a repair transaction installs submodules and adds OHM.
For example, imagine the following scenario:
- Transaction 1 on Monday: the DAO upgrades PRICE from v1.1 to v1.2.
- Transaction 2 on Tuesday: the DAO plans to install submodules and configure OHM, WETH, USDS, and sUSDS.
- Between Monday and Tuesday:
Heart.beat()runs, or a keeper tries to call it. Heartasks the new PRICE module for OHM pricing.- The new PRICE module reverts because OHM is not an approved asset yet.
- The beat fails, so rebases and periodic maintenance stall until the follow-up configuration transaction lands.
Recommendation
Do not rely on a multi-transaction rollout for this upgrade. The module upgrade, policy activation, submodule installation and OHM asset configuration should execute atomically in one governance batch.
If that guarantee is not acceptable, add migration or initialization logic so the upgraded module registers OHM and restores the minimum state needed for legacy
PRICEv1reads before dependent policies are rebound to it. -
I-10 Informational Clone assumes ERC20 decimals for all assets Warning Resolved
Description
_getQuoteInternalcallsIERC20(base_).decimals()andIERC20(quote_).decimals()for every quote. This makes the adapter depend on both assets implementing the ERC20 metadata interface.That assumption is narrower than the ERC-7726 interface it exposes. ERC-7726 allows special non-ERC20 addresses such as the ETH sentinel and ISO-4217 codes. Those values do not implement
decimals(), so the clone will revert even if the PRICE module can provide a cached value for them. Therefore the contract is only generic across ERC20-like contract addresses.Recommendation
Either constrain the clone explicitly to ERC20-like assets in its documentation and factory validation, or add explicit handling for non-ERC20 ERC-7726 assets and source their decimals from a registry instead of assuming
IERC20.decimals(). -
I-11 Informational Health checks ignore disabled oracle state Warning Resolved
Description
getQuoteandgetQuotescall_checkEnabled()before reading cached prices, butisStaleandtimestampskip that check. A disabled clone can therefore report a fresh timestamp andfalsefromisStaleeven though every quote attempt will revert withERC7726Oracle_NotEnabled.This can mislead integrators that treat
isStaleortimestampas a preflight signal for quote availability. The cache may be healthy while the oracle is still unusable.Recommendation
Apply
_checkEnabled()inisStaleandtimestampso all read methods reflect the same enabled-state contract. If these helpers are meant to report cache status only, document that clearly and avoid presenting them as a quote-readiness check. -
I-12 Informational ERC4626 adapter assumes trusted share rate Oracle Resolved
Description
ERC4626Priceprices a vault share by reading rawconvertToAssets()output and multiplying that conversion rate by the underlying asset's USD price. The adapter does not add any smoothing, sanity checks, or manipulation resistance of its own. Consequently, it is only safe when the wrapped vault's share-to-asset conversion is already trusted on oracle timescales.The configured vault is
sUSDSand directUSDSdonation does not changesUSDS.totalAssets(),convertToAssets(), or downstream OHM pricing.sUSDSstill grows over time, but that is the intended yield behavior the adapter is meant to report.This adapter becomes risky if it is later pointed at a vault whose share rate can be moved directly through donations, harvest timing, rebasing side effects, or other externally steerable accounting changes. In that case the reported vault price would inherit that vault-specific behavior.
Recommendation
Document that
ERC4626Priceis only suitable for vaults whose share conversion is already trusted and restrict deployments accordingly. If broader ERC4626 support is required, add vault-specific bounds or use a share-rate source that applies its own smoothing or sanity checks instead of trusting rawconvertToAssets()output. -
I-13 Informational ConfigureOracles cannot be safely retried Warning Resolved
Description
configureOraclesalways queues threeActivatePolicyactions. It does not check whether one or more factories are already active before building the batch.This makes the script non-idempotent.
Kernelreverts whenActivatePolicyis called for an already active policy andBatchScriptV2aborts the whole batch on the first failed call. Consequently, if an operator needs to rerun the batch after a partial rollout, the retry can fail before it reaches the remaining inactive factories. A recovery run can therefore brick itself instead of finishing the missing activations.Recommendation
Query
Kernel.isPolicyActive()for each factory before adding it to the batch. Only enqueue inactive factories and log when a factory is skipped because it is already active. -
I-14 Informational Unchecked casts can rewrite PRICE batch args Warning Resolved
Description
ConfigurePriceV1_2reads numeric batch arguments asuint256and then narrows them with plain Solidity casts before encoding them into PRICE components. For example,_configureUSDS()castsusdsDeviationBpstouint16andusdsUpdateThresholdtouint48. The same pattern is repeated for_ohmObservationWindowinconfigurePriceV1_2()and for the WETH settings in_configureWeth().These casts do not validate bounds. They truncate modulo the destination width. Consequently, a malformed or mistyped JSON value can be turned into a different but still-valid oracle parameter instead of reverting. For example,
65537basis points becomes1,2**48 + 3600becomes3600and2**32 + 1800becomes1800. The target submodules will decode those rewritten values normally, so the batch can install configuration that does not match the operator's input.Recommendation
Reject out-of-range arguments before any narrowing cast. The simplest fix is to compare each raw
uint256againsttype(uint16).max,type(uint32).max, ortype(uint48).maxas appropriate and revert on overflow, then cast only after that check passes.
Remediation Review
12 findings · April 30 to May 8, 2026-
M-01 Medium PRICE accepts MA strategies that later fail Validation Resolved
Description
_validateAssetConfigurationonly counts the final number of configured price sources asfeedCount + 1whenuseMovingAverageis true. It does not prove that the configured strategy can aggregate every runtime input count that the module will use.This matters because the same strategy is used for two different calculations.
getPrice(..., CURRENT)can include the moving average, butstoreObservation()intentionally excludes it when writing the next observation.addAsset()validates only the MA-inclusive calculation. Therefore a configuration with two feeds plus a moving average can pass withSimplePriceFeedStrategy.getMedianPrice, which requires at least three inputs, then every laterstoreObservation()reverts because it sends only the two raw feed prices to the same median strategy.updateAsset()has the inverse problem. It validates the final configuration with_getCurrentPrice(asset_, false), so a strategy-only update can enableuseMovingAveragewithout ever testing the MA-inclusive current price. A single-feed asset with stored observations can be updated to use the median strategy. The update succeeds because the MA-excluded validation returns the single raw feed directly, thengetPrice(asset, CURRENT)reverts because the median strategy receives only two inputs.Recommendation
Validate every calculation that can be used after the configuration is stored. For assets that store a moving average and use it in the strategy, check both the MA-inclusive current price and the MA-excluded observation value before accepting
addAsset()orupdateAsset(). If the design intentionally allows one strategy to handle different input counts, add an explicit strategy capability check or separate strategies for current-price reads and observation writes. -
M-02 Medium Queued MA updates execute with stale timestamps Unexpected Behavior Acknowledged
Description
updateAsset()writes a replacement moving-average state fromparams_.lastObservationTime, then validates the resulting asset with_getCurrentPrice(asset_, false). Passingfalseskips the moving-average freshness check even when the asset still hasuseMovingAverage = true.This becomes unsafe through
PriceConfigv2.queueUpdateAsset(). The queued payload contains a fixedlastObservationTime, butTimelockQueuemakes the action executable only after the timelock delay. With the configured 1 day minimum delay and an 8 hour PRICE observation frequency, a timestamp that was fresh when queued is already stale when execution is first allowed. Any caller can then execute the queued action and store stale moving-average metadata. FreshCURRENTreads for the asset immediately revert withPRICE_MovingAverageStaleuntil a permissioned observation update repairs the state.Recommendation
Reject queued moving-average updates that would be stale at execution, or revalidate the MA-inclusive current price after applying a moving-average update when
useMovingAverageremains enabled. A safer design is to avoid queueing absolute observation timestamps for MA state and instead refresh the observation at execution time from live feeds. -
L-01 Low Morpho oracles keep stale decimal scales Unexpected Behavior Resolved
Description
MorphoOracleFactory._encodeOracleDatasnapshots collateral and loan decimals from the currentpriceCacheand turns them into an immutablescaleFactor. The deployed clone then keeps using that old scale while reading prices from the factory's currentpriceCache.This is unsafe when the decimal source changes for an existing oracle.
BaseOracleFactory.setPriceCachecan rotate the cache policy, andPriceCache.setNonContractAssetMetadatacan update decimals for non-contract assets. After either change, the pair cache can be refreshed with the new metadata, but the Morpho clone still scales the returned ratio with the old decimals.For example, if a loan asset was created with 18 decimals and later has 6 decimals in the active cache, the correct Morpho scale is smaller by
1e12. The old clone would keep returning values scaled with the larger factor. If that oracle is used by a Morpho market, collateral or debt can be materially mispriced. This requires a privileged cache rotation or metadata update, but those are supported maintenance operations rather than key-compromise assumptions.Recommendation
Treat decimals for assets used by enabled Morpho oracles as immutable. Before rotating
priceCache, validate that every affected enabled oracle still resolves the same collateral and loan decimals. If any pair differs, disable and recreate the oracle instead of keeping it live. For non-contract metadata, prevent decimal changes while active Morpho oracles reference the asset, or require the operator to disable and replace all affected oracles as part of the metadata update. If the oracle must survive cache rotations, store the deployment-time collateral and loan decimals and makeprice()revert when the active cache reports different values. -
L-02 Low Price cache accepts stale MA snapshots Unexpected Behavior Acknowledged
Description
PriceCachechecks cached pair freshness only by comparingcachedPrice.updatedAtagainstmaxAge. When_cachePrice()stores a non-unit asset, it readsPRICE.getPrice(asset, Variant.CURRENT). For an asset configured withuseMovingAverage = true, that current price can include the stored moving average.After the moving-average deadline passes, direct
PRICE.getPrice(asset, Variant.CURRENT)reverts because the moving average is stale. The already-cached pair snapshot can still be treated as fresh byPriceCacheuntilupdatedAt + maxAgeexpires, because the cache does not know the moving-average deadline embedded in the stored value.For example, assume an asset has one live feed at
$10, a stored moving average of$20, anduseMovingAverage = true. While the moving average is still fresh,PRICE.getPrice(asset, CURRENT)returns$15and a public cache call stores that$15pair snapshot. When the moving average expires one hour later, direct PRICE current reads revert, but an adapter configured with a two-hourmaxAgecan still read the cached$15value for another hour.Consequently, Morpho, ERC-7726, or Chainlink-style adapters can keep serving an MA-inclusive cached value after the underlying moving-average component is no longer valid for fresh PRICE reads.
Recommendation
Make cache freshness account for the data used inside the cached price. One option is to expose from PRICE whether a current value included a moving average and the timestamp at which that component expires.
PriceCacheshould then mark the pair stale when either the pairmaxAgeexpires or any embedded moving-average component is stale. -
L-03 Low Feed failures block price reconfiguration DoS Acknowledged
Description
addAsset()validates a new asset by reading the current price and then requiring every configured feed to have returned a nonzero value.updateAsset()repeats the same check after applying a configuration change. This happens even when the configured strategy can compute a valid price from the remaining feeds. Consequently, an authorized operator can be blocked from adding or updating an asset while one retained feed is temporarily failing. This can happen even if the final feed set and strategy would still produce a valid nonzero aggregate price at runtime. For example, a strict average strategy can ignore one zero feed and resolve from two live feeds, butaddAsset()andupdateAsset()still revert withPRICE_PriceFeedCallFailedbecausesuccessAllFeedsis false.Recommendation
Do not require
successAllFeedsduring configuration when the strategy has already returned a valid aggregate. Let the configured strategy decide whether enough valid feeds remain, and expose degraded-feed status separately for operator visibility. -
L-04 Low Pyth confidence bypass Configuration Acknowledged
Description
PythPriceFeedslets admins configuremaxConfidencein output-decimal scale, then converts it to Pyth's raw exponent scale before validatingpriceData.conf. If the converted value exceedsuint64.max, the code clamps the limit touint64.max.Because Pyth's raw confidence field is also
uint64, the clamped limit accepts every possible raw confidence value. This silently disables the intended confidence guard for any feed/configuration that crosses the clamp boundary. Current deployment values for normalexpo = -8USD feeds do not appear to clamp, so the issue is a configuration-validation gap rather than an active deployment exploit.Recommendation
Reject configurations whose converted confidence limit exceeds
uint64.max, or require an explicit unbounded-confidence sentinel. -
L-05 Low Unsafe fallback pricing Oracle Acknowledged
Description
OlympusPricev2catches failed feed calls and leaves those feed prices as zero. The configured strategy then decides whether the remaining prices are enough. In best-effort mode,SimplePriceFeedStrategycan return a single surviving source.This becomes dangerous if a manipulable source such as
UniswapV3Price.getTokenPrice()is included in the feed set. That selector reads the current pool tick fromslot0(), so it is a same-block spot price. If other feeds fail or are filtered out, a multi-feed configuration can collapse to one manipulable spot source. Current deployment args use strict mode and the OHM Uniswap feeds use TWAP selectors, so this is an unsafe allowed configuration rather than a current-config exploit.Recommendation
Disallow spot selectors in production price feeds, and require strict mode or an explicit minimum valid-source count for assets with economic consumers.
-
I-01 Informational OHM filter can halt Heart after feed outage Warning Acknowledged
Description
The OHM price configuration uses
getAveragePriceExcludingDeviationswith a 200 bps tolerance and strict mode enabled. It currently reads from three sources:OHM/WETH,OHM/sUSDSand ChainlinkOHM/ETH * ETH/USD. If one source is unavailable or returns zero, the strategy is left with exactly two non-zero prices.In the two-price case,
SimplePriceFeedStrategyuses the average of the two prices as the deviation benchmark. With a 2% tolerance, two surviving prices that differ by a little more than 4% are each more than 2% away from their midpoint. The strategy then excludes both prices and reverts withSimpleStrategy_PriceCountInvalid(0, 2).This leaves a narrow heartbeat liveness dependency in the OHM configuration. A single unavailable OHM source, combined with unusually large divergence between the two remaining sources, can make
PRICE.updateMovingAverage()revert.Heart.beat()calls that function before triggering the rebase and before executing periodic tasks, so this would stop heartbeat execution until the failed source recovers or the two surviving prices move back inside the allowed range.Recent mainnet history suggests this condition is unlikely. During the known OHM/sUSDS zero-quote window from block
24,831,090through24,877,959, sampled every 300 blocks, none of the155comparable samples crossed the two-price threshold. The largest OHM/WETH-vs-Chainlink divergence in that window was183bps. A broader daily-scale sample from block24,000,000through25,021,851also found no threshold crossings, with a maximum divergence of190bps across142comparable samples. Since the strict two-price case requires a little over400bps divergence relative to the lower surviving price, the checked history supports treating this as a hardening and monitoring item.Recommendation
Merely informative. Consider changing the strict two-price fallback only if the protocol wants the heartbeat to tolerate both a source outage and unusually high inter-source divergence.
-
I-02 Informational Synthetic MA check can miss price outage Warning Acknowledged
Description
_validateMovingAverageStrategychecks MA-backed configurations with a value that normal current-price reads do not use. It aggregates the raw feed prices intoobsPrice, then appendsobsPriceas a synthetic moving average.getPrice(asset, CURRENT)appends the stored moving average fromasset.cumulativeObs / asset.numObservationsinstead.This distinction matters for assets configured with
useMovingAverage = trueand a strict deviation-filtered strategy. Validation can accept the raw feeds plus the synthetic value, while the same raw feeds plus the stored moving average would revert. In that case the configuration is accepted, but immediateCURRENTreads remain unavailable until a fresh observation is stored or the asset is changed.The current v1.2 batch does not configure any asset this way. USDS, sUSDS and WETH are added with
storeMovingAverage = falseanduseMovingAverage = false. OHM stores a moving average, but it is added withuseMovingAverage = false, so the moving average is not supplied to the strategy. Therefore this is not a current deployment breakage. It is a hardening issue for future or manual MA-backed configurations that expect validation to prove immediate current-price availability.Recommendation
If successful
addAssetorupdateAssetcalls should guarantee immediateCURRENTprice availability, validate the strategy with the stored moving average that runtime reads will use. If the synthetic value is deliberately intended as a recovery check when the stored moving average is stale or out of consensus, document that narrower guarantee and require a fresh observation before any consumer depends on the asset'sCURRENTprice. -
I-03 Informational OHM rollout materials use stale settings Documentation Acknowledged
Description
The PRICE rollout documentation still describes OHM as using
getAveragePrice()in strict mode and lists 1,800-second Uniswap observation windows. The executable batch now configures OHM withgetAveragePriceExcludingDeviations,ohmDeviationBps = 200, strict mode and 1500-second windows for both OHM/WETH and OHM/sUSDS.The args file also keeps
ohmObservationWindow = 1800even though the script reads the splitohmWethObservationWindowandohmSusdsObservationWindowvalues instead. This does not change deployed behavior, but it makes the rollout materials disagree with the configuration that reviewers and signers are expected to approve.Recommendation
Update the OHM documentation and batch args so they describe the same configuration that the script executes. Remove the unused
ohmObservationWindowkey, or wire it deliberately if a single shared window is intended. -
I-04 Informational WETH feed expectation is stale Warning Acknowledged
Description
The v1.2 batch args still set
wethExpectedPriceto1800e18with a1667bps tolerance.ConfigurePriceV1_2applies that same expectation envelope to every WETH feed before the asset is added andPriceConfigv2rejects any feed value outside the resulting bound.That bound is roughly 1500 to 2100 USD. A live Chainlink ETH/USD read on May 4, 2026 returned
2348.96366170, which is above the configured upper bound. Therefore the batch can fail during WETH feed validation before the PRICE upgrade is configured.Recommendation
Refresh the WETH expected price and tolerance immediately before proposing or executing the batch. Prefer deriving the expectation from live feed reads in a preflight step, then writing the exact values into the signed args.
-
I-05 Informational Submodule upgrades lack ordering Unexpected Behavior Acknowledged
Description
PriceConfigv2.queueUpgradeSubmodule()queues only the replacement submodule address. Multiple pending upgrades for the same subkeycode are not ordered or bound to the implementation that was current when each action was queued.As a result, an older queued upgrade can execute after a newer upgrade and replace the newer implementation during the older action's execution window. This is not an authorization bypass because both upgrades were timelocked and emergency can cancel stale actions, but it can confuse operational review and rollback expectations.
Recommendation
Bind queued submodule upgrades to the expected current implementation, or use a per-subkeycode nonce so stale queued upgrades revert.
No findings match.
More from Olympus
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
