diff --git a/pkg/interfaces/contracts/pool-hooks/IHyperSurgeHook.sol b/pkg/interfaces/contracts/pool-hooks/IHyperSurgeHook.sol new file mode 100644 index 00000000..22c5e9b6 --- /dev/null +++ b/pkg/interfaces/contracts/pool-hooks/IHyperSurgeHook.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +/** + * @title IHyperSurgeHook + * @notice Interface for the Hyper Surge hook: oracle-deviation surge fees and + * per-token external price configuration by pool token index. + * + * @dev + * - This interface exposes Hyper-specific configuration and read APIs. + * - Vault callback methods (e.g., onComputeDynamicSwapFeePercentage, onAfterAddLiquidity, + * onAfterRemoveLiquidity, getHookFlags, onRegister) are defined elsewhere (IHooks) + * and are intentionally not duplicated here. + */ +interface IHyperSurgeHook { + enum TradeType { + ARBITRAGE, + NOISE + } + + // ------------------------------------------------------------------------- + // Events + // ------------------------------------------------------------------------- + + /** + * @notice Emitted when a pool is registered/initialized with this hook. + * @param pool Pool address + * @param numTokens Number of tokens in the pool (2..8) + */ + event PoolRegistered(address indexed pool, uint8 numTokens); + + /** + * @notice Emitted when a token's external price configuration is set by token index. + * @param pool Pool address being configured + * @param tokenIndex Token index within the pool (0-based) + * @param hlPairIndex Hyperliquid pair/market index + * @param hlTokenIndex Hyperliquid token index + * @param szDecimals Hyperliquid size-decimals for that pair + */ + event TokenPriceConfiguredIndex( + address indexed pool, + uint8 indexed tokenIndex, + uint32 hlPairIndex, + uint32 hlTokenIndex, + uint8 szDecimals + ); + + /** + * @notice Emitted when the per-pool maximum surge fee percentage is changed. + * @dev 1e18-scaled (e.g., 1e17 = 10%). + * @param sender address of the sender + * @param pool Pool address + * @param pct New max surge fee percentage (1e18 scale) + * @param tradeType which direction the fee should be charged in + */ + event MaxSurgeFeePercentageChanged(address indexed sender, address indexed pool, uint256 pct, TradeType tradeType); + + /** + * @notice Emitted when the per-pool surge threshold percentage is changed. + * @dev 1e18-scaled (e.g., 5e16 = 5%). + * @param sender address of the sender + * @param pool Pool address + * @param pct New threshold percentage (1e18 scale) + * @param tradeType which direction the fee should be charged in + */ + event ThresholdPercentageChanged(address indexed sender, address indexed pool, uint256 pct, TradeType tradeType); + + /*** + * @notice Emitted when the per pool cap deviation is changed + * @param sender address of the sender + * @param pool address of the pool + * @param pct the fee in pct 1e18 scale + * @param tradeType which direction the fee should be charged in + */ + event CapDeviationPercentageChanged(address indexed sender, address indexed pool, uint256 pct, TradeType tradeType); + + /** + * @notice Configure a single token’s external price mapping by token index for a given pool. + * @param tokenIndex balancer pools index of the token + * @param hlPairIdx the index of the pair being set from hl + * @param hlTokenIdx the index of the token being set from hl + */ + function setTokenPriceConfigIndex( + address pool, + uint8 tokenIndex, + uint32 hlPairIdx, + uint32 hlTokenIdx + ) external; + + /** + * @notice Batch configure multiple tokens’ external price mapping by token index for a given pool. + * @param pool The pool address to configure. + * @param tokenIndices The balancer indices of the tokens to configure (0..7). + * @param hlPairIdx The indices of the pairs being set from hl. + * @param hlTokenIdx The indices of the tokens being set from hl. + */ + function setTokenPriceConfigBatchIndex( + address pool, + uint8[] calldata tokenIndices, + uint32[] calldata hlPairIdx, + uint32[] calldata hlTokenIdx + ) external; + + /** + * @notice Set the per-pool maximum surge fee percentage (cap). + * @param pool Pool address + * @param pct18 New maximum surge fee percentage (1e18 scale) + */ + function setMaxSurgeFeePercentage(address pool, uint256 pct18, TradeType tradeType) external; + + /** + * @notice Set the per-pool surge threshold percentage (deviation level at which fees start ramping). + * @param pool Pool address + * @param pct18 New threshold percentage (1e18 scale) + */ + function setSurgeThresholdPercentage(address pool, uint256 pct18, TradeType tradeType) external; + + /** + @notice sets the deviation where the max fee kicks in + @param pool address of the pool + @param capDevPct18 the deviation to set the cap to in % + */ + function setCapDeviationPercentage(address pool, uint256 capDevPct18, TradeType tradeType) external; + + // ------------------------------------------------------------------------- + // Getters (read-only) + // ------------------------------------------------------------------------- + + /** + * @notice Current per-pool surge threshold percentage (1e18 = 100%). + * @param pool Pool address + * @return pct The surge threshold percentage (1e18 = 100%). + */ + function getSurgeThresholdPercentage(address pool, TradeType tradeType) external view returns (uint256); + + /** + * @notice Current per-pool maximum surge fee percentage (1e18 = 100%). + * @param pool Pool address + * @return pct The maximum surge fee percentage (1e18 = 100%). + */ + function getMaxSurgeFeePercentage(address pool, TradeType tradeType) external view returns (uint256); + + /** + * @notice Default cap deviation percentage used for new pools (1e18 = 100%). + * @param pool Pool address + * @return capDevPct The cap deviation percentage (1e18 = 100%) + */ + function getCapDeviationPercentage(address pool, TradeType tradeType) external view returns (uint256); + + /** + * @notice Number of tokens configured for the pool (2..8). + * @param pool Pool address + * @return numTokens Number of tokens in the pool (2..8) + */ + function getNumTokens(address pool) external view returns (uint8); + + /** + * @notice Read the token price configuration for a specific token index. + * @param pool Pool address + * @param tokenIndex Token index (0-based) + * @return pairIndex Hyperliquid market/pair index (0 if USD-quoted) + * @return priceDivisor Precomputed divisor used to scale Hyperliquid spot into 1e18 + */ + function getTokenPriceConfigIndex( + address pool, + uint8 tokenIndex + ) + external + view + returns ( + uint32 pairIndex, + uint32 priceDivisor + ); + + /** + * @notice Read all token price configurations for a pool (length = numTokens). + * @dev Arrays are aligned by index; entry i corresponds to token index i. + * @return pairIndexArr Array of Hyperliquid pair indices (0 if USD-quoted) + * @return priceDivisorArr Array of price divisors for scaling spot into 1e18 + */ + function getTokenPriceConfigs( + address pool + ) + external + view + returns ( + uint32[] memory pairIndexArr, + uint32[] memory priceDivisorArr + ); + + /** + * @notice Default max surge fee percentage used for new pools (1e18 = 100%). + * @return pct The default max surge fee percentage (1e18 = 100%) + */ + function getDefaultMaxSurgeFeePercentage() external view returns (uint256 pct); + + /** + * @notice Default surge threshold percentage used for new pools (1e18 = 100%). + * @return pct The default surge threshold percentage (1e18 = 100%) + */ + function getDefaultSurgeThresholdPercentage() external view returns (uint256 pct); + + /** + * @notice Default cap deviation percentage used for new pools (1e18 = 100%). + * @return pct The default cap deviation percentage (1e18 = 100%) + */ + function getDefaultCapDeviationPercentage() external view returns (uint256 pct); +} diff --git a/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook-README.md b/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook-README.md new file mode 100644 index 00000000..4c0c115b --- /dev/null +++ b/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook-README.md @@ -0,0 +1,193 @@ +# Hyperliquid Balancer Hook — Arb-Aware Surge Fees + +> Dynamic, oracle-aware swap fees for Balancer V3 weighted pools, using Hyperliquid Core Reader spot prices. The hook measures pool-vs-oracle deviation, distinguishes **noise** vs **arbitrage** directions, and applies a direction-aware fee ramp. It also introduces a conservative guard for **single-asset withdrawals** (and more generally any non-proportional adds/removes). + +--- + +## 1) Background: Balancer hooks, Hyperliquid, and Core Reader spot price + +**Balancer V3 hooks.** Hooks let pool owners run custom logic during swaps and liquidity events (before/after swap, add/remove). This hook computes a dynamic, oracle-aware fee and enforces protective rules around non-proportional liquidity. + +**Hyperliquid.** We use Hyperliquid’s on-chain price interface (Core Reader / precompile) as the external reference. Each market is addressed by a `pairIndex`, and its **spot price** is read as a fixed-point number that we internally normalize to $1e18$ precision for consistent math. + +**Core Reader spot price.** Let `spot(pairIndex)` return a price scaled by $10^d$ (e.g., $d=6$). The hook caches a **price divisor** so that: + +$$ +px_k = \frac{spot(pairIndex_k)}{10^d}\times 10^{18} +$$ + +giving per-token oracle prices $px_k$ in $1e18$ scale. USD-quoted tokens can be set to $px_k = 10^{18}$. + +--- + +## 2) Deviation: pool price vs Hyperliquid spot + +Consider a weighted pool with balances $B_i$ and normalized weights $w_i$ for tokens $i\in\{1,\dots,n\}$. +For any ordered pair $(i,j)$, the **pool-implied price of $j$ in units of $i$** is + +$$ +P_{pool}(j \rightarrow i) = \frac{B_j/w_j}{B_i/w_i} += \frac{B_j w_i}{B_i w_j} \, . +$$ + +Let $px_k$ be the $1e18$-scaled Hyperliquid price for token $k$. The **external price ratio** is + +$$ +P_{ext}(j \rightarrow i) = \frac{px_j}{px_i} \, . +$$ + +We define the **relative deviation** for the pair $(i,j)$ as + +$$ +\delta(i,j) = +\frac{\left| P_{pool}(j \rightarrow i) - P_{ext}(j \rightarrow i) \right|}{P_{ext}(j \rightarrow i)} \, . +$$ + +For a **pool-wide** signal we take the **maximum** across all pairs: + +$$ +\delta_{max} = \max_{i 0$, the trade **increases** mispricing (pushes the pool away). This is more consistent with **noise** flow. + +Thus $\delta$ (and its directional change) is a natural **toxicity** proxy. + +### Arb vs Noise Parameterization + +The hook maintains **two independent parameter sets**: one for trades that **worsen deviation** ("noise") and one for trades that **improve deviation** ("arb"). Each has its own threshold, cap deviation, and maximum surge values. +- **Noise path**: uses post-trade deviation to determine the fee. +- **Arb path**: uses **pre-trade deviation** to determine the fee, rewarding price-improving flow with a distinct ramp profile. +--- + +## 4) Fee model and directionality + +### 4.1 Scalar surge as a function of deviation + +Let: +- $f_{base}$ be the pool’s static fee, +- $f_{max}$ be the max fee cap, +- $\tau \in (0,1)$ be the threshold where surge begins, +- $capDev \in (\tau,1]$ be the deviation where the fee reaches $f_{max}$. + +For a measured deviation $\delta$: + +$$ +span = capDev - \tau, \quad +prog = \min\!\left(1,\; \max\!\left(0, \frac{\delta - \tau}{span}\right)\right) +$$ + +$$ +f_{scalar}(\delta) = f_{base} + (f_{max}-f_{base})\cdot prog +$$ + +This yields a **linear ramp** from $f_{base}$ (for $\delta\le\tau$) up to $f_{max}$ (for $\delta\ge capDev$). + +### 4.2 Direction-aware application + +Let $\Delta\delta$ be computed **with post-trade balances**. Define + +$$ +dir = sign(\Delta\delta) \in \{-1,0,+1\} +$$ + +We apply the scalar surge **only** when the trade worsens deviation: + +$$ +f(\delta,\Delta\delta) = +\begin{cases} +f_{scalar}(\delta), & \Delta\delta > 0 \\ +\alpha \cdot f_{base}, & \Delta\delta \le 0 +\end{cases} +$$ + +where $\alpha \in [0,1]$ is an optional **arbitrage discount**. + + +### 4.3 Oracle Failure Handling + +If any oracle price is unavailable or returned as zero, the hook **falls back to the pool's static fee**. In these cases, surge and add/remove guards are disabled, effectively failing open to maintain liveness. + +--- + +## 5) Single-asset withdrawal and the **guard** + +Single-asset withdraws (and adds) are **non-proportional** and can materially **alter relative prices**. The hook implements a **conservative guard**: + +1. Reconstruct pre-change balances $\tilde{B}$ from post-change $B'$ and deltas $\Delta$. + - Add: $\tilde{B} = B' - \Delta$ + - Remove: $\tilde{B} = B' + \Delta$ +2. Compute $\delta_{before} = \delta(\tilde{B})$ and $\delta_{after} = \delta(B')$. +3. **Block** the operation if + +$$ +\delta_{after} > \delta_{before} \quad \text{and} \quad \delta_{after} > \tau +$$ + +Otherwise allow. + +Why conservative? +- Only block when deviation worsens and ends above threshold. +- Proportional adds/removes are always allowed. + +--- + +## 6) Practical configuration notes + +- **Threshold $\tau$.** Lower values = more sensitivity. +- **capDev.** Where max fee saturates. +- **Arb discount $\alpha$.** Optional. +- **Price mapping.** Normalize all Hyperliquid spots to $1e18$. + +--- + +## 7) Worked example + +Parameters: + +$$ +f_{base}=0.30\%,\; f_{max}=2.00\%,\; \tau=2\%,\; capDev=20\% +$$ + +Observed $\delta = 11\%$: + +$$ +prog=\frac{11-2}{20-2}=0.5 +$$ + +$$ +f_{scalar} = 0.30\% + (2.00\%-0.30\%)\cdot 0.5 = 1.15\% +$$ + +- If $\Delta\delta > 0$: applied fee = **1.15%** +- If $\Delta\delta \le 0$: applied fee = **0.30%** + +--- + +**Security note.** Protect setters with governance roles. \ No newline at end of file diff --git a/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol b/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol new file mode 100644 index 00000000..b055adec --- /dev/null +++ b/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol @@ -0,0 +1,689 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; +import { + PoolSwapParams, + LiquidityManagement, + TokenConfig, + HookFlags, + SwapKind, + AddLiquidityKind, + RemoveLiquidityKind +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol"; +import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol"; +import { SingletonAuthentication } from "@balancer-labs/v3-vault/contracts/SingletonAuthentication.sol"; +import { Version } from "@balancer-labs/v3-solidity-utils/contracts/helpers/Version.sol"; + +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; +import { + HyperSpotPricePrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperSpotPricePrecompile.sol"; +import { + HyperTokenInfoPrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperTokenInfoPrecompile.sol"; + +/// ----------------------------------------------------------------------- +/// Multitoken Hyper Surge Hook — struct-per-index configuration +/// ----------------------------------------------------------------------- +contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Version, IHyperSurgeHook { + using FixedPoint for uint256; + using SafeCast for uint256; + + error InvalidArrayLengths(); + error TokenIndexOutOfRange(); + error NumTokensOutOfRange(); + error InvalidPairIndex(); + error PoolNotInitialized(); + error InvalidDecimals(); + error InvalidSurgeFeePercentage(); + error InvalidThresholdDeviation(); + error InvalidCapDeviationPercentage(); + error InvalidPercentage(); + + struct TokenPriceCfg { + uint32 pairIndex; + uint32 tokenIndex; + uint8 sz; + } + + struct PoolDetails { + uint32 arbMaxSurgeFee9; + uint32 arbThresholdPercentage9; + uint32 arbCapDeviationPercentage9; + uint32 noiseMaxSurgeFee9; + uint32 noiseThresholdPercentage9; + uint32 noiseCapDeviationPercentage9; + uint8 numTokens; + } + + struct PoolCfg { + PoolDetails details; + TokenPriceCfg[8] tokenCfg; + } + + uint256 private constant MAX32 = uint256(type(uint32).max); + + mapping(address => PoolCfg) private _poolCfg; + + uint256 private immutable _defaultMaxSurgeFeePercentage18; + + uint256 private immutable _defaultThresholdPercentage18; + + uint256 private immutable _defaultCapDeviationPercentage18; + + constructor( + IVault vault, + uint256 defaultMaxSurgeFeePercentage18, + uint256 defaultThresholdPercentage18, + uint256 defaultCapDeviationPercentage18, + string memory version + ) SingletonAuthentication(vault) VaultGuard(vault) Version(version) { + _ensureValidPct(defaultMaxSurgeFeePercentage18); + _ensureValidPct(defaultThresholdPercentage18); + _ensureValidPct(defaultCapDeviationPercentage18); + _defaultMaxSurgeFeePercentage18 = defaultMaxSurgeFeePercentage18; + _defaultThresholdPercentage18 = defaultThresholdPercentage18; + _defaultCapDeviationPercentage18 = defaultCapDeviationPercentage18; + } + + ///@inheritdoc IHooks + function getHookFlags() public pure override returns (HookFlags memory hookFlags) { + hookFlags.shouldCallComputeDynamicSwapFee = true; + hookFlags.shouldCallAfterAddLiquidity = true; + hookFlags.shouldCallAfterRemoveLiquidity = true; + } + + /// @inheritdoc IHooks + function onRegister( + address, + address pool, + TokenConfig[] memory tokenCfgs, + LiquidityManagement calldata + ) public override onlyVault returns (bool) { + PoolDetails memory details; + if (tokenCfgs.length >= 2 && tokenCfgs.length <= 8) { + details.arbMaxSurgeFee9 = _safeConvertTo9Decimals(_defaultMaxSurgeFeePercentage18); + details.arbThresholdPercentage9 = _safeConvertTo9Decimals(_defaultThresholdPercentage18); + details.arbCapDeviationPercentage9 = _safeConvertTo9Decimals(_defaultCapDeviationPercentage18); + details.noiseMaxSurgeFee9 = _safeConvertTo9Decimals(_defaultMaxSurgeFeePercentage18); + details.noiseThresholdPercentage9 = _safeConvertTo9Decimals(_defaultThresholdPercentage18); + details.noiseCapDeviationPercentage9 = _safeConvertTo9Decimals(_defaultCapDeviationPercentage18); + + details.numTokens = uint8(tokenCfgs.length); + + _poolCfg[pool].details = details; + } else { + revert NumTokensOutOfRange(); + } + + return true; + } + + /// @notice Configure a single token’s Hyperliquid mapping for a given pool by token index (0..7). + /// @param pool The pool address to configure. + /// @param tokenIndex The balancer index of the token to configure (0..7). + /// @param hlPairIdx the index of the pair being set + /// @param hlTokenIdx the index of the token being set + function setTokenPriceConfigIndex( + address pool, + uint8 tokenIndex, + uint32 hlPairIdx, + uint32 hlTokenIdx + ) external onlySwapFeeManagerOrGovernance(pool) { + PoolDetails storage details = _poolCfg[pool].details; + _setTokenPriceConfigIndex(pool, tokenIndex, hlPairIdx, hlTokenIdx, details); + } + + function _setTokenPriceConfigIndex( + address pool, + uint8 tokenIndex, + uint32 hlPairIdx, + uint32 hlTokenIdx, + PoolDetails storage details + ) internal { + TokenPriceCfg memory tempCfg; + + if (hlPairIdx == 0) { + revert InvalidPairIndex(); + } + + if (tokenIndex >= details.numTokens) { + revert TokenIndexOutOfRange(); + } + + tempCfg.sz = HyperTokenInfoPrecompile.szDecimals(hlTokenIdx); + + if (tempCfg.sz > 8) { + revert InvalidDecimals(); + } + + tempCfg.pairIndex = hlPairIdx; + + _poolCfg[pool].tokenCfg[tokenIndex] = tempCfg; + + emit TokenPriceConfiguredIndex(pool, tokenIndex, tempCfg.pairIndex, hlTokenIdx, tempCfg.sz); + } + + struct SetBatchConfigs { + TokenPriceCfg tempCfg; + uint256 i; + } + + /// @notice Batch version (indices). + /// @param pool the pool address + /// @param tokenIndices the indices of the token configs being changed + /// @param pairIdx the index of the pair being changed + function setTokenPriceConfigBatchIndex( + address pool, + uint8[] calldata tokenIndices, + uint32[] calldata pairIdx, + uint32[] calldata hlTokenIdx + ) external onlySwapFeeManagerOrGovernance(pool) { + PoolDetails storage detail = _poolCfg[pool].details; + SetBatchConfigs memory cfg; + + if (tokenIndices.length != pairIdx.length) { + revert InvalidArrayLengths(); + } + + for (cfg.i = 0; cfg.i < tokenIndices.length; ++cfg.i) { + _setTokenPriceConfigIndex(pool, tokenIndices[cfg.i], pairIdx[cfg.i], hlTokenIdx[cfg.i], detail); + } + } + + ///@inheritdoc IHyperSurgeHook + function setMaxSurgeFeePercentage( + address pool, + uint256 pct18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) { + _ensureValidPct(pct18); + + if (tradeType == TradeType.ARBITRAGE) { + _poolCfg[pool].details.arbMaxSurgeFee9 = _safeConvertTo9Decimals(pct18); + } else { + _poolCfg[pool].details.noiseMaxSurgeFee9 = _safeConvertTo9Decimals(pct18); + } + + emit MaxSurgeFeePercentageChanged(msg.sender, pool, pct18, tradeType); + } + + ///@inheritdoc IHyperSurgeHook + function setSurgeThresholdPercentage( + address pool, + uint256 pct18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) { + _ensureValidPct(pct18); // keep a valid ramp span: threshold < capDev ≤ 1 + uint32 capDev; + PoolDetails memory poolDetails = _poolCfg[pool].details; + if (tradeType == TradeType.ARBITRAGE) { + poolDetails.arbThresholdPercentage9 = _safeConvertTo9Decimals(pct18); + capDev = poolDetails.arbCapDeviationPercentage9; + } else { + poolDetails.noiseThresholdPercentage9 = _safeConvertTo9Decimals(pct18); + capDev = poolDetails.noiseCapDeviationPercentage9; + } + + uint256 capDev18 = _convertTo18Decimals(capDev); + //could be done before with two if/elses but more compact code this way + if (capDev18 != 0 && pct18 >= capDev18) { + revert InvalidThresholdDeviation(); + } + + _poolCfg[pool].details = poolDetails; + + emit ThresholdPercentageChanged(msg.sender, pool, pct18, tradeType); + } + + /// @inheritdoc IHyperSurgeHook + function setCapDeviationPercentage( + address pool, + uint256 capDevPct18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) { + _ensureValidPct(capDevPct18); + uint32 thr; + PoolDetails memory poolDetails = _poolCfg[pool].details; + if (tradeType == TradeType.ARBITRAGE) { + poolDetails.arbCapDeviationPercentage9 = _safeConvertTo9Decimals(capDevPct18); + thr = poolDetails.arbThresholdPercentage9; + } else { + poolDetails.noiseCapDeviationPercentage9 = _safeConvertTo9Decimals(capDevPct18); + thr = poolDetails.noiseThresholdPercentage9; + } + + uint256 thr18 = _convertTo18Decimals(thr); + + if (capDevPct18 <= thr18) { + revert InvalidCapDeviationPercentage(); + } + + _poolCfg[pool].details = poolDetails; + + emit CapDeviationPercentageChanged(msg.sender, pool, capDevPct18, tradeType); + } + + struct AddLiquidityLocals { + uint256[] oldBalances; + uint256 beforeDev; + uint256 afterDev; + uint256 threshold; + bool isWorseningSurge; + } + + /// @notice Allow proportional adds, but block non-proportional adds that worsen deviation and end above threshold. + function onAfterAddLiquidity( + address, + address pool, + AddLiquidityKind kind, + uint256[] memory amountsInScaled18, + uint256[] memory amountsInRaw, + uint256, // lpAmount (unused) + uint256[] memory balancesScaled18, + bytes memory // userData (unused) + ) public view override returns (bool success, uint256[] memory hookAdjustedAmountsInRaw) { + AddLiquidityLocals memory locals; + + // Proportional add is always allowed. + if (kind == AddLiquidityKind.PROPORTIONAL) { + return (true, amountsInRaw); + } + + locals.oldBalances = new uint256[](balancesScaled18.length); + for (uint256 i = 0; i < balancesScaled18.length; ++i) { + locals.oldBalances[i] = balancesScaled18[i] - amountsInScaled18[i]; + } + + uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); + locals.beforeDev = _computeOracleDeviationPct(pool, locals.oldBalances, weights); + locals.afterDev = _computeOracleDeviationPct(pool, balancesScaled18, weights); + locals.threshold = getSurgeThresholdPercentage(pool, TradeType.NOISE); + + // Block only if deviation worsens AND exceeds threshold after the change. + locals.isWorseningSurge = (locals.afterDev > locals.beforeDev) && (locals.afterDev > locals.threshold); + + return (!locals.isWorseningSurge, amountsInRaw); + } + + struct RemoveLiquidityLocals { + uint256 n; + uint256[] oldBalances; + uint256 beforeDev; + uint256 afterDev; + uint256 threshold; + bool isWorseningSurge; + } + + /// @notice Allow proportional removes, but block non-proportional removes that worsen deviation and end above threshold. + function onAfterRemoveLiquidity( + address, + address pool, + RemoveLiquidityKind kind, + uint256, // lpAmount (unused) + uint256[] memory amountsOutScaled18, + uint256[] memory amountsOutRaw, + uint256[] memory balancesScaled18, + bytes memory // userData (unused) + ) public view override returns (bool success, uint256[] memory hookAdjustedAmountsOutRaw) { + RemoveLiquidityLocals memory locals; + locals.n = balancesScaled18.length; + // Proportional remove is always allowed. should we check? + if (kind == RemoveLiquidityKind.PROPORTIONAL) { + return (true, amountsOutRaw); + } + + // Reconstruct pre-remove balances = post + out; if addition overflows, allow. + locals.oldBalances = new uint256[](locals.n); + for (uint256 i = 0; i < locals.n; ++i) { + locals.oldBalances[i] = balancesScaled18[i] + amountsOutScaled18[i]; + } + + uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); + locals.beforeDev = _computeOracleDeviationPct(pool, locals.oldBalances, weights); + locals.afterDev = _computeOracleDeviationPct(pool, balancesScaled18, weights); + locals.threshold = getSurgeThresholdPercentage(pool, TradeType.NOISE); + + locals.isWorseningSurge = (locals.afterDev > locals.beforeDev) && (locals.afterDev > locals.threshold); + + return (!locals.isWorseningSurge, amountsOutRaw); + } + + /// @notice Getter to read the pool-specific surge threshold (1e18 = 100%). + function getSurgeThresholdPercentage(address pool, TradeType tradeType) public view override returns (uint256) { + if (tradeType == TradeType.ARBITRAGE) { + return _convertTo18Decimals(_poolCfg[pool].details.arbThresholdPercentage9); + } else { + return _convertTo18Decimals(_poolCfg[pool].details.noiseThresholdPercentage9); + } + } + + ///@inheritdoc IHyperSurgeHook + function getMaxSurgeFeePercentage(address pool, TradeType tradeType) external view override returns (uint256) { + if (tradeType == TradeType.ARBITRAGE) { + return _convertTo18Decimals(_poolCfg[pool].details.arbMaxSurgeFee9); + } else { + return _convertTo18Decimals(_poolCfg[pool].details.noiseMaxSurgeFee9); + } + } + + ///@inheritdoc IHyperSurgeHook + function getCapDeviationPercentage(address pool, TradeType tradeType) external view override returns (uint256) { + if (tradeType == TradeType.ARBITRAGE) { + return _convertTo18Decimals(_poolCfg[pool].details.arbCapDeviationPercentage9); + } else { + return _convertTo18Decimals(_poolCfg[pool].details.noiseCapDeviationPercentage9); + } + } + + ///@inheritdoc IHyperSurgeHook + function getTokenPriceConfigIndex( + address pool, + uint8 tokenIndex + ) external view override returns (uint32 pairIndex, uint32 priceDivisor) { + TokenPriceCfg memory cfg = _poolCfg[pool].tokenCfg[tokenIndex]; + return (cfg.pairIndex, _divisorFromSz(cfg.sz)); + } + + ///@inheritdoc IHyperSurgeHook + function getTokenPriceConfigs( + address pool + ) external view override returns (uint32[] memory pairIndexArr, uint32[] memory priceDivisorArr) { + PoolDetails memory details = _poolCfg[pool].details; + + pairIndexArr = new uint32[](details.numTokens); + priceDivisorArr = new uint32[](details.numTokens); + + for (uint8 i = 0; i < details.numTokens; ++i) { + TokenPriceCfg memory cfg = _poolCfg[pool].tokenCfg[i]; + pairIndexArr[i] = cfg.pairIndex; + priceDivisorArr[i] = _divisorFromSz(cfg.sz); + } + } + + ///@inheritdoc IHyperSurgeHook + function getDefaultMaxSurgeFeePercentage() external view override returns (uint256) { + return _defaultMaxSurgeFeePercentage18; + } + + ///@inheritdoc IHyperSurgeHook + function getDefaultSurgeThresholdPercentage() external view override returns (uint256) { + return _defaultThresholdPercentage18; + } + + ///@inheritdoc IHyperSurgeHook + function getDefaultCapDeviationPercentage() external view override returns (uint256) { + return _defaultCapDeviationPercentage18; + } + + ///@inheritdoc IHyperSurgeHook + function getNumTokens(address pool) external view override returns (uint8) { + return _poolCfg[pool].details.numTokens; + } + + struct ComputeSurgeFeeLocals { + uint256 calcAmountScaled18; + uint256 poolPxBefore; + uint256 poolPx; + uint256 pxIn; + uint256 pxOut; + uint256 extPx; + uint256 deviationBefore18; + uint256 deviation18; + uint256 threshold18; + uint256 maxPct18; + uint256 increment; + uint256 surgeFee18; + uint256 capDevPct18; + uint256 bIn; + uint256 bOut; + uint256 rawIn; + uint256 rawOut; + uint256 wIn; + uint256 wOut; + uint256 span; + uint256 norm; + PoolDetails poolDetails; + } + + /// @inheritdoc IHooks + function onComputeDynamicSwapFeePercentage( + PoolSwapParams calldata p, + address pool, + uint256 staticSwapFee + ) public view override returns (bool, uint256) { + PoolCfg storage pc = _poolCfg[pool]; + ComputeSurgeFeeLocals memory locals; + locals.poolDetails = pc.details; + + uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); + locals.wIn = weights[p.indexIn]; + locals.wOut = weights[p.indexOut]; + + locals.calcAmountScaled18 = WeightedPool(pool).onSwap(p); + + TokenPriceCfg memory pInCfg = pc.tokenCfg[p.indexIn]; + TokenPriceCfg memory pOutCfg = pc.tokenCfg[p.indexOut]; + + locals.rawIn = HyperSpotPricePrecompile.spotPrice(pInCfg.pairIndex); + locals.rawOut = HyperSpotPricePrecompile.spotPrice(pOutCfg.pairIndex); + locals.pxIn = locals.rawIn.divDown(_divisorFromSz(pInCfg.sz)); + locals.pxOut = locals.rawOut.divDown(_divisorFromSz(pOutCfg.sz)); + locals.bIn = p.balancesScaled18[p.indexIn]; + locals.bOut = p.balancesScaled18[p.indexOut]; + + return _computeSurgeFee(locals, p, staticSwapFee); + } + + /// @notice pure function to compute surge fee + /// @param locals the locals struct containing all the necessary variables + /// @param p swap parameters + /// @param staticSwapFee the static swap fee from the pool + function _computeSurgeFee( + ComputeSurgeFeeLocals memory locals, + PoolSwapParams calldata p, + uint256 staticSwapFee + ) internal pure returns (bool ok, uint256 surgeFee) { + locals.extPx = locals.pxOut.divDown(locals.pxIn); + + //Do not block if there is an issue with the hyperliquid price + if (locals.extPx == 0) { + return (true, staticSwapFee); + } + + locals.poolPxBefore = _pairSpotFromBalancesWeights(locals.bIn, locals.wIn, locals.bOut, locals.wOut); + locals.deviationBefore18 = _relAbsDiff(locals.poolPxBefore, locals.extPx); + + if (p.kind == SwapKind.EXACT_IN) { + locals.bIn += p.amountGivenScaled18; + locals.bOut -= locals.calcAmountScaled18; + } else { + locals.bIn += locals.calcAmountScaled18; + locals.bOut -= p.amountGivenScaled18; + } + + // P_pool = (B_out/w_out) / (B_in/w_in) = (B_out * w_in) / (B_in * w_out) + locals.poolPx = _pairSpotFromBalancesWeights(locals.bIn, locals.wIn, locals.bOut, locals.wOut); + locals.deviation18 = _relAbsDiff(locals.poolPx, locals.extPx); // |pool - ext| / ext + + if (locals.deviation18 > locals.deviationBefore18) { + locals.capDevPct18 = _convertTo18Decimals(locals.poolDetails.noiseCapDeviationPercentage9); + locals.maxPct18 = _convertTo18Decimals(locals.poolDetails.noiseMaxSurgeFee9); + locals.threshold18 = _convertTo18Decimals(locals.poolDetails.noiseThresholdPercentage9); + } else { + locals.capDevPct18 = _convertTo18Decimals(locals.poolDetails.arbCapDeviationPercentage9); + locals.maxPct18 = _convertTo18Decimals(locals.poolDetails.arbMaxSurgeFee9); + locals.threshold18 = _convertTo18Decimals(locals.poolDetails.arbThresholdPercentage9); + + //For the arbitrage direction we use the deviation before. + //Why this is the case is in the readme but in essence + //if a large noise deviation is being corrected the arbitrage pays more + //to take advantage of the larger arb opp and therefore greater profit + //as the fee decreases the closer you get to market price, another + //arb opportunity presents itself once the first arb is taken + //this means a large fee != a large no arb region and the pool stays close to market + locals.deviation18 = locals.deviationBefore18; + } + + if (locals.deviation18 <= locals.threshold18) { + return (true, staticSwapFee); + } + + locals.span = locals.capDevPct18 - locals.threshold18; // > 0 by fallback above + locals.norm = (locals.deviation18 - locals.threshold18).divDown(locals.span); + + if (locals.norm > FixedPoint.ONE) { + locals.norm = FixedPoint.ONE; + } + + locals.increment = (locals.maxPct18 - staticSwapFee).mulDown(locals.norm); + locals.surgeFee18 = staticSwapFee + locals.increment; + + return (true, locals.surgeFee18); + } + + function _pairSpotFromBalancesWeights( + uint256 bIn, + uint256 wIn, + uint256 bOut, + uint256 wOut + ) internal pure returns (uint256) { + uint256 num = bOut.mulDown(wIn); + uint256 den = bIn.mulDown(wOut); + + //would be impossible given normal balances and weights but given + //it is on the withdraw path keep the defensive check + if (den == 0) { + return 0; + } + + return num.divDown(den); + } + + function _relAbsDiff(uint256 a, uint256 b) internal pure returns (uint256) { + if (a > b) { + return (a - b).divDown(b); + } + return (b - a).divDown(b); + } + + function _divisorFromSz(uint32 s) internal pure returns (uint32) { + // s in [0..8], divisor = 10**(8 - s) + // LUT avoids EXP cost both at config and (especially) runtime. + if (s == 0) return 100_000_000; + if (s == 1) return 10_000_000; + if (s == 2) return 1_000_000; + if (s == 3) return 100_000; + if (s == 4) return 10_000; + if (s == 5) return 1_000; + if (s == 6) return 100; + if (s == 7) return 10; + // s == 8 + return 1; + } + + function _ensureValidPct(uint256 pct) internal pure { + if (pct < 1e9 || pct > 1e18 || pct % 1e9 != 0) { + revert InvalidPercentage(); + } + } + + ///@notice Converts a 9 decimal places fixed point number to 18 decimal places. + function _convertTo18Decimals(uint32 setting9Dp) internal pure returns (uint256) { + return uint256(setting9Dp) * 1e9; + } + + function _safeConvertTo9Decimals(uint256 setting18Dp) internal pure returns (uint32) { + return (setting18Dp / 1e9).toUint32(); + } + + struct ComputeOracleDeviationLocals { + uint256[8] px; + uint256 maxDev; + uint256 raw; + uint256 i; + uint256 j; + uint256 bi; + uint256 wi; + uint256 pxi; + uint256 bj; + uint256 wj; + uint256 pxj; + uint256 poolPx; + uint256 extPx; + uint256 dev; + uint256 priceDivisor; + } + + /// @dev Computes the pool-wide oracle deviation as the MAX pairwise deviation + /// across all token pairs (ij) - P_ext(i->j)| / P_ext(i->j). + /// Uses the same spot & external price conventions as the swap-fee compute. + function _computeOracleDeviationPct( + address pool, + uint256[] memory balancesScaled18, + uint256[] memory w + ) internal view returns (uint256 maxDev) { + ComputeOracleDeviationLocals memory locals; + PoolCfg memory pc = _poolCfg[pool]; + + // Build external prices per token (1e18). Missing/zero -> mark as 0 (skipped). + for (locals.i = 0; locals.i < balancesScaled18.length; ++locals.i) { + TokenPriceCfg memory cfg = pc.tokenCfg[locals.i]; + if (cfg.pairIndex != 0) { + locals.raw = HyperSpotPricePrecompile.spotPrice(cfg.pairIndex); // reverts if precompile fails + if (locals.raw != 0) { + locals.priceDivisor = _divisorFromSz(cfg.sz); + if (locals.priceDivisor != 0) { + locals.px[locals.i] = uint256(locals.raw).divDown(uint256(locals.priceDivisor)); + } + } + } + } + + return _findMaxDeviation(locals, balancesScaled18, w); + } + + function _findMaxDeviation( + ComputeOracleDeviationLocals memory locals, + uint256[] memory balancesScaled18, + uint256[] memory w + ) internal pure returns (uint256) { + // Pairwise check (O(n^2), n<=8). + for (locals.i = 0; locals.i < balancesScaled18.length; ++locals.i) { + locals.bi = balancesScaled18[locals.i]; + locals.wi = w[locals.i]; + locals.pxi = locals.px[locals.i]; + + for (locals.j = locals.i + 1; locals.j < balancesScaled18.length; ++locals.j) { + locals.bj = balancesScaled18[locals.j]; + locals.wj = w[locals.j]; + locals.pxj = locals.px[locals.j]; + + // Pool-implied spot for j vs i: (Bj/wj) / (Bi/wi) + locals.poolPx = _pairSpotFromBalancesWeights(locals.bj, locals.wj, locals.bi, locals.wi); + + if (locals.poolPx == 0) { + continue; + } + + // External ratio j/i + locals.extPx = locals.pxj.divDown(locals.pxi); + locals.dev = _relAbsDiff(locals.poolPx, locals.extPx); + + if (locals.dev > locals.maxDev) { + locals.maxDev = locals.dev; + } + } + } + + return locals.maxDev; + } +} diff --git a/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol b/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol new file mode 100644 index 00000000..130d63ac --- /dev/null +++ b/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { HyperSurgeHook } from "../hooks-quantamm/HyperSurgeHook.sol"; +import { PoolSwapParams } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +/// @notice Thin test/mock wrapper around HyperSurgeHook. +/// @dev Intentionally does not change any logic — it only exposes a distinct type +/// that your deployer/tests can target (mirroring StableSurgeHookMock usage). +contract HyperSurgeHookMock is HyperSurgeHook { + constructor( + IVault vault, + uint256 defaultMaxSurgeFeePercentage, + uint256 defaultThresholdPercentage, + uint256 defaultCapDeviation, + string memory version + ) HyperSurgeHook(vault, defaultMaxSurgeFeePercentage, defaultThresholdPercentage, defaultCapDeviation, version) {} + + function ComputeOracleDeviationPct( + address pool, + uint256[] memory balancesScaled18, + uint256[] memory w + ) external view returns (uint256 maxDev) { + return _computeOracleDeviationPct(pool, balancesScaled18, w); + } + + function FindMaxDeviation( + HyperSurgeHook.ComputeOracleDeviationLocals memory locals, + uint256[] memory balancesScaled18, + uint256[] memory w + ) external pure returns (uint256) { + return _findMaxDeviation(locals, balancesScaled18, w); + } + + function PairSpotFromBalancesWeights( + uint256 bIn, + uint256 wIn, + uint256 bOut, + uint256 wOut + ) external pure returns (uint256) { + return _pairSpotFromBalancesWeights(bIn, wIn, bOut, wOut); + } + + function RelAbsDiff(uint256 a, uint256 b) external pure returns (uint256) { + return _relAbsDiff(a, b); + } + + function DivisorFromSz(uint8 s) external pure returns (uint32) { + return _divisorFromSz(s); + } + + function EnsureValidPct(uint256 pct) external pure { + _ensureValidPct(pct); + } + + function ComputeSurgeFee( + ComputeSurgeFeeLocals memory locals, + PoolSwapParams calldata p, + uint256 staticSwapFee + ) external pure returns (bool ok, uint256 surgeFee) { + return _computeSurgeFee(locals, p, staticSwapFee); + } +} diff --git a/pkg/pool-hooks/foundry.toml b/pkg/pool-hooks/foundry.toml index 4b6bf79e..c56461b8 100755 --- a/pkg/pool-hooks/foundry.toml +++ b/pkg/pool-hooks/foundry.toml @@ -40,7 +40,7 @@ runs = 1000 max_test_rejects = 60000 [profile.coverage.fuzz] -runs = 100 +runs = 1000 max_test_rejects = 60000 [profile.intense.fuzz] @@ -48,6 +48,7 @@ verbosity = 3 runs = 100000 max_test_rejects = 600000 + [rpc_endpoints] mainnet = "${MAINNET_RPC_URL}" sepolia = "${SEPOLIA_RPC_URL}" diff --git a/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol b/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol new file mode 100644 index 00000000..3599bf3d --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol @@ -0,0 +1,1184 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +// Base test utilities (provides: vault, pool, poolFactory, admin, authorizer, routers, tokens, etc.) +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; + +// Hook interfaces +import { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; +import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; +import { IAuthorizer } from "@balancer-labs/v3-interfaces/contracts/vault/IAuthorizer.sol"; + +// Vault interfaces/types +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { + TokenConfig, + LiquidityManagement, + PoolSwapParams, + SwapKind, + PoolRoleAccounts, + HookFlags +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +// Local deployer + mock +import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.sol"; +import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; +import { HyperSurgeHook } from "../../contracts/hooks-quantamm/HyperSurgeHook.sol"; +import { + WeightedPoolContractsDeployer +} from "@balancer-labs/v3-pool-weighted/test/foundry/utils/WeightedPoolContractsDeployer.sol"; +import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; + +import { + HyperSpotPricePrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperSpotPricePrecompile.sol"; +import { + HyperTokenInfoPrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperTokenInfoPrecompile.sol"; +import { + HypercorePrecompileMock +} from "@balancer-labs/v3-standalone-utils/test/foundry/utils/HypercorePrecompileMock.sol"; + +contract HLPriceStub { + mapping(uint32 => uint32) internal px; // slot 0 + + fallback(bytes calldata data) external returns (bytes memory ret) { + uint32 pairIndex = abi.decode(data, (uint32)); + return abi.encode(px[pairIndex]); + } + + function set(uint32 pairIndex, uint32 price_1e6) external { + px[pairIndex] = price_1e6; + } +} + +contract HLTokenInfoStub { + mapping(uint32 => uint8) internal sz; // slot 0 + + mapping(uint32 => HyperTokenInfoPrecompile.HyperTokenInfo) internal info; // slot 0 + + // Optional but nice for staticcall patterns: + fallback(bytes calldata data) external returns (bytes memory ret) { + uint32 tokenIndex = abi.decode(data, (uint32)); + + // Read stored record and ensure the struct fields exist + HyperTokenInfoPrecompile.HyperTokenInfo memory t; + + // Copy only what you care about; others can be zero/empty + t.szDecimals = sz[tokenIndex]; + + return abi.encode(t); // <<< return the STRUCT + } + + function set(uint32 pairIndex, uint8 decimals) external { + sz[pairIndex] = decimals; + } +} + +contract HyperSurgeAdminTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoolContractsDeployer { + using ArrayHelpers for *; + using CastingHelpers for address[]; + + uint256 constant ONE = 1e18; + + uint256 internal constant DEFAULT_SWAP_FEE = 1e16; // 1% + + HyperSurgeHookMock internal hook; + + HLPriceStub internal _pxStubDeployer; + HLTokenInfoStub internal _infoStubDeployer; + + function _createPool( + address[] memory tokens, + string memory label + ) internal override returns (address newPool, bytes memory poolArgs) { + // Create a Weighted Pool with the given tokens and default weights. + + if (weights.length == 0 || weights.length != tokens.length) { + weights = new uint256[](tokens.length); + + for (uint256 i = 0; i < tokens.length; i++) { + weights[i] = 1e18 / tokens.length; // Equal weights + } + } + + LiquidityManagement memory liquidityManagement; + PoolRoleAccounts memory roleAccounts; + roleAccounts.poolCreator = admin; + roleAccounts.swapFeeManager = admin; + + WeightedPool.NewPoolParams memory params = WeightedPool.NewPoolParams({ + name: label, + symbol: "WPOOL", + numTokens: tokens.length, + normalizedWeights: weights, + version: "1.0" + }); + + newPool = address(deployWeightedPoolMock(params, IVault(vault))); + + vault.registerPool( + newPool, + vault.buildTokenConfig(tokens.asIERC20()), + DEFAULT_SWAP_FEE, + 0, + false, + roleAccounts, + address(0), + liquidityManagement + ); + + poolArgs = abi.encode( + WeightedPool.NewPoolParams({ + name: label, + symbol: "WPOOL", + numTokens: tokens.length, + normalizedWeights: weights, + version: "1.0" + }), + vault + ); + } + + function setUp() public virtual override { + super.setUp(); // vault, pool, poolFactory, admin, authorizer, tokens, routers, ... + + vm.prank(address(poolFactory)); // some repos require factory to deploy + hook = deployHook( + IVault(address(vault)), + 0.02e18, // default max fee (2%) + 0.02e18, // default threshold (2%) + 1e18, + string("test") + ); + + // 2) Install precompile stubs at fixed addresses + _pxStubDeployer = new HLPriceStub(); + _infoStubDeployer = new HLTokenInfoStub(); + vm.etch(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, address(_pxStubDeployer).code); + vm.etch(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, address(_infoStubDeployer).code); + + // Seed a couple of pairs (pairIndex 1 and 2) + _hlSetSzDecimals(1, 6); + _hlSetSzDecimals(2, 6); + _hlSetSpot(1, 100_000_000); // 100.000000 (1e6 scale) + _hlSetSpot(2, 200_000_000); // 200.000000 (1e6 scale) + + // 3) Grant admin roles to `admin` + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setMaxSurgeFeePercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setSurgeThresholdPercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setCapDeviationPercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigIndex.selector), + admin + ); + } + + /// @notice Register the BaseVaultTest pool with a fuzzed token count n (2..8). + function _registerBasePoolWithN(uint8 n) internal returns (uint8 tokenCount) { + n = uint8(bound(n, 2, 8)); + + TokenConfig[] memory cfg = new TokenConfig[](n); + LiquidityManagement memory lm; + vm.prank(address(vault)); // onRegister is onlyVault + bool ok = hook.onRegister(poolFactory, address(pool), cfg, lm); + assertTrue(ok, "onRegister(base pool) failed"); + return n; + } + + function _hlSetSpot(uint32 pairIdx, uint32 price_1e6) internal { + bytes32 slot = keccak256(abi.encode(bytes32(uint256(pairIdx)), bytes32(uint256(0)))); + vm.store(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, slot, bytes32(uint256(price_1e6))); + } + + function _hlSetSzDecimals(uint32 pairIdx, uint8 sz) internal { + bytes32 slot = keccak256(abi.encode(bytes32(uint256(pairIdx)), bytes32(uint256(0)))); + vm.store(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, slot, bytes32(uint256(sz))); + } + + /// @notice Registering a pool sets lane defaults for N tokens; re-registering resets mutated values to defaults. + /// @dev Bounds: `n ∈ [2,8]` to match Balancer V3 pool sizes; `tradeTypeInt ∈ {0,1}` for {ARB,NOISE}. + /// Verifies that getters return 1e18-scaled defaults derived from constructor ppm9 params, then + /// confirms that mutating params and calling `onRegister` again restores default values. + /// @param n Number of tokens requested via TokenConfig length. + /// @param tradeTypeInt Lane selector as uint8 (0=ARB, 1=NOISE). + function testFuzz_onRegister_withN_setsDefaults_and_second_overwrites_to_defaults( + uint8 n, + uint8 tradeTypeInt + ) public { + // First registration for base pool with fuzzed N tokens + n = _registerBasePoolWithN(n); + IHyperSurgeHook.TradeType tradeType = IHyperSurgeHook.TradeType(bound(tradeTypeInt, 0, 1)); + + // Defaults (from constructor) are set + assertEq(hook.getMaxSurgeFeePercentage(address(pool), tradeType), 0.02e18, "default max mismatch"); + assertEq(hook.getSurgeThresholdPercentage(address(pool), tradeType), 0.02e18, "default threshold mismatch"); + assertEq(hook.getCapDeviationPercentage(address(pool), tradeType), 1e18, "default capDev mismatch"); + + // Change to custom values + vm.startPrank(admin); + + hook.setMaxSurgeFeePercentage(address(pool), 0.50e18, tradeType); + hook.setSurgeThresholdPercentage(address(pool), 0.10e18, tradeType); + hook.setCapDeviationPercentage(address(pool), 0.90e18, tradeType); + vm.stopPrank(); + + // Re-register the SAME pool: impl resets values back to defaults (observed behavior) + TokenConfig[] memory cfg = new TokenConfig[](n); + LiquidityManagement memory lm; + vm.prank(address(vault)); + hook.onRegister(poolFactory, address(pool), cfg, lm); + + // Assert they were clobbered back to constructor defaults + assertEq( + hook.getMaxSurgeFeePercentage(address(pool), tradeType), + 0.02e18, + "re-register should reset max to default" + ); + assertEq( + hook.getSurgeThresholdPercentage(address(pool), tradeType), + 0.02e18, + "re-register should reset threshold to default" + ); + assertEq( + hook.getCapDeviationPercentage(address(pool), tradeType), + 1e18, + "re-register should reset capDev to default" + ); + } + + /// @notice Cap deviation must be > threshold and within (0, 100%] in ppm9 when threshold is zero. + /// @dev Bounds: fuzz `capDev ∈ [0, 1e18]`; accept `0 < capDev ≤ 1e9`, revert on `capDev == 0` or `capDev > 1e9`. + /// @param n Pool size (2..8). + /// @param capDev Cap deviation in ppm9. + /// @param tradeTypeInt Lane selector (0=ARB,1=NOISE). + function testFuzz_setCapDeviationPercentage_bounds_withThrZero(uint8 n, uint256 capDev, uint8 tradeTypeInt) public { + n = _registerBasePoolWithN(n); + IHyperSurgeHook.TradeType tradeType = IHyperSurgeHook.TradeType(bound(tradeTypeInt, 0, 1)); + + vm.startPrank(admin); + hook.setSurgeThresholdPercentage(address(pool), 1e9, tradeType); + + capDev = bound(capDev, 1, ONE + 1e20); + if (capDev > 1e18) { + vm.expectRevert(); // violates capDev <= 1e18 + hook.setCapDeviationPercentage(address(pool), capDev, tradeType); + } else if (capDev <= 1e9 || (capDev > 1e9 && (capDev / 1e9) * 1e9 != capDev)) { + vm.expectRevert(); // violates capDev <= 1e18 + hook.setCapDeviationPercentage(address(pool), capDev, tradeType); + } else { + hook.setCapDeviationPercentage(address(pool), capDev, tradeType); + assertEq(hook.getCapDeviationPercentage(address(pool), tradeType), capDev); + } + vm.stopPrank(); + } + + /// @notice Cap deviation must remain strictly greater than threshold (positive separation). + /// @dev Fuzzes `thr` and `capDev` in ppm9 and asserts acceptance only when `capDev > thr`. + /// @param n Pool size (2..8). + function testFuzz_setCapDeviation_enforces_gt_threshold( + uint8 n, + uint256 thr, + uint256 capDev, + uint8 tradeTypeInt + ) public { + n = _registerBasePoolWithN(n); + IHyperSurgeHook.TradeType tradeType = IHyperSurgeHook.TradeType(bound(tradeTypeInt, 0, 1)); + + thr = bound(thr, 1, 1e9 - 1); // valid threshold + capDev = bound(capDev, thr + 1, 1e9); // valid capDev (>thr, less than or equal1e18) + + vm.startPrank(admin); + hook.setSurgeThresholdPercentage(address(pool), thr * 1e9, tradeType); + hook.setCapDeviationPercentage(address(pool), capDev * 1e9, tradeType); + assertEq(hook.getCapDeviationPercentage(address(pool), tradeType), capDev * 1e9); + vm.stopPrank(); + } + + /// @notice Setting cap deviation ≤ threshold is rejected for safety. + /// @dev Exercises the non-strict and reverse cases (`capDev == thr` or `< thr`) to ensure revert. + /// @param n Pool size (2..8). + function testFuzz_setCapDeviation_rejects_le_threshold( + uint8 n, + uint256 thr, + uint256 capDev, + uint8 tradeTypeInt + ) public { + n = _registerBasePoolWithN(n); + IHyperSurgeHook.TradeType tradeType = IHyperSurgeHook.TradeType(bound(tradeTypeInt, 0, 1)); + + thr = bound(thr, 1, 1e9 - 1); // ensure setting thr succeeds + capDev = bound(capDev, 1, thr); // invalid: capDev <= thr + + vm.startPrank(admin); + hook.setSurgeThresholdPercentage(address(pool), thr * 1e9, tradeType); + vm.expectRevert(); + hook.setCapDeviationPercentage(address(pool), capDev * 1e9, tradeType); + vm.stopPrank(); + } + + // Default capDev is 100% after registration + function testFuzz_defaults_include_capDev_at_100_percent(uint8 n, uint8 tradeTypeInt) public { + n = _registerBasePoolWithN(n); + IHyperSurgeHook.TradeType tradeType = IHyperSurgeHook.TradeType(bound(tradeTypeInt, 0, 1)); + + assertEq(hook.getCapDeviationPercentage(address(pool), tradeType), ONE); + } + + function testFuzz_setTokenPriceConfigIndex_rejects_out_of_range(uint8 n, uint8 idx) public { + _registerBasePoolWithN(n); + n = uint8(bound(n, 2, 8)); + idx = uint8(bound(idx, n, n + 20)); // out-of-range + + vm.startPrank(admin); + vm.expectRevert(); + hook.setTokenPriceConfigIndex(address(pool), idx, 0, 0); + vm.stopPrank(); + } + + function testFuzz_setTokenPriceConfigIndex_accepts(uint8 n, uint8 idx, uint32 pairIdx) public { + n = _registerBasePoolWithN(n); + idx = uint8(bound(idx, 0, n - 1)); + pairIdx = uint32(bound(pairIdx, 21, type(uint32).max - 20)); // non-zero for pair mapping + + vm.startPrank(admin); + hook.setTokenPriceConfigIndex(address(pool), idx, pairIdx, pairIdx + 20); // pair mapping + vm.stopPrank(); + } + + /// @notice Max surge fee must be within [0, 100%] in ppm9 and persisted in storage. + /// @dev Bounds: fuzz `pct ∈ [0, 1e18]`, but acceptance is `pct ≤ 1e9` (100% in ppm9). + /// Reverts when `pct > 1e9`, otherwise stores and getter returns `pct * 1e9`. + /// @param n Pool size (2..8). + /// @param pct Candidate max fee in ppm9 units. + /// @param tradeTypeInt Lane selector (0=ARB,1=NOISE). + function testFuzz_setMaxSurgeFeePercentage_bounds(uint8 n, uint256 pct, uint8 tradeTypeInt) public { + _registerBasePoolWithN(n); + pct = bound(pct, 0, ONE); + IHyperSurgeHook.TradeType tradeType = IHyperSurgeHook.TradeType(bound(tradeTypeInt, 0, 1)); + + vm.startPrank(admin); + if (pct > 1e18) { + vm.expectRevert(); + hook.setMaxSurgeFeePercentage(address(pool), pct, tradeType); + } else if (pct < 1e9 || (pct > 1e9 && (pct / 1e9) * 1e9 != pct)) { + vm.expectRevert(); + hook.setMaxSurgeFeePercentage(address(pool), pct, tradeType); + } else { + hook.setMaxSurgeFeePercentage(address(pool), pct, tradeType); + assertEq(hook.getMaxSurgeFeePercentage(address(pool), tradeType), pct); + } + vm.stopPrank(); + } + + function testFuzz_setSurgeThresholdPercentage_bounds(uint8 n, uint256 thr, uint8 tradeTypeInt) public { + _registerBasePoolWithN(n); + IHyperSurgeHook.TradeType tradeType = IHyperSurgeHook.TradeType(bound(tradeTypeInt, 0, 1)); + + // keep fuzz broad; validation will narrow + thr = bound(thr, 0, 1e20 + 1e18); + + vm.startPrank(admin); + + // First, enforce the pure percentage validation + if (thr > 1e18) { + vm.expectRevert(); + hook.setSurgeThresholdPercentage(address(pool), thr, tradeType); + vm.stopPrank(); + return; + } + + if (thr < 1e9 || (thr % 1e9 != 0)) { + vm.expectRevert(); + hook.setSurgeThresholdPercentage(address(pool), thr, tradeType); + vm.stopPrank(); + return; + } + + // Passed basic validation; now respect existing cap rule: if cap != 0, require thr < cap + uint256 cap = hook.getCapDeviationPercentage(address(pool), tradeType); // 18dp + + if (cap != 0 && thr >= cap) { + vm.expectRevert(); + hook.setSurgeThresholdPercentage(address(pool), thr, tradeType); + } else { + hook.setSurgeThresholdPercentage(address(pool), thr, tradeType); + assertEq(hook.getSurgeThresholdPercentage(address(pool), tradeType), thr, "threshold stored incorrectly"); + } + + vm.stopPrank(); + } + + /// @notice Single-token price config: rejects out-of-range token index. + /// @dev Bounds: `idx ≥ n` must revert; `n ∈ [2,8]` aligns with Balancer V3 pool sizes. + function testFuzz_setTokenPriceConfigIndex_rejects_out_of_range_index(uint8 numTokens, uint8 idx) public { + numTokens = uint8(bound(numTokens, 2, 8)); + idx = uint8(bound(idx, numTokens, 30)); // force OOB + + // Register logical numTokens for the BaseVaultTest pool + TokenConfig[] memory cfg = new TokenConfig[](numTokens); + LiquidityManagement memory lm; + vm.prank(address(vault)); + + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + // OOB index must revert + vm.startPrank(admin); + vm.expectRevert(); + hook.setTokenPriceConfigIndex(address(pool), idx, /*pairIdx*/ 0, /*tokenIdx*/ 0); + vm.stopPrank(); + } + + /// @notice Single-token price config: accepts in-range index with nonzero pair id. + /// @dev Bounds: `idx ∈ [0, n-1]`, `pairIdx > 0`. Confirms happy path does not revert. + function testFuzz_setTokenPriceConfigIndex_accepts_in_range_index(uint8 numTokens, uint8 idx) public { + numTokens = uint8(bound(numTokens, 2, 8)); + idx = uint8(bound(idx, 0, numTokens - 1)); + + TokenConfig[] memory cfg = new TokenConfig[](numTokens); + LiquidityManagement memory lm; + vm.prank(address(vault)); + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + vm.startPrank(admin); + + uint32 pairIdx = 1; + uint32 tokenIdx = 21; + _hlSetSzDecimals(tokenIdx, 6); + hook.setTokenPriceConfigIndex(address(pool), idx, pairIdx, tokenIdx); + + vm.stopPrank(); + } + + function testFuzz_setTokenPriceConfigIndex_pairIdx_nonzero(uint8 numTokens, uint8 idx, uint32 pairIdx) public { + numTokens = uint8(bound(numTokens, 2, 8)); + idx = uint8(bound(idx, 0, numTokens - 1)); + pairIdx = uint32(bound(pairIdx, 21, type(uint32).max - 20)); // ensure non-zero + + TokenConfig[] memory cfg = new TokenConfig[](numTokens); + LiquidityManagement memory lm; + vm.prank(address(vault)); + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + // stub szDecimals for this pair + _hlSetSzDecimals(pairIdx + 20, 8); + + vm.prank(admin); + hook.setTokenPriceConfigIndex(address(pool), idx, pairIdx, pairIdx + 20); + } + + /// @notice szDecimals lookup determines divisor = 10**(6 - sz) for each token’s price pair. + /// @dev Bounds: `sz ∈ [0,6]` are the only valid oracle scales; verifies stored pair index and computed divisor. + /// @param sz Oracle significant-decimal count for the pair (0..6). + /// @param n Pool size (2..8). + + function testFuzz_setTokenPriceConfigIndex_szDecimals_and_divisor(uint8 sz, uint8 n) public { + sz = uint8(bound(sz, 1, 8)); + n = uint8(bound(n, 2, 8)); + + TokenConfig[] memory cfg = new TokenConfig[](n); // 4 tokens, any N in 2..8 + LiquidityManagement memory lm; + vm.prank(address(vault)); + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + uint8 idx = 0; + uint32 pairIdx = 1; + uint32 tokenIdx = 21; + _hlSetSzDecimals(tokenIdx, sz); + + vm.prank(admin); + hook.setTokenPriceConfigIndex(address(pool), idx, pairIdx, tokenIdx); + + (uint32 storedPair, uint32 storedDiv) = hook.getTokenPriceConfigIndex(address(pool), idx); + assertEq(storedPair, pairIdx, "pair index mismatch"); + uint32 expectedDiv = uint32(10 ** uint32(8 - sz)); + assertEq(storedDiv, expectedDiv, "divisor mismatch"); + } + + /// @notice szDecimals > 8 is invalid and must revert on single-token price config. + /// @dev Enforces the oracle scale invariant; rejects `sz ≥ 7`. + /// @param sz Oracle significant-decimal count (≥7 → invalid). + function testFuzz_setTokenPriceConfigIndex_szDecimals_over_8(uint8 sz) public { + // invalid range > 8 should fail in hook + sz = uint8(bound(sz, 9, 30)); + + TokenConfig[] memory cfg = new TokenConfig[](4); + LiquidityManagement memory lm; + vm.prank(address(vault)); + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + uint8 idx = 0; + uint32 pairIdx = 1; + uint32 tokenIdx = 21; + _hlSetSzDecimals(tokenIdx, sz); + + vm.startPrank(admin); + vm.expectRevert(); + hook.setTokenPriceConfigIndex(address(pool), idx, pairIdx, tokenIdx); + vm.stopPrank(); + } + + /// @notice Batch token price config: array length mismatch must revert atomically. + /// @dev Bounds: `a,b ∈ [0,n]`. If `a != b` then revert; else accept and spot-check stored rows. + function testFuzz_setTokenPriceConfigBatchIndex_length_mismatch(uint8 n, uint8 lenA, uint8 lenB) public { + // Register pool (any N in 2..8) + n = _registerBasePoolWithN(n); + + // Grant batch role (if your auth checks it); harmless if not needed + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + // Build two arrays of (possibly) mismatched lengths within [0..n] + uint8 a = uint8(bound(lenA, 0, n)); + uint8 b = uint8(bound(lenB, 0, n)); + + uint8[] memory indices = new uint8[](a); + uint32[] memory pairs = new uint32[](b); + uint32[] memory tokenIdxs = new uint32[](b); + + // Fill indices/pairs with valid values for any elements that exist + for (uint8 i = 0; i < a; ++i) { + indices[i] = uint8(bound(i, 0, n - 1)); + } + for (uint8 i = 0; i < b; ++i) { + uint32 pair = uint32(1000 + i); + pairs[i] = pair; + tokenIdxs[i] = pair + 20; + // Ensure szDecimals(pair) ∈ [0..8] so row-level checks would pass if lengths matched + _hlSetSzDecimals(pair + 20, uint8(i % 9)); + } + + vm.startPrank(admin); + if (a != b) { + // Your hook explicitly reverts on mismatched lengths + vm.expectRevert(); // InvalidArrayLengths() + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + } else { + // Equal lengths: should succeed (including the a=b=0 "no-op" batch) + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + + // Spot-check: for any rows we set, getter must reflect pair+divisor + for (uint8 i = 0; i < a; ++i) { + (uint32 pair, uint32 div) = hook.getTokenPriceConfigIndex(address(pool), indices[i]); + assertEq(pair, pairs[i], "pair mismatch"); + uint8 sz = uint8(i % 9); + uint32 expectedDiv = uint32(10 ** uint32(8 - sz)); + assertEq(div, expectedDiv, "divisor mismatch"); + } + } + vm.stopPrank(); + } + + /// @notice Batch token price config: zero pair id in any row is invalid and reverts the batch. + /// @dev Enforces `pairIdx > 0` precondition for oracle routing. + /// @param n Pool size (2..8). + function test_setTokenPriceConfigBatchIndex_zero_pair_reverts(uint8 n) public { + n = _registerBasePoolWithN(n); + + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + // Non-empty batch with a zero pairIdx → must revert + uint8[] memory indices = new uint8[](1); + uint32[] memory pairs = new uint32[](1); + uint32[] memory tokenIdxs = new uint32[](1); + indices[0] = 0; // valid token index + pairs[0] = 0; // INVALID + tokenIdxs[0] = 0; // INVALID + + vm.startPrank(admin); + vm.expectRevert(); // InvalidPairIndex() + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + vm.stopPrank(); + } + + /// @notice Batch token price config: valid rows are persisted with correct pair ids and divisors. + /// @dev Bounds: `len ∈ [1,n]`. Confirms unset indices remain zero. + /// @param n Pool size (2..8). + /// @param lenSeed Chooses number of rows to configure. + function test_setTokenPriceConfigBatchIndex_success(uint8 n, uint8 lenSeed) public { + n = _registerBasePoolWithN(n); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + uint8 len = uint8(bound(lenSeed, 1, n)); // at least 1 row + uint8[] memory indices = new uint8[](len); + uint32[] memory pairs = new uint32[](len); + uint32[] memory tokenIdxs = new uint32[](len); + + for (uint8 i = 0; i < len; ++i) { + indices[i] = i; // 0..len-1 within n + pairs[i] = uint32(1000 + i); // non-zero pair + tokenIdxs[i] = pairs[i] + 20; + // hook validates szDecimals(pair) ∈ [0..6], so set it + _hlSetSzDecimals(tokenIdxs[i], uint8(i % 9)); + } + + vm.prank(admin); + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + + // Verify stored pair & divisor per row + for (uint8 i = 0; i < len; ++i) { + (uint32 p, uint32 div) = hook.getTokenPriceConfigIndex(address(pool), indices[i]); + assertEq(p, pairs[i]); + uint32 expectedDiv = uint32(10 ** uint32(8 - (i % 9))); + assertEq(div, expectedDiv); + } + } + + /// @notice All admin setters are restricted to SwapFeeManager/Governance or holders of the batch action id. + /// @dev After demonstrating a successful admin batch by `admin`, pranks a non-admin and asserts reverts for: + /// {max fee, threshold, cap, single price index, batch price index}. + /// @param n Pool size (2..8). + function testFuzz_onlyAdmin_rejected_on_all_admin_setters( + uint8 n, + uint8 idxSeed, + uint32 pairIdx, + uint256 maxSeed, + uint256 thrSeed, + uint256 capSeed + ) public { + // Register a live pool first so the reverts (if any) are ACL-related + n = _registerBasePoolWithN(n); + uint8 idx = uint8(bound(idxSeed, 0, n - 1)); + pairIdx = uint32(bound(pairIdx, 21, type(uint32).max - 20)); // non-zero + _hlSetSzDecimals(pairIdx + 20, uint8(bound(uint8(pairIdx), 1, 8))); + + // Grant batch role to admin so only the non-admin fails ACL + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + address rando = address(0xBEEF); + + uint256 maxPct = bound(maxSeed, 1, 1e9); + uint256 thr = bound(thrSeed, 1, 1e9); + uint256 cap = bound(capSeed, thr == 1e9 ? 1e9 : (thr + 1), 1e9); // cap > thr when possible + + // Single index must fail from non-admin + vm.prank(rando); + vm.expectRevert(); + hook.setTokenPriceConfigIndex(address(pool), idx, pairIdx, pairIdx + 20); + + // Batch must fail from non-admin + uint8[] memory indices = new uint8[](1); + uint32[] memory pairs = new uint32[](1); + uint32[] memory tokenIdxs = new uint32[](1); + indices[0] = idx; + pairs[0] = pairIdx; + tokenIdxs[0] = pairIdx + 20; + + vm.prank(rando); + vm.expectRevert(); + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + + // Fee knobs must fail from non-admin (both directions) + vm.prank(rando); + vm.expectRevert(); + hook.setMaxSurgeFeePercentage(address(pool), maxPct * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + + vm.prank(rando); + vm.expectRevert(); + hook.setSurgeThresholdPercentage(address(pool), thr * 1e9, IHyperSurgeHook.TradeType.NOISE); + + vm.prank(rando); + vm.expectRevert(); + hook.setCapDeviationPercentage(address(pool), cap * 1e9, IHyperSurgeHook.TradeType.NOISE); + } + + /// @notice Single-token price config reverts when the pool is not initialized via `onRegister`. + /// @dev Asserts the `initialized` guard on the single-row setter. + function testFuzz_priceConfigIndex_rejects_when_uninitialized(uint8 idxSeed, uint32 pairIdx) public { + // NOT registering the pool → expect PoolNotInitialized + uint8 idx = uint8(bound(idxSeed, 0, 7)); + pairIdx = uint32(bound(pairIdx, 21, type(uint32).max - 20)); + _hlSetSzDecimals(pairIdx + 20, uint8(bound(uint8(pairIdx + 20), 1, 8))); + + vm.startPrank(admin); + vm.expectRevert(); // PoolNotInitialized() + hook.setTokenPriceConfigIndex(address(pool), idx, pairIdx, pairIdx + 20); + vm.stopPrank(); + } + + /// @notice Batch price config reverts when the pool is not initialized via `onRegister`. + /// @dev Asserts the `initialized` guard on the batch setter. + function testFuzz_priceConfigBatch_rejects_when_uninitialized(uint8 a, uint8 b, uint32 p0, uint32 p1) public { + // Grant role needed for batch + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + // Build small batch + uint8[] memory indices = new uint8[](2); + uint32[] memory pairs = new uint32[](2); + uint32[] memory tokenIdxs = new uint32[](2); + indices[0] = uint8(bound(a, 0, 7)); + indices[1] = uint8(bound(b, 0, 7)); + pairs[0] = uint32(bound(p0, 21, type(uint32).max - 20)); + pairs[1] = uint32(bound(p1, 21, type(uint32).max - 20)); + tokenIdxs[0] = pairs[0] + 20; + tokenIdxs[1] = pairs[1] + 20; + + _hlSetSzDecimals(tokenIdxs[0], uint8(bound(uint8(tokenIdxs[0]), 1, 8))); + _hlSetSzDecimals(tokenIdxs[1], uint8(bound(uint8(tokenIdxs[1]), 1, 8))); + + vm.startPrank(admin); + vm.expectRevert(); // PoolNotInitialized() + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + vm.stopPrank(); + } + + /// @notice Batch token price config: any out-of-range token index causes the entire batch to revert. + /// @dev Bounds: mixes in-range and out-of-range indices; asserts atomic failure. + /// @param n Pool size (2..8). + function testFuzz_batch_rejects_tokenIndex_out_of_range( + uint8 n, + uint8 goodIdx, + uint8 badIdx, + uint32 pairIdx + ) public { + n = _registerBasePoolWithN(n); + goodIdx = uint8(bound(goodIdx, 0, n - 1)); + badIdx = uint8(bound(badIdx, n, n + 12)); // OOB + pairIdx = uint32(bound(pairIdx, 21, type(uint32).max - 20)); + _hlSetSzDecimals(pairIdx + 20, uint8(bound(uint8(pairIdx), 1, 8))); + + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + uint8[] memory indices = new uint8[](2); + uint32[] memory pairs = new uint32[](2); + uint32[] memory tokenIdxs = new uint32[](2); + indices[0] = goodIdx; + pairs[0] = pairIdx; + indices[1] = badIdx; + pairs[1] = pairIdx; + tokenIdxs[0] = 0 + 20; + tokenIdxs[1] = pairIdx + 20; + + vm.startPrank(admin); + vm.expectRevert(); // TokenIndexOutOfRange() + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + vm.stopPrank(); + } + + function testFuzz_batch_rejects_zero_pairIdx(uint8 n, uint8 idx0, uint8 idx1, uint32 p1) public { + n = _registerBasePoolWithN(n); + idx0 = uint8(bound(idx0, 0, n - 1)); + idx1 = uint8(bound(idx1, 0, n - 1)); + + p1 = uint32(bound(p1, 21, type(uint32).max - 20)); + _hlSetSzDecimals(p1 + 20, uint8(bound(uint8(p1), 1, 8))); + + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + uint8[] memory indices = new uint8[](2); + uint32[] memory pairs = new uint32[](2); + uint32[] memory tokenIdxs = new uint32[](2); + indices[0] = idx0; + pairs[0] = 0; // zero pairIdx → invalid + indices[1] = idx1; + pairs[1] = p1; + tokenIdxs[0] = 0 + 20; + tokenIdxs[1] = p1 + 20; + + vm.startPrank(admin); + vm.expectRevert(); // InvalidPairIndex() + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + vm.stopPrank(); + } + + /// @notice Batch token price config: szDecimals > 8 in any row must revert atomically. + /// @dev Guards oracle scaling invariants across the whole batch. + /// @param n Pool size (2..8). + function testFuzz_batch_rejects_decimals_over_8(uint8 n, uint8 idxSeed, uint32 pairIdx, uint8 sz) public { + n = _registerBasePoolWithN(n); + uint8 idx = uint8(bound(idxSeed, 0, n - 1)); + + pairIdx = uint32(bound(pairIdx, 21, type(uint32).max - 20)); + sz = uint8(bound(sz, 9, 40)); // > 8 invalid + _hlSetSzDecimals(pairIdx + 20, sz); + + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + uint8[] memory indices = new uint8[](1); + uint32[] memory pairs = new uint32[](1); + uint32[] memory tokenIdxs = new uint32[](1); + indices[0] = idx; + pairs[0] = pairIdx; + tokenIdxs[0] = pairIdx + 20; + + vm.startPrank(admin); + vm.expectRevert(); // InvalidDecimals() + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + vm.stopPrank(); + } + + /// @notice Batch token price config: getters return arrays sized to `n` with exact pair/divisor per row. + /// @dev Bounds: `len ∈ [1,n]`. Confirms unset positions remain zero-initialized. + /// @param n Pool size (2..8). + /// @param lenSeed Chooses number of rows to configure. + function testFuzz_batch_accepts_and_getters_match(uint8 n, uint8 lenSeed) public { + n = _registerBasePoolWithN(n); + uint8 len = uint8(bound(lenSeed, 1, n)); // number of rows we will set + + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + uint8[] memory indices = new uint8[](len); + uint32[] memory pairs = new uint32[](len); + uint32[] memory tokenIdxs = new uint32[](len); + + for (uint8 i = 0; i < len; ++i) { + indices[i] = i; + pairs[i] = uint32(1000 + i); // distinct + tokenIdxs[i] = pairs[i] + 20; + uint8 sz = uint8(i % 9); // 0..8 + _hlSetSzDecimals(tokenIdxs[i], sz); + } + + vm.prank(admin); + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + + // Verify via both getters + (uint32[] memory pairArr, uint32[] memory divArr) = hook.getTokenPriceConfigs(address(pool)); + for (uint8 i = 0; i < len; ++i) { + (uint32 pair, uint32 div) = hook.getTokenPriceConfigIndex(address(pool), i); + assertEq(pair, pairs[i], "pair mismatch"); + assertEq(pairArr[i], pairs[i], "pairArr mismatch"); + // divisor = 10**(8 - sz) with sz = i%9 + uint32 expectedDiv = uint32(10 ** uint32(8 - (i % 9))); + assertEq(div, expectedDiv, "div mismatch"); + assertEq(divArr[i], expectedDiv, "divArr mismatch"); + } + } + + /// @notice Batch token price config: duplicate writes to the same token index use last-write-wins semantics. + /// @dev Ensures deterministic storage in face of repeated indices within one batch. + /// @param n Pool size (2..8). + function testFuzz_batch_duplicate_indices_last_write_wins(uint8 n, uint8 idxSeed, uint32 pA, uint32 pB) public { + n = _registerBasePoolWithN(n); + uint8 idx = uint8(bound(idxSeed, 0, n - 1)); + pA = uint32(bound(pA, 21, type(uint32).max - 20)); + pB = uint32(bound(pB, 21, type(uint32).max - 20)); + _hlSetSzDecimals(pA + 20, uint8(bound(uint8(pA), 1, 8))); + _hlSetSzDecimals(pB + 20, uint8(bound(uint8(pB), 1, 8))); + + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigBatchIndex.selector), + admin + ); + + // Two rows targeting same index, second should overwrite first + uint8[] memory indices = new uint8[](2); + uint32[] memory pairs = new uint32[](2); + + indices[0] = idx; + pairs[0] = pA; + indices[1] = idx; + pairs[1] = pB; + + uint32[] memory tokenIdxs = new uint32[](2); + tokenIdxs[0] = pA + 20; + tokenIdxs[1] = pB + 20; + + vm.prank(admin); + hook.setTokenPriceConfigBatchIndex(address(pool), indices, pairs, tokenIdxs); + + (uint32 pair, uint32 div) = hook.getTokenPriceConfigIndex(address(pool), idx); + assertEq(pair, pB, "last write did not win"); + // divisor must match sz of pB + uint8 szB = uint8(bound(uint8(pB), 1, 8)); + uint32 expectedDiv = uint32(10 ** uint32(8 - szB)); + assertEq(div, expectedDiv); + } + + /// @notice ARB and NOISE lanes are independent: setting values in one lane must not affect the other. + /// @dev Bounds: fuzz ppm9 values with `cap > thr` per lane; asserts getters are lane-scoped. + /// @param n Pool size (2..8). + function testFuzz_fee_knobs_per_direction_independent( + uint8 n, + uint256 arbMaxUnbound, + uint256 arbThrUnbound, + uint256 arbCapUnbound, + uint256 noiseMaxUnbound, + uint256 noiseThrUnbound, + uint256 noiseCapUnbound + ) public { + _registerBasePoolWithN(n); + + uint256 arbMax = bound(arbMaxUnbound, 1, 1e9); + uint256 arbThr = bound(arbThrUnbound, 1, 1e9 - 1); + uint256 arbCap = bound(arbCapUnbound, arbThr + 1, 1e9); + uint256 noiseMax = bound(noiseMaxUnbound, 1, 1e9); + uint256 noiseThr = bound(noiseThrUnbound, 1, 1e9 - 1); + uint256 noiseCap = bound(noiseCapUnbound, noiseThr + 1, 1e9); + + vm.startPrank(admin); + hook.setMaxSurgeFeePercentage(address(pool), arbMax * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setSurgeThresholdPercentage(address(pool), arbThr * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setCapDeviationPercentage(address(pool), arbCap * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + + hook.setMaxSurgeFeePercentage(address(pool), noiseMax * 1e9, IHyperSurgeHook.TradeType.NOISE); + hook.setSurgeThresholdPercentage(address(pool), noiseThr * 1e9, IHyperSurgeHook.TradeType.NOISE); + hook.setCapDeviationPercentage(address(pool), noiseCap * 1e9, IHyperSurgeHook.TradeType.NOISE); + + vm.stopPrank(); + + assertEq(hook.getMaxSurgeFeePercentage(address(pool), IHyperSurgeHook.TradeType.ARBITRAGE), arbMax * 1e9); + assertEq(hook.getSurgeThresholdPercentage(address(pool), IHyperSurgeHook.TradeType.ARBITRAGE), arbThr * 1e9); + assertEq(hook.getCapDeviationPercentage(address(pool), IHyperSurgeHook.TradeType.ARBITRAGE), arbCap * 1e9); + + assertEq(hook.getMaxSurgeFeePercentage(address(pool), IHyperSurgeHook.TradeType.NOISE), noiseMax * 1e9); + assertEq(hook.getSurgeThresholdPercentage(address(pool), IHyperSurgeHook.TradeType.NOISE), noiseThr * 1e9); + assertEq(hook.getCapDeviationPercentage(address(pool), IHyperSurgeHook.TradeType.NOISE), noiseCap * 1e9); + } + + function test_getDefaultGetters_match_constructor() public view { + // The hook in setUp was deployed with 0.02e9 defaults for max & threshold + assertEq(hook.getDefaultMaxSurgeFeePercentage(), 0.02e18); + assertEq(hook.getDefaultSurgeThresholdPercentage(), 0.02e18); + assertEq(hook.getDefaultCapDeviationPercentage(), 1e18); + } + + function testFuzz_fee_setters_valid_before_register_then_reset_on_register( + uint8 n, + uint256 maxPctUnbound, + uint256 thrUnbound, + uint256 capUnbound + ) public { + // Set fees BEFORE onRegister (allowed by code), then register — defaults should overwrite + uint256 maxPct = bound(maxPctUnbound, 1, 1e9); + uint256 thr = bound(thrUnbound, 1, 1e9 - 1); + uint256 cap = bound(capUnbound, thr + 1, 1e9); + + vm.startPrank(admin); + hook.setMaxSurgeFeePercentage(address(pool), maxPct * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setSurgeThresholdPercentage(address(pool), thr * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setCapDeviationPercentage(address(pool), cap * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + vm.stopPrank(); + + // Now register + _registerBasePoolWithN(n); + + // Confirm defaults restored for ARB (constructor defaults = 0.02e9 and cap=1e9) + assertEq(hook.getMaxSurgeFeePercentage(address(pool), IHyperSurgeHook.TradeType.ARBITRAGE), 0.02e18); + assertEq(hook.getSurgeThresholdPercentage(address(pool), IHyperSurgeHook.TradeType.ARBITRAGE), 0.02e18); + assertEq(hook.getCapDeviationPercentage(address(pool), IHyperSurgeHook.TradeType.ARBITRAGE), 1e18); + } + + function testFuzz_onRegister_RevertWhenTokenCountBelowTwo( + uint8 n, + uint256 defaultThreshold, + uint256 defaultMaxFee, + uint256 defaultCap + ) public { + n = uint8(bound(n, 0, 1)); + defaultThreshold = bound(defaultThreshold, 1, 1e9 - 1); + defaultCap = bound(defaultCap, defaultThreshold + 1, 1e9); + defaultMaxFee = bound(defaultMaxFee, 1, 1e9); + + defaultThreshold *= 1e9; + defaultCap *= 1e9; + defaultMaxFee *= 1e9; + + HyperSurgeHookMock h = new HyperSurgeHookMock( + IVault(vault), + defaultMaxFee, + defaultThreshold, + defaultCap, + "test" + ); + + TokenConfig[] memory cfgs = new TokenConfig[](n); + LiquidityManagement memory lm; + + vm.startPrank(address(vault)); + vm.expectRevert(HyperSurgeHook.NumTokensOutOfRange.selector); + h.onRegister(address(0), address(0), cfgs, lm); + vm.stopPrank(); + } + + function testFuzz_onRegister_RevertWhenTokenCountAboveEight( + uint256 n, + uint256 defaultThreshold, + uint256 defaultMaxFee, + uint256 defaultCap + ) public { + n = bound(n, 9, type(uint8).max); + defaultThreshold = bound(defaultThreshold, 1, 1e9 - 1); + defaultCap = bound(defaultCap, defaultThreshold + 1, 1e9); + defaultMaxFee = bound(defaultMaxFee, 1, 1e9); + + defaultThreshold *= 1e9; + defaultCap *= 1e9; + defaultMaxFee *= 1e9; + + HyperSurgeHookMock h = new HyperSurgeHookMock( + IVault(vault), + defaultMaxFee, + defaultThreshold, + defaultCap, + "test" + ); + + TokenConfig[] memory cfgs = new TokenConfig[](n); + LiquidityManagement memory lm; + + vm.startPrank(address(vault)); + vm.expectRevert(HyperSurgeHook.NumTokensOutOfRange.selector); + h.onRegister(address(0), address(0), cfgs, lm); + vm.stopPrank(); + } + + function test_getHookFlags_SignalsAreSet( + uint256 defaultThreshold, + uint256 defaultMaxFee, + uint256 defaultCap + ) public { + defaultThreshold = bound(defaultThreshold, 1, 1e9 - 1); + defaultCap = bound(defaultCap, defaultThreshold + 1, 1e9); + defaultMaxFee = bound(defaultMaxFee, 1, 1e9); + + defaultThreshold *= 1e9; + defaultCap *= 1e9; + defaultMaxFee *= 1e9; + + HyperSurgeHookMock h = new HyperSurgeHookMock( + IVault(vault), + defaultMaxFee, + defaultThreshold, + defaultCap, + "test" + ); + + HookFlags memory f = h.getHookFlags(); + assertTrue(f.shouldCallComputeDynamicSwapFee, "computeDynamicSwapFee flag should be true"); + assertTrue(f.shouldCallAfterAddLiquidity, "afterAddLiquidity flag should be true"); + assertTrue(f.shouldCallAfterRemoveLiquidity, "afterRemoveLiquidity flag should be true"); + } + + function testFuzz_getNumTokens_ReturnsConfiguredCount( + address pool, + uint8 n, + uint256 defaultThreshold, + uint256 defaultMaxFee, + uint256 defaultCap + ) public { + vm.assume(pool != address(0)); + n = uint8(bound(n, 2, 8)); + defaultThreshold = bound(defaultThreshold, 1, 1e9 - 1); + defaultCap = bound(defaultCap, defaultThreshold + 1, 1e9); + defaultMaxFee = bound(defaultMaxFee, 1, 1e9); + + defaultThreshold *= 1e9; + defaultCap *= 1e9; + defaultMaxFee *= 1e9; + + HyperSurgeHookMock h = new HyperSurgeHookMock( + IVault(vault), + defaultMaxFee, + defaultThreshold, + defaultCap, + "test" + ); + + TokenConfig[] memory cfgs = new TokenConfig[](n); + LiquidityManagement memory lm; + + vm.startPrank(address(vault)); + h.onRegister(address(0), pool, cfgs, lm); + vm.stopPrank(); + + assertEq(uint256(h.getNumTokens(pool)), uint256(n), "numTokens should equal configured length"); + + address other = pool == address(0xdead) ? address(0xbeef) : address(0xdead); + assertEq(uint256(h.getNumTokens(other)), 0, "unregistered pool should report 0 tokens"); + } + + function testFuzz_SetSurgeThreshold_Reverts_When_Threshold_GE_CapDeviation( + uint256 rawCapDev18, + bool useArb, + bool equalToCap, + uint16 stepsAbove, + bool preSetBelowFirst + ) public { + TokenConfig[] memory cfg = new TokenConfig[](2); + LiquidityManagement memory lm; + vm.prank(address(vault)); + hook.onRegister(poolFactory, address(pool), cfg, lm); + + IHyperSurgeHook.TradeType tt = useArb ? IHyperSurgeHook.TradeType.ARBITRAGE : IHyperSurgeHook.TradeType.NOISE; + + uint256 initThr = 1e9; + vm.startPrank(admin); + hook.setSurgeThresholdPercentage(address(pool), initThr, tt); + + uint256 minCap = initThr + 1e9; + uint256 capDev18 = bound(rawCapDev18, minCap, 1e18); + capDev18 = (capDev18 / 1e9) * 1e9; + if (capDev18 <= initThr) { + capDev18 = minCap; + } + + hook.setCapDeviationPercentage(address(pool), capDev18, tt); + + if (preSetBelowFirst && capDev18 > 1e9) { + uint256 thrBelow = capDev18 - 1e9; + hook.setSurgeThresholdPercentage(address(pool), thrBelow, tt); + } + + uint256 thrInvalid; + if (equalToCap) { + thrInvalid = capDev18; + } else { + uint256 maxSteps = (1e18 - capDev18) / 1e9; + if (maxSteps == 0) { + thrInvalid = capDev18; + } else { + uint256 steps = bound(uint256(stepsAbove), 1, maxSteps); + thrInvalid = capDev18 + steps * 1e9; + } + if (thrInvalid > 1e18) { + thrInvalid = 1e18; + } + thrInvalid = (thrInvalid / 1e9) * 1e9; + } + + vm.expectRevert(HyperSurgeHook.InvalidThresholdDeviation.selector); + hook.setSurgeThresholdPercentage(address(pool), thrInvalid, tt); + vm.stopPrank(); + } +} diff --git a/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol b/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol new file mode 100644 index 00000000..bd4a6ae7 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol @@ -0,0 +1,3219 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +// Base test utilities (provides: vault, poocomputeLocals, poolFactory, admin, authorizer, routers, tokens, etc.) +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; + +// Hook interfaces +import { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; +import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; +import { IAuthorizer } from "@balancer-labs/v3-interfaces/contracts/vault/IAuthorizer.sol"; + +// Vault interfaces/types +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { + TokenConfig, + LiquidityManagement, + PoolSwapParams, + SwapKind, + PoolRoleAccounts +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +// Local deployer + mock +import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.sol"; +import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; +import { + HyperSpotPricePrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperSpotPricePrecompile.sol"; +import { + HyperTokenInfoPrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperTokenInfoPrecompile.sol"; +import { + HypercorePrecompileMock +} from "@balancer-labs/v3-standalone-utils/test/foundry/utils/HypercorePrecompileMock.sol"; + +import { + WeightedPoolContractsDeployer +} from "@balancer-labs/v3-pool-weighted/test/foundry/utils/WeightedPoolContractsDeployer.sol"; +import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; + +contract HLPriceStub { + mapping(uint32 => uint32) internal px; // slot 0 + + fallback(bytes calldata data) external returns (bytes memory ret) { + uint32 pairIndex = abi.decode(data, (uint32)); + return abi.encode(px[pairIndex]); + } + + function set(uint32 pairIndex, uint32 price_1e6) external { + px[pairIndex] = price_1e6; + } +} + +contract HLTokenInfoStub { + mapping(uint32 => uint8) internal sz; + + // Optional but nice for staticcall patterns: + fallback(bytes calldata data) external returns (bytes memory ret) { + uint32 tokenIndex = abi.decode(data, (uint32)); + + // Read stored record and ensure the struct fields exist + HyperTokenInfoPrecompile.HyperTokenInfo memory t; + + // Copy only what you care about; others can be zero/empty + t.szDecimals = sz[tokenIndex]; + + return abi.encode(t); // <<< return the STRUCT + } + + function set(uint32 pairIndex, uint8 decimals) external { + sz[pairIndex] = decimals; + } +} + +/** + * ============================= + * Test Suite Summary (grouped) + * ============================= + * + * INTEGRATION — Hyper Spot Path + * -------------------------------- + * [testFuzz_hyper_price_spot_success_EXACT_IN_multi] + * Fuzz multi-token EXACT_IN via hyper spot; call succeeds and fee is sane (≥ static, ≤ 100%). + * [testFuzz_hyper_price_spot_success_EXACT_OUT_multi] + * Fuzz multi-token EXACT_OUT via hyper spot; call succeeds and fee is sane (≥ static, ≤ 100%). + * [testFuzz_hyper_price_spot_expected_failure_marker] + * Drives hyper-spot into expected failure and verifies the failure marker/revert behavior. + * + * VIEW-ONLY BEHAVIOR + * -------------------- + * [testFuzz_view_missingPrices_returnsStatic_orRevert] + * With missing external prices, returns static fee (or cleanly reverts); never computes dynamic. + * [testFuzz_view_readsLaneParams_returnsStatic_onSafePath] + * Safe path reads lane params and returns the configured static fee. + * + * MATH & INVARIANTS (Internal) + * ------------------------------ + * [test_internal_exactValues_boundaries] + * Boundary checks: static at/≤ threshold, linear mid-span ramp, clamp to max at/≥ cap. + * [testFuzz_internal_feeRamp_matches_expected_withParams] + * Reference ramp formula matches internal math across fuzzed threshold/cap/max & deviations. + * [testFuzz_internal_monotone_inDeviation] + * Dynamic fee is monotone non-decreasing in absolute deviation under fixed params. + * [testFuzz_internal_balanceScalingInvariance] + * Fee is invariant (within tight tolerance) when scaling balances and trade size by same factor. + * [testFuzz_internal_exactIn_equals_exactOut_whenParamsSame] + * With identical effective lane params, EXACT_IN == EXACT_OUT; opposite lane differs to catch wrong-lane usage. + * + * CONFIGURATION / DEGENERATES + * ----------------------------- + * [test_cfg_fee_static_at_threshold_usingMockWrapper] + * Exactly at threshold → static fee (no ramp kickoff). + * [test_cfg_fee_minimalRamp_just_above_threshold_usingMockWrapper] + * Just above threshold → ramp starts from static with minimal positive slope. + * [test_cfg_fee_degenerateRamp_max_equals_static_usingMockWrapper] + * max == static → degenerate schedule; dynamic == static for all deviations. + * [test_cfg_fee_misconfig_max_below_static_reverts_usingMockWrapper] + * Misconfigured schedule (max < static) is rejected (reverts) rather than emitting an invalid fee. + * + * LANE LOGIC — NOISE (uses AFTER deviation) + * ------------------------------------------- + * [testFuzz_logic_noise_worsens_outside_dynamic_after] + * Start outside; trade worsens deviation → NOISE; dynamic fee from AFTER (≥ static). + * [testFuzz_logic_noise_inside_to_outside_dynamic_after] + * Start inside; worsen enough to exit band → NOISE; dynamic fee from AFTER (≥ static). + * [testFuzz_logic_noise_outside_crosses_and_worsens_dynamic_after] + * Start outside above; cross below and worsen absolute deviation → NOISE; AFTER basis (≥ static). + * [testFuzz_logic_noise_outside_below_worsens_dynamic_after] + * Symmetric “below-side worsen” (no cross) → NOISE; AFTER basis (≥ static). + * [testFuzz_logic_noise_inside_worsens_but_inside_static] + * Start inside; worsen but remain inside → NOISE; fee stays STATIC. + * + * LANE LOGIC — ARB (uses BEFORE deviation) + * ----------------------------------------- + * [testFuzz_logic_arb_outside_improves_but_outside_dynamic_before] + * Start outside; improve but remain outside → ARB; dynamic fee from BEFORE (≥ static). + * [testFuzz_logic_arb_outside_to_threshold_dynamic_before] + * Start outside; improve to at/inside threshold (two-sided bound) → ARB; BEFORE basis (dynamic). + * [testFuzz_logic_arb_outside_to_inside_dynamic_before] + * Start outside; end inside → ARB still uses BEFORE; expects dynamic (not static). + * [test_logic_arb_outside_nochange_dynamic_before] + * No movement while outside → ARB; BEFORE-based dynamic fee (≥ static). + * [test_logic_arb_inside_nochange_static] + * No movement while inside → ARB branch but fee is STATIC (since deviation ≤ threshold). + * + * BOUNDARY & CLAMPING PRECISION + * ------------------------------- + * [testFuzz_bound_noise_after_gt_cap_clamps_to_max_after] + * Start near threshold, worsen so AFTER > cap → NOISE clamps to noiseMax (AFTER basis). + * [testFuzz_bound_arb_before_gt_cap_clamps_to_max_before] + * BEFORE > cap; improve without crossing so AFTER ≤ cap → ARB clamps to arbMax (BEFORE basis). + * [testFuzz_bound_noise_after_at_threshold_static] + * Start inside and worsen to land exactly at threshold → NOISE returns STATIC (no ramp). + * + * */ + +contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoolContractsDeployer { + using ArrayHelpers for *; + using CastingHelpers for address[]; + + uint256 constant ONE = 1e18; + uint256 constant STATIC_SWAP_FEE = 1e16; // 1% (1e18 scale) + + // MUST match addresses the hook libs read + uint256 internal constant DEFAULT_SWAP_FEE = 1e16; // 1% + uint256 constant FEE_ONE = 1e18; + + HyperSurgeHookMock internal hook; + + HLPriceStub internal _pxStubDeployer; + HLTokenInfoStub internal _infoStubDeployer; + + function setUp() public virtual override { + super.setUp(); // vault, poocomputeLocals, poolFactory, admin, authorizer, tokens, routers, ... + + vm.prank(address(poolFactory)); + hook = deployHook( + IVault(address(vault)), + 0.02e18, // default max fee (2%) + 0.02e18, // default threshold (2%) + 1e18, + string("test") + ); + + // 2) Install precompile stubs at fixed addresses + _pxStubDeployer = new HLPriceStub(); + _infoStubDeployer = new HLTokenInfoStub(); + vm.etch(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, address(_pxStubDeployer).code); + vm.etch(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, address(_infoStubDeployer).code); + + // Seed a couple of pairs (pairIndex 1 and 2) + _hlSetSzDecimals(1, 6); + _hlSetSzDecimals(2, 6); + _hlSetSpot(1, 100_000_000); // 100.000000 (1e6 scale) + _hlSetSpot(2, 200_000_000); // 200.000000 (1e6 scale) + + // 3) Grant admin roles to `admin` + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setMaxSurgeFeePercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setSurgeThresholdPercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setCapDeviationPercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigIndex.selector), + admin + ); + } + + struct HyperPriceSpotParams { + uint32 raw; + uint32 divisor; + uint256 amtSeed; + uint256 feeSeed; + uint8 outSeed; + uint256 n; + uint256 maxPct; + uint256 thr; + uint256 cap; + uint8 indexIn; + uint8 indexOut; + uint32 pairIdx; + uint256 MAX_RATIO; + uint256 maxIn; + uint256 staticFee; + } + + function testFuzz_hyper_price_spot_success_EXACT_IN_multi( + uint32 raw, + uint32 divisor, + uint256 amtSeed, + uint256 feeSeed, + uint8 outSeed + ) public { + HyperPriceSpotParams memory params; + + // --- discover live pool size (N) from the deployed weighted pool + params.n = WeightedPool(address(pool)).getNormalizedWeights().length; + assertGe(params.n, 2, "pool must have >=2 tokens"); + require(params.n <= 8, "hook supports up to 8"); + + // --- fuzz external price + decimals (non-zero price) + params.raw = uint32(bound(raw, 1, type(uint32).max)); + params.divisor = uint32(bound(divisor, 1, 1_000_000) % 7); // 0..6 + + // --- hook registration with correct N + TokenConfig[] memory cfg = new TokenConfig[](params.n); + LiquidityManagement memory lm; + vm.prank(address(vault)); + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + // --- fee knobs (1e9) + params.maxPct = bound(feeSeed, 3, 1e9); + params.thr = params.maxPct / 3; + params.cap = params.thr + (1e9 - params.thr) / 2; + if (params.cap == params.thr) params.cap = params.thr + 1; + + // --- make NOISE lane different (keep maxPct same so staticFee bound remains valid) + uint256 noiseThr = (params.thr + 2 < params.cap) ? (params.thr + 1) : (params.thr - 1); + uint256 noiseCap = params.cap; + + vm.startPrank(admin); + hook.setMaxSurgeFeePercentage(address(pool), params.maxPct * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setSurgeThresholdPercentage(address(pool), params.thr * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setCapDeviationPercentage(address(pool), params.cap * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + + hook.setMaxSurgeFeePercentage(address(pool), params.maxPct * 1e9, IHyperSurgeHook.TradeType.NOISE); + hook.setSurgeThresholdPercentage(address(pool), noiseThr * 1e9, IHyperSurgeHook.TradeType.NOISE); + hook.setCapDeviationPercentage(address(pool), noiseCap * 1e9, IHyperSurgeHook.TradeType.NOISE); + vm.stopPrank(); + + // --- configure external price sources for the two indices we’ll swap + params.indexIn = 0; + params.indexOut = uint8(bound(outSeed, 1, uint8(params.n - 1))); + + params.pairIdx = 1; // arbitrary non-zero HL pair id for the out token + _hlSetSzDecimals(params.pairIdx, uint8(params.divisor)); + _hlSetSpot(params.pairIdx, params.raw); + + vm.startPrank(admin); + hook.setTokenPriceConfigIndex(address(pool), params.indexIn, params.pairIdx, params.pairIdx + 20); + hook.setTokenPriceConfigIndex(address(pool), params.indexOut, params.pairIdx, params.pairIdx + 20); // HL pair + vm.stopPrank(); + + // --- balancesScaled18 with length N (simple increasing balances) + uint256[] memory balances = new uint256[](params.n); + for (uint256 i = 0; i < params.n; ++i) { + balances[i] = 1e18 * (i + 1); + } + + // --- build PoolSwapParams (EXACT_IN: 0 -> indexOut) + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = balances; + p.indexIn = params.indexIn; + p.indexOut = params.indexOut; + + // bound amountIn to strictly inside the 30% guard + params.MAX_RATIO = 30e16; // 30% in 1e18 + params.maxIn = (balances[p.indexIn] * params.MAX_RATIO) / 1e18; + if (params.maxIn > 0) params.maxIn -= 1; + p.amountGivenScaled18 = bound(amtSeed, 1, params.maxIn == 0 ? 1 : params.maxIn); + + // static fee (1e9) bounded to maxPct + params.staticFee = bound(feeSeed % 1e9, 0, params.maxPct); + + // --- compute dynamic fee via hook + vm.startPrank(address(vault)); + (bool ok, uint256 dyn) = hook.onComputeDynamicSwapFeePercentage(p, address(pool), params.staticFee); + vm.stopPrank(); + + assertTrue(ok, "compute fee should succeed"); + // returned value is in 1e9 scale here (hook keeps pct in 1e9) + assertLe(dyn, 1e18, "fee must be <= 100% (1e9)"); + assertGe(dyn, params.staticFee, "dyn fee >= static fee"); + } + + function testFuzz_hyper_price_spot_success_EXACT_OUT_multi( + uint32 raw, + uint32 divisor, + uint256 amtSeed, + uint256 feeSeed, + uint8 outSeed + ) public { + HyperPriceSpotParams memory params; + + // --- discover live pool size (N) + params.n = WeightedPool(address(pool)).getNormalizedWeights().length; + assertGe(params.n, 2, "pool must have >=2 tokens"); + require(params.n <= 8, "hook supports up to 8"); + + // --- external price + decimals + params.raw = uint32(bound(raw, 1, type(uint32).max)); + params.divisor = uint32(bound(divisor, 1, 1_000_000) % 7); // 0..6 + + // --- register with correct N + TokenConfig[] memory cfg = new TokenConfig[](params.n); + LiquidityManagement memory lm; + vm.prank(address(vault)); + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + // --- fee knobs (1e9) + params.maxPct = bound(feeSeed, 3, 1e9); + params.thr = params.maxPct / 3; + params.cap = params.thr + (1e9 - params.thr) / 2; + if (params.cap == params.thr) params.cap = params.thr + 1; + + // --- make NOISE lane different (keep maxPct same so staticFee bound remains valid) + uint256 noiseThr = (params.thr + 2 < params.cap) ? (params.thr + 1) : (params.thr - 1); + uint256 noiseCap = params.cap; + + vm.startPrank(admin); + hook.setMaxSurgeFeePercentage(address(pool), params.maxPct * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setSurgeThresholdPercentage(address(pool), params.thr * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setCapDeviationPercentage(address(pool), params.cap * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + + hook.setMaxSurgeFeePercentage(address(pool), params.maxPct * 1e9, IHyperSurgeHook.TradeType.NOISE); + hook.setSurgeThresholdPercentage(address(pool), noiseThr * 1e9, IHyperSurgeHook.TradeType.NOISE); + hook.setCapDeviationPercentage(address(pool), noiseCap * 1e9, IHyperSurgeHook.TradeType.NOISE); + vm.stopPrank(); + + // --- configure price only for the two indices we use + params.indexIn = 0; + params.indexOut = uint8(bound(outSeed, 1, uint8(params.n - 1))); + + params.pairIdx = 1; + _hlSetSzDecimals(params.pairIdx + 20, uint8(params.divisor)); + _hlSetSpot(params.pairIdx, params.raw); + + vm.startPrank(admin); + hook.setTokenPriceConfigIndex(address(pool), params.indexIn, params.pairIdx, params.pairIdx + 20); + hook.setTokenPriceConfigIndex(address(pool), params.indexOut, params.pairIdx, params.pairIdx + 20); // HL pair + vm.stopPrank(); + + // --- balancesScaled18 length N + uint256[] memory balances = new uint256[](params.n); + for (uint256 i = 0; i < params.n; ++i) { + balances[i] = 1e18 * (i + 1); + } + + // --- build PoolSwapParams (EXACT_OUT: 0 -> indexOut) + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_OUT; + p.balancesScaled18 = balances; + p.indexIn = params.indexIn; + p.indexOut = params.indexOut; + + // bound amountOut to strictly inside the 30% guard + params.MAX_RATIO = 30e16; // 30% + params.maxIn = (balances[p.indexOut] * params.MAX_RATIO) / 1e18; + if (params.maxIn > 0) { + params.maxIn -= 1; + } + p.amountGivenScaled18 = bound(amtSeed, 1, params.maxIn == 0 ? 1 : params.maxIn); // for EXACT_OUT this is amountOut + + // static fee (1e9) + params.staticFee = bound(feeSeed % 1e9, 0, params.maxPct); + + vm.startPrank(address(vault)); + (bool ok, uint256 dyn) = hook.onComputeDynamicSwapFeePercentage(p, address(pool), params.staticFee); + vm.stopPrank(); + + assertTrue(ok, "compute fee should succeed"); + assertLe(dyn, 1e18, "fee must be <= 100% (1e9)"); + assertGe(dyn, params.staticFee, "dyn fee >= static fee"); + } + + // Pack locals to avoid stack-too-deep + struct FailureCtx { + uint256 n; + uint8 indexIn; + uint8 indexOut; + uint32 pairIdx; + uint8 sz; + uint256 maxPct; + uint256 thr; + uint256 cap; + uint256 staticFee; + uint256[] balances; + uint256 maxRatio; + uint256 maxIn; + bool ok; + uint256 dyn; + uint256 max9; + uint256 thr9; + uint256 cap9; + uint256 capRoom; + uint256 staticSeed; + uint256 i; + uint256 amtSeed; + } + + function testFuzz_hyper_price_spot_expected_failure_marker(uint256 marker) public { + // Keep the seed bounded and lively + marker = bound(marker, 4, type(uint32).max - 1); + + FailureCtx memory locals; + + // 1) Pool size + locals.n = WeightedPool(address(pool)).getNormalizedWeights().length; + assertGe(locals.n, 2, "pool must have >=2 tokens"); + require(locals.n <= 8, "hook supports up to 8"); + + // 2) Register hook with exactly N TokenConfig entries + TokenConfig[] memory cfg = new TokenConfig[](locals.n); + LiquidityManagement memory lm; + vm.prank(address(vault)); + assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); + + // 3) Build VALID lane params (9), then upscale ONCE to 18dp + // max9 ∈ [1..1e9], thr9 ∈ [1..max9], cap9 ∈ (thr9..1e9] + locals.max9 = 1 + (marker % 1_000_000_000); // avoid 0 + locals.thr9 = 1 + ((marker >> 8) % locals.max9); // greater than or equal to1 and less than or equal to max9 + locals.capRoom = 1_000_000_000 - locals.thr9; // room above thr + locals.cap9 = locals.thr9 + 1; // strictly > thr + if (locals.capRoom > 0) { + locals.cap9 = locals.thr9 + 1 + ((marker >> 16) % locals.capRoom); // (thr9, 1e9] + } + if (locals.cap9 > 1_000_000_000) locals.cap9 = 1_000_000_000; // clamp just in case + + // Upscale once to 18dp + locals.maxPct = locals.max9 * 1e9; + locals.thr = locals.thr9 * 1e9; + locals.cap = locals.cap9 * 1e9; + + // static fee (18dp) ∈ [0..maxPct18] + uint256 staticSeed = (uint256(keccak256(abi.encodePacked(marker))) << 32) | marker; + locals.staticFee = bound(staticSeed, 0, locals.maxPct); + + vm.startPrank(admin); + // Set both lanes using 18dp values + hook.setMaxSurgeFeePercentage(address(pool), locals.maxPct, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setSurgeThresholdPercentage(address(pool), locals.thr, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setCapDeviationPercentage(address(pool), locals.cap, IHyperSurgeHook.TradeType.ARBITRAGE); + + hook.setMaxSurgeFeePercentage(address(pool), locals.maxPct, IHyperSurgeHook.TradeType.NOISE); + hook.setSurgeThresholdPercentage(address(pool), locals.thr, IHyperSurgeHook.TradeType.NOISE); + hook.setCapDeviationPercentage(address(pool), locals.cap, IHyperSurgeHook.TradeType.NOISE); + vm.stopPrank(); + + // 4) Configure price sources for the two indices we’ll use + locals.indexIn = 0; + locals.indexOut = uint8(1 + (marker % (locals.n - 1))); // ∈ [1, n-1] + locals.pairIdx = 2; // any non-zero pair id for HL + locals.sz = uint8((marker >> 16) % 7); // 0..6 + + _hlSetSzDecimals(locals.pairIdx, locals.sz); + _hlSetSpot(locals.pairIdx, 0); // spot=0 → hook may return (ok=false), but must not revert + + vm.startPrank(admin); + hook.setTokenPriceConfigIndex(address(pool), locals.indexIn, locals.pairIdx, locals.pairIdx + 20); + hook.setTokenPriceConfigIndex(address(pool), locals.indexOut, locals.pairIdx, locals.pairIdx + 20); + vm.stopPrank(); + + // 5) Balances array of length N (ascending 1e18, 2e18, ...) + locals.balances = new uint256[](locals.n); + for (locals.i = 0; locals.i < locals.n; ++locals.i) { + locals.balances[locals.i] = 1e18 * (locals.i + 1); + } + + // 6) Build swap params (EXACT_IN), amount within 30% guard + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = locals.balances; + p.indexIn = locals.indexIn; + p.indexOut = locals.indexOut; + + locals.maxRatio = 30e16; // 30% in 1e18 basis + locals.maxIn = (locals.balances[p.indexIn] * locals.maxRatio) / 1e18; + if (locals.maxIn > 0) { + locals.maxIn -= 1; + } + + locals.amtSeed = (marker << 32) | marker; + p.amountGivenScaled18 = bound(locals.amtSeed, 1, locals.maxIn == 0 ? 1 : locals.maxIn); + + vm.expectRevert(HyperSpotPricePrecompile.SpotPriceIsZero.selector); + hook.onComputeDynamicSwapFeePercentage(p, address(pool), locals.staticFee); + } + + struct FeeRampLocals { + uint8 n; + uint256[] w; + uint256[] b; + uint8 i; + uint8 j; + uint32 thrPPM9; + uint32 capPPM9; + uint32 maxPPM9; + uint256 P; + uint256 capDev; + uint256 D; + uint256 pxIn; + uint256 pxOut; + uint256 feeA; + uint256 expected; + bool ok; + } + + /// Fuzz full param surface: N, pair indices, fee params; mock must match exact expected fee. + function testFuzz_internal_feeRamp_matches_expected_withParams( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed, + uint32 thrPPM9, + uint32 capPPM9, + uint32 maxPPM9 + ) public { + FeeRampLocals memory locals; + + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = fee_normWeights(locals.n, wSeed); + locals.b = fee_balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 11))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 12))), 0, locals.n - 2))) % locals.n; + + (locals.thrPPM9, locals.capPPM9, locals.maxPPM9) = fee_boundParams(thrPPM9, capPPM9, maxPPM9); + + locals.P = fee_pairSpotFromBW(locals.b[locals.i], locals.w[locals.i], locals.b[locals.j], locals.w[locals.j]); + vm.assume(locals.P > 0); + + locals.capDev = fee_ppm9To1e18(locals.capPPM9); + locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 2 + 1); + + (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.maxPPM9), + fee_ppm9To1e18(locals.thrPPM9), + fee_ppm9To1e18(locals.capPPM9), + "fee-fuzz" + ); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals = fee_makeLocals( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j], + locals.pxIn, + locals.pxOut, + locals.thrPPM9, + locals.capPPM9, + locals.maxPPM9 + ); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + (locals.ok, locals.feeA) = mock.ComputeSurgeFee(computeLocals, p, STATIC_SWAP_FEE); + assertTrue(locals.ok, "compute must succeed"); + + locals.expected = fee_expectedFeeWithParams( + locals.P, + locals.pxIn, + locals.pxOut, + STATIC_SWAP_FEE, + locals.thrPPM9, + locals.capPPM9, + locals.maxPPM9 + ); + assertEq(locals.feeA, locals.expected, "mock engine must match expected ramp"); + } + + struct monotoneDeviationLocals { + uint8 n; + uint256[] w; + uint256[] b; + uint8 i; + uint8 j; + uint32 thrPPM9; + uint32 capPPM9; + uint32 maxPPM9; + uint256 P; + uint256 capDev; + uint256 D1; + uint256 D2; + uint256 pxIn1; + uint256 pxOut1; + uint256 pxIn2; + uint256 pxOut2; + uint256 fee1; + uint256 fee2; + } + + /// Monotonicity in deviation under arbitrary (valid) lane params. + function testFuzz_internal_monotone_inDeviation( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 d1, + uint256 d2, + uint32 thrPPM9, + uint32 capPPM9, + uint32 maxPPM9 + ) public { + monotoneDeviationLocals memory locals; + + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = fee_normWeights(locals.n, wSeed); + locals.b = fee_balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(d1, 21))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(d1, 22))), 0, locals.n - 2))) % locals.n; + + (locals.thrPPM9, locals.capPPM9, locals.maxPPM9) = fee_boundParams(thrPPM9, capPPM9, maxPPM9); + + locals.P = fee_pairSpotFromBW(locals.b[locals.i], locals.w[locals.i], locals.b[locals.j], locals.w[locals.j]); + vm.assume(locals.P > 0); + + locals.capDev = fee_ppm9To1e18(locals.capPPM9); + + locals.D1 = uint256(keccak256(abi.encode(d1))) % (locals.capDev + locals.capDev / 2 + 1); + locals.D2 = uint256(keccak256(abi.encode(d2))) % (locals.capDev + locals.capDev / 2 + 1); + if (locals.D2 < locals.D1) (locals.D1, locals.D2) = (locals.D2, locals.D1); + + (locals.pxIn1, locals.pxOut1) = fee_localsForDeviation(locals.P, locals.D1); + (locals.pxIn2, locals.pxOut2) = fee_localsForDeviation(locals.P, locals.D2); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.maxPPM9), + fee_ppm9To1e18(locals.thrPPM9), + fee_ppm9To1e18(locals.capPPM9), + "fee-mono" + ); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + (, locals.fee1) = mock.ComputeSurgeFee( + fee_makeLocals( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j], + locals.pxIn1, + locals.pxOut1, + locals.thrPPM9, + locals.capPPM9, + locals.maxPPM9 + ), + p, + STATIC_SWAP_FEE + ); + (, locals.fee2) = mock.ComputeSurgeFee( + fee_makeLocals( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j], + locals.pxIn2, + locals.pxOut2, + locals.thrPPM9, + locals.capPPM9, + locals.maxPPM9 + ), + p, + STATIC_SWAP_FEE + ); + + assertLe(locals.fee1, locals.fee2, "fee must be non-decreasing in deviation"); + } + + struct balanceScalingLocals { + uint8 n; + uint256[] w; + uint256[] b; + uint8 i; + uint8 j; + uint32 thrPPM9; + uint32 capPPM9; + uint32 maxPPM9; + uint256 P; + uint256 capDev; + uint256 scaleSeed; + uint256 D; + uint256 pxIn; + uint256 pxOut; + uint256 bMin; + uint256 baseAmt; + uint256 fee1; + uint256 fee2; + } + + function testFuzz_internal_balanceScalingInvariance( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed, + uint64 scaleSeed, + uint32 thrPPM9, + uint32 capPPM9, + uint32 maxPPM9 + ) public { + balanceScalingLocals memory locals; + + // --- Setup, seeds, and bounds (same as before) --- + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = fee_normWeights(locals.n, wSeed); + locals.b = fee_balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 31))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 32))), 0, locals.n - 2))) % locals.n; + + (locals.thrPPM9, locals.capPPM9, locals.maxPPM9) = fee_boundParams(thrPPM9, capPPM9, maxPPM9); + + // Pool spot from balances/weights; ensure sane + locals.P = fee_pairSpotFromBW(locals.b[locals.i], locals.w[locals.i], locals.b[locals.j], locals.w[locals.j]); + vm.assume(locals.P > 0); + + locals.capDev = fee_ppm9To1e18(locals.capPPM9); + + // Choose a deviation up to 1.5 * cap to exercise both sides near edges + locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 2 + 1); + + // External price inputs that produce the desired deviation + (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + + // Scale factor k and a base amount small relative to balances to avoid overflow + locals.scaleSeed = 1 + (uint256(scaleSeed) % 1_000_000_000); // k in [1 .. 1e9] + + locals.bMin = locals.b[locals.i] < locals.b[locals.j] ? locals.b[locals.i] : locals.b[locals.j]; + // base amount ~ bMin / 1e12 (but at least 1 wei); keeps amount*k << 2^256 + locals.baseAmt = locals.bMin / 1e12; + if (locals.baseAmt == 0) locals.baseAmt = 1; + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.maxPPM9), + fee_ppm9To1e18(locals.thrPPM9), + fee_ppm9To1e18(locals.capPPM9), + "fee-scale" + ); + + // Unscaled trade + PoolSwapParams memory p1; + p1.kind = SwapKind.EXACT_IN; + p1.amountGivenScaled18 = locals.baseAmt; + + PoolSwapParams memory p2; + p2.kind = SwapKind.EXACT_IN; + p2.amountGivenScaled18 = locals.baseAmt * locals.scaleSeed; + + (, locals.fee1) = mock.ComputeSurgeFee( + fee_makeLocals( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j], + locals.pxIn, + locals.pxOut, + locals.thrPPM9, + locals.capPPM9, + locals.maxPPM9 + ), + p1, + STATIC_SWAP_FEE + ); + + (, locals.fee2) = mock.ComputeSurgeFee( + fee_makeLocals( + locals.b[locals.i] * locals.scaleSeed, + locals.w[locals.i], + locals.b[locals.j] * locals.scaleSeed, + locals.w[locals.j], + locals.pxIn, + locals.pxOut, + locals.thrPPM9, + locals.capPPM9, + locals.maxPPM9 + ), + p2, + STATIC_SWAP_FEE + ); + + // --- Branch-aware assertion (inferred) --- + uint256 strictTol = 100; // knife-edge rounding flips + uint256 delta = locals.fee1 > locals.fee2 ? (locals.fee1 - locals.fee2) : (locals.fee2 - locals.fee1); + + if (delta <= strictTol) { + // Noise-like behavior: strict homogeneity holds + assertApproxEqAbs( + locals.fee1, + locals.fee2, + strictTol, + "noise path: fee invariant to balance + amount scaling (100 wei)" + ); + } else { + // Arb-like behavior: deviation reset makes fee non-homogeneous; allow a tiny bounded drift + // Use ~1e-10 relative tolerance with a small absolute floor to remain meaningful for tiny fees. + uint256 relaxedTol = locals.fee1 / 1e10; + if (relaxedTol < 1e5) relaxedTol = 1e5; + + assertApproxEqAbs( + locals.fee1, + locals.fee2, + relaxedTol, + "arb path: fee approximately invariant after deviation reset (branch-aware tolerance)" + ); + } + } + + struct ExactValuesBoundariesLocal { + uint256 w0; + uint256 w1; + uint256 b0; + uint256 b1; + uint256 P; + uint32 thr; + uint32 cap; + uint32 maxp; + uint256 D; + uint256 pxIn; + uint256 pxOut; + uint256 feeA; + uint256 feeB; + uint256 feeC; + uint256 feeD; + } + + function test_internal_exactValues_boundaries() public { + ExactValuesBoundariesLocal memory locals; + + // 2 tokens, 50/50, equal balances + locals.w0 = 5e17; + locals.w1 = 5e17; + locals.b0 = 1e24; + locals.b1 = 1e24; + locals.P = fee_pairSpotFromBW(locals.b0, locals.w0, locals.b1, locals.w1); + assertGt(locals.P, 0); + + locals.thr = 1_000_000; // 0.1% + locals.cap = 500_000_000; // 50% + locals.maxp = 50_000_000; // 5% + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.maxp), + fee_ppm9To1e18(locals.thr), + fee_ppm9To1e18(locals.cap), + "fee-boundary" + ); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + // Below threshold + locals.D = fee_ppm9To1e18(locals.thr) - 1; + (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + + computeLocals.bIn = locals.b0; + computeLocals.wIn = locals.w0; + computeLocals.bOut = locals.b1; + computeLocals.wOut = locals.w1; + computeLocals.pxIn = locals.pxIn; + computeLocals.pxOut = locals.pxOut; + computeLocals.calcAmountScaled18 = 0; + + // ARB lane = locals’ params (since deviation doesn’t increase with calcAmount=0) + computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr; + computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap; + computeLocals.poolDetails.arbMaxSurgeFee9 = locals.maxp; + + // Make NOISE lane different + computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; + computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; + computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeA) = mock.ComputeSurgeFee(computeLocals, p, STATIC_SWAP_FEE); + assertEq(locals.feeA, STATIC_SWAP_FEE, "below threshold means static fee"); + + locals.D = (fee_ppm9To1e18(locals.thr) + fee_ppm9To1e18(locals.cap)) / 2; + (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + + computeLocals.bIn = locals.b0; + computeLocals.wIn = locals.w0; + computeLocals.bOut = locals.b1; + computeLocals.wOut = locals.w1; + computeLocals.pxIn = locals.pxIn; + computeLocals.pxOut = locals.pxOut; + computeLocals.calcAmountScaled18 = 0; + + computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr; + computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap; + computeLocals.poolDetails.arbMaxSurgeFee9 = locals.maxp; + + computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; + computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; + computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeB) = mock.ComputeSurgeFee(computeLocals, p, STATIC_SWAP_FEE); + + uint256 expected = fee_expectedFeeWithParams( + locals.P, + locals.pxIn, + locals.pxOut, + STATIC_SWAP_FEE, + locals.thr, + locals.cap, + locals.maxp + ); + assertEq(locals.feeB, expected, "mid-span linear ramp"); + + // At cap and above cap + + uint256 Dcap = fee_ppm9To1e18(locals.cap); + (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, Dcap); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals1; + computeLocals1.bIn = locals.b0; + computeLocals1.wIn = locals.w0; + computeLocals1.bOut = locals.b1; + computeLocals1.wOut = locals.w1; + computeLocals1.pxIn = locals.pxIn; + computeLocals1.pxOut = locals.pxOut; + computeLocals1.calcAmountScaled18 = 0; + computeLocals1.poolDetails.arbThresholdPercentage9 = locals.thr; + computeLocals1.poolDetails.arbCapDeviationPercentage9 = locals.cap; + computeLocals1.poolDetails.arbMaxSurgeFee9 = locals.maxp; + computeLocals1.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; + computeLocals1.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; + computeLocals1.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeC) = mock.ComputeSurgeFee(computeLocals1, p, STATIC_SWAP_FEE); + assertEq(locals.feeC, fee_ppm9To1e18(locals.maxp), "at cap means max fee"); + + uint256 Dbeyond = Dcap + 1; + (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, Dbeyond); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals2; + computeLocals2.bIn = locals.b0; + computeLocals2.wIn = locals.w0; + computeLocals2.bOut = locals.b1; + computeLocals2.wOut = locals.w1; + computeLocals2.pxIn = locals.pxIn; + computeLocals2.pxOut = locals.pxOut; + computeLocals2.calcAmountScaled18 = 0; + computeLocals2.poolDetails.arbThresholdPercentage9 = locals.thr; + computeLocals2.poolDetails.arbCapDeviationPercentage9 = locals.cap; + computeLocals2.poolDetails.arbMaxSurgeFee9 = locals.maxp; + computeLocals2.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; + computeLocals2.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; + computeLocals2.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeD) = mock.ComputeSurgeFee(computeLocals2, p, STATIC_SWAP_FEE); + assertEq(locals.feeD, fee_ppm9To1e18(locals.maxp), "above cap means clamped to max fee"); + } + + struct ExactInEqualsExactOutLocals { + uint8 n; + uint256[] w; + uint256[] b; + uint8 i; + uint8 j; + uint32 thr; + uint32 cap; + uint32 maxp; + uint256 P; + uint256 capDev; + uint256 D; + uint256 pxIn; + uint256 pxOut; + uint256 feeIn; + uint256 feeOut; + } + + /// EXACT_IN vs EXACT_OUT: with identical lane params, the engine result must match. + /// Correction: keep the *effective* lane params for the chosen direction the same, + /// but make ARB and NOISE lanes different so a wrong-lane implementation would not hide here. + function testFuzz_internal_exactIn_equals_exactOut_whenParamsSame( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed + ) public { + ExactInEqualsExactOutLocals memory locals; + + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = fee_normWeights(locals.n, wSeed); + locals.b = fee_balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 41))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 42))), 0, locals.n - 2))) % locals.n; + + locals.thr = 1_000_000; // 0.1% + locals.cap = 500_000_000; // 50% + locals.maxp = 50_000_000; // 5% + + locals.P = fee_pairSpotFromBW(locals.b[locals.i], locals.w[locals.i], locals.b[locals.j], locals.w[locals.j]); + vm.assume(locals.P > 0); + + locals.capDev = fee_ppm9To1e18(locals.cap); + locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 2 + 1); + (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.maxp), + fee_ppm9To1e18(locals.thr), + fee_ppm9To1e18(locals.cap), + "fee-io" + ); + + // EXACT_IN + PoolSwapParams memory pIn; + pIn.kind = SwapKind.EXACT_IN; + + // Build locals with NOISE = (thr/cap/maxp) and ARB deliberately different + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L1; + L1.bIn = locals.b[locals.i]; + L1.wIn = locals.w[locals.i]; + L1.bOut = locals.b[locals.j]; + L1.wOut = locals.w[locals.j]; + L1.pxIn = locals.pxIn; + L1.pxOut = locals.pxOut; + L1.calcAmountScaled18 = 0; + + // Effective (chosen) lane params + L1.poolDetails.noiseThresholdPercentage9 = locals.thr; + L1.poolDetails.noiseCapDeviationPercentage9 = locals.cap; + L1.poolDetails.noiseMaxSurgeFee9 = locals.maxp; + + // Different ARB lane params so wrong-lane usage wouldn’t accidentally match + L1.poolDetails.arbThresholdPercentage9 = locals.thr + 1; + L1.poolDetails.arbCapDeviationPercentage9 = locals.cap - 1; + L1.poolDetails.arbMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeIn) = mock.ComputeSurgeFee(L1, pIn, STATIC_SWAP_FEE); + + // EXACT_OUT + PoolSwapParams memory pOut; + pOut.kind = SwapKind.EXACT_OUT; + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L2; + L2.bIn = locals.b[locals.i]; + L2.wIn = locals.w[locals.i]; + L2.bOut = locals.b[locals.j]; + L2.wOut = locals.w[locals.j]; + L2.pxIn = locals.pxIn; + L2.pxOut = locals.pxOut; + L2.calcAmountScaled18 = 0; + + L2.poolDetails.noiseThresholdPercentage9 = locals.thr; + L2.poolDetails.noiseCapDeviationPercentage9 = locals.cap; + L2.poolDetails.noiseMaxSurgeFee9 = locals.maxp; + + L2.poolDetails.arbThresholdPercentage9 = locals.thr + 1; + L2.poolDetails.arbCapDeviationPercentage9 = locals.cap - 1; + L2.poolDetails.arbMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeOut) = mock.ComputeSurgeFee(L2, pOut, STATIC_SWAP_FEE); + + assertEq(locals.feeIn, locals.feeOut, "with equal lane params, kind should not change math result"); + } + + function testFuzz_view_missingPrices_reverts(uint8 nSeed, uint256 /* wSeed */, uint256 bSeed, uint8 iSeed) public { + // --- Register pool and adapt to its actual token count --- + uint8 nTarget = uint8(bound(nSeed, 2, 8)); + _registerBasePoolWithN(nTarget); + + uint256[] memory weights = WeightedPool(address(pool)).getNormalizedWeights(); + uint256 m = weights.length; + assertGe(m, 2, "pool must have at least 2 tokens"); + + // --- Random non-zero balances of exact pool length --- + uint256[] memory b = fee_balances(uint8(m), bSeed); + + // --- Pick a valid distinct pair (i != j) --- + uint256 i = uint256(bound(iSeed, 0, m - 1)); + uint256 j = (i + 1) % m; + + // --- Build base swap params template with those balances --- + PoolSwapParams memory p; + p.balancesScaled18 = new uint256[](m); + for (uint256 k = 0; k < m; ++k) { + p.balancesScaled18[k] = b[k]; + } + p.indexIn = i; + p.indexOut = j; + + uint256 bIn = b[i]; + uint256 bOut = b[j]; + + uint256 safeInAmt = bIn / 1e6; + if (safeInAmt == 0) safeInAmt = 1; + uint256 safeOutAmt = bOut / 1e6; + if (safeOutAmt == 0) safeOutAmt = 1; + + // Sanity: amounts are indeed tiny relative to balances to avoid accidental reverts + // (these checks also self-document the invariant we rely on) + assertLt(safeInAmt, bIn / 10, "safeInAmt too large vs balanceIn"); // < 10% (much stricter in practice) + assertLt(safeOutAmt, bOut / 10, "safeOutAmt too large vs balanceOut"); // < 10% + + p.kind = SwapKind.EXACT_IN; + p.amountGivenScaled18 = safeInAmt; + + vm.expectRevert(HyperSpotPricePrecompile.SpotPriceIsZero.selector); + hook.onComputeDynamicSwapFeePercentage(p, address(pool), STATIC_SWAP_FEE); + + p.kind = SwapKind.EXACT_OUT; + p.amountGivenScaled18 = safeOutAmt; + + vm.expectRevert(HyperSpotPricePrecompile.SpotPriceIsZero.selector); + hook.onComputeDynamicSwapFeePercentage(p, address(pool), STATIC_SWAP_FEE); + } + + function testFuzz_view_readsLaneParams_reverts_onSafePath(uint8 nSeed) public { + uint8 n = uint8(bound(nSeed, 2, 8)); + _registerBasePoolWithN(n); + + // Diverge NOISE and ARB lane params (authorized admin) + vm.startPrank(admin); + hook.setSurgeThresholdPercentage(address(pool), 5_000_000 * 1e9, IHyperSurgeHook.TradeType.NOISE); // 0.5% + hook.setCapDeviationPercentage(address(pool), 400_000_000 * 1e9, IHyperSurgeHook.TradeType.NOISE); // 40% + hook.setMaxSurgeFeePercentage(address(pool), 25_000_000 * 1e9, IHyperSurgeHook.TradeType.NOISE); // 2.5% + + hook.setSurgeThresholdPercentage(address(pool), 1_000_000 * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); // 0.1% + hook.setCapDeviationPercentage(address(pool), 300_000_000 * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); // 30% + hook.setMaxSurgeFeePercentage(address(pool), 50_000_000 * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); // 5% + vm.stopPrank(); + + // Adapt to the pool’s true size to avoid OOB / shape mismatches + uint256[] memory weights = WeightedPool(address(pool)).getNormalizedWeights(); + uint256 m = weights.length; + assertGe(m, 2, "pool must have at least 2 tokens"); + + // Build non-zero balances of correct length m + uint256[] memory balances = new uint256[](m); + for (uint256 k = 0; k < m; ++k) { + balances[k] = 1e24 + k; + } + + PoolSwapParams memory p; + p.amountGivenScaled18 = 1e18; // non-zero trade amount + p.balancesScaled18 = balances; + p.indexIn = 0; + p.indexOut = (m > 1) ? 1 : 0; + + // EXACT_IN: either revert or static fee (but never a computed dynamic fee) + p.kind = SwapKind.EXACT_IN; + vm.expectRevert(HyperSpotPricePrecompile.SpotPriceIsZero.selector); + hook.onComputeDynamicSwapFeePercentage(p, address(pool), STATIC_SWAP_FEE); + + // EXACT_OUT: same invariant + p.kind = SwapKind.EXACT_OUT; + vm.expectRevert(HyperSpotPricePrecompile.SpotPriceIsZero.selector); + hook.onComputeDynamicSwapFeePercentage(p, address(pool), STATIC_SWAP_FEE); + } + + struct DeviationEqualsThreshold { + uint256 staticFee; + uint256 maxFee; + uint32 thr9; + uint32 cap9; + uint32 max9; + uint256 E; + uint256 thr; + uint256 fee; + } + + /// 1) deviation == threshold => returns static fee (boundary counted as "inside") + function test_cfg_fee_static_at_threshold_usingMockWrapper() public view { + DeviationEqualsThreshold memory locals; + + locals.staticFee = 30e14; // 30 bps = 0.003 * 1e18 + locals.maxFee = 120e14; // 120 bps + + // 9 lane params (contract upscales to 18dp) + locals.thr9 = 100_000_000; // 10% + locals.cap9 = 500_000_000; // 50% + locals.max9 = uint32(locals.maxFee / 1e9); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; + computeLocals.pxIn = 1e18; + computeLocals.pxOut = 10e18; // external price E = 10 + + // set both lanes the same (lane choice irrelevant for this edge) + computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr9; + computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; + computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.max9; + computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr9; + computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap9; + computeLocals.poolDetails.arbMaxSurgeFee9 = locals.max9; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.amountGivenScaled18 = 0; + + locals.E = 10e18; + locals.thr = uint256(locals.thr9) * 1e9; // 18dp + + locals.fee = _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.thr); + assertEq(locals.fee, locals.staticFee, "fee must equal static when deviation == threshold"); + } + + struct justAboveThreshold { + uint256 staticFee; + uint256 maxFee; + uint32 thr9; + uint32 cap9; + uint32 max9; + uint256 E; + uint256 thr; + uint256 cap; + uint256 dev; + uint256 span; + uint256 ramp; + uint256 expected; + } + + function test_cfg_fee_minimalRamp_just_above_threshold() public view { + justAboveThreshold memory locals; + + locals.staticFee = 30e14; // 30 bps + locals.maxFee = 120e14; // 120 bps + + locals.thr9 = 100_000_000; // 10% + locals.cap9 = 500_000_000; // 50% + locals.max9 = uint32(locals.maxFee / 1e9); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; + computeLocals.pxIn = 1e18; + computeLocals.pxOut = 10e18; + + computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr9; + computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; + computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.max9; + computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr9; + computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap9; + computeLocals.poolDetails.arbMaxSurgeFee9 = locals.max9; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.amountGivenScaled18 = 0; + + locals.E = 10e18; + locals.thr = uint256(locals.thr9) * 1e9; + locals.cap = uint256(locals.cap9) * 1e9; + locals.dev = (uint256(locals.thr9) + 1) * 1e9; // smallest 18dp step above threshold + + uint256 fee = _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.dev); + + // Expected: static + (max - static) * (dev - thr) / (cap - thr) (div-down) + locals.span = locals.cap - locals.thr; + locals.ramp = ((locals.maxFee - locals.staticFee) * (locals.dev - locals.thr)) / locals.span; + locals.expected = locals.staticFee + locals.ramp; + + assertEq(fee, locals.expected, "minimal ramp just above threshold"); + assertGt(fee, locals.staticFee, "fee > static just above threshold"); + assertLt(fee, locals.maxFee, "fee < max when deviation < cap"); + } + + struct MaxEqualsStatic { + uint256 staticFee; + uint256 maxFee; + uint32 thr9; + uint32 cap9; + uint32 max9; + uint256 E; + uint256 thr; + uint256 cap; + uint256 devAtThr; + uint256 devMid; + uint256 devAtCap; + uint256 devBeyond; + } + + /// 3) degenerate: max == static => always static (even outside threshold) + function test_cfg_fee_degenerateRamp_max_equals_static() public view { + MaxEqualsStatic memory locals; + + locals.staticFee = 45e14; // 45 bps + locals.maxFee = locals.staticFee; + + locals.thr9 = 50_000_000; // 5% + locals.cap9 = 250_000_000; // 25% + locals.max9 = uint32(locals.maxFee / 1e9); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; + computeLocals.pxIn = 1e18; + computeLocals.pxOut = 10e18; + + computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr9; + computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; + computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.max9; + computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr9; + computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap9; + computeLocals.poolDetails.arbMaxSurgeFee9 = locals.max9; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.amountGivenScaled18 = 0; + + locals.E = 10e18; + locals.thr = uint256(locals.thr9) * 1e9; + locals.cap = uint256(locals.cap9) * 1e9; + + locals.devAtThr = locals.thr; + locals.devMid = locals.thr + (locals.cap - locals.thr) / 2; + locals.devAtCap = locals.cap; + locals.devBeyond = locals.cap + 12345; + + assertEq( + _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.devAtThr), + locals.staticFee, + "at thr => static" + ); + assertEq( + _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.devMid), + locals.staticFee, + "mid => static" + ); + assertEq( + _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.devAtCap), + locals.staticFee, + "at cap => static" + ); + assertEq( + _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.devBeyond), + locals.staticFee, + "beyond cap => static" + ); + } + + struct MaxBelowStatic { + uint256 staticFee; + uint256 maxFee; + uint32 thr9; + uint32 cap9; + uint32 max9; + uint256 E; + uint256 thr; + uint256 cap; + uint256 devMid; + uint256 feeMid; + uint256 span; + uint256 ramp; + uint256 expected; + } + + function test_fee_misconfig_maxBelowStatic_usingMockWrapper() public { + MaxBelowStatic memory locals; + + // Misconfig: max < static + locals.staticFee = 80e14; // 80 bps (1e18 scale) + locals.maxFee = 20e14; // 20 bps (1e18 scale) -> lower than static + locals.thr9 = 100_000_000; // 10% in 1e9 + locals.cap9 = 300_000_000; // 30% in 1e9 + locals.max9 = uint32(locals.maxFee / 1e9); + + // Local mock (don’t rely on global `hook`) + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.max9), + fee_ppm9To1e18(locals.thr9), + fee_ppm9To1e18(locals.cap9), + "misconfig-maxBelowStatic" + ); + + // Base inputs used for both sub-tests + locals.E = 10e18; // external price + locals.thr = uint256(locals.thr9) * 1e9; // 18dp threshold + locals.cap = uint256(locals.cap9) * 1e9; // 18dp cap + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory base; + base.pxIn = 1e18; + base.pxOut = locals.E; + + // Set BOTH lanes to the same (misconfigured) params so lane choice doesn't matter here. + base.poolDetails.noiseThresholdPercentage9 = locals.thr9; + base.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; + base.poolDetails.noiseMaxSurgeFee9 = locals.max9; + base.poolDetails.arbThresholdPercentage9 = locals.thr9; + base.poolDetails.arbCapDeviationPercentage9 = locals.cap9; + base.poolDetails.arbMaxSurgeFee9 = locals.max9; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.amountGivenScaled18 = 0; // keep balances-based price exact + p.balancesScaled18 = new uint256[](2); + p.balancesScaled18[0] = 1e18; + p.balancesScaled18[1] = locals.E; + + // Reused working struct + HyperSurgeHookMock.ComputeSurgeFeeLocals memory T; + + // ---------- (a) dev >= cap -> revert (underflow in mock ramp) ---------- + uint256 dev = locals.cap + 999; // strictly beyond cap + uint256 P = locals.E + (locals.E * dev) / 1e18; // P = E * (1 + dev) + T = base; + T.wIn = 1e18; + T.wOut = 1e18; + T.bIn = 1e18; + T.bOut = P; + T.calcAmountScaled18 = 0; + + vm.expectRevert(stdError.arithmeticError); + mock.ComputeSurgeFee(T, p, locals.staticFee); + + // ---------- (b) thr < dev < cap -> revert (underflow in mock ramp) ---------- + dev = locals.thr + (locals.cap - locals.thr) / 3; // strictly between thr & cap + P = locals.E + (locals.E * dev) / 1e18; + T = base; + T.wIn = 1e18; + T.wOut = 1e18; + T.bIn = 1e18; + T.bOut = P; + T.calcAmountScaled18 = 0; + + vm.expectRevert(stdError.arithmeticError); + mock.ComputeSurgeFee(T, p, locals.staticFee); + } + + struct OutsideDynamicAfterLocals { + uint256 E; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 price_before; + uint256 price_after; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + /// 1) Noise: starts outside threshold, deviation worsens → NOISE lane, dynamic fee based on **after** deviation. + function testFuzz_logic_noise_worsens_outside_dynamic_after( + uint256 eSeed, + uint32 noiseThrSeed, + uint32 noiseCapSeed, + uint32 noiseMaxSeed, + uint64 amtSeed + ) public { + OutsideDynamicAfterLocals memory locals; + + // --- Fuzz + bounds --- + locals.E = bound(eSeed, 1e16, 1e24); // pxOut + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + // ARB lane (unused here, but keep distinct) + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = uint256(locals.noiseThr9) * 1e9; + locals.cap = uint256(locals.noiseCap9) * 1e9; + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + + // Start BELOW E: price_before = E * (1 - deviationBefore) + locals.price_before = locals.E - (locals.E * locals.deviationBefore) / 1e18; + + // Build compute locals + swap that worsens deviation (EXACT_IN; calc=0 → P decreases further) + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.price_before; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + + locals.p.kind = SwapKind.EXACT_IN; + // ensure deviation increases *measurably* in Q18 (avoid 1-wei changes) + locals.p.amountGivenScaled18 = bound(uint256(amtSeed), 1e9, 5e17); // [1e9, 0.5e18] + + // Expected (NOISE) uses AFTER deviation: price_after = price_before / (1 + x) + locals.price_after = (locals.price_before * 1e18) / (1e18 + locals.p.amountGivenScaled18); + locals.expected = fee_expectedFeeWithParams( + locals.price_after, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "logic-1" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq(locals.dyn, locals.expected, "noise path must use AFTER deviation for dynamic fee"); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee >= static"); + } + + struct BetterStillOutsideLocals { + uint256 E; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 price_before; + uint256 price_after; + uint256 xMax; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + function testFuzz_logic_arb_outside_improves_but_outside_dynamic_before( + uint256 eSeed, + uint32 arbThrSeed, + uint32 arbCapSeed, + uint32 arbMaxSeed, + uint64 amtSeed + ) public { + BetterStillOutsideLocals memory locals; + + // --- Fuzz + bounds --- + locals.E = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + // NOISE lane different (unused in assertion) + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = uint256(locals.arbThr9) * 1e9; + locals.cap = uint256(locals.arbCap9) * 1e9; + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + + // Start ABOVE E + locals.price_before = locals.E + (locals.E * locals.deviationBefore) / 1e18; + + // Compute xMax to remain outside after: price_after >= E*(1 + thr) + // price_after = price_before / (1 + x) means x less than or equal to (price_before / (E*(1+thr)) - 1) * 1e18 + vm.assume(locals.E * (1e18 + locals.thr) != 0); // defensive + uint256 denom = (locals.E * (1e18 + locals.thr)) / 1e18; + vm.assume(denom != 0); + uint256 ratio = (locals.price_before * 1e18) / denom; + vm.assume(ratio > 1e18); // Ensure room to remain outside + locals.xMax = ratio - 1e18; + if (locals.xMax > 9e17) { + locals.xMax = 9e17; + } // clamp + + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.price_before; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = bound(uint256(amtSeed), 1, locals.xMax == 0 ? 1 : locals.xMax); + + // Expected (ARB) uses BEFORE deviation + locals.expected = fee_expectedFeeWithParams( + locals.price_before, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "logic-2" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + // Still outside afterward (sanity) + locals.price_after = (locals.price_before * 1e18) / (1e18 + locals.p.amountGivenScaled18); + uint256 deviationAfter = (( + locals.price_after > locals.E ? (locals.price_after - locals.E) : (locals.E - locals.price_after) + ) * 1e18) / locals.E; + assertGt(deviationAfter, locals.thr, "should remain outside threshold after improving"); + + assertEq(locals.dyn, locals.expected, "arb path must use BEFORE deviation for dynamic fee"); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee >= static"); + } + + struct NoiseWorsensInsideButStaysInsideLocals { + uint256 E; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 deviationBefore; + uint256 price_before; + uint256 price_after; + uint256 xMax; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 fee; + } + + /// 3) Noise: starts inside threshold, worsens but stays inside → NOISE lane, **base (static)** fee. + function testFuzz_logic_noise_inside_worse_but_inside_static( + uint256 eSeed, + uint32 noiseThrSeed, + uint32 noiseCapSeed, + uint32 noiseMaxSeed, + uint64 amtSeed + ) public { + NoiseWorsensInsideButStaysInsideLocals memory locals; + + locals.E = bound(eSeed, 1e16, 1e24); + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 1_000_000_000 - 1)); // (0,1) + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + locals.thr = uint256(locals.noiseThr9) * 1e9; + locals.deviationBefore = locals.thr / 4 + 1; + locals.price_before = locals.E - (locals.E * locals.deviationBefore) / 1e18; + + uint256 R1e18 = (locals.price_before * 1e18) / locals.E; + uint256 denom = 1e18 - locals.thr; + uint256 q = (R1e18 * 1e18) / denom; + locals.xMax = q > 1e18 ? (q - 1e18) : 0; + if (locals.xMax > 5e17) { + locals.xMax = 5e17; + } + + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.price_before; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + + locals.p.kind = SwapKind.EXACT_IN; + + // Ensure a *measurable* worsening so NOISE is chosen: + // pick x with a lower floor (e.g., 1e9 wei) but never exceed xMax. + uint256 lo = 1e9; // 1e-9 in t; safely above Q18 rounding noise + uint256 hi = locals.xMax; + if (hi < lo) { + lo = 1; + } // if xMax < floor, fall back to [1, xMax] + if (hi < lo) { + hi = lo; + } // clamp + locals.p.amountGivenScaled18 = bound(uint256(amtSeed), lo, hi); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "logic-3" + ); + (, locals.fee) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + // Sanity: still inside after worsening + locals.price_after = (locals.price_before * 1e18) / (1e18 + locals.p.amountGivenScaled18); + uint256 deviationAfter = (( + locals.price_after > locals.E ? (locals.price_after - locals.E) : (locals.E - locals.price_after) + ) * 1e18) / locals.E; + assertLe(deviationAfter, locals.thr, "must remain inside threshold"); + + // Inside-after on NOISE → static + assertEq(locals.fee, STATIC_SWAP_FEE, "inside threshold after worsening must still return static (noise path)"); + } + + struct NoiseCrossesPriceWorsensLocals { + uint256 E; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 price_before; + uint256 price_after; + uint256 tCross; + uint256 tWorse; + uint256 tMin; + uint256 x; + uint256 num; // numerator for tWorse calculation + uint256 den; // denominator for tWorse calculation + uint256 q; // intermediate value for tWorse calculation + uint256 epsT; // safety margin for tMin + uint256 span; // range for x selection + uint256 lo; // lower bound for x + uint256 hi; // upper bound for x + uint256 deviationAfter; // absolute deviation after + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + function testFuzz_logic_noise_outside_crosses_and_worsens_dynamic_after( + uint256 eSeed, + uint32 noiseThrSeed, + uint32 noiseCapSeed, + uint32 noiseMaxSeed, + uint64 amtSeed + ) public { + NoiseCrossesPriceWorsensLocals memory locals; + + // --- Fuzz + bounds --- + locals.E = bound(eSeed, 1e16, 1e24); + + // Keep thr < 1 so denominators stay positive and bands are non-degenerate + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); // (0, 0.9) + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); // (thr, 1] + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + + // ARB lane different (unused in assertion) + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = uint256(locals.noiseThr9) * 1e9; + locals.cap = uint256(locals.noiseCap9) * 1e9; + + // Start ABOVE E with a deviation strictly outside the threshold: + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 4; + locals.price_before = locals.E + (locals.E * locals.deviationBefore) / 1e18; + + // Build compute locals + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.price_before; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + + locals.p.kind = SwapKind.EXACT_IN; + + // We need BOTH: + // (1) Cross: price_after < E means t > Db (R = 1 + Db) + // (2) Worsen: |after| > |before| when ending below: + // 1 - R/(1+t) > Db means (1 - Db)(1 + t) > 1 + Db means t > 2Db/(1 - Db) + locals.tCross = locals.deviationBefore; + // tWorse = ceil( (2*Db) / (1 - Db) ) in Q18 + locals.num = (2 * locals.deviationBefore) * 1e18; // Q36 + locals.den = 1e18 - locals.deviationBefore; + locals.q = (locals.num + locals.den - 1) / locals.den; // ceilDiv -> Q18 + locals.tWorse = locals.q; + + // Add a safety margin to overcome integer rounding in price_after and deviationAfter. + // Use 1e13 in Q18 (i.e., 1e-5) which is ample even for E as large as 1e24. + locals.epsT = 1e13; + locals.tMin = (locals.tWorse > locals.tCross ? locals.tWorse : locals.tCross) + locals.epsT; + + // Choose x = t*1e18 with t in [tMin, tMin + span] + locals.span = 5e17; // allow up to +0.5 in t + locals.lo = locals.tMin; + locals.hi = locals.tMin + locals.span; + if (locals.lo == 0) { + locals.lo = 1; + } // avoid x==0 + + if (locals.hi < locals.lo) { + locals.hi = locals.lo; + } // clamp on overflow + + locals.x = bound(uint256(amtSeed), locals.lo, locals.hi); + locals.p.amountGivenScaled18 = locals.x; + + // Expected uses NOISE with AFTER deviation + locals.price_after = (locals.price_before * 1e18) / (1e18 + locals.x); + + // Sanity: crossed and worsened absolute deviation + locals.deviationBefore = ((locals.price_before - locals.E) * 1e18) / locals.E; + locals.deviationAfter = ((locals.E - locals.price_after) * 1e18) / locals.E; + require(locals.price_after < locals.E, "must cross below E"); + require(locals.deviationAfter > locals.deviationBefore, "must worsen absolute deviation after crossing"); + + locals.expected = fee_expectedFeeWithParams( + locals.price_after, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "logic-4" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq( + locals.dyn, + locals.expected, + "noise path must use AFTER deviation even when crossing the price (worsening)" + ); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee >= static"); + } + + struct OutsideToInsideDynamicBefore { + uint256 E; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 price_before; + uint256 price_after; + uint256 R1e18; // R in 1e18 scale: R = price_before / E + uint256 xLower; // min x to get price_after less than or equal to E*(1+thr) + uint256 xUpper; // max x to keep price_after greater than or equal to E*(1−thr) + uint256 x; // chosen amountGivenScaled18 inside [xLower, xUpper] + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + /// 5) Arb: starts outside, ends inside → ARB lane still uses **BEFORE** deviation (dynamic, not base). + function testFuzz_logic_arb_outside_to_inside_dynamic_before( + uint256 eSeed, + uint32 arbThrSeed, + uint32 arbCapSeed, + uint32 arbMaxSeed, + uint64 amtSeed + ) public { + OutsideToInsideDynamicBefore memory locals; + + // --- Fuzz + bounds --- + locals.E = bound(eSeed, 1e16, 1e24); + // Keep thr strictly < 1e9 so (1e18 - thr) > 0 + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + // NOISE lane can be anything different; not used by this assertion + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = uint256(locals.arbThr9) * 1e9; + locals.cap = uint256(locals.arbCap9) * 1e9; + + // Start ABOVE E with an outside deviation deviationBefore > thr + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + locals.price_before = locals.E + (locals.E * locals.deviationBefore) / 1e18; // price_before = E * (1 + deviationBefore) + locals.R1e18 = (locals.price_before * 1e18) / locals.E; // R = 1e18 + deviationBefore + + // Two-sided “inside” band: 1 − thr less than or equal to price_after/E less than or equal to 1 + thr, + // with price_after/E = R / (1 + t), t = x / 1e18. + + // Lower bound on t (bring down to less than or equal to 1+thr): + // t greater than or equal to R/(1+thr) − 1 means xLower = ceil( (R1e18 * 1e18) / (1e18 + thr) ) − 1e18 + uint256 denomPlus = 1e18 + locals.thr; + uint256 numPlus = locals.R1e18 * 1e18; // Q36 + uint256 qPlus = (numPlus + denomPlus - 1) / denomPlus; // ceilDiv to Q18 + locals.xLower = qPlus > 1e18 ? (qPlus - 1e18) : 0; + + // Upper bound on t (don’t overshoot below 1 − thr): + // t less than or equal to R/(1−thr) − 1 means xUpper = floor( (R1e18 * 1e18) / (1e18 − thr) ) − 1e18 + uint256 denomMinus = 1e18 - locals.thr; // > 0 by bound + uint256 numMinus = locals.R1e18 * 1e18; // Q36 + uint256 qMinus = numMinus / denomMinus; // floorDiv to Q18 + locals.xUpper = qMinus > 1e18 ? (qMinus - 1e18) : 0; + + // Choose x inside [xLower, xUpper] using bound (no vm.assume). Collapse if inverted. + uint256 lo = locals.xLower; + uint256 hi = locals.xUpper; + if (hi < lo) { + hi = lo; + } + // avoid degenerate zero (x == 0 keeps price_after == price_before and won’t end inside) + if (lo == 0) lo = 1; + if (hi < lo) hi = lo; + + locals.x = bound(uint256(amtSeed), lo, hi); + + // Build compute locals + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.price_before; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = locals.x; + + // Expected (ARB) uses BEFORE deviation even though end is inside + locals.expected = fee_expectedFeeWithParams( + locals.price_before, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "logic-5" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + // Sanity: end is inside (two-sided) + locals.price_after = (locals.price_before * 1e18) / (1e18 + locals.p.amountGivenScaled18); + uint256 deviationAfter = (( + locals.price_after > locals.E ? (locals.price_after - locals.E) : (locals.E - locals.price_after) + ) * 1e18) / locals.E; + assertLe(deviationAfter, locals.thr, "end should be inside threshold"); + + assertEq( + locals.dyn, + locals.expected, + "arb path must use BEFORE deviation even if the end state is inside threshold" + ); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee >= static"); + } + + struct InsideToOutsideDynamicAfterLocals { + uint256 E; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 priceBefore; + uint256 priceAfter; + uint256 R1e18; // = priceBefore/E (Q18) + uint256 tLower; // min t to make priceAfter/E less than or equal to 1 - thr (Q18) + uint256 x; // = t * 1e18 (amount in) + uint256 num; // numerator for tLower calculation + uint256 den; // denominator for tLower calculation + uint256 q; // intermediate value for tLower calculation + uint256 eps; // epsilon for x calculation + uint256 lo; // lower bound for x + uint256 hi; // upper bound for x + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + /// [LANE] Inside → cross outside (NOISE, dynamic with AFTER) + function testFuzz_logic_noise_inside_to_outside_dynamic_after( + uint256 eSeed, + uint32 noiseThrSeed, + uint32 noiseCapSeed, + uint32 noiseMaxSeed, + uint64 amtSeed + ) public { + InsideToOutsideDynamicAfterLocals memory locals; + + // Lane params (NOISE fuzzed, ARB fixed and different) + locals.E = bound(eSeed, 1e16, 1e24); + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = uint256(locals.noiseThr9) * 1e9; + locals.cap = uint256(locals.noiseCap9) * 1e9; + + // Start BELOW E but inside: deviationBefore ∈ [0, thr) + locals.deviationBefore = (locals.thr / 3) + 1; // safely inside + locals.priceBefore = locals.E - (locals.E * locals.deviationBefore) / 1e18; // P/E = 1 - deviationBefore + locals.R1e18 = (locals.priceBefore * 1e18) / locals.E; + + // Need priceAfter/E less than or equal to 1 - thr ⇒ t greater than or equal to R/(1 - thr) - 1 + + locals.num = locals.R1e18 * 1e18; // Q36 + locals.den = 1e18 - locals.thr; + locals.q = (locals.num + locals.den - 1) / locals.den; // ceilDiv → Q18 + locals.tLower = locals.q > 1e18 ? (locals.q - 1e18) : 0; + + // Pick x greater than or equal to tLower (plus small epsilon) to cross outside + locals.eps = 1e12; + locals.lo = locals.tLower + locals.eps; + if (locals.lo == 0) locals.lo = 1; + locals.hi = locals.lo + 5e17; // allow up to +0.5 in t + locals.x = bound(uint256(amtSeed), locals.lo, locals.hi); + + // Build locals + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.priceBefore; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = locals.x; + + // Expected (NOISE) uses AFTER + locals.priceAfter = (locals.priceBefore * 1e18) / (1e18 + locals.x); + uint256 deviationAfter = (( + locals.priceAfter > locals.E ? (locals.priceAfter - locals.E) : (locals.E - locals.priceAfter) + ) * 1e18) / locals.E; + assertGt(deviationAfter, locals.thr, "must end outside threshold (worsened)"); + locals.expected = fee_expectedFeeWithParams( + locals.priceAfter, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "lane-inside2outside" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq(locals.dyn, locals.expected, "noise/after: dynamic fee must match expected"); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee greater than or equal to static"); + } + + struct OutsideToThresholdDynamicBeforeLocals { + uint256 E; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 priceBefore; + uint256 priceAfter; + uint256 R1e18; + uint256 tLower; + uint256 tUpper; + uint256 x; + uint256 epsT; + uint256 lo; + uint256 hi; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + /// [LANE] Outside → to (or just inside) threshold (ARB, dynamic with BEFORE) + function testFuzz_logic_arb_outside_to_threshold_dynamic_before( + uint256 eSeed, + uint32 arbThrSeed, + uint32 arbCapSeed, + uint32 arbMaxSeed, + uint64 amtSeed + ) public { + OutsideToThresholdDynamicBeforeLocals memory locals; + + locals.E = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + // Distinct NOISE lane (unused in expected but kept different) + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = uint256(locals.arbThr9) * 1e9; + locals.cap = uint256(locals.arbCap9) * 1e9; + + // Start ABOVE, outside + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + locals.priceBefore = locals.E + (locals.E * locals.deviationBefore) / 1e18; + + // R = priceBefore / E in Q18; compute both ceil and floor variants to bound tightly + // R_up = ceil( (priceBefore * 1e18) / E ) + // R_down = floor( (priceBefore * 1e18) / E ) + uint256 numR = locals.priceBefore * 1e18; + locals.R1e18 = (numR + locals.E - 1) / locals.E; + + // We need 1 - thr less than or equal to priceAfter/E less than or equal to 1 + thr, and priceAfter/E = R / (1 + t), with t = x/1e18 (Q18). + // Lower bound on t (to get under the upper edge 1 + thr): + // t ≥ R/(1 + thr) − 1 + // Use R_up and ceil-div to be conservative, then subtract 1e18. + uint256 denomPlus = 1e18 + locals.thr; + uint256 numPlus = locals.R1e18 * 1e18; // Q36 + uint256 qPlus = (numPlus + denomPlus - 1) / denomPlus; // ceilDiv → Q18 + locals.tLower = qPlus > 1e18 ? (qPlus - 1e18) : 0; + + // Upper bound on t (don’t drop below the lower edge 1 − thr): + // t less than or equal to R/(1 − thr) − 1 + // Use R_down and floor-div to be conservative, then subtract 1e18. + uint256 denomMinus = 1e18 - locals.thr; + uint256 numMinus = locals.R1e18 * 1e18; // Q36 + uint256 qMinus = numMinus / denomMinus; // floorDiv → Q18 + locals.tUpper = qMinus > 1e18 ? (qMinus - 1e18) : 0; + + // Choose t inside [tLower + eps, tUpper − eps] and map amtSeed with bound(...). + // eps helps avoid equality-edge flips due to integer rounding. + locals.epsT = 1; // one Q18 unit (~1e-18) is ample given we used ceil/floor conservatively + locals.lo = locals.tLower + locals.epsT; + locals.hi = (locals.tUpper > locals.epsT) ? (locals.tUpper - locals.epsT) : locals.tUpper; + + // If interval collapses or inverted (can happen with extreme tiny thr), clamp to a point and proceed. + if (locals.hi < locals.lo) { + locals.hi = locals.lo; + } + if (locals.lo == 0) { + locals.lo = 1; + if (locals.hi < locals.lo) locals.hi = locals.lo; + } + + locals.x = bound(uint256(amtSeed), locals.lo, locals.hi); + + // Build locals + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.priceBefore; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = locals.x; + + // Sanity: end is inside (two-sided) + locals.priceAfter = (locals.priceBefore * 1e18) / (1e18 + locals.p.amountGivenScaled18); + uint256 dAfter = (( + locals.priceAfter > locals.E ? (locals.priceAfter - locals.E) : (locals.E - locals.priceAfter) + ) * 1e18) / locals.E; + assertLe(dAfter, locals.thr, "end should be at/inside threshold"); + + // Expected (ARB) uses BEFORE even if end is at/inside threshold + locals.expected = fee_expectedFeeWithParams( + locals.priceBefore, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "lane-out2thr" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq( + locals.dyn, + locals.expected, + "arb/before: dynamic fee must use BEFORE deviation even at threshold end" + ); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee greater than or equal to static"); + } + + struct ArbNoMoveOutsideDynamicLocals { + uint256 E; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 priceBefore; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + function test_logic_arb_outside_nochange_dynamic_before( + uint256 eSeed, + uint32 arbThrSeed, + uint32 arbCapSeed, + uint32 arbMaxSeed + ) public { + ArbNoMoveOutsideDynamicLocals memory locals; + + locals.E = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + // NOISE lane different (unused) + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = uint256(locals.arbThr9) * 1e9; + locals.cap = uint256(locals.arbCap9) * 1e9; + + // Start ABOVE, outside + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; + locals.priceBefore = locals.E + (locals.E * locals.deviationBefore) / 1e18; + + // No movement: amount = 0, so deviationAfter == deviationBefore → ARB path + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.priceBefore; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = 0; + + locals.expected = fee_expectedFeeWithParams( + locals.priceBefore, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "lane-nomove-outside" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq(locals.dyn, locals.expected, "no-move/outside must be ARB, dynamic from BEFORE"); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee greater than or equal to static"); + } + + struct ArbNoMoveInsideLocals { + uint256 E; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 deviationBefore; + uint256 priceBefore; + uint256 fee; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + } + + /// [LANE] No movement, inside: ARB path, but STATIC fee (since BEFORE less than or equal to thr) + function test_logic_arb_inside_nochange_static( + uint256 eSeed, + uint32 arbThrSeed, + uint32 arbCapSeed, + uint32 arbMaxSeed + ) public { + ArbNoMoveInsideLocals memory locals; + + locals.E = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 1_000_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = uint256(locals.arbThr9) * 1e9; + + // Start BELOW, inside + locals.deviationBefore = (locals.thr / 3) + 1; // strictly inside + locals.priceBefore = locals.E - (locals.E * locals.deviationBefore) / 1e18; + + // No movement: deviationAfter == deviationBefore → ARB branch, but less than or equal to thr ⇒ static + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.priceBefore; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = uint32(locals.arbCap9); + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = 0; + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "lane-nomove-inside" + ); + (, locals.fee) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq( + locals.fee, + STATIC_SWAP_FEE, + "no-move/inside must return static (ARB branch, but less than or equal to thr)" + ); + } + + struct NoiseCrossesPriceWorsensDymanicLocals { + uint256 E; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 priceBefore; + uint256 priceAfter; + uint256 tCross; + uint256 tWorse; + uint256 tMin; + uint256 x; + uint256 num; + uint256 den; + uint256 q; + uint256 epsT; + uint256 lo; + uint256 hi; + uint256 deviationAfter; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 expected; + uint256 dyn; + } + + /// [LANE] Symmetric “below” case: start outside BELOW, worsen further BELOW (no cross) → NOISE uses AFTER + /// Note: With calc=0 and this simplified price update, EXACT_IN can only decrease P, + /// so a true below→above cross is not representable without changing the price update model. + /// This test locks the symmetric NOISE/AFTER behavior from the “below” side. + function testFuzz_logic_noise_outside_below_worsens_dynamic_after( + uint256 eSeed, + uint32 noiseThrSeed, + uint32 noiseCapSeed, + uint32 noiseMaxSeed, + uint64 amtSeed + ) public { + NoiseCrossesPriceWorsensDymanicLocals memory locals; + + // External price (pxOut/pxIn -> E); keep as in all other tests + locals.E = bound(eSeed, 1e16, 1e24); + + // Distinct NOISE lane params (fuzzed) and different ARB params (unused in expected but distinct to catch wrong-lane) + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = uint256(locals.noiseThr9) * 1e9; + locals.cap = uint256(locals.noiseCap9) * 1e9; + + // Start OUTSIDE BELOW price: priceBefore = E * (1 - D_before), with D_before in (thr, cap) + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; + locals.priceBefore = locals.E - (locals.E * locals.deviationBefore) / 1e18; + + // Build compute locals with the standard orientation (pxIn=1e18, pxOut=E) + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.priceBefore; + locals.comp.pxIn = 1e18; // keep the usual frame + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + + // EXACT_IN reduces P further → deviation worsens from the BELOW side (NOISE lane) + locals.p.kind = SwapKind.EXACT_IN; + // ensure a measurable worsening but no overflow; avoid 1-wei knife edges + uint256 lo = 1e9; + uint256 hi = 5e17; + locals.p.amountGivenScaled18 = bound(uint256(amtSeed), lo, hi); + + // AFTER price for expected (NOISE uses AFTER) + locals.priceAfter = (locals.priceBefore * 1e18) / (1e18 + locals.p.amountGivenScaled18); + + // Sanity: still BELOW E and deviation increased + uint256 dBefore = ((locals.E - locals.priceBefore) * 1e18) / locals.E; + uint256 dAfter = ((locals.E - locals.priceAfter) * 1e18) / locals.E; + assertGt(dAfter, dBefore, "deviation must worsen from the below side"); + + // Expected NOISE fee from AFTER deviation + locals.expected = fee_expectedFeeWithParams( + locals.priceAfter, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "lane-below-worsen" + ); + (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq(locals.dyn, locals.expected, "noise/after (below side): dynamic fee must match expected"); + assertGe(locals.dyn, STATIC_SWAP_FEE, "dynamic fee greater than or equal to static"); + } + + struct BoundArbBeforeClampToMaxLocals { + uint256 E; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 cap; + uint256 Db; + uint256 priceBefore; + uint256 priceAfter; + uint256 tLower; + uint256 tUpperNoCross; + uint256 x; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + uint256 fee; + uint256 expected; + } + + /// [BOUND] ARB with BEFORE > cap, AFTER < cap: ARB clamps to maxArb (basis = BEFORE) + /// Start ABOVE with BEFORE deviation > cap, improve so AFTER less than or equal to cap (stay above; no cross). + /// Assert: ARB lane; fee == arbMax (clamped by BEFORE). + function testFuzz_bound_arb_before_gt_cap_clamps_to_max_before( + uint256 eSeed, + uint32 arbThrSeed, + uint32 arbCapSeed, + uint32 arbMaxSeed, + uint64 amtSeed + ) public { + BoundArbBeforeClampToMaxLocals memory locals; + + // External price + locals.E = bound(eSeed, 1e16, 1e24); + + // ARB lane params (ensure thr < cap < 1.0) + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); // (0, 0.9) + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000 - 1)); // (thr, 1) + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9) + 1, 1_000_000_000)); + + // Distinct NOISE params (unused in expected but kept different to catch wrong-lane) + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = uint256(locals.arbThr9) * 1e9; + locals.cap = uint256(locals.arbCap9) * 1e9; + assertLt(locals.cap, 1e18, "cap must be < 100%"); + + // BEFORE deviation strictly above cap but < 1, with safe margin + // margin = max(1, (1e18 - cap)/16) keeps Db < 1 while staying comfortably > cap + uint256 margin = (1e18 - locals.cap) / 16; + if (margin == 0) { + margin = 1; + } + locals.Db = locals.cap + margin; + if (locals.Db >= 1e18) { + locals.Db = 1e18 - 1; + } + + // Sanity: BEFORE > cap + assertGt(locals.Db, locals.cap, "setup must have BEFORE > cap"); + + // Price ABOVE E with BEFORE deviation Db + locals.priceBefore = locals.E + (locals.E * locals.Db) / 1e18; + + // ABOVE side with EXACT_IN: + // D_after_pos (no-cross) = (Db - t)/(1 + t). Want AFTER less than or equal to cap ⇒ t ≥ (Db - cap)/(1 + cap). + + uint256 num = (locals.Db - locals.cap) * 1e18; // Q36 (Db > cap guaranteed) + uint256 den = 1e18 + locals.cap; + uint256 q = (num + den - 1) / den; // ceilDiv → Q18 + locals.tLower = q; + + // Avoid crossing E: need t < Db. Use tiny epsilon below Db to stay strictly above E. + uint256 epsCross = 1; // one Q18 unit + locals.tUpperNoCross = (locals.Db > epsCross) ? (locals.Db - epsCross) : 0; + + uint256 lo = (locals.tLower == 0 ? 1 : locals.tLower); + uint256 hi = locals.tUpperNoCross; + + if (hi < lo) { + hi = lo; + } + locals.x = bound(uint256(amtSeed), lo, hi); + + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.priceBefore; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = locals.x; + + // AFTER should be less than or equal to cap (improved) and we shouldn’t have crossed E. + locals.priceAfter = (locals.priceBefore * 1e18) / (1e18 + locals.x); + uint256 dAfter = (( + locals.priceAfter > locals.E ? (locals.priceAfter - locals.E) : (locals.E - locals.priceAfter) + ) * 1e18) / locals.E; + assertLe(dAfter, locals.cap, "AFTER should be less than or equal to cap (improved)"); + + // ARB uses BEFORE and must clamp to maxArb + (, locals.fee) = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "arb-before-cap" + ).ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + locals.expected = fee_expectedFeeWithParams( + locals.priceBefore, + locals.comp.pxIn, + locals.comp.pxOut, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + assertEq(locals.fee, locals.expected, "ARB should compute from BEFORE and clamp at cap->max"); + assertEq(locals.fee, fee_ppm9To1e18(locals.arbMax9), "ARB fee must equal arbMax"); + } + + struct BoundNoiseExactThresholdLocals { + uint256 E; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 Db; + uint256 priceBefore; + uint256 priceAfter; + uint256 tEdge; + uint256 x; + uint256 fee; + HyperSurgeHookMock.ComputeSurgeFeeLocals comp; + PoolSwapParams p; + } + + function testFuzz_bound_noise_after_at_threshold_static( + uint256 eSeed, + uint32 noiseThrSeed, + uint32 noiseCapSeed, + uint32 noiseMaxSeed, + uint64 amtSeed + ) public { + BoundNoiseExactThresholdLocals memory locals; + + locals.E = bound(eSeed, 1e16, 1e24); + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = uint256(locals.noiseThr9) * 1e9; + + locals.Db = locals.thr / 4 + 1; + locals.priceBefore = locals.E - (locals.E * locals.Db) / 1e18; + + uint256 num = (locals.thr - locals.Db) * 1e18; + uint256 den = 1e18 - locals.thr; + locals.tEdge = den == 0 ? 0 : (num / den); + + uint256 epsT = 1e6; + uint256 lo = (locals.tEdge > epsT) ? (locals.tEdge - epsT) : 1; + uint256 hi = locals.tEdge; + if (hi < lo) { + hi = lo; + } + + locals.x = bound(uint256(amtSeed), lo, hi); + locals.comp.wIn = 1e18; + locals.comp.wOut = 1e18; + locals.comp.bIn = 1e18; + locals.comp.bOut = locals.priceBefore; + locals.comp.pxIn = 1e18; + locals.comp.pxOut = locals.E; + locals.comp.calcAmountScaled18 = 0; + locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; + locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + locals.p.kind = SwapKind.EXACT_IN; + locals.p.amountGivenScaled18 = locals.x; + locals.priceAfter = (locals.priceBefore * 1e18) / (1e18 + locals.p.amountGivenScaled18); + + uint256 dBefore = ((locals.E - locals.priceBefore) * 1e18) / locals.E; + uint256 dAfter = ((locals.E - locals.priceAfter) * 1e18) / locals.E; + assertLe(dAfter, locals.thr, "AFTER should be less than or equal to threshold (at-or-just-inside)"); + assertGt(dAfter, dBefore, "deviation must worsen (positive t)"); + + (, locals.fee) = new HyperSurgeHookMock( + IVault(vault), + fee_ppm9To1e18(locals.arbMax9), + fee_ppm9To1e18(locals.arbThr9), + fee_ppm9To1e18(locals.arbCap9), + "noise-exact-thr" + ).ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + assertEq(locals.fee, STATIC_SWAP_FEE, "At threshold end-state: NOISE must return static (no ramp)"); + } + + uint32 constant HL_IDX_SZ_0 = 100; + uint32 constant HL_IDX_SZ_8 = 108; + + bytes4 constant _SEL_SPOT_PRICE = bytes4(keccak256("spotPrice(uint32)")); + address constant _HYPER_SPOT_PRICE_PRECOMPILE = 0x0000000000000000000000000000000000000808; + + function _mockHyperSpotPrice(uint32 pairIndex, uint64 raw) internal { + vm.mockCall( + _HYPER_SPOT_PRICE_PRECOMPILE, + abi.encode(pairIndex), // <- no selector + abi.encode(raw) // 32-byte padded uint64 + ); + } + + function testFuzz_Fee_FallbacksToStatic_When_ExtPxZero(bool givenIn, uint64 rawInHuge) public { + TokenConfig[] memory cfg = new TokenConfig[](2); + LiquidityManagement memory lm; + vm.prank(address(vault)); + hook.onRegister(poolFactory, address(pool), cfg, lm); + + // 2) Use the same HL token index you used (108 -> sz=0 -> divisor=1e8) on BOTH tokens + uint32 pairIn = 8001; + uint32 pairOut = 8002; + + vm.startPrank(admin); + hook.setTokenPriceConfigIndex(address(pool), 0, pairIn, 108); // div=1e8 + hook.setTokenPriceConfigIndex(address(pool), 1, pairOut, 108); // div=1e8 + vm.stopPrank(); + + // 3) Force extPx == 0 with NON-ZERO raws: + // extPx = floor((pxOut*1e18)/pxIn) = floor((rawOut*1e18)/rawIn) + // => choose rawOut=1 and rawIn > 1e18 (fits in uint64), so extPx == 0 + rawInHuge = uint64(bound(uint256(rawInHuge), 1e18 + 1, type(uint64).max)); + + // (optional) prove we hit the correct precompile and calldata (no selector) + vm.expectCall(_HYPER_SPOT_PRICE_PRECOMPILE, abi.encode(pairIn)); + vm.expectCall(_HYPER_SPOT_PRICE_PRECOMPILE, abi.encode(pairOut)); + + // Mock the spot prices with the correct calldata (NO selector) + _mockHyperSpotPrice(pairIn, rawInHuge); // pxIn = rawInHuge * 1e10 + _mockHyperSpotPrice(pairOut, 1); // pxOut = 1 * 1e10 + + // 4) Build params (all 7 fields) + uint256[] memory balances = new uint256[](2); + balances[0] = 1e18; + balances[1] = 1e18; + + SwapKind kind = givenIn ? SwapKind.EXACT_IN : SwapKind.EXACT_OUT; + + PoolSwapParams memory p = PoolSwapParams({ + kind: kind, + amountGivenScaled18: 5e15, + balancesScaled18: balances, + indexIn: 0, + indexOut: 1, + router: address(0), + userData: "" + }); + + // 5) Expect: NO revert; the hook falls back to pool static fee because extPx == 0 + uint256 staticFee = WeightedPool(address(pool)).getStaticSwapFeePercentage(); + (bool ok, uint256 dynFee) = hook.onComputeDynamicSwapFeePercentage(p, address(pool), staticFee); + + assertTrue(ok, "extPx==0 must not block"); + assertEq(dynFee, staticFee, "extPx==0 must return static fee"); + } + + function testFuzz_Fee_ClampsToMax_When_DeviationBeyondCap(bool givenIn, uint64 rawOutHuge) public { + uint256 idxIn = 0; + uint256 idxOut = 1; + uint256 amountGiven = 5e15; + + uint256[] memory balances = new uint256[](2); + balances[0] = 1e18; + balances[1] = 1e18; + + TokenConfig[] memory cfg = new TokenConfig[](2); + LiquidityManagement memory lm; + vm.prank(address(vault)); + hook.onRegister(poolFactory, address(pool), cfg, lm); + + uint32 pairIn = 91001; + uint32 pairOut = 91002; + vm.startPrank(admin); + hook.setTokenPriceConfigIndex(address(pool), uint8(idxIn), pairIn, HL_IDX_SZ_8); + hook.setTokenPriceConfigIndex(address(pool), uint8(idxOut), pairOut, HL_IDX_SZ_8); + + uint256 thr = 1e16; // 1% + uint256 cap = 2e16; // 2% + uint256 max = 15e15; // 1.5% + + hook.setSurgeThresholdPercentage(address(pool), thr, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setSurgeThresholdPercentage(address(pool), thr, IHyperSurgeHook.TradeType.NOISE); + hook.setCapDeviationPercentage(address(pool), cap, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setCapDeviationPercentage(address(pool), cap, IHyperSurgeHook.TradeType.NOISE); + hook.setMaxSurgeFeePercentage(address(pool), max, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setMaxSurgeFeePercentage(address(pool), max, IHyperSurgeHook.TradeType.NOISE); + vm.stopPrank(); + + // External price >> 1.0: + // extPx = (pxOut / pxIn) with same divisor. Set pxOut very large, pxIn = 1 unit. + // Use HL_IDX_SZ_8 (divisor 1e8) so raw numbers are easy: rawIn=1e8, rawOut in [5e9, max]. + rawOutHuge = uint64(bound(uint256(rawOutHuge), 5e9, type(uint64).max)); + _mockHyperSpotPrice(pairIn, uint64(1e8)); + _mockHyperSpotPrice(pairOut, rawOutHuge); + + SwapKind kind = givenIn ? SwapKind.EXACT_IN : SwapKind.EXACT_OUT; + PoolSwapParams memory p = _makeParams(idxIn, idxOut, kind, amountGiven, balances); + + uint256 staticFee = WeightedPool(address(pool)).getStaticSwapFeePercentage(); + (bool ok, uint256 fee) = hook.onComputeDynamicSwapFeePercentage(p, address(pool), staticFee); + + assertTrue(ok, "fee path must not block"); + assertEq(fee, max, "fee must clamp at configured maxPct"); + } + + function testFuzz_Fee_ReturnsStatic_When_DeviationBelowThreshold(bool givenIn, uint64 rawBase) public { + uint256 idxIn = 0; + uint256 idxOut = 1; + uint256 amountGiven = 5e15; + + uint256[] memory balances = new uint256[](2); + balances[0] = 1e18; + balances[1] = 1e18; + + TokenConfig[] memory cfg = new TokenConfig[](2); + LiquidityManagement memory lm; + vm.prank(address(vault)); + hook.onRegister(poolFactory, address(pool), cfg, lm); + + uint32 pairIn = 92001; + uint32 pairOut = 92002; + vm.startPrank(admin); + hook.setTokenPriceConfigIndex(address(pool), uint8(idxIn), pairIn, HL_IDX_SZ_8); + hook.setTokenPriceConfigIndex(address(pool), uint8(idxOut), pairOut, HL_IDX_SZ_8); + + // Set a relatively generous threshold (5%) and a higher cap so we stay in "below threshold" + uint256 thr = 5e16; // 5% + uint256 cap = 20e16; // 20% (arbitrary > thr) + uint256 max = 50e16; // 50% (irrelevant here) + + hook.setSurgeThresholdPercentage(address(pool), thr, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setSurgeThresholdPercentage(address(pool), thr, IHyperSurgeHook.TradeType.NOISE); + hook.setCapDeviationPercentage(address(pool), cap, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setCapDeviationPercentage(address(pool), cap, IHyperSurgeHook.TradeType.NOISE); + hook.setMaxSurgeFeePercentage(address(pool), max, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setMaxSurgeFeePercentage(address(pool), max, IHyperSurgeHook.TradeType.NOISE); + vm.stopPrank(); + + // Make extPx ≈ 1.0 within ~1e-8 relative drift, far below the 5% threshold. + // Same divisor (1e8): extPx = (rawOut/rawIn). Pick rawOut = rawBase + 1, rawIn = rawBase. + rawBase = uint64(bound(uint256(rawBase), 1e8, 5e9)); // ensure > 0 and leaves headroom for +1 + _mockHyperSpotPrice(pairIn, rawBase); + _mockHyperSpotPrice(pairOut, rawBase + 1); + + SwapKind kind = givenIn ? SwapKind.EXACT_IN : SwapKind.EXACT_OUT; + PoolSwapParams memory p = _makeParams(idxIn, idxOut, kind, amountGiven, balances); + + uint256 staticFee = WeightedPool(address(pool)).getStaticSwapFeePercentage(); + (bool ok, uint256 fee) = hook.onComputeDynamicSwapFeePercentage(p, address(pool), staticFee); + + assertTrue(ok, "below-threshold path must not block"); + assertEq(fee, staticFee, "below-threshold deviation must return static fee"); + } + + function _makeParams( + uint256 indexIn, + uint256 indexOut, + SwapKind kind, + uint256 amountGivenScaled18, + uint256[] memory balancesScaled18 + ) internal pure returns (PoolSwapParams memory p) { + p = PoolSwapParams({ + kind: kind, + amountGivenScaled18: amountGivenScaled18, + balancesScaled18: balancesScaled18, + indexIn: indexIn, + indexOut: indexOut, + router: address(0), + userData: bytes("") + }); + } + + function _assertStaticFeeOrRevert(PoolSwapParams memory p) internal view { + (bool ok, uint256 fee) = hook.onComputeDynamicSwapFeePercentage(p, address(pool), STATIC_SWAP_FEE); + assertTrue(ok, "invalid shape must not set ok=false"); + assertEq(fee, STATIC_SWAP_FEE, "invalid shape must not produce a dynamic fee"); + } + + function _createPool( + address[] memory tokens, + string memory label + ) internal override returns (address newPool, bytes memory poolArgs) { + if (weights.length == 0 || weights.length != tokens.length) { + weights = new uint256[](tokens.length); + + for (uint256 i = 0; i < tokens.length; i++) { + weights[i] = 1e18 / tokens.length; // Equal weights + } + } + + LiquidityManagement memory liquidityManagement; + PoolRoleAccounts memory roleAccounts; + roleAccounts.poolCreator = admin; + roleAccounts.swapFeeManager = admin; + + WeightedPool.NewPoolParams memory params = WeightedPool.NewPoolParams({ + name: label, + symbol: "WPOOL", + numTokens: tokens.length, + normalizedWeights: weights, + version: "1.0" + }); + + newPool = address(deployWeightedPoolMock(params, IVault(vault))); + + vault.registerPool( + newPool, + vault.buildTokenConfig(tokens.asIERC20()), + DEFAULT_SWAP_FEE, + 0, + false, + roleAccounts, + address(0), + liquidityManagement + ); + + poolArgs = abi.encode( + WeightedPool.NewPoolParams({ + name: label, + symbol: "WPOOL", + numTokens: tokens.length, + normalizedWeights: weights, + version: "1.0" + }), + vault + ); + } + + /// @notice Register the BaseVaultTest pool with a fuzzed token count n (2..8). + function _registerBasePoolWithN(uint8 n) internal { + n = uint8(bound(n, 2, 8)); + + TokenConfig[] memory cfg = new TokenConfig[](n); + LiquidityManagement memory lm; + vm.prank(address(vault)); // onRegister is onlyVault + bool ok = hook.onRegister(poolFactory, address(pool), cfg, lm); + assertTrue(ok, "onRegister(base pool) failed"); + } + + function _hlSetSpot(uint32 pairIdx, uint32 price_1e6) internal { + bytes32 slot = keccak256(abi.encode(bytes32(uint256(pairIdx)), bytes32(uint256(0)))); + vm.store(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, slot, bytes32(uint256(price_1e6))); + } + + function _hlSetSzDecimals(uint32 pairIdx, uint8 sz) internal { + bytes32 slot = keccak256(abi.encode(bytes32(uint256(pairIdx)), bytes32(uint256(0)))); + vm.store(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, slot, bytes32(uint256(sz))); + } + + function _feeAtDeviation( + HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals, + PoolSwapParams memory p, + uint256 staticFee, + uint256 extPxE18, + uint256 deviation18 + ) internal view returns (uint256) { + // pool price P = E * (1 + deviation) + uint256 P = extPxE18 + (extPxE18 * deviation18) / 1e18; + + // Make poolPx = P using simple weights/balances: + // poolPx = (bOut * wIn) / (bIn * wOut) + computeLocals.wIn = 1e18; + computeLocals.wOut = 1e18; + computeLocals.bIn = 1e18; + computeLocals.bOut = P; + + // Keep deltas zero so poolPx == poolPxBefore (no lane flip due to swap) + computeLocals.calcAmountScaled18 = 0; + + (bool ok, uint256 fee) = hook.ComputeSurgeFee(computeLocals, p, staticFee); + assertTrue(ok, "compute ok"); + return fee; + } + + function fee_mulDown(uint256 a, uint256 b) internal pure returns (uint256) { + return (a * b) / FEE_ONE; + } + + function fee_divDown(uint256 a, uint256 b) internal pure returns (uint256) { + return (a * FEE_ONE) / b; + } + + function fee_relAbsDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? fee_divDown(a - b, b) : fee_divDown(b - a, b); + } + + // Pool pair-spot with the SAME staging & rounding the hook uses: + // P = (B_out * w_in) / (B_in * w_out) + function fee_pairSpotFromBW(uint256 bIn, uint256 wIn, uint256 bOut, uint256 wOut) internal pure returns (uint256) { + uint256 num = fee_mulDown(bOut, wIn); + uint256 den = fee_mulDown(bIn, wOut); + return den == 0 ? 0 : fee_divDown(num, den); + } + + // Weights: normalized with 1% floor, deterministic from a seed + function fee_normWeights(uint8 n, uint256 seed) internal pure returns (uint256[] memory w) { + uint256 WEIGHT_MIN = 1e16; // 1% + require(uint256(n) * WEIGHT_MIN <= FEE_ONE, "min too big"); + w = new uint256[](n); + + uint256[] memory r = new uint256[](n); + uint256 sumR; + unchecked { + for (uint8 i = 0; i < n; ++i) { + r[i] = 1 + (uint256(keccak256(abi.encode(seed, i))) % 1e9); + sumR += r[i]; + } + } + + uint256 base = uint256(n) * WEIGHT_MIN; + uint256 rem = FEE_ONE - base; + uint256 acc; + for (uint8 i = 0; i < n; ++i) { + uint256 share = (r[i] * rem) / sumR; + w[i] = WEIGHT_MIN + share; + acc += w[i]; + } + if (acc != FEE_ONE) { + if (acc < FEE_ONE) w[0] += (FEE_ONE - acc); + else { + uint256 over = acc - FEE_ONE; + w[0] = w[0] > over + WEIGHT_MIN ? (w[0] - over) : WEIGHT_MIN; + } + } + } + + // Balances: large safe magnitudes + function fee_balances(uint8 n, uint256 seed) internal pure returns (uint256[] memory b) { + b = new uint256[](n); + for (uint8 i = 0; i < n; ++i) { + // 1e12 .. 1e24 + uint256 x = 1e12 + (uint256(keccak256(abi.encode(seed, i))) % (1e24 - 1e12)); + b[i] = x; + } + } + + // Choose deviation D, then set external px so that extPx = P / (1 + D) + function fee_localsForDeviation(uint256 P, uint256 D) internal pure returns (uint256 pxIn, uint256 pxOut) { + pxIn = FEE_ONE; + pxOut = fee_divDown(P, FEE_ONE + D); + } + + function fee_ppm9To1e18(uint32 v) internal pure returns (uint256) { + return uint256(v) * 1e9; + } + + // Expected fee (exact same rounding & clamping as the hook) + function fee_expectedFeeWithParams( + uint256 poolPx, + uint256 pxIn, + uint256 pxOut, + uint256 staticSwapFee, + uint32 thresholdPPM9, + uint32 capDevPPM9, + uint32 maxFeePPM9 + ) internal pure returns (uint256) { + uint256 extPx = fee_divDown(pxOut, pxIn); + uint256 deviation = fee_relAbsDiff(poolPx, extPx); + + uint256 threshold = fee_ppm9To1e18(thresholdPPM9); + uint256 capDev = fee_ppm9To1e18(capDevPPM9); + uint256 maxPct = fee_ppm9To1e18(maxFeePPM9); + + if (deviation <= threshold) { + return staticSwapFee; + } + + uint256 span = capDev - threshold; + uint256 norm = fee_divDown(deviation - threshold, span); + if (norm > FEE_ONE) { + norm = FEE_ONE; + } + + uint256 incr = fee_mulDown(maxPct - staticSwapFee, norm); + uint256 fee = staticSwapFee + incr; + if (fee > maxPct) { + fee = maxPct; + } + return fee; + } + + function fee_makeLocals( + uint256 bIn, + uint256 wIn, + uint256 bOut, + uint256 wOut, + uint256 pxIn, + uint256 pxOut, + uint32 thrPPM9, + uint32 capPPM9, + uint32 maxPPM9 + ) internal pure returns (HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals) { + computeLocals.bIn = bIn; + computeLocals.wIn = wIn; + computeLocals.bOut = bOut; + computeLocals.wOut = wOut; + computeLocals.pxIn = pxIn; + computeLocals.pxOut = pxOut; + computeLocals.poolDetails.noiseThresholdPercentage9 = thrPPM9; + computeLocals.poolDetails.noiseCapDeviationPercentage9 = capPPM9; + computeLocals.poolDetails.noiseMaxSurgeFee9 = maxPPM9; + computeLocals.poolDetails.arbThresholdPercentage9 = thrPPM9; + computeLocals.poolDetails.arbCapDeviationPercentage9 = capPPM9; + computeLocals.poolDetails.arbMaxSurgeFee9 = maxPPM9; + } + + function fee_boundParams( + uint32 thrPPM9, + uint32 capPPM9, + uint32 maxPPM9 + ) internal pure returns (uint32 thr, uint32 cap, uint32 maxp) { + // Constrain to valid ranges: + // Threshold in [0.0001% .. 20%] + thr = uint32(bound(thrPPM9, 1_000, 200_000_000)); + + // Cap in (threshold .. 90%] + cap = uint32(bound(capPPM9, thr + 1, 900_000_000)); + + // Max fee must be >= static swap fee (1% => 10_000_000 ppm9), and <= 90% + maxp = uint32(bound(maxPPM9, 10_000_000, 900_000_000)); + } +} diff --git a/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol b/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol new file mode 100644 index 00000000..b4ad0da2 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol @@ -0,0 +1,1257 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +// Base test utilities (provides: vault, pool, poolFactory, admin, authorizer, routers, tokens, etc.) +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; + +import { CastingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/CastingHelpers.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; + +// Hook interfaces +import { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; +import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; +import { IAuthorizer } from "@balancer-labs/v3-interfaces/contracts/vault/IAuthorizer.sol"; +import { AddLiquidityKind, RemoveLiquidityKind } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +// Vault interfaces/types +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { + TokenConfig, + LiquidityManagement, + PoolSwapParams, + SwapKind, + PoolRoleAccounts +} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +// Local deployer + mock +import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.sol"; +import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; +import { HyperSurgeHook } from ".../../contracts/hooks-quantamm/HyperSurgeHook.sol"; +import { + WeightedPoolContractsDeployer +} from "@balancer-labs/v3-pool-weighted/test/foundry/utils/WeightedPoolContractsDeployer.sol"; +import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; + +import { + HyperSpotPricePrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperSpotPricePrecompile.sol"; +import { + HyperTokenInfoPrecompile +} from "@balancer-labs/v3-standalone-utils/contracts/utils/HyperTokenInfoPrecompile.sol"; +import { + HypercorePrecompileMock +} from "@balancer-labs/v3-standalone-utils/test/foundry/utils/HypercorePrecompileMock.sol"; + +contract HLPriceStub { + mapping(uint32 => uint32) internal px; // slot 0 + + fallback(bytes calldata data) external returns (bytes memory ret) { + uint32 pairIndex = abi.decode(data, (uint32)); + return abi.encode(px[pairIndex]); + } + + function set(uint32 pairIndex, uint32 price_1e6) external { + px[pairIndex] = price_1e6; + } +} + +contract HLTokenInfoStub { + mapping(uint32 => uint8) internal sz; // slot 0 + + // Optional but nice for staticcall patterns: + fallback(bytes calldata data) external returns (bytes memory ret) { + uint32 tokenIndex = abi.decode(data, (uint32)); + + // Read stored record and ensure the struct fields exist + HyperTokenInfoPrecompile.HyperTokenInfo memory t; + + // Copy only what you care about; others can be zero/empty + t.szDecimals = sz[tokenIndex]; + + return abi.encode(t); // <<< return the STRUCT + } + + function set(uint32 pairIndex, uint8 decimals) external { + sz[pairIndex] = decimals; + } +} + +contract HyperSurgeLiquidityCheckTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoolContractsDeployer { + using ArrayHelpers for *; + using CastingHelpers for address[]; + + uint256 constant ONE = 1e18; + + uint256 internal constant DEFAULT_SWAP_FEE = 1e16; // 1% + + HyperSurgeHookMock internal hook; + + HLPriceStub internal _pxStubDeployer; + HLTokenInfoStub internal _infoStubDeployer; + + function _createPool( + address[] memory tokens, + string memory label + ) internal override returns (address newPool, bytes memory poolArgs) { + // Create a Weighted Pool with the given tokens and default weights. + + if (weights.length == 0 || weights.length != tokens.length) { + weights = new uint256[](tokens.length); + + for (uint256 i = 0; i < tokens.length; i++) { + weights[i] = 1e18 / tokens.length; // Equal weights + } + } + + LiquidityManagement memory liquidityManagement; + PoolRoleAccounts memory roleAccounts; + roleAccounts.poolCreator = admin; + roleAccounts.swapFeeManager = admin; + + WeightedPool.NewPoolParams memory params = WeightedPool.NewPoolParams({ + name: label, + symbol: "WPOOL", + numTokens: tokens.length, + normalizedWeights: weights, + version: "1.0" + }); + + newPool = address(deployWeightedPoolMock(params, IVault(vault))); + + vault.registerPool( + newPool, + vault.buildTokenConfig(tokens.asIERC20()), + DEFAULT_SWAP_FEE, + 0, + false, + roleAccounts, + address(0), + liquidityManagement + ); + + poolArgs = abi.encode( + WeightedPool.NewPoolParams({ + name: label, + symbol: "WPOOL", + numTokens: tokens.length, + normalizedWeights: weights, + version: "1.0" + }), + vault + ); + } + + function _hlSetSpot(uint32 pairIdx, uint32 price_1e6) internal { + bytes32 slot = keccak256(abi.encode(bytes32(uint256(pairIdx)), bytes32(uint256(0)))); + vm.store(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, slot, bytes32(uint256(price_1e6))); + } + + function _hlSetSzDecimals(uint32 tokenIdx, uint8 sz) internal { + bytes32 slot = keccak256(abi.encode(bytes32(uint256(tokenIdx)), bytes32(uint256(0)))); + vm.store(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, slot, bytes32(uint256(sz))); + } + + function setUp() public virtual override { + super.setUp(); // vault, pool, poolFactory, admin, authorizer, tokens, routers, ... + + vm.prank(address(poolFactory)); // some repos require factory to deploy + hook = deployHook( + IVault(address(vault)), + 0.02e18, // default max fee (2%) + 0.02e18, // default threshold (2%) + 1e18, + string("test") + ); + + _pxStubDeployer = new HLPriceStub(); + _infoStubDeployer = new HLTokenInfoStub(); + vm.etch(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, address(_pxStubDeployer).code); + vm.etch(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, address(_infoStubDeployer).code); + + // Seed a couple of pairs (pairIndex 1 and 2) + _hlSetSzDecimals(1, 6); + _hlSetSzDecimals(2, 6); + _hlSetSpot(1, 100_000_000); // 100.000000 (1e6 scale) + _hlSetSpot(2, 200_000_000); // 200.000000 (1e6 scale) + + // 3) Grant admin roles to `admin` + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setMaxSurgeFeePercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setSurgeThresholdPercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setCapDeviationPercentage.selector), + admin + ); + authorizer.grantRole( + IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigIndex.selector), + admin + ); + } + + function _poolTokenCount() internal view returns (uint8) { + uint256 len = WeightedPool(address(pool)).getNormalizedWeights().length; + require(len > 0 && len <= type(uint8).max, "weights"); + return uint8(len); + } + + /// Register with nUsed = min(bound(n,2..8), poolTokenCount) + function _registerBasePoolWithPoolN(uint8 n) internal returns (uint8 nUsed) { + uint8 poolN = _poolTokenCount(); + nUsed = uint8(bound(n, 2, 8)); + if (nUsed > poolN) nUsed = poolN; + + TokenConfig[] memory cfg = new TokenConfig[](nUsed); + LiquidityManagement memory lm; + vm.prank(address(vault)); // onlyVault + bool ok = hook.onRegister(poolFactory, address(pool), cfg, lm); + assertTrue(ok, "onRegister failed"); + } + + /// Configure HL for all token indices [0..nUsed-1] + function _configHLForAll(uint8 nUsed, uint32 basePairSeed, uint8 szSeed) internal { + uint8 sz = uint8(bound(szSeed, 1, 8)); + uint32 base = uint32(bound(uint256(basePairSeed), 21, type(uint32).max - nUsed - 21)); + for (uint8 i = 0; i < nUsed; ++i) { + uint32 pairIdx = base + i; // non-zero, distinct + uint32 tokenIdx = base + i + 20; // 0..nUsed-1 + _hlSetSzDecimals(tokenIdx, sz); // 0..6 + _hlSetSpot(pairIdx, 1); // raw=1 (ratio stability) + vm.prank(admin); + hook.setTokenPriceConfigIndex(address(pool), i, pairIdx, tokenIdx); + } + } + + /// Small, permissive thresholds in ppb (1e9) + function _configThresholds() internal { + vm.startPrank(admin); + hook.setMaxSurgeFeePercentage(address(pool), 50_000_000_000000000, IHyperSurgeHook.TradeType.NOISE); // 5% + hook.setSurgeThresholdPercentage(address(pool), 1_000_000_000000000, IHyperSurgeHook.TradeType.NOISE); // 0.1% + hook.setCapDeviationPercentage(address(pool), 500_000_000_000000000, IHyperSurgeHook.TradeType.NOISE); // 50% + vm.stopPrank(); + } + + function _balancesEqual(uint8 nUsed) internal pure returns (uint256[] memory balances) { + balances = new uint256[](nUsed); + for (uint8 i = 0; i < nUsed; ++i) { + balances[i] = 1e20; + } + } + + function _balancesProportionalToWeights(uint8 nUsed) internal view returns (uint256[] memory balances) { + uint256[] memory weights = WeightedPool(address(pool)).getNormalizedWeights(); // 1e18 scale, sum=1e18 + balances = new uint256[](nUsed); + uint256 scale = 1e20; // big scale to reduce rounding noise + for (uint8 i = 0; i < nUsed; ++i) { + uint256 bi = (scale * weights[i]) / 1e18; + balances[i] = bi == 0 ? 1 : bi; + } + } + + function testFuzz_onAfterAddLiquidity_proportional_allows_n( + uint8 n, + uint32 pairSeed, + uint8 szSeed, + uint256 amtSeed + ) public { + uint8 nUsed = _registerBasePoolWithPoolN(n); + _configHLForAll(nUsed, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory balances = _balancesEqual(nUsed); + uint256[] memory amountsScaled18 = new uint256[](nUsed); + uint256[] memory amountsRaw = new uint256[](nUsed); + + for (uint8 i = 0; i < nUsed; ++i) { + uint256 weightScaled = 1e18 * (i + 1); + uint256 amount = (uint256(keccak256(abi.encode(amtSeed, i))) % (weightScaled / 10 + 1)); + amountsScaled18[i] = amount; + amountsRaw[i] = amount; + } + + (bool ok, ) = hook.onAfterAddLiquidity( + address(this), + address(pool), + AddLiquidityKind.PROPORTIONAL, + amountsScaled18, + amountsRaw, + 0, + balances, + "" + ); + assertTrue(ok, "PROPORTIONAL must allow"); + } + + /// @notice UNBALANCED add with a *longer* amounts array must not OOB, + /// and if the post-add state is balanced (old is imbalanced), the add improves/keeps deviation ⇒ allow. + /// @dev The hook iterates by `balances.length`, so `amounts.length == m+1` is safe (extra tail ignored). + /// We adapt to the hook’s actual `numTokens` by reading the price-config arrays length from storage, + /// then configure 1:1 external prices for all `m` tokens so deviation is driven purely by balances. + /// @param nSeed Pool size seed (bounded to [2,8]) – used by registration helper. + /// @param pairSeed Fuzzed seed for pair ids (helper will derive valid, non-zero pair ids). + /// @param szSeed Fuzzed seed for szDecimals (helper will clamp to ≤6). + function testFuzz_onAfterAddLiquidity_lengthMismatch_improves_allows_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = uint8(bound(nSeed, 2, 8)); + _registerBasePoolWithPoolN(n); + + // Use the hook’s actual token count (numTokens) from storage-sized arrays. + (uint32[] memory pairs, ) = hook.getTokenPriceConfigs(address(pool)); + uint256 m = pairs.length; + assertGe(m, 2, "pool must have at least 2 tokens"); + + // Configure external prices for the *actual* m tokens, then thresholds (0.1% etc). + _configHLForAll(uint8(m), pairSeed, szSeed); + _configThresholds(); + + // Post-add balances: perfectly balanced vector of length m. + uint256[] memory balancesBalanced = new uint256[](m); + for (uint256 k = 0; k < m; ++k) { + balancesBalanced[k] = 1e24; + } + + // Make "old" imbalanced by setting a nonzero add on index 0; use amounts length m+1 (mismatch). + uint256 d = balancesBalanced[0] / 50; // 2% > 0.1% threshold + if (d == 0) { + d = 1; + } + uint256[] memory amountsInScaled18 = new uint256[](m + 1); + uint256[] memory amountsInRaw = new uint256[](m + 1); + amountsInScaled18[0] = d; + amountsInRaw[0] = d; + + (bool ok, ) = hook.onAfterAddLiquidity( + address(this), + address(pool), + AddLiquidityKind.UNBALANCED, // any non-PROPORTIONAL kind + amountsInScaled18, + amountsInRaw, + 0, // lpAmount (unused) + balancesBalanced, // post-add is balanced (dev = 0) + "" // userData (unused) + ); + vm.stopPrank(); + + assertTrue(ok, "improving/neutral deviation must allow even with longer amounts array"); + } + + /// @notice UNBALANCED add with a *longer* amounts array must not OOB, + /// and if the post-add state worsens deviation beyond threshold, it must block. + /// @dev We adapt to the hook’s `numTokens` (via price-config length), configure 1:1 prices for `m` tokens, + /// then create a post-add imbalance (+10% on idx 0). We set amounts[0]=bump so old = post − bump ⇒ balanced. + /// With small threshold (0.1%), this must block. + /// @param nSeed Pool size seed (bounded to [2,8]) – used by registration helper. + /// @param pairSeed Fuzzed seed for pair ids (helper will derive valid, non-zero pair ids). + /// @param szSeed Fuzzed seed for szDecimals (helper will clamp to ≤6). + function testFuzz_onAfterAddLiquidity_lengthMismatch_worsens_blocks_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = uint8(bound(nSeed, 2, 8)); + _registerBasePoolWithPoolN(n); + + (uint32[] memory pairs, ) = hook.getTokenPriceConfigs(address(pool)); + uint256 m = pairs.length; + assertGe(m, 2, "pool must have at least 2 tokens"); + + _configHLForAll(uint8(m), pairSeed, szSeed); + _configThresholds(); + + // Start from balanced vector, then make post-add imbalanced by +10% on index 0. + uint256[] memory balancesImbalanced = new uint256[](m); + for (uint256 k = 0; k < m; ++k) { + balancesImbalanced[k] = 1e24; + } + uint256 bump = balancesImbalanced[0] / 10; // 10% >> 0.1% threshold + if (bump == 0) { + bump = 1; + } + balancesImbalanced[0] += bump; + + // amounts length m+1 (mismatch); set amounts[0]=bump so old = post-add − bump ⇒ balanced. + uint256[] memory amountsInScaled18 = new uint256[](m + 1); + uint256[] memory amountsInRaw = new uint256[](m + 1); + amountsInScaled18[0] = bump; + amountsInRaw[0] = bump; + + (bool ok, ) = hook.onAfterAddLiquidity( + address(this), + address(pool), + AddLiquidityKind.UNBALANCED, + amountsInScaled18, + amountsInRaw, + 0, + balancesImbalanced, // post-add: imbalanced ⇒ dev ~ 10% + "" + ); + vm.stopPrank(); + + assertFalse(ok, "worsening deviation above threshold must block even with longer amounts array"); + } + + function testFuzz_onAfterAddLiquidity_underflow_reverts_n( + uint8 n, + uint32 pairSeed, + uint8 szSeed, + uint256 bump + ) public { + uint8 nUsed = _registerBasePoolWithPoolN(n); + _configHLForAll(nUsed, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory balances = _balancesEqual(nUsed); + uint256[] memory amountsScaled18 = new uint256[](nUsed); + uint256[] memory amountsRaw = new uint256[](nUsed); + + // Force underflow in old = B' - in (index 0): in > B' + uint256 overflowBump = ((bump % 5) + 1); + amountsScaled18[0] = balances[0] + overflowBump; + amountsRaw[0] = amountsScaled18[0]; + + vm.expectRevert(); // current hook reverts on this arithmetic underflow + hook.onAfterAddLiquidity( + address(this), + address(pool), + AddLiquidityKind.UNBALANCED, + amountsScaled18, + amountsRaw, + 0, + balances, + "" + ); + vm.stopPrank(); + } + + function testFuzz_onAfterAddLiquidity_improves_allows_n( + uint8 n, + uint32 pairSeed, + uint8 szSeed, + uint256 delta + ) public { + uint8 nUsed = _registerBasePoolWithPoolN(n); + _configHLForAll(nUsed, pairSeed, szSeed); + _configThresholds(); + + // old imbalanced (old = Bp - d at idx0), after Bp balanced + uint256[] memory balances = _balancesEqual(nUsed); + uint256[] memory amountsScaled18 = new uint256[](nUsed); + uint256[] memory amountsRaw = new uint256[](nUsed); + + delta = bound(delta, 1, balances[0] / 2); + amountsScaled18[0] = delta; + amountsRaw[0] = delta; // old = [Bp0 - d, Bp1, ...] → after improves to balanced + + (bool ok, ) = hook.onAfterAddLiquidity( + address(this), + address(pool), + AddLiquidityKind.UNBALANCED, + amountsScaled18, + amountsRaw, + 0, + balances, + "" + ); + assertTrue(ok, "improving/neutral deviation must allow"); + } + + function testFuzz_onAfterRemoveLiquidity_proportional_allows_n( + uint8 n, + uint32 pairSeed, + uint8 szSeed, + uint256 amtSeed + ) public { + uint8 nUsed = _registerBasePoolWithPoolN(n); + _configHLForAll(nUsed, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory balances = _balancesEqual(nUsed); + uint256[] memory amountsScaled18 = new uint256[](nUsed); + uint256[] memory amountsRaw = new uint256[](nUsed); + for (uint8 i = 0; i < nUsed; ++i) { + uint256 b = 1e18 * (i + 1); + uint256 a = (uint256(keccak256(abi.encode(amtSeed, i))) % (b / 10 + 1)); + amountsScaled18[i] = a; + amountsRaw[i] = a; + } + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.PROPORTIONAL, + 0, + amountsScaled18, + amountsRaw, + balances, + "" + ); + assertTrue(ok, "PROPORTIONAL must allow"); + } + + /// @notice With checked arithmetic in onAfterRemoveLiquidity, any overflow while reconstructing + /// pre-remove balances (post + out) must revert (fail-fast). + /// @dev We fabricate an impossible state to prove the invariant: balances[0] = MAX and + /// amountsOutScaled18[0] = 1 ⇒ (balances + out) overflows. In production, the Vault + /// would not produce such inputs; this is a harness sanity check. Lengths are kept + /// equal to avoid the early "length mismatch ⇒ allow" branch. N is fuzzed 2..8. + /// @param nSeed Fuzzed pool size seed (bounded to [2,8]). + function testFuzz_onAfterRemoveLiquidity_overflow_reverts_n(uint8 nSeed) public { + uint8 n = uint8(bound(nSeed, 2, 8)); + _registerBasePoolWithPoolN(n); + + // Equal-length arrays to reach the arithmetic path (no early allow). + uint256[] memory balances = new uint256[](n); + uint256[] memory amountsOutScaled18 = new uint256[](n); + uint256[] memory amountsOutRaw = new uint256[](n); + + // Seed sane non-zero balances, then force an overflow at index 0. + for (uint256 i = 0; i < n; ++i) { + balances[i] = 1e24; + } + balances[0] = type(uint256).max; // impossible in reality, useful to prove fail-fast + amountsOutScaled18[0] = 1; + amountsOutRaw[0] = 1; + + vm.expectRevert(); + hook.onAfterRemoveLiquidity( + address(this), // sender + address(pool), // pool + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, // any non-PROPORTIONAL kind + 0, // lpAmount (unused) + amountsOutScaled18, + amountsOutRaw, + balances, + "" // userData (unused) + ); + } + + function testFuzz_onAfterAddLiquidity_worsens_blocks_n( + uint8 n, + uint32 pairSeed, + uint8 szSeed, + uint256 deltaSeed + ) public { + // Register and configure all tokens with HL pairs (ext ratio = 1) + uint8 nUsed = _registerBasePoolWithPoolN(n); + _configHLForAll(nUsed, pairSeed, szSeed); + _configThresholds(); // default NOISE threshold 2% in 1e9 + + // Construct pre-add (old) balances proportional to weights ⇒ beforeDev == 0 + uint256[] memory oldB = _balancesProportionalToWeights(nUsed); + + // Choose a single-sided add on token 0 big enough to exceed the 2% threshold + uint256 minDelta = (oldB[0] * 3) / 100; // ≥3% to be safely > threshold (2%) + uint256 maxDelta = oldB[0] / 2; // keep it tame + uint256 d = bound(deltaSeed, minDelta == 0 ? 1 : minDelta, maxDelta == 0 ? 1 : maxDelta); + + // Post-add balances B' = old + in + uint256[] memory Bprime = new uint256[](nUsed); + for (uint8 i = 0; i < nUsed; ++i) { + Bprime[i] = oldB[i]; + } + Bprime[0] = Bprime[0] + d; + + // AmountsIn arrays (scaled18/raw) matching B' - old + uint256[] memory amountsScaled18 = new uint256[](nUsed); + uint256[] memory amountsRaw = new uint256[](nUsed); + amountsScaled18[0] = d; + amountsRaw[0] = d; + + (bool ok, ) = hook.onAfterAddLiquidity( + address(this), + address(pool), + AddLiquidityKind.UNBALANCED, + amountsScaled18, + amountsRaw, + 0, + Bprime, + "" + ); + + // We started on-oracle (beforeDev≈0) and moved away by ≥3% ⇒ must block. + assertFalse(ok, "worsening deviation must block"); + } + + function testFuzz_onAfterRemoveLiquidity_worsens_blocks_n( + uint8 n, + uint32 pairSeed, + uint8 szSeed, + uint256 deltaSeed + ) public { + // Register and configure all tokens with HL pairs (ext ratio = 1) + uint8 nUsed = _registerBasePoolWithPoolN(n); + _configHLForAll(nUsed, pairSeed, szSeed); + _configThresholds(); // default NOISE threshold 2% + + // Pre-remove "old" balances proportional to weights ⇒ beforeDev == 0 + uint256[] memory oldB = _balancesProportionalToWeights(nUsed); + + // Choose a single-sided removal on token 0 big enough to exceed the 2% threshold + uint256 minDelta = (oldB[0] * 3) / 100; // ≥3% + uint256 maxDelta = oldB[0] / 2; + uint256 d = bound(deltaSeed, minDelta == 0 ? 1 : minDelta, maxDelta == 0 ? 1 : maxDelta); + + // Post-remove balances B' = old − out (make sure it doesn't underflow) + uint256[] memory Bprime = new uint256[](nUsed); + for (uint8 i = 0; i < nUsed; ++i) { + Bprime[i] = oldB[i]; + } + Bprime[0] = Bprime[0] - d; + + // AmountsOut arrays (scaled18/raw) matching old − B' + uint256[] memory amountsScaled18 = new uint256[](nUsed); + uint256[] memory amountsRaw = new uint256[](nUsed); + amountsScaled18[0] = d; + amountsRaw[0] = d; + + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + + // From on-oracle to ≥3% away ⇒ must block. + assertFalse(ok, "worsening deviation must block"); + } + + function testFuzz_onAfterRemoveLiquidity_improves_allows_n( + uint8 n, + uint32 pairSeed, + uint8 szSeed, + uint256 delta + ) public { + uint8 nUsed = _registerBasePoolWithPoolN(n); + _configHLForAll(nUsed, pairSeed, szSeed); + _configThresholds(); + + // old imbalanced; choose B' balanced by having out only on idx0 + uint256[] memory Bp = _balancesEqual(nUsed); + uint256[] memory amountsScaled18 = new uint256[](nUsed); + uint256[] memory amountsRaw = new uint256[](nUsed); + + uint256 d = bound(delta, 1, Bp[0] / 2); + amountsScaled18[0] = d; + amountsRaw[0] = d; // old = B' + d at idx0 → imbalanced; after is balanced + + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bp, + "" + ); + assertTrue(ok, "improving/neutral deviation must allow"); + } + + /// CASE 1: Starts outside threshold and worsens ⇒ must BLOCK. + /// Old: token0 5% BELOW proportional (above-price, |dev|=5%). + /// Remove: further 2% from token0 ⇒ |dev| increases (remains outside). + function testFuzz_onAfterRemoveLiquidity_case1_outside_worsens_blocks_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); // ~2% + + // Balanced baseline + uint256[] memory base = _balancesProportionalToWeights(n); + + // Old state O: token0 reduced by 5% + uint256 d5 = base[0] / 20; + if (d5 == 0) d5 = 1; + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] - d5; + + // Post B' : remove an additional 2% from token0 + uint256 d2 = base[0] / 50; + if (d2 == 0) d2 = 1; + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + Bprime[0] = deviatedBalances[0] - d2; + + // Amounts = O - B' + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[0] = deviatedBalances[0] - Bprime[0]; + amountsRaw[0] = amountsScaled18[0]; + + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + + assertFalse(ok, "outside + worsened must block"); + } + + /// CASE 2: Starts outside threshold, improves but still outside ⇒ must ALLOW. + /// Old: token0 5% BELOW proportional (|dev|=5%). + /// Remove: 1% from token1 ⇒ shrinks |dev| to ~4% (>2%) but improves. + function testFuzz_onAfterRemoveLiquidity_case2_outside_improves_but_outside_allows_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory base = _balancesProportionalToWeights(n); + + // Old O: token0 5% low + uint256 d5 = base[0] / 20; + if (d5 == 0) { + d5 = 1; + } + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] - d5; + + // Post B' : remove 1% from token1 -> reduces deviation but stays > 2% + uint256 d1 = base[1] / 100; + if (d1 == 0) { + d1 = 1; + } + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + Bprime[1] = deviatedBalances[1] - d1; + + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[1] = deviatedBalances[1] - Bprime[1]; + amountsRaw[1] = amountsScaled18[1]; + + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + + assertTrue(ok, "outside but improving (still outside) must allow"); + } + + /// CASE 3: Starts inside threshold, worsens but stays inside ⇒ must ALLOW. + /// Old: token0 1% BELOW proportional (|dev|=1% < 2%). + /// Remove: extra 0.5% from token0 ⇒ |dev|~1.5% still inside. + function testFuzz_onAfterRemoveLiquidity_case3_inside_worsens_but_inside_allows_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); + + vm.startPrank(admin); + // 2% in ppm9 (2e7); use NOISE lane because onAfterRemoveLiquidity checks NOISE + hook.setSurgeThresholdPercentage(address(pool), 20_000_000_000000000, IHyperSurgeHook.TradeType.NOISE); + vm.stopPrank(); + + uint256[] memory base = _balancesProportionalToWeights(n); + + uint256 d1 = base[0] / 100; + if (d1 == 0) { + d1 = 1; + } // 1% + uint256 d05 = base[0] / 200; + if (d05 == 0) { + d05 = 1; + } // 0.5% + + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] - d1; + + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + Bprime[0] = deviatedBalances[0] - d05; // worsens but still <= 2% + + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[0] = deviatedBalances[0] - Bprime[0]; + amountsRaw[0] = amountsScaled18[0]; + + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + + assertTrue(ok, "inside but worsening (still inside) must allow"); + } + + /// CASE 4: Starts inside threshold, worsens but stays inside (opposite orientation) ⇒ ALLOW. + /// Old: token0 1% ABOVE proportional (below-price, |dev|=1%). + /// Remove: 0.5% from token1 (reduces token1) ⇒ increases relative excess of token0 but still < 2%. + function testFuzz_onAfterRemoveLiquidity_case4_inside_worsens_but_inside_allows_alt_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); + + vm.startPrank(admin); + // 2% in ppm9 (2e7); use NOISE lane because onAfterRemoveLiquidity checks NOISE + hook.setSurgeThresholdPercentage(address(pool), 20_000_000_000000000, IHyperSurgeHook.TradeType.NOISE); + vm.stopPrank(); + + uint256[] memory base = _balancesProportionalToWeights(n); + + uint256 d1 = base[0] / 100; + if (d1 == 0) { + d1 = 1; + } // 1% + uint256 d05 = base[1] / 200; + if (d05 == 0) { + d05 = 1; + } // 0.5% + + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] + d1; // token0 too large (below-price orientation) + + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + Bprime[1] = deviatedBalances[1] - d05; // makes token0 relatively larger ⇒ worsens but still inside + + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[1] = deviatedBalances[1] - Bprime[1]; + amountsRaw[1] = amountsScaled18[1]; + + vm.startPrank(address(vault)); + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + vm.stopPrank(); + + assertTrue(ok, "inside but worsening (alt orientation) must allow"); + } + + /// CASE 5: Starts outside ABOVE-price, ends outside BELOW-price ⇒ must BLOCK. + /// Old: token0 5% BELOW proportional (above-price). + /// Remove: 10% from token1 ⇒ cross to the other side with |dev| ≈ 5.6% (>2%). + function testFuzz_onAfterRemoveLiquidity_case5_outside_above_to_outside_below_blocks_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory base = _balancesProportionalToWeights(n); + + uint256 d5 = base[0] / 20; + if (d5 == 0) { + d5 = 1; + } // 5% + uint256 d10 = base[1] / 10; + if (d10 == 0) { + d10 = 1; + } // 10% + + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] - d5; // above-price + + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + Bprime[1] = deviatedBalances[1] - d10; // strong remove from token1 ⇒ flip and still outside + + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[1] = deviatedBalances[1] - Bprime[1]; + amountsRaw[1] = amountsScaled18[1]; + + vm.startPrank(address(vault)); + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + vm.stopPrank(); + + assertFalse(ok, "outside above -> outside below (worsened) must block"); + } + + /// CASE 6: Starts outside BELOW-price, ends outside ABOVE-price ⇒ must BLOCK. + /// Old: token0 5% ABOVE proportional (below-price). + /// Remove: amount so token0 ends ~0.95 * base (≈5% above-price) ⇒ still outside and worsened. + function testFuzz_onAfterRemoveLiquidity_case6_outside_below_to_same_above_allows_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory base = _balancesProportionalToWeights(n); + + uint256 d5 = base[0] / 20; + if (d5 == 0) { + d5 = 1; + } // 5% + + // Old O: token0 5% high (below-price) + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] + d5; + + // Target post: token0 ≈ 95% of base ⇒ remove d = O0 - 0.95*base0 = (1.05 - 0.95)*base0 = 0.10*base0 + uint256 dTarget = base[0] / 10; + if (dTarget == 0) dTarget = 1; + + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + // Safe by construction: O0 = 1.05*base0 ≥ base0/10 + Bprime[0] = deviatedBalances[0] - dTarget; + + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[0] = deviatedBalances[0] - Bprime[0]; + amountsRaw[0] = amountsScaled18[0]; + + vm.startPrank(address(vault)); + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + vm.stopPrank(); + + assertTrue(ok, "must be greater than, equal is fine"); + } + + /// CASE 6 (worsened): Starts outside BELOW-price, ends outside ABOVE-price with *larger* deviation ⇒ must BLOCK. + /// Old: token0 slightly ABOVE proportional (≈2.5% → below-price). + /// Post: token0 well BELOW proportional (≈10% → above-price). + /// With _configThresholds() (e.g., ≈2%), both states are outside, and afterDev > beforeDev ⇒ hook blocks. + function testFuzz_onAfterRemoveLiquidity_case6_outside_below_to_outside_above_blocks_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + // Pool & oracle config (HL sets 1:1 ext px so deviations are driven by balances) + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); // ensure a small threshold (≈2%) so both sides are outside + + // Balanced baseline (proportional to weights) + uint256[] memory base = _balancesProportionalToWeights(n); + + // Choose before/after magnitudes: before ≈ 2.5%, after ≈ 10% (both > threshold, and after > before). + uint256 dBefore = base[0] / 40; // 2.5% + if (dBefore == 0) { + dBefore = 1; + } + uint256 dAfter = base[0] / 10; // 10% + if (dAfter == 0) { + dAfter = 1; + } + + // Old O: token0 2.5% HIGH (below-price side) + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] + dBefore; + + // Post B': token0 10% LOW (above-price side); other tokens remain at base + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + Bprime[0] = base[0] - dAfter; + + // SINGLE_TOKEN_EXACT_IN remove: amounts = O - B' (only index 0 non-zero) + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[0] = deviatedBalances[0] - Bprime[0]; // dBefore + dAfter + amountsRaw[0] = amountsScaled18[0]; + + // Call must be from vault + vm.startPrank(address(vault)); + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + vm.stopPrank(); + + // Crossed sides and deviation magnitude increased -> afterDev > beforeDev and afterDev > threshold -> block + assertFalse(ok, "outside below -> outside above (worsened) must block"); + } + + /// CASE 7: Starts outside BELOW-price, ends inside ABOVE-price ⇒ must ALLOW (improves into threshold). + /// Old: token0 5% ABOVE proportional (below-price). + /// Remove: amount so token0 ends ~0.99 * base (≈1% above-price) ⇒ inside threshold and improved. + function testFuzz_onAfterRemoveLiquidity_case7_outside_below_to_inside_above_allows_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory base = _balancesProportionalToWeights(n); + + uint256 d5 = base[0] / 20; + if (d5 == 0) { + d5 = 1; + } // 5% + uint256 d06 = base[0] / 16; + if (d06 == 0) { + d06 = 1; + } // ~6.25% (≈ from 1.05 -> ~0.9875), close enough; still < 2% if tuned + + // Old: 5% high + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] + d5; + + // Post: remove ~6% of base0 from token0 so it crosses to slightly low (~<=1–1.5%), inside threshold. + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + if (d06 >= deviatedBalances[0]) { + d06 = deviatedBalances[0] - 1; + } // safety + Bprime[0] = deviatedBalances[0] - d06; + + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[0] = deviatedBalances[0] - Bprime[0]; + amountsRaw[0] = amountsScaled18[0]; + + vm.startPrank(address(vault)); + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + vm.stopPrank(); + + assertTrue(ok, "outside below -> inside above must allow"); + } + + /// CASE 8: Starts outside ABOVE-price, ends inside BELOW-price ⇒ must ALLOW (improves into threshold). + /// Old: token0 5% BELOW proportional (above-price). + /// Remove: ~6% from token1 ⇒ cross to slight below-price but |dev|<2%. + function testFuzz_onAfterRemoveLiquidity_case8_outside_above_to_inside_below_allows_n( + uint8 nSeed, + uint32 pairSeed, + uint8 szSeed + ) public { + uint8 n = _registerBasePoolWithPoolN(uint8(bound(nSeed, 2, 8))); + _configHLForAll(n, pairSeed, szSeed); + _configThresholds(); + + uint256[] memory base = _balancesProportionalToWeights(n); + + uint256 d5 = base[0] / 20; + if (d5 == 0) { + d5 = 1; + } // 5% + uint256 d06 = base[1] / 16; + if (d06 == 0) { + d06 = 1; + } // ~6.25% + + // Old: token0 5% low + uint256[] memory deviatedBalances = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + deviatedBalances[k] = base[k]; + } + deviatedBalances[0] = base[0] - d5; + + // Post: remove ~6% from token1 so the relative goes slightly to the other side but inside threshold + uint256[] memory Bprime = new uint256[](n); + for (uint256 k = 0; k < n; ++k) { + Bprime[k] = deviatedBalances[k]; + } + if (d06 >= deviatedBalances[1]) { + d06 = deviatedBalances[1] - 1; + } // safety + Bprime[1] = deviatedBalances[1] - d06; + + uint256[] memory amountsScaled18 = new uint256[](n); + uint256[] memory amountsRaw = new uint256[](n); + amountsScaled18[1] = deviatedBalances[1] - Bprime[1]; + amountsRaw[1] = amountsScaled18[1]; + + vm.startPrank(address(vault)); + (bool ok, ) = hook.onAfterRemoveLiquidity( + address(this), + address(pool), + RemoveLiquidityKind.SINGLE_TOKEN_EXACT_IN, + 0, + amountsScaled18, + amountsRaw, + Bprime, + "" + ); + vm.stopPrank(); + + assertTrue(ok, "outside above -> inside below must allow"); + } + + struct DefenciveZeroCheck { + uint256 bIn; + uint256 bOut; + uint256 pxIn; + uint256 pxOut; + uint256 pxBase; + uint256 amountGiven; + uint256 calcAmount; + bool ok; + uint256 fee; + uint256 staticFee; + } + + function testFuzz_ComputeSurgeFee_defensive_denominator_zero_allows( + bool exactIn, + uint256 bInRaw, + uint256 bOutRaw, + uint256 amtGivenRaw, + uint256 calcAmtRaw + ) public view { + DefenciveZeroCheck memory check; + check.bIn = bound(bInRaw, 1e18, 1e22); + check.bOut = bound(bOutRaw, 1e18, 1e22); + check.pxIn = 1e18; + check.pxOut = 1e18; + check.amountGiven = bound(amtGivenRaw, 1, check.bIn / 1_000_000); // ≤ 1e-6 of bIn + check.calcAmount = bound(calcAmtRaw, 1, check.bOut / 1_000_000); // ≤ 1e-6 of bOut + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L; + L.bIn = check.bIn; + L.bOut = check.bOut; + L.wIn = 1e18; + L.wOut = 0; // <<< makes den = bIn.mulDown(wOut) == 0 → poolPx == 0 + L.pxIn = check.pxIn; + L.pxOut = check.pxOut; + L.calcAmountScaled18 = check.calcAmount; + L.poolDetails.noiseThresholdPercentage9 = 10_000_000; // 1% + L.poolDetails.noiseCapDeviationPercentage9 = 50_000_000; // 5% + L.poolDetails.noiseMaxSurgeFee9 = 100_000_000; // 10% + L.poolDetails.arbThresholdPercentage9 = 10_000_000; // 1% + L.poolDetails.arbCapDeviationPercentage9 = 50_000_000; // 5% + L.poolDetails.arbMaxSurgeFee9 = 200_000_000; // 20% + L.poolDetails.numTokens = 2; + + uint256[] memory balances = new uint256[](2); + balances[0] = check.bIn; + balances[1] = check.bOut; + + PoolSwapParams memory p = PoolSwapParams({ + kind: exactIn ? SwapKind.EXACT_IN : SwapKind.EXACT_OUT, + amountGivenScaled18: check.amountGiven, + balancesScaled18: balances, + indexIn: 0, + indexOut: 1, + router: address(0), + userData: "" + }); + + check.staticFee = 1e16; // 1% + (check.ok, check.fee) = hook.ComputeSurgeFee(L, p, check.staticFee); + + assertTrue(check.ok, "compute fee must not block when pool spot denominator is zero"); + assertLe(check.fee, 1e18, "fee must be a valid 18-dec percentage"); + assertGe(check.fee, check.staticFee, "fee must be at least the static fee"); + } +} diff --git a/pkg/pool-hooks/test/foundry/HyperSurgeMaxDeviation.t.sol b/pkg/pool-hooks/test/foundry/HyperSurgeMaxDeviation.t.sol new file mode 100644 index 00000000..f938c66d --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeMaxDeviation.t.sol @@ -0,0 +1,738 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; +import { PoolSwapParams, SwapKind } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; + +/// @notice Drop-in replacement for the "find max deviation" fuzz tests. +/// This suite focuses on the surge-fee ramp behavior by fuzzing the +/// number of tokens and weights, while *overriding two prices* to +/// land (1) below threshold, (2) above cap, and (3) between. +/// It mirrors the helper-style used in the original tests and uses +/// the hook's ComputeSurgeFee pure entrypoint. +contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { + uint256 constant ONE = 1e18; + uint256 constant DEFAULT_MAX_SURGE_FEE_PPM9 = 0.05e9; // 5% + uint256 constant DEFAULT_THRESHOLD_PPM9 = 0.1e9; // 0.1% + uint256 constant DEFAULT_CAP_DEV_PPM9 = 0.5e9; // 50% + uint256 constant STATIC_SWAP_FEE = 1e16; // 1% (1e18 scale) + uint256 constant WEIGHT_MIN = 1e16; // 1% + + HyperSurgeHookMock internal hook; + + function setUp() public override { + super.setUp(); // vault + + // Vault is unused by the pure helper; supply a placeholder. + hook = new HyperSurgeHookMock( + IVault(vault), + DEFAULT_MAX_SURGE_FEE_PPM9 * 1e9, + DEFAULT_THRESHOLD_PPM9 * 1e9, + DEFAULT_CAP_DEV_PPM9 * 1e9, + "test" + ); + } + + // Simple normalized weights with a 1% floor, deterministic from a seed. + function _normWeights(uint8 n, uint256 seed) internal pure returns (uint256[] memory w) { + require(uint256(n) * WEIGHT_MIN <= ONE, "min too big"); + w = new uint256[](n); + + uint256[] memory r = new uint256[](n); + uint256 sumR; + unchecked { + for (uint8 i = 0; i < n; ++i) { + r[i] = 1 + (uint256(keccak256(abi.encode(seed, i))) % 1e9); + sumR += r[i]; + } + } + + uint256 base = uint256(n) * WEIGHT_MIN; + uint256 rem = ONE - base; + uint256 acc; + for (uint8 i = 0; i < n; ++i) { + uint256 share = (r[i] * rem) / sumR; + w[i] = WEIGHT_MIN + share; + acc += w[i]; + } + if (acc != ONE) { + if (acc < ONE) w[0] += (ONE - acc); + else { + uint256 over = acc - ONE; + w[0] = w[0] > over + WEIGHT_MIN ? (w[0] - over) : WEIGHT_MIN; + } + } + } + + // Pick balances in a safe magnitude to avoid underflow/overflow/zero-denominator. + function _balances(uint8 n, uint256 seed) internal pure returns (uint256[] memory b) { + b = new uint256[](n); + for (uint8 i = 0; i < n; ++i) { + // 1e12 .. 1e24 + uint256 x = 1e12 + (uint256(keccak256(abi.encode(seed, i))) % (1e24 - 1e12)); + b[i] = x; + } + } // Build a locals struct with two overridden prices targeting a desired deviation `D` (1e18 scale). + + // We set pxIn = 1e18 and pxOut so that extPx = pxOut/pxIn = P / (1 + D), using the same divDown rounding. + function _localsForDeviation( + uint256 P, // pair spot (1e18) + uint256 D // target deviation (1e18) + ) internal pure returns (uint256 pxIn, uint256 pxOut) { + pxIn = ONE; + // extPx = P / (1 + D) (use hook-style rounding) + pxOut = _divDown(P, ONE + D); + } + + // Instantiate ComputeSurgeFeeLocals with common pool details (NOISE lane), + // with b/w and px values provided by the caller. + function _makeLocals( + uint256 bIn, + uint256 wIn, + uint256 bOut, + uint256 wOut, + uint256 pxIn, + uint256 pxOut + ) internal pure returns (HyperSurgeHookMock.ComputeSurgeFeeLocals memory L) { + L.bIn = bIn; + L.wIn = wIn; + L.bOut = bOut; + L.wOut = wOut; + L.pxIn = pxIn; + L.pxOut = pxOut; + + // Configure NOISE lane (used when deviation does not worsen). + L.poolDetails.noiseThresholdPercentage9 = uint32(DEFAULT_THRESHOLD_PPM9); + L.poolDetails.noiseMaxSurgeFee9 = uint32(DEFAULT_MAX_SURGE_FEE_PPM9); + L.poolDetails.noiseCapDeviationPercentage9 = uint32(DEFAULT_CAP_DEV_PPM9); + + // Set ARB lane too (not used here, but keep consistent). + L.poolDetails.arbThresholdPercentage9 = uint32(DEFAULT_THRESHOLD_PPM9); + L.poolDetails.arbMaxSurgeFee9 = uint32(DEFAULT_MAX_SURGE_FEE_PPM9); + L.poolDetails.arbCapDeviationPercentage9 = uint32(DEFAULT_CAP_DEV_PPM9); + } + + // 1e18 fixed-point helpers identical to Balancer's FixedPoint + function _mulDown(uint256 a, uint256 b) internal pure returns (uint256) { + return (a * b) / 1e18; + } + + function _divDown(uint256 a, uint256 b) internal pure returns (uint256) { + return (a * 1e18) / b; + } + + function _relAbsDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? _divDown(a - b, b) : _divDown(b - a, b); + } + + // Replace any existing pair-spot helper with this: + function _pairSpotFromBalancesWeights( + uint256 bIn, + uint256 wIn, + uint256 bOut, + uint256 wOut + ) internal pure returns (uint256) { + uint256 num = _mulDown(bOut, wIn); + uint256 den = _mulDown(bIn, wOut); + if (den == 0) return 0; + return _divDown(num, den); + } + + function _expectedFeeFromLocals(uint256 poolPx, uint256 pxIn, uint256 pxOut) internal pure returns (uint256) { + uint256 extPx = _divDown(pxOut, pxIn); // identical to hook’s locals.extPx + uint256 deviation = _relAbsDiff(poolPx, extPx); + + uint256 threshold = DEFAULT_THRESHOLD_PPM9 * 1e9; + uint256 capDev = DEFAULT_CAP_DEV_PPM9 * 1e9; + uint256 maxPct = DEFAULT_MAX_SURGE_FEE_PPM9 * 1e9; + + if (deviation <= threshold) return STATIC_SWAP_FEE; + + uint256 span = capDev - threshold; + uint256 norm = _divDown(deviation - threshold, span); + if (norm > ONE) norm = ONE; + + uint256 incr = _mulDown(maxPct - STATIC_SWAP_FEE, norm); + uint256 fee = STATIC_SWAP_FEE + incr; + if (fee > maxPct) fee = maxPct; + return fee; + } + + + /// 1) Below threshold ⇒ the dynamic fee must equal the static (minimum) fee. + function testFuzz_feeBelowThreshold_min(uint8 nSeed, uint256 wSeed, uint256 bSeed, uint256 dSeed) public view { + uint8 n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(n, wSeed); + uint256[] memory b = _balances(n, bSeed); + + // Pick a pair i!=j. + uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, n - 1)); + uint8 j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, n - 1)); + if (j == i) j = (i + 1) % n; + + uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); + + vm.assume(P > 0); + + uint256 threshold = DEFAULT_THRESHOLD_PPM9; + // target deviation in [0 .. threshold] (inclusive lower range) + uint256 D = uint256(keccak256(abi.encode(dSeed))) % (threshold + 1); + + (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P, D); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + + PoolSwapParams memory p; // zero-initialized; p.kind defaults to 0 (= EXACT_IN) + p.kind = SwapKind.EXACT_IN; // keep before==after so we take the NOISE lane + + (bool ok, uint256 fee) = hook.ComputeSurgeFee(L, p, STATIC_SWAP_FEE); + assertTrue(ok, "compute must succeed"); + assertEq(fee, STATIC_SWAP_FEE, "below threshold must return static fee"); + } + + /// 2) Above cap deviation ⇒ the dynamic fee must equal the configured maximum. + function testFuzz_feeAboveCap_max(uint8 nSeed, uint256 wSeed, uint256 bSeed, uint256 dSeed) public view { + uint8 n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(n, wSeed); + uint256[] memory b = _balances(n, bSeed); + + uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 3))), 0, n - 1)); + uint8 j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 4))), 0, n - 1)); + if (j == i) j = (i + 1) % n; + + uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); + vm.assume(P > 0); + + uint256 capDev = DEFAULT_CAP_DEV_PPM9 * 1e9; + + // Choose a deviation D >= capDev (push comfortably above to avoid rounding back below). + uint256 extra = (ONE - capDev) / 4; // up to +25% beyond cap (bounded to keep pxOut > 0) + uint256 D = capDev + (uint256(keccak256(abi.encode(dSeed, 5))) % (extra + 1)); + + (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P, D); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (bool ok, uint256 fee) = hook.ComputeSurgeFee(L, p, STATIC_SWAP_FEE); + assertTrue(ok, "compute must succeed"); + + uint256 maxPct = DEFAULT_MAX_SURGE_FEE_PPM9 * 1e9; + assertEq(fee, maxPct, "above cap must return max fee"); + } + + /// 3) Between threshold and cap ⇒ the dynamic fee must be a linear ramp between static and max. + function testFuzz_feeBetween_linear(uint8 nSeed, uint256 wSeed, uint256 bSeed, uint256 dSeed) public view { + uint8 n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(n, wSeed); + uint256[] memory b = _balances(n, bSeed); + + uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 6))), 0, n - 1)); + uint8 j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 7))), 0, n - 1)); + if (j == i) j = (i + 1) % n; + + uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); + vm.assume(P > 0); + + uint256 threshold = DEFAULT_THRESHOLD_PPM9; + uint256 capDev = DEFAULT_CAP_DEV_PPM9; + uint256 span = capDev - threshold; + + // Target a deviation strictly inside (threshold, capDev): + uint256 D = threshold + 1 + (uint256(keccak256(abi.encode(dSeed, 8))) % (span - 1)); + + (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P, D); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (bool ok, uint256 fee) = hook.ComputeSurgeFee(L, p, STATIC_SWAP_FEE); + assertTrue(ok, "compute must succeed"); + + // Compute expected with identical rounding. + uint256 expected = _expectedFeeFromLocals(P, pxIn, pxOut); + assertEq(fee, expected, "fee must follow linear ramp between min and max"); + } + + function _ppm9To1e18(uint32 v) internal pure returns (uint256) { + // 1 ppm9 unit = 1e-9 in 1e18 fixed => multiply by 1e9 + return uint256(v) * 1e9; + } + + // Expected fee with custom lane parameters (all in ppm9 for the lane fields). + function _expectedFeeWithParams( + uint256 poolPx, + uint256 pxIn, + uint256 pxOut, + uint256 staticSwapFee, + uint32 thresholdPPM9, + uint32 capDevPPM9, + uint32 maxFeePPM9 + ) internal pure returns (uint256) { + uint256 extPx = _divDown(pxOut, pxIn); + uint256 deviation = _relAbsDiff(poolPx, extPx); + + uint256 threshold = _ppm9To1e18(thresholdPPM9); + uint256 capDev = _ppm9To1e18(capDevPPM9); + uint256 maxPct = _ppm9To1e18(maxFeePPM9); + + if (deviation <= threshold) return staticSwapFee; + + uint256 span = capDev - threshold; + uint256 norm = _divDown(deviation - threshold, span); + if (norm > ONE) norm = ONE; + + uint256 incr = _mulDown(maxPct - staticSwapFee, norm); + uint256 fee = staticSwapFee + incr; + if (fee > maxPct) fee = maxPct; + return fee; + } + + struct MonotonicInDeviationLocals { + uint8 n; + uint8 i; + uint8 j; + uint256 deviation; + uint256 capDev1e18; + uint256 price; + uint256 expected; + uint256 pxIn; + uint256 pxOut; + bool ok; + uint256 fee; + } + + /// Monotonicity: if the measured deviation increases, the fee must not decrease. + function testFuzz_feeMonotonicInDeviation( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed1, + uint256 dSeed2 + ) public view { + MonotonicInDeviationLocals memory locals; + locals.n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(locals.n, wSeed); + uint256[] memory b = _balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed1, 1))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed1, 2))), 0, locals.n - 2))) % locals.n; + + locals.price = _pairSpotFromBalancesWeights(b[locals.i], w[locals.i], b[locals.j], w[locals.j]); + vm.assume(locals.price > 0); + + locals.capDev1e18 = DEFAULT_CAP_DEV_PPM9; + // Pick two target deviations in [0, capDev*3/2] + uint256 D1 = uint256(keccak256(abi.encode(dSeed1))) % (locals.capDev1e18 + locals.capDev1e18 / 2 + 1); + uint256 D2raw = uint256(keccak256(abi.encode(dSeed2))) % (locals.capDev1e18 + locals.capDev1e18 / 2 + 1); + (locals.deviation, locals.expected) = D1 <= D2raw ? (D1, D2raw) : (D2raw, D1); + + (locals.pxIn, locals.pxOut) = _localsForDeviation(locals.price, locals.deviation); + (uint256 pxIn2, uint256 pxOut2) = _localsForDeviation(locals.price, locals.expected); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L1 = _makeLocals( + b[locals.i], + w[locals.i], + b[locals.j], + w[locals.j], + locals.pxIn, + locals.pxOut + ); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L2 = _makeLocals( + b[locals.i], + w[locals.i], + b[locals.j], + w[locals.j], + pxIn2, + pxOut2 + ); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee(L1, p, STATIC_SWAP_FEE); + (, uint256 fee2) = hook.ComputeSurgeFee(L2, p, STATIC_SWAP_FEE); + + assertLe(locals.fee, fee2, "fee must be non-decreasing with deviation"); + } + + function testFuzz_swapSymmetry_sameLaneParams( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed + ) public view { + uint8 n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(n, wSeed); + uint256[] memory b = _balances(n, bSeed); + + uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, n - 1)); + uint8 j = (i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, n - 2))) % n; + + // Pool spot for (i -> j) using the same rounding/staging as the hook + uint256 P_ij = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); + vm.assume(P_ij > 0); + + // Pick some deviation (bounded safely below 1 to keep pxOut > 0 in _localsForDeviation) + uint256 capDev = DEFAULT_CAP_DEV_PPM9; + uint256 D = uint256(keccak256(abi.encode(dSeed))) % (capDev + capDev / 2 + 1); + + (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P_ij, D); + + // Orientation A (i -> j) + HyperSurgeHookMock.ComputeSurgeFeeLocals memory LA = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + // Orientation B (j -> i) with inverted external prices + HyperSurgeHookMock.ComputeSurgeFeeLocals memory LB = _makeLocals(b[j], w[j], b[i], w[i], pxOut, pxIn); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (bool okA, uint256 feeA) = hook.ComputeSurgeFee(LA, p, STATIC_SWAP_FEE); + (bool okB, uint256 feeB) = hook.ComputeSurgeFee(LB, p, STATIC_SWAP_FEE); + assertTrue(okA && okB, "compute must succeed"); + + // Measure deviations exactly like the hook does in each orientation + uint256 extA = _divDown(LA.pxOut, LA.pxIn); + uint256 devA = _relAbsDiff(P_ij, extA); + + // Compute the swapped pool spot with the SAME rounding (don’t assume 1/P) + uint256 P_ji = _pairSpotFromBalancesWeights(LB.bIn, LB.wIn, LB.bOut, LB.wOut); + uint256 extB = _divDown(LB.pxOut, LB.pxIn); + uint256 devB = _relAbsDiff(P_ji, extB); + + // Correct directional assertion: + if (devA > devB) { + // allow 1 wei to avoid knife-edge floor rounding flips + assertGe(feeA + 1, feeB, "larger deviation must not yield smaller fee (A vs B)"); + } else if (devB > devA) { + assertGe(feeB + 1, feeA, "larger deviation must not yield smaller fee (B vs A)"); + } else { + assertApproxEqAbs(feeA, feeB, 1, "equal deviations should give equal fees (1 wei)"); + } + } + + struct FeeRespectedLocals { + uint8 n; + uint8 i; + uint8 j; + uint256 deviation; + uint256 capDev1e18; + uint256 price; + uint256 expected; + uint256 pxIn; + uint256 pxOut; + bool ok; + uint256 fee; + } + + /// Static fee fuzz: for arbitrary static fees (<= max), the hook's result must match the expected ramp. + function testFuzz_staticFeeRespected( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed, + uint64 staticFeeSeed + ) public view { + FeeRespectedLocals memory locals; + locals.n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(locals.n, wSeed); + uint256[] memory b = _balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, locals.n - 2))) % locals.n; + + locals.price = _pairSpotFromBalancesWeights(b[locals.i], w[locals.i], b[locals.j], w[locals.j]); + vm.assume(locals.price > 0); + + locals.capDev1e18 = DEFAULT_CAP_DEV_PPM9; + locals.deviation = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev1e18 + locals.capDev1e18 / 2 + 1); + + (locals.pxIn, locals.pxOut) = _localsForDeviation(locals.price, locals.deviation); + + // Choose static fee in [0 .. maxPct] + uint256 maxPct = DEFAULT_MAX_SURGE_FEE_PPM9; + uint256 staticFee = uint256(staticFeeSeed) % (maxPct + 1); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals( + b[locals.i], + w[locals.i], + b[locals.j], + w[locals.j], + locals.pxIn, + locals.pxOut + ); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee(L, p, staticFee); + assertTrue(locals.ok, "compute must succeed"); + + locals.expected = _expectedFeeWithParams( + _pairSpotFromBalancesWeights(b[locals.i], w[locals.i], b[locals.j], w[locals.j]), + locals.pxIn, + locals.pxOut, + staticFee, + uint32(DEFAULT_THRESHOLD_PPM9), + uint32(DEFAULT_CAP_DEV_PPM9), + uint32(DEFAULT_MAX_SURGE_FEE_PPM9) + ); + assertEq(locals.fee, locals.expected, "fee must respect custom static fee & ramp"); + } + + struct LaneParametersLocals { + uint8 n; + uint8 i; + uint8 j; + uint256 deviation; + uint256 capDev1e18; + uint256 price; + uint256 expected; + uint256 pxIn; + uint256 pxOut; + bool ok; + uint256 fee; + } + + /// Replacement for the old "swap symmetry" test. + /// Correct property: whichever orientation produces the larger measured deviation + /// must not have a smaller fee (monotonic ramp). + function testFuzz_directionalOrdering_sameLaneParams( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed + ) public view { + LaneParametersLocals memory locals; + locals.n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(locals.n, wSeed); + uint256[] memory b = _balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, locals.n - 2))) % locals.n; + + locals.price = _pairSpotFromBalancesWeights(b[locals.i], w[locals.i], b[locals.j], w[locals.j]); + vm.assume(locals.price > 0); + + locals.capDev1e18 = DEFAULT_CAP_DEV_PPM9; + locals.deviation = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev1e18 + locals.capDev1e18 / 2 + 1); + + (locals.pxIn, locals.pxOut) = _localsForDeviation(locals.price, locals.deviation); + + // Orientation A (i -> j) + HyperSurgeHookMock.ComputeSurgeFeeLocals memory LA = _makeLocals( + b[locals.i], + w[locals.i], + b[locals.j], + w[locals.j], + locals.pxIn, + locals.pxOut + ); + // Orientation B (j -> i) with inverted external prices + HyperSurgeHookMock.ComputeSurgeFeeLocals memory LB = _makeLocals( + b[locals.j], + w[locals.j], + b[locals.i], + w[locals.i], + locals.pxOut, + locals.pxIn + ); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee(LA, p, STATIC_SWAP_FEE); + (bool okB, uint256 feeB) = hook.ComputeSurgeFee(LB, p, STATIC_SWAP_FEE); + assertTrue(locals.ok && okB, "compute must succeed"); + + // Measure deviations exactly like the hook does + uint256 extA = _divDown(LA.pxOut, LA.pxIn); + uint256 extB = _divDown(LB.pxOut, LB.pxIn); + uint256 devA = _relAbsDiff(locals.price, extA); + uint256 devB = _relAbsDiff(_pairSpotFromBalancesWeights(LB.bIn, LB.wIn, LB.bOut, LB.wOut), extB); // equals 1/P vs 1/ext due to swap + + // Directional ordering with ±1 wei tolerance for knife-edge rounding + if (devA > devB) { + assertGe(locals.fee + 1, feeB, "larger deviation must not yield smaller fee (A vs B)"); + } else if (devB > devA) { + assertGe(feeB + 1, locals.fee, "larger deviation must not yield smaller fee (B vs A)"); + } else { + assertApproxEqAbs(locals.fee, feeB, 1, "equal deviations should give equal fees (around1 wei)"); + } + } + + struct ThresholdAndCap { + uint8 n; + uint8 i; + uint8 j; + uint256 P; + uint256 threshold; + uint256 capDev; + int8[5] offs; + uint256 Dt; + uint256 pxInT; + uint256 pxOutT; + uint256 extT; + uint256 expectedT; + uint256 Dc; + uint256 pxInC; + uint256 pxOutC; + uint256 expectedC; + } + + /// Boundary behavior: probe exactly at threshold/cap and within ±2 wei to ensure + /// step/continuity matches the ramp and clamping, with hook-style rounding. + function testFuzz_boundaryBehavior_thresholdAndCap( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed + ) public view { + ThresholdAndCap memory locals; + locals.n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(locals.n, wSeed); + uint256[] memory b = _balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, locals.n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, locals.n - 2))) % locals.n; + + locals.P = _pairSpotFromBalancesWeights(b[locals.i], w[locals.i], b[locals.j], w[locals.j]); + vm.assume(locals.P > 0); + + locals.threshold = DEFAULT_THRESHOLD_PPM9; + locals.capDev = DEFAULT_CAP_DEV_PPM9; + + locals.offs = [-2, -1, 0, 1, 2]; + + for (uint256 k = 0; k < locals.offs.length; ++k) { + // --- Around THRESHOLD --- + if (locals.offs[k] < 0) { + uint256 delta = uint256(uint8(-locals.offs[k])); + locals.Dt = locals.threshold > delta ? locals.threshold - delta : 0; + } else { + locals.Dt = locals.threshold + uint256(uint8(locals.offs[k])); + } + (locals.pxInT, locals.pxOutT) = _localsForDeviation(locals.P, locals.Dt); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory LT = _makeLocals( + b[locals.i], + w[locals.i], + b[locals.j], + w[locals.j], + locals.pxInT, + locals.pxOutT + ); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (bool okT, uint256 feeT) = hook.ComputeSurgeFee(LT, p, STATIC_SWAP_FEE); + assertTrue(okT, "compute must succeed (threshold ring)"); + + locals.extT = _divDown(locals.pxOutT, locals.pxInT); + locals.expectedT = _expectedFeeFromLocals(locals.P, locals.pxInT, locals.pxOutT); + // Exact match to the hook’s rounding-based expected value + assertEq(feeT, locals.expectedT, "threshold ring fee mismatch"); + + // --- Around CAP --- + if (locals.offs[k] < 0) { + uint256 deltaC = uint256(uint8(-locals.offs[k])); + locals.Dc = locals.capDev > deltaC ? locals.capDev - deltaC : 0; + } else { + // guard upper bound to avoid overflow in _localsForDeviation denominator + uint256 room = ONE > locals.capDev ? (ONE - locals.capDev) : 0; + uint256 add = uint256(uint8(locals.offs[k])); + locals.Dc = locals.capDev + (add <= room ? add : room); + } + (locals.pxInC, locals.pxOutC) = _localsForDeviation(locals.P, locals.Dc); + HyperSurgeHookMock.ComputeSurgeFeeLocals memory LC = _makeLocals( + b[locals.i], + w[locals.i], + b[locals.j], + w[locals.j], + locals.pxInC, + locals.pxOutC + ); + + (bool okC, uint256 feeC) = hook.ComputeSurgeFee(LC, p, STATIC_SWAP_FEE); + assertTrue(okC, "compute must succeed (cap ring)"); + + locals.expectedC = _expectedFeeFromLocals(locals.P, locals.pxInC, locals.pxOutC); + assertEq(feeC, locals.expectedC, "cap ring fee mismatch"); + } + } + + /// Balance scaling invariance (unchanged idea, included for completeness). + function testFuzz_balanceScalingInvariance( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed, + uint64 scaleSeed + ) public view { + uint8 n = uint8(bound(nSeed, 2, 8)); + uint256[] memory w = _normWeights(n, wSeed); + uint256[] memory b = _balances(n, bSeed); + + uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, n - 1)); + uint8 j = (i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, n - 2))) % n; + + uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); + vm.assume(P > 0); + + uint256 capDev = DEFAULT_CAP_DEV_PPM9; + uint256 D = uint256(keccak256(abi.encode(dSeed))) % (capDev + capDev / 3 + 1); + + (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P, D); + + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L1 = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + + uint256 k = 1 + (uint256(scaleSeed) % 1_000_000_000); // [1 .. 1e9] + HyperSurgeHookMock.ComputeSurgeFeeLocals memory L2 = _makeLocals(b[i] * k, w[i], b[j] * k, w[j], pxIn, pxOut); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + + (, uint256 fee1) = hook.ComputeSurgeFee(L1, p, STATIC_SWAP_FEE); + (, uint256 fee2) = hook.ComputeSurgeFee(L2, p, STATIC_SWAP_FEE); + + assertApproxEqAbs(fee1, fee2, 1, "fee must be invariant to balance scaling"); + } + + struct ExactOutArbLaneBoundaryLocals { + uint8 n; + uint8 i; + uint8 j; + uint32 thrOK; + uint32 capOK; + uint32 maxOK; + uint256 thr; + uint256 cap; + uint256 maxFee; + uint256 span; + uint256 D; + uint256 P; + uint256 pxIn; + uint256 pxOut; + uint256 incMax; + uint256 numer; + uint256 norm; + uint256 inc; + uint256 want; + uint256 got; + uint256 wIn; + uint256 wOut; + uint256 bIn; + uint256 bOut; + uint256 rIn; + uint256 rOut; + uint256 feeA; + uint256 feeB; + uint256 denom; + uint256 extPx; + } +} diff --git a/pkg/pool-hooks/test/foundry/utils/HyperSurgeHookDeployer.sol b/pkg/pool-hooks/test/foundry/utils/HyperSurgeHookDeployer.sol new file mode 100644 index 00000000..98c75f96 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/utils/HyperSurgeHookDeployer.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { HyperSurgeHookMock } from "../../../contracts/test/HyperSurgeHookMock.sol"; + +/// @notice Deployer that instantiates the HyperSurgeHookMock. +/// @dev Mirrors your StableSurgeHookDeployer pattern so tests can share setup code. +abstract contract HyperSurgeHookDeployer { + function deployHook( + IVault vault, + uint256 defaultMaxSurgeFeePercentage, + uint256 defaultThresholdPercentage, + uint256 defaultCapDeviation, + string memory version + ) internal returns (HyperSurgeHookMock hook) { + hook = new HyperSurgeHookMock( + vault, + defaultMaxSurgeFeePercentage, + defaultThresholdPercentage, + defaultCapDeviation, + version + ); + } + + uint256[] internal weights; + + function setWeights(uint256[] memory newWeights) external { + weights = newWeights; + } +} diff --git a/pkg/pool-weighted/contracts/WeightedPool.sol b/pkg/pool-weighted/contracts/WeightedPool.sol index 2e37891b..f0f27919 100644 --- a/pkg/pool-weighted/contracts/WeightedPool.sol +++ b/pkg/pool-weighted/contracts/WeightedPool.sol @@ -141,7 +141,7 @@ contract WeightedPool is IWeightedPool, BalancerPoolToken, PoolInfo, Version { } /// @inheritdoc IBasePool - function onSwap(PoolSwapParams memory request) public view virtual onlyVault returns (uint256) { + function onSwap(PoolSwapParams memory request) public view virtual returns (uint256) { uint256 balanceTokenInScaled18 = request.balancesScaled18[request.indexIn]; uint256 balanceTokenOutScaled18 = request.balancesScaled18[request.indexOut]; diff --git a/pkg/standalone-utils/contracts/utils/HyperSpotPricePrecompile.sol b/pkg/standalone-utils/contracts/utils/HyperSpotPricePrecompile.sol new file mode 100644 index 00000000..09014f1d --- /dev/null +++ b/pkg/standalone-utils/contracts/utils/HyperSpotPricePrecompile.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +/** + * @notice Library to interact with the Hyperliquid spot price precompile. + * @dev The precompile is a special type of code, executed in the Hypercore's node. For more information, see + * https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/hyperevm/interacting-with-hypercore . + */ +library HyperSpotPricePrecompile { + address public constant SPOT_PRICE_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000808; + + /// @notice The precompile had an error while fetching the spot price. + error SpotPricePrecompileFailed(); + + /// @notice The spot price is zero. + error SpotPriceIsZero(); + + function spotPrice(uint32 pairIndex) internal view returns (uint256) { + (bool success, bytes memory spotPriceBytes) = SPOT_PRICE_PRECOMPILE_ADDRESS.staticcall(abi.encode(pairIndex)); + if (success == false) { + revert SpotPricePrecompileFailed(); + } + uint256 price = abi.decode(spotPriceBytes, (uint256)); + if (price == 0) { + revert SpotPriceIsZero(); + } + return price; + } +} \ No newline at end of file diff --git a/pkg/standalone-utils/contracts/utils/HyperTokenInfoPrecompile.sol b/pkg/standalone-utils/contracts/utils/HyperTokenInfoPrecompile.sol new file mode 100644 index 00000000..bf66ecfc --- /dev/null +++ b/pkg/standalone-utils/contracts/utils/HyperTokenInfoPrecompile.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +library HyperTokenInfoPrecompile { + struct HyperTokenInfo { + string name; + uint64[] spots; + uint64 deployerTradingFeeShare; + address deployer; + address evmContract; + uint8 szDecimals; + uint8 weiDecimals; + int8 evmExtraWeiDecimals; + } + + address public constant TOKEN_INFO_PRECOMPILE_ADDRESS = 0x000000000000000000000000000000000000080C; + error TokenInfoPrecompileFailed(); + + function szDecimals(uint32 tokenIndex) internal view returns (uint8) { + (bool success, bytes memory out) = TOKEN_INFO_PRECOMPILE_ADDRESS.staticcall(abi.encode(tokenIndex)); + if (success == false) { + revert TokenInfoPrecompileFailed(); + } + HyperTokenInfo memory tokenInfo = abi.decode(out, (HyperTokenInfo)); + return tokenInfo.szDecimals; + } +} diff --git a/pkg/standalone-utils/test/foundry/HyperEVMPrecompileMocks.t.sol b/pkg/standalone-utils/test/foundry/HyperEVMPrecompileMocks.t.sol new file mode 100644 index 00000000..c9e65971 --- /dev/null +++ b/pkg/standalone-utils/test/foundry/HyperEVMPrecompileMocks.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { HyperTokenInfoPrecompile } from "../../contracts/utils/HyperTokenInfoPrecompile.sol"; +import { HyperSpotPricePrecompile } from "../../contracts/utils/HyperSpotPricePrecompile.sol"; +import { HypercorePrecompileMock } from "./utils/HypercorePrecompileMock.sol"; + +contract HyperEVMPrecompileMocksTest is Test { + bytes internal constant ALPHABET = "0123456789abcdef"; + + function testTokenInfoPrecompile() public { + uint32 uethIndex = 221; + // `cast call` the precompile to get the onchain data. + bytes memory data = _ffiPrecompile(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, uethIndex); + // Store the szDecimals of the UETH token, as returned by the precompile. + uint256 originalSzDecimals = abi.decode(data, (HyperTokenInfoPrecompile.HyperTokenInfo)).szDecimals; + + // Mock the precompile. + vm.etch(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS, address(new HypercorePrecompileMock()).code); + // Set the onchain data to the mock. + HypercorePrecompileMock(HyperTokenInfoPrecompile.TOKEN_INFO_PRECOMPILE_ADDRESS).setData(data); + + // Check if the library, using the mocked precompile, returns the same szDecimals. + assertEq(HyperTokenInfoPrecompile.szDecimals(uethIndex), originalSzDecimals, "Wrong szDecimals"); + } + + function testSpotPricePrecompile() public { + uint32 uethUsdPairIndex = 151; + // `cast call` the precompile to get the onchain data. + bytes memory data = _ffiPrecompile(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, uethUsdPairIndex); + // Store the spot price of the UETH/USD pair, as returned by the precompile. + uint256 originalSpotPrice = abi.decode(data, (uint256)); + + // Mock the precompile. + vm.etch(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS, address(new HypercorePrecompileMock()).code); + // Set the onchain data to the mock. + HypercorePrecompileMock(HyperSpotPricePrecompile.SPOT_PRICE_PRECOMPILE_ADDRESS).setData(data); + + // Check if the library, using the mocked precompile, returns the same spot price. + assertEq(HyperSpotPricePrecompile.spotPrice(uethUsdPairIndex), originalSpotPrice, "Wrong spot price"); + } + + function _ffiPrecompile(address _precompile, uint32 index) internal returns (bytes memory) { + bytes memory indexBytes = abi.encode(index); + string[] memory inputs = new string[](6); + inputs[0] = "cast"; + inputs[1] = "call"; + inputs[2] = string(abi.encodePacked("0x", _addressToHexString(_precompile))); + inputs[3] = string(abi.encodePacked("0x", _bytesToHexString(indexBytes, 32))); + inputs[4] = "--rpc-url"; + inputs[5] = "https://rpc.hyperliquid.xyz/evm"; + + return vm.ffi(inputs); + } + + function _addressToHexString(address _address) internal pure returns (string memory) { + bytes20 _bytes = bytes20(_address); + return (_bytesToHexString(abi.encode(_bytes), 20)); + } + + function _bytesToHexString(bytes memory _bytes, uint256 length) internal pure returns (string memory) { + bytes memory answer = new bytes(2 * length); + + for (uint i = 0; i < length; i++) { + answer[i * 2] = ALPHABET[uint8(_bytes[i] >> 4)]; + answer[i * 2 + 1] = ALPHABET[uint8(_bytes[i] & 0x0f)]; + } + return string(answer); + } +} diff --git a/pkg/standalone-utils/test/foundry/utils/HypercorePrecompileMock.sol b/pkg/standalone-utils/test/foundry/utils/HypercorePrecompileMock.sol new file mode 100644 index 00000000..b9bb122d --- /dev/null +++ b/pkg/standalone-utils/test/foundry/utils/HypercorePrecompileMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.24; + +contract HypercorePrecompileMock { + bytes internal data; + + function setData(bytes memory _data) external { + data = _data; + } + + fallback(bytes calldata) external returns (bytes memory) { + return data; + } +}