diff --git a/.gas-snapshot b/.gas-snapshot index c473cbf..e9c442e 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,5 +1 @@ -TestOscillonHook:test_beforeSwap_AppliesPolicyLadder_WhenUSDCDepegs() (gas: 433433) -TestOscillonHook:test_beforeSwap_AppliesPolicyLadder_WhenUSDTDepegs() (gas: 426490) -TestOscillonHook:test_beforeSwap_Reverts_WhenCallerIsNotPoolManager() (gas: 16056) -TestOscillonHook:test_beforeSwap_Reverts_WhenExactOutputExceedsDeepDepegCap() (gas: 94780) -TestOscillonHook:test_beforeSwap_Reverts_WhenInputStableIsAbovePegByFreezeThreshold() (gas: 74754) \ No newline at end of file +OscillonHookBasicTest:test_swap_WhenStableDropsTo089_UsesMaxFee() (gas: 278293) \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index b815ef2..b9a1da8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,6 @@ "v4-periphery/=lib/v4-periphery/", "v4-core-test/=lib/v4-core/test/", "hookmate/=lib/hookmate/src/" - ] + ], + "solidity.compileUsingRemoteVersion": "v0.8.26+commit.8a97fa7a" } diff --git a/README.md b/README.md index dcc5171..9b327a1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,34 @@ Inventory-risk protection hook for Uniswap v4 stable pools. +/\* + +- ╔══════════════════════════════════════════════════════════════════╗ +- ║ OscillonHook.sol v2.0 — Multi-Pool ║ +- ║ ║ +- ║ ARCHITECTURE CHANGE vs v1.x: ║ +- ║ v1: immutable ORACLE0/ORACLE1/STABLE0/STABLE1 ║ +- ║ → hardcoded to ONE pool forever at deploy ║ +- ║ ║ +- ║ v2: mapping(PoolId => PoolConfig) ║ +- ║ → owner calls registerPool() for each stable pair ║ +- ║ → supports USDC/USDT, USDC/DAI, USDT/DAI, ║ +- ║ USDC/crvUSD, any stable pair with Chainlink feeds ║ +- ║ ║ +- ║ FREEZE REMOVED: ║ +- ║ v1: severe depeg → revert PoolFrozen() → pool bricked ║ +- ║ v2: severe depeg → fee capped at MAX_FEE_PIPS (50 bps) ║ +- ║ swaps still flow, LPs still protected, nothing breaks ║ +- ║ ║ +- ║ Supported pools (register after deploy): ║ +- ║ • USDC / USDT ║ +- ║ • USDC / DAI ║ +- ║ • USDT / DAI ║ +- ║ • USDC / crvUSD ║ +- ║ • any stable pair with a Chainlink USD feed ║ +- ╚══════════════════════════════════════════════════════════════════╝ + \*/ + ## Overview `OscillonHook` is a `beforeSwap` hook that protects LP inventory during stablecoin depeg events. diff --git a/remappings.txt b/remappings.txt index 1768df8..ef9e55a 100644 --- a/remappings.txt +++ b/remappings.txt @@ -16,5 +16,6 @@ openzeppelin-contracts/=lib/v4-core/lib/openzeppelin-contracts/ permit2/=lib/v4-periphery/lib/permit2/ solmate/=lib/v4-core/lib/solmate/ v4-core/=lib/v4-core/src/ +v4-core-test/=lib/v4-core/test/ v4-hooks-public/=lib/v4-hooks-public/ v4-periphery/=lib/v4-periphery/ diff --git a/script/DeployOscillonHook.s.sol b/script/DeployOscillonHook.s.sol index 38ba015..7940932 100644 --- a/script/DeployOscillonHook.s.sol +++ b/script/DeployOscillonHook.s.sol @@ -1,50 +1,156 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -import {Script} from "forge-std/Script.sol"; -import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +/* + * DeployOscillonHook.s.sol + * + * Deploys OscillonHook and registers 4 stable pools. + * + * Run: + * forge script script/DeployOscillonHook.s.sol:DeployOscillonHookScript \ + * --rpc-url $ARBITRUM_RPC \ + * --broadcast --verify + */ + +import {Script, console2} from "forge-std/Script.sol"; import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; import {HookMiner} from "v4-hooks-public/src/utils/HookMiner.sol"; -import {OscillonHook, IChainlinkOracle} from "../src/OscillonHook.sol"; - -/// @notice Mines and deploys OscillonHook at a valid hook-flagged address. -/// @dev Required env vars: -/// - POOL_MANAGER -/// - ORACLE0 -/// - STABLE0 -/// - STABLE0_DECIMALS -/// - ORACLE1 -/// - STABLE1 -/// - STABLE1_DECIMALS +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {OscillonHook} from "../src/OscillonHook.sol"; + contract DeployOscillonHookScript is Script { - address constant CREATE2_DEPLOYER = address(0x4e59b44847b379578588920cA78FbF26c0B4956C); + // Foundry deterministic CREATE2 deployer proxy + address constant CREATE2_DEPLOYER = + 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + // Arbitrum v4 PoolManager from Uniswap v4 deployments + address constant ARBITRUM_POOL_MANAGER = + 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32; + + // Stablecoins + address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + address constant USDT = 0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9; + address constant DAI = 0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1; + address constant CRVUSD = 0x498Bf2B1e120FeD3ad3D42EA2165E9b73f99C1e5; + + // Chainlink USD feeds on Arbitrum + address constant CL_USDC_USD = 0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3; + address constant CL_USDT_USD = 0x3f3f5dF88dC9F13eac63DF89EC16ef6e7E25DdE7; + address constant CL_DAI_USD = 0xc5C8E77B397E531B8EC06BFb0048328B30E9eCfB; + address constant CL_CRVUSD_USD = 0x0a32255dd4BB6177C994bAAc73E0606fDD568f66; function run() external { - IPoolManager manager = IPoolManager(vm.envAddress("POOL_MANAGER")); - address oracle0 = vm.envAddress("ORACLE0"); - address stable0 = vm.envAddress("STABLE0"); - uint8 stable0Decimals = uint8(vm.envUint("STABLE0_DECIMALS")); - address oracle1 = vm.envAddress("ORACLE1"); - address stable1 = vm.envAddress("STABLE1"); - uint8 stable1Decimals = uint8(vm.envUint("STABLE1_DECIMALS")); + address poolManager = vm.envOr( + "POOL_MANAGER", + ARBITRUM_POOL_MANAGER + ); + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + + vm.startBroadcast(deployerKey); uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG); - bytes memory constructorArgs = - abi.encode(manager, oracle0, stable0, stable0Decimals, oracle1, stable1, stable1Decimals); - - (address hookAddress, bytes32 salt) = - HookMiner.find(CREATE2_DEPLOYER, flags, type(OscillonHook).creationCode, constructorArgs); - - vm.broadcast(); - OscillonHook deployed = new OscillonHook{salt: salt}( - manager, - IChainlinkOracle(oracle0), - stable0, - stable0Decimals, - IChainlinkOracle(oracle1), - stable1, - stable1Decimals + + (address hookAddr, bytes32 salt) = HookMiner.find( + CREATE2_DEPLOYER, + flags, + type(OscillonHook).creationCode, + abi.encode(IPoolManager(poolManager)) + ); + + OscillonHook hook = new OscillonHook{salt: salt}( + IPoolManager(poolManager) ); - require(address(deployed) == hookAddress, "hook address mismatch"); + + require(address(hook) == hookAddr, "Hook address mismatch"); + console2.log("OscillonHook deployed at:", address(hook)); + console2.log("PoolManager:", poolManager); + + _registerSortedPool( + hook, + USDC, + USDT, + CL_USDC_USD, + CL_USDT_USD, + 6, + 6, + "USDC/USDT" + ); + _registerSortedPool( + hook, + USDC, + DAI, + CL_USDC_USD, + CL_DAI_USD, + 6, + 18, + "USDC/DAI" + ); + _registerSortedPool( + hook, + USDT, + DAI, + CL_USDT_USD, + CL_DAI_USD, + 6, + 18, + "USDT/DAI" + ); + _registerSortedPool( + hook, + USDC, + CRVUSD, + CL_USDC_USD, + CL_CRVUSD_USD, + 6, + 18, + "USDC/crvUSD" + ); + + vm.stopBroadcast(); + + console2.log("=== OSCILLON DEPLOYMENT COMPLETE ==="); + console2.log("Hook address :", address(hook)); + console2.log("Owner :", deployer); + console2.log("Pools active : 4"); + console2.log("Next step : seed each pool with liquidity"); + console2.log("Then : verify on Arbiscan and submit to 1inch"); + } + + function _registerSortedPool( + OscillonHook hook, + address tokenA, + address tokenB, + address oracleA, + address oracleB, + uint8 decimalsA, + uint8 decimalsB, + string memory label + ) internal { + PoolKey memory key; + if (tokenA < tokenB) { + key = PoolKey({ + currency0: Currency.wrap(tokenA), + currency1: Currency.wrap(tokenB), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: 1, + hooks: hook + }); + hook.registerPool(key, oracleA, oracleB, decimalsA, decimalsB); + } else { + key = PoolKey({ + currency0: Currency.wrap(tokenB), + currency1: Currency.wrap(tokenA), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: 1, + hooks: hook + }); + hook.registerPool(key, oracleB, oracleA, decimalsB, decimalsA); + } + + console2.log("Registered:", label); } } diff --git a/src/OscillonHook.sol b/src/OscillonHook.sol index 8fe3248..63a5478 100644 --- a/src/OscillonHook.sol +++ b/src/OscillonHook.sol @@ -6,188 +6,511 @@ import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; -import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; -import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import { + BeforeSwapDelta, + BeforeSwapDeltaLibrary +} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {IAggregatorV3Interface} from "./interface/IAggregatorV3Interface.sol"; -interface IChainlinkOracle { - function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80); +// ─── Errors ────────────────────────────────────────────────────────────────── - function decimals() external view returns (uint8); -} +error NotOwner(); +error PoolNotRegistered(); +error PoolAlreadyRegistered(); +error UnsupportedToken(address token); +error OracleAnswerInvalid(); +error OracleStale(uint256 updatedAt, uint256 blockTime); +error OracleRoundIncomplete(uint80 roundId, uint80 answeredInRound); +error ZeroAddress(); +error SameStable(); contract OscillonHook is BaseHook { using PoolIdLibrary for PoolKey; - event DepegDetected(uint256 depegBps, uint24 fee, uint256 swapSize); + // ── Events ─────────────────────────────────────────────────────────────── - error UnsupportedStablePool(); - error PoolFrozen(); + /// @notice Emitted on every swap — useful for dashboard indexing. + event DepegDetected( + PoolId indexed poolId, + uint256 depegBps, + uint24 feeApplied, + uint256 swapSize, + bool isDrain + ); - /// @notice Oracle and stable token configuration for stable token 0 - IChainlinkOracle public immutable ORACLE0; - address public immutable STABLE0; - uint8 public immutable ORACLE0_DECIMALS; - uint256 public immutable MAX_DEPEG_SWAP0; + /// @notice Emitted when owner registers a new stable pool. + event PoolRegistered( + PoolId indexed poolId, + address token0, + address token1, + address oracle0, + address oracle1 + ); - /// @notice Oracle and stable token configuration for stable token 1 - IChainlinkOracle public immutable ORACLE1; - address public immutable STABLE1; - uint8 public immutable ORACLE1_DECIMALS; - uint256 public immutable MAX_DEPEG_SWAP1; + /// @notice Emitted when pool config is updated (oracle change etc). + event PoolUpdated(PoolId indexed poolId); - // Fee schedule (returned via lpFeeOverride) is in "hundredths of a bip" - uint24 public constant BASE_FEE_PIPS = 100; // ~1 bps - uint24 public constant SMALL_FEE_PIPS = 800; // ~8 bps - uint24 public constant DRAIN_FEE_PIPS = 2800; // ~28 bps - uint24 public constant RESTORE_FEE_PIPS = 30; // ~0.3 bps + event OwnershipTransferred( + address indexed oldOwner, + address indexed newOwner + ); - uint256 public constant SMALL_DEPEG_BPS = 7; // small depeg threshold - uint256 public constant DRAIN_DEPEG_BPS = 20; // drain/deep depeg threshold - uint256 public constant FREEZE_DEPEG_BPS = 60; // circuit breaker threshold - uint256 public constant RESTORE_WINDOW = 1 hours; + // ── Governance ─────────────────────────────────────────────────────────── - uint256 public constant MAX_DEPEG_SWAP_FACTOR = 10_000; // exact-in cap factor + address public owner; - mapping(PoolId => uint256) public lastHighDepegAt; + // ── Fee constants (in Uniswap v4 pips — 1 bps = 100 pips) ─────────────── - constructor( - IPoolManager _poolManager, - IChainlinkOracle _oracle0, - address _stable0, - uint8 stableDecimals0, - IChainlinkOracle _oracle1, - address _stable1, - uint8 stableDecimals1 - ) BaseHook(_poolManager) { - ORACLE0 = _oracle0; - STABLE0 = _stable0; - ORACLE0_DECIMALS = _oracle0.decimals(); - MAX_DEPEG_SWAP0 = MAX_DEPEG_SWAP_FACTOR * (10 ** uint256(stableDecimals0)); - - ORACLE1 = _oracle1; - STABLE1 = _stable1; - ORACLE1_DECIMALS = _oracle1.decimals(); - MAX_DEPEG_SWAP1 = MAX_DEPEG_SWAP_FACTOR * (10 ** uint256(stableDecimals1)); - - require(_stable0 != _stable1, "STABLES_EQUAL"); + uint24 public constant BASE_FEE_PIPS = 100; // 1 bps — healthy pool + uint24 public constant SMALL_FEE_PIPS = 800; // 8 bps — micro-depeg + uint24 public constant DRAIN_FEE_PIPS = 2800; // 28 bps — drain direction + uint24 public constant RESTORE_FEE_PIPS = 30; // 0.3 bps — restore reward + + /// @notice v2: replaces PoolFrozen revert. + /// Severe depeg → fee capped here instead of freezing. + /// Pool keeps running. LPs still protected by high cost of arb. + uint24 public constant MAX_FEE_PIPS = 5000; // 50 bps — severe depeg cap + + // ── Depeg thresholds (bps) ──────────────────────────────────────────────── + + uint256 public constant SMALL_DEPEG_BPS = 7; + uint256 public constant DRAIN_DEPEG_BPS = 20; + + /// @notice v2: no freeze at this threshold — fee just hits MAX_FEE_PIPS. + uint256 public constant SEVERE_DEPEG_BPS = 50; + + // ── Timing ──────────────────────────────────────────────────────────────── + + uint256 public constant RESTORE_WINDOW = 1 hours; + uint256 public constant MAX_ORACLE_AGE = 2 minutes; + + // ── Swap size cap factor ────────────────────────────────────────────────── + + uint256 public constant MAX_DEPEG_SWAP_FACTOR = 50_000; // $50k default cap + + // ── Per-pool configuration ──────────────────────────────────────────────── + + /// @notice + /// v2 CORE CHANGE: all previously-immutable oracle/stable config + /// is now stored here per-pool. Each PoolId gets its own config. + /// A single deployed hook contract serves all registered pools. + struct PoolConfig { + // Is this pool registered with Oscillon? + bool registered; + // token0 of the pool (e.g. USDC) + address token0; + // token1 of the pool (e.g. USDT) + address token1; + // Chainlink oracle for token0/USD (e.g. USDC/USD feed) + address oracle0; + // Chainlink oracle for token1/USD (e.g. USDT/USD feed) + address oracle1; + // Decimal precision of oracle0 answer (usually 8) + uint8 oracle0Decimals; + // Decimal precision of oracle1 answer (usually 8) + uint8 oracle1Decimals; + // Max exact-in swap size during drain (per-pool, based on decimals) + uint256 maxDepegSwap0; + uint256 maxDepegSwap1; + // Last timestamp this pool hit SMALL_DEPEG_BPS or above + // Used for restore window tracking + uint256 lastHighDepegAt; + // Accrued fee surplus for LP redistribution (Phase 2) + uint256 surplusAccrued; } - /// @notice OscillonHook stablecoin permissions - function getHookPermissions() public pure override returns (Hooks.Permissions memory) { - return Hooks.Permissions({ - beforeInitialize: true, - afterInitialize: false, - beforeAddLiquidity: false, - afterAddLiquidity: false, - beforeRemoveLiquidity: false, - afterRemoveLiquidity: false, - beforeSwap: true, - afterSwap: false, - beforeDonate: false, - afterDonate: false, - beforeSwapReturnDelta: false, - afterSwapReturnDelta: false, - afterAddLiquidityReturnDelta: false, - afterRemoveLiquidityReturnDelta: false - }); + struct SwapContext { + uint256 depegBps; + bool isDrain; + int256 amountSpecified; + uint256 swapSize; + bool tokenInIsToken0; } - function _readDepeg(IChainlinkOracle oracle, uint8 oracleDecimals) - internal - view - returns (uint256 depegBps, bool pegBelow) + /// @notice The registry. One hook → many pools. + mapping(PoolId => PoolConfig) public poolConfigs; + + // ── Constructor ─────────────────────────────────────────────────────────── + + constructor(IPoolManager _poolManager) BaseHook(_poolManager) { + owner = msg.sender; + } + + // ── Hook permissions ────────────────────────────────────────────────────── + + function getHookPermissions() + public + pure + override + returns (Hooks.Permissions memory) { - (, int256 oraclePrice,, uint256 updatedAt,) = oracle.latestRoundData(); - require(oraclePrice > 0, "Bad oracle"); - require(updatedAt <= block.timestamp, "Future oracle"); - require(block.timestamp - updatedAt <= 1 hours, "Stale oracle"); + return + Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); + } - // Normalize oracle price into 1e18 (peg is 1e18). - uint256 pegPrice1e18 = (uint256(oraclePrice) * 1e18) / (10 ** uint256(oracleDecimals)); - pegBelow = pegPrice1e18 < 1e18; + // ── Pool registration ───────────────────────────────────────────────────── + + /// @notice Register a stable pair with its oracle feeds. + /// Call this once per pool after hook deployment. + /// + /// @dev v2 CORE FUNCTION — replaces constructor oracle hardcoding. + /// + /// Example calls: + /// registerPool(usdcUsdtKey, CHAINLINK_USDC_USD, CHAINLINK_USDT_USD, 6, 6) + /// registerPool(usdcDaiKey, CHAINLINK_USDC_USD, CHAINLINK_DAI_USD, 6, 18) + /// registerPool(usdtDaiKey, CHAINLINK_USDT_USD, CHAINLINK_DAI_USD, 6, 18) + /// registerPool(usdcCrvKey, CHAINLINK_USDC_USD, CHAINLINK_CRVUSD_USD, 6, 18) + /// + /// @param key PoolKey of the stable pair to protect + /// @param oracle0 Chainlink feed address for token0/USD + /// @param oracle1 Chainlink feed address for token1/USD + /// @param stableDecimals0 ERC20 decimals of token0 (for swap cap math) + /// @param stableDecimals1 ERC20 decimals of token1 (for swap cap math) + + function registerPool( + PoolKey calldata key, + address oracle0, + address oracle1, + uint8 stableDecimals0, + uint8 stableDecimals1 + ) external onlyOwner { + if (oracle0 == address(0) || oracle1 == address(0)) + revert ZeroAddress(); + + address t0 = Currency.unwrap(key.currency0); + address t1 = Currency.unwrap(key.currency1); + if (t0 == t1) revert SameStable(); + + PoolId id = key.toId(); + if (poolConfigs[id].registered) revert PoolAlreadyRegistered(); + + // Cache oracle decimals at registration — saves a call on every swap + uint8 dec0 = IAggregatorV3Interface(oracle0).decimals(); + uint8 dec1 = IAggregatorV3Interface(oracle1).decimals(); + + poolConfigs[id] = PoolConfig({ + registered: true, + token0: t0, + token1: t1, + oracle0: oracle0, + oracle1: oracle1, + oracle0Decimals: dec0, + oracle1Decimals: dec1, + maxDepegSwap0: MAX_DEPEG_SWAP_FACTOR * + (10 ** uint256(stableDecimals0)), + maxDepegSwap1: MAX_DEPEG_SWAP_FACTOR * + (10 ** uint256(stableDecimals1)), + lastHighDepegAt: 0, + surplusAccrued: 0 + }); - if (pegBelow) { - depegBps = ((1e18 - pegPrice1e18) * 10_000) / 1e18; - } else { - depegBps = ((pegPrice1e18 - 1e18) * 10_000) / 1e18; - } + emit PoolRegistered(id, t0, t1, oracle0, oracle1); } - function _selectFeeAndUpdate( + /// @notice Update oracle addresses for a registered pool. + /// Use if Chainlink deprecates a feed. + function updatePoolOracles( PoolKey calldata key, - uint256 depegBps, - bool pegBelow, - uint256 swapSize, - bool tokenInIsStable0 - ) internal returns (uint24 fee) { - PoolId poolId = key.toId(); + address newOracle0, + address newOracle1 + ) external onlyOwner { + require( + IAggregatorV3Interface(newOracle0).decimals() > 0, + "Invalid oracle" + ); + require( + IAggregatorV3Interface(newOracle1).decimals() > 0, + "Invalid oracle" + ); + PoolId id = key.toId(); + if (!poolConfigs[id].registered) revert PoolNotRegistered(); + if (newOracle0 == address(0) || newOracle1 == address(0)) + revert ZeroAddress(); + + PoolConfig storage cfg = poolConfigs[id]; + + cfg.oracle0 = newOracle0; + cfg.oracle1 = newOracle1; + cfg.oracle0Decimals = IAggregatorV3Interface(newOracle0).decimals(); + cfg.oracle1Decimals = IAggregatorV3Interface(newOracle1).decimals(); + + emit PoolUpdated(id); + } + + // ── beforeSwap — core logic ─────────────────────────────────────────────── + + /// @notice Runs before every swap in every registered pool. + /// + /// FLOW: + /// 1. Load pool config — skip unregistered pools silently + /// 2. Identify which token is being sold (tokenIn) + /// 3. Read oracle for tokenIn only (directional asymmetry) + /// 4. Calculate deviation in bps + /// 5. Calculate final fee based on direction + deviation + /// 6. Return fee | OVERRIDE_FEE_FLAG + /// + /// NOTE on unregistered pools: + /// If _beforeSwap is called for a pool that isn't registered, + /// we return BASE_FEE silently — the hook gracefully does nothing. + /// This prevents reverts on any pool that happens to use this hook address. + function _beforeSwap( + address, + PoolKey calldata key, + SwapParams calldata params, + bytes calldata + ) internal override returns (bytes4, BeforeSwapDelta, uint24) { + PoolId id = key.toId(); + PoolConfig storage cfg = poolConfigs[id]; + + // ── 1. Unregistered pool → pass through at base fee + if (!cfg.registered) { + return ( + this.beforeSwap.selector, + BeforeSwapDeltaLibrary.ZERO_DELTA, + BASE_FEE_PIPS | LPFeeLibrary.OVERRIDE_FEE_FLAG + ); + } + SwapContext memory ctx = _buildSwapContext(cfg, params); - uint256 lastHigh = lastHighDepegAt[poolId]; - bool inRestoreWindow = lastHigh != 0 && (block.timestamp - lastHigh) <= RESTORE_WINDOW; + uint24 fee = _selectFee(cfg, ctx); - uint256 maxSwap = tokenInIsStable0 ? MAX_DEPEG_SWAP0 : MAX_DEPEG_SWAP1; + emit DepegDetected(id, ctx.depegBps, fee, ctx.swapSize, ctx.isDrain); - fee = BASE_FEE_PIPS; + // ── 7. Return fee with OVERRIDE_FEE_FLAG + return ( + this.beforeSwap.selector, + BeforeSwapDeltaLibrary.ZERO_DELTA, + fee | LPFeeLibrary.OVERRIDE_FEE_FLAG + ); + } - // Circuit breaker: freeze when the input stable is severely off-peg in either direction. - if (depegBps >= FREEZE_DEPEG_BPS) revert PoolFrozen(); + function _buildSwapContext( + PoolConfig storage cfg, + SwapParams calldata params + ) internal view returns (SwapContext memory ctx) { + // zeroForOne = true -> tokenIn is token0 + // zeroForOne = false -> tokenIn is token1 + bool tokenInIsToken0 = params.zeroForOne; + address tokenIn = tokenInIsToken0 ? cfg.token0 : cfg.token1; + if (tokenIn != cfg.token0 && tokenIn != cfg.token1) { + revert UnsupportedToken(tokenIn); + } - if (pegBelow) { - // Update last depeg time when we're in the "drain" tier. - if (depegBps >= DRAIN_DEPEG_BPS) { - lastHighDepegAt[poolId] = block.timestamp; - fee = DRAIN_FEE_PIPS; + address oracleAddr = tokenInIsToken0 ? cfg.oracle0 : cfg.oracle1; + uint8 oracleDec = tokenInIsToken0 + ? cfg.oracle0Decimals + : cfg.oracle1Decimals; + (uint256 depegBps, bool pegBelow) = _readDepeg(oracleAddr, oracleDec); + uint256 swapSize = params.amountSpecified < 0 + ? uint256(-params.amountSpecified) + : uint256(params.amountSpecified); + ctx = SwapContext({ + depegBps: depegBps, + isDrain: pegBelow, + amountSpecified: params.amountSpecified, + swapSize: swapSize, + tokenInIsToken0: tokenInIsToken0 + }); + return ctx; + } - // Cap swap size for both exact-in and exact-out paths during deep depeg. - require(swapSize <= maxSwap, "Depeg swap limit"); - } else if (depegBps >= SMALL_DEPEG_BPS) { - fee = SMALL_FEE_PIPS; + // ── Fee selection ───────────────────────────────────────────────────────── + + /// @notice Computes the final fee for a swap. + /// + /// Fee ladder: + /// + /// No depeg: + /// → BASE_FEE_PIPS (1 bps) + /// + /// Restore direction (buying the depegged token): + /// → RESTORE_FEE_PIPS (0.3 bps) — cheaper than anywhere + /// → OR restore window active after recent depeg → RESTORE_FEE_PIPS + /// + /// Drain direction (selling depegged token in): + /// → SMALL (8 bps) if 7 bps <= depeg < 20 bps + /// → DRAIN (28 bps) if 20 bps <= depeg < 50 bps + /// → MAX (50 bps) if depeg >= 50 bps [v2: replaces PoolFrozen revert] + /// + /// Large drain (>$50k during depeg): + /// → additional swap size cap applied + + // test this each line + function _selectFee( + PoolConfig storage cfg, + SwapContext memory ctx + ) internal returns (uint24 fee) { + // ── Restore window check ────────────────────────────────────────────── + bool inRestoreWindow = cfg.lastHighDepegAt != 0 && + (block.timestamp - cfg.lastHighDepegAt) <= RESTORE_WINDOW; + // ── Healthy pool — no depeg ─────────────────────────────────────────── + if (ctx.depegBps < SMALL_DEPEG_BPS) { + // If we are in the restore window (recently resolved depeg) + // and this is a restore-direction swap → discount + if (inRestoreWindow && !ctx.isDrain) { + return RESTORE_FEE_PIPS; } + return BASE_FEE_PIPS; } - // Restore fee shortly after a high depeg ended. - if (inRestoreWindow && depegBps <= SMALL_DEPEG_BPS) { - fee = RESTORE_FEE_PIPS; + // ── Active depeg — update last high timestamp ───────────────────────── + // Track from SMALL tier so restore window fires correctly for any depeg. + cfg.lastHighDepegAt = block.timestamp; + + // ── Restore direction — give discount ───────────────────────────────── + // Even during an active depeg, swaps going the "right" direction + // (buying the depegged token) get the restore discount. + // This attracts aggregator flow and helps pool rebalance naturally. + if (!ctx.isDrain) { + return RESTORE_FEE_PIPS; } - } - /// @notice OscillonHook core: inventory-risk layer for a stable/stable pool. - /// In severe depeg, swaps selling the depegged stable into the pool are frozen. - function _beforeSwap(address, PoolKey calldata key, SwapParams calldata params, bytes calldata) - internal - override - returns (bytes4, BeforeSwapDelta, uint24) - { - // Enforce "stable-only" pools by requiring both legs are configured stables. - if (Currency.unwrap(key.currency0) != STABLE0 && Currency.unwrap(key.currency0) != STABLE1) { - revert UnsupportedStablePool(); + // ── Drain direction — graduated fee ─────────────────────────────────── + // + // v2 CHANGE: no PoolFrozen revert at SEVERE_DEPEG_BPS. + // Instead: fee hits MAX_FEE_PIPS (50 bps) and stays there. + // Pool keeps running. Arb is expensive but not impossible. + // LPs protected by high cost. No permanently bricked pools. + + if (ctx.depegBps >= SEVERE_DEPEG_BPS) { + fee = MAX_FEE_PIPS; // 50 bps — severe depeg cap + } else if (ctx.depegBps >= DRAIN_DEPEG_BPS) { + fee = DRAIN_FEE_PIPS; // 28 bps + } else { + fee = SMALL_FEE_PIPS; // 8 bps + } + + // ── Large swap cap during drain ─────────────────────────────────────── + // Only applies to exact-in swaps above the size threshold. + // Retail swaps (<$50k) never hit this. + uint256 maxSwap = ctx.tokenInIsToken0 + ? cfg.maxDepegSwap0 + : cfg.maxDepegSwap1; + if (ctx.amountSpecified < 0 && ctx.swapSize > maxSwap) { + // Cap the swap size — excess reverts + // In production this uses BeforeSwapDelta to limit input + // For MVP: simple require + require( + ctx.swapSize <= maxSwap, + "Oscillon: drain swap exceeds size limit" + ); } - if (Currency.unwrap(key.currency1) != STABLE0 && Currency.unwrap(key.currency1) != STABLE1) { - revert UnsupportedStablePool(); + // Accrue surplus for LP redistribution + if (fee > BASE_FEE_PIPS) { + uint256 surplusBps = uint256(fee / 100) - 1; // surplus above 1 bps + uint256 surplusAmount = (ctx.swapSize * surplusBps) / 10_000; + cfg.surplusAccrued += (surplusAmount * 85) / 100; // 85% to LPs, 15% protocol } - // Determine which stable is being sold into the pool. - address tokenIn = params.zeroForOne ? Currency.unwrap(key.currency0) : Currency.unwrap(key.currency1); + return fee; + } - // Read depeg for the *input* stable only (directional asymmetry). - uint256 depegBps; - bool pegBelow; - if (tokenIn == STABLE0) { - (depegBps, pegBelow) = _readDepeg(ORACLE0, ORACLE0_DECIMALS); - } else { - (depegBps, pegBelow) = _readDepeg(ORACLE1, ORACLE1_DECIMALS); - } + // ── Oracle read ─────────────────────────────────────────────────────────── + + /// @notice Reads a Chainlink feed and returns deviation from $1 peg. + /// @return depegBps Deviation in basis points + /// @return pegBelow True if token is trading below $1 + function _readDepeg( + address oracleAddr, + uint8 oracleDec + ) internal view returns (uint256 depegBps, bool pegBelow) { + ( + uint80 roundId, + int256 answer, + , + uint256 updatedAt, + uint80 answeredInRound + ) = IAggregatorV3Interface(oracleAddr).latestRoundData(); + + if (answer <= 0) revert OracleAnswerInvalid(); + if (answeredInRound < roundId) + revert OracleRoundIncomplete(roundId, answeredInRound); + if (block.timestamp > updatedAt + MAX_ORACLE_AGE) + revert OracleStale(updatedAt, block.timestamp); + + // Normalise to 1e18. $1.0000 = 1e18. + + /** + * 0.89 / 18 + */ + uint256 price1e18 = (uint256(answer) * 1e18) / + (10 ** uint256(oracleDec)); + + pegBelow = price1e18 < 1e18; + depegBps = pegBelow + ? ((1e18 - price1e18) * 10_000) / 1e18 + : ((price1e18 - 1e18) * 10_000) / 1e18; + } + + // ── View helpers ────────────────────────────────────────────────────────── + + /// @notice Read the current state of any registered pool. + /// Used by the Oscillon dashboard. + function getPoolState( + PoolKey calldata key + ) + external + view + returns ( + bool registered, + uint256 depegBps0, + bool pegBelow0, + uint256 depegBps1, + bool pegBelow1, + bool inRestoreWindow, + uint256 surplusAccrued + ) + { + PoolId id = key.toId(); + PoolConfig storage cfg = poolConfigs[id]; + + registered = cfg.registered; + surplusAccrued = cfg.surplusAccrued; + inRestoreWindow = + cfg.lastHighDepegAt != 0 && + (block.timestamp - cfg.lastHighDepegAt) <= RESTORE_WINDOW; + + if (!cfg.registered) return (false, 0, false, 0, false, false, 0); - // Calculate exact-in size magnitude (used for caps + event). - uint256 swapSize = - params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); + (depegBps0, pegBelow0) = _readDepeg(cfg.oracle0, cfg.oracle0Decimals); + (depegBps1, pegBelow1) = _readDepeg(cfg.oracle1, cfg.oracle1Decimals); + } + + /// @notice Returns all config for a pool — useful for integrators. + function getPoolConfig( + PoolKey calldata key + ) external view returns (PoolConfig memory) { + return poolConfigs[key.toId()]; + } - uint24 fee = _selectFeeAndUpdate(key, depegBps, pegBelow, swapSize, tokenIn == STABLE0); + // ── Governance ──────────────────────────────────────────────────────────── - emit DepegDetected(depegBps, fee, swapSize); + function transferOwnership(address newOwner) external onlyOwner { + if (newOwner == address(0)) revert ZeroAddress(); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } - return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, fee | LPFeeLibrary.OVERRIDE_FEE_FLAG); + modifier onlyOwner() { + if (msg.sender != owner) revert NotOwner(); + _; } } diff --git a/src/interface/IAggregatorV3Interface.sol b/src/interface/IAggregatorV3Interface.sol new file mode 100644 index 0000000..8c76a32 --- /dev/null +++ b/src/interface/IAggregatorV3Interface.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +interface IAggregatorV3Interface { + function decimals() external view returns (uint8); + function description() external view returns (string memory); + function version() external view returns (uint256); + function getRoundData( + uint80 _roundId + ) + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); +} diff --git a/test/OscillonHook.t.sol b/test/OscillonHook.t.sol index dfcdbc0..3189e7c 100644 --- a/test/OscillonHook.t.sol +++ b/test/OscillonHook.t.sol @@ -2,56 +2,49 @@ pragma solidity ^0.8.0; import {Test} from "forge-std/Test.sol"; -import "forge-std/console.sol"; - import {Deployers} from "v4-core-test/utils/Deployers.sol"; import {PoolSwapTest} from "v4-core/test/PoolSwapTest.sol"; import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; -import {PoolManager} from "v4-core/PoolManager.sol"; import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; import {IHooks} from "v4-core/interfaces/IHooks.sol"; - -import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol"; -import {PoolId} from "v4-core/types/PoolId.sol"; - +import {Currency} from "v4-core/types/Currency.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol"; import {Hooks} from "v4-core/libraries/Hooks.sol"; import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol"; import {TickMath} from "v4-core/libraries/TickMath.sol"; -import {SqrtPriceMath} from "v4-core/libraries/SqrtPriceMath.sol"; -import {LiquidityAmounts} from "v4-core-test/utils/LiquidityAmounts.sol"; + import {MockV3Aggregator} from "./mock/MockV3Aggregator.sol"; +import {OscillonHook} from "../src/OscillonHook.sol"; +import {console} from "forge-std/console.sol"; -import {ERC1155TokenReceiver} from "solmate/src/tokens/ERC1155.sol"; +contract OscillonHookBasicTest is Test, Deployers { + using PoolIdLibrary for PoolKey; -import {OscillonHook} from "../src/OscillonHook.sol"; + event DepegDetected( + PoolId indexed poolId, + uint256 depegBps, + uint24 feeApplied, + uint256 swapSize, + bool isDrain + ); + + uint256 constant AMOUNT_IN = 1e15; + uint24 constant MAX_FEE_PIPS = 5000; // severe depeg fee cap in hook -contract TestOscillonHook is Test, Deployers { - // two configured stables for the hook (e.g. USDC/USDT) MockERC20 stable0; MockERC20 stable1; Currency stable0Currency; Currency stable1Currency; - MockV3Aggregator oracle0; MockV3Aggregator oracle1; OscillonHook hook; - - // Match OscillonHook's event signature so `vm.expectEmit` can validate it. - event DepegDetected(uint256 depegBps, uint24 fee, uint256 swapSize); - - uint256 constant AMOUNT_IN = 1e15; - - // Fee schedule in OscillonHook.sol (100 pips ~= 1 bps, etc.) - uint24 constant BASE_FEE_PIPS = 100; - uint24 constant SMALL_FEE_PIPS = 800; - uint24 constant DRAIN_FEE_PIPS = 2800; - uint24 constant RESTORE_FEE_PIPS = 30; + PoolKey poolKey; function setUp() public { deployFreshManagerAndRouters(); - // Deploy stable tokens (mocked as 18 decimals for tests). stable0 = new MockERC20("USD Coin", "USDC", 18); stable1 = new MockERC20("Tether", "USDT", 18); stable0Currency = Currency.wrap(address(stable0)); @@ -60,189 +53,93 @@ contract TestOscillonHook is Test, Deployers { stable0.mint(address(this), type(uint128).max); stable1.mint(address(this), type(uint128).max); - // Mint approvals for routers (spender is the router contracts). 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); - // Deploy two oracles (1e18 = $1). oracle0 = new MockV3Aggregator(18, int256(1e18)); oracle1 = new MockV3Aggregator(18, int256(1e18)); uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG); - - // Pass: manager, oracle0, stable0, stableDecimals0, oracle1, stable1, stableDecimals1 - bytes memory constructorArgs = - abi.encode(manager, oracle0, address(stable0), uint8(18), oracle1, address(stable1), uint8(18)); - deployCodeTo("OscillonHook", constructorArgs, address(flags)); + deployCodeTo("OscillonHook", abi.encode(manager), address(flags)); hook = OscillonHook(payable(address(flags))); - // Pool must be stable/stable, and PoolManager requires currency0 < currency1. Currency c0 = stable0Currency; Currency c1 = stable1Currency; if (Currency.unwrap(c0) > Currency.unwrap(c1)) { (c0, c1) = (c1, c0); } - - (key,) = initPool(c0, c1, IHooks(address(hook)), LPFeeLibrary.DYNAMIC_FEE_FLAG, SQRT_PRICE_1_1); - - // Add liquidity using the default test parameters from Deployers. - modifyLiquidityRouter.modifyLiquidity(key, LIQUIDITY_PARAMS, ZERO_BYTES); - } - - function _sellStable1IntoPool() internal { - bool stable1IsCurrency0 = Currency.unwrap(key.currency0) == Currency.unwrap(stable1Currency); - bool zeroForOne = stable1IsCurrency0; // input token is currency0 - uint160 sqrtPriceLimitX96 = zeroForOne ? (TickMath.MIN_SQRT_PRICE + 1) : (TickMath.MAX_SQRT_PRICE - 1); - - swapRouter.swap( - key, - IPoolManager.SwapParams({ - zeroForOne: zeroForOne, - amountSpecified: -int256(AMOUNT_IN), - sqrtPriceLimitX96: sqrtPriceLimitX96 - }), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), - "" + console.log(Currency.unwrap(c0), Currency.unwrap(c1)); + + (poolKey, ) = initPool( + c0, + c1, + IHooks(address(hook)), + LPFeeLibrary.DYNAMIC_FEE_FLAG, + SQRT_PRICE_1_1 ); - } - function _sellStable0IntoPool() internal { - bool stable0IsCurrency0 = Currency.unwrap(key.currency0) == Currency.unwrap(stable0Currency); - bool zeroForOne = stable0IsCurrency0; // input token is currency0 - uint160 sqrtPriceLimitX96 = zeroForOne ? (TickMath.MIN_SQRT_PRICE + 1) : (TickMath.MAX_SQRT_PRICE - 1); - - swapRouter.swap( - key, - IPoolManager.SwapParams({ - zeroForOne: zeroForOne, - amountSpecified: -int256(AMOUNT_IN), - sqrtPriceLimitX96: sqrtPriceLimitX96 - }), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), - "" + modifyLiquidityRouter.modifyLiquidity( + poolKey, + LIQUIDITY_PARAMS, + ZERO_BYTES ); - } - - function test_beforeSwap_AppliesPolicyLadder_WhenUSDTDepegs() public { - // Scenario 1 — healthy pool (depeg = 0 => base fee) - oracle0.updateAnswer(int256(1e18)); - oracle1.updateAnswer(int256(1e18)); - - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(0, BASE_FEE_PIPS, AMOUNT_IN); - _sellStable1IntoPool(); - - // Scenario 2 — small depeg: 7 bps => small fee - // oraclePrice = 1e18 * (1 - 7/10000) = 0.9993e18 - oracle1.updateAnswer(999300000000000000); - - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(7, SMALL_FEE_PIPS, AMOUNT_IN); - _sellStable1IntoPool(); - - // Scenario 3 — drain tier: 20 bps => drain fee - // oraclePrice = 1e18 * (1 - 20/10000) = 0.998e18 - oracle1.updateAnswer(998000000000000000); - - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(20, DRAIN_FEE_PIPS, AMOUNT_IN); - _sellStable1IntoPool(); - - // Scenario 4 — restore: back to peg => restore fee within restore window - vm.warp(block.timestamp + 30 minutes); - oracle1.updateAnswer(int256(1e18)); - - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(0, RESTORE_FEE_PIPS, AMOUNT_IN); - _sellStable1IntoPool(); - - // Scenario 5 — severe depeg: 60 bps => circuit breaker freeze (revert) - oracle1.updateAnswer(994000000000000000); // 0.994e18 - vm.expectRevert(); - _sellStable1IntoPool(); - } - - function test_beforeSwap_AppliesPolicyLadder_WhenUSDCDepegs() public { - // Scenario 1 — healthy pool (base fee) - oracle0.updateAnswer(int256(1e18)); - oracle1.updateAnswer(int256(1e18)); - - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(0, BASE_FEE_PIPS, AMOUNT_IN); - _sellStable0IntoPool(); - - // Scenario 2 — small depeg for stable0 (7 bps => small fee) - oracle0.updateAnswer(999300000000000000); - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(7, SMALL_FEE_PIPS, AMOUNT_IN); - _sellStable0IntoPool(); - - // Scenario 3 — drain tier for stable0 (20 bps => drain fee) - oracle0.updateAnswer(998000000000000000); - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(20, DRAIN_FEE_PIPS, AMOUNT_IN); - _sellStable0IntoPool(); - - // Scenario 4 — restore for stable0 => restore fee - vm.warp(block.timestamp + 30 minutes); - oracle0.updateAnswer(int256(1e18)); - vm.expectEmit(true, true, true, true, address(hook)); - emit DepegDetected(0, RESTORE_FEE_PIPS, AMOUNT_IN); - _sellStable0IntoPool(); - - // Scenario 5 — severe depeg (60 bps) => circuit breaker freeze (revert) - oracle0.updateAnswer(994000000000000000); - vm.expectRevert(); - _sellStable0IntoPool(); - } + // 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_beforeSwap_Reverts_WhenCallerIsNotPoolManager() public { - bool zeroForOne = true; - uint160 sqrtPriceLimitX96 = TickMath.MIN_SQRT_PRICE + 1; - vm.expectRevert(); - IHooks(address(hook)).beforeSwap( - address(this), - key, - IPoolManager.SwapParams({ - zeroForOne: zeroForOne, - amountSpecified: -int256(AMOUNT_IN), - sqrtPriceLimitX96: sqrtPriceLimitX96 - }), - "" + // Use low-level call to avoid PoolKey type conflicts between remapped deps. + (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), + oracleForCurrency0, + oracleForCurrency1, + uint8(18), + uint8(18) + ) ); + require(ok, "registerPool failed"); } - function test_beforeSwap_Reverts_WhenInputStableIsAbovePegByFreezeThreshold() public { - // Input stable = stable1 path. - // 1.006e18 => +60 bps above peg => should freeze too. - oracle1.updateAnswer(1006000000000000000); + function test_swap_WhenStableDropsTo089_UsesMaxFee() public { + // Depeg stable1 from $1.00 -> $0.89 (11% depeg = 1100 bps). + oracle1.updateAnswer(890000000000000000); - vm.expectRevert(); - _sellStable1IntoPool(); - } + 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_beforeSwap_Reverts_WhenExactOutputExceedsDeepDepegCap() public { - // Deep depeg below peg so the cap path is active. - oracle1.updateAnswer(998000000000000000); // 20 bps below peg + vm.expectEmit(true, false, false, true, address(hook)); + emit DepegDetected(poolKey.toId(), 1100, MAX_FEE_PIPS, AMOUNT_IN, true); - bool stable1IsCurrency0 = Currency.unwrap(key.currency0) == Currency.unwrap(stable1Currency); - bool zeroForOne = stable1IsCurrency0; // input token is currency0 - uint160 sqrtPriceLimitX96 = zeroForOne ? (TickMath.MIN_SQRT_PRICE + 1) : (TickMath.MAX_SQRT_PRICE - 1); - - // exact-output path: amountSpecified > 0 - uint256 tooMuchOut = 10_001e18; - vm.expectRevert(); swapRouter.swap( - key, + poolKey, IPoolManager.SwapParams({ zeroForOne: zeroForOne, - amountSpecified: int256(tooMuchOut), + amountSpecified: -int256(AMOUNT_IN), sqrtPriceLimitX96: sqrtPriceLimitX96 }), - PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}), + PoolSwapTest.TestSettings({ + takeClaims: false, + settleUsingBurn: false + }), "" ); }