diff --git a/.prettierrc.json b/.prettierrc.json index 452c7148..b474e5de 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -12,7 +12,7 @@ "options": { "singleQuote": false, "tabWidth": 4, - "compiler": "0.8.24" + "compiler": "0.8.26" } }, { 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..b26765c9 --- /dev/null +++ b/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol @@ -0,0 +1,725 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +import { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; +import { IHooks } from "@balancer-labs/v3-interfaces/contracts/vault/IHooks.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { SingletonAuthentication } from "@balancer-labs/v3-vault/contracts/SingletonAuthentication.sol"; +import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.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 { Version } from "@balancer-labs/v3-solidity-utils/contracts/helpers/Version.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 { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol"; +import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.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 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; + + modifier ensureValidPercentage(uint256 percentageValue) { + _ensureValidPercentage(percentageValue); + + _; + } + + constructor( + IVault vault, + uint256 defaultMaxSurgeFeePercentage18, + uint256 defaultThresholdPercentage18, + uint256 defaultCapDeviationPercentage18, + string memory version + ) SingletonAuthentication(vault) VaultGuard(vault) Version(version) { + _ensureValidPercentage(defaultMaxSurgeFeePercentage18); + _ensureValidPercentage(defaultThresholdPercentage18); + _ensureValidPercentage(defaultCapDeviationPercentage18); + _defaultMaxSurgeFeePercentage18 = defaultMaxSurgeFeePercentage18; + _defaultThresholdPercentage18 = defaultThresholdPercentage18; + _defaultCapDeviationPercentage18 = defaultCapDeviationPercentage18; + } + + /************************************************** + Hooks + **************************************************/ + + /// @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; + details.numTokens = uint8(tokenCfgs.length); + // Set the pool details, so we can use the setters and emit the proper events. + _poolCfg[pool].details = details; + + _setMaxSurgeFeePercentage(pool, _defaultMaxSurgeFeePercentage18, TradeType.ARBITRAGE); + _setMaxSurgeFeePercentage(pool, _defaultMaxSurgeFeePercentage18, TradeType.NOISE); + _setSurgeThresholdPercentage(pool, _defaultThresholdPercentage18, TradeType.ARBITRAGE); + _setSurgeThresholdPercentage(pool, _defaultThresholdPercentage18, TradeType.NOISE); + _setCapDeviationPercentage(pool, _defaultCapDeviationPercentage18, TradeType.ARBITRAGE); + _setCapDeviationPercentage(pool, _defaultCapDeviationPercentage18, TradeType.NOISE); + + return true; + } + + /// @inheritdoc IHooks + 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) { + // Allow proportional adds, but block non-proportional adds that worsen deviation and end above threshold. + if (kind == AddLiquidityKind.PROPORTIONAL) { + return (true, amountsInRaw); + } + + uint256[] memory oldBalancesScaled18 = new uint256[](balancesScaled18.length); + for (uint256 i = 0; i < balancesScaled18.length; ++i) { + oldBalancesScaled18[i] = balancesScaled18[i] - amountsInScaled18[i]; + } + + bool isPriceDeviationWorsening = _isPriceDeviationWorsening(pool, oldBalancesScaled18, balancesScaled18); + + return (isPriceDeviationWorsening == false, amountsInRaw); + } + + /// @inheritdoc IHooks + 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) { + // Allow proportional removes, but block non-proportional removes that worsen deviation and end above threshold. + if (kind == RemoveLiquidityKind.PROPORTIONAL) { + return (true, amountsOutRaw); + } + + // Reconstruct pre-remove balances = post + out; if addition overflows, allow. + uint256[] memory oldBalancesScaled18 = new uint256[](balancesScaled18.length); + for (uint256 i = 0; i < balancesScaled18.length; ++i) { + oldBalancesScaled18[i] = balancesScaled18[i] + amountsOutScaled18[i]; + } + + bool isPriceDeviationWorsening = _isPriceDeviationWorsening(pool, oldBalancesScaled18, balancesScaled18); + + return (isPriceDeviationWorsening == false, amountsOutRaw); + } + + /// @inheritdoc IHooks + function onComputeDynamicSwapFeePercentage( + PoolSwapParams calldata p, + address pool, + uint256 staticSwapFee + ) public view override returns (bool, uint256) { + PoolCfg memory pc = _poolCfg[pool]; + uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); + uint256 calculatedAmountScaled18 = WeightedPool(pool).onSwap(p); + uint256 oraclePrice = _computeExternalPrice(pc, p.indexIn, p.indexOut); + return _computeSurgeFee(p, pc.details, staticSwapFee, weights, calculatedAmountScaled18, oraclePrice); + } + + /************************************************** + Setters + **************************************************/ + + /** + * @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); + } + + /** + * @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 + * @param hlTokenIdx the index of the token being set + */ + function setTokenPriceConfigBatchIndex( + address pool, + uint8[] calldata tokenIndices, + uint32[] calldata pairIdx, + uint32[] calldata hlTokenIdx + ) external onlySwapFeeManagerOrGovernance(pool) { + InputHelpers.ensureInputLengthMatch(tokenIndices.length, pairIdx.length); + + PoolDetails storage detail = _poolCfg[pool].details; + + for (uint256 i = 0; i < tokenIndices.length; ++i) { + _setTokenPriceConfigIndex(pool, tokenIndices[i], pairIdx[i], hlTokenIdx[i], detail); + } + } + + 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); + } + + /// @inheritdoc IHyperSurgeHook + function setMaxSurgeFeePercentage( + address pool, + uint256 newMaxSurgeFeePercentageScaled18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) ensureValidPercentage(newMaxSurgeFeePercentageScaled18) { + _setMaxSurgeFeePercentage(pool, newMaxSurgeFeePercentageScaled18, tradeType); + } + + function _setMaxSurgeFeePercentage( + address pool, + uint256 newMaxSurgeFeePercentageScaled18, + TradeType tradeType + ) internal { + if (tradeType == TradeType.ARBITRAGE) { + _poolCfg[pool].details.arbMaxSurgeFee9 = _safeConvertTo9Decimals(newMaxSurgeFeePercentageScaled18); + } else { + _poolCfg[pool].details.noiseMaxSurgeFee9 = _safeConvertTo9Decimals(newMaxSurgeFeePercentageScaled18); + } + + emit MaxSurgeFeePercentageChanged(msg.sender, pool, newMaxSurgeFeePercentageScaled18, tradeType); + } + + /// @inheritdoc IHyperSurgeHook + function setSurgeThresholdPercentage( + address pool, + uint256 newThresholdPercentageScaled18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) ensureValidPercentage(newThresholdPercentageScaled18) { + _setSurgeThresholdPercentage(pool, newThresholdPercentageScaled18, tradeType); + } + + function _setSurgeThresholdPercentage( + address pool, + uint256 newThresholdPercentageScaled18, + TradeType tradeType + ) internal { + uint256 capDeviationPercentageScaled18; + PoolDetails memory poolDetails = _poolCfg[pool].details; + if (tradeType == TradeType.ARBITRAGE) { + poolDetails.arbThresholdPercentage9 = _safeConvertTo9Decimals(newThresholdPercentageScaled18); + capDeviationPercentageScaled18 = _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9); + } else { + poolDetails.noiseThresholdPercentage9 = _safeConvertTo9Decimals(newThresholdPercentageScaled18); + capDeviationPercentageScaled18 = _convertTo18Decimals(poolDetails.noiseCapDeviationPercentage9); + } + + // Keep a valid ramp span: threshold < capDev ≤ 1 + if (capDeviationPercentageScaled18 != 0 && newThresholdPercentageScaled18 >= capDeviationPercentageScaled18) { + revert InvalidThresholdDeviation(); + } + + _poolCfg[pool].details = poolDetails; + + emit ThresholdPercentageChanged(msg.sender, pool, newThresholdPercentageScaled18, tradeType); + } + + /// @inheritdoc IHyperSurgeHook + function setCapDeviationPercentage( + address pool, + uint256 newCapDeviationPercentageScaled18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) ensureValidPercentage(newCapDeviationPercentageScaled18) { + _setCapDeviationPercentage(pool, newCapDeviationPercentageScaled18, tradeType); + } + + function _setCapDeviationPercentage( + address pool, + uint256 newCapDeviationPercentageScaled18, + TradeType tradeType + ) internal { + uint256 thresholdPercentageScaled18; + PoolDetails memory poolDetails = _poolCfg[pool].details; + if (tradeType == TradeType.ARBITRAGE) { + poolDetails.arbCapDeviationPercentage9 = _safeConvertTo9Decimals(newCapDeviationPercentageScaled18); + thresholdPercentageScaled18 = _convertTo18Decimals(poolDetails.arbThresholdPercentage9); + } else { + poolDetails.noiseCapDeviationPercentage9 = _safeConvertTo9Decimals(newCapDeviationPercentageScaled18); + thresholdPercentageScaled18 = _convertTo18Decimals(poolDetails.noiseThresholdPercentage9); + } + + // Keep a valid ramp span: threshold < capDev ≤ 1 + if (newCapDeviationPercentageScaled18 <= thresholdPercentageScaled18) { + revert InvalidCapDeviationPercentage(); + } + + _poolCfg[pool].details = poolDetails; + + emit CapDeviationPercentageChanged(msg.sender, pool, newCapDeviationPercentageScaled18, tradeType); + } + + /************************************************** + Getters + **************************************************/ + + /// @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; + } + + function _computeSurgeFee( + PoolSwapParams calldata params, + PoolDetails memory poolDetails, + uint256 staticSwapFee, + uint256[] memory weights, + uint256 calculatedAmountScaled18, + uint256 oraclePrice + ) internal pure returns (bool ok, uint256 surgeFee) { + // Do not block if there is an issue with the hyperliquid price + if (oraclePrice == 0) { + return (true, staticSwapFee); + } + + ( + uint256 deviationScaled18, + uint256 capDeviationPercentageScaled18, + uint256 maxSurgeFeeScaled18, + uint256 thresholdScaled18 + ) = _computeDeviationAndSelectPoolDetails(params, weights, calculatedAmountScaled18, oraclePrice, poolDetails); + + if (deviationScaled18 <= thresholdScaled18) { + return (true, staticSwapFee); + } + + uint256 span = capDeviationPercentageScaled18 - thresholdScaled18; // > 0 by fallback above + uint256 norm = (deviationScaled18 - thresholdScaled18).divDown(span); + + if (norm > FixedPoint.ONE) { + norm = FixedPoint.ONE; + } + + uint256 increment = (maxSurgeFeeScaled18 - staticSwapFee).mulDown(norm); + uint256 surgeFeeScaled18 = staticSwapFee + increment; + + return (true, surgeFeeScaled18); + } + + function _computeDeviationAndSelectPoolDetails( + PoolSwapParams calldata params, + uint256[] memory weights, + uint256 calculatedAmountScaled18, + uint256 oraclePrice, + PoolDetails memory poolDetails + ) + internal + pure + returns ( + uint256 deviationScaled18, + uint256 capDeviationScaled18, + uint256 maxSurgeFeeScaled18, + uint256 thresholdScaled18 + ) + { + uint256 deviationBeforeScaled18; + { + uint256 poolPriceBefore = _pairSpotFromBalancesWeights( + params.balancesScaled18, + weights, + params.indexIn, + params.indexOut + ); + deviationBeforeScaled18 = _relAbsDiff(poolPriceBefore, oraclePrice); + } + + uint256[] memory newBalancesScaled18 = new uint256[](params.balancesScaled18.length); + for (uint256 i = 0; i < params.balancesScaled18.length; i++) { + newBalancesScaled18[i] = params.balancesScaled18[i]; + } + + if (params.kind == SwapKind.EXACT_IN) { + newBalancesScaled18[params.indexIn] += params.amountGivenScaled18; + newBalancesScaled18[params.indexOut] -= calculatedAmountScaled18; + } else { + newBalancesScaled18[params.indexIn] += calculatedAmountScaled18; + newBalancesScaled18[params.indexOut] -= params.amountGivenScaled18; + } + + // P_pool = (B_out/w_out) / (B_in/w_in) = (B_out * w_in) / (B_in * w_out) + { + uint256 poolPriceAfter = _pairSpotFromBalancesWeights( + newBalancesScaled18, + weights, + params.indexIn, + params.indexOut + ); + deviationScaled18 = _relAbsDiff(poolPriceAfter, oraclePrice); // |pool - ext| / ext + } + + // Check if the swap is a noise (deviation is worsening) or an arbitrage (deviation is improving). + if (deviationScaled18 > deviationBeforeScaled18) { + // Deviation is worsening, use noise details. + capDeviationScaled18 = _convertTo18Decimals(poolDetails.noiseCapDeviationPercentage9); + maxSurgeFeeScaled18 = _convertTo18Decimals(poolDetails.noiseMaxSurgeFee9); + thresholdScaled18 = _convertTo18Decimals(poolDetails.noiseThresholdPercentage9); + } else { + // Deviation is improving, use arb details (informed swap). + capDeviationScaled18 = _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9); + maxSurgeFeeScaled18 = _convertTo18Decimals(poolDetails.arbMaxSurgeFee9); + thresholdScaled18 = _convertTo18Decimals(poolDetails.arbThresholdPercentage9); + + // For the arbitrage direction we use the deviation before. 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. For more + // information, check the HyperSurgeHook-README.md file. + deviationScaled18 = deviationBeforeScaled18; + } + } + + function _computeExternalPrice( + PoolCfg memory pc, + uint256 indexTokenIn, + uint256 indexTokenOut + ) internal view returns (uint256) { + TokenPriceCfg memory pInCfg = pc.tokenCfg[indexTokenIn]; + TokenPriceCfg memory pOutCfg = pc.tokenCfg[indexTokenOut]; + + uint256 rawPriceTokenIn = HyperSpotPricePrecompile.spotPrice(pInCfg.pairIndex); + uint256 rawPriceTokenOut = HyperSpotPricePrecompile.spotPrice(pOutCfg.pairIndex); + uint256 priceTokenInScaled18 = rawPriceTokenIn.divDown(_divisorFromSz(pInCfg.sz)); + uint256 priceTokenOutScaled18 = rawPriceTokenOut.divDown(_divisorFromSz(pOutCfg.sz)); + return priceTokenOutScaled18.divDown(priceTokenInScaled18); + } + + function _pairSpotFromBalancesWeights( + uint256[] memory balancesScaled18, + uint256[] memory weights, + uint256 indexTokenIn, + uint256 indexTokenOut + ) internal pure returns (uint256) { + // This would cause a division by zero error. In normal circumstances this should never happen, + // but we keep the defensive check since it is on the withdraw path. + if (balancesScaled18[indexTokenIn] == 0 || weights[indexTokenOut] == 0) { + return 0; + } + + // Use pure math increases the precision of the operations and reduces gas cost. + return + ((balancesScaled18[indexTokenOut] * weights[indexTokenIn]) / weights[indexTokenOut]).divDown( + balancesScaled18[indexTokenIn] + ); + } + + 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; + } + + 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. + * + * @param pool The pool address + * @param balancesScaled18 The balances of the pool + * @param w The weights of the pool + */ + 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 weights + ) 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 = weights[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 = weights[locals.j]; + locals.pxj = locals.px[locals.j]; + + // Pool-implied spot for j vs i: (Bj/wj) / (Bi/wi) + locals.poolPx = _pairSpotFromBalancesWeights(balancesScaled18, weights, locals.i, locals.j); + + 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; + } + + /** + * @notice Checks if the pool price deviation is worsening after a add/remove liquidity operation. + * @dev The pool price deviation is worsening if the deviation between oracle and pool price increased and the + * deviation is greater than the surge threshold. + * + * @param pool The pool address + * @param oldBalancesScaled18 The balances before the add/remove liquidity operation + * @param newBalancesScaled18 The balances after the add/remove liquidity operation + * @return True if the pool price deviation is worsening, false otherwise + */ + function _isPriceDeviationWorsening( + address pool, + uint256[] memory oldBalancesScaled18, + uint256[] memory newBalancesScaled18 + ) internal view returns (bool) { + uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); + uint256 priceDeviationBefore = _computeOracleDeviationPct(pool, oldBalancesScaled18, weights); + uint256 priceDeviationAfter = _computeOracleDeviationPct(pool, newBalancesScaled18, weights); + uint256 surgeThreshold = getSurgeThresholdPercentage(pool, TradeType.NOISE); + + return (priceDeviationAfter > priceDeviationBefore) && (priceDeviationAfter > surgeThreshold); + } + + /// @notice Converts a 9 decimal places fixed point number to 18 decimal places. + function _convertTo18Decimals(uint32 valueScaled9) internal pure returns (uint256) { + return uint256(valueScaled9) * 1e9; + } + + /// @notice Converts a 18 decimal places fixed point number to 9 decimal places. + function _safeConvertTo9Decimals(uint256 valueScaled18) internal pure returns (uint32) { + return (valueScaled18 / 1e9).toUint32(); + } + + function _ensureValidPercentage(uint256 percentageValue) internal pure { + if (percentageValue < 1e9 || percentageValue > 1e18 || percentageValue % 1e9 != 0) { + revert InvalidPercentage(); + } + } +} diff --git a/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol b/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol index 43fcc74e..a1d4d2ed 100644 --- a/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol +++ b/pkg/pool-hooks/contracts/hooks-quantamm/LPNFT.sol @@ -4,16 +4,15 @@ pragma solidity >=0.8.24; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "./UpliftOnlyExample.sol"; -/** +/** * @notice This contract is a simple ERC721 contract for LP NFTs. It overrides update which means * that the deposits can be transferred feely between users. * this does require the router to change the vault balances and deposit records as well */ -/// @title LPNFT contract for QuantAMM LP NFTs -/// @notice implements ERC721 for LP NFTs +/// @title LPNFT contract for QuantAMM LP NFTs +/// @notice implements ERC721 for LP NFTs contract LPNFT is ERC721 { - uint256 numMinted; /// @notice the address of the QuantAMM router this token is for @@ -21,16 +20,11 @@ contract LPNFT is ERC721 { /// @notice Modifier for only allowing the router to call certain functions modifier onlyUpliftOnlyRouter() { - require(msg.sender == address(router), "ROUTERONLY"); + require(msg.sender == address(router), "ROUTERONLY"); _; } - - constructor( - string memory _name, - string memory _symbol, - address _router - ) ERC721(_name, _symbol) { + constructor(string memory _name, string memory _symbol, address _router) ERC721(_name, _symbol) { router = UpliftOnlyExample(payable(_router)); } @@ -47,7 +41,7 @@ contract LPNFT is ERC721 { /// @inheritdoc ERC721 function _update(address to, uint256 tokenId, address auth) internal override returns (address previousOwner) { - previousOwner = super._update(to, tokenId, auth); + previousOwner = super._update(to, tokenId, auth); //_update is called during mint, burn and transfer. This functionality is only for transfer if (to != address(0) && previousOwner != address(0)) { //if transfering the record in the vault needs to be changed to reflect the change in ownership diff --git a/pkg/pool-hooks/contracts/test/HardhatImports.sol b/pkg/pool-hooks/contracts/test/HardhatImports.sol index 88e30256..d71c0b9c 100644 --- a/pkg/pool-hooks/contracts/test/HardhatImports.sol +++ b/pkg/pool-hooks/contracts/test/HardhatImports.sol @@ -16,8 +16,9 @@ import { RateProviderMock } from "@balancer-labs/v3-vault/contracts/test/RatePro import { WETHTestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/WETHTestToken.sol"; -import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; import { WeightedPoolFactory } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPoolFactory.sol"; +import { WeightedPoolMock } from "@balancer-labs/v3-pool-weighted/contracts/test/WeightedPoolMock.sol"; +import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; import { StablePool } from "@balancer-labs/v3-pool-stable/contracts/StablePool.sol"; import { StablePoolFactory } from "@balancer-labs/v3-pool-stable/contracts/StablePoolFactory.sol"; diff --git a/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol b/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol new file mode 100644 index 00000000..c819aadd --- /dev/null +++ b/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol @@ -0,0 +1,67 @@ +// 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[] memory balancesScaled18, + uint256[] memory weights, + uint256 indexTokenIn, + uint256 indexTokenOut + ) external pure returns (uint256) { + return _pairSpotFromBalancesWeights(balancesScaled18, weights, indexTokenIn, indexTokenOut); + } + + 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 { + _ensureValidPercentage(pct); + } + + function ComputeSurgeFee( + PoolSwapParams calldata p, + PoolDetails memory poolDetails, + uint256 staticSwapFee, + uint256[] memory weights, + uint256 calculatedAmountScaled18, + uint256 oraclePrice + ) external pure returns (bool ok, uint256 surgeFee) { + return _computeSurgeFee(p, poolDetails, staticSwapFee, weights, calculatedAmountScaled18, oraclePrice); + } +} 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..1ae28053 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol @@ -0,0 +1,1120 @@ +// 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 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..eae4fdfa --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol @@ -0,0 +1,3002 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; +import { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; +import { IAuthorizer } from "@balancer-labs/v3-interfaces/contracts/vault/IAuthorizer.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.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 { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.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 { + WeightedPoolContractsDeployer +} from "@balancer-labs/v3-pool-weighted/test/foundry/utils/WeightedPoolContractsDeployer.sol"; +import { + HypercorePrecompileMock +} from "@balancer-labs/v3-standalone-utils/test/foundry/utils/HypercorePrecompileMock.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 { HyperSurgeHook } from "../../contracts/hooks-quantamm/HyperSurgeHook.sol"; +import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; +import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.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 CastingHelpers for address[]; + using FixedPoint for uint256; + using ArrayHelpers for *; + + uint256 constant STATIC_SWAP_FEE = 1e16; // 1% + uint256 constant ONE_SCALED_9 = 1e9; + uint256[] FIFTY_FIFTY; + + // MUST match addresses the hook libs read + uint256 internal constant DEFAULT_SWAP_FEE = 1e16; // 1% + + 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%) + FixedPoint.ONE, + 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 + ); + + FIFTY_FIFTY = [uint256(50e16), uint256(50e16)].toMemoryArray(); + } + + struct HyperPriceSpotParams { + uint32 raw; + uint32 divisor; + uint256 amtSeed; + uint256 feeSeed; + uint8 outSeed; + uint256 n; + uint32 maxPct9; + uint32 thr9; + uint32 cap9; + 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.maxPct9 = uint32(bound(feeSeed, 3, ONE_SCALED_9)); + params.thr9 = uint32(params.maxPct9 / 3); + params.cap9 = uint32(params.thr9 + (ONE_SCALED_9 - params.thr9) / 2); + if (params.cap9 == params.thr9) params.cap9 = params.thr9 + 1; + + // --- make NOISE lane different (keep maxPct same so staticFee bound remains valid) + uint32 noiseThr9 = (params.thr9 + 2 < params.cap9) ? (params.thr9 + 1) : (params.thr9 - 1); + uint32 noiseCap9 = params.cap9; + + vm.startPrank(admin); + hook.setMaxSurgeFeePercentage( + address(pool), + _convertTo18Decimals(params.maxPct9), + IHyperSurgeHook.TradeType.ARBITRAGE + ); + hook.setSurgeThresholdPercentage( + address(pool), + _convertTo18Decimals(params.thr9), + IHyperSurgeHook.TradeType.ARBITRAGE + ); + hook.setCapDeviationPercentage( + address(pool), + _convertTo18Decimals(params.cap9), + IHyperSurgeHook.TradeType.ARBITRAGE + ); + + hook.setMaxSurgeFeePercentage( + address(pool), + _convertTo18Decimals(params.maxPct9), + IHyperSurgeHook.TradeType.NOISE + ); + hook.setSurgeThresholdPercentage( + address(pool), + _convertTo18Decimals(noiseThr9), + IHyperSurgeHook.TradeType.NOISE + ); + hook.setCapDeviationPercentage(address(pool), _convertTo18Decimals(noiseCap9), 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] = FixedPoint.ONE * (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% + params.maxIn = balances[p.indexIn].mulDown(params.MAX_RATIO); + 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 % ONE_SCALED_9, 0, params.maxPct9); + + // --- 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, FixedPoint.ONE, "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.maxPct9 = uint32(bound(feeSeed, 3, ONE_SCALED_9)); + params.thr9 = uint32(params.maxPct9 / 3); + params.cap9 = uint32(params.thr9 + (ONE_SCALED_9 - params.thr9) / 2); + if (params.cap9 == params.thr9) params.cap9 = params.thr9 + 1; + + // --- make NOISE lane different (keep maxPct same so staticFee bound remains valid) + uint32 noiseThr9 = (params.thr9 + 2 < params.cap9) ? (params.thr9 + 1) : (params.thr9 - 1); + uint32 noiseCap9 = params.cap9; + + vm.startPrank(admin); + hook.setMaxSurgeFeePercentage( + address(pool), + _convertTo18Decimals(params.maxPct9), + IHyperSurgeHook.TradeType.ARBITRAGE + ); + hook.setSurgeThresholdPercentage( + address(pool), + _convertTo18Decimals(params.thr9), + IHyperSurgeHook.TradeType.ARBITRAGE + ); + hook.setCapDeviationPercentage( + address(pool), + _convertTo18Decimals(params.cap9), + IHyperSurgeHook.TradeType.ARBITRAGE + ); + + hook.setMaxSurgeFeePercentage( + address(pool), + _convertTo18Decimals(params.maxPct9), + IHyperSurgeHook.TradeType.NOISE + ); + hook.setSurgeThresholdPercentage( + address(pool), + _convertTo18Decimals(noiseThr9), + IHyperSurgeHook.TradeType.NOISE + ); + hook.setCapDeviationPercentage(address(pool), _convertTo18Decimals(noiseCap9), 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] = FixedPoint.ONE * (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].mulDown(params.MAX_RATIO); + 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 % ONE_SCALED_9, 0, params.maxPct9); + + 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, FixedPoint.ONE, "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; + uint32 max9; + uint32 thr9; + uint32 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 = uint32(1 + (marker % ONE_SCALED_9)); // avoid 0 + locals.thr9 = uint32(1 + ((marker >> 8) % locals.max9)); // greater than or equal to1 and less than or equal to max9 + locals.capRoom = ONE_SCALED_9 - locals.thr9; // room above thr + locals.cap9 = uint32(locals.thr9 + 1); // strictly > thr + if (locals.capRoom > 0) { + locals.cap9 = uint32(locals.thr9 + 1 + ((marker >> 16) % locals.capRoom)); // (thr9, 1e9] + } + if (locals.cap9 > ONE_SCALED_9) locals.cap9 = uint32(ONE_SCALED_9); // clamp just in case + + // Upscale once to 18dp + locals.maxPct = _convertTo18Decimals(locals.max9); + locals.thr = _convertTo18Decimals(locals.thr9); + locals.cap = _convertTo18Decimals(locals.cap9); + + // 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] = FixedPoint.ONE * (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% + locals.maxIn = locals.balances[p.indexIn].mulDown(locals.maxRatio); + 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; + uint256 P; + uint256 capDev; + uint256 D; + uint256 oraclePrice; + 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[3] memory percentageSeeds + ) 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.P = fee_pairSpotFromBW(locals.b[locals.i], locals.w[locals.i], locals.b[locals.j], locals.w[locals.j]); + vm.assume(locals.P > 0); + + PoolSwapParams memory p = _createPoolSwapParams(SwapKind.EXACT_IN, locals.b, locals.i, locals.j, 0); + HyperSurgeHook.PoolDetails memory poolDetails = _createPoolDetails(percentageSeeds, locals.n); + + locals.capDev = _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9); + locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 2 + 1); + + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(poolDetails.arbMaxSurgeFee9), + _convertTo18Decimals(poolDetails.arbThresholdPercentage9), + _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9), + "fee-fuzz" + ); + + (locals.ok, locals.feeA) = mock.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + locals.w, + 0, + locals.oraclePrice + ); + assertTrue(locals.ok, "compute must succeed"); + + locals.expected = fee_expectedFeeWithParams( + locals.P, + locals.oraclePrice, + STATIC_SWAP_FEE, + poolDetails.arbThresholdPercentage9, + poolDetails.arbCapDeviationPercentage9, + poolDetails.arbMaxSurgeFee9 + ); + assertEq(locals.feeA, locals.expected, "mock engine must match expected ramp"); + } + + struct monotoneDeviationLocals { + uint8 n; + uint256[] w; + uint256[] b; + uint8 i; + uint8 j; + uint256 P; + uint256 capDev; + uint256 D1; + uint256 D2; + uint256 oraclePrice1; + uint256 oraclePrice2; + 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[3] memory percentageSeeds + ) 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; + + HyperSurgeHook.PoolDetails memory poolDetails = _createPoolDetails(percentageSeeds, locals.n); + PoolSwapParams memory p = _createPoolSwapParams(SwapKind.EXACT_IN, locals.b, locals.i, locals.j, 0); + + 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 = _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9); + + 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.oraclePrice1) = fee_computeOraclePriceForDeviation(locals.P, locals.D1); + (locals.oraclePrice2) = fee_computeOraclePriceForDeviation(locals.P, locals.D2); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(poolDetails.arbMaxSurgeFee9), + _convertTo18Decimals(poolDetails.arbThresholdPercentage9), + _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9), + "fee-mono" + ); + + (, locals.fee1) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice1); + (, locals.fee2) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice2); + + assertLe(locals.fee1, locals.fee2, "fee must be non-decreasing in deviation"); + } + + struct balanceScalingLocals { + uint8 n; + uint256[] w; + uint256[] b; + uint8 i; + uint8 j; + uint256 P; + uint256 capDev; + uint256 scaleSeed; + uint256 D; + uint256 oraclePrice; + uint256 bMin; + uint256 baseAmt; + uint256 fee1; + uint256 fee2; + } + + function testFuzz_internal_balanceScalingInvariance( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed, + uint64 scaleSeed, + uint32[3] memory percentageSeeds + ) 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; + + HyperSurgeHook.PoolDetails memory poolDetails = _createPoolDetails(percentageSeeds, locals.n); + + // 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 = _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9); + + // 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.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); + + // Scale factor k and a base amount small relative to balances to avoid overflow + locals.scaleSeed = 1 + (uint256(scaleSeed) % ONE_SCALED_9); // 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), + _convertTo18Decimals(poolDetails.arbMaxSurgeFee9), + _convertTo18Decimals(poolDetails.arbThresholdPercentage9), + _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9), + "fee-scale" + ); + + // Unscaled trade + PoolSwapParams memory p1 = _createPoolSwapParams( + SwapKind.EXACT_IN, + locals.b, + locals.i, + locals.j, + locals.baseAmt + ); + uint256[] memory bScaled = new uint256[](locals.n); + for (uint256 i = 0; i < locals.n; i++) { + bScaled[i] = locals.b[i] * locals.scaleSeed; + } + PoolSwapParams memory p2 = _createPoolSwapParams( + SwapKind.EXACT_IN, + bScaled, + locals.i, + locals.j, + locals.baseAmt * locals.scaleSeed + ); + + (, locals.fee1) = mock.ComputeSurgeFee(p1, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + (, locals.fee2) = mock.ComputeSurgeFee(p2, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + + // --- 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[] w; + uint256[] b; + uint256 P; + uint32 thr; + uint32 cap; + uint32 maxp; + uint256 D; + uint256 oraclePrice; + uint256 feeA; + uint256 feeB; + uint256 feeC; + uint256 feeD; + } + + function test_internal_exactValues_boundaries() public { + ExactValuesBoundariesLocal memory locals; + + // 2 tokens, 50/50, equal balances + locals.w = [uint256(5e17), uint256(5e17)].toMemoryArray(); + locals.b = [uint256(1e24), uint256(1e24)].toMemoryArray(); + locals.P = fee_pairSpotFromBW(locals.b[0], locals.w[0], locals.b[1], locals.w[1]); + 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), + _convertTo18Decimals(locals.maxp), + _convertTo18Decimals(locals.thr), + _convertTo18Decimals(locals.cap), + "fee-boundary" + ); + + PoolSwapParams memory p = _createPoolSwapParams(SwapKind.EXACT_IN, locals.b, 0, 1, 0); + p.kind = SwapKind.EXACT_IN; + + // Below threshold + locals.D = _convertTo18Decimals(locals.thr) - 1; + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.numTokens = 2; + + // ARB lane = locals’ params (since deviation doesn’t increase with calcAmount=0) + poolDetails.arbThresholdPercentage9 = locals.thr; + poolDetails.arbCapDeviationPercentage9 = locals.cap; + poolDetails.arbMaxSurgeFee9 = locals.maxp; + + // Make NOISE lane different + poolDetails.noiseThresholdPercentage9 = locals.thr + 1; + poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; + poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeA) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + assertEq(locals.feeA, STATIC_SWAP_FEE, "below threshold means static fee"); + + locals.D = (_convertTo18Decimals(locals.thr) + _convertTo18Decimals(locals.cap)) / 2; + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); + + (, locals.feeB) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + + uint256 expected = fee_expectedFeeWithParams( + locals.P, + locals.oraclePrice, + STATIC_SWAP_FEE, + locals.thr, + locals.cap, + locals.maxp + ); + assertEq(locals.feeB, expected, "mid-span linear ramp"); + + // At cap and above cap + + uint256 Dcap = _convertTo18Decimals(locals.cap); + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, Dcap); + + (, locals.feeC) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + assertEq(locals.feeC, _convertTo18Decimals(locals.maxp), "at cap means max fee"); + + uint256 Dbeyond = Dcap + 1; + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, Dbeyond); + + (, locals.feeD) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + assertEq(locals.feeD, _convertTo18Decimals(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 oraclePrice; + 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 = _convertTo18Decimals(locals.cap); + locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 2 + 1); + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.maxp), + _convertTo18Decimals(locals.thr), + _convertTo18Decimals(locals.cap), + "fee-io" + ); + + // EXACT_IN + PoolSwapParams memory pIn = _createPoolSwapParams(SwapKind.EXACT_IN, locals.b, locals.i, locals.j, 0); + + // Build pool details with NOISE = (thr/cap/maxp) and ARB deliberately different + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.numTokens = locals.n; + + // Effective (chosen) lane params + poolDetails.noiseThresholdPercentage9 = locals.thr; + poolDetails.noiseCapDeviationPercentage9 = locals.cap; + poolDetails.noiseMaxSurgeFee9 = locals.maxp; + + // Different ARB lane params so wrong-lane usage wouldn’t accidentally match + poolDetails.arbThresholdPercentage9 = locals.thr + 1; + poolDetails.arbCapDeviationPercentage9 = locals.cap - 1; + poolDetails.arbMaxSurgeFee9 = locals.maxp + 1; + + (, locals.feeIn) = mock.ComputeSurgeFee(pIn, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + + // EXACT_OUT + PoolSwapParams memory pOut = _createPoolSwapParams(SwapKind.EXACT_OUT, locals.b, locals.i, locals.j, 0); + + (, locals.feeOut) = mock.ComputeSurgeFee(pOut, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + + 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 --- + 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% + + PoolSwapParams memory p = _createPoolSwapParams(SwapKind.EXACT_IN, b, uint8(i), uint8(j), 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), + _convertTo18Decimals(5_000_000), + IHyperSurgeHook.TradeType.NOISE + ); // 0.5% + hook.setCapDeviationPercentage( + address(pool), + _convertTo18Decimals(400_000_000), + IHyperSurgeHook.TradeType.NOISE + ); // 40% + hook.setMaxSurgeFeePercentage(address(pool), _convertTo18Decimals(25_000_000), IHyperSurgeHook.TradeType.NOISE); // 2.5% + + hook.setSurgeThresholdPercentage( + address(pool), + _convertTo18Decimals(1_000_000), + IHyperSurgeHook.TradeType.ARBITRAGE + ); // 0.1% + hook.setCapDeviationPercentage( + address(pool), + _convertTo18Decimals(300_000_000), + IHyperSurgeHook.TradeType.ARBITRAGE + ); // 30% + hook.setMaxSurgeFeePercentage( + address(pool), + _convertTo18Decimals(50_000_000), + 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 = _createPoolSwapParams(SwapKind.EXACT_IN, balances, 0, 1, FixedPoint.ONE); + + // EXACT_IN: either revert or static fee (but never a computed dynamic fee) + 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); + } + + /// 1) deviation == threshold => returns static fee (boundary counted as "inside") + function test_cfg_fee_static_at_threshold_usingMockWrapper() public view { + uint256 staticFee = 0.3e16; // 0.3% + uint256 maxFee = 1.2e16; // 1.2% + + // 9 lane params (contract upscales to 18dp) + uint32 thr9 = 100_000_000; // 10% + uint32 cap9 = 500_000_000; // 50% + uint32 max9 = uint32(maxFee / ONE_SCALED_9); + + // set both lanes the same (lane choice irrelevant for this edge) + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = thr9; + poolDetails.noiseCapDeviationPercentage9 = cap9; + poolDetails.noiseMaxSurgeFee9 = max9; + poolDetails.arbThresholdPercentage9 = thr9; + poolDetails.arbCapDeviationPercentage9 = cap9; + poolDetails.arbMaxSurgeFee9 = max9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.indexIn = 0; + p.indexOut = 1; + p.amountGivenScaled18 = 0; + + // External price (priceTokenOut / priceTokenIn). + uint256 E = 10e18; + + uint256 fee = _feeAtDeviation(p, poolDetails, staticFee, E, _convertTo18Decimals(thr9)); + assertEq(fee, staticFee, "fee must equal static when deviation == threshold"); + } + + function test_cfg_fee_minimalRamp_just_above_threshold() public view { + uint256 staticFee = 0.3e16; // 0.3% + uint256 maxFee = 1.2e16; // 1.2% + + uint32 thr9 = 100_000_000; // 10% + uint32 cap9 = 500_000_000; // 50% + uint32 max9 = uint32(maxFee / ONE_SCALED_9); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = thr9; + poolDetails.noiseCapDeviationPercentage9 = cap9; + poolDetails.noiseMaxSurgeFee9 = max9; + poolDetails.arbThresholdPercentage9 = thr9; + poolDetails.arbCapDeviationPercentage9 = cap9; + poolDetails.arbMaxSurgeFee9 = max9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.amountGivenScaled18 = 0; + p.indexIn = 0; + p.indexOut = 1; + + uint256 oraclePrice = 10e18; + uint256 deviation = _convertTo18Decimals(thr9 + 1); // smallest 9dp step above threshold + uint256 fee = _feeAtDeviation(p, poolDetails, staticFee, oraclePrice, deviation); + + // Expected: static + (max - static) * (dev - thr) / (cap - thr) (div-down) + uint256 span = _convertTo18Decimals(cap9 - thr9); + uint256 ramp = ((maxFee - staticFee) * (deviation - _convertTo18Decimals(thr9))) / span; + uint256 expected = staticFee + ramp; + + assertEq(fee, expected, "minimal ramp just above threshold"); + assertGt(fee, staticFee, "fee > static just above threshold"); + assertLt(fee, maxFee, "fee < max when deviation < cap"); + } + + /// 3) degenerate: max == static => always static (even outside threshold) + function test_cfg_fee_degenerateRamp_max_equals_static() public view { + uint256 staticFee = 0.45e16; // 0.45% + uint256 maxFee = staticFee; + + uint32 thr9 = 50_000_000; // 5% + uint32 cap9 = 250_000_000; // 25% + uint32 max9 = uint32(maxFee / ONE_SCALED_9); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = thr9; + poolDetails.noiseCapDeviationPercentage9 = cap9; + poolDetails.noiseMaxSurgeFee9 = max9; + poolDetails.arbThresholdPercentage9 = thr9; + poolDetails.arbCapDeviationPercentage9 = cap9; + poolDetails.arbMaxSurgeFee9 = max9; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.amountGivenScaled18 = 0; + p.indexIn = 0; + p.indexOut = 1; + + uint256 oraclePrice = 10e18; + uint256 thr = _convertTo18Decimals(thr9); + uint256 cap = _convertTo18Decimals(cap9); + + assertEq(_feeAtDeviation(p, poolDetails, staticFee, oraclePrice, thr), staticFee, "at thr => static"); + assertEq( + _feeAtDeviation(p, poolDetails, staticFee, oraclePrice, thr + (cap - thr) / 2), + staticFee, + "mid => static" + ); + assertEq(_feeAtDeviation(p, poolDetails, staticFee, oraclePrice, cap), staticFee, "at cap => static"); + assertEq( + _feeAtDeviation(p, poolDetails, staticFee, oraclePrice, cap + 12345), + staticFee, + "beyond cap => static" + ); + } + + function test_fee_misconfig_maxBelowStatic_usingMockWrapper() public { + // Misconfig: max < static + uint256 staticFee = 0.8e16; // 0.8% + uint256 maxFee = 0.2e16; // 0.2% -> lower than static + uint32 thr9 = 100_000_000; // 10% + uint32 cap9 = 300_000_000; // 30% + uint32 max9 = uint32(maxFee / ONE_SCALED_9); + + // Local mock (don’t rely on global `hook`) + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(max9), + _convertTo18Decimals(thr9), + _convertTo18Decimals(cap9), + "misconfig-maxBelowStatic" + ); + + uint256 oraclePrice = 10e18; + + // Set BOTH lanes to the same (misconfigured) params so lane choice doesn't matter here. + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = thr9; + poolDetails.noiseCapDeviationPercentage9 = cap9; + poolDetails.noiseMaxSurgeFee9 = max9; + poolDetails.arbThresholdPercentage9 = thr9; + poolDetails.arbCapDeviationPercentage9 = cap9; + poolDetails.arbMaxSurgeFee9 = max9; + poolDetails.numTokens = 2; + + // ---------- (a) dev >= cap -> revert (underflow in mock ramp) ---------- + uint256 deviationAboveCap = _convertTo18Decimals(cap9) + 999; // strictly beyond cap + + // `amountGivenScaled18 = 0` to keep balances-based price exact + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + [FixedPoint.ONE, oraclePrice.mulDown(FixedPoint.ONE + deviationAboveCap)].toMemoryArray(), + 0, + 1, + 0 + ); + + vm.expectRevert(stdError.arithmeticError); + mock.ComputeSurgeFee(p, poolDetails, staticFee, FIFTY_FIFTY, 0, oraclePrice); + + // ---------- (b) thr < dev < cap -> revert (underflow in mock ramp) ---------- + uint256 deviationBetweenThrAndCap = _convertTo18Decimals(thr9 + (cap9 - thr9) / 3); // strictly between thr & cap + p = _createPoolSwapParams( + SwapKind.EXACT_IN, + [FixedPoint.ONE, oraclePrice.mulDown(FixedPoint.ONE + deviationBetweenThrAndCap)].toMemoryArray(), + 0, + 1, + 0 + ); + + vm.expectRevert(stdError.arithmeticError); + mock.ComputeSurgeFee(p, poolDetails, staticFee, FIFTY_FIFTY, 0, oraclePrice); + } + + struct OutsideDynamicAfterLocals { + 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 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 --- + uint256 oraclePrice = bound(eSeed, 1e16, 1e24); + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, ONE_SCALED_9)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + // ARB lane (unused here, but keep distinct) + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = _convertTo18Decimals(locals.noiseThr9); + locals.cap = _convertTo18Decimals(locals.noiseCap9); + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + + // Start BELOW E: price_before = E * (1 - deviationBefore) + locals.price_before = oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); + + // Build compute locals + swap that worsens deviation (EXACT_IN; calc=0 → P decreases further) + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.price_before].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.numTokens = 2; + + // ensure deviation increases *measurably* in Q18 (avoid 1-wei changes) + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), ONE_SCALED_9, 5e17) + ); + + // Expected (NOISE) uses AFTER deviation: price_after = price_before / (1 + x) + locals.price_after = locals.price_before.divDown(FixedPoint.ONE + p.amountGivenScaled18); + locals.expected = fee_expectedFeeWithParams( + locals.price_after, + oraclePrice, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "logic-1" + ); + + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, oraclePrice); + + // Small error expected due to precision loss. + 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 { + 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; + 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 --- + uint256 oraclePrice = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, ONE_SCALED_9)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + // NOISE lane different (unused in assertion) + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = _convertTo18Decimals(locals.arbThr9); + locals.cap = _convertTo18Decimals(locals.arbCap9); + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + + // Start ABOVE E + locals.price_before = oraclePrice.mulDown(FixedPoint.ONE + locals.deviationBefore); + + // 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(oraclePrice * (FixedPoint.ONE + locals.thr) != 0); // defensive + uint256 denom = oraclePrice.mulDown(FixedPoint.ONE + locals.thr); + vm.assume(denom != 0); + uint256 ratio = locals.price_before.divDown(denom); + vm.assume(ratio > FixedPoint.ONE); // Ensure room to remain outside + locals.xMax = ratio - FixedPoint.ONE; + if (locals.xMax > 9e17) { + locals.xMax = 9e17; + } // clamp + + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.price_before].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), 1, locals.xMax == 0 ? 1 : locals.xMax) + ); + + // Expected (ARB) uses BEFORE deviation + locals.expected = fee_expectedFeeWithParams( + locals.price_before, + oraclePrice, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "logic-2" + ); + + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, oraclePrice); + + // Still outside afterward (sanity) + locals.price_after = locals.price_before.divDown(FixedPoint.ONE + p.amountGivenScaled18); + uint256 deviationAfter = ( + locals.price_after > oraclePrice ? (locals.price_after - oraclePrice) : (oraclePrice - locals.price_after) + ).divDown(oraclePrice); + 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 oraclePrice; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 deviationBefore; + uint256 price_before; + uint256 price_after; + uint256 xMax; + uint256 fee; + uint256 R1e18; + uint256 denom; + uint256 q; + } + + /// 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.oraclePrice = bound(eSeed, 1e16, 1e24); + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, ONE_SCALED_9 - 1)); // (0,1) + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, ONE_SCALED_9)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + locals.thr = _convertTo18Decimals(locals.noiseThr9); + locals.deviationBefore = locals.thr / 4 + 1; + locals.price_before = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); + + locals.R1e18 = locals.price_before.divDown(locals.oraclePrice); + locals.denom = FixedPoint.ONE - locals.thr; + locals.q = locals.R1e18.divDown(locals.denom); + locals.xMax = locals.q > FixedPoint.ONE ? (locals.q - FixedPoint.ONE) : 0; + if (locals.xMax > 5e17) { + locals.xMax = 5e17; + } + + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.price_before].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.numTokens = 2; + + // Ensure a *measurable* worsening so NOISE is chosen: + // pick x with a lower floor (e.g., 1e9 wei) but never exceed xMax. + uint256 lo = ONE_SCALED_9; // 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 + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), lo, hi) + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "logic-3" + ); + (, locals.fee) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + // Sanity: still inside after worsening + locals.price_after = locals.price_before.divDown(FixedPoint.ONE + p.amountGivenScaled18); + uint256 deviationAfter = ( + locals.price_after > locals.oraclePrice + ? (locals.price_after - locals.oraclePrice) + : (locals.oraclePrice - locals.price_after) + ).divDown(locals.oraclePrice); + 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 oraclePrice; + 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 + 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.oraclePrice = 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, ONE_SCALED_9)); // (thr, 1] + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + + // ARB lane different (unused in assertion) + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = _convertTo18Decimals(locals.noiseThr9); + locals.cap = _convertTo18Decimals(locals.noiseCap9); + + // Start ABOVE E with a deviation strictly outside the threshold: + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 4; + locals.price_before = locals.oraclePrice.mulDown(FixedPoint.ONE + locals.deviationBefore); + + // Build compute locals + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.price_before].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.numTokens = 2; + + // 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) * FixedPoint.ONE; // Q36 + locals.den = FixedPoint.ONE - 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 + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), locals.lo, locals.hi) + ); + + // Expected uses NOISE with AFTER deviation + locals.price_after = locals.price_before.divDown(FixedPoint.ONE + p.amountGivenScaled18); + + // Sanity: crossed and worsened absolute deviation + locals.deviationBefore = (locals.price_before - locals.oraclePrice).divDown(locals.oraclePrice); + locals.deviationAfter = (locals.oraclePrice - locals.price_after).divDown(locals.oraclePrice); + require(locals.price_after < locals.oraclePrice, "must cross below oraclePrice"); + require(locals.deviationAfter > locals.deviationBefore, "must worsen absolute deviation after crossing"); + + locals.expected = fee_expectedFeeWithParams( + locals.price_after, + locals.oraclePrice, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "logic-4" + ); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + 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 oraclePrice; + 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 expected; + uint256 dyn; + uint256 denomPlus; + uint256 numPlus; + uint256 qPlus; + uint256 denomMinus; + uint256 numMinus; + uint256 qMinus; + } + + /// 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.oraclePrice = 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, ONE_SCALED_9)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + // 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 = _convertTo18Decimals(locals.arbThr9); + locals.cap = _convertTo18Decimals(locals.arbCap9); + + // Start ABOVE E with an outside deviation deviationBefore > thr + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + locals.price_before = locals.oraclePrice.mulDown(FixedPoint.ONE + locals.deviationBefore); // price_before = E * (1 + deviationBefore) + locals.R1e18 = locals.price_before.divDown(locals.oraclePrice); // 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 + locals.denomPlus = FixedPoint.ONE + locals.thr; + locals.numPlus = locals.R1e18 * FixedPoint.ONE; // Q36 + locals.qPlus = (locals.numPlus + locals.denomPlus - 1) / locals.denomPlus; // ceilDiv to Q18 + locals.xLower = locals.qPlus > FixedPoint.ONE ? (locals.qPlus - FixedPoint.ONE) : 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 + locals.denomMinus = FixedPoint.ONE - locals.thr; // > 0 by bound + locals.numMinus = locals.R1e18 * FixedPoint.ONE; // Q36 + locals.qMinus = locals.numMinus / locals.denomMinus; // floorDiv to Q18 + locals.xUpper = locals.qMinus > FixedPoint.ONE ? (locals.qMinus - FixedPoint.ONE) : 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; + + // Build compute locals + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.price_before].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), lo, hi) + ); + + // Expected (ARB) uses BEFORE deviation even though end is inside + locals.expected = fee_expectedFeeWithParams( + locals.price_before, + locals.oraclePrice, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "logic-5" + ); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + // Sanity: end is inside (two-sided) + locals.price_after = locals.price_before.divDown(FixedPoint.ONE + p.amountGivenScaled18); + uint256 deviationAfter = ( + locals.price_after > locals.oraclePrice + ? (locals.price_after - locals.oraclePrice) + : (locals.oraclePrice - locals.price_after) + ).divDown(locals.oraclePrice); + 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 oraclePrice; + 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 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 + 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.oraclePrice = bound(eSeed, 1e16, 1e24); + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, ONE_SCALED_9)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = _convertTo18Decimals(locals.noiseThr9); + locals.cap = _convertTo18Decimals(locals.noiseCap9); + + // Start BELOW E but inside: deviationBefore ∈ [0, thr) + locals.deviationBefore = (locals.thr / 3) + 1; // safely inside + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); // P/E = 1 - deviationBefore + locals.R1e18 = locals.priceBefore.divDown(locals.oraclePrice); + + // Need priceAfter/E less than or equal to 1 - thr ⇒ t greater than or equal to R/(1 - thr) - 1 + + locals.num = locals.R1e18 * FixedPoint.ONE; // Q36 + locals.den = FixedPoint.ONE - locals.thr; + locals.q = (locals.num + locals.den - 1) / locals.den; // ceilDiv → Q18 + locals.tLower = locals.q > FixedPoint.ONE ? (locals.q - FixedPoint.ONE) : 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 + + // Build locals + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.priceBefore].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), locals.lo, locals.hi) + ); + + // Expected (NOISE) uses AFTER + locals.priceAfter = locals.priceBefore.divDown(FixedPoint.ONE + p.amountGivenScaled18); + uint256 deviationAfter = ( + locals.priceAfter > locals.oraclePrice + ? (locals.priceAfter - locals.oraclePrice) + : (locals.oraclePrice - locals.priceAfter) + ).divDown(locals.oraclePrice); + assertGt(deviationAfter, locals.thr, "must end outside threshold (worsened)"); + locals.expected = fee_expectedFeeWithParams( + locals.priceAfter, + locals.oraclePrice, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "lane-inside2outside" + ); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + 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 oraclePrice; + 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 epsT; + uint256 lo; + uint256 hi; + uint256 expected; + uint256 dyn; + uint256 denomPlus; + uint256 numPlus; + uint256 qPlus; + uint256 denomMinus; + uint256 numMinus; + uint256 qMinus; + } + + /// [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.oraclePrice = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, ONE_SCALED_9)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + // 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 = _convertTo18Decimals(locals.arbThr9); + locals.cap = _convertTo18Decimals(locals.arbCap9); + + // Start ABOVE, outside + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; // strictly outside + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE + locals.deviationBefore); + + // 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 * FixedPoint.ONE; + locals.R1e18 = (numR + locals.oraclePrice - 1) / locals.oraclePrice; + + // 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. + locals.denomPlus = FixedPoint.ONE + locals.thr; + locals.numPlus = locals.R1e18 * FixedPoint.ONE; // Q36 + locals.qPlus = (locals.numPlus + locals.denomPlus - 1) / locals.denomPlus; // ceilDiv → Q18 + locals.tLower = locals.qPlus > FixedPoint.ONE ? (locals.qPlus - FixedPoint.ONE) : 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. + locals.denomMinus = FixedPoint.ONE - locals.thr; + locals.numMinus = locals.R1e18 * FixedPoint.ONE; // Q36 + locals.qMinus = locals.numMinus / locals.denomMinus; // floorDiv → Q18 + locals.tUpper = locals.qMinus > FixedPoint.ONE ? (locals.qMinus - FixedPoint.ONE) : 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; + } + + // Build locals + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.priceBefore].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), locals.lo, locals.hi) + ); + + // Sanity: end is inside (two-sided) + locals.priceAfter = locals.priceBefore.divDown(FixedPoint.ONE + p.amountGivenScaled18); + uint256 dAfter = ( + locals.priceAfter > locals.oraclePrice + ? (locals.priceAfter - locals.oraclePrice) + : (locals.oraclePrice - locals.priceAfter) + ).divDown(locals.oraclePrice); + 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.oraclePrice, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "lane-out2thr" + ); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + 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 oraclePrice; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 cap; + uint256 deviationBefore; + uint256 priceBefore; + 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.oraclePrice = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, ONE_SCALED_9)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + // NOISE lane different (unused) + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = _convertTo18Decimals(locals.arbThr9); + locals.cap = _convertTo18Decimals(locals.arbCap9); + + // Start ABOVE, outside + locals.deviationBefore = locals.thr + (locals.cap - locals.thr) / 3; + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE + locals.deviationBefore); + + // No movement: amount = 0, so deviationAfter == deviationBefore → ARB path + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.priceBefore].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams(SwapKind.EXACT_IN, balancesScaled18, 0, 1, 0); + + locals.expected = fee_expectedFeeWithParams( + locals.priceBefore, + locals.oraclePrice, + STATIC_SWAP_FEE, + locals.arbThr9, + locals.arbCap9, + locals.arbMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "lane-nomove-outside" + ); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + 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 oraclePrice; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint256 thr; + uint256 deviationBefore; + uint256 priceBefore; + uint256 fee; + } + + /// [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.oraclePrice = bound(eSeed, 1e16, 1e24); + locals.arbThr9 = uint32(bound(arbThrSeed, 1, ONE_SCALED_9 - 1)); + locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, ONE_SCALED_9)); + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + locals.noiseThr9 = 5_000_000; + locals.noiseCap9 = 400_000_000; + locals.noiseMax9 = 25_000_000; + + locals.thr = _convertTo18Decimals(locals.arbThr9); + + // Start BELOW, inside + locals.deviationBefore = (locals.thr / 3) + 1; // strictly inside + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); + + // No movement: deviationAfter == deviationBefore → ARB branch, but less than or equal to thr ⇒ static + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.priceBefore].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams(SwapKind.EXACT_IN, balancesScaled18, 0, 1, 0); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "lane-nomove-inside" + ); + (, locals.fee) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + assertEq( + locals.fee, + STATIC_SWAP_FEE, + "no-move/inside must return static (ARB branch, but less than or equal to thr)" + ); + } + + struct NoiseCrossesPriceWorsensDymanicLocals { + uint256 oraclePrice; + 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; + 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.oraclePrice = 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, ONE_SCALED_9)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = _convertTo18Decimals(locals.noiseThr9); + locals.cap = _convertTo18Decimals(locals.noiseCap9); + + // 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.oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); + + // Build compute locals with the standard orientation (pxIn=1e18, pxOut=E) + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.priceBefore].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.numTokens = 2; + + // ensure a measurable worsening but no overflow; avoid 1-wei knife edges + locals.lo = ONE_SCALED_9; + locals.hi = 5e17; + + // EXACT_IN reduces P further → deviation worsens from the BELOW side (NOISE lane) + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), locals.lo, locals.hi) + ); + + // AFTER price for expected (NOISE uses AFTER) + locals.priceAfter = locals.priceBefore.divDown(FixedPoint.ONE + p.amountGivenScaled18); + + // Sanity: still BELOW E and deviation increased + uint256 dBefore = (locals.oraclePrice - locals.priceBefore).divDown(locals.oraclePrice); + uint256 dAfter = (locals.oraclePrice - locals.priceAfter).divDown(locals.oraclePrice); + assertGt(dAfter, dBefore, "deviation must worsen from the below side"); + + // Expected NOISE fee from AFTER deviation + locals.expected = fee_expectedFeeWithParams( + locals.priceAfter, + locals.oraclePrice, + STATIC_SWAP_FEE, + locals.noiseThr9, + locals.noiseCap9, + locals.noiseMax9 + ); + + HyperSurgeHookMock mock = new HyperSurgeHookMock( + IVault(vault), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "lane-below-worsen" + ); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + 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 oraclePrice; + 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 fee; + uint256 expected; + uint256 num; + uint256 den; + uint256 q; + uint256 epsCross; + uint256 lo; + uint256 hi; + uint256 margin; + } + + /// [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.oraclePrice = 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, ONE_SCALED_9 - 1)); // (thr, 1) + locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9) + 1, ONE_SCALED_9)); + + // 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 = _convertTo18Decimals(locals.arbThr9); + locals.cap = _convertTo18Decimals(locals.arbCap9); + assertLt(locals.cap, FixedPoint.ONE, "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 + locals.margin = (FixedPoint.ONE - locals.cap) / 16; + if (locals.margin == 0) { + locals.margin = 1; + } + locals.Db = locals.cap + locals.margin; + if (locals.Db >= FixedPoint.ONE) { + locals.Db = FixedPoint.ONE - 1; + } + + // Sanity: BEFORE > cap + assertGt(locals.Db, locals.cap, "setup must have BEFORE > cap"); + + // Price ABOVE E with BEFORE deviation Db + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE + locals.Db); + + // 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). + + locals.num = (locals.Db - locals.cap) * FixedPoint.ONE; // Q36 (Db > cap guaranteed) + locals.den = FixedPoint.ONE + locals.cap; + locals.q = (locals.num + locals.den - 1) / locals.den; // ceilDiv → Q18 + locals.tLower = locals.q; + + // Avoid crossing E: need t < Db. Use tiny epsilon below Db to stay strictly above E. + locals.epsCross = 1; // one Q18 unit + locals.tUpperNoCross = (locals.Db > locals.epsCross) ? (locals.Db - locals.epsCross) : 0; + + locals.lo = (locals.tLower == 0 ? 1 : locals.tLower); + locals.hi = locals.tUpperNoCross; + + if (locals.hi < locals.lo) { + locals.hi = locals.lo; + } + + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.priceBefore].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), locals.lo, locals.hi) + ); + + // AFTER should be less than or equal to cap (improved) and we shouldn’t have crossed E. + locals.priceAfter = locals.priceBefore.divDown(FixedPoint.ONE + p.amountGivenScaled18); + uint256 dAfter = ( + locals.priceAfter > locals.oraclePrice + ? (locals.priceAfter - locals.oraclePrice) + : (locals.oraclePrice - locals.priceAfter) + ).divDown(locals.oraclePrice); + 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), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "arb-before-cap" + ).ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + locals.expected = fee_expectedFeeWithParams( + locals.priceBefore, + locals.oraclePrice, + 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, _convertTo18Decimals(locals.arbMax9), "ARB fee must equal arbMax"); + } + + struct BoundNoiseExactThresholdLocals { + uint256 oraclePrice; + uint32 noiseThr9; + uint32 noiseCap9; + uint32 noiseMax9; + uint32 arbThr9; + uint32 arbCap9; + uint32 arbMax9; + uint256 thr; + uint256 Db; + uint256 priceBefore; + uint256 priceAfter; + uint256 tEdge; + uint256 fee; + uint256 num; + uint256 den; + uint256 epsT; + uint256 lo; + uint256 hi; + uint256 margin; + } + + function testFuzz_bound_noise_after_at_threshold_static( + uint256 eSeed, + uint32 noiseThrSeed, + uint32 noiseCapSeed, + uint32 noiseMaxSeed, + uint64 amtSeed + ) public { + BoundNoiseExactThresholdLocals memory locals; + + locals.oraclePrice = bound(eSeed, 1e16, 1e24); + locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); + locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, ONE_SCALED_9)); + locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / ONE_SCALED_9), ONE_SCALED_9)); + locals.arbThr9 = 1_000_000; + locals.arbCap9 = 300_000_000; + locals.arbMax9 = 50_000_000; + + locals.thr = _convertTo18Decimals(locals.noiseThr9); + + locals.Db = locals.thr / 4 + 1; + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.Db); + + locals.num = (locals.thr - locals.Db) * FixedPoint.ONE; + locals.den = FixedPoint.ONE - locals.thr; + locals.tEdge = locals.den == 0 ? 0 : (locals.num / locals.den); + + locals.epsT = 1e6; + locals.lo = (locals.tEdge > locals.epsT) ? (locals.tEdge - locals.epsT) : 1; + locals.hi = locals.tEdge; + if (locals.hi < locals.lo) { + locals.hi = locals.lo; + } + + uint256[] memory balancesScaled18 = [FixedPoint.ONE, locals.priceBefore].toMemoryArray(); + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; + poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; + poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; + poolDetails.arbThresholdPercentage9 = locals.arbThr9; + poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; + poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + poolDetails.numTokens = 2; + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), locals.lo, locals.hi) + ); + + locals.priceAfter = locals.priceBefore.divDown(FixedPoint.ONE + p.amountGivenScaled18); + + uint256 dBefore = (locals.oraclePrice - locals.priceBefore).divDown(locals.oraclePrice); + uint256 dAfter = (locals.oraclePrice - locals.priceAfter).divDown(locals.oraclePrice); + 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), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), + "noise-exact-thr" + ).ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); + + 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), FixedPoint.ONE + 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] = FixedPoint.ONE; + balances[1] = FixedPoint.ONE; + + 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] = FixedPoint.ONE; + balances[1] = FixedPoint.ONE; + + 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] = FixedPoint.ONE; + balances[1] = FixedPoint.ONE; + + 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] = FixedPoint.ONE / 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( + PoolSwapParams memory p, + HyperSurgeHook.PoolDetails memory poolDetails, + uint256 staticFee, + uint256 extPxE18, + uint256 deviation18 + ) internal view returns (uint256) { + // pool price P = E * (1 + deviation) + uint256 P = extPxE18 + extPxE18.mulDown(deviation18); + + // Make poolPx = P using simple weights/balances: + // poolPx = (bOut * wIn) / (bIn * wOut) + p.balancesScaled18 = [uint256(FixedPoint.ONE), uint256(P)].toMemoryArray(); + + // Keep deltas zero so poolPx == poolPxBefore (no lane flip due to swap) => calculatedAmount = 0 + (bool ok, uint256 fee) = hook.ComputeSurgeFee(p, poolDetails, staticFee, FIFTY_FIFTY, 0, extPxE18); + assertTrue(ok, "compute ok"); + return fee; + } + + function fee_relAbsDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? (a - b).divDown(b) : (b - a).divDown(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) { + return (bIn == 0 || wOut == 0) ? 0 : ((bOut * wIn) / wOut).divDown(bIn); + } + + // 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 <= FixedPoint.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))) % ONE_SCALED_9); + sumR += r[i]; + } + } + + uint256 base = uint256(n) * WEIGHT_MIN; + uint256 rem = FixedPoint.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 != FixedPoint.ONE) { + if (acc < FixedPoint.ONE) w[0] += (FixedPoint.ONE - acc); + else { + uint256 over = acc - FixedPoint.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_computeOraclePriceForDeviation(uint256 P, uint256 deviation) internal pure returns (uint256) { + return P.divDown(FixedPoint.ONE + deviation); + } + + function _convertTo18Decimals(uint32 valueScaled9) internal pure returns (uint256) { + return uint256(valueScaled9) * ONE_SCALED_9; + } + + // Expected fee (exact same rounding & clamping as the hook) + function fee_expectedFeeWithParams( + uint256 poolPx, + uint256 oraclePrice, + uint256 staticSwapFee, + uint32 thresholdPPM9, + uint32 capDevPPM9, + uint32 maxFeePPM9 + ) internal pure returns (uint256) { + uint256 deviation = fee_relAbsDiff(poolPx, oraclePrice); + + uint256 threshold = _convertTo18Decimals(thresholdPPM9); + uint256 capDev = _convertTo18Decimals(capDevPPM9); + uint256 maxPct = _convertTo18Decimals(maxFeePPM9); + + if (deviation <= threshold) { + return staticSwapFee; + } + + uint256 span = capDev - threshold; + uint256 norm = (deviation - threshold).divDown(span); + if (norm > FixedPoint.ONE) { + norm = FixedPoint.ONE; + } + + uint256 incr = (maxPct - staticSwapFee).mulDown(norm); + uint256 fee = staticSwapFee + incr; + if (fee > maxPct) { + fee = maxPct; + } + return fee; + } + + function _createPoolSwapParams( + SwapKind kind, + uint256[] memory balancesScaled18, + uint8 indexIn, + uint8 indexOut, + uint256 amountGivenScaled18 + ) internal pure returns (PoolSwapParams memory p) { + p.kind = kind; + p.balancesScaled18 = balancesScaled18; + p.indexIn = indexIn; + p.indexOut = indexOut; + p.amountGivenScaled18 = amountGivenScaled18; + } + + function _createPoolDetails( + uint32[3] memory percentageSeeds, + uint8 n + ) internal pure returns (HyperSurgeHook.PoolDetails memory poolDetails) { + (uint32 thrPPM9, uint32 capPPM9, uint32 maxPPM9) = fee_boundParams( + percentageSeeds[0], + percentageSeeds[1], + percentageSeeds[2] + ); + poolDetails.noiseThresholdPercentage9 = thrPPM9; + poolDetails.noiseCapDeviationPercentage9 = capPPM9; + poolDetails.noiseMaxSurgeFee9 = maxPPM9; + poolDetails.arbThresholdPercentage9 = thrPPM9; + poolDetails.arbCapDeviationPercentage9 = capPPM9; + poolDetails.arbMaxSurgeFee9 = maxPPM9; + poolDetails.numTokens = n; + } + + 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..ca7f1798 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol @@ -0,0 +1,1254 @@ +// 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 { HyperSurgeHook } from "../../contracts/hooks-quantamm/HyperSurgeHook.sol"; +import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; +import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.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(); + + assertFalse(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 oraclePrice; + 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, FixedPoint.ONE, 1e22); + check.bOut = bound(bOutRaw, FixedPoint.ONE, 1e22); + check.oraclePrice = FixedPoint.ONE; + 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 + + uint256[] memory balancesScaled18 = [check.bIn, check.bOut].toMemoryArray(); + uint256[] memory weights = [FixedPoint.ONE, uint256(0)].toMemoryArray(); // <<< makes den = bIn.mulDown(wOut) == 0 → poolPx == 0 + + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.noiseThresholdPercentage9 = 10_000_000; // 1% + poolDetails.noiseCapDeviationPercentage9 = 50_000_000; // 5% + poolDetails.noiseMaxSurgeFee9 = 100_000_000; // 10% + poolDetails.arbThresholdPercentage9 = 10_000_000; // 1% + poolDetails.arbCapDeviationPercentage9 = 50_000_000; // 5% + poolDetails.arbMaxSurgeFee9 = 200_000_000; // 20% + poolDetails.numTokens = 2; + + PoolSwapParams memory p = PoolSwapParams({ + kind: exactIn ? SwapKind.EXACT_IN : SwapKind.EXACT_OUT, + amountGivenScaled18: check.amountGiven, + balancesScaled18: balancesScaled18, + indexIn: 0, + indexOut: 1, + router: address(0), + userData: "" + }); + + check.staticFee = 1e16; // 1% + (check.ok, check.fee) = hook.ComputeSurgeFee( + p, + poolDetails, + check.staticFee, + weights, + check.calcAmount, + check.oraclePrice + ); + + assertTrue(check.ok, "compute fee must not block when pool spot denominator is zero"); + assertLe(check.fee, FixedPoint.ONE, "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..433ce2a0 --- /dev/null +++ b/pkg/pool-hooks/test/foundry/HyperSurgeMaxDeviation.t.sol @@ -0,0 +1,833 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.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 { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; + +import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; +import { HyperSurgeHook } from "../../contracts/hooks-quantamm/HyperSurgeHook.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 { + using FixedPoint for uint256; + using ArrayHelpers for *; + + 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 <= FixedPoint.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 = FixedPoint.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 != FixedPoint.ONE) { + if (acc < FixedPoint.ONE) w[0] += (FixedPoint.ONE - acc); + else { + uint256 over = acc - FixedPoint.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). + + // Choose deviation D, then set external px so that extPx = P / (1 + D) + function fee_computeOraclePriceForDeviation(uint256 P, uint256 deviation) internal pure returns (uint256) { + return P.divDown(FixedPoint.ONE + deviation); + } + + function _getDefaultPoolDetails() internal pure returns (HyperSurgeHook.PoolDetails memory poolDetails) { + // Configure NOISE lane (used when deviation does not worsen). + poolDetails.noiseThresholdPercentage9 = uint32(DEFAULT_THRESHOLD_PPM9); + poolDetails.noiseMaxSurgeFee9 = uint32(DEFAULT_MAX_SURGE_FEE_PPM9); + poolDetails.noiseCapDeviationPercentage9 = uint32(DEFAULT_CAP_DEV_PPM9); + + // Set ARB lane too (not used here, but keep consistent). + poolDetails.arbThresholdPercentage9 = uint32(DEFAULT_THRESHOLD_PPM9); + poolDetails.arbMaxSurgeFee9 = uint32(DEFAULT_MAX_SURGE_FEE_PPM9); + poolDetails.arbCapDeviationPercentage9 = uint32(DEFAULT_CAP_DEV_PPM9); + + poolDetails.numTokens = 2; + } + + function _relAbsDiff(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? (a - b).divDown(b) : (b - a).divDown(b); + } + + // Replace any existing pair-spot helper with this: + function _pairSpotFromBalancesWeights( + uint256 bIn, + uint256 wIn, + uint256 bOut, + uint256 wOut + ) internal pure returns (uint256) { + // If the denominator is zero, the pool price is zero. + if (bIn == 0 || wOut == 0) return 0; + return ((bOut * wIn) / wOut).divDown(bIn); + } + + function _expectedFeeFromLocals(uint256 poolPx, uint256 oraclePrice) internal pure returns (uint256) { + uint256 deviation = _relAbsDiff(poolPx, oraclePrice); + + 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 = (deviation - threshold).divDown(span); + if (norm > FixedPoint.ONE) norm = FixedPoint.ONE; + + uint256 incr = (maxPct - STATIC_SWAP_FEE).mulDown(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); + + // 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; + + 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 + p.balancesScaled18 = _balances(n, bSeed); + p.indexIn = i; + p.indexOut = j; + p.amountGivenScaled18 = 0; + + uint256 P = _pairSpotFromBalancesWeights(p.balancesScaled18[i], w[i], p.balancesScaled18[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 oraclePrice = fee_computeOraclePriceForDeviation(P, D); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + (bool ok, uint256 fee) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, w, 0, oraclePrice); + assertTrue(ok, "compute must succeed"); + assertEq(fee, STATIC_SWAP_FEE, "below threshold must return static fee"); + } + + struct FeeAboveCapLocals { + uint8 n; + uint8 i; + uint8 j; + uint256[] w; + uint256[] b; + uint256 P; + uint256 capDev; + uint256 D; + uint256 oraclePrice; + uint256 extra; + bool ok; + uint256 fee; + uint256 maxPct; + } + + /// 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 { + FeeAboveCapLocals memory locals; + + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = _normWeights(locals.n, wSeed); + locals.b = _balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 3))), 0, locals.n - 1)); + locals.j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 4))), 0, locals.n - 1)); + if (locals.j == locals.i) locals.j = (locals.i + 1) % locals.n; + + locals.P = _pairSpotFromBalancesWeights( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j] + ); + vm.assume(locals.P > 0); + + locals.capDev = DEFAULT_CAP_DEV_PPM9 * 1e9; + + // Choose a deviation D >= capDev (push comfortably above to avoid rounding back below). + locals.extra = (FixedPoint.ONE - locals.capDev) / 4; // up to +25% beyond cap (bounded to keep pxOut > 0) + locals.D = locals.capDev + (uint256(keccak256(abi.encode(dSeed, 5))) % (locals.extra + 1)); + + uint256 oraclePrice = fee_computeOraclePriceForDeviation(locals.P, locals.D); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = locals.b; + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, oraclePrice); + assertTrue(locals.ok, "compute must succeed"); + + locals.maxPct = DEFAULT_MAX_SURGE_FEE_PPM9 * 1e9; + assertEq(locals.fee, locals.maxPct, "above cap must return max fee"); + } + + struct FeeBetweenLinearLocals { + uint8 n; + uint8 i; + uint8 j; + uint256[] w; + uint256[] b; + uint256 P; + uint256 capDev; + uint256 D; + uint256 oraclePrice; + bool ok; + uint256 fee; + uint256 threshold; + uint256 span; + } + + /// 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 { + FeeBetweenLinearLocals memory locals; + + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = _normWeights(locals.n, wSeed); + locals.b = _balances(locals.n, bSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 6))), 0, locals.n - 1)); + locals.j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 7))), 0, locals.n - 1)); + if (locals.j == locals.i) locals.j = (locals.i + 1) % locals.n; + + locals.P = _pairSpotFromBalancesWeights( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j] + ); + vm.assume(locals.P > 0); + + locals.threshold = DEFAULT_THRESHOLD_PPM9; + locals.capDev = DEFAULT_CAP_DEV_PPM9; + locals.span = locals.capDev - locals.threshold; + + // Target a deviation strictly inside (threshold, capDev): + locals.D = locals.threshold + 1 + (uint256(keccak256(abi.encode(dSeed, 8))) % (locals.span - 1)); + + locals.oraclePrice = fee_computeOraclePriceForDeviation(locals.P, locals.D); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = locals.b; + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + locals.w, + 0, + locals.oraclePrice + ); + assertTrue(locals.ok, "compute must succeed"); + + // Compute expected with identical rounding. + uint256 expected = _expectedFeeFromLocals(locals.P, locals.oraclePrice); + assertEq(locals.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 oraclePrice, + uint256 staticSwapFee, + uint32 thresholdPPM9, + uint32 capDevPPM9, + uint32 maxFeePPM9 + ) internal pure returns (uint256) { + uint256 deviation = _relAbsDiff(poolPx, oraclePrice); + + uint256 threshold = _ppm9To1e18(thresholdPPM9); + uint256 capDev = _ppm9To1e18(capDevPPM9); + uint256 maxPct = _ppm9To1e18(maxFeePPM9); + + if (deviation <= threshold) return staticSwapFee; + + uint256 span = capDev - threshold; + uint256 norm = (deviation - threshold).divDown(span); + if (norm > FixedPoint.ONE) norm = FixedPoint.ONE; + + uint256 incr = (maxPct - staticSwapFee).mulDown(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 oraclePrice; + 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.oraclePrice) = fee_computeOraclePriceForDeviation(locals.price, locals.deviation); + uint256 oraclePrice2 = fee_computeOraclePriceForDeviation(locals.price, locals.expected); + + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = b; + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, w, 0, locals.oraclePrice); + (, uint256 fee2) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, w, 0, oraclePrice2); + + assertLe(locals.fee, fee2, "fee must be non-decreasing with deviation"); + } + + struct SwapSymmetryLocals { + uint256[] w; + uint8 i; + uint8 j; + uint256 P; + uint256 oraclePrice; + bool okA; + uint256 feeA; + uint256 devA; + bool okB; + uint256 feeB; + uint256 devB; + } + + function testFuzz_swapSymmetry_sameLaneParams(uint8 n, uint256 wSeed, uint256 bSeed, uint256 dSeed) public view { + SwapSymmetryLocals memory locals; + + n = uint8(bound(n, 2, 8)); + locals.w = _normWeights(n, wSeed); + + locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, n - 1)); + locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, n - 2))) % n; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = _balances(n, bSeed); + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + // Pool spot for (i -> j) using the same rounding/staging as the hook + uint256 P_ij = _pairSpotFromBalancesWeights( + p.balancesScaled18[locals.i], + locals.w[locals.i], + p.balancesScaled18[locals.j], + locals.w[locals.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); + + locals.oraclePrice = fee_computeOraclePriceForDeviation(P_ij, D); + + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + // Orientation A (i -> j) + (locals.okA, locals.feeA) = hook.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + locals.w, + 0, + locals.oraclePrice + ); + // Orientation B (j -> i) + p.indexIn = locals.j; + p.indexOut = locals.i; + (locals.okB, locals.feeB) = hook.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + locals.w, + 0, + FixedPoint.ONE.divDown(locals.oraclePrice) + ); + assertTrue(locals.okA && locals.okB, "compute must succeed"); + + // Measure deviations exactly like the hook does in each orientation + locals.devA = _relAbsDiff(P_ij, locals.oraclePrice); + + // Compute the swapped pool spot with the SAME rounding (don’t assume 1/P) + uint256 P_ji = _pairSpotFromBalancesWeights( + p.balancesScaled18[locals.j], + locals.w[locals.j], + p.balancesScaled18[locals.i], + locals.w[locals.i] + ); + locals.devB = _relAbsDiff(P_ji, FixedPoint.ONE.divDown(locals.oraclePrice)); + + // Correct directional assertion: + if (locals.devA > locals.devB) { + // allow 1 wei to avoid knife-edge floor rounding flips + assertGe(locals.feeA + 1, locals.feeB, "larger deviation must not yield smaller fee (A vs B)"); + } else if (locals.devB > locals.devA) { + assertGe(locals.feeB + 1, locals.feeA, "larger deviation must not yield smaller fee (B vs A)"); + } else { + assertApproxEqAbs(locals.feeA, locals.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 oraclePrice; + 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.oraclePrice) = fee_computeOraclePriceForDeviation(locals.price, locals.deviation); + + // Choose static fee in [0 .. maxPct] + uint256 maxPct = DEFAULT_MAX_SURGE_FEE_PPM9; + uint256 staticFee = uint256(staticFeeSeed) % (maxPct + 1); + + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = b; + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee(p, poolDetails, staticFee, w, 0, locals.oraclePrice); + assertTrue(locals.ok, "compute must succeed"); + + locals.expected = _expectedFeeWithParams( + _pairSpotFromBalancesWeights(b[locals.i], w[locals.i], b[locals.j], w[locals.j]), + locals.oraclePrice, + 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 oraclePrice; + 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.oraclePrice) = fee_computeOraclePriceForDeviation(locals.price, locals.deviation); + + // Orientation A (i -> j) + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = b; + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + (locals.ok, locals.fee) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, w, 0, locals.oraclePrice); + + // Orientation B (j -> i) with inverted external prices + p.indexIn = locals.j; + p.indexOut = locals.i; + (bool okB, uint256 feeB) = hook.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + w, + 0, + FixedPoint.ONE.divDown(locals.oraclePrice) + ); + assertTrue(locals.ok && okB, "compute must succeed"); + + // Measure deviations exactly like the hook does + uint256 devA = _relAbsDiff(locals.price, locals.oraclePrice); + uint256 devB = _relAbsDiff( + _pairSpotFromBalancesWeights(b[locals.j], w[locals.j], b[locals.i], w[locals.i]), + FixedPoint.ONE.divDown(locals.oraclePrice) + ); // 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[] w; + uint256[] b; + uint256 P; + uint256 threshold; + uint256 capDev; + int8[5] offs; + uint256 Dt; + uint256 oraclePriceT; + uint256 extT; + uint256 expectedT; + uint256 Dc; + uint256 oraclePriceC; + 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)); + locals.w = _normWeights(locals.n, wSeed); + locals.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( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.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.oraclePriceT) = fee_computeOraclePriceForDeviation(locals.P, locals.Dt); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = locals.b; + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + (bool okT, uint256 feeT) = hook.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + locals.w, + 0, + locals.oraclePriceT + ); + assertTrue(okT, "compute must succeed (threshold ring)"); + + locals.expectedT = _expectedFeeFromLocals(locals.P, locals.oraclePriceT); + // 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 = FixedPoint.ONE > locals.capDev ? (FixedPoint.ONE - locals.capDev) : 0; + uint256 add = uint256(uint8(locals.offs[k])); + locals.Dc = locals.capDev + (add <= room ? add : room); + } + (locals.oraclePriceC) = fee_computeOraclePriceForDeviation(locals.P, locals.Dc); + (bool okC, uint256 feeC) = hook.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + locals.w, + 0, + locals.oraclePriceC + ); + + assertTrue(okC, "compute must succeed (cap ring)"); + + locals.expectedC = _expectedFeeFromLocals(locals.P, locals.oraclePriceC); + assertEq(feeC, locals.expectedC, "cap ring fee mismatch"); + } + } + + struct BalanceScalingInvarianceLocals { + uint8 n; + uint8 i; + uint8 j; + uint256[] w; + uint256[] b; + uint256 P; + uint256 capDev; + uint256 D; + uint256 oraclePrice; + uint256 fee1; + uint256 fee2; + uint256 k; + } + + /// Balance scaling invariance (unchanged idea, included for completeness). + function testFuzz_balanceScalingInvariance( + uint8 nSeed, + uint256 wSeed, + uint256 bSeed, + uint256 dSeed, + uint64 scaleSeed + ) public view { + BalanceScalingInvarianceLocals memory locals; + + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = _normWeights(locals.n, wSeed); + locals.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( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j] + ); + vm.assume(locals.P > 0); + + locals.capDev = DEFAULT_CAP_DEV_PPM9; + locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 3 + 1); + + locals.oraclePrice = fee_computeOraclePriceForDeviation(locals.P, locals.D); + + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); + poolDetails.numTokens = locals.n; + + PoolSwapParams memory p; + p.kind = SwapKind.EXACT_IN; + p.balancesScaled18 = locals.b; + p.indexIn = locals.i; + p.indexOut = locals.j; + p.amountGivenScaled18 = 0; + + (, locals.fee1) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + + locals.k = 1 + (uint256(scaleSeed) % 1_000_000_000); // [1 .. 1e9] + + for (uint256 bl = 0; bl < locals.n; bl++) { + p.balancesScaled18[bl] = p.balancesScaled18[bl] * locals.k; + } + + (, locals.fee2) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); + + assertApproxEqAbs(locals.fee1, locals.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; + } +}