From ac582f69cd746a3db99df375831d037f39299527 Mon Sep 17 00:00:00 2001 From: sammed-21 Date: Thu, 4 Jun 2026 14:52:12 +0530 Subject: [PATCH 1/3] fix: overflow state --- src/OscillonHook.sol | 29 ++- test/OscillonHook.t.sol | 517 ++++++++++++++++++++++++++++++++++------ 2 files changed, 471 insertions(+), 75 deletions(-) diff --git a/src/OscillonHook.sol b/src/OscillonHook.sol index 8858479..4534a43 100644 --- a/src/OscillonHook.sol +++ b/src/OscillonHook.sol @@ -20,6 +20,7 @@ import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; import {FixedPointMathLib} from "solmate/src/utils/FixedPointMathLib.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IAggregatorV3Interface} from "./interface/IAggregatorV3Interface.sol"; +import {console2} from "forge-std/console2.sol"; // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -241,7 +242,7 @@ contract OscillonHook is BaseHook { address oracle1, uint8 stableDecimals0, uint8 stableDecimals1 - ) external { + ) external onlyOwner { // [CHANGE 9] Stable-only enforcement via tick spacing if (key.tickSpacing != 1) revert NotStablePool(); @@ -304,7 +305,23 @@ contract OscillonHook is BaseHook { // [CHANGE 2] Exact output disabled during ANY active depeg // Prevents Bunni-style rounding attack on exact output path + console2.log("params amountSpecified", params.amountSpecified); + console2.log( + "params depeg > = Small Depeg", + ctx.depegBps >= SMALL_DEPEG_BPS + ); + + console2.log("params amountSpecified", params.amountSpecified > 0); if (params.amountSpecified > 0 && ctx.depegBps >= SMALL_DEPEG_BPS) { + console2.log("depegBps", ctx.depegBps); + console2.log("params.amountSpecified", params.amountSpecified); + console2.log("ctx.tokenInIsToken0", ctx.tokenInIsToken0); + console2.log("ctx.token0", cfg.token0); + console2.log("ctx.token1", cfg.token1); + console2.log("ctx.oracle0", cfg.oracle0); + console2.log("ctx.oracle1", cfg.oracle1); + console2.log("ctx.oracle0Decimals", cfg.oracle0Decimals); + console2.log("ctx.oracle1Decimals", cfg.oracle1Decimals); revert ExactOutputDisabledDuringDepeg(ctx.depegBps); } @@ -399,9 +416,7 @@ contract OscillonHook is BaseHook { if (nowTs == last.blockTimestamp) return; // dedupe within same second int56 delta = int56(uint56(nowTs - last.blockTimestamp)); - int56 newCumulative = last.tickCumulative + - int56(currentTick) * - delta; + int56 newCumulative = last.tickCumulative + int56(currentTick) * delta; uint16 nextIdx = (lastIdx + 1) % OBS_CARDINALITY; observations[poolId][nextIdx] = Observation({ @@ -442,7 +457,9 @@ contract OscillonHook is BaseHook { bool pegBelow, bool usingFallback ) = _readDepegWithFallback(key, feed, dec); - + console2.log("depegBps", depegBps); + console2.log("pegBelow", pegBelow); + console2.log("usingFallback", usingFallback); uint256 swapSize = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); @@ -649,7 +666,7 @@ contract OscillonHook is BaseHook { uint256 updatedAt, uint80 answeredInRound ) = IAggregatorV3Interface(oracle).latestRoundData(); - + console2.log("answer", answer); if (answer <= 0) revert OracleAnswerInvalid(); if (answeredInRound < roundId) revert OracleRoundIncomplete(roundId, answeredInRound); diff --git a/test/OscillonHook.t.sol b/test/OscillonHook.t.sol index 228b3fd..16f0efb 100644 --- a/test/OscillonHook.t.sol +++ b/test/OscillonHook.t.sol @@ -1,6 +1,16 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; +// Tests for OscillonHook v3.0: +// - in-hook TWAP observation ring buffer (OscillonHookTwapTest) +// - depeg detection + dynamic-fee selection (OscillonHookDepegFeeTest) +// +// Why low-level calls in places: OscillonHook imports PoolId/PoolKey from +// @uniswap/v4-core/... while the v4-core test utils use v4-core/... — these +// remap to physically different files, so the user-defined value types are +// type-incompatible at the Solidity level even though they're identical at +// the ABI level. We use staticcall/call to bridge. + import {Test} from "forge-std/Test.sol"; import {Deployers} from "v4-core-test/utils/Deployers.sol"; import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; @@ -17,34 +27,26 @@ import {TickMath} from "v4-core/libraries/TickMath.sol"; import {MockV3Aggregator} from "./mock/MockV3Aggregator.sol"; import {OscillonHook} from "../src/OscillonHook.sol"; -import {console} from "forge-std/console.sol"; -contract OscillonHookBasicTest is Test, Deployers { +contract OscillonHookTwapTest is Test, Deployers { using PoolIdLibrary for PoolKey; - event DepegDetected(PoolId indexed poolId, uint256 depegBps, uint24 feeApplied, uint256 swapSize, bool isDrain); - - uint256 constant AMOUNT_IN = 1e15; - uint24 constant FEE_20_BPS_DEPEG = 101; - uint24 constant FEE_50_BPS_DEPEG = 111; - uint24 constant FEE_100_BPS_DEPEG = 145; + uint16 constant OBS_CARDINALITY = 144; + uint32 constant TWAP_WINDOW = 1800; MockERC20 stable0; MockERC20 stable1; - Currency stable0Currency; - Currency stable1Currency; MockV3Aggregator oracle0; MockV3Aggregator oracle1; OscillonHook hook; PoolKey poolKey; + bytes32 poolId; // raw PoolId (bytes32) to avoid cross-package type issues function setUp() public { deployFreshManagerAndRouters(); stable0 = new MockERC20("USD Coin", "USDC", 18); stable1 = new MockERC20("Tether", "USDT", 18); - stable0Currency = Currency.wrap(address(stable0)); - stable1Currency = Currency.wrap(address(stable1)); stable0.mint(address(this), type(uint128).max); stable1.mint(address(this), type(uint128).max); @@ -57,34 +59,340 @@ contract OscillonHookBasicTest is Test, Deployers { oracle0 = new MockV3Aggregator(18, int256(1e18)); oracle1 = new MockV3Aggregator(18, int256(1e18)); - uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG); + // v3.0 permissions: beforeSwap + afterInitialize + afterSwap. + uint160 flags = uint160( + Hooks.BEFORE_SWAP_FLAG | + Hooks.AFTER_INITIALIZE_FLAG | + Hooks.AFTER_SWAP_FLAG + ); deployCodeTo("OscillonHook", abi.encode(manager), address(flags)); hook = OscillonHook(payable(address(flags))); - Currency c0 = stable0Currency; - Currency c1 = stable1Currency; - if (Currency.unwrap(c0) > Currency.unwrap(c1)) { - (c0, c1) = (c1, c0); + // Approve oracles (required by v3 oracle registry). + hook.approveOracle(address(oracle0)); + hook.approveOracle(address(oracle1)); + + // Order currencies; init pool with tickSpacing=1 (stable-only guard). + Currency c0 = Currency.wrap(address(stable0)); + Currency c1 = Currency.wrap(address(stable1)); + if (Currency.unwrap(c0) > Currency.unwrap(c1)) (c0, c1) = (c1, c0); + + (poolKey, ) = initPool( + c0, + c1, + IHooks(address(hook)), + LPFeeLibrary.DYNAMIC_FEE_FLAG, + int24(1), + SQRT_PRICE_1_1 + ); + poolId = PoolId.unwrap(poolKey.toId()); + + modifyLiquidityRouter.modifyLiquidity( + poolKey, + LIQUIDITY_PARAMS, + ZERO_BYTES + ); + + address oForC0 = Currency.unwrap(poolKey.currency0) == address(stable0) + ? address(oracle0) + : address(oracle1); + address oForC1 = Currency.unwrap(poolKey.currency0) == address(stable0) + ? address(oracle1) + : address(oracle0); + + // Low-level call: PoolKey type differs across the v4-core / @uniswap/v4-core remap. + (bool ok, ) = address(hook).call( + abi.encodeWithSignature( + "registerPool((address,address,uint24,int24,address),address,address,uint8,uint8)", + Currency.unwrap(poolKey.currency0), + Currency.unwrap(poolKey.currency1), + poolKey.fee, + poolKey.tickSpacing, + address(poolKey.hooks), + oForC0, + oForC1, + uint8(18), + uint8(18) + ) + ); + require(ok, "registerPool failed"); + } + + // ── _afterInitialize seeds the ring buffer ─────────────────────────────── + + function test_afterInitialize_seedsObservationZero() public view { + assertEq( + _obsCardinality(), + uint16(1), + "cardinality should be 1 after init" + ); + assertEq(_obsIndex(), uint16(0), "newest index should be 0 after init"); + + (uint32 ts, int56 cum, bool initialized) = _observationAt(0); + assertTrue(initialized, "obs 0 must be initialized"); + assertEq( + uint256(ts), + block.timestamp, + "obs 0 timestamp must equal block.timestamp" + ); + assertEq(int256(cum), int256(0), "obs 0 tickCumulative must be 0"); + } + + // ── _afterSwap appends a new observation when time has advanced ────────── + + function test_afterSwap_appendsObservation() public { + uint16 cardBefore = _obsCardinality(); + uint16 idxBefore = _obsIndex(); + + vm.warp(block.timestamp + 60); + _doSmallSwap(); + + assertEq( + _obsCardinality(), + cardBefore + 1, + "cardinality should grow by 1" + ); + assertEq( + _obsIndex(), + idxBefore + 1, + "newest index should advance by 1" + ); + + (uint32 ts, , bool initialized) = _observationAt(idxBefore + 1); + assertTrue(initialized, "new obs must be initialized"); + assertEq( + uint256(ts), + block.timestamp, + "new obs timestamp should equal now" + ); + } + + // ── Same-second swaps are deduped (no buffer growth) ───────────────────── + + function test_afterSwap_dedupesSameSecondWrites() public { + vm.warp(block.timestamp + 60); + _doSmallSwap(); + + uint16 cardAfterFirst = _obsCardinality(); + uint16 idxAfterFirst = _obsIndex(); + + // Second swap at the SAME timestamp — should be a no-op for the buffer. + _doSmallSwap(); + + assertEq( + _obsCardinality(), + cardAfterFirst, + "second same-second swap must not add obs" + ); + assertEq(_obsIndex(), idxAfterFirst, "newest index must not advance"); + } + + // ── Buffer spans the TWAP window after enough activity ─────────────────── + + function test_observation_buildsHistoryOver30Min() public { + // 60 swaps at 35 sec apart → 35 minutes of history (> TWAP_WINDOW). + for (uint256 i = 0; i < 60; i++) { + vm.warp(block.timestamp + 35); + _doSmallSwap(); } - console.log(Currency.unwrap(c0), Currency.unwrap(c1)); - (poolKey,) = initPool(c0, c1, IHooks(address(hook)), LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1); + uint16 card = _obsCardinality(); + assertEq(card, uint16(61), "init obs + 60 swap obs"); - modifyLiquidityRouter.modifyLiquidity(poolKey, LIQUIDITY_PARAMS, ZERO_BYTES); + // Oldest observation must be older than the TWAP window. + uint16 newestIdx = _obsIndex(); + uint16 oldestIdx = card < OBS_CARDINALITY + ? 0 + : (newestIdx + 1) % OBS_CARDINALITY; + (uint32 oldestTs, , ) = _observationAt(oldestIdx); + assertLe( + uint256(oldestTs) + TWAP_WINDOW, + block.timestamp, + "oldest observation must precede now by >= TWAP_WINDOW" + ); + } + + // ── Ring buffer wraps and cardinality caps at OBS_CARDINALITY ──────────── - // Register pool using oracle order that matches currency0/currency1. - address oracleForCurrency0; - address oracleForCurrency1; - if (Currency.unwrap(poolKey.currency0) == address(stable0)) { - oracleForCurrency0 = address(oracle0); - oracleForCurrency1 = address(oracle1); - } else { - oracleForCurrency0 = address(oracle1); - oracleForCurrency1 = address(oracle0); + function test_observation_ringBufferWrapsAtCardinality() public { + // (OBS_CARDINALITY + 10) swaps after init → buffer wraps; newest = 10. + for (uint256 i = 0; i < uint256(OBS_CARDINALITY) + 10; i++) { + vm.warp(block.timestamp + 10); + _doSmallSwap(); } - // Use low-level call to avoid PoolKey type conflicts between remapped deps. - (bool ok,) = address(hook).call( + assertEq( + _obsCardinality(), + OBS_CARDINALITY, + "cardinality caps at OBS_CARDINALITY" + ); + + // 1 init write at idx 0, then (OBS_CARDINALITY + 10) appends. + // newestIdx = (0 + OBS_CARDINALITY + 10) % OBS_CARDINALITY = 10. + assertEq(_obsIndex(), uint16(10), "newest index wraps correctly"); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + function _doSmallSwap() internal { + // Tiny size to keep the price near 1.0 and avoid SwapCapExceeded + // on the rare drain-direction swap. + oracle0.updateAnswer(int256(1e18)); + oracle1.updateAnswer(int256(1e18)); + + swapRouter.swap( + poolKey, + IPoolManager.SwapParams({ + zeroForOne: true, + amountSpecified: -int256(1e6), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + PoolSwapTest.TestSettings({ + takeClaims: false, + settleUsingBurn: false + }), + "" + ); + } + + function _obsCardinality() internal view returns (uint16) { + (bool ok, bytes memory data) = address(hook).staticcall( + abi.encodeWithSignature("obsCardinality(bytes32)", poolId) + ); + require(ok, "obsCardinality call failed"); + return abi.decode(data, (uint16)); + } + + function _obsIndex() internal view returns (uint16) { + (bool ok, bytes memory data) = address(hook).staticcall( + abi.encodeWithSignature("obsIndex(bytes32)", poolId) + ); + require(ok, "obsIndex call failed"); + return abi.decode(data, (uint16)); + } + + function _observationAt( + uint16 idx + ) internal view returns (uint32 ts, int56 cum, bool initialized) { + (bool ok, bytes memory data) = address(hook).staticcall( + abi.encodeWithSignature("observations(bytes32,uint16)", poolId, idx) + ); + require(ok, "observations call failed"); + (ts, cum, initialized) = abi.decode(data, (uint32, int56, bool)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Depeg detection + dynamic-fee selection +// ───────────────────────────────────────────────────────────────────────────── +// +// Fee model (v3.0): +// depegBps < 7 → BASE_FEE_PIPS (100 = 1 bps) +// restore-direction (input ABOVE peg) → BASE_FEE_PIPS +// drain-direction: +// rawFee = 100 + (K * depegBps²) / 10_000 +// K = 60 if poolLiquidity < 500_000e6 else K = 45 +// fee capped at MAX_FEE_PIPS = 5000 +// +// With LIQUIDITY_PARAMS (1e18 active liquidity in [-120, 120]) we are in the +// K=45 regime, so: +// 20 bps depeg → fee = 100 + (45 * 400)/10_000 = 101 +// 50 bps depeg → fee = 100 + (45 * 2_500)/10_000 = 111 +// 100 bps depeg → fee = 100 + (45 * 10_000)/10_000 = 145 +// +// Disagreement guard ([CHANGE 4]): if |Chainlink − TWAP| > 20 bps, the hook +// uses the reading closer to $1. In this test environment spot==TWAP==$1, so +// any oracle deviation > 20 bps is overridden to $1. That's why the +// "quadratic fee" tests use exactly 20 bps (boundary case, guard does not +// trigger because the check is strictly `>`). + +contract OscillonHookDepegFeeTest is Test, Deployers { + using PoolIdLibrary for PoolKey; + + // Mirror the contract's emit signature; canonical type for PoolId is bytes32. + event DepegDetected( + bytes32 indexed poolId, + uint256 depegBps, + uint24 feeApplied, + uint256 swapSize, + bool isDrain, + bool usingFallback + ); + + uint24 constant BASE_FEE = 100; + uint24 constant FEE_AT_20_BPS = 101; + uint256 constant AMOUNT_IN = 1e15; + + MockERC20 stable0; + MockERC20 stable1; + MockV3Aggregator oracle0; + MockV3Aggregator oracle1; // always represents the "input" oracle in our tests + OscillonHook hook; + PoolKey poolKey; + bytes32 poolId; + + // Direction that always sells stable1 (so the hook reads oracle1 for input). + bool sellStable1ZeroForOne; + + function setUp() public { + deployFreshManagerAndRouters(); + + stable0 = new MockERC20("USD Coin", "USDC", 18); + stable1 = new MockERC20("Tether", "USDT", 18); + + stable0.mint(address(this), type(uint128).max); + stable1.mint(address(this), type(uint128).max); + stable0.approve(address(swapRouter), type(uint128).max); + stable1.approve(address(swapRouter), type(uint128).max); + stable0.approve(address(modifyLiquidityRouter), type(uint128).max); + stable1.approve(address(modifyLiquidityRouter), type(uint128).max); + + oracle0 = new MockV3Aggregator(18, int256(1e18)); + oracle1 = new MockV3Aggregator(18, int256(1e18)); + + uint160 flags = uint160( + Hooks.BEFORE_SWAP_FLAG | + Hooks.AFTER_INITIALIZE_FLAG | + Hooks.AFTER_SWAP_FLAG + ); + deployCodeTo("OscillonHook", abi.encode(manager), address(flags)); + hook = OscillonHook(payable(address(flags))); + + hook.approveOracle(address(oracle0)); + hook.approveOracle(address(oracle1)); + + Currency c0 = Currency.wrap(address(stable0)); + Currency c1 = Currency.wrap(address(stable1)); + if (Currency.unwrap(c0) > Currency.unwrap(c1)) (c0, c1) = (c1, c0); + + (poolKey, ) = initPool( + c0, + c1, + IHooks(address(hook)), + LPFeeLibrary.DYNAMIC_FEE_FLAG, + int24(1), + SQRT_PRICE_1_1 + ); + poolId = PoolId.unwrap(poolKey.toId()); + + modifyLiquidityRouter.modifyLiquidity( + poolKey, + LIQUIDITY_PARAMS, + ZERO_BYTES + ); + + // Map oracle0/oracle1 to whichever currency they correspond to. + bool stable0IsCurrency0 = Currency.unwrap(poolKey.currency0) == + address(stable0); + address oForC0 = stable0IsCurrency0 + ? address(oracle0) + : address(oracle1); + address oForC1 = stable0IsCurrency0 + ? address(oracle1) + : address(oracle0); + sellStable1ZeroForOne = !stable0IsCurrency0; // sell stable1 → input is currency1 unless stable1 sorts first + + (bool ok, ) = address(hook).call( abi.encodeWithSignature( "registerPool((address,address,uint24,int24,address),address,address,uint8,uint8)", Currency.unwrap(poolKey.currency0), @@ -92,8 +400,8 @@ contract OscillonHookBasicTest is Test, Deployers { poolKey.fee, poolKey.tickSpacing, address(poolKey.hooks), - oracleForCurrency0, - oracleForCurrency1, + oForC0, + oForC1, uint8(18), uint8(18) ) @@ -101,63 +409,134 @@ contract OscillonHookBasicTest is Test, Deployers { require(ok, "registerPool failed"); } - function test_swap_WhenStableDropsTo089_UsesMaxFee() public { - // 0.99 => 100 bps depeg. - _swapSellingStable1Expect(990000000000000000, 100, FEE_100_BPS_DEPEG, true); - } + // ── Healthy pool: no depeg → BASE_FEE ──────────────────────────────────── - function test_swap_DynamicFee_LowDepeg_NoCap() public { - // 0.998 => 20 bps depeg. - _swapSellingStable1Expect(998000000000000000, 20, FEE_20_BPS_DEPEG, true); + function test_swap_NoDepeg_AppliesBaseFee() public { + vm.expectEmit(true, false, false, true, address(hook)); + emit DepegDetected(poolId, 0, BASE_FEE, AMOUNT_IN, false, false); + _swap(int256(-int256(AMOUNT_IN))); } - function test_swap_DynamicFee_SevereButNotCapped() public { - // 0.995 => 50 bps depeg. - _swapSellingStable1Expect(995000000000000000, 50, FEE_50_BPS_DEPEG, true); + // ── Depeg below SMALL_DEPEG_BPS (7) → still BASE_FEE ───────────────────── + + function test_swap_SmallDepegBelowThreshold_AppliesBaseFee() public { + // 0.9994 → 6 bps deviation, below the 7-bps activation threshold. + oracle1.updateAnswer(int256(0.9994e18)); + vm.expectEmit(true, false, false, true, address(hook)); + emit DepegDetected(poolId, 6, BASE_FEE, AMOUNT_IN, true, false); + _swap(int256(-int256(AMOUNT_IN))); } - function test_swap_WhenStableAbovePeg_UsesRestoreFee() public { - // 1.01 => 100 bps deviation above peg, so this is restore direction. - _swapSellingStable1Expect(1010000000000000000, 100, 30, false); + // ── Restore direction (input ABOVE peg) → BASE_FEE ─────────────────────── + + function test_swap_RestoreDirection_AppliesBaseFee() public { + // 1.001 → +10 bps; below the 20-bps disagreement threshold so the + // guard does not override. Restore direction → BASE_FEE. + oracle1.updateAnswer(int256(1.001e18)); + vm.expectEmit(true, false, false, true, address(hook)); + emit DepegDetected(poolId, 10, BASE_FEE, AMOUNT_IN, false, false); + _swap(int256(-int256(AMOUNT_IN))); } - function test_transferOwnership_UpdatesImmediately() public { - address multisig = address(0xBEEF); - hook.transferOwnership(multisig); - assertEq(hook.owner(), multisig); + // ── Drain depeg at 20 bps → quadratic fee = 101 ────────────────────────── + + function test_swap_Drain20bps_AppliesQuadraticFee() public { + // 0.998 → 20 bps below peg. Diff vs spot = 20 bps == ORACLE_DISAGREE_BPS, + // and the guard check is strictly `>`, so Chainlink is used directly. + oracle1.updateAnswer(int256(0.998e18)); + vm.expectEmit(true, false, false, true, address(hook)); + emit DepegDetected(poolId, 20, FEE_AT_20_BPS, AMOUNT_IN, true, false); + _swap(int256(-int256(AMOUNT_IN))); } - function test_transferOwnership_OldOwnerLosesAccessAfterAccept() public { - address multisig = address(0xCAFE); - hook.transferOwnership(multisig); + // ── Disagreement guard: |CL − TWAP| > 20 bps → conservative wins ──────── - vm.expectRevert(bytes4(keccak256("NotOwner()"))); - hook.transferOwnership(address(0xD00D)); + function test_disagreementGuard_LargeMismatch_UsesConservative() public { + // 0.99 → 100 bps depeg on Chainlink, but spot/TWAP = $1.00. + // |CL − TWAP| = 100 bps > 20 → guard activates → conservative ($1) wins. + // depegBps = 0, isDrain = false, fee = BASE_FEE. + oracle1.updateAnswer(int256(0.99e18)); + vm.expectEmit(true, false, false, true, address(hook)); + emit DepegDetected(poolId, 0, BASE_FEE, AMOUNT_IN, false, false); + _swap(int256(-int256(AMOUNT_IN))); } - function _swapSellingStable1Expect( - int256 oracleAnswer, - uint256 expectedDepegBps, - uint24 expectedFeePips, - bool expectedIsDrain - ) internal { - oracle1.updateAnswer(oracleAnswer); + // ── Chainlink stale → falls back to TWAP (usingFallback = true) ───────── - bool stable1IsCurrency0 = Currency.unwrap(poolKey.currency0) == address(stable1); - bool zeroForOne = stable1IsCurrency0; // sell stable1 into pool - uint160 sqrtPriceLimitX96 = zeroForOne ? (TickMath.MIN_SQRT_PRICE + 1) : (TickMath.MAX_SQRT_PRICE - 1); + function test_chainlinkStale_FallsBackToTWAP() public { + // Even with a huge oracle deviation, Chainlink should be IGNORED once + // it goes stale (>25h since updatedAt) — the catch path uses TWAP, + // which equals spot ($1) here, so depegBps = 0, fee = BASE_FEE. + oracle1.updateAnswer(int256(0.95e18)); + + // Warp far past MAX_ORACLE_AGE (25h) so the staleness check trips. + vm.warp(block.timestamp + 26 hours); + oracle1.setUpdatedAt(1); // belt-and-braces: force updatedAt firmly stale. vm.expectEmit(true, false, false, true, address(hook)); - emit DepegDetected(poolKey.toId(), expectedDepegBps, expectedFeePips, AMOUNT_IN, expectedIsDrain); + emit DepegDetected(poolId, 0, BASE_FEE, AMOUNT_IN, false, true); // usingFallback = true + _swap(int256(-int256(AMOUNT_IN))); + } + + // ── Exact-output during a depeg → reverts ─────────────────────────────── + + function test_exactOutput_RevertsDuringDepeg() public { + oracle1.updateAnswer(int256(0.998e18)); // 20 bps depeg, depegBps >= 7 + + // v4 wraps hook reverts in WrappedError(address,bytes4,bytes,bytes4), + // so we capture the revert payload and assert the inner reason is + // ExactOutputDisabledDuringDepeg(20). + bytes4 innerSelector = bytes4( + keccak256("ExactOutputDisabledDuringDepeg(uint256)") + ); + + bool reverted; + try this._swapExternal(int256(int256(AMOUNT_IN))) { + // unreachable + } catch (bytes memory data) { + reverted = true; + // Search for the inner selector inside the wrapped revert data. + bool found; + for (uint256 i = 0; i + 4 <= data.length; i++) { + if ( + data[i] == innerSelector[0] && + data[i + 1] == innerSelector[1] && + data[i + 2] == innerSelector[2] && + data[i + 3] == innerSelector[3] + ) { + found = true; + break; + } + } + assertTrue(found, "expected inner ExactOutputDisabledDuringDepeg"); + } + assertTrue(reverted, "swap should have reverted"); + } + + /// @dev External wrapper so we can use try/catch on the swap. + function _swapExternal(int256 amountSpecified) external { + require(msg.sender == address(this), "only self"); + _swap(amountSpecified); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + function _swap(int256 amountSpecified) internal { + uint160 sqrtPriceLimitX96 = sellStable1ZeroForOne + ? (TickMath.MIN_SQRT_PRICE + 1) + : (TickMath.MAX_SQRT_PRICE - 1); swapRouter.swap( poolKey, IPoolManager.SwapParams({ - zeroForOne: zeroForOne, - amountSpecified: -int256(AMOUNT_IN), + zeroForOne: sellStable1ZeroForOne, + amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96 }), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + PoolSwapTest.TestSettings({ + takeClaims: false, + settleUsingBurn: false + }), "" ); } From 81d42a279bd0fe7906fdca9bd9f45998fc15d971 Mon Sep 17 00:00:00 2001 From: sammed-21 Date: Thu, 4 Jun 2026 15:00:27 +0530 Subject: [PATCH 2/3] fix: remove unnecssary comment --- src/OscillonHook.sol | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/OscillonHook.sol b/src/OscillonHook.sol index 4534a43..0a83340 100644 --- a/src/OscillonHook.sol +++ b/src/OscillonHook.sol @@ -20,7 +20,6 @@ import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; import {FixedPointMathLib} from "solmate/src/utils/FixedPointMathLib.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IAggregatorV3Interface} from "./interface/IAggregatorV3Interface.sol"; -import {console2} from "forge-std/console2.sol"; // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -129,7 +128,7 @@ contract OscillonHook is BaseHook { // ── Depeg thresholds (bps) ──────────────────────────────────────────────── - uint256 public constant SMALL_DEPEG_BPS = 7; + uint256 public constant SMALL_DEPEG_BPS = 5; // ── Timing ─────────────────────────────────────────────────────────────── @@ -305,23 +304,7 @@ contract OscillonHook is BaseHook { // [CHANGE 2] Exact output disabled during ANY active depeg // Prevents Bunni-style rounding attack on exact output path - console2.log("params amountSpecified", params.amountSpecified); - console2.log( - "params depeg > = Small Depeg", - ctx.depegBps >= SMALL_DEPEG_BPS - ); - - console2.log("params amountSpecified", params.amountSpecified > 0); if (params.amountSpecified > 0 && ctx.depegBps >= SMALL_DEPEG_BPS) { - console2.log("depegBps", ctx.depegBps); - console2.log("params.amountSpecified", params.amountSpecified); - console2.log("ctx.tokenInIsToken0", ctx.tokenInIsToken0); - console2.log("ctx.token0", cfg.token0); - console2.log("ctx.token1", cfg.token1); - console2.log("ctx.oracle0", cfg.oracle0); - console2.log("ctx.oracle1", cfg.oracle1); - console2.log("ctx.oracle0Decimals", cfg.oracle0Decimals); - console2.log("ctx.oracle1Decimals", cfg.oracle1Decimals); revert ExactOutputDisabledDuringDepeg(ctx.depegBps); } @@ -457,9 +440,6 @@ contract OscillonHook is BaseHook { bool pegBelow, bool usingFallback ) = _readDepegWithFallback(key, feed, dec); - console2.log("depegBps", depegBps); - console2.log("pegBelow", pegBelow); - console2.log("usingFallback", usingFallback); uint256 swapSize = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); @@ -666,7 +646,6 @@ contract OscillonHook is BaseHook { uint256 updatedAt, uint80 answeredInRound ) = IAggregatorV3Interface(oracle).latestRoundData(); - console2.log("answer", answer); if (answer <= 0) revert OracleAnswerInvalid(); if (answeredInRound < roundId) revert OracleRoundIncomplete(roundId, answeredInRound); From cdacdc2b7171e1d3eac4eff03a40c09a977aba1a Mon Sep 17 00:00:00 2001 From: sammed-21 Date: Thu, 4 Jun 2026 15:09:12 +0530 Subject: [PATCH 3/3] fix: workflow ci --- .github/workflows/test.yml | 40 ++++++++++++++++++++------------------ foundry.toml | 7 +++++-- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4481ec6..dc6c273 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,15 +2,11 @@ name: CI on: push: + branches: [main] pull_request: - workflow_dispatch: - -env: - FOUNDRY_PROFILE: ci jobs: - check: - name: Foundry project + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -19,22 +15,28 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable - - name: Show Forge version + - name: Print versions run: | forge --version + forge solc --version - - name: Run Forge fmt - run: | - forge fmt --check - id: fmt + - name: Build + run: forge build --sizes - - name: Run Forge build - run: | - forge build --sizes - id: build + - name: Run tests + run: forge test -vvv - - name: Run Forge tests - run: | - forge test -vvv - id: test + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: foundry-rs/foundry-toolchain@v1 + + - name: Check formatting + run: forge fmt --check diff --git a/foundry.toml b/foundry.toml index 4b8119e..a577eaf 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,7 +6,10 @@ libs = ["lib"] solc_version = '0.8.26' evm_versoin = 'cancun' optimizer_runs = 800 - via_ir = false -ffi = true +via_ir = false + + +[profile.ci] +ffi = false optimizer = true # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options