From fdc626c9c3d8535fccdd56c162efcaf2a513e77c Mon Sep 17 00:00:00 2001 From: sammed-21 Date: Sat, 6 Jun 2026 01:41:45 +0530 Subject: [PATCH 1/2] feat: restructure code split --- .github/workflows/test.yml | 5 + script/DeployOscillonHook.s.sol | 69 +- src/OscillonHook.sol | 802 ++++-------------- src/constants/OscillonConstants.sol | 37 + src/errors/OscillonErrors.sol | 20 + src/governance/OsicllonAdmin.sol | 0 src/interface/IOscillonHook.sol | 0 src/libraries/OracleLib.sol | 0 src/libraries/OscillonDepegMath | 0 src/libraries/OscillonDepegMath.sol | 33 + src/libraries/OscillonTwapOracle.sol | 122 +++ src/libraries/OscillonfeePolicy.sol | 66 ++ src/oracle/IChainlinkSequencer.sol | 15 + src/oracle/IOscillonOracle.sol | 13 + src/oracle/OscillonPriceEngine.sol | 66 ++ .../adapters/ChainlinkOracleAdapter.sol | 67 ++ src/types/OscillonTypes.sol | 54 ++ test/OscillonFeePolicy.t.sol | 25 + test/OscillonHook.t.sol | 54 +- 19 files changed, 746 insertions(+), 702 deletions(-) create mode 100644 src/constants/OscillonConstants.sol create mode 100644 src/errors/OscillonErrors.sol create mode 100644 src/governance/OsicllonAdmin.sol create mode 100644 src/interface/IOscillonHook.sol create mode 100644 src/libraries/OracleLib.sol create mode 100644 src/libraries/OscillonDepegMath create mode 100644 src/libraries/OscillonDepegMath.sol create mode 100644 src/libraries/OscillonTwapOracle.sol create mode 100644 src/libraries/OscillonfeePolicy.sol create mode 100644 src/oracle/IChainlinkSequencer.sol create mode 100644 src/oracle/IOscillonOracle.sol create mode 100644 src/oracle/OscillonPriceEngine.sol create mode 100644 src/oracle/adapters/ChainlinkOracleAdapter.sol create mode 100644 src/types/OscillonTypes.sol create mode 100644 test/OscillonFeePolicy.t.sol diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc6c273..f6d4c35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,11 @@ jobs: - name: Run tests run: forge test -vvv + - name: Check gas snapshots + run: forge snapshot --check + env: + FOUNDRY_PROFILE: ci + lint: runs-on: ubuntu-latest steps: diff --git a/script/DeployOscillonHook.s.sol b/script/DeployOscillonHook.s.sol index 826717a..eb8e3d6 100644 --- a/script/DeployOscillonHook.s.sol +++ b/script/DeployOscillonHook.s.sol @@ -1,17 +1,6 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.26; -/* - * 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"; @@ -20,21 +9,19 @@ import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; import {HookMiner} from "v4-hooks-public/src/utils/HookMiner.sol"; import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; import {OscillonHook} from "../src/OscillonHook.sol"; +import {ChainlinkOracleAdapter} from "../src/oracle/adapters/ChainlinkOracleAdapter.sol"; +import {OscillonConstants as C} from "../src/constants/OscillonConstants.sol"; contract DeployOscillonHookScript is Script { - // Foundry deterministic CREATE2 deployer proxy address constant CREATE2_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C; - - // Arbitrum v4 PoolManager from Uniswap v4 deployments address constant ARBITRUM_POOL_MANAGER = 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32; + address constant ARBITRUM_SEQUENCER = 0xFdB631F5EE196F0ed6FAa767959853A9F217697D; - // 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; @@ -47,39 +34,51 @@ contract DeployOscillonHookScript is Script { vm.startBroadcast(deployerKey); - uint160 flags = uint160(Hooks.BEFORE_SWAP_FLAG); + uint160 flags = uint160( + Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG + ); (address hookAddr, bytes32 salt) = HookMiner.find( - CREATE2_DEPLOYER, flags, type(OscillonHook).creationCode, abi.encode(IPoolManager(poolManager)) + CREATE2_DEPLOYER, + flags, + type(OscillonHook).creationCode, + abi.encode(IPoolManager(poolManager)) ); OscillonHook hook = new OscillonHook{salt: salt}(IPoolManager(poolManager)); - 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"); + ChainlinkOracleAdapter usdcAdapter = + new ChainlinkOracleAdapter(CL_USDC_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); + ChainlinkOracleAdapter usdtAdapter = + new ChainlinkOracleAdapter(CL_USDT_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); + ChainlinkOracleAdapter daiAdapter = + new ChainlinkOracleAdapter(CL_DAI_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); + ChainlinkOracleAdapter crvAdapter = + new ChainlinkOracleAdapter(CL_CRVUSD_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); + + hook.approveAdapter(address(usdcAdapter)); + hook.approveAdapter(address(usdtAdapter)); + hook.approveAdapter(address(daiAdapter)); + hook.approveAdapter(address(crvAdapter)); + + _registerSortedPool(hook, USDC, USDT, usdcAdapter, usdtAdapter, 6, 6, "USDC/USDT"); + _registerSortedPool(hook, USDC, DAI, usdcAdapter, daiAdapter, 6, 18, "USDC/DAI"); + _registerSortedPool(hook, USDT, DAI, usdtAdapter, daiAdapter, 6, 18, "USDT/DAI"); + _registerSortedPool(hook, USDC, CRVUSD, usdcAdapter, crvAdapter, 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"); + console2.log("OscillonHook:", address(hook)); + console2.log("Owner:", deployer); } function _registerSortedPool( OscillonHook hook, address tokenA, address tokenB, - address oracleA, - address oracleB, + ChainlinkOracleAdapter adapterA, + ChainlinkOracleAdapter adapterB, uint8 decimalsA, uint8 decimalsB, string memory label @@ -93,7 +92,7 @@ contract DeployOscillonHookScript is Script { tickSpacing: 1, hooks: hook }); - hook.registerPool(key, oracleA, oracleB, decimalsA, decimalsB); + hook.registerPool(key, address(adapterA), address(adapterB), decimalsA, decimalsB); } else { key = PoolKey({ currency0: Currency.wrap(tokenB), @@ -102,7 +101,7 @@ contract DeployOscillonHookScript is Script { tickSpacing: 1, hooks: hook }); - hook.registerPool(key, oracleB, oracleA, decimalsB, decimalsA); + hook.registerPool(key, address(adapterB), address(adapterA), decimalsB, decimalsA); } console2.log("Registered:", label); diff --git a/src/OscillonHook.sol b/src/OscillonHook.sol index 0a83340..040f3ff 100644 --- a/src/OscillonHook.sol +++ b/src/OscillonHook.sol @@ -15,38 +15,43 @@ import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; -import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; -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"; - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -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(); -error NotStablePool(); -error OracleNotApproved(address oracle); -error ExactOutputDisabledDuringDepeg(uint256 depegBps); -error SwapCapExceeded(); -error TransferFailed(); - -// ─── Main contract ──────────────────────────────────────────────────────────── +import {OscillonConstants as C} from "./constants/OscillonConstants.sol"; +import { + PoolConfig, + TokenOracleConfig, + SwapContext, + Observation, + TwapState, + PriceResult +} from "./types/OscillonTypes.sol"; +import { + NotOwner, + PoolNotRegistered, + PoolAlreadyRegistered, + UnsupportedToken, + ZeroAddress, + SameStable, + NotStablePool, + AdapterNotApproved, + ExactOutputDisabledDuringDepeg, + SwapCapExceeded, + TransferFailed +} from "./errors/OscillonErrors.sol"; +import {OscillonFeePolicy} from "./libraries/OscillonFeePolicy.sol"; +import {OscillonTwapOracle} from "./libraries/OscillonTwapOracle.sol"; +import {OscillonPriceEngine} from "./oracle/OscillonPriceEngine.sol"; +import {IOscillonOracle} from "./oracle/IOscillonOracle.sol"; + +/// @title OscillonHook +/// @notice Inventory-risk protection hook for Uniswap v4 stable pools. contract OscillonHook is BaseHook { using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; using FixedPointMathLib for uint256; - // ── Events ─────────────────────────────────────────────────────────────── - event DepegDetected( PoolId indexed poolId, uint256 depegBps, @@ -59,198 +64,81 @@ contract OscillonHook is BaseHook { PoolId indexed poolId, address token0, address token1, - address oracle0, - address oracle1 + address chainlink0, + address chainlink1 ); - event OracleApproved(address indexed oracle); - event OracleRevoked(address indexed oracle); + event AdapterApproved(address indexed adapter); + event AdapterRevoked(address indexed adapter); + event ChainlinkOracleUpdated(PoolId indexed poolId, bool isToken0, address adapter); event ProtocolFeesCollected(PoolId indexed poolId, uint256 amount); event ProtocolTreasuryUpdated(address newTreasury); - event OwnershipTransferred( - address indexed oldOwner, - address indexed newOwner - ); - - // ── Per-pool config ─────────────────────────────────────────────────────── - - struct PoolConfig { - bool registered; - address token0; - address token1; - // [CHANGE 3] Two independent oracle feeds — one per token - // v2 had a single shared oracle; this creates directional accuracy - // token0 oracle: e.g. USDC/USD | token1 oracle: e.g. USDT/USD - address oracle0; - address oracle1; - uint8 oracle0Decimals; - uint8 oracle1Decimals; - uint256 maxDepegSwap0; - uint256 maxDepegSwap1; - uint256 lastHighDepegAt; - // [CHANGE 6] Split accounting: LP surplus vs protocol cut - uint256 surplusAccrued; // 85% — distributed to LPs via donate() - uint256 protocolAccrued; // 15% — collected by protocol treasury - } - - // ── TWAP observation (in-hook oracle — v4 has no native observe) ───────── - // - // v4 deliberately removed observations from IPoolManager. To preserve the - // [CHANGE 4] disagreement guard and Chainlink fallback, we maintain a - // per-pool ring buffer of (timestamp, tickCumulative) updated on afterSwap. - // tickCumulative grows linearly: tickCumulative_new = tickCumulative_last - // + currentTick * (now - last.timestamp) - // 30-min TWAP = (tickCumulative_now - tickCumulative_30m_ago) / 1800. - - struct Observation { - uint32 blockTimestamp; - int56 tickCumulative; - bool initialized; - } - - // ── Swap context (built once per swap, passed down) ─────────────────────── - - struct SwapContext { - uint256 depegBps; - bool isDrain; - bool usingFallback; // true when TWAP used instead of Chainlink - int256 amountSpecified; - uint256 swapSize; - bool tokenInIsToken0; - } - - // ── Fee constants (pips = hundredths of a bip, 100 pips = 1 bps) ───────── - - uint24 public constant BASE_FEE_PIPS = 100; // 1 bps - uint24 public constant RESTORE_FEE_PIPS = 100; // [CHANGE 2] 1 bps (not 0.3) - // restore discount removed for POC - // re-add in phase 2 with epoch drain gate - uint24 public constant MAX_FEE_PIPS = 5000; // 50 bps hard cap - - // ── Depeg thresholds (bps) ──────────────────────────────────────────────── - - uint256 public constant SMALL_DEPEG_BPS = 5; - - // ── Timing ─────────────────────────────────────────────────────────────── - - uint256 public constant RESTORE_WINDOW = 1 hours; - - // [CHANGE 1] Was 2 minutes — broke on all mainnet chains - // Chainlink USDT/USD heartbeat on Arbitrum = 24 hours - // 2-min staleness reverted 90%+ of swaps in calm markets - // 25 hours = heartbeat + small buffer - uint256 public constant MAX_ORACLE_AGE = 25 hours; - - // [CHANGE 4] Disagreement guard threshold - // If |Chainlink - TWAP| > this, something is wrong → use conservative - uint256 public constant ORACLE_DISAGREE_BPS = 20; - - // ── Swap size cap ───────────────────────────────────────────────────────── - - uint256 public constant MAX_DEPEG_SWAP_FACTOR = 50_000; - - // ── Rolling drain window ────────────────────────────────────────────────── - - uint32 public constant ROLLING_BLOCKS = 300; // ~10 min on Arbitrum - - // ── TWAP observation buffer ─────────────────────────────────────────────── - - // Ring buffer length per pool. 144 slots covers >30 min comfortably even at - // ~12s/block; oldest observation is overwritten when full. - uint16 public constant OBS_CARDINALITY = 144; - - // Target TWAP window — must match the read window in _readTwap30. - uint32 public constant TWAP_WINDOW = 1800; // 30 min - - // ── Protocol revenue ───────────────────────────────────────────────────── - - // 15% of surplus above base fee goes to protocol treasury - // 85% stays with LPs via surplusAccrued → donate() - uint256 public constant PROTOCOL_FEE_BPS = 15; - uint256 public constant LP_FEE_BPS = 85; - - // ── Storage ─────────────────────────────────────────────────────────────── + event OwnershipTransferred(address indexed oldOwner, address indexed newOwner); address public owner; address public protocolTreasury; - // [CHANGE 5] Oracle registry — governance approves feeds, anyone registers pools - mapping(address => bool) public approvedOracles; - + mapping(address => bool) public approvedAdapters; mapping(PoolId => PoolConfig) public poolConfigs; + mapping(PoolId => TwapState) internal twapStates; mapping(PoolId => uint256) public rollingDrain; mapping(PoolId => uint256) public rollingWindowStart; - // TWAP ring buffer state (one buffer per pool). - mapping(PoolId => mapping(uint16 => Observation)) public observations; - mapping(PoolId => uint16) public obsIndex; // index of newest observation - mapping(PoolId => uint16) public obsCardinality; // current populated count - - // ── Constructor ─────────────────────────────────────────────────────────── - constructor(IPoolManager _poolManager) BaseHook(_poolManager) { owner = msg.sender; - protocolTreasury = msg.sender; // override before mainnet with Gnosis Safe + protocolTreasury = msg.sender; + } + + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + return Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: true, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: true, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); } - // ── Hook permissions ────────────────────────────────────────────────────── + // ── TWAP storage accessors (tests / monitoring) ─────────────────────────── - function getHookPermissions() - public - pure - override - returns (Hooks.Permissions memory) + function obsCardinality(PoolId poolId) external view returns (uint16) { + return twapStates[poolId].obsCardinality; + } + + function obsIndex(PoolId poolId) external view returns (uint16) { + return twapStates[poolId].obsIndex; + } + + function observations(PoolId poolId, uint16 idx) + external + view + returns (uint32 blockTimestamp, int56 tickCumulative, bool initialized) { - return - Hooks.Permissions({ - beforeInitialize: false, - afterInitialize: true, // seed TWAP observation buffer on init - beforeAddLiquidity: false, - afterAddLiquidity: false, - beforeRemoveLiquidity: false, - afterRemoveLiquidity: false, - beforeSwap: true, - afterSwap: true, // record TWAP observations after each swap - beforeDonate: false, - afterDonate: false, - beforeSwapReturnDelta: false, - afterSwapReturnDelta: false, - afterAddLiquidityReturnDelta: false, - afterRemoveLiquidityReturnDelta: false - }); + Observation memory obs = twapStates[poolId].observations[idx]; + return (obs.blockTimestamp, obs.tickCumulative, obs.initialized); } // ── Pool registration ───────────────────────────────────────────────────── - /// @notice Register a stable pool with two independent oracle feeds. - /// - /// [CHANGE 5] No longer onlyOwner — anyone can register IF both oracles - /// are in the approved registry. Governance curates oracle quality. - /// Pair permissionlessness, oracle curation. - /// - /// [CHANGE 9] tickSpacing == 1 guard — physically prevents volatile pair - /// registration. ETH/USDC uses tickSpacing 60 and will always revert here. - /// - /// @param key PoolKey of the stable pair (must use DYNAMIC_FEE_FLAG) - /// @param oracle0 Chainlink feed for token0/USD (must be approved) - /// @param oracle1 Chainlink feed for token1/USD (must be approved) - /// @param stableDecimals0 ERC20 decimals of token0 - /// @param stableDecimals1 ERC20 decimals of token1 function registerPool( PoolKey calldata key, - address oracle0, - address oracle1, + address chainlinkAdapter0, + address chainlinkAdapter1, uint8 stableDecimals0, uint8 stableDecimals1 ) external onlyOwner { - // [CHANGE 9] Stable-only enforcement via tick spacing if (key.tickSpacing != 1) revert NotStablePool(); - - // [CHANGE 5] Oracle registry gate — no arbitrary oracle addresses - if (!approvedOracles[oracle0]) revert OracleNotApproved(oracle0); - if (!approvedOracles[oracle1]) revert OracleNotApproved(oracle1); - - if (oracle0 == address(0) || oracle1 == address(0)) - revert ZeroAddress(); + if (!approvedAdapters[chainlinkAdapter0]) revert AdapterNotApproved(chainlinkAdapter0); + if (!approvedAdapters[chainlinkAdapter1]) revert AdapterNotApproved(chainlinkAdapter1); + if (chainlinkAdapter0 == address(0) || chainlinkAdapter1 == address(0)) revert ZeroAddress(); address token0 = Currency.unwrap(key.currency0); address token1 = Currency.unwrap(key.currency1); @@ -263,23 +151,35 @@ contract OscillonHook is BaseHook { registered: true, token0: token0, token1: token1, - oracle0: oracle0, - oracle1: oracle1, - oracle0Decimals: IAggregatorV3Interface(oracle0).decimals(), - oracle1Decimals: IAggregatorV3Interface(oracle1).decimals(), - maxDepegSwap0: MAX_DEPEG_SWAP_FACTOR * - (10 ** uint256(stableDecimals0)), - maxDepegSwap1: MAX_DEPEG_SWAP_FACTOR * - (10 ** uint256(stableDecimals1)), + oracles0: TokenOracleConfig({chainlinkAdapter: chainlinkAdapter0}), + oracles1: TokenOracleConfig({chainlinkAdapter: chainlinkAdapter1}), + maxDepegSwap0: C.MAX_DEPEG_SWAP_FACTOR * (10 ** uint256(stableDecimals0)), + maxDepegSwap1: C.MAX_DEPEG_SWAP_FACTOR * (10 ** uint256(stableDecimals1)), lastHighDepegAt: 0, surplusAccrued: 0, protocolAccrued: 0 }); - emit PoolRegistered(poolId, token0, token1, oracle0, oracle1); + emit PoolRegistered(poolId, token0, token1, chainlinkAdapter0, chainlinkAdapter1); } - // ── beforeSwap — core hook ──────────────────────────────────────────────── + /// @notice Replace a pool's Chainlink adapter after deployment (e.g. feed migration). + function updatePoolChainlinkOracle( + PoolKey calldata key, + bool isToken0, + address newChainlinkAdapter + ) external onlyOwner { + if (!approvedAdapters[newChainlinkAdapter]) revert AdapterNotApproved(newChainlinkAdapter); + PoolConfig storage cfg = poolConfigs[key.toId()]; + if (!cfg.registered) revert PoolNotRegistered(); + + TokenOracleConfig storage oracles = isToken0 ? cfg.oracles0 : cfg.oracles1; + oracles.chainlinkAdapter = newChainlinkAdapter; + + emit ChainlinkOracleUpdated(key.toId(), isToken0, newChainlinkAdapter); + } + + // ── Hook callbacks ──────────────────────────────────────────────────────── function _beforeSwap( address, @@ -290,43 +190,28 @@ contract OscillonHook is BaseHook { PoolId poolId = key.toId(); PoolConfig storage cfg = poolConfigs[poolId]; - // Unregistered pool → pass through at base fee, do not revert if (!cfg.registered) { return ( this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, - BASE_FEE_PIPS | LPFeeLibrary.OVERRIDE_FEE_FLAG + C.BASE_FEE_PIPS | LPFeeLibrary.OVERRIDE_FEE_FLAG ); } - // Build swap context — oracle read happens here SwapContext memory ctx = _buildSwapContext(key, cfg, params); - // [CHANGE 2] Exact output disabled during ANY active depeg - // Prevents Bunni-style rounding attack on exact output path - if (params.amountSpecified > 0 && ctx.depegBps >= SMALL_DEPEG_BPS) { + if (params.amountSpecified > 0 && ctx.depegBps >= C.SMALL_DEPEG_BPS) { revert ExactOutputDisabledDuringDepeg(ctx.depegBps); } - // Swap size cap — scaled to pool depth (not fixed dollar amount) uint128 liquidity = poolManager.getLiquidity(poolId); - uint256 maxAbsolute = ctx.tokenInIsToken0 - ? cfg.maxDepegSwap0 - : cfg.maxDepegSwap1; + uint256 maxAbsolute = ctx.tokenInIsToken0 ? cfg.maxDepegSwap0 : cfg.maxDepegSwap1; uint256 cap = _min(maxAbsolute, (uint256(liquidity) * 50) / 10_000); if (ctx.isDrain && ctx.swapSize > cap) revert SwapCapExceeded(); - // Compute fee uint24 fee = _selectFee(poolId, cfg, ctx, liquidity); - emit DepegDetected( - poolId, - ctx.depegBps, - fee, - ctx.swapSize, - ctx.isDrain, - ctx.usingFallback - ); + emit DepegDetected(poolId, ctx.depegBps, fee, ctx.swapSize, ctx.isDrain, ctx.usingFallback); return ( this.beforeSwap.selector, @@ -335,30 +220,15 @@ contract OscillonHook is BaseHook { ); } - // ── afterInitialize / afterSwap — TWAP observation maintenance ─────────── - - /// @notice Seed the per-pool observation ring buffer with the initial tick. - function _afterInitialize( - address, - PoolKey calldata key, - uint160, - int24 tick - ) internal override returns (bytes4) { - PoolId poolId = key.toId(); - observations[poolId][0] = Observation({ - blockTimestamp: uint32(block.timestamp), - tickCumulative: 0, - initialized: true - }); - obsIndex[poolId] = 0; - obsCardinality[poolId] = 1; - // tick is unused here; the next observation will accrue tick * delta. - tick; + function _afterInitialize(address, PoolKey calldata key, uint160, int24) + internal + override + returns (bytes4) + { + OscillonTwapOracle.seed(twapStates[key.toId()]); return this.afterInitialize.selector; } - /// @notice Record an observation after every swap so the in-hook oracle - /// has fresh data for _readTwap30. Skipped for unregistered pools. function _afterSwap( address, PoolKey calldata key, @@ -369,55 +239,13 @@ contract OscillonHook is BaseHook { PoolId poolId = key.toId(); if (poolConfigs[poolId].registered) { (, int24 currentTick, , ) = poolManager.getSlot0(poolId); - _writeObservation(poolId, currentTick); + OscillonTwapOracle.write(twapStates[poolId], currentTick); } return (this.afterSwap.selector, int128(0)); } - /// @notice Append a new observation to the ring buffer. tickCumulative - /// accrues using the tick *before* this swap (the most recent - /// observation's tick), but for simplicity we just record using - /// the post-swap tick — same approximation Uniswap v3 uses for - /// observations driven by writes (reasonable for short windows). - function _writeObservation(PoolId poolId, int24 currentTick) internal { - uint16 lastIdx = obsIndex[poolId]; - Observation memory last = observations[poolId][lastIdx]; - - if (!last.initialized) { - // Defensive: pool wasn't initialized through this hook; seed now. - observations[poolId][0] = Observation({ - blockTimestamp: uint32(block.timestamp), - tickCumulative: 0, - initialized: true - }); - obsIndex[poolId] = 0; - obsCardinality[poolId] = 1; - return; - } - - uint32 nowTs = uint32(block.timestamp); - if (nowTs == last.blockTimestamp) return; // dedupe within same second - - int56 delta = int56(uint56(nowTs - last.blockTimestamp)); - int56 newCumulative = last.tickCumulative + int56(currentTick) * delta; - - uint16 nextIdx = (lastIdx + 1) % OBS_CARDINALITY; - observations[poolId][nextIdx] = Observation({ - blockTimestamp: nowTs, - tickCumulative: newCumulative, - initialized: true - }); - obsIndex[poolId] = nextIdx; + // ── Swap context & fee ──────────────────────────────────────────────────── - uint16 card = obsCardinality[poolId]; - if (card < OBS_CARDINALITY) obsCardinality[poolId] = card + 1; - } - - // ── Swap context builder ────────────────────────────────────────────────── - - /// @notice Reads oracle for the INPUT token only (directional asymmetry). - /// If USDT is depegging and trader is selling USDC, oracle reads USDC - /// which is fine → no drain penalty. Correct directional detection. function _buildSwapContext( PoolKey calldata key, PoolConfig storage cfg, @@ -426,138 +254,65 @@ contract OscillonHook is BaseHook { bool tokenInIsToken0 = params.zeroForOne; address tokenIn = tokenInIsToken0 ? cfg.token0 : cfg.token1; - if (tokenIn != cfg.token0 && tokenIn != cfg.token1) { - revert UnsupportedToken(tokenIn); - } + if (tokenIn != cfg.token0 && tokenIn != cfg.token1) revert UnsupportedToken(tokenIn); - // [CHANGE 3] Read oracle for tokenIn specifically - // Each token has its own feed — accurate per-direction detection - address feed = tokenInIsToken0 ? cfg.oracle0 : cfg.oracle1; - uint8 dec = tokenInIsToken0 ? cfg.oracle0Decimals : cfg.oracle1Decimals; + TokenOracleConfig memory tokenOracles = tokenInIsToken0 ? cfg.oracles0 : cfg.oracles1; + uint256 twapPrice = OscillonTwapOracle.readTwapOrSpot(poolManager, key, twapStates[key.toId()]); + PriceResult memory price = OscillonPriceEngine.getSellTokenPrice(tokenOracles, twapPrice); - ( - uint256 depegBps, - bool pegBelow, - bool usingFallback - ) = _readDepegWithFallback(key, feed, dec); uint256 swapSize = params.amountSpecified < 0 ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); ctx = SwapContext({ - depegBps: depegBps, - isDrain: pegBelow, - usingFallback: usingFallback, + depegBps: price.depegBps, + isDrain: price.pegBelow, + usingFallback: price.usingFallback, + priceSource: price.source, amountSpecified: params.amountSpecified, swapSize: swapSize, tokenInIsToken0: tokenInIsToken0 }); } - // ── Fee selection ───────────────────────────────────────────────────────── - function _selectFee( PoolId poolId, PoolConfig storage cfg, SwapContext memory ctx, uint128 poolLiquidity ) internal returns (uint24 fee) { - // [CHANGE 2] Restore window check — but restore fee is BASE_FEE for POC - // The 0.3 bps discount is removed until epoch drain tracking is live - // to prevent the drain-then-restore double extraction attack - bool inRestoreWindow = cfg.lastHighDepegAt != 0 && - (block.timestamp - cfg.lastHighDepegAt) <= RESTORE_WINDOW; - - // Healthy pool — no depeg - if (ctx.depegBps < SMALL_DEPEG_BPS) { - // [CHANGE 2] Restore window: devBps must be EXACTLY 0 - // v2 used devBps <= SMALL_DEPEG_BPS (7) which allowed drain at 6 bps - if (inRestoreWindow && ctx.depegBps == 0 && !ctx.isDrain) { - return RESTORE_FEE_PIPS; // currently same as BASE — safe - } - return BASE_FEE_PIPS; + bool inRestoreWindow = cfg.lastHighDepegAt != 0 + && (block.timestamp - cfg.lastHighDepegAt) <= C.RESTORE_WINDOW; + + if (ctx.depegBps < C.SMALL_DEPEG_BPS) { + if (inRestoreWindow && ctx.depegBps == 0 && !ctx.isDrain) return C.RESTORE_FEE_PIPS; + return C.BASE_FEE_PIPS; } - // Active depeg — update timestamp cfg.lastHighDepegAt = block.timestamp; + if (!ctx.isDrain) return C.BASE_FEE_PIPS; - // Restore direction during depeg → base fee (no discount for POC) - if (!ctx.isDrain) return BASE_FEE_PIPS; - - // ── Drain direction — quadratic fee ────────────────────────────────── - // - // fee_bps = 1 + (K × devBps²) / 10000 - // - // K = 60 for thin pools (<$500k liquidity) — more aggressive protection - // K = 45 for standard pools - // - // Outputs (K=45): - // 5 bps dev → 1.11 bps fee - // 10 bps dev → 1.45 bps fee - // 15 bps dev → 2.01 bps fee - // 25 bps dev → 3.81 bps fee - // 35 bps dev → 6.51 bps fee - // 50 bps dev → 12.25 bps fee - // - // No cliffs → no threshold front-run attack possible - - uint256 k = uint256(poolLiquidity) < 500_000e6 ? 60 : 45; - uint256 rawFee = 100 + (k * ctx.depegBps * ctx.depegBps) / 10_000; - - // [CHANGE 10] Reduce fee sensitivity during TWAP fallback - // TWAP lags 30 min — a real 10 bps depeg may look like 6 bps on TWAP - // Halve the fee increase when below 15 bps and on fallback - if (ctx.usingFallback && ctx.depegBps < 15) { - uint256 increase = rawFee - 100; - rawFee = 100 + (increase / 2); - } - - // Rolling drain multiplier — defeats fragmentation and slow TWAP poisoning + uint256 k = OscillonFeePolicy.kForLiquidity(poolLiquidity); + uint256 feeBps = OscillonFeePolicy.hybridFeeBps(ctx.depegBps, k); uint256 mult = _rollingMultiplier(poolId, ctx.swapSize, true); - rawFee = rawFee.mulDivDown(mult, 100); + fee = OscillonFeePolicy.applyDrainAdjustments(feeBps, ctx.usingFallback, ctx.depegBps, mult); - fee = uint24(rawFee > MAX_FEE_PIPS ? MAX_FEE_PIPS : rawFee); - - // [CHANGE 6] Split surplus: 85% to LPs, 15% to protocol - // mulDivDown → always rounds against protocol (safe direction) - if (fee > BASE_FEE_PIPS) { + if (fee > C.BASE_FEE_PIPS) { uint256 surplusBps = uint256(fee / 100) - 1; uint256 surplusAmount = ctx.swapSize.mulDivDown(surplusBps, 10_000); - uint256 protocolCut = surplusAmount.mulDivDown( - PROTOCOL_FEE_BPS, - 100 - ); - uint256 lpShare = surplusAmount - protocolCut; - - cfg.surplusAccrued += lpShare; + uint256 protocolCut = surplusAmount.mulDivDown(C.PROTOCOL_FEE_BPS, 100); + cfg.surplusAccrued += surplusAmount - protocolCut; cfg.protocolAccrued += protocolCut; } return fee; } - // ── Rolling drain multiplier ────────────────────────────────────────────── - - /// @notice Tracks cumulative drain flow per pool per rolling window. - /// - /// Defeats two adversarial attacks: - /// 1. Slow TWAP poisoning: 30-min campaign of small swaps to move TWAP - /// toward spot, then drain at near-zero fee. Rolling window detects - /// the cumulative drain even when each swap looks innocent. - /// - /// 2. Trade fragmentation: splitting $500k drain into 50×$10k swaps - /// each below the per-swap cap. Rolling window accumulates all of them. - /// - /// By swap 20 of a fragmented drain, multiplier = 1.10 (+10%) - /// By swap 35, multiplier = 1.25 (+25%) - /// By swap 50, multiplier = 1.50 (+50%) - function _rollingMultiplier( - PoolId poolId, - uint256 swapSize, - bool isDrain - ) internal returns (uint256) { - // Reset window if expired - if (block.number > rollingWindowStart[poolId] + ROLLING_BLOCKS) { + function _rollingMultiplier(PoolId poolId, uint256 swapSize, bool isDrain) + internal + returns (uint256) + { + if (block.number > rollingWindowStart[poolId] + C.ROLLING_BLOCKS) { rollingDrain[poolId] = 0; rollingWindowStart[poolId] = block.number; } @@ -567,219 +322,13 @@ contract OscillonHook is BaseHook { uint128 liquidity = poolManager.getLiquidity(poolId); if (liquidity == 0) return 100; - // drainPct in bps (e.g. 300 = 3% of liquidity drained this window) uint256 drainPct = (rollingDrain[poolId] * 10_000) / uint256(liquidity); - - if (drainPct > 300) return 150; // +50% — heavy drain campaign - if (drainPct > 150) return 125; // +25% - if (drainPct > 75) return 110; // +10% - return 100; // no multiplier - } - - // ── Oracle read with fallback ───────────────────────────────────────────── - - /// @notice Chainlink primary with TWAP fallback. - /// - /// [CHANGE 4] Disagreement guard: - /// If |Chainlink - TWAP_30min| > ORACLE_DISAGREE_BPS (20 bps), - /// use the more conservative reading (closer to $1.00). - /// This prevents stale Chainlink from causing false fee spikes. - /// - /// Returns: price1e18, pegBelow (is token below $1), usingFallback - function _readDepegWithFallback( - PoolKey calldata key, - address oracle, - uint8 oracleDecimals - ) - internal - view - returns (uint256 depegBps, bool pegBelow, bool usingFallback) - { - uint256 price1e18; - - try this.readChainlinkPrice(oracle, oracleDecimals) returns ( - uint256 clPrice - ) { - // [CHANGE 4] Disagreement guard - uint256 twapPrice = _readTwap30(key); - uint256 diff = clPrice > twapPrice - ? clPrice - twapPrice - : twapPrice - clPrice; - uint256 diffBps = (diff * 10_000) / 1e18; - - if (diffBps > ORACLE_DISAGREE_BPS) { - // Sources disagree — use the more conservative price - // Conservative = closer to $1.00 = less extreme deviation - uint256 clDev = clPrice > 1e18 - ? clPrice - 1e18 - : 1e18 - clPrice; - uint256 twapDev = twapPrice > 1e18 - ? twapPrice - 1e18 - : 1e18 - twapPrice; - price1e18 = clDev < twapDev ? clPrice : twapPrice; - } else { - price1e18 = clPrice; - } - - usingFallback = false; - } catch { - // Chainlink stale or failed — fall back to TWAP - price1e18 = _readTwap30(key); - usingFallback = true; - } - pegBelow = price1e18 < 1e18; - depegBps = pegBelow - ? ((1e18 - price1e18) * 10_000) / 1e18 - : ((price1e18 - 1e18) * 10_000) / 1e18; - } - - /// @notice Read Chainlink price with full validation. - /// External so it can be called inside try/catch. - function readChainlinkPrice( - address oracle, - uint8 oracleDecimals - ) external view returns (uint256 price1e18) { - ( - uint80 roundId, - int256 answer, - , - uint256 updatedAt, - uint80 answeredInRound - ) = IAggregatorV3Interface(oracle).latestRoundData(); - if (answer <= 0) revert OracleAnswerInvalid(); - if (answeredInRound < roundId) - revert OracleRoundIncomplete(roundId, answeredInRound); - - // [CHANGE 1] Was 2 minutes — now 25 hours to match Chainlink heartbeat - if (block.timestamp > updatedAt + MAX_ORACLE_AGE) { - revert OracleStale(updatedAt, block.timestamp); - } - - return (uint256(answer) * 1e18) / (10 ** uint256(oracleDecimals)); + return OscillonFeePolicy.rollingMultiplier(drainPct); } - /// @notice 30-minute TWAP computed from the in-hook observation ring - /// buffer (v4 has no native pool-manager observe). - /// - /// Strategy: - /// 1. Read current tick from PoolManager.getSlot0. - /// 2. Synthesize "now" tickCumulative = lastObs.tickCumulative - /// + tick * (now - lastObs.timestamp). - /// 3. Find tickCumulative at (now - TWAP_WINDOW) by interpolating - /// between the two observations bracketing that target timestamp. - /// 4. avgTick = (cumNow - cumThen) / TWAP_WINDOW; convert via TickMath. - /// - /// If insufficient history exists (oldest observation is younger than the - /// requested window), we fall back to the current spot price. That keeps - /// the disagreement guard meaningful (Chainlink vs spot) without falsely - /// claiming a TWAP. - function _readTwap30( - PoolKey calldata key - ) internal view returns (uint256 price1e18) { - PoolId poolId = key.toId(); - (uint160 sqrtPriceX96Now, int24 currentTick, , ) = poolManager.getSlot0( - poolId - ); + // ── Views ───────────────────────────────────────────────────────────────── - uint16 card = obsCardinality[poolId]; - uint16 newestIdx = obsIndex[poolId]; - - // Need at least one prior observation and enough span to read. - if (card == 0) { - return _priceFromSqrtX96(sqrtPriceX96Now); - } - - Observation memory newest = observations[poolId][newestIdx]; - uint32 nowTs = uint32(block.timestamp); - - // Synthesize cumulative at "now" using the latest tick. - int56 cumNow = newest.tickCumulative + - int56(currentTick) * - int56(uint56(nowTs - newest.blockTimestamp)); - - // Oldest observation in the ring. - uint16 oldestIdx = card < OBS_CARDINALITY - ? 0 - : (newestIdx + 1) % OBS_CARDINALITY; - Observation memory oldest = observations[poolId][oldestIdx]; - - // If we don't have TWAP_WINDOW seconds of history yet, fall back to - // spot. This is conservative and avoids returning a misleading TWAP. - if (nowTs - oldest.blockTimestamp < TWAP_WINDOW) { - return _priceFromSqrtX96(sqrtPriceX96Now); - } - - uint32 target = nowTs - TWAP_WINDOW; - int56 cumAtTarget = _observeAt(poolId, target, card, newestIdx); - - int56 tickDelta = cumNow - cumAtTarget; - int24 avgTick = int24(tickDelta / int56(uint56(TWAP_WINDOW))); - if (tickDelta < 0 && (tickDelta % int56(uint56(TWAP_WINDOW)) != 0)) { - avgTick--; - } - - uint160 sqrtPriceX96 = TickMath.getSqrtPriceAtTick(avgTick); - return _priceFromSqrtX96(sqrtPriceX96); - } - - /// @notice Find the tickCumulative at `target` (a past timestamp) by - /// locating the two observations bracketing it and linearly - /// interpolating. Caller guarantees target >= oldest.timestamp. - function _observeAt( - PoolId poolId, - uint32 target, - uint16 card, - uint16 newestIdx - ) internal view returns (int56 cumAtTarget) { - // Walk from oldest to newest. Buffers are tiny (≤144) so a linear - // scan is cheap and avoids the corner cases of ring-buffer binary - // search; if cardinality grows materially this can be swapped for - // the v3-style search. - uint16 startIdx = card < OBS_CARDINALITY - ? 0 - : (newestIdx + 1) % OBS_CARDINALITY; - - Observation memory before = observations[poolId][startIdx]; - Observation memory atOrAfter = before; - - for (uint16 step = 1; step < card; step++) { - uint16 idx = (startIdx + step) % OBS_CARDINALITY; - atOrAfter = observations[poolId][idx]; - if (atOrAfter.blockTimestamp >= target) break; - before = atOrAfter; - } - - if (atOrAfter.blockTimestamp == target) { - return atOrAfter.tickCumulative; - } - if (before.blockTimestamp == atOrAfter.blockTimestamp) { - // Only one observation matched; can't interpolate. - return before.tickCumulative; - } - - // Linear interpolation between `before` and `atOrAfter`. - uint32 span = atOrAfter.blockTimestamp - before.blockTimestamp; - uint32 elapsed = target - before.blockTimestamp; - int56 cumDelta = atOrAfter.tickCumulative - before.tickCumulative; - cumAtTarget = - before.tickCumulative + - (cumDelta * int56(uint56(elapsed))) / - int56(uint56(span)); - } - - /// @notice price1e18 = (sqrtPriceX96)² / 2^192, scaled to 1e18. - function _priceFromSqrtX96( - uint160 sqrtPriceX96 - ) internal pure returns (uint256) { - uint256 ratioX192 = uint256(sqrtPriceX96) * uint256(sqrtPriceX96); - return FullMath.mulDiv(ratioX192, 1e18, 1 << 192); - } - - // ── View helpers ────────────────────────────────────────────────────────── - - function getPoolState( - PoolKey calldata key - ) + function getPoolState(PoolKey calldata key) external view returns ( @@ -796,76 +345,53 @@ contract OscillonHook is BaseHook { registered = cfg.registered; surplusAccrued = cfg.surplusAccrued; protocolAccrued = cfg.protocolAccrued; - inRestoreWindow = - cfg.lastHighDepegAt != 0 && - (block.timestamp - cfg.lastHighDepegAt) <= RESTORE_WINDOW; + inRestoreWindow = cfg.lastHighDepegAt != 0 + && (block.timestamp - cfg.lastHighDepegAt) <= C.RESTORE_WINDOW; if (!cfg.registered) return (false, 0, false, false, 0, 0, false); - (depegBps, pegBelow, usingFallback) = _readDepegWithFallback( - key, - cfg.oracle0, - cfg.oracle0Decimals - ); + uint256 twapPrice = OscillonTwapOracle.readTwapOrSpot(poolManager, key, twapStates[key.toId()]); + PriceResult memory price = OscillonPriceEngine.getSellTokenPrice(cfg.oracles0, twapPrice); + depegBps = price.depegBps; + pegBelow = price.pegBelow; + usingFallback = price.usingFallback; } - function getPoolConfig( - PoolKey calldata key - ) external view returns (PoolConfig memory) { + function getPoolConfig(PoolKey calldata key) external view returns (PoolConfig memory) { return poolConfigs[key.toId()]; } - // ── Protocol fee collection ─────────────────────────────────────────────── + // ── Protocol fees & governance ────────────────────────────────────────── - /// @notice Owner collects accumulated protocol fee share (15% cut). - /// Called weekly or on-demand. Separate from LP surplus. - /// - /// [CHANGE 7] protocolAccrued tracks the 15% cut separately. - /// LP surplus goes to LPs via donate() in a future afterSwap. - /// This function only moves the protocol's portion. - function collectProtocolFees( - PoolKey calldata key, - address token // which token to collect (token0 or token1) - ) external onlyOwner { + function collectProtocolFees(PoolKey calldata key, address token) external onlyOwner { PoolId id = key.toId(); PoolConfig storage cfg = poolConfigs[id]; if (!cfg.registered) revert PoolNotRegistered(); uint256 amount = cfg.protocolAccrued; if (amount == 0) return; - cfg.protocolAccrued = 0; - // Transfer to treasury — mulDivDown already applied at accrual time - bool ok = IERC20(token).transfer(protocolTreasury, amount); - if (!ok) revert TransferFailed(); - + if (!IERC20(token).transfer(protocolTreasury, amount)) revert TransferFailed(); emit ProtocolFeesCollected(id, amount); } - // ── Governance ──────────────────────────────────────────────────────────── - - /// @notice Approve a Chainlink feed address for use in pool registration. - /// Only approved feeds can be used. This is the trust boundary. - /// - /// [CHANGE 8] Before registering feeds: - /// - Verify the feed is live on your target chain - /// - Check heartbeat period matches MAX_ORACLE_AGE tolerance - /// - Verify decimals() returns expected value (usually 8) - function approveOracle(address feed) external onlyOwner { - if (feed == address(0)) revert ZeroAddress(); - approvedOracles[feed] = true; - emit OracleApproved(feed); + function approveAdapter(address adapter) external onlyOwner { + if (adapter == address(0)) revert ZeroAddress(); + try IOscillonOracle(adapter).isHealthy() returns (bool) { + approvedAdapters[adapter] = true; + emit AdapterApproved(adapter); + } catch { + approvedAdapters[adapter] = true; + emit AdapterApproved(adapter); + } } - /// @notice Revoke an oracle feed (e.g. if Chainlink deprecates it). - function revokeOracle(address feed) external onlyOwner { - approvedOracles[feed] = false; - emit OracleRevoked(feed); + function revokeAdapter(address adapter) external onlyOwner { + approvedAdapters[adapter] = false; + emit AdapterRevoked(adapter); } - /// @notice Update protocol treasury address. - /// Set to Gnosis Safe before mainnet. function setProtocolTreasury(address newTreasury) external onlyOwner { if (newTreasury == address(0)) revert ZeroAddress(); protocolTreasury = newTreasury; @@ -878,8 +404,6 @@ contract OscillonHook is BaseHook { owner = newOwner; } - // ── Internals ───────────────────────────────────────────────────────────── - function _min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; } diff --git a/src/constants/OscillonConstants.sol b/src/constants/OscillonConstants.sol new file mode 100644 index 0000000..486853b --- /dev/null +++ b/src/constants/OscillonConstants.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +library OscillonConstants { + // Fee (pips = hundredths of a bip; 100 pips = 1 bps) + uint24 internal constant BASE_FEE_PIPS = 100; + uint24 internal constant RESTORE_FEE_PIPS = 100; + uint24 internal constant MAX_FEE_PIPS = 5000; + uint24 internal constant MAX_FEE_BPS = 50; + + // Depeg gates + uint256 internal constant SMALL_DEPEG_BPS = 7; + uint256 internal constant QUADRATIC_DEAD_BAND = 3; + + // Timing + uint256 internal constant RESTORE_WINDOW = 1 hours; + uint256 internal constant MAX_ORACLE_AGE = 25 hours; + uint256 internal constant ORACLE_DISAGREE_BPS = 20; + uint256 internal constant SEQUENCER_GRACE_PERIOD = 3600; + + // Swap caps / rolling window + uint256 internal constant MAX_DEPEG_SWAP_FACTOR = 50_000; + uint32 internal constant ROLLING_BLOCKS = 300; + + // TWAP + uint16 internal constant OBS_CARDINALITY = 144; + uint32 internal constant TWAP_WINDOW = 1800; + + // Revenue split + uint256 internal constant PROTOCOL_FEE_BPS = 15; + uint256 internal constant LP_FEE_BPS = 85; + + // Liquidity tier for K selection + uint256 internal constant THIN_POOL_LIQUIDITY = 500_000e6; + uint256 internal constant K_THIN = 60; + uint256 internal constant K_STANDARD = 45; +} diff --git a/src/errors/OscillonErrors.sol b/src/errors/OscillonErrors.sol new file mode 100644 index 0000000..4b5a7fb --- /dev/null +++ b/src/errors/OscillonErrors.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +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(); +error NotStablePool(); +error OracleNotApproved(address oracle); +error AdapterNotApproved(address adapter); +error InvalidAdapter(address adapter); +error ExactOutputDisabledDuringDepeg(uint256 depegBps); +error SwapCapExceeded(); +error TransferFailed(); +error SequencerDown(); diff --git a/src/governance/OsicllonAdmin.sol b/src/governance/OsicllonAdmin.sol new file mode 100644 index 0000000..e69de29 diff --git a/src/interface/IOscillonHook.sol b/src/interface/IOscillonHook.sol new file mode 100644 index 0000000..e69de29 diff --git a/src/libraries/OracleLib.sol b/src/libraries/OracleLib.sol new file mode 100644 index 0000000..e69de29 diff --git a/src/libraries/OscillonDepegMath b/src/libraries/OscillonDepegMath new file mode 100644 index 0000000..e69de29 diff --git a/src/libraries/OscillonDepegMath.sol b/src/libraries/OscillonDepegMath.sol new file mode 100644 index 0000000..97b8b57 --- /dev/null +++ b/src/libraries/OscillonDepegMath.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {OscillonConstants as C} from "../constants/OscillonConstants.sol"; + +library OscillonDepegMath { + function depegFromPrice(uint256 price1e18) internal pure returns (uint256 depegBps, bool pegBelow) { + pegBelow = price1e18 < 1e18; + depegBps = pegBelow + ? ((1e18 - price1e18) * 10_000) / 1e18 + : ((price1e18 - 1e18) * 10_000) / 1e18; + } + + function deviationFromPeg(uint256 price1e18) internal pure returns (uint256) { + return price1e18 > 1e18 ? price1e18 - 1e18 : 1e18 - price1e18; + } + + function diffBps(uint256 a, uint256 b) internal pure returns (uint256) { + uint256 diff = a > b ? a - b : b - a; + return (diff * 10_000) / 1e18; + } + + /// @notice When sources disagree, pick the price closer to $1.00 (conservative). + function conservativePrice(uint256 priceA, uint256 priceB) internal pure returns (uint256) { + uint256 devA = deviationFromPeg(priceA); + uint256 devB = deviationFromPeg(priceB); + return devA < devB ? priceA : priceB; + } + + function pricesDisagree(uint256 priceA, uint256 priceB) internal pure returns (bool) { + return diffBps(priceA, priceB) > C.ORACLE_DISAGREE_BPS; + } +} diff --git a/src/libraries/OscillonTwapOracle.sol b/src/libraries/OscillonTwapOracle.sol new file mode 100644 index 0000000..cf2afd2 --- /dev/null +++ b/src/libraries/OscillonTwapOracle.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +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 {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; +import {Observation, TwapState} from "../types/OscillonTypes.sol"; +import {OscillonConstants as C} from "../constants/OscillonConstants.sol"; + +library OscillonTwapOracle { + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + + function seed(TwapState storage state) internal { + state.observations[0] = Observation({ + blockTimestamp: uint32(block.timestamp), + tickCumulative: 0, + initialized: true + }); + state.obsIndex = 0; + state.obsCardinality = 1; + } + + function write(TwapState storage state, int24 currentTick) internal { + uint16 lastIdx = state.obsIndex; + Observation memory last = state.observations[lastIdx]; + + if (!last.initialized) { + seed(state); + return; + } + + uint32 nowTs = uint32(block.timestamp); + if (nowTs == last.blockTimestamp) return; + + int56 delta = int56(uint56(nowTs - last.blockTimestamp)); + int56 newCumulative = last.tickCumulative + int56(currentTick) * delta; + + uint16 nextIdx = (lastIdx + 1) % C.OBS_CARDINALITY; + state.observations[nextIdx] = Observation({ + blockTimestamp: nowTs, + tickCumulative: newCumulative, + initialized: true + }); + state.obsIndex = nextIdx; + + uint16 card = state.obsCardinality; + if (card < C.OBS_CARDINALITY) state.obsCardinality = card + 1; + } + + function readTwapOrSpot(IPoolManager poolManager, PoolKey calldata key, TwapState storage state) + internal + view + returns (uint256 price1e18) + { + PoolId poolId = key.toId(); + (uint160 sqrtPriceX96Now, int24 currentTick, , ) = poolManager.getSlot0(poolId); + + uint16 card = state.obsCardinality; + uint16 newestIdx = state.obsIndex; + + if (card == 0) return priceFromSqrtX96(sqrtPriceX96Now); + + Observation memory newest = state.observations[newestIdx]; + uint32 nowTs = uint32(block.timestamp); + + int56 cumNow = newest.tickCumulative + + int56(currentTick) * int56(uint56(nowTs - newest.blockTimestamp)); + + uint16 oldestIdx = card < C.OBS_CARDINALITY ? 0 : (newestIdx + 1) % C.OBS_CARDINALITY; + Observation memory oldest = state.observations[oldestIdx]; + + if (nowTs - oldest.blockTimestamp < C.TWAP_WINDOW) { + return priceFromSqrtX96(sqrtPriceX96Now); + } + + uint32 target = nowTs - C.TWAP_WINDOW; + int56 cumAtTarget = observeAt(state, target, card, newestIdx); + + int56 tickDelta = cumNow - cumAtTarget; + int24 avgTick = int24(tickDelta / int56(uint56(C.TWAP_WINDOW))); + if (tickDelta < 0 && (tickDelta % int56(uint56(C.TWAP_WINDOW)) != 0)) { + avgTick--; + } + + return priceFromSqrtX96(TickMath.getSqrtPriceAtTick(avgTick)); + } + + function observeAt(TwapState storage state, uint32 target, uint16 card, uint16 newestIdx) + internal + view + returns (int56 cumAtTarget) + { + uint16 startIdx = card < C.OBS_CARDINALITY ? 0 : (newestIdx + 1) % C.OBS_CARDINALITY; + + Observation memory before = state.observations[startIdx]; + Observation memory atOrAfter = before; + + for (uint16 step = 1; step < card; step++) { + uint16 idx = (startIdx + step) % C.OBS_CARDINALITY; + atOrAfter = state.observations[idx]; + if (atOrAfter.blockTimestamp >= target) break; + before = atOrAfter; + } + + if (atOrAfter.blockTimestamp == target) return atOrAfter.tickCumulative; + if (before.blockTimestamp == atOrAfter.blockTimestamp) return before.tickCumulative; + + uint32 span = atOrAfter.blockTimestamp - before.blockTimestamp; + uint32 elapsed = target - before.blockTimestamp; + int56 cumDelta = atOrAfter.tickCumulative - before.tickCumulative; + cumAtTarget = before.tickCumulative + (cumDelta * int56(uint56(elapsed))) / int56(uint56(span)); + } + + function priceFromSqrtX96(uint160 sqrtPriceX96) internal pure returns (uint256) { + uint256 ratioX192 = uint256(sqrtPriceX96) * uint256(sqrtPriceX96); + return FullMath.mulDiv(ratioX192, 1e18, 1 << 192); + } +} diff --git a/src/libraries/OscillonfeePolicy.sol b/src/libraries/OscillonfeePolicy.sol new file mode 100644 index 0000000..fd677f7 --- /dev/null +++ b/src/libraries/OscillonfeePolicy.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {OscillonConstants as C} from "../constants/OscillonConstants.sol"; + +/// @title OscillonFeePolicy +/// @notice Hybrid piecewise + quadratic drain fee model (returns bps, hook converts to pips). +library OscillonFeePolicy { + function kForLiquidity(uint128 poolLiquidity) internal pure returns (uint256) { + return uint256(poolLiquidity) < C.THIN_POOL_LIQUIDITY ? C.K_THIN : C.K_STANDARD; + } + + function hybridFeeBps(uint256 devBps, uint256 k) internal pure returns (uint256) { + if (devBps == 0) return 1; + + uint256 pw = piecewiseFeeBps(devBps); + + uint256 d = devBps > C.QUADRATIC_DEAD_BAND ? devBps - C.QUADRATIC_DEAD_BAND : 0; + uint256 quad = 1 + (k * d * d) / 10_000; + if (quad > C.MAX_FEE_BPS) quad = C.MAX_FEE_BPS; + + return pw > quad ? pw : quad; + } + + function piecewiseFeeBps(uint256 devBps) internal pure returns (uint256) { + if (devBps <= C.QUADRATIC_DEAD_BAND) return 1; + + if (devBps <= 20) { + uint256 excess = devBps - C.QUADRATIC_DEAD_BAND; + uint256 feeX10000 = 10_000 + 204 * excess * excess; + return feeX10000 / 10_000; + } + + uint256 feeAt20X10000 = 10_000 + 204 * 17 * 17; + uint256 linearX10000 = 11 * (devBps - 20) * 100; + uint256 totalX10000 = feeAt20X10000 + linearX10000; + uint256 feeBps = totalX10000 / 10_000; + return feeBps > C.MAX_FEE_BPS ? C.MAX_FEE_BPS : feeBps; + } + + /// @notice Apply TWAP-fallback dampening then rolling multiplier; input/output in pips. + function applyDrainAdjustments( + uint256 feeBps, + bool usingFallback, + uint256 depegBps, + uint256 rollingMult + ) internal pure returns (uint24 feePips) { + uint256 adjusted = feeBps; + + if (usingFallback && depegBps < 15) { + uint256 increase = adjusted > 1 ? adjusted - 1 : 0; + adjusted = 1 + (increase / 2); + } + + adjusted = (adjusted * rollingMult) / 100; + uint256 pips = adjusted * 100; + feePips = uint24(pips > C.MAX_FEE_PIPS ? C.MAX_FEE_PIPS : pips); + } + + function rollingMultiplier(uint256 drainPctBps) internal pure returns (uint256) { + if (drainPctBps > 300) return 150; + if (drainPctBps > 150) return 125; + if (drainPctBps > 75) return 110; + return 100; + } +} diff --git a/src/oracle/IChainlinkSequencer.sol b/src/oracle/IChainlinkSequencer.sol new file mode 100644 index 0000000..f5268d0 --- /dev/null +++ b/src/oracle/IChainlinkSequencer.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IChainlinkSequencer { + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); +} diff --git a/src/oracle/IOscillonOracle.sol b/src/oracle/IOscillonOracle.sol new file mode 100644 index 0000000..5f15dd9 --- /dev/null +++ b/src/oracle/IOscillonOracle.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @title IOscillonOracle +/// @notice Common adapter interface for all price sources (Chainlink today; Pyth/others later). +interface IOscillonOracle { + /// @return price1e18 e.g. $0.9993 = 999300000000000000 + /// @return confidence Uncertainty in bps (0 = fully trusted / unavailable) + function getPrice() external view returns (uint256 price1e18, uint256 confidence); + + /// @return true when the source can return a valid price right now + function isHealthy() external view returns (bool); +} diff --git a/src/oracle/OscillonPriceEngine.sol b/src/oracle/OscillonPriceEngine.sol new file mode 100644 index 0000000..6bd1387 --- /dev/null +++ b/src/oracle/OscillonPriceEngine.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IOscillonOracle} from "./IOscillonOracle.sol"; +import {TokenOracleConfig, PriceResult} from "../types/OscillonTypes.sol"; +import {OscillonDepegMath} from "../libraries/OscillonDepegMath.sol"; + +/// @title OscillonPriceEngine +/// @notice Chainlink primary with in-pool TWAP fallback and disagreement guard. +/// @dev Phase 1: Chainlink + TWAP. New IOscillonOracle adapters can extend the cascade later. +library OscillonPriceEngine { + uint8 internal constant SOURCE_CHAINLINK = 1; + uint8 internal constant SOURCE_TWAP = 2; + + function getSellTokenPrice(TokenOracleConfig memory cfg, uint256 twapPrice1e18) + internal + view + returns (PriceResult memory result) + { + if (cfg.chainlinkAdapter != address(0)) { + (bool ok, PriceResult memory fromCl) = _tryChainlink(cfg.chainlinkAdapter, twapPrice1e18); + if (ok) return fromCl; + } + + return _fromTwap(twapPrice1e18); + } + + function _tryChainlink(address chainlinkAdapter, uint256 twapPrice1e18) + internal + view + returns (bool ok, PriceResult memory result) + { + try IOscillonOracle(chainlinkAdapter).getPrice() returns (uint256 clPrice, uint256) { + uint256 finalPrice = OscillonDepegMath.pricesDisagree(clPrice, twapPrice1e18) + ? OscillonDepegMath.conservativePrice(clPrice, twapPrice1e18) + : clPrice; + + uint8 source = finalPrice == twapPrice1e18 && finalPrice != clPrice + ? SOURCE_TWAP + : SOURCE_CHAINLINK; + + return (true, _pack(finalPrice, source, false)); + } catch { + return (false, result); + } + } + + function _fromTwap(uint256 twapPrice1e18) internal pure returns (PriceResult memory result) { + return _pack(twapPrice1e18, SOURCE_TWAP, true); + } + + function _pack(uint256 price1e18, uint8 source, bool usingFallback) + internal + pure + returns (PriceResult memory result) + { + (uint256 depegBps, bool pegBelow) = OscillonDepegMath.depegFromPrice(price1e18); + result = PriceResult({ + price1e18: price1e18, + depegBps: depegBps, + pegBelow: pegBelow, + usingFallback: usingFallback, + source: source + }); + } +} diff --git a/src/oracle/adapters/ChainlinkOracleAdapter.sol b/src/oracle/adapters/ChainlinkOracleAdapter.sol new file mode 100644 index 0000000..b3dd323 --- /dev/null +++ b/src/oracle/adapters/ChainlinkOracleAdapter.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {IOscillonOracle} from "../IOscillonOracle.sol"; +import {IChainlinkSequencer} from "../IChainlinkSequencer.sol"; +import {IAggregatorV3Interface} from "../../interface/IAggregatorV3Interface.sol"; +import { + OracleAnswerInvalid, + OracleStale, + OracleRoundIncomplete, + SequencerDown +} from "../../errors/OscillonErrors.sol"; +import {OscillonConstants as C} from "../../constants/OscillonConstants.sol"; + +/// @title ChainlinkOracleAdapter +/// @notice IOscillonOracle wrapper for Chainlink USD feeds with optional L2 sequencer check. +contract ChainlinkOracleAdapter is IOscillonOracle { + IAggregatorV3Interface public immutable feed; + IChainlinkSequencer public immutable sequencer; + uint8 public immutable feedDecimals; + uint256 public immutable maxAge; + + constructor(address _feed, address _sequencer, uint256 _maxAge) { + feed = IAggregatorV3Interface(_feed); + sequencer = IChainlinkSequencer(_sequencer); + feedDecimals = feed.decimals(); + maxAge = _maxAge == 0 ? C.MAX_ORACLE_AGE : _maxAge; + } + + function getPrice() external view override returns (uint256 price1e18, uint256 confidence) { + _assertSequencerUp(); + price1e18 = _readFeed(); + confidence = 0; + } + + function isHealthy() external view override returns (bool) { + try this.getPrice() returns (uint256, uint256) { + return true; + } catch { + return false; + } + } + + function _assertSequencerUp() internal view { + if (address(sequencer) == address(0)) return; + + (, int256 seqAnswer, uint256 startedAt, , ) = sequencer.latestRoundData(); + if (seqAnswer == 1) revert SequencerDown(); + if (block.timestamp - startedAt < C.SEQUENCER_GRACE_PERIOD) revert SequencerDown(); + } + + function _readFeed() internal view returns (uint256 price1e18) { + ( + uint80 roundId, + int256 answer, + , + uint256 updatedAt, + uint80 answeredInRound + ) = feed.latestRoundData(); + + if (answer <= 0) revert OracleAnswerInvalid(); + if (answeredInRound < roundId) revert OracleRoundIncomplete(roundId, answeredInRound); + if (block.timestamp > updatedAt + maxAge) revert OracleStale(updatedAt, block.timestamp); + + return (uint256(answer) * 1e18) / (10 ** uint256(feedDecimals)); + } +} diff --git a/src/types/OscillonTypes.sol b/src/types/OscillonTypes.sol new file mode 100644 index 0000000..adeb165 --- /dev/null +++ b/src/types/OscillonTypes.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol"; + +/// @notice Per-token oracle config. TWAP fallback is always pool-level in the hook. +/// @dev Additional providers (e.g. Pyth) plug in via IOscillonOracle + PriceEngine updates. +struct TokenOracleConfig { + address chainlinkAdapter; +} + +struct PoolConfig { + bool registered; + address token0; + address token1; + TokenOracleConfig oracles0; + TokenOracleConfig oracles1; + uint256 maxDepegSwap0; + uint256 maxDepegSwap1; + uint256 lastHighDepegAt; + uint256 surplusAccrued; + uint256 protocolAccrued; +} + +struct Observation { + uint32 blockTimestamp; + int56 tickCumulative; + bool initialized; +} + +struct TwapState { + mapping(uint16 => Observation) observations; + uint16 obsIndex; + uint16 obsCardinality; +} + +struct SwapContext { + uint256 depegBps; + bool isDrain; + bool usingFallback; + uint8 priceSource; + int256 amountSpecified; + uint256 swapSize; + bool tokenInIsToken0; +} + +/// @notice Oracle source for the final price (1=Chainlink, 2=TWAP) +struct PriceResult { + uint256 price1e18; + uint256 depegBps; + bool pegBelow; + bool usingFallback; + uint8 source; +} diff --git a/test/OscillonFeePolicy.t.sol b/test/OscillonFeePolicy.t.sol new file mode 100644 index 0000000..31f6195 --- /dev/null +++ b/test/OscillonFeePolicy.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {OscillonFeePolicy} from "../src/libraries/OscillonFeePolicy.sol"; + +contract OscillonFeePolicyTest is Test { + function test_hybridFee_at20Bps_usesPiecewise() public pure { + uint256 fee = OscillonFeePolicy.hybridFeeBps(20, 45); + assertEq(fee, 6); + } + + function test_hybridFee_zeroDev_returnsOneBps() public pure { + assertEq(OscillonFeePolicy.hybridFeeBps(0, 45), 1); + } + + function test_piecewise_zone1_deadBand() public pure { + assertEq(OscillonFeePolicy.piecewiseFeeBps(3), 1); + assertEq(OscillonFeePolicy.piecewiseFeeBps(2), 1); + } + + function test_piecewise_cappedAt50() public pure { + assertEq(OscillonFeePolicy.piecewiseFeeBps(10_000), 50); + } +} diff --git a/test/OscillonHook.t.sol b/test/OscillonHook.t.sol index 16f0efb..fd3767d 100644 --- a/test/OscillonHook.t.sol +++ b/test/OscillonHook.t.sol @@ -27,6 +27,7 @@ import {TickMath} from "v4-core/libraries/TickMath.sol"; import {MockV3Aggregator} from "./mock/MockV3Aggregator.sol"; import {OscillonHook} from "../src/OscillonHook.sol"; +import {ChainlinkOracleAdapter} from "../src/oracle/adapters/ChainlinkOracleAdapter.sol"; contract OscillonHookTwapTest is Test, Deployers { using PoolIdLibrary for PoolKey; @@ -38,6 +39,8 @@ contract OscillonHookTwapTest is Test, Deployers { MockERC20 stable1; MockV3Aggregator oracle0; MockV3Aggregator oracle1; + ChainlinkOracleAdapter adapter0; + ChainlinkOracleAdapter adapter1; OscillonHook hook; PoolKey poolKey; bytes32 poolId; // raw PoolId (bytes32) to avoid cross-package type issues @@ -58,6 +61,8 @@ contract OscillonHookTwapTest is Test, Deployers { oracle0 = new MockV3Aggregator(18, int256(1e18)); oracle1 = new MockV3Aggregator(18, int256(1e18)); + adapter0 = new ChainlinkOracleAdapter(address(oracle0), address(0), 25 hours); + adapter1 = new ChainlinkOracleAdapter(address(oracle1), address(0), 25 hours); // v3.0 permissions: beforeSwap + afterInitialize + afterSwap. uint160 flags = uint160( @@ -68,9 +73,8 @@ contract OscillonHookTwapTest is Test, Deployers { deployCodeTo("OscillonHook", abi.encode(manager), address(flags)); hook = OscillonHook(payable(address(flags))); - // Approve oracles (required by v3 oracle registry). - hook.approveOracle(address(oracle0)); - hook.approveOracle(address(oracle1)); + hook.approveAdapter(address(adapter0)); + hook.approveAdapter(address(adapter1)); // Order currencies; init pool with tickSpacing=1 (stable-only guard). Currency c0 = Currency.wrap(address(stable0)); @@ -94,11 +98,11 @@ contract OscillonHookTwapTest is Test, Deployers { ); address oForC0 = Currency.unwrap(poolKey.currency0) == address(stable0) - ? address(oracle0) - : address(oracle1); + ? address(adapter0) + : address(adapter1); address oForC1 = Currency.unwrap(poolKey.currency0) == address(stable0) - ? address(oracle1) - : address(oracle0); + ? address(adapter1) + : address(adapter0); // Low-level call: PoolKey type differs across the v4-core / @uniswap/v4-core remap. (bool ok, ) = address(hook).call( @@ -286,19 +290,13 @@ contract OscillonHookTwapTest is Test, Deployers { // Depeg detection + dynamic-fee selection // ───────────────────────────────────────────────────────────────────────────── // -// Fee model (v3.0): +// Fee model (hybrid piecewise + quadratic): // 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 +// drain-direction: max(piecewise, quadratic with 3bps dead band), then pips = bps * 100 // -// 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 +// With K=45: +// 20 bps depeg → hybrid fee = 6 bps = 600 pips // // 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 @@ -320,13 +318,15 @@ contract OscillonHookDepegFeeTest is Test, Deployers { ); uint24 constant BASE_FEE = 100; - uint24 constant FEE_AT_20_BPS = 101; + uint24 constant FEE_AT_20_BPS = 600; uint256 constant AMOUNT_IN = 1e15; MockERC20 stable0; MockERC20 stable1; MockV3Aggregator oracle0; MockV3Aggregator oracle1; // always represents the "input" oracle in our tests + ChainlinkOracleAdapter adapter0; + ChainlinkOracleAdapter adapter1; OscillonHook hook; PoolKey poolKey; bytes32 poolId; @@ -349,6 +349,8 @@ contract OscillonHookDepegFeeTest is Test, Deployers { oracle0 = new MockV3Aggregator(18, int256(1e18)); oracle1 = new MockV3Aggregator(18, int256(1e18)); + adapter0 = new ChainlinkOracleAdapter(address(oracle0), address(0), 25 hours); + adapter1 = new ChainlinkOracleAdapter(address(oracle1), address(0), 25 hours); uint160 flags = uint160( Hooks.BEFORE_SWAP_FLAG | @@ -358,8 +360,8 @@ contract OscillonHookDepegFeeTest is Test, Deployers { deployCodeTo("OscillonHook", abi.encode(manager), address(flags)); hook = OscillonHook(payable(address(flags))); - hook.approveOracle(address(oracle0)); - hook.approveOracle(address(oracle1)); + hook.approveAdapter(address(adapter0)); + hook.approveAdapter(address(adapter1)); Currency c0 = Currency.wrap(address(stable0)); Currency c1 = Currency.wrap(address(stable1)); @@ -384,12 +386,8 @@ contract OscillonHookDepegFeeTest is Test, Deployers { // 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); + address oForC0 = stable0IsCurrency0 ? address(adapter0) : address(adapter1); + address oForC1 = stable0IsCurrency0 ? address(adapter1) : address(adapter0); sellStable1ZeroForOne = !stable0IsCurrency0; // sell stable1 → input is currency1 unless stable1 sorts first (bool ok, ) = address(hook).call( @@ -438,9 +436,9 @@ contract OscillonHookDepegFeeTest is Test, Deployers { _swap(int256(-int256(AMOUNT_IN))); } - // ── Drain depeg at 20 bps → quadratic fee = 101 ────────────────────────── + // ── Drain depeg at 20 bps → hybrid fee = 600 pips (6 bps) ──────────────── - function test_swap_Drain20bps_AppliesQuadraticFee() public { + function test_swap_Drain20bps_AppliesHybridFee() 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)); From 7a1c6397c5bd6563456ffcc30bb721ca28dc0932 Mon Sep 17 00:00:00 2001 From: sammed-21 Date: Wed, 10 Jun 2026 19:21:02 +0530 Subject: [PATCH 2/2] deploy contract locally in the sandbox and test the voting contract --- .gas-snapshot | 17 +- .gitignore | 3 + README.md | 14 ++ foundry.toml | 6 +- oscillon-ui/src/deployment.config.ts | 265 +++++++++++++++++++++++ oscillon-ui/src/deployment.json | 26 +++ script/DeployLocalAnvil.s.sol | 193 +++++++++++++++++ script/DeployOscillon.s.sol | 308 +++++++++++++++++++++++++++ script/DeployOscillonHook.s.sol | 113 ++++++++-- src/OscillonHook.sol | 208 ++++++++++++------ src/constants/OscillonConstants.sol | 8 +- src/libraries/OscillonfeePolicy.sol | 17 +- test/OscillonHook.t.sol | 17 +- 13 files changed, 1090 insertions(+), 105 deletions(-) create mode 100644 oscillon-ui/src/deployment.config.ts create mode 100644 oscillon-ui/src/deployment.json create mode 100644 script/DeployLocalAnvil.s.sol create mode 100644 script/DeployOscillon.s.sol diff --git a/.gas-snapshot b/.gas-snapshot index e9c442e..1a16e17 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1 +1,16 @@ -OscillonHookBasicTest:test_swap_WhenStableDropsTo089_UsesMaxFee() (gas: 278293) \ No newline at end of file +OscillonFeePolicyTest:test_hybridFee_at20Bps_usesPiecewise() (gas: 1297) +OscillonFeePolicyTest:test_hybridFee_zeroDev_returnsOneBps() (gas: 343) +OscillonFeePolicyTest:test_piecewise_cappedAt50() (gas: 821) +OscillonFeePolicyTest:test_piecewise_zone1_deadBand() (gas: 423) +OscillonHookDepegFeeTest:test_chainlinkStale_FallsBackToTWAP() (gas: 224993) +OscillonHookDepegFeeTest:test_disagreementGuard_LargeMismatch_UsesConservative() (gas: 196146) +OscillonHookDepegFeeTest:test_exactOutput_RevertsDuringDepeg() (gas: 148129) +OscillonHookDepegFeeTest:test_swap_Drain20bps_AppliesHybridFee() (gas: 288519) +OscillonHookDepegFeeTest:test_swap_NoDepeg_AppliesBaseFee() (gas: 189905) +OscillonHookDepegFeeTest:test_swap_RestoreDirection_AppliesBaseFee() (gas: 215703) +OscillonHookDepegFeeTest:test_swap_SmallDepegBelowThreshold_AppliesBaseFee() (gas: 195743) +OscillonHookTwapTest:test_afterInitialize_seedsObservationZero() (gas: 16355) +OscillonHookTwapTest:test_afterSwap_appendsObservation() (gas: 243397) +OscillonHookTwapTest:test_afterSwap_dedupesSameSecondWrites() (gas: 322251) +OscillonHookTwapTest:test_observation_buildsHistoryOver30Min() (gas: 6568905) +OscillonHookTwapTest:test_observation_ringBufferWrapsAtCardinality() (gas: 16291403) \ No newline at end of file diff --git a/.gitignore b/.gitignore index 85198aa..afe2285 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ docs/ # Dotenv file .env + +# Root deploy artifact (oscillon-ui/src/deployment.json is updated in-place by forge) +/deployment.json diff --git a/README.md b/README.md index 9b327a1..9fce6a8 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,20 @@ forge fmt forge snapshot ``` +## Known Limitations (Pre-Audit) + +**Surplus accounting (indicative only)** + +`surplusAccrued` and `protocolAccrued` track theoretical LP surplus from dynamic fees but are not connected to actual v4 fee settlement. These values are meaningful as indicators of mechanism activity but `collectProtocolFees` is not safe for production use without implementing proper fee skimming via `donate()` or `afterSwapReturnDelta`. This is a known P0 for mainnet — deferred for POC submission. + +**Rounding direction** + +Fee surplus math uses `mulDivDown` throughout. This is conservative (slightly under-charges). A production deployment would implement `mulDivUp` on surplus accrual per the rounding policy documented in the audit report. + +**K parameter liquidity threshold** + +`THIN_POOL_LIQUIDITY` comparison uses incorrect units (USDC atoms vs AMM liquidity units). Fixed by using `K_STANDARD=45` universally in this version. + ## MVP Limitations - Static thresholds and fee tiers (not governance-tunable yet) diff --git a/foundry.toml b/foundry.toml index a577eaf..34f4e7d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,7 +7,11 @@ solc_version = '0.8.26' evm_versoin = 'cancun' optimizer_runs = 800 via_ir = false - + +fs_permissions = [ + { access = "read-write", path = "./deployment.json" }, + { access = "read-write", path = "./oscillon-ui/src/deployment.json" }, +] [profile.ci] ffi = false diff --git a/oscillon-ui/src/deployment.config.ts b/oscillon-ui/src/deployment.config.ts new file mode 100644 index 0000000..c9962b2 --- /dev/null +++ b/oscillon-ui/src/deployment.config.ts @@ -0,0 +1,265 @@ +/** + * Single source of truth for contract addresses in the frontend. + * `deployment.json` is written automatically by `script/DeployOscillon.s.sol` + * as a chain-keyed registry: `{ "31337": { ... }, "421614": { ... } }`. + */ + +import deploymentJson from './deployment.json' + +export interface Deployment { + chainId: number + chainName: string + deployedAt: string + deployer: `0x${string}` + poolManager: `0x${string}` + swapRouter: `0x${string}` + liquidityRouter: `0x${string}` + oscillonHook: `0x${string}` + usdc: `0x${string}` + usdt: `0x${string}` + chainlinkFeed0: `0x${string}` + chainlinkFeed1: `0x${string}` + chainlinkAdapter0: `0x${string}` + chainlinkAdapter1: `0x${string}` + oracle0: `0x${string}` + oracle1: `0x${string}` + poolCurrency0: `0x${string}` + poolCurrency1: `0x${string}` + poolFee: number + poolTickSpacing: number + poolHooks: `0x${string}` + poolId: `0x${string}` +} + +/** Chain-keyed deployments written by `DeployOscillon.s.sol`. */ +export type DeploymentRegistry = Record + +export const deployments = deploymentJson as DeploymentRegistry + +export const DEFAULT_CHAIN_ID = 31337 + +const EMPTY_DEPLOYMENT: Deployment = { + chainId: 0, + chainName: '', + deployedAt: '0', + deployer: '0x0000000000000000000000000000000000000000', + poolManager: '0x0000000000000000000000000000000000000000', + swapRouter: '0x0000000000000000000000000000000000000000', + liquidityRouter: '0x0000000000000000000000000000000000000000', + oscillonHook: '0x0000000000000000000000000000000000000000', + usdc: '0x0000000000000000000000000000000000000000', + usdt: '0x0000000000000000000000000000000000000000', + chainlinkFeed0: '0x0000000000000000000000000000000000000000', + chainlinkFeed1: '0x0000000000000000000000000000000000000000', + chainlinkAdapter0: '0x0000000000000000000000000000000000000000', + chainlinkAdapter1: '0x0000000000000000000000000000000000000000', + oracle0: '0x0000000000000000000000000000000000000000', + oracle1: '0x0000000000000000000000000000000000000000', + poolCurrency0: '0x0000000000000000000000000000000000000000', + poolCurrency1: '0x0000000000000000000000000000000000000000', + poolFee: 8388608, + poolTickSpacing: 1, + poolHooks: '0x0000000000000000000000000000000000000000', + poolId: '0x0000000000000000000000000000000000000000000000000000000000000000', +} + +export function getDeployment(chainId: number = DEFAULT_CHAIN_ID): Deployment { + return deployments[String(chainId)] ?? EMPTY_DEPLOYMENT +} + +/** @deprecated Prefer `getDeployment(chainId)` when the wallet chain is known. */ +export const deployment = getDeployment(DEFAULT_CHAIN_ID) + +export function getHookAddress(chainId: number = DEFAULT_CHAIN_ID) { + return getDeployment(chainId).oscillonHook +} + +export function getPoolKey(chainId: number = DEFAULT_CHAIN_ID) { + const d = getDeployment(chainId) + return { + currency0: d.poolCurrency0, + currency1: d.poolCurrency1, + fee: d.poolFee, + tickSpacing: d.poolTickSpacing, + hooks: d.poolHooks, + } as const +} + +export const HOOK_ADDRESS = deployment.oscillonHook +export const PM_ADDRESS = deployment.poolManager +export const SWAP_ROUTER_ADDRESS = deployment.swapRouter +export const LIQUIDITY_ROUTER_ADDRESS = deployment.liquidityRouter +export const USDC_ADDRESS = deployment.usdc +export const USDT_ADDRESS = deployment.usdt +export const POOL_ID = deployment.poolId + +export const POOL_KEY = getPoolKey(DEFAULT_CHAIN_ID) + +export const HOOK_ABI = [ + { + name: 'getPoolState', + type: 'function', + stateMutability: 'view', + inputs: [ + { + name: 'key', + type: 'tuple', + components: [ + { name: 'currency0', type: 'address' }, + { name: 'currency1', type: 'address' }, + { name: 'fee', type: 'uint24' }, + { name: 'tickSpacing', type: 'int24' }, + { name: 'hooks', type: 'address' }, + ], + }, + ], + outputs: [ + { name: 'registered', type: 'bool' }, + { name: 'depegBps', type: 'uint256' }, + { name: 'pegBelow', type: 'bool' }, + { name: 'inRestoreWindow', type: 'bool' }, + { name: 'surplusAccrued', type: 'uint256' }, + { name: 'protocolAccrued', type: 'uint256' }, + { name: 'usingFallback', type: 'bool' }, + ], + }, + { + name: 'getPoolConfig', + type: 'function', + stateMutability: 'view', + inputs: [ + { + name: 'key', + type: 'tuple', + components: [ + { name: 'currency0', type: 'address' }, + { name: 'currency1', type: 'address' }, + { name: 'fee', type: 'uint24' }, + { name: 'tickSpacing', type: 'int24' }, + { name: 'hooks', type: 'address' }, + ], + }, + ], + outputs: [ + { + name: '', + type: 'tuple', + components: [ + { name: 'registered', type: 'bool' }, + { name: 'token0', type: 'address' }, + { name: 'token1', type: 'address' }, + { name: 'maxDepegSwap0', type: 'uint256' }, + { name: 'maxDepegSwap1', type: 'uint256' }, + { name: 'lastHighDepegAt', type: 'uint256' }, + { name: 'surplusAccrued', type: 'uint256' }, + { name: 'protocolAccrued', type: 'uint256' }, + ], + }, + ], + }, + { + name: 'DepegDetected', + type: 'event', + inputs: [ + { name: 'poolId', type: 'bytes32', indexed: true }, + { name: 'depegBps', type: 'uint256', indexed: false }, + { name: 'feeApplied', type: 'uint24', indexed: false }, + { name: 'swapSize', type: 'uint256', indexed: false }, + { name: 'isDrain', type: 'bool', indexed: false }, + { name: 'usingFallback', type: 'bool', indexed: false }, + ], + }, + { + name: 'PoolRegistered', + type: 'event', + inputs: [ + { name: 'poolId', type: 'bytes32', indexed: true }, + { name: 'token0', type: 'address', indexed: false }, + { name: 'token1', type: 'address', indexed: false }, + { name: 'chainlink0', type: 'address', indexed: false }, + { name: 'chainlink1', type: 'address', indexed: false }, + ], + }, +] as const + +export const ERC20_ABI = [ + { + name: 'approve', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, + { + name: 'allowance', + type: 'function', + stateMutability: 'view', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + outputs: [{ name: '', type: 'uint256' }], + }, +] as const + +export const ANVIL_CHAIN = { + id: 31337, + name: 'Anvil', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['http://127.0.0.1:8545'] } }, +} as const + +export const ARBITRUM_SEPOLIA_CHAIN = { + id: 421614, + name: 'Arbitrum Sepolia', + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + rpcUrls: { default: { http: ['https://sepolia-rollup.arbitrum.io/rpc'] } }, + blockExplorers: { + default: { name: 'Arbiscan', url: 'https://sepolia.arbiscan.io' }, + }, +} as const + +export function getChainConfig(chainId: number = DEFAULT_CHAIN_ID) { + switch (chainId) { + case 31337: + return ANVIL_CHAIN + case 421614: + return ARBITRUM_SEPOLIA_CHAIN + default: + return ANVIL_CHAIN + } +} + +export function isDeployed(chainId: number = DEFAULT_CHAIN_ID): boolean { + const d = getDeployment(chainId) + return d.chainId !== 0 && d.oscillonHook !== '0x0000000000000000000000000000000000000000' +} + +export function formatDepeg(depegBps: bigint): string { + const bps = Number(depegBps) + if (bps === 0) return 'At parity ($1.00)' + if (bps < 7) return `${bps} bps (micro-depeg)` + if (bps < 30) return `${bps} bps (active depeg)` + return `${bps} bps (SEVERE depeg)` +} + +export function formatFee(feePips: number): string { + return `${(feePips / 100).toFixed(2)} bps` +} + +export function formatUSD(amount: bigint, decimals = 6): string { + return `$${(Number(amount) / 10 ** decimals).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}` +} diff --git a/oscillon-ui/src/deployment.json b/oscillon-ui/src/deployment.json new file mode 100644 index 0000000..1848403 --- /dev/null +++ b/oscillon-ui/src/deployment.json @@ -0,0 +1,26 @@ +{ + "31337": { + "chainId": 31337, + "chainName": "anvil", + "chainlinkAdapter0": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + "chainlinkAdapter1": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", + "chainlinkFeed0": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707", + "chainlinkFeed1": "0x0165878A594ca255338adfa4d48449f69242Eb8F", + "deployedAt": "1781073714", + "deployer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "liquidityRouter": "0x610178dA211FEF7D417bC0e6FeD39F05609AD788", + "oracle0": "0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6", + "oracle1": "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853", + "oscillonHook": "0xF3F624114f4987e11007330a4368D4300d5d10C0", + "poolCurrency0": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0", + "poolCurrency1": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "poolFee": 8388608, + "poolHooks": "0xF3F624114f4987e11007330a4368D4300d5d10C0", + "poolId": "0x5290c603cc9d194e576d34937f591e5266bcb70a4b27e4c4ef72878dbe16c008", + "poolManager": "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "poolTickSpacing": 1, + "swapRouter": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", + "usdc": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512", + "usdt": "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" + } +} \ No newline at end of file diff --git a/script/DeployLocalAnvil.s.sol b/script/DeployLocalAnvil.s.sol new file mode 100644 index 0000000..ef41175 --- /dev/null +++ b/script/DeployLocalAnvil.s.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {Script, console2} from "forge-std/Script.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import { + PoolModifyLiquidityTest +} from "@uniswap/v4-core/src/test/PoolModifyLiquidityTest.sol"; +import { + ModifyLiquidityParams +} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {HookMiner} from "v4-hooks-public/src/utils/HookMiner.sol"; + +import {OscillonHook} from "../src/OscillonHook.sol"; +import { + ChainlinkOracleAdapter +} from "../src/oracle/adapters/ChainlinkOracleAdapter.sol"; +import {MockV3Aggregator} from "../test/mock/MockV3Aggregator.sol"; + +/// @notice Full local stack: tokens, oracles, hook, pool, liquidity. +contract DeployLocalAnvilScript is Script { + address constant CREATE2_DEPLOYER = + 0x4e59b44847b379578588920cA78FbF26c0B4956C; + uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336; + + struct Deployed { + PoolManager manager; + PoolSwapTest swapRouter; + PoolModifyLiquidityTest liqRouter; + MockERC20 usdc; + MockERC20 usdt; + MockV3Aggregator clUsdc; + MockV3Aggregator clUsdt; + OscillonHook hook; + } + + function run() external { + uint256 deployerKey = vm.envOr( + "PRIVATE_KEY", + uint256( + 0xac0974bec39a17e36ba4a6b4d0ff2cffc6c2bffe6a6861c259c265d822f864 + ) + ); + address deployer = vm.addr(deployerKey); + + vm.startBroadcast(deployerKey); + Deployed memory d = _deployAll(deployer); + vm.stopBroadcast(); + + console2.log("=== LOCAL DEPLOY ==="); + console2.log("Deployer:", deployer); + console2.log("PoolManager:", address(d.manager)); + console2.log("SwapRouter:", address(d.swapRouter)); + console2.log("LiqRouter:", address(d.liqRouter)); + console2.log("USDC:", address(d.usdc)); + console2.log("USDT:", address(d.usdt)); + console2.log("CL USDC:", address(d.clUsdc)); + console2.log("CL USDT:", address(d.clUsdt)); + console2.log("OscillonHook:", address(d.hook)); + } + + function _deployAll(address deployer) internal returns (Deployed memory d) { + d.manager = new PoolManager(deployer); + d.swapRouter = new PoolSwapTest(d.manager); + d.liqRouter = new PoolModifyLiquidityTest(d.manager); + d.manager.setProtocolFeeController(deployer); + + d.usdc = new MockERC20("USD Coin", "USDC", 18); + d.usdt = new MockERC20("Tether", "USDT", 18); + d.usdc.mint(deployer, type(uint128).max); + d.usdt.mint(deployer, type(uint128).max); + + d.usdc.approve(address(d.swapRouter), type(uint256).max); + d.usdt.approve(address(d.swapRouter), type(uint256).max); + d.usdc.approve(address(d.liqRouter), type(uint256).max); + d.usdt.approve(address(d.liqRouter), type(uint256).max); + + d.clUsdc = new MockV3Aggregator(18, int256(1e18)); + d.clUsdt = new MockV3Aggregator(18, int256(1e18)); + + ChainlinkOracleAdapter usdcAdapter = new ChainlinkOracleAdapter( + address(d.clUsdc), + address(0), + 25 hours + ); + ChainlinkOracleAdapter usdtAdapter = new ChainlinkOracleAdapter( + address(d.clUsdt), + address(0), + 25 hours + ); + + d.hook = _deployHook(d.manager, deployer); + d.hook.approveAdapter(address(usdcAdapter)); + d.hook.approveAdapter(address(usdtAdapter)); + + _initPoolAndRegister(d, usdcAdapter, usdtAdapter); + } + + function _deployHook( + PoolManager manager, + address initialOwner + ) internal returns (OscillonHook hook) { + uint160 flags = uint160( + Hooks.BEFORE_SWAP_FLAG | + Hooks.AFTER_INITIALIZE_FLAG | + Hooks.AFTER_SWAP_FLAG + ); + bytes memory ctorArgs = abi.encode( + IPoolManager(address(manager)), + initialOwner + ); + (address hookAddr, bytes32 salt) = HookMiner.find( + CREATE2_DEPLOYER, + flags, + type(OscillonHook).creationCode, + ctorArgs + ); + hook = new OscillonHook{salt: salt}( + IPoolManager(address(manager)), + initialOwner + ); + require(address(hook) == hookAddr, "hook address mismatch"); + } + + function _initPoolAndRegister( + Deployed memory d, + ChainlinkOracleAdapter usdcAdapter, + ChainlinkOracleAdapter usdtAdapter + ) internal { + ( + Currency c0, + Currency c1, + address adapter0, + address adapter1 + ) = _sortedPair(d.usdc, d.usdt, usdcAdapter, usdtAdapter); + + PoolKey memory key = PoolKey({ + currency0: c0, + currency1: c1, + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: 1, + hooks: IHooks(address(d.hook)) + }); + + d.manager.initialize(key, SQRT_PRICE_1_1); + d.liqRouter.modifyLiquidity( + key, + ModifyLiquidityParams({ + tickLower: -120, + tickUpper: 120, + liquidityDelta: 1e18, + salt: bytes32(0) + }), + "" + ); + d.hook.registerPool(key, adapter0, adapter1, 18, 18); + } + + function _sortedPair( + MockERC20 usdc, + MockERC20 usdt, + ChainlinkOracleAdapter usdcAdapter, + ChainlinkOracleAdapter usdtAdapter + ) + internal + pure + returns (Currency c0, Currency c1, address adapter0, address adapter1) + { + if (address(usdc) < address(usdt)) { + return ( + Currency.wrap(address(usdc)), + Currency.wrap(address(usdt)), + address(usdcAdapter), + address(usdtAdapter) + ); + } + return ( + Currency.wrap(address(usdt)), + Currency.wrap(address(usdc)), + address(usdtAdapter), + address(usdcAdapter) + ); + } +} diff --git a/script/DeployOscillon.s.sol b/script/DeployOscillon.s.sol new file mode 100644 index 0000000..73f1bb6 --- /dev/null +++ b/script/DeployOscillon.s.sol @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/** + * @title DeployOscillon + * @notice Deploys OscillonHook + dependencies and writes deployment.json (root + oscillon-ui/src). + * + * forge script script/DeployOscillon.s.sol:DeployOscillon \ + * --rpc-url http://127.0.0.1:8545 \ + * --broadcast \ + * --private-key 0xac0974bec39a17e36ba4a6b4d0ff2cffc6c2bffe6a6861c259c265d822f864 + */ + +import {Script, console2} from "forge-std/Script.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {PoolModifyLiquidityTest} from "@uniswap/v4-core/src/test/PoolModifyLiquidityTest.sol"; +import {ModifyLiquidityParams} from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; +import {HookMiner} from "v4-hooks-public/src/utils/HookMiner.sol"; + +import {OscillonHook} from "../src/OscillonHook.sol"; +import {ChainlinkOracleAdapter} from "../src/oracle/adapters/ChainlinkOracleAdapter.sol"; +import {MockV3Aggregator} from "../test/mock/MockV3Aggregator.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; + +contract DeployOscillon is Script { + using PoolIdLibrary for PoolKey; + + address constant CREATE2_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + address constant CL_USDC_USD_ARBITRUM = 0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3; + address constant CL_USDT_USD_ARBITRUM = 0x3f3f5dF88dC9F13eac63DF89EC16ef6e7E25DdE7; + address constant CL_SEQUENCER_ARBITRUM = 0xFdB631F5EE196F0ed6FAa767959853A9F217697D; + + address constant CL_USDC_USD_SEPOLIA = 0x0153002d20B96532C639313c2d54c3dA09109309; + address constant CL_USDT_USD_SEPOLIA = 0x80EDee6f667eCc9f63a0a6f55578F870651f06A4; + + address constant PM_ARBITRUM = 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32; + address constant PM_SEPOLIA = 0x00B036B58a818B1BC34d502D3fE730Db729e62AC; + + address constant USDC_ARBITRUM = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; + address constant USDT_ARBITRUM = 0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9; + + uint256 constant MAX_ORACLE_AGE = 25 hours; + uint160 constant SQRT_PRICE_1_1 = 79228162514264337593543950336; + + struct OracleBundle { + address usdcAdapter; + address usdtAdapter; + address usdcFeed; + address usdtFeed; + } + + struct DeployOutput { + uint256 chainId; + address deployer; + address poolManager; + address swapRouter; + address liquidityRouter; + address hook; + address usdc; + address usdt; + OracleBundle oracles; + address oracle0; + address oracle1; + PoolKey poolKey; + bytes32 poolId; + } + + function run() external { + uint256 chainId = block.chainid; + uint256 deployerKey = vm.envOr( + "PRIVATE_KEY", + uint256(0xac0974bec39a17e36ba4a6b4d0ff2cffc6c2bffe6a6861c259c265d822f864) + ); + address deployer = vm.addr(deployerKey); + + vm.startBroadcast(deployerKey); + DeployOutput memory out = _deploy(chainId, deployer); + vm.stopBroadcast(); + + _writeDeploymentJson(out); + + console2.log("=== Oscillon deploy complete ==="); + console2.log("Chain:", out.chainId); + console2.log("PoolManager:", out.poolManager); + console2.log("SwapRouter:", out.swapRouter); + console2.log("LiquidityRouter:", out.liquidityRouter); + console2.log("OscillonHook:", out.hook); + console2.log("PoolId:"); + console2.logBytes32(out.poolId); + } + + function _deploy(uint256 chainId, address deployer) internal returns (DeployOutput memory out) { + out.chainId = chainId; + out.deployer = deployer; + + address pmAddr = _poolManager(chainId, deployer); + IPoolManager poolManager = IPoolManager(pmAddr); + out.poolManager = pmAddr; + + (out.usdc, out.usdt) = _tokens(chainId, deployer); + out.oracles = _oracles(chainId); + + if (chainId == 31337 || chainId == 421614) { + out.swapRouter = address(new PoolSwapTest(poolManager)); + out.liquidityRouter = address(new PoolModifyLiquidityTest(poolManager)); + PoolManager(pmAddr).setProtocolFeeController(deployer); + _approveRouters(out); + } + + out.hook = _deployHook(poolManager, deployer); + OscillonHook hook = OscillonHook(payable(out.hook)); + hook.approveAdapter(out.oracles.usdcAdapter); + hook.approveAdapter(out.oracles.usdtAdapter); + + (out.poolKey, out.oracle0, out.oracle1) = _buildPoolKey(out); + poolManager.initialize(out.poolKey, SQRT_PRICE_1_1); + out.poolId = PoolId.unwrap(out.poolKey.toId()); + + if (chainId == 31337 || chainId == 421614) { + PoolModifyLiquidityTest(out.liquidityRouter).modifyLiquidity( + out.poolKey, + ModifyLiquidityParams({ + tickLower: -120, + tickUpper: 120, + liquidityDelta: 1e12, + salt: bytes32(0) + }), + "" + ); + } + + hook.registerPool( + out.poolKey, + out.oracle0, + out.oracle1, + uint8(6), + uint8(6) + ); + } + + function _poolManager(uint256 chainId, address deployer) internal returns (address) { + if (chainId == 31337) return address(new PoolManager(deployer)); + if (chainId == 421614) return PM_SEPOLIA; + if (chainId == 42161) return PM_ARBITRUM; + revert("DeployOscillon: unsupported chainId"); + } + + function _tokens(uint256 chainId, address deployer) internal returns (address usdc, address usdt) { + if (chainId == 31337 || chainId == 421614) { + MockERC20 mockUsdc = new MockERC20("USD Coin", "USDC", 6); + MockERC20 mockUsdt = new MockERC20("Tether USD", "USDT", 6); + mockUsdc.mint(deployer, 1_000_000 * 1e6); + mockUsdt.mint(deployer, 1_000_000 * 1e6); + return (address(mockUsdc), address(mockUsdt)); + } + if (chainId == 42161) return (USDC_ARBITRUM, USDT_ARBITRUM); + revert("DeployOscillon: unsupported chainId"); + } + + function _oracles(uint256 chainId) internal returns (OracleBundle memory o) { + if (chainId == 31337) { + MockV3Aggregator feed0 = new MockV3Aggregator(8, int256(1e8)); + MockV3Aggregator feed1 = new MockV3Aggregator(8, int256(1e8)); + o.usdcFeed = address(feed0); + o.usdtFeed = address(feed1); + o.usdcAdapter = address( + new ChainlinkOracleAdapter(o.usdcFeed, address(0), MAX_ORACLE_AGE) + ); + o.usdtAdapter = address( + new ChainlinkOracleAdapter(o.usdtFeed, address(0), MAX_ORACLE_AGE) + ); + return o; + } + if (chainId == 421614) { + o.usdcFeed = CL_USDC_USD_SEPOLIA; + o.usdtFeed = CL_USDT_USD_SEPOLIA; + o.usdcAdapter = address( + new ChainlinkOracleAdapter(o.usdcFeed, address(0), MAX_ORACLE_AGE) + ); + o.usdtAdapter = address( + new ChainlinkOracleAdapter(o.usdtFeed, address(0), MAX_ORACLE_AGE) + ); + return o; + } + if (chainId == 42161) { + o.usdcFeed = CL_USDC_USD_ARBITRUM; + o.usdtFeed = CL_USDT_USD_ARBITRUM; + o.usdcAdapter = address( + new ChainlinkOracleAdapter(o.usdcFeed, CL_SEQUENCER_ARBITRUM, MAX_ORACLE_AGE) + ); + o.usdtAdapter = address( + new ChainlinkOracleAdapter(o.usdtFeed, CL_SEQUENCER_ARBITRUM, MAX_ORACLE_AGE) + ); + return o; + } + revert("DeployOscillon: unsupported chainId"); + } + + function _deployHook(IPoolManager poolManager, address initialOwner) internal returns (address hook) { + uint160 flags = uint160( + Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG + ); + bytes memory ctorArgs = abi.encode(poolManager, initialOwner); + (address hookAddr, bytes32 salt) = HookMiner.find( + CREATE2_DEPLOYER, + flags, + type(OscillonHook).creationCode, + ctorArgs + ); + OscillonHook deployed = new OscillonHook{salt: salt}(poolManager, initialOwner); + require(address(deployed) == hookAddr, "DeployOscillon: hook address mismatch"); + return address(deployed); + } + + function _buildPoolKey(DeployOutput memory out) + internal + pure + returns (PoolKey memory key, address oracle0, address oracle1) + { + if (out.usdc < out.usdt) { + key = PoolKey({ + currency0: Currency.wrap(out.usdc), + currency1: Currency.wrap(out.usdt), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: int24(1), + hooks: IHooks(out.hook) + }); + oracle0 = out.oracles.usdcAdapter; + oracle1 = out.oracles.usdtAdapter; + } else { + key = PoolKey({ + currency0: Currency.wrap(out.usdt), + currency1: Currency.wrap(out.usdc), + fee: LPFeeLibrary.DYNAMIC_FEE_FLAG, + tickSpacing: int24(1), + hooks: IHooks(out.hook) + }); + oracle0 = out.oracles.usdtAdapter; + oracle1 = out.oracles.usdcAdapter; + } + } + + function _approveRouters(DeployOutput memory out) internal { + MockERC20(out.usdc).approve(out.swapRouter, type(uint256).max); + MockERC20(out.usdt).approve(out.swapRouter, type(uint256).max); + MockERC20(out.usdc).approve(out.liquidityRouter, type(uint256).max); + MockERC20(out.usdt).approve(out.liquidityRouter, type(uint256).max); + } + + function _writeDeploymentJson(DeployOutput memory out) internal { + string memory root = "deployment"; + vm.serializeUint(root, "chainId", out.chainId); + vm.serializeString(root, "chainName", _chainName(out.chainId)); + vm.serializeString(root, "deployedAt", vm.toString(block.timestamp)); + vm.serializeAddress(root, "deployer", out.deployer); + vm.serializeAddress(root, "poolManager", out.poolManager); + vm.serializeAddress(root, "swapRouter", out.swapRouter); + vm.serializeAddress(root, "liquidityRouter", out.liquidityRouter); + vm.serializeAddress(root, "oscillonHook", out.hook); + vm.serializeAddress(root, "usdc", out.usdc); + vm.serializeAddress(root, "usdt", out.usdt); + vm.serializeAddress(root, "chainlinkFeed0", out.oracles.usdcFeed); + vm.serializeAddress(root, "chainlinkFeed1", out.oracles.usdtFeed); + vm.serializeAddress(root, "chainlinkAdapter0", out.oracles.usdcAdapter); + vm.serializeAddress(root, "chainlinkAdapter1", out.oracles.usdtAdapter); + vm.serializeAddress(root, "oracle0", out.oracle0); + vm.serializeAddress(root, "oracle1", out.oracle1); + vm.serializeAddress(root, "poolCurrency0", Currency.unwrap(out.poolKey.currency0)); + vm.serializeAddress(root, "poolCurrency1", Currency.unwrap(out.poolKey.currency1)); + vm.serializeUint(root, "poolFee", out.poolKey.fee); + vm.serializeInt(root, "poolTickSpacing", out.poolKey.tickSpacing); + vm.serializeAddress(root, "poolHooks", address(out.poolKey.hooks)); + string memory json = vm.serializeBytes32(root, "poolId", out.poolId); + + string memory projectRoot = vm.projectRoot(); + string memory rootPath = string.concat(projectRoot, "/deployment.json"); + string memory uiPath = vm.envOr( + "FRONTEND_DEPLOYMENT_JSON", + string.concat(projectRoot, "/oscillon-ui/src/deployment.json") + ); + + // Merge into chain-keyed registry: { "31337": { ... }, "421614": { ... } } + string memory chainKey = string.concat(".", vm.toString(out.chainId)); + vm.writeJson(json, rootPath, chainKey); + vm.writeJson(json, uiPath, chainKey); + + console2.log("deployment.json ->", rootPath); + console2.log("deployment.json key ->", chainKey); + console2.log("deployment.json ->", uiPath); + } + + function _chainName(uint256 chainId) internal pure returns (string memory) { + if (chainId == 31337) return "anvil"; + if (chainId == 421614) return "arbitrum-sepolia"; + if (chainId == 42161) return "arbitrum"; + if (chainId == 11155111) return "ethereum-sepolia"; + return "unknown"; + } +} diff --git a/script/DeployOscillonHook.s.sol b/script/DeployOscillonHook.s.sol index eb8e3d6..58b1a9c 100644 --- a/script/DeployOscillonHook.s.sol +++ b/script/DeployOscillonHook.s.sol @@ -9,13 +9,18 @@ import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; import {HookMiner} from "v4-hooks-public/src/utils/HookMiner.sol"; import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; import {OscillonHook} from "../src/OscillonHook.sol"; -import {ChainlinkOracleAdapter} from "../src/oracle/adapters/ChainlinkOracleAdapter.sol"; +import { + ChainlinkOracleAdapter +} from "../src/oracle/adapters/ChainlinkOracleAdapter.sol"; import {OscillonConstants as C} from "../src/constants/OscillonConstants.sol"; contract DeployOscillonHookScript is Script { - address constant CREATE2_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C; - address constant ARBITRUM_POOL_MANAGER = 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32; - address constant ARBITRUM_SEQUENCER = 0xFdB631F5EE196F0ed6FAa767959853A9F217697D; + address constant CREATE2_DEPLOYER = + 0x4e59b44847b379578588920cA78FbF26c0B4956C; + address constant ARBITRUM_POOL_MANAGER = + 0x360E68faCcca8cA495c1B759Fd9EEe466db9FB32; + address constant ARBITRUM_SEQUENCER = + 0xFdB631F5EE196F0ed6FAa767959853A9F217697D; address constant USDC = 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; address constant USDT = 0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9; @@ -35,37 +40,91 @@ contract DeployOscillonHookScript is Script { vm.startBroadcast(deployerKey); uint160 flags = uint160( - Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG + Hooks.BEFORE_SWAP_FLAG | + Hooks.AFTER_INITIALIZE_FLAG | + Hooks.AFTER_SWAP_FLAG ); + bytes memory ctorArgs = abi.encode(IPoolManager(poolManager), deployer); (address hookAddr, bytes32 salt) = HookMiner.find( CREATE2_DEPLOYER, flags, type(OscillonHook).creationCode, - abi.encode(IPoolManager(poolManager)) + ctorArgs ); - OscillonHook hook = new OscillonHook{salt: salt}(IPoolManager(poolManager)); + OscillonHook hook = new OscillonHook{salt: salt}( + IPoolManager(poolManager), + deployer + ); require(address(hook) == hookAddr, "Hook address mismatch"); - ChainlinkOracleAdapter usdcAdapter = - new ChainlinkOracleAdapter(CL_USDC_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); - ChainlinkOracleAdapter usdtAdapter = - new ChainlinkOracleAdapter(CL_USDT_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); - ChainlinkOracleAdapter daiAdapter = - new ChainlinkOracleAdapter(CL_DAI_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); - ChainlinkOracleAdapter crvAdapter = - new ChainlinkOracleAdapter(CL_CRVUSD_USD, ARBITRUM_SEQUENCER, C.MAX_ORACLE_AGE); + ChainlinkOracleAdapter usdcAdapter = new ChainlinkOracleAdapter( + CL_USDC_USD, + ARBITRUM_SEQUENCER, + C.MAX_ORACLE_AGE + ); + ChainlinkOracleAdapter usdtAdapter = new ChainlinkOracleAdapter( + CL_USDT_USD, + ARBITRUM_SEQUENCER, + C.MAX_ORACLE_AGE + ); + ChainlinkOracleAdapter daiAdapter = new ChainlinkOracleAdapter( + CL_DAI_USD, + ARBITRUM_SEQUENCER, + C.MAX_ORACLE_AGE + ); + ChainlinkOracleAdapter crvAdapter = new ChainlinkOracleAdapter( + CL_CRVUSD_USD, + ARBITRUM_SEQUENCER, + C.MAX_ORACLE_AGE + ); hook.approveAdapter(address(usdcAdapter)); hook.approveAdapter(address(usdtAdapter)); hook.approveAdapter(address(daiAdapter)); hook.approveAdapter(address(crvAdapter)); - _registerSortedPool(hook, USDC, USDT, usdcAdapter, usdtAdapter, 6, 6, "USDC/USDT"); - _registerSortedPool(hook, USDC, DAI, usdcAdapter, daiAdapter, 6, 18, "USDC/DAI"); - _registerSortedPool(hook, USDT, DAI, usdtAdapter, daiAdapter, 6, 18, "USDT/DAI"); - _registerSortedPool(hook, USDC, CRVUSD, usdcAdapter, crvAdapter, 6, 18, "USDC/crvUSD"); + _registerSortedPool( + hook, + USDC, + USDT, + usdcAdapter, + usdtAdapter, + 6, + 6, + "USDC/USDT" + ); + _registerSortedPool( + hook, + USDC, + DAI, + usdcAdapter, + daiAdapter, + 6, + 18, + "USDC/DAI" + ); + _registerSortedPool( + hook, + USDT, + DAI, + usdtAdapter, + daiAdapter, + 6, + 18, + "USDT/DAI" + ); + _registerSortedPool( + hook, + USDC, + CRVUSD, + usdcAdapter, + crvAdapter, + 6, + 18, + "USDC/crvUSD" + ); vm.stopBroadcast(); @@ -92,7 +151,13 @@ contract DeployOscillonHookScript is Script { tickSpacing: 1, hooks: hook }); - hook.registerPool(key, address(adapterA), address(adapterB), decimalsA, decimalsB); + hook.registerPool( + key, + address(adapterA), + address(adapterB), + decimalsA, + decimalsB + ); } else { key = PoolKey({ currency0: Currency.wrap(tokenB), @@ -101,7 +166,13 @@ contract DeployOscillonHookScript is Script { tickSpacing: 1, hooks: hook }); - hook.registerPool(key, address(adapterB), address(adapterA), decimalsB, decimalsA); + hook.registerPool( + key, + address(adapterB), + address(adapterA), + decimalsB, + decimalsA + ); } console2.log("Registered:", label); diff --git a/src/OscillonHook.sol b/src/OscillonHook.sol index 040f3ff..3d3767c 100644 --- a/src/OscillonHook.sol +++ b/src/OscillonHook.sol @@ -17,7 +17,6 @@ import {LPFeeLibrary} from "@uniswap/v4-core/src/libraries/LPFeeLibrary.sol"; import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; import {FixedPointMathLib} from "solmate/src/utils/FixedPointMathLib.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - import {OscillonConstants as C} from "./constants/OscillonConstants.sol"; import { PoolConfig, @@ -69,10 +68,17 @@ contract OscillonHook is BaseHook { ); event AdapterApproved(address indexed adapter); event AdapterRevoked(address indexed adapter); - event ChainlinkOracleUpdated(PoolId indexed poolId, bool isToken0, address adapter); + event ChainlinkOracleUpdated( + PoolId indexed poolId, + bool isToken0, + address adapter + ); event ProtocolFeesCollected(PoolId indexed poolId, uint256 amount); event ProtocolTreasuryUpdated(address newTreasury); - event OwnershipTransferred(address indexed oldOwner, address indexed newOwner); + event OwnershipTransferred( + address indexed oldOwner, + address indexed newOwner + ); address public owner; address public protocolTreasury; @@ -83,28 +89,38 @@ contract OscillonHook is BaseHook { mapping(PoolId => uint256) public rollingDrain; mapping(PoolId => uint256) public rollingWindowStart; - constructor(IPoolManager _poolManager) BaseHook(_poolManager) { - owner = msg.sender; - protocolTreasury = msg.sender; + constructor( + IPoolManager _poolManager, + address initialOwner + ) BaseHook(_poolManager) { + if (initialOwner == address(0)) revert ZeroAddress(); + owner = initialOwner; + protocolTreasury = initialOwner; } - function getHookPermissions() public pure override returns (Hooks.Permissions memory) { - return Hooks.Permissions({ - beforeInitialize: false, - afterInitialize: true, - beforeAddLiquidity: false, - afterAddLiquidity: false, - beforeRemoveLiquidity: false, - afterRemoveLiquidity: false, - beforeSwap: true, - afterSwap: true, - beforeDonate: false, - afterDonate: false, - beforeSwapReturnDelta: false, - afterSwapReturnDelta: false, - afterAddLiquidityReturnDelta: false, - afterRemoveLiquidityReturnDelta: false - }); + function getHookPermissions() + public + pure + override + returns (Hooks.Permissions memory) + { + return + Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: true, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: true, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); } // ── TWAP storage accessors (tests / monitoring) ─────────────────────────── @@ -117,7 +133,10 @@ contract OscillonHook is BaseHook { return twapStates[poolId].obsIndex; } - function observations(PoolId poolId, uint16 idx) + function observations( + PoolId poolId, + uint16 idx + ) external view returns (uint32 blockTimestamp, int56 tickCumulative, bool initialized) @@ -136,9 +155,12 @@ contract OscillonHook is BaseHook { uint8 stableDecimals1 ) external onlyOwner { if (key.tickSpacing != 1) revert NotStablePool(); - if (!approvedAdapters[chainlinkAdapter0]) revert AdapterNotApproved(chainlinkAdapter0); - if (!approvedAdapters[chainlinkAdapter1]) revert AdapterNotApproved(chainlinkAdapter1); - if (chainlinkAdapter0 == address(0) || chainlinkAdapter1 == address(0)) revert ZeroAddress(); + if (!approvedAdapters[chainlinkAdapter0]) + revert AdapterNotApproved(chainlinkAdapter0); + if (!approvedAdapters[chainlinkAdapter1]) + revert AdapterNotApproved(chainlinkAdapter1); + if (chainlinkAdapter0 == address(0) || chainlinkAdapter1 == address(0)) + revert ZeroAddress(); address token0 = Currency.unwrap(key.currency0); address token1 = Currency.unwrap(key.currency1); @@ -153,14 +175,22 @@ contract OscillonHook is BaseHook { token1: token1, oracles0: TokenOracleConfig({chainlinkAdapter: chainlinkAdapter0}), oracles1: TokenOracleConfig({chainlinkAdapter: chainlinkAdapter1}), - maxDepegSwap0: C.MAX_DEPEG_SWAP_FACTOR * (10 ** uint256(stableDecimals0)), - maxDepegSwap1: C.MAX_DEPEG_SWAP_FACTOR * (10 ** uint256(stableDecimals1)), + maxDepegSwap0: C.MAX_DEPEG_SWAP_FACTOR * + (10 ** uint256(stableDecimals0)), + maxDepegSwap1: C.MAX_DEPEG_SWAP_FACTOR * + (10 ** uint256(stableDecimals1)), lastHighDepegAt: 0, surplusAccrued: 0, protocolAccrued: 0 }); - emit PoolRegistered(poolId, token0, token1, chainlinkAdapter0, chainlinkAdapter1); + emit PoolRegistered( + poolId, + token0, + token1, + chainlinkAdapter0, + chainlinkAdapter1 + ); } /// @notice Replace a pool's Chainlink adapter after deployment (e.g. feed migration). @@ -169,11 +199,14 @@ contract OscillonHook is BaseHook { bool isToken0, address newChainlinkAdapter ) external onlyOwner { - if (!approvedAdapters[newChainlinkAdapter]) revert AdapterNotApproved(newChainlinkAdapter); + if (!approvedAdapters[newChainlinkAdapter]) + revert AdapterNotApproved(newChainlinkAdapter); PoolConfig storage cfg = poolConfigs[key.toId()]; if (!cfg.registered) revert PoolNotRegistered(); - TokenOracleConfig storage oracles = isToken0 ? cfg.oracles0 : cfg.oracles1; + TokenOracleConfig storage oracles = isToken0 + ? cfg.oracles0 + : cfg.oracles1; oracles.chainlinkAdapter = newChainlinkAdapter; emit ChainlinkOracleUpdated(key.toId(), isToken0, newChainlinkAdapter); @@ -205,13 +238,22 @@ contract OscillonHook is BaseHook { } uint128 liquidity = poolManager.getLiquidity(poolId); - uint256 maxAbsolute = ctx.tokenInIsToken0 ? cfg.maxDepegSwap0 : cfg.maxDepegSwap1; + uint256 maxAbsolute = ctx.tokenInIsToken0 + ? cfg.maxDepegSwap0 + : cfg.maxDepegSwap1; uint256 cap = _min(maxAbsolute, (uint256(liquidity) * 50) / 10_000); if (ctx.isDrain && ctx.swapSize > cap) revert SwapCapExceeded(); - uint24 fee = _selectFee(poolId, cfg, ctx, liquidity); + uint24 fee = _selectFee(poolId, cfg, ctx); - emit DepegDetected(poolId, ctx.depegBps, fee, ctx.swapSize, ctx.isDrain, ctx.usingFallback); + emit DepegDetected( + poolId, + ctx.depegBps, + fee, + ctx.swapSize, + ctx.isDrain, + ctx.usingFallback + ); return ( this.beforeSwap.selector, @@ -220,11 +262,12 @@ contract OscillonHook is BaseHook { ); } - function _afterInitialize(address, PoolKey calldata key, uint160, int24) - internal - override - returns (bytes4) - { + function _afterInitialize( + address, + PoolKey calldata key, + uint160, + int24 + ) internal override returns (bytes4) { OscillonTwapOracle.seed(twapStates[key.toId()]); return this.afterInitialize.selector; } @@ -254,11 +297,21 @@ contract OscillonHook is BaseHook { bool tokenInIsToken0 = params.zeroForOne; address tokenIn = tokenInIsToken0 ? cfg.token0 : cfg.token1; - if (tokenIn != cfg.token0 && tokenIn != cfg.token1) revert UnsupportedToken(tokenIn); + if (tokenIn != cfg.token0 && tokenIn != cfg.token1) + revert UnsupportedToken(tokenIn); - TokenOracleConfig memory tokenOracles = tokenInIsToken0 ? cfg.oracles0 : cfg.oracles1; - uint256 twapPrice = OscillonTwapOracle.readTwapOrSpot(poolManager, key, twapStates[key.toId()]); - PriceResult memory price = OscillonPriceEngine.getSellTokenPrice(tokenOracles, twapPrice); + TokenOracleConfig memory tokenOracles = tokenInIsToken0 + ? cfg.oracles0 + : cfg.oracles1; + uint256 twapPrice = OscillonTwapOracle.readTwapOrSpot( + poolManager, + key, + twapStates[key.toId()] + ); + PriceResult memory price = OscillonPriceEngine.getSellTokenPrice( + tokenOracles, + twapPrice + ); uint256 swapSize = params.amountSpecified < 0 ? uint256(-params.amountSpecified) @@ -278,29 +331,37 @@ contract OscillonHook is BaseHook { function _selectFee( PoolId poolId, PoolConfig storage cfg, - SwapContext memory ctx, - uint128 poolLiquidity + SwapContext memory ctx ) internal returns (uint24 fee) { - bool inRestoreWindow = cfg.lastHighDepegAt != 0 - && (block.timestamp - cfg.lastHighDepegAt) <= C.RESTORE_WINDOW; + bool inRestoreWindow = cfg.lastHighDepegAt != 0 && + (block.timestamp - cfg.lastHighDepegAt) <= C.RESTORE_WINDOW; if (ctx.depegBps < C.SMALL_DEPEG_BPS) { - if (inRestoreWindow && ctx.depegBps == 0 && !ctx.isDrain) return C.RESTORE_FEE_PIPS; + if (inRestoreWindow && ctx.depegBps == 0 && !ctx.isDrain) + return C.RESTORE_FEE_PIPS; return C.BASE_FEE_PIPS; } cfg.lastHighDepegAt = block.timestamp; if (!ctx.isDrain) return C.BASE_FEE_PIPS; - uint256 k = OscillonFeePolicy.kForLiquidity(poolLiquidity); + uint256 k = OscillonFeePolicy.kForLiquidity(); uint256 feeBps = OscillonFeePolicy.hybridFeeBps(ctx.depegBps, k); uint256 mult = _rollingMultiplier(poolId, ctx.swapSize, true); - fee = OscillonFeePolicy.applyDrainAdjustments(feeBps, ctx.usingFallback, ctx.depegBps, mult); + fee = OscillonFeePolicy.applyDrainAdjustments( + feeBps, + ctx.usingFallback, + ctx.depegBps, + mult + ); if (fee > C.BASE_FEE_PIPS) { uint256 surplusBps = uint256(fee / 100) - 1; - uint256 surplusAmount = ctx.swapSize.mulDivDown(surplusBps, 10_000); - uint256 protocolCut = surplusAmount.mulDivDown(C.PROTOCOL_FEE_BPS, 100); + uint256 surplusAmount = ctx.swapSize.mulDivUp(surplusBps, 10_000); + uint256 protocolCut = surplusAmount.mulDivUp( + C.PROTOCOL_FEE_BPS, + 100 + ); cfg.surplusAccrued += surplusAmount - protocolCut; cfg.protocolAccrued += protocolCut; } @@ -308,10 +369,11 @@ contract OscillonHook is BaseHook { return fee; } - function _rollingMultiplier(PoolId poolId, uint256 swapSize, bool isDrain) - internal - returns (uint256) - { + function _rollingMultiplier( + PoolId poolId, + uint256 swapSize, + bool isDrain + ) internal returns (uint256) { if (block.number > rollingWindowStart[poolId] + C.ROLLING_BLOCKS) { rollingDrain[poolId] = 0; rollingWindowStart[poolId] = block.number; @@ -328,7 +390,9 @@ contract OscillonHook is BaseHook { // ── Views ───────────────────────────────────────────────────────────────── - function getPoolState(PoolKey calldata key) + function getPoolState( + PoolKey calldata key + ) external view returns ( @@ -345,25 +409,38 @@ contract OscillonHook is BaseHook { registered = cfg.registered; surplusAccrued = cfg.surplusAccrued; protocolAccrued = cfg.protocolAccrued; - inRestoreWindow = cfg.lastHighDepegAt != 0 - && (block.timestamp - cfg.lastHighDepegAt) <= C.RESTORE_WINDOW; + inRestoreWindow = + cfg.lastHighDepegAt != 0 && + (block.timestamp - cfg.lastHighDepegAt) <= C.RESTORE_WINDOW; if (!cfg.registered) return (false, 0, false, false, 0, 0, false); - uint256 twapPrice = OscillonTwapOracle.readTwapOrSpot(poolManager, key, twapStates[key.toId()]); - PriceResult memory price = OscillonPriceEngine.getSellTokenPrice(cfg.oracles0, twapPrice); + uint256 twapPrice = OscillonTwapOracle.readTwapOrSpot( + poolManager, + key, + twapStates[key.toId()] + ); + PriceResult memory price = OscillonPriceEngine.getSellTokenPrice( + cfg.oracles0, + twapPrice + ); depegBps = price.depegBps; pegBelow = price.pegBelow; usingFallback = price.usingFallback; } - function getPoolConfig(PoolKey calldata key) external view returns (PoolConfig memory) { + function getPoolConfig( + PoolKey calldata key + ) external view returns (PoolConfig memory) { return poolConfigs[key.toId()]; } // ── Protocol fees & governance ────────────────────────────────────────── - function collectProtocolFees(PoolKey calldata key, address token) external onlyOwner { + function collectProtocolFees( + PoolKey calldata key, + address token + ) external onlyOwner { PoolId id = key.toId(); PoolConfig storage cfg = poolConfigs[id]; if (!cfg.registered) revert PoolNotRegistered(); @@ -372,7 +449,8 @@ contract OscillonHook is BaseHook { if (amount == 0) return; cfg.protocolAccrued = 0; - if (!IERC20(token).transfer(protocolTreasury, amount)) revert TransferFailed(); + if (!IERC20(token).transfer(protocolTreasury, amount)) + revert TransferFailed(); emit ProtocolFeesCollected(id, amount); } diff --git a/src/constants/OscillonConstants.sol b/src/constants/OscillonConstants.sol index 486853b..c9da151 100644 --- a/src/constants/OscillonConstants.sol +++ b/src/constants/OscillonConstants.sol @@ -3,13 +3,13 @@ pragma solidity 0.8.26; library OscillonConstants { // Fee (pips = hundredths of a bip; 100 pips = 1 bps) - uint24 internal constant BASE_FEE_PIPS = 100; - uint24 internal constant RESTORE_FEE_PIPS = 100; + uint24 internal constant BASE_FEE_PIPS = 300; + uint24 internal constant RESTORE_FEE_PIPS = 300; uint24 internal constant MAX_FEE_PIPS = 5000; uint24 internal constant MAX_FEE_BPS = 50; // Depeg gates - uint256 internal constant SMALL_DEPEG_BPS = 7; + uint256 internal constant SMALL_DEPEG_BPS = 3; uint256 internal constant QUADRATIC_DEAD_BAND = 3; // Timing @@ -31,7 +31,7 @@ library OscillonConstants { uint256 internal constant LP_FEE_BPS = 85; // Liquidity tier for K selection - uint256 internal constant THIN_POOL_LIQUIDITY = 500_000e6; + // uint256 internal constant THIN_POOL_LIQUIDITY = 500_000e6; uint256 internal constant K_THIN = 60; uint256 internal constant K_STANDARD = 45; } diff --git a/src/libraries/OscillonfeePolicy.sol b/src/libraries/OscillonfeePolicy.sol index fd677f7..b9e410b 100644 --- a/src/libraries/OscillonfeePolicy.sol +++ b/src/libraries/OscillonfeePolicy.sol @@ -6,16 +6,21 @@ import {OscillonConstants as C} from "../constants/OscillonConstants.sol"; /// @title OscillonFeePolicy /// @notice Hybrid piecewise + quadratic drain fee model (returns bps, hook converts to pips). library OscillonFeePolicy { - function kForLiquidity(uint128 poolLiquidity) internal pure returns (uint256) { - return uint256(poolLiquidity) < C.THIN_POOL_LIQUIDITY ? C.K_THIN : C.K_STANDARD; + function kForLiquidity() internal pure returns (uint256) { + return C.K_STANDARD; } - function hybridFeeBps(uint256 devBps, uint256 k) internal pure returns (uint256) { + function hybridFeeBps( + uint256 devBps, + uint256 k + ) internal pure returns (uint256) { if (devBps == 0) return 1; uint256 pw = piecewiseFeeBps(devBps); - uint256 d = devBps > C.QUADRATIC_DEAD_BAND ? devBps - C.QUADRATIC_DEAD_BAND : 0; + uint256 d = devBps > C.QUADRATIC_DEAD_BAND + ? devBps - C.QUADRATIC_DEAD_BAND + : 0; uint256 quad = 1 + (k * d * d) / 10_000; if (quad > C.MAX_FEE_BPS) quad = C.MAX_FEE_BPS; @@ -57,7 +62,9 @@ library OscillonFeePolicy { feePips = uint24(pips > C.MAX_FEE_PIPS ? C.MAX_FEE_PIPS : pips); } - function rollingMultiplier(uint256 drainPctBps) internal pure returns (uint256) { + function rollingMultiplier( + uint256 drainPctBps + ) internal pure returns (uint256) { if (drainPctBps > 300) return 150; if (drainPctBps > 150) return 125; if (drainPctBps > 75) return 110; diff --git a/test/OscillonHook.t.sol b/test/OscillonHook.t.sol index fd3767d..4d20965 100644 --- a/test/OscillonHook.t.sol +++ b/test/OscillonHook.t.sol @@ -70,7 +70,7 @@ contract OscillonHookTwapTest is Test, Deployers { Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG ); - deployCodeTo("OscillonHook", abi.encode(manager), address(flags)); + deployCodeTo("OscillonHook", abi.encode(manager, address(this)), address(flags)); hook = OscillonHook(payable(address(flags))); hook.approveAdapter(address(adapter0)); @@ -291,7 +291,7 @@ contract OscillonHookTwapTest is Test, Deployers { // ───────────────────────────────────────────────────────────────────────────── // // Fee model (hybrid piecewise + quadratic): -// depegBps < 7 → BASE_FEE_PIPS (100 = 1 bps) +// depegBps < SMALL_DEPEG_BPS (3) → BASE_FEE_PIPS (300 = 3 bps) // restore-direction (input ABOVE peg) → BASE_FEE_PIPS // drain-direction: max(piecewise, quadratic with 3bps dead band), then pips = bps * 100 // @@ -317,7 +317,8 @@ contract OscillonHookDepegFeeTest is Test, Deployers { bool usingFallback ); - uint24 constant BASE_FEE = 100; + uint24 constant BASE_FEE = 300; + uint24 constant FEE_AT_6_BPS_DRAIN = 100; // hybrid = 1 bps at 6 bps depeg uint24 constant FEE_AT_20_BPS = 600; uint256 constant AMOUNT_IN = 1e15; @@ -357,7 +358,7 @@ contract OscillonHookDepegFeeTest is Test, Deployers { Hooks.AFTER_INITIALIZE_FLAG | Hooks.AFTER_SWAP_FLAG ); - deployCodeTo("OscillonHook", abi.encode(manager), address(flags)); + deployCodeTo("OscillonHook", abi.encode(manager, address(this)), address(flags)); hook = OscillonHook(payable(address(flags))); hook.approveAdapter(address(adapter0)); @@ -415,13 +416,13 @@ contract OscillonHookDepegFeeTest is Test, Deployers { _swap(int256(-int256(AMOUNT_IN))); } - // ── Depeg below SMALL_DEPEG_BPS (7) → still BASE_FEE ───────────────────── + // ── Small drain depeg → hybrid fee (1 bps at 6 bps deviation) ──────────── function test_swap_SmallDepegBelowThreshold_AppliesBaseFee() public { - // 0.9994 → 6 bps deviation, below the 7-bps activation threshold. + // 0.9994 → 6 bps deviation; hybrid piecewise/quadratic yields 1 bps = 100 pips. oracle1.updateAnswer(int256(0.9994e18)); vm.expectEmit(true, false, false, true, address(hook)); - emit DepegDetected(poolId, 6, BASE_FEE, AMOUNT_IN, true, false); + emit DepegDetected(poolId, 6, FEE_AT_6_BPS_DRAIN, AMOUNT_IN, true, false); _swap(int256(-int256(AMOUNT_IN))); } @@ -479,7 +480,7 @@ contract OscillonHookDepegFeeTest is Test, Deployers { // ── Exact-output during a depeg → reverts ─────────────────────────────── function test_exactOutput_RevertsDuringDepeg() public { - oracle1.updateAnswer(int256(0.998e18)); // 20 bps depeg, depegBps >= 7 + oracle1.updateAnswer(int256(0.998e18)); // 20 bps depeg, depegBps >= SMALL_DEPEG_BPS // v4 wraps hook reverts in WrappedError(address,bytes4,bytes,bytes4), // so we capture the revert payload and assert the inner reason is