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/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol b/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol index b055adec..b26765c9 100644 --- a/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol +++ b/pkg/pool-hooks/contracts/hooks-quantamm/HyperSurgeHook.sol @@ -3,32 +3,24 @@ 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 { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; -import { - PoolSwapParams, - LiquidityManagement, - TokenConfig, - HookFlags, - SwapKind, - AddLiquidityKind, - RemoveLiquidityKind -} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; +import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; -import { BaseHooks } from "@balancer-labs/v3-vault/contracts/BaseHooks.sol"; -import { VaultGuard } from "@balancer-labs/v3-vault/contracts/VaultGuard.sol"; import { SingletonAuthentication } from "@balancer-labs/v3-vault/contracts/SingletonAuthentication.sol"; -import { Version } from "@balancer-labs/v3-solidity-utils/contracts/helpers/Version.sol"; - +import { 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 @@ -39,7 +31,6 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi error InvalidArrayLengths(); error TokenIndexOutOfRange(); - error NumTokensOutOfRange(); error InvalidPairIndex(); error PoolNotInitialized(); error InvalidDecimals(); @@ -79,6 +70,12 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi uint256 private immutable _defaultCapDeviationPercentage18; + modifier ensureValidPercentage(uint256 percentageValue) { + _ensureValidPercentage(percentageValue); + + _; + } + constructor( IVault vault, uint256 defaultMaxSurgeFeePercentage18, @@ -86,15 +83,19 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi uint256 defaultCapDeviationPercentage18, string memory version ) SingletonAuthentication(vault) VaultGuard(vault) Version(version) { - _ensureValidPct(defaultMaxSurgeFeePercentage18); - _ensureValidPct(defaultThresholdPercentage18); - _ensureValidPct(defaultCapDeviationPercentage18); + _ensureValidPercentage(defaultMaxSurgeFeePercentage18); + _ensureValidPercentage(defaultThresholdPercentage18); + _ensureValidPercentage(defaultCapDeviationPercentage18); _defaultMaxSurgeFeePercentage18 = defaultMaxSurgeFeePercentage18; _defaultThresholdPercentage18 = defaultThresholdPercentage18; _defaultCapDeviationPercentage18 = defaultCapDeviationPercentage18; } - ///@inheritdoc IHooks + /************************************************** + Hooks + **************************************************/ + + /// @inheritdoc IHooks function getHookFlags() public pure override returns (HookFlags memory hookFlags) { hookFlags.shouldCallComputeDynamicSwapFee = true; hookFlags.shouldCallAfterAddLiquidity = true; @@ -109,29 +110,97 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi LiquidityManagement calldata ) public override onlyVault returns (bool) { PoolDetails memory details; - if (tokenCfgs.length >= 2 && tokenCfgs.length <= 8) { - details.arbMaxSurgeFee9 = _safeConvertTo9Decimals(_defaultMaxSurgeFeePercentage18); - details.arbThresholdPercentage9 = _safeConvertTo9Decimals(_defaultThresholdPercentage18); - details.arbCapDeviationPercentage9 = _safeConvertTo9Decimals(_defaultCapDeviationPercentage18); - details.noiseMaxSurgeFee9 = _safeConvertTo9Decimals(_defaultMaxSurgeFeePercentage18); - details.noiseThresholdPercentage9 = _safeConvertTo9Decimals(_defaultThresholdPercentage18); - details.noiseCapDeviationPercentage9 = _safeConvertTo9Decimals(_defaultCapDeviationPercentage18); + details.numTokens = uint8(tokenCfgs.length); + // Set the pool details, so we can use the setters and emit the proper events. + _poolCfg[pool].details = details; - details.numTokens = uint8(tokenCfgs.length); + _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); - _poolCfg[pool].details = details; - } else { - revert NumTokensOutOfRange(); + 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); } - return true; + 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); } - /// @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 + /// @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, @@ -142,6 +211,28 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi _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, @@ -172,190 +263,100 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi emit TokenPriceConfiguredIndex(pool, tokenIndex, tempCfg.pairIndex, hlTokenIdx, tempCfg.sz); } - struct SetBatchConfigs { - TokenPriceCfg tempCfg; - uint256 i; - } - - /// @notice Batch version (indices). - /// @param pool the pool address - /// @param tokenIndices the indices of the token configs being changed - /// @param pairIdx the index of the pair being changed - function setTokenPriceConfigBatchIndex( + /// @inheritdoc IHyperSurgeHook + function setMaxSurgeFeePercentage( address pool, - uint8[] calldata tokenIndices, - uint32[] calldata pairIdx, - uint32[] calldata hlTokenIdx - ) external onlySwapFeeManagerOrGovernance(pool) { - PoolDetails storage detail = _poolCfg[pool].details; - SetBatchConfigs memory cfg; - - if (tokenIndices.length != pairIdx.length) { - revert InvalidArrayLengths(); - } - - for (cfg.i = 0; cfg.i < tokenIndices.length; ++cfg.i) { - _setTokenPriceConfigIndex(pool, tokenIndices[cfg.i], pairIdx[cfg.i], hlTokenIdx[cfg.i], detail); - } + uint256 newMaxSurgeFeePercentageScaled18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) ensureValidPercentage(newMaxSurgeFeePercentageScaled18) { + _setMaxSurgeFeePercentage(pool, newMaxSurgeFeePercentageScaled18, tradeType); } - ///@inheritdoc IHyperSurgeHook - function setMaxSurgeFeePercentage( + function _setMaxSurgeFeePercentage( address pool, - uint256 pct18, + uint256 newMaxSurgeFeePercentageScaled18, TradeType tradeType - ) external override onlySwapFeeManagerOrGovernance(pool) { - _ensureValidPct(pct18); - + ) internal { if (tradeType == TradeType.ARBITRAGE) { - _poolCfg[pool].details.arbMaxSurgeFee9 = _safeConvertTo9Decimals(pct18); + _poolCfg[pool].details.arbMaxSurgeFee9 = _safeConvertTo9Decimals(newMaxSurgeFeePercentageScaled18); } else { - _poolCfg[pool].details.noiseMaxSurgeFee9 = _safeConvertTo9Decimals(pct18); + _poolCfg[pool].details.noiseMaxSurgeFee9 = _safeConvertTo9Decimals(newMaxSurgeFeePercentageScaled18); } - emit MaxSurgeFeePercentageChanged(msg.sender, pool, pct18, tradeType); + emit MaxSurgeFeePercentageChanged(msg.sender, pool, newMaxSurgeFeePercentageScaled18, tradeType); } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function setSurgeThresholdPercentage( address pool, - uint256 pct18, + uint256 newThresholdPercentageScaled18, TradeType tradeType - ) external override onlySwapFeeManagerOrGovernance(pool) { - _ensureValidPct(pct18); // keep a valid ramp span: threshold < capDev ≤ 1 - uint32 capDev; + ) 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(pct18); - capDev = poolDetails.arbCapDeviationPercentage9; + poolDetails.arbThresholdPercentage9 = _safeConvertTo9Decimals(newThresholdPercentageScaled18); + capDeviationPercentageScaled18 = _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9); } else { - poolDetails.noiseThresholdPercentage9 = _safeConvertTo9Decimals(pct18); - capDev = poolDetails.noiseCapDeviationPercentage9; + poolDetails.noiseThresholdPercentage9 = _safeConvertTo9Decimals(newThresholdPercentageScaled18); + capDeviationPercentageScaled18 = _convertTo18Decimals(poolDetails.noiseCapDeviationPercentage9); } - uint256 capDev18 = _convertTo18Decimals(capDev); - //could be done before with two if/elses but more compact code this way - if (capDev18 != 0 && pct18 >= capDev18) { + // Keep a valid ramp span: threshold < capDev ≤ 1 + if (capDeviationPercentageScaled18 != 0 && newThresholdPercentageScaled18 >= capDeviationPercentageScaled18) { revert InvalidThresholdDeviation(); } _poolCfg[pool].details = poolDetails; - emit ThresholdPercentageChanged(msg.sender, pool, pct18, tradeType); + emit ThresholdPercentageChanged(msg.sender, pool, newThresholdPercentageScaled18, tradeType); } /// @inheritdoc IHyperSurgeHook function setCapDeviationPercentage( address pool, - uint256 capDevPct18, + uint256 newCapDeviationPercentageScaled18, + TradeType tradeType + ) external override onlySwapFeeManagerOrGovernance(pool) ensureValidPercentage(newCapDeviationPercentageScaled18) { + _setCapDeviationPercentage(pool, newCapDeviationPercentageScaled18, tradeType); + } + + function _setCapDeviationPercentage( + address pool, + uint256 newCapDeviationPercentageScaled18, TradeType tradeType - ) external override onlySwapFeeManagerOrGovernance(pool) { - _ensureValidPct(capDevPct18); - uint32 thr; + ) internal { + uint256 thresholdPercentageScaled18; PoolDetails memory poolDetails = _poolCfg[pool].details; if (tradeType == TradeType.ARBITRAGE) { - poolDetails.arbCapDeviationPercentage9 = _safeConvertTo9Decimals(capDevPct18); - thr = poolDetails.arbThresholdPercentage9; + poolDetails.arbCapDeviationPercentage9 = _safeConvertTo9Decimals(newCapDeviationPercentageScaled18); + thresholdPercentageScaled18 = _convertTo18Decimals(poolDetails.arbThresholdPercentage9); } else { - poolDetails.noiseCapDeviationPercentage9 = _safeConvertTo9Decimals(capDevPct18); - thr = poolDetails.noiseThresholdPercentage9; + poolDetails.noiseCapDeviationPercentage9 = _safeConvertTo9Decimals(newCapDeviationPercentageScaled18); + thresholdPercentageScaled18 = _convertTo18Decimals(poolDetails.noiseThresholdPercentage9); } - uint256 thr18 = _convertTo18Decimals(thr); - - if (capDevPct18 <= thr18) { + // Keep a valid ramp span: threshold < capDev ≤ 1 + if (newCapDeviationPercentageScaled18 <= thresholdPercentageScaled18) { revert InvalidCapDeviationPercentage(); } _poolCfg[pool].details = poolDetails; - emit CapDeviationPercentageChanged(msg.sender, pool, capDevPct18, tradeType); - } - - struct AddLiquidityLocals { - uint256[] oldBalances; - uint256 beforeDev; - uint256 afterDev; - uint256 threshold; - bool isWorseningSurge; - } - - /// @notice Allow proportional adds, but block non-proportional adds that worsen deviation and end above threshold. - function onAfterAddLiquidity( - address, - address pool, - AddLiquidityKind kind, - uint256[] memory amountsInScaled18, - uint256[] memory amountsInRaw, - uint256, // lpAmount (unused) - uint256[] memory balancesScaled18, - bytes memory // userData (unused) - ) public view override returns (bool success, uint256[] memory hookAdjustedAmountsInRaw) { - AddLiquidityLocals memory locals; - - // Proportional add is always allowed. - if (kind == AddLiquidityKind.PROPORTIONAL) { - return (true, amountsInRaw); - } - - locals.oldBalances = new uint256[](balancesScaled18.length); - for (uint256 i = 0; i < balancesScaled18.length; ++i) { - locals.oldBalances[i] = balancesScaled18[i] - amountsInScaled18[i]; - } - - uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); - locals.beforeDev = _computeOracleDeviationPct(pool, locals.oldBalances, weights); - locals.afterDev = _computeOracleDeviationPct(pool, balancesScaled18, weights); - locals.threshold = getSurgeThresholdPercentage(pool, TradeType.NOISE); - - // Block only if deviation worsens AND exceeds threshold after the change. - locals.isWorseningSurge = (locals.afterDev > locals.beforeDev) && (locals.afterDev > locals.threshold); - - return (!locals.isWorseningSurge, amountsInRaw); + emit CapDeviationPercentageChanged(msg.sender, pool, newCapDeviationPercentageScaled18, tradeType); } - struct RemoveLiquidityLocals { - uint256 n; - uint256[] oldBalances; - uint256 beforeDev; - uint256 afterDev; - uint256 threshold; - bool isWorseningSurge; - } - - /// @notice Allow proportional removes, but block non-proportional removes that worsen deviation and end above threshold. - function onAfterRemoveLiquidity( - address, - address pool, - RemoveLiquidityKind kind, - uint256, // lpAmount (unused) - uint256[] memory amountsOutScaled18, - uint256[] memory amountsOutRaw, - uint256[] memory balancesScaled18, - bytes memory // userData (unused) - ) public view override returns (bool success, uint256[] memory hookAdjustedAmountsOutRaw) { - RemoveLiquidityLocals memory locals; - locals.n = balancesScaled18.length; - // Proportional remove is always allowed. should we check? - if (kind == RemoveLiquidityKind.PROPORTIONAL) { - return (true, amountsOutRaw); - } - - // Reconstruct pre-remove balances = post + out; if addition overflows, allow. - locals.oldBalances = new uint256[](locals.n); - for (uint256 i = 0; i < locals.n; ++i) { - locals.oldBalances[i] = balancesScaled18[i] + amountsOutScaled18[i]; - } - - uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); - locals.beforeDev = _computeOracleDeviationPct(pool, locals.oldBalances, weights); - locals.afterDev = _computeOracleDeviationPct(pool, balancesScaled18, weights); - locals.threshold = getSurgeThresholdPercentage(pool, TradeType.NOISE); - - locals.isWorseningSurge = (locals.afterDev > locals.beforeDev) && (locals.afterDev > locals.threshold); - - return (!locals.isWorseningSurge, amountsOutRaw); - } + /************************************************** + Getters + **************************************************/ /// @notice Getter to read the pool-specific surge threshold (1e18 = 100%). function getSurgeThresholdPercentage(address pool, TradeType tradeType) public view override returns (uint256) { @@ -366,7 +367,7 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi } } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getMaxSurgeFeePercentage(address pool, TradeType tradeType) external view override returns (uint256) { if (tradeType == TradeType.ARBITRAGE) { return _convertTo18Decimals(_poolCfg[pool].details.arbMaxSurgeFee9); @@ -375,7 +376,7 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi } } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getCapDeviationPercentage(address pool, TradeType tradeType) external view override returns (uint256) { if (tradeType == TradeType.ARBITRAGE) { return _convertTo18Decimals(_poolCfg[pool].details.arbCapDeviationPercentage9); @@ -384,7 +385,7 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi } } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getTokenPriceConfigIndex( address pool, uint8 tokenIndex @@ -393,7 +394,7 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi return (cfg.pairIndex, _divisorFromSz(cfg.sz)); } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getTokenPriceConfigs( address pool ) external view override returns (uint32[] memory pairIndexArr, uint32[] memory priceDivisorArr) { @@ -409,163 +410,167 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi } } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getDefaultMaxSurgeFeePercentage() external view override returns (uint256) { return _defaultMaxSurgeFeePercentage18; } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getDefaultSurgeThresholdPercentage() external view override returns (uint256) { return _defaultThresholdPercentage18; } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getDefaultCapDeviationPercentage() external view override returns (uint256) { return _defaultCapDeviationPercentage18; } - ///@inheritdoc IHyperSurgeHook + /// @inheritdoc IHyperSurgeHook function getNumTokens(address pool) external view override returns (uint8) { return _poolCfg[pool].details.numTokens; } - struct ComputeSurgeFeeLocals { - uint256 calcAmountScaled18; - uint256 poolPxBefore; - uint256 poolPx; - uint256 pxIn; - uint256 pxOut; - uint256 extPx; - uint256 deviationBefore18; - uint256 deviation18; - uint256 threshold18; - uint256 maxPct18; - uint256 increment; - uint256 surgeFee18; - uint256 capDevPct18; - uint256 bIn; - uint256 bOut; - uint256 rawIn; - uint256 rawOut; - uint256 wIn; - uint256 wOut; - uint256 span; - uint256 norm; - PoolDetails poolDetails; - } - - /// @inheritdoc IHooks - function onComputeDynamicSwapFeePercentage( - PoolSwapParams calldata p, - address pool, - uint256 staticSwapFee - ) public view override returns (bool, uint256) { - PoolCfg storage pc = _poolCfg[pool]; - ComputeSurgeFeeLocals memory locals; - locals.poolDetails = pc.details; - - uint256[] memory weights = WeightedPool(pool).getNormalizedWeights(); - locals.wIn = weights[p.indexIn]; - locals.wOut = weights[p.indexOut]; - - locals.calcAmountScaled18 = WeightedPool(pool).onSwap(p); - - TokenPriceCfg memory pInCfg = pc.tokenCfg[p.indexIn]; - TokenPriceCfg memory pOutCfg = pc.tokenCfg[p.indexOut]; - - locals.rawIn = HyperSpotPricePrecompile.spotPrice(pInCfg.pairIndex); - locals.rawOut = HyperSpotPricePrecompile.spotPrice(pOutCfg.pairIndex); - locals.pxIn = locals.rawIn.divDown(_divisorFromSz(pInCfg.sz)); - locals.pxOut = locals.rawOut.divDown(_divisorFromSz(pOutCfg.sz)); - locals.bIn = p.balancesScaled18[p.indexIn]; - locals.bOut = p.balancesScaled18[p.indexOut]; - - return _computeSurgeFee(locals, p, staticSwapFee); - } - - /// @notice pure function to compute surge fee - /// @param locals the locals struct containing all the necessary variables - /// @param p swap parameters - /// @param staticSwapFee the static swap fee from the pool function _computeSurgeFee( - ComputeSurgeFeeLocals memory locals, - PoolSwapParams calldata p, - uint256 staticSwapFee + PoolSwapParams calldata params, + PoolDetails memory poolDetails, + uint256 staticSwapFee, + uint256[] memory weights, + uint256 calculatedAmountScaled18, + uint256 oraclePrice ) internal pure returns (bool ok, uint256 surgeFee) { - locals.extPx = locals.pxOut.divDown(locals.pxIn); + // Do not block if there is an issue with the hyperliquid price + if (oraclePrice == 0) { + return (true, staticSwapFee); + } - //Do not block if there is an issue with the hyperliquid price - if (locals.extPx == 0) { + ( + uint256 deviationScaled18, + uint256 capDeviationPercentageScaled18, + uint256 maxSurgeFeeScaled18, + uint256 thresholdScaled18 + ) = _computeDeviationAndSelectPoolDetails(params, weights, calculatedAmountScaled18, oraclePrice, poolDetails); + + if (deviationScaled18 <= thresholdScaled18) { return (true, staticSwapFee); } - locals.poolPxBefore = _pairSpotFromBalancesWeights(locals.bIn, locals.wIn, locals.bOut, locals.wOut); - locals.deviationBefore18 = _relAbsDiff(locals.poolPxBefore, locals.extPx); + uint256 span = capDeviationPercentageScaled18 - thresholdScaled18; // > 0 by fallback above + uint256 norm = (deviationScaled18 - thresholdScaled18).divDown(span); - if (p.kind == SwapKind.EXACT_IN) { - locals.bIn += p.amountGivenScaled18; - locals.bOut -= locals.calcAmountScaled18; - } else { - locals.bIn += locals.calcAmountScaled18; - locals.bOut -= p.amountGivenScaled18; + if (norm > FixedPoint.ONE) { + norm = FixedPoint.ONE; } - // P_pool = (B_out/w_out) / (B_in/w_in) = (B_out * w_in) / (B_in * w_out) - locals.poolPx = _pairSpotFromBalancesWeights(locals.bIn, locals.wIn, locals.bOut, locals.wOut); - locals.deviation18 = _relAbsDiff(locals.poolPx, locals.extPx); // |pool - ext| / ext + 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); + } - if (locals.deviation18 > locals.deviationBefore18) { - locals.capDevPct18 = _convertTo18Decimals(locals.poolDetails.noiseCapDeviationPercentage9); - locals.maxPct18 = _convertTo18Decimals(locals.poolDetails.noiseMaxSurgeFee9); - locals.threshold18 = _convertTo18Decimals(locals.poolDetails.noiseThresholdPercentage9); - } else { - locals.capDevPct18 = _convertTo18Decimals(locals.poolDetails.arbCapDeviationPercentage9); - locals.maxPct18 = _convertTo18Decimals(locals.poolDetails.arbMaxSurgeFee9); - locals.threshold18 = _convertTo18Decimals(locals.poolDetails.arbThresholdPercentage9); - - //For the arbitrage direction we use the deviation before. - //Why this is the case is in the readme but in essence - //if a large noise deviation is being corrected the arbitrage pays more - //to take advantage of the larger arb opp and therefore greater profit - //as the fee decreases the closer you get to market price, another - //arb opportunity presents itself once the first arb is taken - //this means a large fee != a large no arb region and the pool stays close to market - locals.deviation18 = locals.deviationBefore18; + uint256[] memory newBalancesScaled18 = new uint256[](params.balancesScaled18.length); + for (uint256 i = 0; i < params.balancesScaled18.length; i++) { + newBalancesScaled18[i] = params.balancesScaled18[i]; } - if (locals.deviation18 <= locals.threshold18) { - return (true, staticSwapFee); + 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; } - locals.span = locals.capDevPct18 - locals.threshold18; // > 0 by fallback above - locals.norm = (locals.deviation18 - locals.threshold18).divDown(locals.span); + // 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 + } - if (locals.norm > FixedPoint.ONE) { - locals.norm = FixedPoint.ONE; + // 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; } + } - locals.increment = (locals.maxPct18 - staticSwapFee).mulDown(locals.norm); - locals.surgeFee18 = staticSwapFee + locals.increment; + 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]; - return (true, locals.surgeFee18); + 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 bIn, - uint256 wIn, - uint256 bOut, - uint256 wOut + uint256[] memory balancesScaled18, + uint256[] memory weights, + uint256 indexTokenIn, + uint256 indexTokenOut ) internal pure returns (uint256) { - uint256 num = bOut.mulDown(wIn); - uint256 den = bIn.mulDown(wOut); - - //would be impossible given normal balances and weights but given - //it is on the withdraw path keep the defensive check - if (den == 0) { + // 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; } - return num.divDown(den); + // 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) { @@ -590,21 +595,6 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi return 1; } - function _ensureValidPct(uint256 pct) internal pure { - if (pct < 1e9 || pct > 1e18 || pct % 1e9 != 0) { - revert InvalidPercentage(); - } - } - - ///@notice Converts a 9 decimal places fixed point number to 18 decimal places. - function _convertTo18Decimals(uint32 setting9Dp) internal pure returns (uint256) { - return uint256(setting9Dp) * 1e9; - } - - function _safeConvertTo9Decimals(uint256 setting18Dp) internal pure returns (uint32) { - return (setting18Dp / 1e9).toUint32(); - } - struct ComputeOracleDeviationLocals { uint256[8] px; uint256 maxDev; @@ -623,9 +613,15 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi 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. + /** + * @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, @@ -654,30 +650,31 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi function _findMaxDeviation( ComputeOracleDeviationLocals memory locals, uint256[] memory balancesScaled18, - uint256[] memory w + 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 = w[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 = w[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(locals.bj, locals.wj, locals.bi, locals.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; } @@ -686,4 +683,43 @@ contract HyperSurgeHook is BaseHooks, VaultGuard, SingletonAuthentication, Versi 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 index 130d63ac..c819aadd 100644 --- a/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol +++ b/pkg/pool-hooks/contracts/test/HyperSurgeHookMock.sol @@ -34,12 +34,12 @@ contract HyperSurgeHookMock is HyperSurgeHook { } function PairSpotFromBalancesWeights( - uint256 bIn, - uint256 wIn, - uint256 bOut, - uint256 wOut + uint256[] memory balancesScaled18, + uint256[] memory weights, + uint256 indexTokenIn, + uint256 indexTokenOut ) external pure returns (uint256) { - return _pairSpotFromBalancesWeights(bIn, wIn, bOut, wOut); + return _pairSpotFromBalancesWeights(balancesScaled18, weights, indexTokenIn, indexTokenOut); } function RelAbsDiff(uint256 a, uint256 b) external pure returns (uint256) { @@ -51,14 +51,17 @@ contract HyperSurgeHookMock is HyperSurgeHook { } function EnsureValidPct(uint256 pct) external pure { - _ensureValidPct(pct); + _ensureValidPercentage(pct); } function ComputeSurgeFee( - ComputeSurgeFeeLocals memory locals, PoolSwapParams calldata p, - uint256 staticSwapFee + PoolDetails memory poolDetails, + uint256 staticSwapFee, + uint256[] memory weights, + uint256 calculatedAmountScaled18, + uint256 oraclePrice ) external pure returns (bool ok, uint256 surgeFee) { - return _computeSurgeFee(locals, p, staticSwapFee); + return _computeSurgeFee(p, poolDetails, staticSwapFee, weights, calculatedAmountScaled18, oraclePrice); } } diff --git a/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol b/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol index 3599bf3d..1ae28053 100644 --- a/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol +++ b/pkg/pool-hooks/test/foundry/HyperSurgeAdmin.t.sol @@ -999,70 +999,6 @@ contract HyperSurgeAdminTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedP assertEq(hook.getCapDeviationPercentage(address(pool), IHyperSurgeHook.TradeType.ARBITRAGE), 1e18); } - function testFuzz_onRegister_RevertWhenTokenCountBelowTwo( - uint8 n, - uint256 defaultThreshold, - uint256 defaultMaxFee, - uint256 defaultCap - ) public { - n = uint8(bound(n, 0, 1)); - defaultThreshold = bound(defaultThreshold, 1, 1e9 - 1); - defaultCap = bound(defaultCap, defaultThreshold + 1, 1e9); - defaultMaxFee = bound(defaultMaxFee, 1, 1e9); - - defaultThreshold *= 1e9; - defaultCap *= 1e9; - defaultMaxFee *= 1e9; - - HyperSurgeHookMock h = new HyperSurgeHookMock( - IVault(vault), - defaultMaxFee, - defaultThreshold, - defaultCap, - "test" - ); - - TokenConfig[] memory cfgs = new TokenConfig[](n); - LiquidityManagement memory lm; - - vm.startPrank(address(vault)); - vm.expectRevert(HyperSurgeHook.NumTokensOutOfRange.selector); - h.onRegister(address(0), address(0), cfgs, lm); - vm.stopPrank(); - } - - function testFuzz_onRegister_RevertWhenTokenCountAboveEight( - uint256 n, - uint256 defaultThreshold, - uint256 defaultMaxFee, - uint256 defaultCap - ) public { - n = bound(n, 9, type(uint8).max); - defaultThreshold = bound(defaultThreshold, 1, 1e9 - 1); - defaultCap = bound(defaultCap, defaultThreshold + 1, 1e9); - defaultMaxFee = bound(defaultMaxFee, 1, 1e9); - - defaultThreshold *= 1e9; - defaultCap *= 1e9; - defaultMaxFee *= 1e9; - - HyperSurgeHookMock h = new HyperSurgeHookMock( - IVault(vault), - defaultMaxFee, - defaultThreshold, - defaultCap, - "test" - ); - - TokenConfig[] memory cfgs = new TokenConfig[](n); - LiquidityManagement memory lm; - - vm.startPrank(address(vault)); - vm.expectRevert(HyperSurgeHook.NumTokensOutOfRange.selector); - h.onRegister(address(0), address(0), cfgs, lm); - vm.stopPrank(); - } - function test_getHookFlags_SignalsAreSet( uint256 defaultThreshold, uint256 defaultMaxFee, diff --git a/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol b/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol index bd4a6ae7..eae4fdfa 100644 --- a/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol +++ b/pkg/pool-hooks/test/foundry/HyperSurgeFee.t.sol @@ -3,45 +3,33 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; -// Base test utilities (provides: vault, poocomputeLocals, poolFactory, admin, authorizer, routers, tokens, etc.) -import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; +import { 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"; -// Hook interfaces -import { IHyperSurgeHook } from "@balancer-labs/v3-interfaces/contracts/pool-hooks/IHyperSurgeHook.sol"; -import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; -import { IAuthorizer } from "@balancer-labs/v3-interfaces/contracts/vault/IAuthorizer.sol"; - -// Vault interfaces/types -import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; import { - TokenConfig, - LiquidityManagement, - PoolSwapParams, - SwapKind, - PoolRoleAccounts -} from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; - -// Local deployer + mock -import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.sol"; -import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; + 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 { - HypercorePrecompileMock -} from "@balancer-labs/v3-standalone-utils/test/foundry/utils/HypercorePrecompileMock.sol"; - -import { - WeightedPoolContractsDeployer -} from "@balancer-labs/v3-pool-weighted/test/foundry/utils/WeightedPoolContractsDeployer.sol"; -import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; +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 @@ -160,15 +148,16 @@ contract HLTokenInfoStub { * */ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoolContractsDeployer { - using ArrayHelpers for *; using CastingHelpers for address[]; + using FixedPoint for uint256; + using ArrayHelpers for *; - uint256 constant ONE = 1e18; - uint256 constant STATIC_SWAP_FEE = 1e16; // 1% (1e18 scale) + 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% - uint256 constant FEE_ONE = 1e18; HyperSurgeHookMock internal hook; @@ -183,7 +172,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo IVault(address(vault)), 0.02e18, // default max fee (2%) 0.02e18, // default threshold (2%) - 1e18, + FixedPoint.ONE, string("test") ); @@ -216,6 +205,8 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo IAuthentication(address(hook)).getActionId(IHyperSurgeHook.setTokenPriceConfigIndex.selector), admin ); + + FIFTY_FIFTY = [uint256(50e16), uint256(50e16)].toMemoryArray(); } struct HyperPriceSpotParams { @@ -225,9 +216,9 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 feeSeed; uint8 outSeed; uint256 n; - uint256 maxPct; - uint256 thr; - uint256 cap; + uint32 maxPct9; + uint32 thr9; + uint32 cap9; uint8 indexIn; uint8 indexOut; uint32 pairIdx; @@ -261,23 +252,43 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); // --- fee knobs (1e9) - params.maxPct = bound(feeSeed, 3, 1e9); - params.thr = params.maxPct / 3; - params.cap = params.thr + (1e9 - params.thr) / 2; - if (params.cap == params.thr) params.cap = params.thr + 1; + 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) - uint256 noiseThr = (params.thr + 2 < params.cap) ? (params.thr + 1) : (params.thr - 1); - uint256 noiseCap = params.cap; + uint32 noiseThr9 = (params.thr9 + 2 < params.cap9) ? (params.thr9 + 1) : (params.thr9 - 1); + uint32 noiseCap9 = params.cap9; vm.startPrank(admin); - hook.setMaxSurgeFeePercentage(address(pool), params.maxPct * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); - hook.setSurgeThresholdPercentage(address(pool), params.thr * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); - hook.setCapDeviationPercentage(address(pool), params.cap * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setMaxSurgeFeePercentage( + address(pool), + _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), params.maxPct * 1e9, IHyperSurgeHook.TradeType.NOISE); - hook.setSurgeThresholdPercentage(address(pool), noiseThr * 1e9, IHyperSurgeHook.TradeType.NOISE); - hook.setCapDeviationPercentage(address(pool), noiseCap * 1e9, IHyperSurgeHook.TradeType.NOISE); + 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 @@ -296,7 +307,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // --- balancesScaled18 with length N (simple increasing balances) uint256[] memory balances = new uint256[](params.n); for (uint256 i = 0; i < params.n; ++i) { - balances[i] = 1e18 * (i + 1); + balances[i] = FixedPoint.ONE * (i + 1); } // --- build PoolSwapParams (EXACT_IN: 0 -> indexOut) @@ -307,13 +318,13 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo p.indexOut = params.indexOut; // bound amountIn to strictly inside the 30% guard - params.MAX_RATIO = 30e16; // 30% in 1e18 - params.maxIn = (balances[p.indexIn] * params.MAX_RATIO) / 1e18; + 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 % 1e9, 0, params.maxPct); + params.staticFee = bound(feeSeed % ONE_SCALED_9, 0, params.maxPct9); // --- compute dynamic fee via hook vm.startPrank(address(vault)); @@ -322,7 +333,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo assertTrue(ok, "compute fee should succeed"); // returned value is in 1e9 scale here (hook keeps pct in 1e9) - assertLe(dyn, 1e18, "fee must be <= 100% (1e9)"); + assertLe(dyn, FixedPoint.ONE, "fee must be <= 100% (1e9)"); assertGe(dyn, params.staticFee, "dyn fee >= static fee"); } @@ -351,23 +362,43 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo assertTrue(hook.onRegister(poolFactory, address(pool), cfg, lm), "onRegister failed"); // --- fee knobs (1e9) - params.maxPct = bound(feeSeed, 3, 1e9); - params.thr = params.maxPct / 3; - params.cap = params.thr + (1e9 - params.thr) / 2; - if (params.cap == params.thr) params.cap = params.thr + 1; + 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) - uint256 noiseThr = (params.thr + 2 < params.cap) ? (params.thr + 1) : (params.thr - 1); - uint256 noiseCap = params.cap; + uint32 noiseThr9 = (params.thr9 + 2 < params.cap9) ? (params.thr9 + 1) : (params.thr9 - 1); + uint32 noiseCap9 = params.cap9; vm.startPrank(admin); - hook.setMaxSurgeFeePercentage(address(pool), params.maxPct * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); - hook.setSurgeThresholdPercentage(address(pool), params.thr * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); - hook.setCapDeviationPercentage(address(pool), params.cap * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); + hook.setMaxSurgeFeePercentage( + address(pool), + _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), params.maxPct * 1e9, IHyperSurgeHook.TradeType.NOISE); - hook.setSurgeThresholdPercentage(address(pool), noiseThr * 1e9, IHyperSurgeHook.TradeType.NOISE); - hook.setCapDeviationPercentage(address(pool), noiseCap * 1e9, IHyperSurgeHook.TradeType.NOISE); + 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 @@ -386,7 +417,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // --- balancesScaled18 length N uint256[] memory balances = new uint256[](params.n); for (uint256 i = 0; i < params.n; ++i) { - balances[i] = 1e18 * (i + 1); + balances[i] = FixedPoint.ONE * (i + 1); } // --- build PoolSwapParams (EXACT_OUT: 0 -> indexOut) @@ -398,21 +429,21 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // bound amountOut to strictly inside the 30% guard params.MAX_RATIO = 30e16; // 30% - params.maxIn = (balances[p.indexOut] * params.MAX_RATIO) / 1e18; + 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 % 1e9, 0, params.maxPct); + 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, 1e18, "fee must be <= 100% (1e9)"); + assertLe(dyn, FixedPoint.ONE, "fee must be <= 100% (1e9)"); assertGe(dyn, params.staticFee, "dyn fee >= static fee"); } @@ -432,9 +463,9 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 maxIn; bool ok; uint256 dyn; - uint256 max9; - uint256 thr9; - uint256 cap9; + uint32 max9; + uint32 thr9; + uint32 cap9; uint256 capRoom; uint256 staticSeed; uint256 i; @@ -460,19 +491,19 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // 3) Build VALID lane params (9), then upscale ONCE to 18dp // max9 ∈ [1..1e9], thr9 ∈ [1..max9], cap9 ∈ (thr9..1e9] - locals.max9 = 1 + (marker % 1_000_000_000); // avoid 0 - locals.thr9 = 1 + ((marker >> 8) % locals.max9); // greater than or equal to1 and less than or equal to max9 - locals.capRoom = 1_000_000_000 - locals.thr9; // room above thr - locals.cap9 = locals.thr9 + 1; // strictly > thr + 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 = locals.thr9 + 1 + ((marker >> 16) % locals.capRoom); // (thr9, 1e9] + locals.cap9 = uint32(locals.thr9 + 1 + ((marker >> 16) % locals.capRoom)); // (thr9, 1e9] } - if (locals.cap9 > 1_000_000_000) locals.cap9 = 1_000_000_000; // clamp just in case + if (locals.cap9 > ONE_SCALED_9) locals.cap9 = uint32(ONE_SCALED_9); // clamp just in case // Upscale once to 18dp - locals.maxPct = locals.max9 * 1e9; - locals.thr = locals.thr9 * 1e9; - locals.cap = locals.cap9 * 1e9; + 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; @@ -506,7 +537,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // 5) Balances array of length N (ascending 1e18, 2e18, ...) locals.balances = new uint256[](locals.n); for (locals.i = 0; locals.i < locals.n; ++locals.i) { - locals.balances[locals.i] = 1e18 * (locals.i + 1); + locals.balances[locals.i] = FixedPoint.ONE * (locals.i + 1); } // 6) Build swap params (EXACT_IN), amount within 30% guard @@ -516,8 +547,8 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo p.indexIn = locals.indexIn; p.indexOut = locals.indexOut; - locals.maxRatio = 30e16; // 30% in 1e18 basis - locals.maxIn = (locals.balances[p.indexIn] * locals.maxRatio) / 1e18; + locals.maxRatio = 30e16; // 30% + locals.maxIn = locals.balances[p.indexIn].mulDown(locals.maxRatio); if (locals.maxIn > 0) { locals.maxIn -= 1; } @@ -535,14 +566,10 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256[] b; uint8 i; uint8 j; - uint32 thrPPM9; - uint32 capPPM9; - uint32 maxPPM9; uint256 P; uint256 capDev; uint256 D; - uint256 pxIn; - uint256 pxOut; + uint256 oraclePrice; uint256 feeA; uint256 expected; bool ok; @@ -554,9 +581,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 wSeed, uint256 bSeed, uint256 dSeed, - uint32 thrPPM9, - uint32 capPPM9, - uint32 maxPPM9 + uint32[3] memory percentageSeeds ) public { FeeRampLocals memory locals; @@ -567,48 +592,42 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 11))), 0, locals.n - 1)); locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 12))), 0, locals.n - 2))) % locals.n; - (locals.thrPPM9, locals.capPPM9, locals.maxPPM9) = fee_boundParams(thrPPM9, capPPM9, maxPPM9); - locals.P = fee_pairSpotFromBW(locals.b[locals.i], locals.w[locals.i], locals.b[locals.j], locals.w[locals.j]); vm.assume(locals.P > 0); - locals.capDev = fee_ppm9To1e18(locals.capPPM9); + 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.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.maxPPM9), - fee_ppm9To1e18(locals.thrPPM9), - fee_ppm9To1e18(locals.capPPM9), + _convertTo18Decimals(poolDetails.arbMaxSurgeFee9), + _convertTo18Decimals(poolDetails.arbThresholdPercentage9), + _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9), "fee-fuzz" ); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals = fee_makeLocals( - locals.b[locals.i], - locals.w[locals.i], - locals.b[locals.j], - locals.w[locals.j], - locals.pxIn, - locals.pxOut, - locals.thrPPM9, - locals.capPPM9, - locals.maxPPM9 - ); - PoolSwapParams memory p; - p.kind = SwapKind.EXACT_IN; - (locals.ok, locals.feeA) = mock.ComputeSurgeFee(computeLocals, p, STATIC_SWAP_FEE); + (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.pxIn, - locals.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, - locals.thrPPM9, - locals.capPPM9, - locals.maxPPM9 + poolDetails.arbThresholdPercentage9, + poolDetails.arbCapDeviationPercentage9, + poolDetails.arbMaxSurgeFee9 ); assertEq(locals.feeA, locals.expected, "mock engine must match expected ramp"); } @@ -619,31 +638,24 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256[] b; uint8 i; uint8 j; - uint32 thrPPM9; - uint32 capPPM9; - uint32 maxPPM9; uint256 P; uint256 capDev; uint256 D1; uint256 D2; - uint256 pxIn1; - uint256 pxOut1; - uint256 pxIn2; - uint256 pxOut2; + uint256 oraclePrice1; + uint256 oraclePrice2; uint256 fee1; uint256 fee2; } - /// Monotonicity in deviation under arbitrary (valid) lane params. + // Monotonicity in deviation under arbitrary (valid) lane params. function testFuzz_internal_monotone_inDeviation( uint8 nSeed, uint256 wSeed, uint256 bSeed, uint256 d1, uint256 d2, - uint32 thrPPM9, - uint32 capPPM9, - uint32 maxPPM9 + uint32[3] memory percentageSeeds ) public { monotoneDeviationLocals memory locals; @@ -654,60 +666,31 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo locals.i = uint8(bound(uint256(keccak256(abi.encode(d1, 21))), 0, locals.n - 1)); locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(d1, 22))), 0, locals.n - 2))) % locals.n; - (locals.thrPPM9, locals.capPPM9, locals.maxPPM9) = fee_boundParams(thrPPM9, capPPM9, maxPPM9); + 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 = fee_ppm9To1e18(locals.capPPM9); + 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.pxIn1, locals.pxOut1) = fee_localsForDeviation(locals.P, locals.D1); - (locals.pxIn2, locals.pxOut2) = fee_localsForDeviation(locals.P, locals.D2); + (locals.oraclePrice1) = fee_computeOraclePriceForDeviation(locals.P, locals.D1); + (locals.oraclePrice2) = fee_computeOraclePriceForDeviation(locals.P, locals.D2); HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.maxPPM9), - fee_ppm9To1e18(locals.thrPPM9), - fee_ppm9To1e18(locals.capPPM9), + _convertTo18Decimals(poolDetails.arbMaxSurgeFee9), + _convertTo18Decimals(poolDetails.arbThresholdPercentage9), + _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9), "fee-mono" ); - PoolSwapParams memory p; - p.kind = SwapKind.EXACT_IN; - (, locals.fee1) = mock.ComputeSurgeFee( - fee_makeLocals( - locals.b[locals.i], - locals.w[locals.i], - locals.b[locals.j], - locals.w[locals.j], - locals.pxIn1, - locals.pxOut1, - locals.thrPPM9, - locals.capPPM9, - locals.maxPPM9 - ), - p, - STATIC_SWAP_FEE - ); - (, locals.fee2) = mock.ComputeSurgeFee( - fee_makeLocals( - locals.b[locals.i], - locals.w[locals.i], - locals.b[locals.j], - locals.w[locals.j], - locals.pxIn2, - locals.pxOut2, - locals.thrPPM9, - locals.capPPM9, - locals.maxPPM9 - ), - p, - STATIC_SWAP_FEE - ); + (, 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"); } @@ -718,15 +701,11 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256[] b; uint8 i; uint8 j; - uint32 thrPPM9; - uint32 capPPM9; - uint32 maxPPM9; uint256 P; uint256 capDev; uint256 scaleSeed; uint256 D; - uint256 pxIn; - uint256 pxOut; + uint256 oraclePrice; uint256 bMin; uint256 baseAmt; uint256 fee1; @@ -739,9 +718,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 bSeed, uint256 dSeed, uint64 scaleSeed, - uint32 thrPPM9, - uint32 capPPM9, - uint32 maxPPM9 + uint32[3] memory percentageSeeds ) public { balanceScalingLocals memory locals; @@ -753,22 +730,22 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo locals.i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 31))), 0, locals.n - 1)); locals.j = (locals.i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 32))), 0, locals.n - 2))) % locals.n; - (locals.thrPPM9, locals.capPPM9, locals.maxPPM9) = fee_boundParams(thrPPM9, capPPM9, maxPPM9); + 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 = fee_ppm9To1e18(locals.capPPM9); + 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.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + (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) % 1_000_000_000); // k in [1 .. 1e9] + 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 @@ -777,53 +754,35 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.maxPPM9), - fee_ppm9To1e18(locals.thrPPM9), - fee_ppm9To1e18(locals.capPPM9), + _convertTo18Decimals(poolDetails.arbMaxSurgeFee9), + _convertTo18Decimals(poolDetails.arbThresholdPercentage9), + _convertTo18Decimals(poolDetails.arbCapDeviationPercentage9), "fee-scale" ); // Unscaled trade - PoolSwapParams memory p1; - p1.kind = SwapKind.EXACT_IN; - p1.amountGivenScaled18 = locals.baseAmt; - - PoolSwapParams memory p2; - p2.kind = SwapKind.EXACT_IN; - p2.amountGivenScaled18 = locals.baseAmt * locals.scaleSeed; - - (, locals.fee1) = mock.ComputeSurgeFee( - fee_makeLocals( - locals.b[locals.i], - locals.w[locals.i], - locals.b[locals.j], - locals.w[locals.j], - locals.pxIn, - locals.pxOut, - locals.thrPPM9, - locals.capPPM9, - locals.maxPPM9 - ), - p1, - STATIC_SWAP_FEE - ); - - (, locals.fee2) = mock.ComputeSurgeFee( - fee_makeLocals( - locals.b[locals.i] * locals.scaleSeed, - locals.w[locals.i], - locals.b[locals.j] * locals.scaleSeed, - locals.w[locals.j], - locals.pxIn, - locals.pxOut, - locals.thrPPM9, - locals.capPPM9, - locals.maxPPM9 - ), - p2, - STATIC_SWAP_FEE + 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); @@ -852,17 +811,14 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } struct ExactValuesBoundariesLocal { - uint256 w0; - uint256 w1; - uint256 b0; - uint256 b1; + uint256[] w; + uint256[] b; uint256 P; uint32 thr; uint32 cap; uint32 maxp; uint256 D; - uint256 pxIn; - uint256 pxOut; + uint256 oraclePrice; uint256 feeA; uint256 feeB; uint256 feeC; @@ -873,11 +829,9 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo ExactValuesBoundariesLocal memory locals; // 2 tokens, 50/50, equal balances - locals.w0 = 5e17; - locals.w1 = 5e17; - locals.b0 = 1e24; - locals.b1 = 1e24; - locals.P = fee_pairSpotFromBW(locals.b0, locals.w0, locals.b1, locals.w1); + 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% @@ -886,65 +840,43 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.maxp), - fee_ppm9To1e18(locals.thr), - fee_ppm9To1e18(locals.cap), + _convertTo18Decimals(locals.maxp), + _convertTo18Decimals(locals.thr), + _convertTo18Decimals(locals.cap), "fee-boundary" ); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; - PoolSwapParams memory p; + + PoolSwapParams memory p = _createPoolSwapParams(SwapKind.EXACT_IN, locals.b, 0, 1, 0); p.kind = SwapKind.EXACT_IN; // Below threshold - locals.D = fee_ppm9To1e18(locals.thr) - 1; - (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + locals.D = _convertTo18Decimals(locals.thr) - 1; + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); - computeLocals.bIn = locals.b0; - computeLocals.wIn = locals.w0; - computeLocals.bOut = locals.b1; - computeLocals.wOut = locals.w1; - computeLocals.pxIn = locals.pxIn; - computeLocals.pxOut = locals.pxOut; - computeLocals.calcAmountScaled18 = 0; + HyperSurgeHook.PoolDetails memory poolDetails; + poolDetails.numTokens = 2; // ARB lane = locals’ params (since deviation doesn’t increase with calcAmount=0) - computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr; - computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap; - computeLocals.poolDetails.arbMaxSurgeFee9 = locals.maxp; + poolDetails.arbThresholdPercentage9 = locals.thr; + poolDetails.arbCapDeviationPercentage9 = locals.cap; + poolDetails.arbMaxSurgeFee9 = locals.maxp; // Make NOISE lane different - computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; - computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; - computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; + poolDetails.noiseThresholdPercentage9 = locals.thr + 1; + poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; + poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; - (, locals.feeA) = mock.ComputeSurgeFee(computeLocals, p, STATIC_SWAP_FEE); + (, 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 = (fee_ppm9To1e18(locals.thr) + fee_ppm9To1e18(locals.cap)) / 2; - (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + locals.D = (_convertTo18Decimals(locals.thr) + _convertTo18Decimals(locals.cap)) / 2; + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); - computeLocals.bIn = locals.b0; - computeLocals.wIn = locals.w0; - computeLocals.bOut = locals.b1; - computeLocals.wOut = locals.w1; - computeLocals.pxIn = locals.pxIn; - computeLocals.pxOut = locals.pxOut; - computeLocals.calcAmountScaled18 = 0; - - computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr; - computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap; - computeLocals.poolDetails.arbMaxSurgeFee9 = locals.maxp; - - computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; - computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; - computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; - - (, locals.feeB) = mock.ComputeSurgeFee(computeLocals, p, STATIC_SWAP_FEE); + (, locals.feeB) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); uint256 expected = fee_expectedFeeWithParams( locals.P, - locals.pxIn, - locals.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, locals.thr, locals.cap, @@ -954,47 +886,17 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // At cap and above cap - uint256 Dcap = fee_ppm9To1e18(locals.cap); - (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, Dcap); - - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals1; - computeLocals1.bIn = locals.b0; - computeLocals1.wIn = locals.w0; - computeLocals1.bOut = locals.b1; - computeLocals1.wOut = locals.w1; - computeLocals1.pxIn = locals.pxIn; - computeLocals1.pxOut = locals.pxOut; - computeLocals1.calcAmountScaled18 = 0; - computeLocals1.poolDetails.arbThresholdPercentage9 = locals.thr; - computeLocals1.poolDetails.arbCapDeviationPercentage9 = locals.cap; - computeLocals1.poolDetails.arbMaxSurgeFee9 = locals.maxp; - computeLocals1.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; - computeLocals1.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; - computeLocals1.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; - - (, locals.feeC) = mock.ComputeSurgeFee(computeLocals1, p, STATIC_SWAP_FEE); - assertEq(locals.feeC, fee_ppm9To1e18(locals.maxp), "at cap means max fee"); + uint256 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.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, Dbeyond); - - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals2; - computeLocals2.bIn = locals.b0; - computeLocals2.wIn = locals.w0; - computeLocals2.bOut = locals.b1; - computeLocals2.wOut = locals.w1; - computeLocals2.pxIn = locals.pxIn; - computeLocals2.pxOut = locals.pxOut; - computeLocals2.calcAmountScaled18 = 0; - computeLocals2.poolDetails.arbThresholdPercentage9 = locals.thr; - computeLocals2.poolDetails.arbCapDeviationPercentage9 = locals.cap; - computeLocals2.poolDetails.arbMaxSurgeFee9 = locals.maxp; - computeLocals2.poolDetails.noiseThresholdPercentage9 = locals.thr + 1; - computeLocals2.poolDetails.noiseCapDeviationPercentage9 = locals.cap - 1; - computeLocals2.poolDetails.noiseMaxSurgeFee9 = locals.maxp + 1; - - (, locals.feeD) = mock.ComputeSurgeFee(computeLocals2, p, STATIC_SWAP_FEE); - assertEq(locals.feeD, fee_ppm9To1e18(locals.maxp), "above cap means clamped to max fee"); + (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 { @@ -1009,15 +911,14 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 P; uint256 capDev; uint256 D; - uint256 pxIn; - uint256 pxOut; + 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. + // 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, @@ -1040,66 +941,41 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo locals.P = fee_pairSpotFromBW(locals.b[locals.i], locals.w[locals.i], locals.b[locals.j], locals.w[locals.j]); vm.assume(locals.P > 0); - locals.capDev = fee_ppm9To1e18(locals.cap); + locals.capDev = _convertTo18Decimals(locals.cap); locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 2 + 1); - (locals.pxIn, locals.pxOut) = fee_localsForDeviation(locals.P, locals.D); + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.P, locals.D); HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.maxp), - fee_ppm9To1e18(locals.thr), - fee_ppm9To1e18(locals.cap), + _convertTo18Decimals(locals.maxp), + _convertTo18Decimals(locals.thr), + _convertTo18Decimals(locals.cap), "fee-io" ); // EXACT_IN - PoolSwapParams memory pIn; - pIn.kind = SwapKind.EXACT_IN; - - // Build locals with NOISE = (thr/cap/maxp) and ARB deliberately different - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L1; - L1.bIn = locals.b[locals.i]; - L1.wIn = locals.w[locals.i]; - L1.bOut = locals.b[locals.j]; - L1.wOut = locals.w[locals.j]; - L1.pxIn = locals.pxIn; - L1.pxOut = locals.pxOut; - L1.calcAmountScaled18 = 0; + 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 - L1.poolDetails.noiseThresholdPercentage9 = locals.thr; - L1.poolDetails.noiseCapDeviationPercentage9 = locals.cap; - L1.poolDetails.noiseMaxSurgeFee9 = locals.maxp; + poolDetails.noiseThresholdPercentage9 = locals.thr; + poolDetails.noiseCapDeviationPercentage9 = locals.cap; + poolDetails.noiseMaxSurgeFee9 = locals.maxp; // Different ARB lane params so wrong-lane usage wouldn’t accidentally match - L1.poolDetails.arbThresholdPercentage9 = locals.thr + 1; - L1.poolDetails.arbCapDeviationPercentage9 = locals.cap - 1; - L1.poolDetails.arbMaxSurgeFee9 = locals.maxp + 1; + poolDetails.arbThresholdPercentage9 = locals.thr + 1; + poolDetails.arbCapDeviationPercentage9 = locals.cap - 1; + poolDetails.arbMaxSurgeFee9 = locals.maxp + 1; - (, locals.feeIn) = mock.ComputeSurgeFee(L1, pIn, STATIC_SWAP_FEE); + (, locals.feeIn) = mock.ComputeSurgeFee(pIn, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); // EXACT_OUT - PoolSwapParams memory pOut; - pOut.kind = SwapKind.EXACT_OUT; - - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L2; - L2.bIn = locals.b[locals.i]; - L2.wIn = locals.w[locals.i]; - L2.bOut = locals.b[locals.j]; - L2.wOut = locals.w[locals.j]; - L2.pxIn = locals.pxIn; - L2.pxOut = locals.pxOut; - L2.calcAmountScaled18 = 0; - - L2.poolDetails.noiseThresholdPercentage9 = locals.thr; - L2.poolDetails.noiseCapDeviationPercentage9 = locals.cap; - L2.poolDetails.noiseMaxSurgeFee9 = locals.maxp; + PoolSwapParams memory pOut = _createPoolSwapParams(SwapKind.EXACT_OUT, locals.b, locals.i, locals.j, 0); - L2.poolDetails.arbThresholdPercentage9 = locals.thr + 1; - L2.poolDetails.arbCapDeviationPercentage9 = locals.cap - 1; - L2.poolDetails.arbMaxSurgeFee9 = locals.maxp + 1; - - (, locals.feeOut) = mock.ComputeSurgeFee(L2, pOut, STATIC_SWAP_FEE); + (, 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"); } @@ -1121,14 +997,6 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 j = (i + 1) % m; // --- Build base swap params template with those balances --- - PoolSwapParams memory p; - p.balancesScaled18 = new uint256[](m); - for (uint256 k = 0; k < m; ++k) { - p.balancesScaled18[k] = b[k]; - } - p.indexIn = i; - p.indexOut = j; - uint256 bIn = b[i]; uint256 bOut = b[j]; @@ -1142,8 +1010,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo assertLt(safeInAmt, bIn / 10, "safeInAmt too large vs balanceIn"); // < 10% (much stricter in practice) assertLt(safeOutAmt, bOut / 10, "safeOutAmt too large vs balanceOut"); // < 10% - p.kind = SwapKind.EXACT_IN; - p.amountGivenScaled18 = safeInAmt; + 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); @@ -1161,13 +1028,33 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // Diverge NOISE and ARB lane params (authorized admin) vm.startPrank(admin); - hook.setSurgeThresholdPercentage(address(pool), 5_000_000 * 1e9, IHyperSurgeHook.TradeType.NOISE); // 0.5% - hook.setCapDeviationPercentage(address(pool), 400_000_000 * 1e9, IHyperSurgeHook.TradeType.NOISE); // 40% - hook.setMaxSurgeFeePercentage(address(pool), 25_000_000 * 1e9, IHyperSurgeHook.TradeType.NOISE); // 2.5% - - hook.setSurgeThresholdPercentage(address(pool), 1_000_000 * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); // 0.1% - hook.setCapDeviationPercentage(address(pool), 300_000_000 * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); // 30% - hook.setMaxSurgeFeePercentage(address(pool), 50_000_000 * 1e9, IHyperSurgeHook.TradeType.ARBITRAGE); // 5% + 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 @@ -1181,14 +1068,9 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo balances[k] = 1e24 + k; } - PoolSwapParams memory p; - p.amountGivenScaled18 = 1e18; // non-zero trade amount - p.balancesScaled18 = balances; - p.indexIn = 0; - p.indexOut = (m > 1) ? 1 : 0; + 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) - p.kind = SwapKind.EXACT_IN; vm.expectRevert(HyperSpotPricePrecompile.SpotPriceIsZero.selector); hook.onComputeDynamicSwapFeePercentage(p, address(pool), STATIC_SWAP_FEE); @@ -1198,272 +1080,176 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo hook.onComputeDynamicSwapFeePercentage(p, address(pool), STATIC_SWAP_FEE); } - struct DeviationEqualsThreshold { - uint256 staticFee; - uint256 maxFee; - uint32 thr9; - uint32 cap9; - uint32 max9; - uint256 E; - uint256 thr; - uint256 fee; - } - /// 1) deviation == threshold => returns static fee (boundary counted as "inside") function test_cfg_fee_static_at_threshold_usingMockWrapper() public view { - DeviationEqualsThreshold memory locals; - - locals.staticFee = 30e14; // 30 bps = 0.003 * 1e18 - locals.maxFee = 120e14; // 120 bps + uint256 staticFee = 0.3e16; // 0.3% + uint256 maxFee = 1.2e16; // 1.2% // 9 lane params (contract upscales to 18dp) - locals.thr9 = 100_000_000; // 10% - locals.cap9 = 500_000_000; // 50% - locals.max9 = uint32(locals.maxFee / 1e9); - - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; - computeLocals.pxIn = 1e18; - computeLocals.pxOut = 10e18; // external price E = 10 + 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) - computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr9; - computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; - computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.max9; - computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr9; - computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap9; - computeLocals.poolDetails.arbMaxSurgeFee9 = locals.max9; + 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; - locals.E = 10e18; - locals.thr = uint256(locals.thr9) * 1e9; // 18dp + // External price (priceTokenOut / priceTokenIn). + uint256 E = 10e18; - locals.fee = _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.thr); - assertEq(locals.fee, locals.staticFee, "fee must equal static when deviation == threshold"); - } - - struct justAboveThreshold { - uint256 staticFee; - uint256 maxFee; - uint32 thr9; - uint32 cap9; - uint32 max9; - uint256 E; - uint256 thr; - uint256 cap; - uint256 dev; - uint256 span; - uint256 ramp; - uint256 expected; + 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 { - justAboveThreshold memory locals; - - locals.staticFee = 30e14; // 30 bps - locals.maxFee = 120e14; // 120 bps - - locals.thr9 = 100_000_000; // 10% - locals.cap9 = 500_000_000; // 50% - locals.max9 = uint32(locals.maxFee / 1e9); - - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; - computeLocals.pxIn = 1e18; - computeLocals.pxOut = 10e18; - - computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr9; - computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; - computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.max9; - computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr9; - computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap9; - computeLocals.poolDetails.arbMaxSurgeFee9 = locals.max9; + 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; - locals.E = 10e18; - locals.thr = uint256(locals.thr9) * 1e9; - locals.cap = uint256(locals.cap9) * 1e9; - locals.dev = (uint256(locals.thr9) + 1) * 1e9; // smallest 18dp step above threshold - - uint256 fee = _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.dev); + 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) - locals.span = locals.cap - locals.thr; - locals.ramp = ((locals.maxFee - locals.staticFee) * (locals.dev - locals.thr)) / locals.span; - locals.expected = locals.staticFee + locals.ramp; + uint256 span = _convertTo18Decimals(cap9 - thr9); + uint256 ramp = ((maxFee - staticFee) * (deviation - _convertTo18Decimals(thr9))) / span; + uint256 expected = staticFee + ramp; - assertEq(fee, locals.expected, "minimal ramp just above threshold"); - assertGt(fee, locals.staticFee, "fee > static just above threshold"); - assertLt(fee, locals.maxFee, "fee < max when deviation < cap"); - } - - struct MaxEqualsStatic { - uint256 staticFee; - uint256 maxFee; - uint32 thr9; - uint32 cap9; - uint32 max9; - uint256 E; - uint256 thr; - uint256 cap; - uint256 devAtThr; - uint256 devMid; - uint256 devAtCap; - uint256 devBeyond; + 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 { - MaxEqualsStatic memory locals; + uint256 staticFee = 0.45e16; // 0.45% + uint256 maxFee = staticFee; - locals.staticFee = 45e14; // 45 bps - locals.maxFee = locals.staticFee; + uint32 thr9 = 50_000_000; // 5% + uint32 cap9 = 250_000_000; // 25% + uint32 max9 = uint32(maxFee / ONE_SCALED_9); - locals.thr9 = 50_000_000; // 5% - locals.cap9 = 250_000_000; // 25% - locals.max9 = uint32(locals.maxFee / 1e9); - - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals; - computeLocals.pxIn = 1e18; - computeLocals.pxOut = 10e18; - - computeLocals.poolDetails.noiseThresholdPercentage9 = locals.thr9; - computeLocals.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; - computeLocals.poolDetails.noiseMaxSurgeFee9 = locals.max9; - computeLocals.poolDetails.arbThresholdPercentage9 = locals.thr9; - computeLocals.poolDetails.arbCapDeviationPercentage9 = locals.cap9; - computeLocals.poolDetails.arbMaxSurgeFee9 = locals.max9; + 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; - locals.E = 10e18; - locals.thr = uint256(locals.thr9) * 1e9; - locals.cap = uint256(locals.cap9) * 1e9; - - locals.devAtThr = locals.thr; - locals.devMid = locals.thr + (locals.cap - locals.thr) / 2; - locals.devAtCap = locals.cap; - locals.devBeyond = locals.cap + 12345; + uint256 oraclePrice = 10e18; + uint256 thr = _convertTo18Decimals(thr9); + uint256 cap = _convertTo18Decimals(cap9); + assertEq(_feeAtDeviation(p, poolDetails, staticFee, oraclePrice, thr), staticFee, "at thr => static"); assertEq( - _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.devAtThr), - locals.staticFee, - "at thr => static" - ); - assertEq( - _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.devMid), - locals.staticFee, + _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(computeLocals, p, locals.staticFee, locals.E, locals.devAtCap), - locals.staticFee, - "at cap => static" - ); - assertEq( - _feeAtDeviation(computeLocals, p, locals.staticFee, locals.E, locals.devBeyond), - locals.staticFee, + _feeAtDeviation(p, poolDetails, staticFee, oraclePrice, cap + 12345), + staticFee, "beyond cap => static" ); } - struct MaxBelowStatic { - uint256 staticFee; - uint256 maxFee; - uint32 thr9; - uint32 cap9; - uint32 max9; - uint256 E; - uint256 thr; - uint256 cap; - uint256 devMid; - uint256 feeMid; - uint256 span; - uint256 ramp; - uint256 expected; - } - function test_fee_misconfig_maxBelowStatic_usingMockWrapper() public { - MaxBelowStatic memory locals; - // Misconfig: max < static - locals.staticFee = 80e14; // 80 bps (1e18 scale) - locals.maxFee = 20e14; // 20 bps (1e18 scale) -> lower than static - locals.thr9 = 100_000_000; // 10% in 1e9 - locals.cap9 = 300_000_000; // 30% in 1e9 - locals.max9 = uint32(locals.maxFee / 1e9); + 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), - fee_ppm9To1e18(locals.max9), - fee_ppm9To1e18(locals.thr9), - fee_ppm9To1e18(locals.cap9), + _convertTo18Decimals(max9), + _convertTo18Decimals(thr9), + _convertTo18Decimals(cap9), "misconfig-maxBelowStatic" ); - // Base inputs used for both sub-tests - locals.E = 10e18; // external price - locals.thr = uint256(locals.thr9) * 1e9; // 18dp threshold - locals.cap = uint256(locals.cap9) * 1e9; // 18dp cap - - HyperSurgeHookMock.ComputeSurgeFeeLocals memory base; - base.pxIn = 1e18; - base.pxOut = locals.E; + uint256 oraclePrice = 10e18; // Set BOTH lanes to the same (misconfigured) params so lane choice doesn't matter here. - base.poolDetails.noiseThresholdPercentage9 = locals.thr9; - base.poolDetails.noiseCapDeviationPercentage9 = locals.cap9; - base.poolDetails.noiseMaxSurgeFee9 = locals.max9; - base.poolDetails.arbThresholdPercentage9 = locals.thr9; - base.poolDetails.arbCapDeviationPercentage9 = locals.cap9; - base.poolDetails.arbMaxSurgeFee9 = locals.max9; - - PoolSwapParams memory p; - p.kind = SwapKind.EXACT_IN; - p.amountGivenScaled18 = 0; // keep balances-based price exact - p.balancesScaled18 = new uint256[](2); - p.balancesScaled18[0] = 1e18; - p.balancesScaled18[1] = locals.E; - - // Reused working struct - HyperSurgeHookMock.ComputeSurgeFeeLocals memory T; + 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 dev = locals.cap + 999; // strictly beyond cap - uint256 P = locals.E + (locals.E * dev) / 1e18; // P = E * (1 + dev) - T = base; - T.wIn = 1e18; - T.wOut = 1e18; - T.bIn = 1e18; - T.bOut = P; - T.calcAmountScaled18 = 0; + 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(T, p, locals.staticFee); + mock.ComputeSurgeFee(p, poolDetails, staticFee, FIFTY_FIFTY, 0, oraclePrice); // ---------- (b) thr < dev < cap -> revert (underflow in mock ramp) ---------- - dev = locals.thr + (locals.cap - locals.thr) / 3; // strictly between thr & cap - P = locals.E + (locals.E * dev) / 1e18; - T = base; - T.wIn = 1e18; - T.wOut = 1e18; - T.bIn = 1e18; - T.bOut = P; - T.calcAmountScaled18 = 0; + 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(T, p, locals.staticFee); + mock.ComputeSurgeFee(p, poolDetails, staticFee, FIFTY_FIFTY, 0, oraclePrice); } struct OutsideDynamicAfterLocals { - uint256 E; uint32 noiseThr9; uint32 noiseCap9; uint32 noiseMax9; @@ -1475,8 +1261,6 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 deviationBefore; uint256 price_before; uint256 price_after; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 expected; uint256 dyn; } @@ -1492,47 +1276,48 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo OutsideDynamicAfterLocals memory locals; // --- Fuzz + bounds --- - locals.E = bound(eSeed, 1e16, 1e24); // pxOut + uint256 oraclePrice = bound(eSeed, 1e16, 1e24); locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); - locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); - locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.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 = uint256(locals.noiseThr9) * 1e9; - locals.cap = uint256(locals.noiseCap9) * 1e9; + 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 = locals.E - (locals.E * locals.deviationBefore) / 1e18; + locals.price_before = oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); // Build compute locals + swap that worsens deviation (EXACT_IN; calc=0 → P decreases further) - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.price_before; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - - locals.p.kind = SwapKind.EXACT_IN; + 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) - locals.p.amountGivenScaled18 = bound(uint256(amtSeed), 1e9, 5e17); // [1e9, 0.5e18] + 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 * 1e18) / (1e18 + locals.p.amountGivenScaled18); + locals.price_after = locals.price_before.divDown(FixedPoint.ONE + p.amountGivenScaled18); locals.expected = fee_expectedFeeWithParams( locals.price_after, - locals.comp.pxIn, - locals.comp.pxOut, + oraclePrice, STATIC_SWAP_FEE, locals.noiseThr9, locals.noiseCap9, @@ -1541,19 +1326,20 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "logic-1" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, 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 { - uint256 E; uint32 arbThr9; uint32 arbCap9; uint32 arbMax9; @@ -1566,8 +1352,6 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 price_before; uint256 price_after; uint256 xMax; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 expected; uint256 dyn; } @@ -1582,56 +1366,57 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo BetterStillOutsideLocals memory locals; // --- Fuzz + bounds --- - locals.E = bound(eSeed, 1e16, 1e24); + uint256 oraclePrice = bound(eSeed, 1e16, 1e24); locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); - locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); - locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + 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 = uint256(locals.arbThr9) * 1e9; - locals.cap = uint256(locals.arbCap9) * 1e9; + 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 = locals.E + (locals.E * locals.deviationBefore) / 1e18; + 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(locals.E * (1e18 + locals.thr) != 0); // defensive - uint256 denom = (locals.E * (1e18 + locals.thr)) / 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 * 1e18) / denom; - vm.assume(ratio > 1e18); // Ensure room to remain outside - locals.xMax = ratio - 1e18; + 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 - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.price_before; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = bound(uint256(amtSeed), 1, locals.xMax == 0 ? 1 : locals.xMax); + 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, - locals.comp.pxIn, - locals.comp.pxOut, + oraclePrice, STATIC_SWAP_FEE, locals.arbThr9, locals.arbCap9, @@ -1640,18 +1425,19 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "logic-2" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, oraclePrice); // Still outside afterward (sanity) - locals.price_after = (locals.price_before * 1e18) / (1e18 + locals.p.amountGivenScaled18); - uint256 deviationAfter = (( - locals.price_after > locals.E ? (locals.price_after - locals.E) : (locals.E - locals.price_after) - ) * 1e18) / locals.E; + 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"); @@ -1659,7 +1445,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } struct NoiseWorsensInsideButStaysInsideLocals { - uint256 E; + uint256 oraclePrice; uint32 noiseThr9; uint32 noiseCap9; uint32 noiseMax9; @@ -1671,9 +1457,10 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 price_before; uint256 price_after; uint256 xMax; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 fee; + uint256 R1e18; + uint256 denom; + uint256 q; } /// 3) Noise: starts inside threshold, worsens but stays inside → NOISE lane, **base (static)** fee. @@ -1686,45 +1473,40 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo ) public { NoiseWorsensInsideButStaysInsideLocals memory locals; - locals.E = bound(eSeed, 1e16, 1e24); - locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 1_000_000_000 - 1)); // (0,1) - locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); - locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.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 = uint256(locals.noiseThr9) * 1e9; + locals.thr = _convertTo18Decimals(locals.noiseThr9); locals.deviationBefore = locals.thr / 4 + 1; - locals.price_before = locals.E - (locals.E * locals.deviationBefore) / 1e18; + locals.price_before = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); - uint256 R1e18 = (locals.price_before * 1e18) / locals.E; - uint256 denom = 1e18 - locals.thr; - uint256 q = (R1e18 * 1e18) / denom; - locals.xMax = q > 1e18 ? (q - 1e18) : 0; + 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; } - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.price_before; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - - locals.p.kind = SwapKind.EXACT_IN; + 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 = 1e9; // 1e-9 in t; safely above Q18 rounding noise + uint256 lo = ONE_SCALED_9; // 1e-9 in t; safely above Q18 rounding noise uint256 hi = locals.xMax; if (hi < lo) { lo = 1; @@ -1732,22 +1514,31 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo if (hi < lo) { hi = lo; } // clamp - locals.p.amountGivenScaled18 = bound(uint256(amtSeed), lo, hi); + + PoolSwapParams memory p = _createPoolSwapParams( + SwapKind.EXACT_IN, + balancesScaled18, + 0, + 1, + bound(uint256(amtSeed), lo, hi) + ); HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "logic-3" ); - (, locals.fee) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, 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 * 1e18) / (1e18 + locals.p.amountGivenScaled18); - uint256 deviationAfter = (( - locals.price_after > locals.E ? (locals.price_after - locals.E) : (locals.E - locals.price_after) - ) * 1e18) / locals.E; + 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 @@ -1755,7 +1546,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } struct NoiseCrossesPriceWorsensLocals { - uint256 E; + uint256 oraclePrice; uint32 noiseThr9; uint32 noiseCap9; uint32 noiseMax9; @@ -1779,8 +1570,6 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 lo; // lower bound for x uint256 hi; // upper bound for x uint256 deviationAfter; // absolute deviation after - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 expected; uint256 dyn; } @@ -1795,41 +1584,36 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo NoiseCrossesPriceWorsensLocals memory locals; // --- Fuzz + bounds --- - locals.E = bound(eSeed, 1e16, 1e24); + 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, 1_000_000_000)); // (thr, 1] - locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + 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 = uint256(locals.noiseThr9) * 1e9; - locals.cap = uint256(locals.noiseCap9) * 1e9; + 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.E + (locals.E * locals.deviationBefore) / 1e18; + locals.price_before = locals.oraclePrice.mulDown(FixedPoint.ONE + locals.deviationBefore); // Build compute locals - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.price_before; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - - locals.p.kind = SwapKind.EXACT_IN; + 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) @@ -1837,8 +1621,8 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // 1 - R/(1+t) > Db means (1 - Db)(1 + t) > 1 + Db means t > 2Db/(1 - Db) locals.tCross = locals.deviationBefore; // tWorse = ceil( (2*Db) / (1 - Db) ) in Q18 - locals.num = (2 * locals.deviationBefore) * 1e18; // Q36 - locals.den = 1e18 - locals.deviationBefore; + locals.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; @@ -1859,22 +1643,26 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo locals.hi = locals.lo; } // clamp on overflow - locals.x = bound(uint256(amtSeed), locals.lo, locals.hi); - locals.p.amountGivenScaled18 = locals.x; + 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 * 1e18) / (1e18 + locals.x); + locals.price_after = locals.price_before.divDown(FixedPoint.ONE + p.amountGivenScaled18); // Sanity: crossed and worsened absolute deviation - locals.deviationBefore = ((locals.price_before - locals.E) * 1e18) / locals.E; - locals.deviationAfter = ((locals.E - locals.price_after) * 1e18) / locals.E; - require(locals.price_after < locals.E, "must cross below E"); + 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.comp.pxIn, - locals.comp.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, locals.noiseThr9, locals.noiseCap9, @@ -1883,12 +1671,12 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "logic-4" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); assertEq( locals.dyn, @@ -1899,7 +1687,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } struct OutsideToInsideDynamicBefore { - uint256 E; + uint256 oraclePrice; uint32 arbThr9; uint32 arbCap9; uint32 arbMax9; @@ -1914,11 +1702,14 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 R1e18; // R in 1e18 scale: R = price_before / E uint256 xLower; // min x to get price_after less than or equal to E*(1+thr) uint256 xUpper; // max x to keep price_after greater than or equal to E*(1−thr) - uint256 x; // chosen amountGivenScaled18 inside [xLower, xUpper] - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 expected; uint256 dyn; + 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). @@ -1932,40 +1723,40 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo OutsideToInsideDynamicBefore memory locals; // --- Fuzz + bounds --- - locals.E = bound(eSeed, 1e16, 1e24); + 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, 1_000_000_000)); - locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + 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 = uint256(locals.arbThr9) * 1e9; - locals.cap = uint256(locals.arbCap9) * 1e9; + 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.E + (locals.E * locals.deviationBefore) / 1e18; // price_before = E * (1 + deviationBefore) - locals.R1e18 = (locals.price_before * 1e18) / locals.E; // R = 1e18 + deviationBefore + 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 - uint256 denomPlus = 1e18 + locals.thr; - uint256 numPlus = locals.R1e18 * 1e18; // Q36 - uint256 qPlus = (numPlus + denomPlus - 1) / denomPlus; // ceilDiv to Q18 - locals.xLower = qPlus > 1e18 ? (qPlus - 1e18) : 0; + 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 - uint256 denomMinus = 1e18 - locals.thr; // > 0 by bound - uint256 numMinus = locals.R1e18 * 1e18; // Q36 - uint256 qMinus = numMinus / denomMinus; // floorDiv to Q18 - locals.xUpper = qMinus > 1e18 ? (qMinus - 1e18) : 0; + 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; @@ -1977,31 +1768,30 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo if (lo == 0) lo = 1; if (hi < lo) hi = lo; - locals.x = bound(uint256(amtSeed), lo, hi); - // Build compute locals - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.price_before; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = locals.x; + 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.comp.pxIn, - locals.comp.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, locals.arbThr9, locals.arbCap9, @@ -2010,18 +1800,20 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "logic-5" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, 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 * 1e18) / (1e18 + locals.p.amountGivenScaled18); - uint256 deviationAfter = (( - locals.price_after > locals.E ? (locals.price_after - locals.E) : (locals.E - locals.price_after) - ) * 1e18) / locals.E; + 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( @@ -2033,7 +1825,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } struct InsideToOutsideDynamicAfterLocals { - uint256 E; + uint256 oraclePrice; uint32 noiseThr9; uint32 noiseCap9; uint32 noiseMax9; @@ -2047,15 +1839,12 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 priceAfter; uint256 R1e18; // = priceBefore/E (Q18) uint256 tLower; // min t to make priceAfter/E less than or equal to 1 - thr (Q18) - uint256 x; // = t * 1e18 (amount in) uint256 num; // numerator for tLower calculation uint256 den; // denominator for tLower calculation uint256 q; // intermediate value for tLower calculation uint256 eps; // epsilon for x calculation uint256 lo; // lower bound for x uint256 hi; // upper bound for x - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 expected; uint256 dyn; } @@ -2071,64 +1860,66 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo InsideToOutsideDynamicAfterLocals memory locals; // Lane params (NOISE fuzzed, ARB fixed and different) - locals.E = bound(eSeed, 1e16, 1e24); + locals.oraclePrice = bound(eSeed, 1e16, 1e24); locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); - locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); - locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.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 = uint256(locals.noiseThr9) * 1e9; - locals.cap = uint256(locals.noiseCap9) * 1e9; + 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.E - (locals.E * locals.deviationBefore) / 1e18; // P/E = 1 - deviationBefore - locals.R1e18 = (locals.priceBefore * 1e18) / locals.E; + 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 * 1e18; // Q36 - locals.den = 1e18 - locals.thr; + 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 > 1e18 ? (locals.q - 1e18) : 0; + 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 - locals.x = bound(uint256(amtSeed), locals.lo, locals.hi); // Build locals - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.priceBefore; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = locals.x; + 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 * 1e18) / (1e18 + locals.x); - uint256 deviationAfter = (( - locals.priceAfter > locals.E ? (locals.priceAfter - locals.E) : (locals.E - locals.priceAfter) - ) * 1e18) / locals.E; + 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.comp.pxIn, - locals.comp.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, locals.noiseThr9, locals.noiseCap9, @@ -2137,19 +1928,19 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "lane-inside2outside" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, 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 E; + uint256 oraclePrice; uint32 arbThr9; uint32 arbCap9; uint32 arbMax9; @@ -2164,14 +1955,17 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 R1e18; uint256 tLower; uint256 tUpper; - uint256 x; uint256 epsT; uint256 lo; uint256 hi; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; 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) @@ -2184,44 +1978,44 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo ) public { OutsideToThresholdDynamicBeforeLocals memory locals; - locals.E = bound(eSeed, 1e16, 1e24); + locals.oraclePrice = bound(eSeed, 1e16, 1e24); locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); - locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); - locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + 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 = uint256(locals.arbThr9) * 1e9; - locals.cap = uint256(locals.arbCap9) * 1e9; + 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.E + (locals.E * locals.deviationBefore) / 1e18; + 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 * 1e18; - locals.R1e18 = (numR + locals.E - 1) / locals.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. - uint256 denomPlus = 1e18 + locals.thr; - uint256 numPlus = locals.R1e18 * 1e18; // Q36 - uint256 qPlus = (numPlus + denomPlus - 1) / denomPlus; // ceilDiv → Q18 - locals.tLower = qPlus > 1e18 ? (qPlus - 1e18) : 0; + 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. - uint256 denomMinus = 1e18 - locals.thr; - uint256 numMinus = locals.R1e18 * 1e18; // Q36 - uint256 qMinus = numMinus / denomMinus; // floorDiv → Q18 - locals.tUpper = qMinus > 1e18 ? (qMinus - 1e18) : 0; + 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. @@ -2238,38 +2032,39 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo if (locals.hi < locals.lo) locals.hi = locals.lo; } - locals.x = bound(uint256(amtSeed), locals.lo, locals.hi); - // Build locals - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.priceBefore; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = locals.x; + 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 * 1e18) / (1e18 + locals.p.amountGivenScaled18); - uint256 dAfter = (( - locals.priceAfter > locals.E ? (locals.priceAfter - locals.E) : (locals.E - locals.priceAfter) - ) * 1e18) / locals.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.thr, "end should be at/inside threshold"); // Expected (ARB) uses BEFORE even if end is at/inside threshold locals.expected = fee_expectedFeeWithParams( locals.priceBefore, - locals.comp.pxIn, - locals.comp.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, locals.arbThr9, locals.arbCap9, @@ -2278,12 +2073,12 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "lane-out2thr" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, locals.dyn) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); assertEq( locals.dyn, @@ -2294,7 +2089,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } struct ArbNoMoveOutsideDynamicLocals { - uint256 E; + uint256 oraclePrice; uint32 arbThr9; uint32 arbCap9; uint32 arbMax9; @@ -2305,8 +2100,6 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 cap; uint256 deviationBefore; uint256 priceBefore; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 expected; uint256 dyn; } @@ -2319,44 +2112,39 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo ) public { ArbNoMoveOutsideDynamicLocals memory locals; - locals.E = bound(eSeed, 1e16, 1e24); + locals.oraclePrice = bound(eSeed, 1e16, 1e24); locals.arbThr9 = uint32(bound(arbThrSeed, 1, 900_000_000 - 1)); - locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); - locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + 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 = uint256(locals.arbThr9) * 1e9; - locals.cap = uint256(locals.arbCap9) * 1e9; + 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.E + (locals.E * locals.deviationBefore) / 1e18; + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE + locals.deviationBefore); // No movement: amount = 0, so deviationAfter == deviationBefore → ARB path - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.priceBefore; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = 0; + 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.comp.pxIn, - locals.comp.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, locals.arbThr9, locals.arbCap9, @@ -2365,19 +2153,19 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "lane-nomove-outside" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, 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 E; + uint256 oraclePrice; uint32 arbThr9; uint32 arbCap9; uint32 arbMax9; @@ -2388,8 +2176,6 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 deviationBefore; uint256 priceBefore; uint256 fee; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; } /// [LANE] No movement, inside: ARB path, but STATIC fee (since BEFORE less than or equal to thr) @@ -2401,46 +2187,42 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo ) public { ArbNoMoveInsideLocals memory locals; - locals.E = bound(eSeed, 1e16, 1e24); - locals.arbThr9 = uint32(bound(arbThrSeed, 1, 1_000_000_000 - 1)); - locals.arbCap9 = uint32(bound(arbCapSeed, locals.arbThr9 + 1, 1_000_000_000)); - locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.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 = uint256(locals.arbThr9) * 1e9; + locals.thr = _convertTo18Decimals(locals.arbThr9); // Start BELOW, inside locals.deviationBefore = (locals.thr / 3) + 1; // strictly inside - locals.priceBefore = locals.E - (locals.E * locals.deviationBefore) / 1e18; + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); // No movement: deviationAfter == deviationBefore → ARB branch, but less than or equal to thr ⇒ static - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.priceBefore; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = uint32(locals.arbCap9); - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = 0; + 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), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "lane-nomove-inside" ); - (, locals.fee) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, locals.fee) = mock.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); assertEq( locals.fee, @@ -2450,7 +2232,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } struct NoiseCrossesPriceWorsensDymanicLocals { - uint256 E; + uint256 oraclePrice; uint32 noiseThr9; uint32 noiseCap9; uint32 noiseMax9; @@ -2473,8 +2255,6 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 lo; uint256 hi; uint256 deviationAfter; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; uint256 expected; uint256 dyn; } @@ -2493,58 +2273,60 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo NoiseCrossesPriceWorsensDymanicLocals memory locals; // External price (pxOut/pxIn -> E); keep as in all other tests - locals.E = bound(eSeed, 1e16, 1e24); + 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, 1_000_000_000)); - locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + 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 = uint256(locals.noiseThr9) * 1e9; - locals.cap = uint256(locals.noiseCap9) * 1e9; + 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.E - (locals.E * locals.deviationBefore) / 1e18; + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.deviationBefore); // Build compute locals with the standard orientation (pxIn=1e18, pxOut=E) - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.priceBefore; - locals.comp.pxIn = 1e18; // keep the usual frame - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; + 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; - // EXACT_IN reduces P further → deviation worsens from the BELOW side (NOISE lane) - locals.p.kind = SwapKind.EXACT_IN; // ensure a measurable worsening but no overflow; avoid 1-wei knife edges - uint256 lo = 1e9; - uint256 hi = 5e17; - locals.p.amountGivenScaled18 = bound(uint256(amtSeed), lo, hi); + 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 * 1e18) / (1e18 + locals.p.amountGivenScaled18); + locals.priceAfter = locals.priceBefore.divDown(FixedPoint.ONE + p.amountGivenScaled18); // Sanity: still BELOW E and deviation increased - uint256 dBefore = ((locals.E - locals.priceBefore) * 1e18) / locals.E; - uint256 dAfter = ((locals.E - locals.priceAfter) * 1e18) / locals.E; + 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.comp.pxIn, - locals.comp.pxOut, + locals.oraclePrice, STATIC_SWAP_FEE, locals.noiseThr9, locals.noiseCap9, @@ -2553,19 +2335,19 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo HyperSurgeHookMock mock = new HyperSurgeHookMock( IVault(vault), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "lane-below-worsen" ); - (, locals.dyn) = mock.ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + (, 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 E; + uint256 oraclePrice; uint32 arbThr9; uint32 arbCap9; uint32 arbMax9; @@ -2579,11 +2361,15 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 priceAfter; uint256 tLower; uint256 tUpperNoCross; - uint256 x; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; 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) @@ -2599,107 +2385,109 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo BoundArbBeforeClampToMaxLocals memory locals; // External price - locals.E = bound(eSeed, 1e16, 1e24); + 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, 1_000_000_000 - 1)); // (thr, 1) - locals.arbMax9 = uint32(bound(arbMaxSeed, uint32(STATIC_SWAP_FEE / 1e9) + 1, 1_000_000_000)); + 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 = uint256(locals.arbThr9) * 1e9; - locals.cap = uint256(locals.arbCap9) * 1e9; - assertLt(locals.cap, 1e18, "cap must be < 100%"); + 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 - uint256 margin = (1e18 - locals.cap) / 16; - if (margin == 0) { - margin = 1; + locals.margin = (FixedPoint.ONE - locals.cap) / 16; + if (locals.margin == 0) { + locals.margin = 1; } - locals.Db = locals.cap + margin; - if (locals.Db >= 1e18) { - locals.Db = 1e18 - 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.E + (locals.E * locals.Db) / 1e18; + 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). - uint256 num = (locals.Db - locals.cap) * 1e18; // Q36 (Db > cap guaranteed) - uint256 den = 1e18 + locals.cap; - uint256 q = (num + den - 1) / den; // ceilDiv → Q18 - locals.tLower = q; + 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. - uint256 epsCross = 1; // one Q18 unit - locals.tUpperNoCross = (locals.Db > epsCross) ? (locals.Db - epsCross) : 0; + locals.epsCross = 1; // one Q18 unit + locals.tUpperNoCross = (locals.Db > locals.epsCross) ? (locals.Db - locals.epsCross) : 0; - uint256 lo = (locals.tLower == 0 ? 1 : locals.tLower); - uint256 hi = locals.tUpperNoCross; + locals.lo = (locals.tLower == 0 ? 1 : locals.tLower); + locals.hi = locals.tUpperNoCross; - if (hi < lo) { - hi = lo; + if (locals.hi < locals.lo) { + locals.hi = locals.lo; } - locals.x = bound(uint256(amtSeed), lo, hi); - - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.priceBefore; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = locals.x; + + 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 * 1e18) / (1e18 + locals.x); - uint256 dAfter = (( - locals.priceAfter > locals.E ? (locals.priceAfter - locals.E) : (locals.E - locals.priceAfter) - ) * 1e18) / locals.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), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "arb-before-cap" - ).ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + ).ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, FIFTY_FIFTY, 0, locals.oraclePrice); locals.expected = fee_expectedFeeWithParams( locals.priceBefore, - locals.comp.pxIn, - locals.comp.pxOut, + 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, fee_ppm9To1e18(locals.arbMax9), "ARB fee must equal arbMax"); + assertEq(locals.fee, _convertTo18Decimals(locals.arbMax9), "ARB fee must equal arbMax"); } struct BoundNoiseExactThresholdLocals { - uint256 E; + uint256 oraclePrice; uint32 noiseThr9; uint32 noiseCap9; uint32 noiseMax9; @@ -2711,10 +2499,13 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 priceBefore; uint256 priceAfter; uint256 tEdge; - uint256 x; uint256 fee; - HyperSurgeHookMock.ComputeSurgeFeeLocals comp; - PoolSwapParams p; + uint256 num; + uint256 den; + uint256 epsT; + uint256 lo; + uint256 hi; + uint256 margin; } function testFuzz_bound_noise_after_at_threshold_static( @@ -2726,60 +2517,63 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo ) public { BoundNoiseExactThresholdLocals memory locals; - locals.E = bound(eSeed, 1e16, 1e24); + locals.oraclePrice = bound(eSeed, 1e16, 1e24); locals.noiseThr9 = uint32(bound(noiseThrSeed, 1, 900_000_000 - 1)); - locals.noiseCap9 = uint32(bound(noiseCapSeed, locals.noiseThr9 + 1, 1_000_000_000)); - locals.noiseMax9 = uint32(bound(noiseMaxSeed, uint32(STATIC_SWAP_FEE / 1e9), 1_000_000_000)); + locals.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 = uint256(locals.noiseThr9) * 1e9; + locals.thr = _convertTo18Decimals(locals.noiseThr9); locals.Db = locals.thr / 4 + 1; - locals.priceBefore = locals.E - (locals.E * locals.Db) / 1e18; + locals.priceBefore = locals.oraclePrice.mulDown(FixedPoint.ONE - locals.Db); - uint256 num = (locals.thr - locals.Db) * 1e18; - uint256 den = 1e18 - locals.thr; - locals.tEdge = den == 0 ? 0 : (num / den); + locals.num = (locals.thr - locals.Db) * FixedPoint.ONE; + locals.den = FixedPoint.ONE - locals.thr; + locals.tEdge = locals.den == 0 ? 0 : (locals.num / locals.den); - uint256 epsT = 1e6; - uint256 lo = (locals.tEdge > epsT) ? (locals.tEdge - epsT) : 1; - uint256 hi = locals.tEdge; - if (hi < lo) { - hi = lo; + 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; } - locals.x = bound(uint256(amtSeed), lo, hi); - locals.comp.wIn = 1e18; - locals.comp.wOut = 1e18; - locals.comp.bIn = 1e18; - locals.comp.bOut = locals.priceBefore; - locals.comp.pxIn = 1e18; - locals.comp.pxOut = locals.E; - locals.comp.calcAmountScaled18 = 0; - locals.comp.poolDetails.noiseThresholdPercentage9 = locals.noiseThr9; - locals.comp.poolDetails.noiseCapDeviationPercentage9 = locals.noiseCap9; - locals.comp.poolDetails.noiseMaxSurgeFee9 = locals.noiseMax9; - locals.comp.poolDetails.arbThresholdPercentage9 = locals.arbThr9; - locals.comp.poolDetails.arbCapDeviationPercentage9 = locals.arbCap9; - locals.comp.poolDetails.arbMaxSurgeFee9 = locals.arbMax9; - locals.p.kind = SwapKind.EXACT_IN; - locals.p.amountGivenScaled18 = locals.x; - locals.priceAfter = (locals.priceBefore * 1e18) / (1e18 + locals.p.amountGivenScaled18); - - uint256 dBefore = ((locals.E - locals.priceBefore) * 1e18) / locals.E; - uint256 dAfter = ((locals.E - locals.priceAfter) * 1e18) / locals.E; + 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), - fee_ppm9To1e18(locals.arbMax9), - fee_ppm9To1e18(locals.arbThr9), - fee_ppm9To1e18(locals.arbCap9), + _convertTo18Decimals(locals.arbMax9), + _convertTo18Decimals(locals.arbThr9), + _convertTo18Decimals(locals.arbCap9), "noise-exact-thr" - ).ComputeSurgeFee(locals.comp, locals.p, STATIC_SWAP_FEE); + ).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)"); } @@ -2816,7 +2610,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // 3) Force extPx == 0 with NON-ZERO raws: // extPx = floor((pxOut*1e18)/pxIn) = floor((rawOut*1e18)/rawIn) // => choose rawOut=1 and rawIn > 1e18 (fits in uint64), so extPx == 0 - rawInHuge = uint64(bound(uint256(rawInHuge), 1e18 + 1, type(uint64).max)); + 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)); @@ -2828,8 +2622,8 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo // 4) Build params (all 7 fields) uint256[] memory balances = new uint256[](2); - balances[0] = 1e18; - balances[1] = 1e18; + balances[0] = FixedPoint.ONE; + balances[1] = FixedPoint.ONE; SwapKind kind = givenIn ? SwapKind.EXACT_IN : SwapKind.EXACT_OUT; @@ -2857,8 +2651,8 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 amountGiven = 5e15; uint256[] memory balances = new uint256[](2); - balances[0] = 1e18; - balances[1] = 1e18; + balances[0] = FixedPoint.ONE; + balances[1] = FixedPoint.ONE; TokenConfig[] memory cfg = new TokenConfig[](2); LiquidityManagement memory lm; @@ -2906,8 +2700,8 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo uint256 amountGiven = 5e15; uint256[] memory balances = new uint256[](2); - balances[0] = 1e18; - balances[1] = 1e18; + balances[0] = FixedPoint.ONE; + balances[1] = FixedPoint.ONE; TokenConfig[] memory cfg = new TokenConfig[](2); LiquidityManagement memory lm; @@ -2981,7 +2775,7 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo weights = new uint256[](tokens.length); for (uint256 i = 0; i < tokens.length; i++) { - weights[i] = 1e18 / tokens.length; // Equal weights + weights[i] = FixedPoint.ONE / tokens.length; // Equal weights } } @@ -3045,77 +2839,62 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } function _feeAtDeviation( - HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals, 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 * deviation18) / 1e18; + uint256 P = extPxE18 + extPxE18.mulDown(deviation18); // Make poolPx = P using simple weights/balances: // poolPx = (bOut * wIn) / (bIn * wOut) - computeLocals.wIn = 1e18; - computeLocals.wOut = 1e18; - computeLocals.bIn = 1e18; - computeLocals.bOut = P; + p.balancesScaled18 = [uint256(FixedPoint.ONE), uint256(P)].toMemoryArray(); - // Keep deltas zero so poolPx == poolPxBefore (no lane flip due to swap) - computeLocals.calcAmountScaled18 = 0; - - (bool ok, uint256 fee) = hook.ComputeSurgeFee(computeLocals, p, staticFee); + // 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_mulDown(uint256 a, uint256 b) internal pure returns (uint256) { - return (a * b) / FEE_ONE; - } - - function fee_divDown(uint256 a, uint256 b) internal pure returns (uint256) { - return (a * FEE_ONE) / b; - } - function fee_relAbsDiff(uint256 a, uint256 b) internal pure returns (uint256) { - return a > b ? fee_divDown(a - b, b) : fee_divDown(b - a, b); + 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) { - uint256 num = fee_mulDown(bOut, wIn); - uint256 den = fee_mulDown(bIn, wOut); - return den == 0 ? 0 : fee_divDown(num, den); + 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 <= FEE_ONE, "min too big"); + 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); + r[i] = 1 + (uint256(keccak256(abi.encode(seed, i))) % ONE_SCALED_9); sumR += r[i]; } } uint256 base = uint256(n) * WEIGHT_MIN; - uint256 rem = FEE_ONE - base; + 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 != FEE_ONE) { - if (acc < FEE_ONE) w[0] += (FEE_ONE - acc); + if (acc != FixedPoint.ONE) { + if (acc < FixedPoint.ONE) w[0] += (FixedPoint.ONE - acc); else { - uint256 over = acc - FEE_ONE; + uint256 over = acc - FixedPoint.ONE; w[0] = w[0] > over + WEIGHT_MIN ? (w[0] - over) : WEIGHT_MIN; } } @@ -3132,43 +2911,40 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo } // Choose deviation D, then set external px so that extPx = P / (1 + D) - function fee_localsForDeviation(uint256 P, uint256 D) internal pure returns (uint256 pxIn, uint256 pxOut) { - pxIn = FEE_ONE; - pxOut = fee_divDown(P, FEE_ONE + D); + function fee_computeOraclePriceForDeviation(uint256 P, uint256 deviation) internal pure returns (uint256) { + return P.divDown(FixedPoint.ONE + deviation); } - function fee_ppm9To1e18(uint32 v) internal pure returns (uint256) { - return uint256(v) * 1e9; + 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 pxIn, - uint256 pxOut, + uint256 oraclePrice, uint256 staticSwapFee, uint32 thresholdPPM9, uint32 capDevPPM9, uint32 maxFeePPM9 ) internal pure returns (uint256) { - uint256 extPx = fee_divDown(pxOut, pxIn); - uint256 deviation = fee_relAbsDiff(poolPx, extPx); + uint256 deviation = fee_relAbsDiff(poolPx, oraclePrice); - uint256 threshold = fee_ppm9To1e18(thresholdPPM9); - uint256 capDev = fee_ppm9To1e18(capDevPPM9); - uint256 maxPct = fee_ppm9To1e18(maxFeePPM9); + uint256 threshold = _convertTo18Decimals(thresholdPPM9); + uint256 capDev = _convertTo18Decimals(capDevPPM9); + uint256 maxPct = _convertTo18Decimals(maxFeePPM9); if (deviation <= threshold) { return staticSwapFee; } uint256 span = capDev - threshold; - uint256 norm = fee_divDown(deviation - threshold, span); - if (norm > FEE_ONE) { - norm = FEE_ONE; + uint256 norm = (deviation - threshold).divDown(span); + if (norm > FixedPoint.ONE) { + norm = FixedPoint.ONE; } - uint256 incr = fee_mulDown(maxPct - staticSwapFee, norm); + uint256 incr = (maxPct - staticSwapFee).mulDown(norm); uint256 fee = staticSwapFee + incr; if (fee > maxPct) { fee = maxPct; @@ -3176,29 +2952,36 @@ contract HyperSurgeFeeTest is BaseVaultTest, HyperSurgeHookDeployer, WeightedPoo return fee; } - function fee_makeLocals( - uint256 bIn, - uint256 wIn, - uint256 bOut, - uint256 wOut, - uint256 pxIn, - uint256 pxOut, - uint32 thrPPM9, - uint32 capPPM9, - uint32 maxPPM9 - ) internal pure returns (HyperSurgeHookMock.ComputeSurgeFeeLocals memory computeLocals) { - computeLocals.bIn = bIn; - computeLocals.wIn = wIn; - computeLocals.bOut = bOut; - computeLocals.wOut = wOut; - computeLocals.pxIn = pxIn; - computeLocals.pxOut = pxOut; - computeLocals.poolDetails.noiseThresholdPercentage9 = thrPPM9; - computeLocals.poolDetails.noiseCapDeviationPercentage9 = capPPM9; - computeLocals.poolDetails.noiseMaxSurgeFee9 = maxPPM9; - computeLocals.poolDetails.arbThresholdPercentage9 = thrPPM9; - computeLocals.poolDetails.arbCapDeviationPercentage9 = capPPM9; - computeLocals.poolDetails.arbMaxSurgeFee9 = maxPPM9; + function _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( diff --git a/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol b/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol index b4ad0da2..ca7f1798 100644 --- a/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol +++ b/pkg/pool-hooks/test/foundry/HyperSurgeLiquidityChecks.t.sol @@ -27,9 +27,9 @@ import { } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; // Local deployer + mock -import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.sol"; +import { HyperSurgeHook } from "../../contracts/hooks-quantamm/HyperSurgeHook.sol"; import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; -import { HyperSurgeHook } from ".../../contracts/hooks-quantamm/HyperSurgeHook.sol"; +import { HyperSurgeHookDeployer } from "./utils/HyperSurgeHookDeployer.sol"; import { WeightedPoolContractsDeployer } from "@balancer-labs/v3-pool-weighted/test/foundry/utils/WeightedPoolContractsDeployer.sol"; @@ -999,7 +999,7 @@ contract HyperSurgeLiquidityCheckTest is BaseVaultTest, HyperSurgeHookDeployer, ); vm.stopPrank(); - assertTrue(ok, "must be greater than, equal is fine"); + 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. @@ -1192,8 +1192,7 @@ contract HyperSurgeLiquidityCheckTest is BaseVaultTest, HyperSurgeHookDeployer, struct DefenciveZeroCheck { uint256 bIn; uint256 bOut; - uint256 pxIn; - uint256 pxOut; + uint256 oraclePrice; uint256 pxBase; uint256 amountGiven; uint256 calcAmount; @@ -1210,37 +1209,28 @@ contract HyperSurgeLiquidityCheckTest is BaseVaultTest, HyperSurgeHookDeployer, uint256 calcAmtRaw ) public view { DefenciveZeroCheck memory check; - check.bIn = bound(bInRaw, 1e18, 1e22); - check.bOut = bound(bOutRaw, 1e18, 1e22); - check.pxIn = 1e18; - check.pxOut = 1e18; + check.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 - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L; - L.bIn = check.bIn; - L.bOut = check.bOut; - L.wIn = 1e18; - L.wOut = 0; // <<< makes den = bIn.mulDown(wOut) == 0 → poolPx == 0 - L.pxIn = check.pxIn; - L.pxOut = check.pxOut; - L.calcAmountScaled18 = check.calcAmount; - L.poolDetails.noiseThresholdPercentage9 = 10_000_000; // 1% - L.poolDetails.noiseCapDeviationPercentage9 = 50_000_000; // 5% - L.poolDetails.noiseMaxSurgeFee9 = 100_000_000; // 10% - L.poolDetails.arbThresholdPercentage9 = 10_000_000; // 1% - L.poolDetails.arbCapDeviationPercentage9 = 50_000_000; // 5% - L.poolDetails.arbMaxSurgeFee9 = 200_000_000; // 20% - L.poolDetails.numTokens = 2; - - uint256[] memory balances = new uint256[](2); - balances[0] = check.bIn; - balances[1] = check.bOut; + 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: balances, + balancesScaled18: balancesScaled18, indexIn: 0, indexOut: 1, router: address(0), @@ -1248,10 +1238,17 @@ contract HyperSurgeLiquidityCheckTest is BaseVaultTest, HyperSurgeHookDeployer, }); check.staticFee = 1e16; // 1% - (check.ok, check.fee) = hook.ComputeSurgeFee(L, p, check.staticFee); + (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, 1e18, "fee must be a valid 18-dec percentage"); + 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 index f938c66d..433ce2a0 100644 --- a/pkg/pool-hooks/test/foundry/HyperSurgeMaxDeviation.t.sol +++ b/pkg/pool-hooks/test/foundry/HyperSurgeMaxDeviation.t.sol @@ -3,10 +3,15 @@ pragma solidity ^0.8.24; import "forge-std/Test.sol"; -import { HyperSurgeHookMock } from "../../contracts/test/HyperSurgeHookMock.sol"; import { PoolSwapParams, SwapKind } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; + +import { 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 @@ -15,7 +20,9 @@ import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVa /// It mirrors the helper-style used in the original tests and uses /// the hook's ComputeSurgeFee pure entrypoint. contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { - uint256 constant ONE = 1e18; + 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% @@ -39,7 +46,7 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { // Simple normalized weights with a 1% floor, deterministic from a seed. function _normWeights(uint8 n, uint256 seed) internal pure returns (uint256[] memory w) { - require(uint256(n) * WEIGHT_MIN <= ONE, "min too big"); + require(uint256(n) * WEIGHT_MIN <= FixedPoint.ONE, "min too big"); w = new uint256[](n); uint256[] memory r = new uint256[](n); @@ -52,17 +59,17 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { } uint256 base = uint256(n) * WEIGHT_MIN; - uint256 rem = ONE - base; + 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 != ONE) { - if (acc < ONE) w[0] += (ONE - acc); + if (acc != FixedPoint.ONE) { + if (acc < FixedPoint.ONE) w[0] += (FixedPoint.ONE - acc); else { - uint256 over = acc - ONE; + uint256 over = acc - FixedPoint.ONE; w[0] = w[0] > over + WEIGHT_MIN ? (w[0] - over) : WEIGHT_MIN; } } @@ -78,55 +85,27 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { } } // Build a locals struct with two overridden prices targeting a desired deviation `D` (1e18 scale). - // We set pxIn = 1e18 and pxOut so that extPx = pxOut/pxIn = P / (1 + D), using the same divDown rounding. - function _localsForDeviation( - uint256 P, // pair spot (1e18) - uint256 D // target deviation (1e18) - ) internal pure returns (uint256 pxIn, uint256 pxOut) { - pxIn = ONE; - // extPx = P / (1 + D) (use hook-style rounding) - pxOut = _divDown(P, ONE + D); + // 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); } - // Instantiate ComputeSurgeFeeLocals with common pool details (NOISE lane), - // with b/w and px values provided by the caller. - function _makeLocals( - uint256 bIn, - uint256 wIn, - uint256 bOut, - uint256 wOut, - uint256 pxIn, - uint256 pxOut - ) internal pure returns (HyperSurgeHookMock.ComputeSurgeFeeLocals memory L) { - L.bIn = bIn; - L.wIn = wIn; - L.bOut = bOut; - L.wOut = wOut; - L.pxIn = pxIn; - L.pxOut = pxOut; - + function _getDefaultPoolDetails() internal pure returns (HyperSurgeHook.PoolDetails memory poolDetails) { // Configure NOISE lane (used when deviation does not worsen). - L.poolDetails.noiseThresholdPercentage9 = uint32(DEFAULT_THRESHOLD_PPM9); - L.poolDetails.noiseMaxSurgeFee9 = uint32(DEFAULT_MAX_SURGE_FEE_PPM9); - L.poolDetails.noiseCapDeviationPercentage9 = uint32(DEFAULT_CAP_DEV_PPM9); + 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). - L.poolDetails.arbThresholdPercentage9 = uint32(DEFAULT_THRESHOLD_PPM9); - L.poolDetails.arbMaxSurgeFee9 = uint32(DEFAULT_MAX_SURGE_FEE_PPM9); - L.poolDetails.arbCapDeviationPercentage9 = uint32(DEFAULT_CAP_DEV_PPM9); - } + poolDetails.arbThresholdPercentage9 = uint32(DEFAULT_THRESHOLD_PPM9); + poolDetails.arbMaxSurgeFee9 = uint32(DEFAULT_MAX_SURGE_FEE_PPM9); + poolDetails.arbCapDeviationPercentage9 = uint32(DEFAULT_CAP_DEV_PPM9); - // 1e18 fixed-point helpers identical to Balancer's FixedPoint - function _mulDown(uint256 a, uint256 b) internal pure returns (uint256) { - return (a * b) / 1e18; - } - - function _divDown(uint256 a, uint256 b) internal pure returns (uint256) { - return (a * 1e18) / b; + poolDetails.numTokens = 2; } function _relAbsDiff(uint256 a, uint256 b) internal pure returns (uint256) { - return a > b ? _divDown(a - b, b) : _divDown(b - a, b); + return a > b ? (a - b).divDown(b) : (b - a).divDown(b); } // Replace any existing pair-spot helper with this: @@ -136,15 +115,13 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { uint256 bOut, uint256 wOut ) internal pure returns (uint256) { - uint256 num = _mulDown(bOut, wIn); - uint256 den = _mulDown(bIn, wOut); - if (den == 0) return 0; - return _divDown(num, den); + // 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 pxIn, uint256 pxOut) internal pure returns (uint256) { - uint256 extPx = _divDown(pxOut, pxIn); // identical to hook’s locals.extPx - uint256 deviation = _relAbsDiff(poolPx, extPx); + 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; @@ -153,28 +130,33 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { if (deviation <= threshold) return STATIC_SWAP_FEE; uint256 span = capDev - threshold; - uint256 norm = _divDown(deviation - threshold, span); - if (norm > ONE) norm = ONE; + uint256 norm = (deviation - threshold).divDown(span); + if (norm > FixedPoint.ONE) norm = FixedPoint.ONE; - uint256 incr = _mulDown(maxPct - STATIC_SWAP_FEE, norm); + 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); - uint256[] memory b = _balances(n, bSeed); // Pick a pair i!=j. uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, n - 1)); uint8 j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, n - 1)); if (j == i) j = (i + 1) % n; - uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); + 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); @@ -182,81 +164,139 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { // target deviation in [0 .. threshold] (inclusive lower range) uint256 D = uint256(keccak256(abi.encode(dSeed))) % (threshold + 1); - (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P, D); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); - - PoolSwapParams memory p; // zero-initialized; p.kind defaults to 0 (= EXACT_IN) - p.kind = SwapKind.EXACT_IN; // keep before==after so we take the NOISE lane + uint256 oraclePrice = fee_computeOraclePriceForDeviation(P, D); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); - (bool ok, uint256 fee) = hook.ComputeSurgeFee(L, p, STATIC_SWAP_FEE); + (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 { - uint8 n = uint8(bound(nSeed, 2, 8)); - uint256[] memory w = _normWeights(n, wSeed); - uint256[] memory b = _balances(n, bSeed); + FeeAboveCapLocals memory locals; - uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 3))), 0, n - 1)); - uint8 j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 4))), 0, n - 1)); - if (j == i) j = (i + 1) % n; + 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); - uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); - vm.assume(P > 0); - - uint256 capDev = DEFAULT_CAP_DEV_PPM9 * 1e9; + locals.capDev = DEFAULT_CAP_DEV_PPM9 * 1e9; // Choose a deviation D >= capDev (push comfortably above to avoid rounding back below). - uint256 extra = (ONE - capDev) / 4; // up to +25% beyond cap (bounded to keep pxOut > 0) - uint256 D = capDev + (uint256(keccak256(abi.encode(dSeed, 5))) % (extra + 1)); + 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 pxIn, uint256 pxOut) = _localsForDeviation(P, D); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + 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; - (bool ok, uint256 fee) = hook.ComputeSurgeFee(L, p, STATIC_SWAP_FEE); - assertTrue(ok, "compute must succeed"); + (locals.ok, locals.fee) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, oraclePrice); + assertTrue(locals.ok, "compute must succeed"); - uint256 maxPct = DEFAULT_MAX_SURGE_FEE_PPM9 * 1e9; - assertEq(fee, maxPct, "above cap must return max fee"); + 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 { - uint8 n = uint8(bound(nSeed, 2, 8)); - uint256[] memory w = _normWeights(n, wSeed); - uint256[] memory b = _balances(n, bSeed); + FeeBetweenLinearLocals memory locals; - uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 6))), 0, n - 1)); - uint8 j = uint8(bound(uint256(keccak256(abi.encode(dSeed, 7))), 0, n - 1)); - if (j == i) j = (i + 1) % n; - - uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); - vm.assume(P > 0); + 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); - uint256 threshold = DEFAULT_THRESHOLD_PPM9; - uint256 capDev = DEFAULT_CAP_DEV_PPM9; - uint256 span = capDev - threshold; + locals.threshold = DEFAULT_THRESHOLD_PPM9; + locals.capDev = DEFAULT_CAP_DEV_PPM9; + locals.span = locals.capDev - locals.threshold; // Target a deviation strictly inside (threshold, capDev): - uint256 D = threshold + 1 + (uint256(keccak256(abi.encode(dSeed, 8))) % (span - 1)); + locals.D = locals.threshold + 1 + (uint256(keccak256(abi.encode(dSeed, 8))) % (locals.span - 1)); - (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P, D); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + locals.oraclePrice = fee_computeOraclePriceForDeviation(locals.P, locals.D); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); PoolSwapParams memory p; p.kind = SwapKind.EXACT_IN; - - (bool ok, uint256 fee) = hook.ComputeSurgeFee(L, p, STATIC_SWAP_FEE); - assertTrue(ok, "compute must succeed"); + 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(P, pxIn, pxOut); - assertEq(fee, expected, "fee must follow linear ramp between min and max"); + 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) { @@ -267,15 +307,13 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { // Expected fee with custom lane parameters (all in ppm9 for the lane fields). function _expectedFeeWithParams( uint256 poolPx, - uint256 pxIn, - uint256 pxOut, + uint256 oraclePrice, uint256 staticSwapFee, uint32 thresholdPPM9, uint32 capDevPPM9, uint32 maxFeePPM9 ) internal pure returns (uint256) { - uint256 extPx = _divDown(pxOut, pxIn); - uint256 deviation = _relAbsDiff(poolPx, extPx); + uint256 deviation = _relAbsDiff(poolPx, oraclePrice); uint256 threshold = _ppm9To1e18(thresholdPPM9); uint256 capDev = _ppm9To1e18(capDevPPM9); @@ -284,10 +322,10 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { if (deviation <= threshold) return staticSwapFee; uint256 span = capDev - threshold; - uint256 norm = _divDown(deviation - threshold, span); - if (norm > ONE) norm = ONE; + uint256 norm = (deviation - threshold).divDown(span); + if (norm > FixedPoint.ONE) norm = FixedPoint.ONE; - uint256 incr = _mulDown(maxPct - staticSwapFee, norm); + uint256 incr = (maxPct - staticSwapFee).mulDown(norm); uint256 fee = staticSwapFee + incr; if (fee > maxPct) fee = maxPct; return fee; @@ -301,8 +339,7 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { uint256 capDev1e18; uint256 price; uint256 expected; - uint256 pxIn; - uint256 pxOut; + uint256 oraclePrice; bool ok; uint256 fee; } @@ -332,87 +369,113 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { uint256 D2raw = uint256(keccak256(abi.encode(dSeed2))) % (locals.capDev1e18 + locals.capDev1e18 / 2 + 1); (locals.deviation, locals.expected) = D1 <= D2raw ? (D1, D2raw) : (D2raw, D1); - (locals.pxIn, locals.pxOut) = _localsForDeviation(locals.price, locals.deviation); - (uint256 pxIn2, uint256 pxOut2) = _localsForDeviation(locals.price, locals.expected); + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.price, locals.deviation); + uint256 oraclePrice2 = fee_computeOraclePriceForDeviation(locals.price, locals.expected); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L1 = _makeLocals( - b[locals.i], - w[locals.i], - b[locals.j], - w[locals.j], - locals.pxIn, - locals.pxOut - ); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L2 = _makeLocals( - b[locals.i], - w[locals.i], - b[locals.j], - w[locals.j], - pxIn2, - pxOut2 - ); + 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(L1, p, STATIC_SWAP_FEE); - (, uint256 fee2) = hook.ComputeSurgeFee(L2, p, STATIC_SWAP_FEE); + (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"); } - function testFuzz_swapSymmetry_sameLaneParams( - uint8 nSeed, - uint256 wSeed, - uint256 bSeed, - uint256 dSeed - ) public view { - uint8 n = uint8(bound(nSeed, 2, 8)); - uint256[] memory w = _normWeights(n, wSeed); - uint256[] memory b = _balances(n, bSeed); + struct SwapSymmetryLocals { + uint256[] w; + uint8 i; + uint8 j; + uint256 P; + uint256 oraclePrice; + bool okA; + uint256 feeA; + uint256 devA; + bool okB; + uint256 feeB; + uint256 devB; + } - uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, n - 1)); - uint8 j = (i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, n - 2))) % n; + 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(b[i], w[i], b[j], w[j]); + 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); - (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P_ij, D); + locals.oraclePrice = fee_computeOraclePriceForDeviation(P_ij, D); - // Orientation A (i -> j) - HyperSurgeHookMock.ComputeSurgeFeeLocals memory LA = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); - // Orientation B (j -> i) with inverted external prices - HyperSurgeHookMock.ComputeSurgeFeeLocals memory LB = _makeLocals(b[j], w[j], b[i], w[i], pxOut, pxIn); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); - PoolSwapParams memory p; - p.kind = SwapKind.EXACT_IN; - - (bool okA, uint256 feeA) = hook.ComputeSurgeFee(LA, p, STATIC_SWAP_FEE); - (bool okB, uint256 feeB) = hook.ComputeSurgeFee(LB, p, STATIC_SWAP_FEE); - assertTrue(okA && okB, "compute must succeed"); + // 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 - uint256 extA = _divDown(LA.pxOut, LA.pxIn); - uint256 devA = _relAbsDiff(P_ij, extA); + 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(LB.bIn, LB.wIn, LB.bOut, LB.wOut); - uint256 extB = _divDown(LB.pxOut, LB.pxIn); - uint256 devB = _relAbsDiff(P_ji, extB); + 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 (devA > devB) { + if (locals.devA > locals.devB) { // allow 1 wei to avoid knife-edge floor rounding flips - assertGe(feeA + 1, feeB, "larger deviation must not yield smaller fee (A vs B)"); - } else if (devB > devA) { - assertGe(feeB + 1, feeA, "larger deviation must not yield smaller fee (B vs A)"); + 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(feeA, feeB, 1, "equal deviations should give equal fees (1 wei)"); + assertApproxEqAbs(locals.feeA, locals.feeB, 1, "equal deviations should give equal fees (1 wei)"); } } @@ -424,8 +487,7 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { uint256 capDev1e18; uint256 price; uint256 expected; - uint256 pxIn; - uint256 pxOut; + uint256 oraclePrice; bool ok; uint256 fee; } @@ -452,31 +514,27 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { locals.capDev1e18 = DEFAULT_CAP_DEV_PPM9; locals.deviation = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev1e18 + locals.capDev1e18 / 2 + 1); - (locals.pxIn, locals.pxOut) = _localsForDeviation(locals.price, locals.deviation); + (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); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L = _makeLocals( - b[locals.i], - w[locals.i], - b[locals.j], - w[locals.j], - locals.pxIn, - locals.pxOut - ); + 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(L, p, staticFee); + (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.pxIn, - locals.pxOut, + locals.oraclePrice, staticFee, uint32(DEFAULT_THRESHOLD_PPM9), uint32(DEFAULT_CAP_DEV_PPM9), @@ -493,8 +551,7 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { uint256 capDev1e18; uint256 price; uint256 expected; - uint256 pxIn; - uint256 pxOut; + uint256 oraclePrice; bool ok; uint256 fee; } @@ -522,39 +579,39 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { locals.capDev1e18 = DEFAULT_CAP_DEV_PPM9; locals.deviation = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev1e18 + locals.capDev1e18 / 2 + 1); - (locals.pxIn, locals.pxOut) = _localsForDeviation(locals.price, locals.deviation); + (locals.oraclePrice) = fee_computeOraclePriceForDeviation(locals.price, locals.deviation); // Orientation A (i -> j) - HyperSurgeHookMock.ComputeSurgeFeeLocals memory LA = _makeLocals( - b[locals.i], - w[locals.i], - b[locals.j], - w[locals.j], - locals.pxIn, - locals.pxOut - ); - // Orientation B (j -> i) with inverted external prices - HyperSurgeHookMock.ComputeSurgeFeeLocals memory LB = _makeLocals( - b[locals.j], - w[locals.j], - b[locals.i], - w[locals.i], - locals.pxOut, - locals.pxIn - ); + 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); - (locals.ok, locals.fee) = hook.ComputeSurgeFee(LA, p, STATIC_SWAP_FEE); - (bool okB, uint256 feeB) = hook.ComputeSurgeFee(LB, p, STATIC_SWAP_FEE); + // 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 extA = _divDown(LA.pxOut, LA.pxIn); - uint256 extB = _divDown(LB.pxOut, LB.pxIn); - uint256 devA = _relAbsDiff(locals.price, extA); - uint256 devB = _relAbsDiff(_pairSpotFromBalancesWeights(LB.bIn, LB.wIn, LB.bOut, LB.wOut), extB); // equals 1/P vs 1/ext due to swap + 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) { @@ -570,18 +627,18 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { uint8 n; uint8 i; uint8 j; + uint256[] w; + uint256[] b; uint256 P; uint256 threshold; uint256 capDev; int8[5] offs; uint256 Dt; - uint256 pxInT; - uint256 pxOutT; + uint256 oraclePriceT; uint256 extT; uint256 expectedT; uint256 Dc; - uint256 pxInC; - uint256 pxOutC; + uint256 oraclePriceC; uint256 expectedC; } @@ -595,13 +652,18 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { ) public view { ThresholdAndCap memory locals; locals.n = uint8(bound(nSeed, 2, 8)); - uint256[] memory w = _normWeights(locals.n, wSeed); - uint256[] memory b = _balances(locals.n, bSeed); + locals.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(b[locals.i], w[locals.i], b[locals.j], w[locals.j]); + 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; @@ -617,24 +679,27 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { } else { locals.Dt = locals.threshold + uint256(uint8(locals.offs[k])); } - (locals.pxInT, locals.pxOutT) = _localsForDeviation(locals.P, locals.Dt); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory LT = _makeLocals( - b[locals.i], - w[locals.i], - b[locals.j], - w[locals.j], - locals.pxInT, - locals.pxOutT - ); + (locals.oraclePriceT) = fee_computeOraclePriceForDeviation(locals.P, locals.Dt); + HyperSurgeHook.PoolDetails memory poolDetails = _getDefaultPoolDetails(); PoolSwapParams memory p; p.kind = SwapKind.EXACT_IN; - - (bool okT, uint256 feeT) = hook.ComputeSurgeFee(LT, p, STATIC_SWAP_FEE); + 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.extT = _divDown(locals.pxOutT, locals.pxInT); - locals.expectedT = _expectedFeeFromLocals(locals.P, locals.pxInT, locals.pxOutT); + 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"); @@ -644,28 +709,42 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { locals.Dc = locals.capDev > deltaC ? locals.capDev - deltaC : 0; } else { // guard upper bound to avoid overflow in _localsForDeviation denominator - uint256 room = ONE > locals.capDev ? (ONE - locals.capDev) : 0; + uint256 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.pxInC, locals.pxOutC) = _localsForDeviation(locals.P, locals.Dc); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory LC = _makeLocals( - b[locals.i], - w[locals.i], - b[locals.j], - w[locals.j], - locals.pxInC, - locals.pxOutC + (locals.oraclePriceC) = fee_computeOraclePriceForDeviation(locals.P, locals.Dc); + (bool okC, uint256 feeC) = hook.ComputeSurgeFee( + p, + poolDetails, + STATIC_SWAP_FEE, + locals.w, + 0, + locals.oraclePriceC ); - (bool okC, uint256 feeC) = hook.ComputeSurgeFee(LC, p, STATIC_SWAP_FEE); assertTrue(okC, "compute must succeed (cap ring)"); - locals.expectedC = _expectedFeeFromLocals(locals.P, locals.pxInC, locals.pxOutC); + 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, @@ -674,33 +753,49 @@ contract HyperSurgeFindMaxFeeRampTest is BaseVaultTest { uint256 dSeed, uint64 scaleSeed ) public view { - uint8 n = uint8(bound(nSeed, 2, 8)); - uint256[] memory w = _normWeights(n, wSeed); - uint256[] memory b = _balances(n, bSeed); + BalanceScalingInvarianceLocals memory locals; - uint8 i = uint8(bound(uint256(keccak256(abi.encode(dSeed, 1))), 0, n - 1)); - uint8 j = (i + 1 + uint8(bound(uint256(keccak256(abi.encode(dSeed, 2))), 0, n - 2))) % n; + locals.n = uint8(bound(nSeed, 2, 8)); + locals.w = _normWeights(locals.n, wSeed); + locals.b = _balances(locals.n, bSeed); - uint256 P = _pairSpotFromBalancesWeights(b[i], w[i], b[j], w[j]); - vm.assume(P > 0); + 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; - uint256 capDev = DEFAULT_CAP_DEV_PPM9; - uint256 D = uint256(keccak256(abi.encode(dSeed))) % (capDev + capDev / 3 + 1); + locals.P = _pairSpotFromBalancesWeights( + locals.b[locals.i], + locals.w[locals.i], + locals.b[locals.j], + locals.w[locals.j] + ); + vm.assume(locals.P > 0); - (uint256 pxIn, uint256 pxOut) = _localsForDeviation(P, D); + locals.capDev = DEFAULT_CAP_DEV_PPM9; + locals.D = uint256(keccak256(abi.encode(dSeed))) % (locals.capDev + locals.capDev / 3 + 1); - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L1 = _makeLocals(b[i], w[i], b[j], w[j], pxIn, pxOut); + locals.oraclePrice = fee_computeOraclePriceForDeviation(locals.P, locals.D); - uint256 k = 1 + (uint256(scaleSeed) % 1_000_000_000); // [1 .. 1e9] - HyperSurgeHookMock.ComputeSurgeFeeLocals memory L2 = _makeLocals(b[i] * k, w[i], b[j] * k, w[j], pxIn, pxOut); + 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; + } - (, uint256 fee1) = hook.ComputeSurgeFee(L1, p, STATIC_SWAP_FEE); - (, uint256 fee2) = hook.ComputeSurgeFee(L2, p, STATIC_SWAP_FEE); + (, locals.fee2) = hook.ComputeSurgeFee(p, poolDetails, STATIC_SWAP_FEE, locals.w, 0, locals.oraclePrice); - assertApproxEqAbs(fee1, fee2, 1, "fee must be invariant to balance scaling"); + assertApproxEqAbs(locals.fee1, locals.fee2, 1, "fee must be invariant to balance scaling"); } struct ExactOutArbLaneBoundaryLocals {