From 1997ae3979ec747112b0ba0010f2b86b4d4a8d51 Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Mon, 20 Jul 2026 11:49:55 +0530 Subject: [PATCH] fix(L3): cap composition slippage + gate on freshness; L7 analyzed - L3: rebalanceComposition clamps the caller-supplied maxSlippageBps to a 5% ceiling so a keeper can no longer drive minOut toward zero, and the whole function is gated on wstethBuyAllowed() so neither branch trims at a mispriced/stale mark (the buy branch already required it; the sell branch did not). Composition maintenance pauses during a depeg/outage and the keeper retries when healthy. - L7: assessed and documented as already-mitigated. For an exact-input single-hop swap the oracle-anchored amountOutMinimum already bounds the output (hence extractable sandwich value) to the slippage bound; a sqrtPriceLimitX96 would only add partial-fill semantics without tightening that bound, so it is intentionally omitted. Comment on _swap records the reasoning. 2 regression tests (slippage clamp misses on both tiers; sell branch no-ops during depeg). 134 -> 136; 9 fork tests green. --- src/ExecutionModule.sol | 21 ++++++++++++++++++++- test/ExecutionLayer.t.sol | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/ExecutionModule.sol b/src/ExecutionModule.sol index afc77f5..a753557 100644 --- a/src/ExecutionModule.sol +++ b/src/ExecutionModule.sol @@ -49,6 +49,10 @@ contract ExecutionModule is IExecutionModule, Ownable { uint24 public fallbackFee = 3000; uint24 public wstethPoolFee = 100; + /// @dev Ceiling on the caller-supplied composition-rebalance slippage + /// (audit L3): a keeper cannot drive minOut toward zero. + uint256 internal constant MAX_COMPOSITION_SLIPPAGE_BPS = 500; + event RebalanceExecuted(int256 deltaWad, uint256 usdcMoved, uint256 wethMoved); event CompositionRebalanced(int256 wethToWstethWad); event AssetsFreed(uint256 amountWad); @@ -132,8 +136,17 @@ contract ExecutionModule is IExecutionModule, Ownable { /// @notice Move the risky leg's wstETH share toward its target, bounded /// by `maxMoveWad` per call. WETH<->wstETH through the tight pool. + /// @dev Keeper-only maintenance. The caller-supplied slippage is clamped + /// (audit L3) so it can never drive minOut to zero, and the whole + /// function is gated on wstethBuyAllowed() so neither branch trims at + /// a mispriced/stale mark (the buy branch already required this; the + /// sell branch did not). Composition maintenance simply pauses during + /// a depeg or feed outage; the keeper retries when healthy. function rebalanceComposition(uint256 maxMoveWad, uint256 maxSlippageBps) external { if (msg.sender != keeper && msg.sender != owner()) revert NotKeeper(); + if (maxSlippageBps > MAX_COMPOSITION_SLIPPAGE_BPS) maxSlippageBps = MAX_COMPOSITION_SLIPPAGE_BPS; + if (!priceSource.wstethBuyAllowed()) return; // L3: no mispriced trims when stale/depegged + uint256 total = riskyLeg.value(); if (total == 0) return; uint256 targetWad = total * riskyLeg.wstethTargetBps() / 10_000; @@ -142,7 +155,6 @@ contract ExecutionModule is IExecutionModule, Ownable { uint256 wstUsd = priceSource.wstethUsdWad(); if (currentWad < targetWad) { - if (!priceSource.wstethBuyAllowed()) return; // depeg/staleness: no new wstETH uint256 moveWad = FixedPointMathLib.min(targetWad - currentWad, maxMoveWad); (uint256 wethGot,) = riskyLeg.provide(moveWad, address(this)); if (wethGot == 0) return; @@ -202,6 +214,13 @@ contract ExecutionModule is IExecutionModule, Ownable { /// @dev Try the primary fee tier; on any revert (thin pool, minOut miss), /// retry once on the fallback tier with the same bound. + /// @dev sqrtPriceLimitX96 = 0 is deliberate (audit L7). For an exact-input + /// single-hop swap the oracle-anchored `amountOutMinimum` already + /// bounds the output (hence the extractable sandwich value) to the + /// slippage bound: the swap either delivers >= minOut or reverts. A + /// price limit would only add exact-input partial-fill semantics + /// (leftover tokenIn to account for) without tightening that bound, + /// so it is intentionally omitted rather than risk a mis-set limit. function _swap(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint256 minOut, address recipient) internal returns (uint256 amountOut) diff --git a/test/ExecutionLayer.t.sol b/test/ExecutionLayer.t.sol index 5f6f2bb..08a9ac5 100644 --- a/test/ExecutionLayer.t.sol +++ b/test/ExecutionLayer.t.sol @@ -171,6 +171,34 @@ contract ExecutionLayerTest is Test { assertEq(usdc.balanceOf(address(vault)), 40e6); } + // ---------- L3 regression: composition slippage cap + freshness gate ---- + + function test_l3_slippageClampedToCeiling() public { + vm.prank(owner); + riskyLeg.setWstethTarget(2500); + weth.mint(address(riskyLeg), 10e18); + // 6% execution cost on BOTH tiers: exceeds the 5% composition clamp, + // so even asking for 100% slippage cannot drive minOut low enough + router.setTier(100, 600, false); + router.setTier(3000, 600, false); + vm.prank(keeper); + vm.expectRevert(); // clamped to 500bps; 6% cost misses minOut on both tiers + exec.rebalanceComposition(5_000e18, 10_000); + } + + function test_l3_sellBranchGatedOnDepeg() public { + vm.prank(owner); + riskyLeg.setWstethTarget(1000); // 10% + weth.mint(address(riskyLeg), 5e18); // 10,000 + wsteth.mint(address(riskyLeg), 5e18); // 12,000 -> ~54% wstETH, above target + uint256 shareBefore = riskyLeg.wstethShareBps(); + + prices.setBuyAllowed(false); // depeg/staleness + vm.prank(keeper); + exec.rebalanceComposition(100_000e18, 50); // sell branch must now no-op + assertEq(riskyLeg.wstethShareBps(), shareBefore); // unchanged + } + function test_composition_movesTowardTarget() public { vm.prank(owner); riskyLeg.setWstethTarget(2500); // 25%