From 26a0a5e7e8fa81973b18f71ca03fad41832d7ecf Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Sat, 18 Jul 2026 16:17:47 +0530 Subject: [PATCH] fix(H5): mark wstETH at the Lido exchange rate, not the DEX spot Audit H5: wstethUsdWad marked wstETH at the lower of the exchange rate and the instantaneous UniV3 slot0 spot. slot0 is flash-loan manipulable within a block, and because it was the lower-of, an attacker could push the mark DOWN (front-run settleEpoch, depress the pool, mint extra shares cheaply, back-run) to skew share pricing. Fix: mark wstETH purely at stEthPerToken x ETH/USD, the fundamental redemption value and the standard wstETH-collateral marking (Aave/Morpho). It is not flash-manipulable, so it cannot skew NAV or settlement pricing. The pool spot is kept ONLY as a depeg gate on BUYING more wstETH (wstethBuyAllowed), where a manipulated spot is fail-safe: it can only block a buy, never inflate value. Regression: a basis breach keeps the mark at the exchange rate while blocking buys; an extreme flash-pushed pool spot leaves the mark unchanged. 116 -> 117. --- src/OracleHub.sol | 18 +++++++++--------- test/OracleHub.t.sol | 25 +++++++++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/OracleHub.sol b/src/OracleHub.sol index daa63e4..abef960 100644 --- a/src/OracleHub.sol +++ b/src/OracleHub.sol @@ -105,16 +105,16 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { return snapshotEthUsdWad; // degraded: last-good so floor defense continues } - /// @notice wstETH marked at the LOWER of exchange-rate and pool-spot - /// valuations whenever they disagree beyond the basis limit. + /// @notice wstETH marked at the Lido exchange rate (stEthPerToken x + /// ETH/USD), NOT the DEX spot (audit H5). The exchange rate is the + /// fundamental redemption value and, unlike a UniV3 slot0 read, is + /// not flash-loan manipulable, so it cannot be used to skew share + /// pricing at settlement. This is the standard wstETH-collateral + /// marking (Aave/Morpho). The pool spot is kept only as a depeg + /// gate on BUYING more wstETH (wstethBuyAllowed), where a pushed + /// spot is fail-safe: it can only block a buy, never inflate value. function wstethUsdWad() external view returns (uint256) { - uint256 eth = ethUsdWad(); - uint256 rateBased = wsteth.stEthPerToken().mulWad(eth); - uint256 poolBased = _poolWethPerWsteth().mulWad(eth); - if (_basisBps(rateBased, poolBased) > maxBasisBps) { - return FixedPointMathLib.min(rateBased, poolBased); - } - return rateBased; + return wsteth.stEthPerToken().mulWad(ethUsdWad()); } function wstethBuyAllowed() external view returns (bool) { diff --git a/test/OracleHub.t.sol b/test/OracleHub.t.sol index addf1a3..5cfac58 100644 --- a/test/OracleHub.t.sol +++ b/test/OracleHub.t.sol @@ -97,16 +97,25 @@ contract OracleHubTest is Test { hub.refresh(); } - function test_basisBreach_marksLower_blocksBuys() public { - // pool prints wstETH 5% below the exchange rate (depeg scenario) - pool.setRatioWad(1.14e18); - assertApproxEqRel(hub.wstethUsdWad(), 2280e18, 1e6); // marked at pool (lower) + function test_h5_basisBreach_marksAtRate_blocksBuys() public { + // H5: the VALUE mark is always the exchange rate, never the pool spot, + // so a manipulated/depegged pool cannot skew NAV/share pricing. A basis + // breach only flips the buy gate (fail-safe, blocks buying more wstETH). + pool.setRatioWad(1.14e18); // pool 5% below the exchange rate (depeg) + assertApproxEqRel(hub.wstethUsdWad(), 2400e18, 1e6); // still exchange-rate + assertFalse(hub.wstethBuyAllowed()); // but buys are blocked + + pool.setRatioWad(1.32e18); // pool above the exchange rate + assertApproxEqRel(hub.wstethUsdWad(), 2400e18, 1e6); // still exchange-rate assertFalse(hub.wstethBuyAllowed()); + } - // exchange rate lower than pool: still marks the lower one - pool.setRatioWad(1.32e18); - assertApproxEqRel(hub.wstethUsdWad(), 2400e18, 1e6); // rate-based is lower - assertFalse(hub.wstethBuyAllowed()); + function test_h5_poolPushDownCannotLowerTheMark() public { + // the attack: push the pool spot far down to depress the wstETH mark + // and mint extra shares cheaply. Under the fix the mark does not move. + uint256 markBefore = hub.wstethUsdWad(); + pool.setRatioWad(0.5e18); // extreme flash-manipulated spot + assertEq(hub.wstethUsdWad(), markBefore); // unaffected } function test_smallBasis_usesExchangeRate() public {