diff --git a/.gitmodules b/.gitmodules index 41f8aa8..749dd8f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,3 +7,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/Vectorized/solady diff --git a/foundry.lock b/foundry.lock index eaccf44..fd603be 100644 --- a/foundry.lock +++ b/foundry.lock @@ -8,6 +8,12 @@ "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" } }, + "lib/solady": { + "tag": { + "name": "v0.1.26", + "rev": "acd959aa4bd04720d640bf4e6a5c71037510cc4b" + } + }, "lib/v4-periphery": { "rev": "3779387e5d296f39df543d23524b050f89a62917" } diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..acd959a --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit acd959aa4bd04720d640bf4e6a5c71037510cc4b diff --git a/src/FixedFeeSwapMarket.sol b/src/FixedFeeSwapMarket.sol new file mode 100644 index 0000000..93383e3 --- /dev/null +++ b/src/FixedFeeSwapMarket.sol @@ -0,0 +1,1207 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {BaseHook} from "v4-periphery/src/utils/BaseHook.sol"; +import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; +import {LiquidityMath} from "v4-core/libraries/LiquidityMath.sol"; +import {SafeCast} from "v4-core/libraries/SafeCast.sol"; +import {TickBitmap} from "v4-core/libraries/TickBitmap.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {BeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol"; +import {ModifyLiquidityParams, SwapParams} from "v4-core/types/PoolOperation.sol"; +import {toBeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Hooks} from "v4-core/libraries/Hooks.sol"; + +struct TickInfo { + // the total position liquidity that references this tick + uint128 liquidityGross; + // amount of net liquidity added (subtracted) when tick is crossed from left to right (right to + // left), + int128 liquidityNet; + // fee growth per unit of liquidity on the _other_ side of this tick (relative to the current + // tick) + // only has relative meaning, not absolute — the value depends on when the tick is initialized + uint256 feeGrowthOutside0X128; + uint256 feeGrowthOutside1X128; +} + +struct TokenDelta { + int128 amount0; + int128 amount1; +} + +struct PositionInfo { + address owner; + int24 tickLower; + int24 tickUpper; + int128 liquidity; + // Fee tracking + uint256 feeGrowthInside0LastX128; + uint256 feeGrowthInside1LastX128; + uint128 tokensOwed0; + uint128 tokensOwed1; +} + +/// @dev Minimal struct for swap step context to avoid stack-too-deep +struct SwapContext { + bool principalForCash; + bool tickMovingLeft; + int24 tickLimit; +} + +contract FixedFeeSwapMarket is BaseHook { + using SafeCast for uint256; + using SafeCast for int256; + using FixedPointMathLib for int256; + using FixedPointMathLib for int128; + using SafeCast for int128; + using SafeERC20 for IERC20; + using TickBitmap for mapping(int16 => uint256); + + // ============ Events ============ + + /// @notice Emitted when fees are collected from a position + event FeesCollected( + bytes32 indexed positionId, address indexed recipient, uint128 amount0, uint128 amount1 + ); + + /// @notice Emitted when a swap is executed + event Swap(address indexed sender, bool principalForCash, uint256 amountIn, uint256 amountOut); + + /// @notice Emitted when liquidity is modified for a position + event LiquidityModified( + bytes32 indexed positionId, + address indexed owner, + int24 tickLower, + int24 tickUpper, + int128 liquidityDelta, + int128 amount0, + int128 amount1 + ); + + // ============ Errors ============ + + /// @notice Thrown when the fee rate is 100% or higher + error FixedFeeSwapMarket_FeeRateTooHigh(); + + /// @notice Thrown when yield spacing is zero or negative + error FixedFeeSwapMarket_InvalidYieldSpacing(); + + /// @notice Thrown when trying to remove more liquidity than the position holds + error FixedFeeSwapMarket_InsufficientPositionLiquidity(); + + /// @notice Thrown when swap hook parameters are malformed or have incorrect length + error FixedFeeSwapMarket_InvalidHookParams(); + + /// @notice Thrown when liquidity amount is zero or exceeds slippage bounds + error FixedFeeSwapMarket_InvalidLiquidityAmount(); + + /// @notice Thrown when price moves in an unexpected direction during swap calculation + error FixedFeeSwapMarket_InvalidPriceDirection(); + + /// @notice Thrown when the lower tick is greater than or equal to the upper tick + error FixedFeeSwapMarket_InvalidTickRange(); + + /// @notice Thrown when tick traversal produces an invalid delta or zero boundary amount + error FixedFeeSwapMarket_InvalidTickTraversal(); + + /// @notice Thrown when decreaseLiquidity is called with a non-negative liquidity delta + error FixedFeeSwapMarket_LiquidityDeltaMustBeNegative(); + + /// @notice Thrown when attempting to swap with no active liquidity in range + error FixedFeeSwapMarket_NoActiveLiquidity(); + + /// @notice Thrown when the caller is not the owner of the position + error FixedFeeSwapMarket_NotPositionOwner(); + + /// @notice Thrown when the specified position does not exist + error FixedFeeSwapMarket_PositionNotFound(); + + /// @notice Thrown when the swap amount is zero or negative + error FixedFeeSwapMarket_SwapAmountCannotBeZero(); + + /// @notice Thrown when a swap or time-dependent calculation is attempted at or after maturity + error FixedFeeSwapMarket_MarketExpired(); + + /// @notice Thrown when a tick's gross liquidity would underflow + error FixedFeeSwapMarket_TickLiquidityGrossUnderflow(); + + /// @notice Thrown when liquidity is added or removed via PoolManager instead of this hook + error FixedFeeSwapMarket_LiquidityOnlyViaHook(); + + // ============ Constants ============ + + int24 constant NO_TICK_LIMIT = type(int24).max; + uint256 constant WAD = 1e18; + uint256 constant BIPS = 10_000; + uint256 constant SWAP_HOOK_PARAMS_LENGTH = 96; + + /// @notice Denominator for fee calculations, set to 1e6 to support hundredths of a bip + uint256 constant FEE_DENOMINATOR = 1_000_000; + + /// @notice Q128 fixed-point multiplier for fee growth calculations + uint256 constant Q128 = 1 << 128; + + // ============ Immutables ============ + + uint48 immutable MATURITY; + uint48 immutable START_TIME; + IERC20 immutable TOKEN0; + IERC20 immutable TOKEN1; + + /// @notice Fee rate in hundredths of a bip, where a value of 1 equals 0.0001% + /// @dev 3000 = 0.30% fee, similar to Uniswap v3 medium tier + uint24 public immutable FEE_RATE; + + /// @notice Yield movement (in bps) represented by one tick step + int24 public immutable YIELD_PER_TICK; + + // ============ State Variables ============ + + int24 currentTick; + int256 currentPrice; + uint128 activeLiquidity; + + /// @notice Global fee growth per unit of liquidity for TOKEN0 (principal) + uint256 public feeGrowthGlobal0X128; + + /// @notice Global fee growth per unit of liquidity for TOKEN1 (cash) + uint256 public feeGrowthGlobal1X128; + + // ============ Mappings ============ + + mapping(int24 tick => TickInfo) ticks; + mapping(int16 wordPos => uint256) tickBitmap; + mapping(bytes32 positionId => PositionInfo) positions; + + // ============ Constructor ============ + + constructor( + uint48 _lengthOfMarket, + address _token0, + address _token1, + uint24 _feeRate, + int24 _yieldPerTick, + IPoolManager _manager, + int24 _initialTick + ) BaseHook(_manager) { + if (_feeRate >= FEE_DENOMINATOR) revert FixedFeeSwapMarket_FeeRateTooHigh(); + if (_yieldPerTick <= 0) revert FixedFeeSwapMarket_InvalidYieldSpacing(); + START_TIME = uint48(block.timestamp); + MATURITY = uint48(block.timestamp) + _lengthOfMarket; + TOKEN0 = IERC20(_token0); + TOKEN1 = IERC20(_token1); + FEE_RATE = _feeRate; + YIELD_PER_TICK = _yieldPerTick; + currentTick = _initialTick; + } + + // ============ Public Functions ============ + + /// @notice Returns the hook permissions indicating which callbacks this hook implements + /// @return The permissions struct with beforeSwap enabled + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + return Hooks.Permissions({ + beforeInitialize: false, + afterInitialize: false, + beforeAddLiquidity: true, + afterAddLiquidity: false, + beforeRemoveLiquidity: true, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: false, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: false, + afterSwapReturnDelta: false, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); + } + + /// @notice Converts a yield in basis points to the corresponding tick + /// @param _yieldBps The yield rate in basis points + /// @return The tick value representing this yield + function yieldToTick(int256 _yieldBps) public view returns (int24) { + return int24(_yieldBps / int256(YIELD_PER_TICK)); + } + + /// @notice Converts a tick to its corresponding discount factor price + /// @dev Price is calculated as exp(-yield * scaledTimeRemaining) where yield = tick * + /// YIELD_PER_TICK. At START_TIME, remaining = duration so prices are fully discounted. + /// At MATURITY, all prices converge to 1.0 (PT redeemable 1:1). + /// @param _tick The tick value to convert + /// @return The price as a WAD-scaled discount factor, where 1e18 represents a price of 1.0 + function tickToPrice(int256 _tick) public view returns (int256) { + if (block.timestamp >= MATURITY) return int256(WAD); + uint256 _remaining = MATURITY - block.timestamp; + uint256 _duration = MATURITY - START_TIME; + uint256 _scaledTime = (_remaining * WAD) / _duration; + return _priceFromYieldWad(_tickToYieldWad(_tick), _scaledTime); + } + + // ============ External Functions ============ + + /// @notice Creates a new liquidity position or adds to an existing one at the same tick range + /// @dev Position ID is derived from tickUpper, tickLower, and msg.sender + /// @param _tickLower The lower tick boundary of the position, representing the lower yield + /// @param _tickUpper The upper tick boundary of the position, representing the higher yield + /// @param _liquidity The amount of liquidity to mint + /// @param _amount0Max Maximum TOKEN0 principal tokens willing to deposit + /// @param _amount1Max Maximum TOKEN1 cash tokens willing to deposit + function mintLiquidity( + int24 _tickLower, + int24 _tickUpper, + uint256 _liquidity, + uint256 _amount0Max, + uint256 _amount1Max + ) external virtual { + if (_tickLower < 0) revert FixedFeeSwapMarket_InvalidTickRange(); + bytes32 _positionId = _getPositionId(msg.sender, _tickUpper, _tickLower); + PositionInfo storage position = positions[_positionId]; + bool _isNew = position.owner == address(0); + int128 _liquidityBefore = position.liquidity; + + if (_isNew) { + position.tickUpper = _tickUpper; + position.tickLower = _tickLower; + position.owner = msg.sender; + } + + // Call modifyLiquidity first so tick init sets feeGrowthOutside correctly + TokenDelta memory _tokenDelta = + _modifyLiquidity(msg.sender, _tickUpper, _tickLower, _liquidity.toInt128()); + uint128 _amount0In = _tokenDelta.amount0.toUint128(); + uint128 _amount1In = _tokenDelta.amount1.toUint128(); + if (_amount0In > _amount0Max || _amount1In > _amount1Max) { + revert FixedFeeSwapMarket_InvalidLiquidityAmount(); + } + + // Update liquidity without overwriting fee fields + position.liquidity = position.liquidity + _liquidity.toInt128(); + + // Initialize snapshots when liquidity transitions from 0 -> positive + // (isNew implies liquidityBefore == 0, so we only need to check liquidityBefore) + if (_liquidityBefore == 0) _refreshFeeSnapshots(position); + + if (_amount0In != 0) TOKEN0.safeTransferFrom(msg.sender, address(this), _amount0In); + if (_amount1In != 0) TOKEN1.safeTransferFrom(msg.sender, address(this), _amount1In); + } + + /// @notice Increases liquidity for an existing position + /// @dev Caller must be the position owner. Accrues pending fees before adding liquidity. + /// @param _positionId The unique identifier of the position + /// @param _liquidity The amount of liquidity to add + /// @param _amount0Max Maximum TOKEN0 principal tokens willing to deposit + /// @param _amount1Max Maximum TOKEN1 cash tokens willing to deposit + function increaseLiquidity( + bytes32 _positionId, + uint256 _liquidity, + uint256 _amount0Max, + uint256 _amount1Max + ) external virtual { + PositionInfo storage position = _requirePositionOwner(_positionId); + int128 _liquidityBefore = position.liquidity; + + // modifyLiquidity accrues fees internally and refreshes snapshots + TokenDelta memory _tokenDelta = + _modifyLiquidity(msg.sender, position.tickUpper, position.tickLower, _liquidity.toInt128()); + uint128 _amount0In = _tokenDelta.amount0.toUint128(); + uint128 _amount1In = _tokenDelta.amount1.toUint128(); + if (_amount0In > _amount0Max || _amount1In > _amount1Max) { + revert FixedFeeSwapMarket_InvalidLiquidityAmount(); + } + + // Update position liquidity without overwriting fee fields + position.liquidity = position.liquidity + _liquidity.toInt128(); + + // If the position had zero liquidity, refresh snapshots to prevent earning past fees + if (_liquidityBefore == 0) _refreshFeeSnapshots(position); + + if (_amount0In != 0) TOKEN0.safeTransferFrom(msg.sender, address(this), _amount0In); + if (_amount1In != 0) TOKEN1.safeTransferFrom(msg.sender, address(this), _amount1In); + } + + /// @notice Decreases liquidity from an existing position and withdraws tokens + /// @dev Caller must be the position owner. Accrues pending fees before removing liquidity. + /// @param _positionId The unique identifier of the position + /// @param _liquidity The amount of liquidity to remove, must be negative + /// @param _amount0Min Minimum TOKEN0 principal tokens to receive + /// @param _amount1Min Minimum TOKEN1 cash tokens to receive + function decreaseLiquidity( + bytes32 _positionId, + int128 _liquidity, + uint256 _amount0Min, + uint256 _amount1Min + ) external virtual { + PositionInfo storage position = _requirePositionOwner(_positionId); + if (_liquidity >= 0) revert FixedFeeSwapMarket_LiquidityDeltaMustBeNegative(); + if (uint128(position.liquidity) < uint128(_liquidity.abs())) { + revert FixedFeeSwapMarket_InsufficientPositionLiquidity(); + } + + // modifyLiquidity accrues fees owed before burning liquidity + TokenDelta memory _tokenDelta = + _modifyLiquidity(msg.sender, position.tickUpper, position.tickLower, _liquidity); + uint128 _amount0Out = uint128(_tokenDelta.amount0.abs()); + uint128 _amount1Out = uint128(_tokenDelta.amount1.abs()); + if (_amount0Out < _amount0Min || _amount1Out < _amount1Min) { + revert FixedFeeSwapMarket_InvalidLiquidityAmount(); + } + + // Update position liquidity without overwriting fee fields + position.liquidity = position.liquidity + _liquidity; + + if (_tokenDelta.amount0 < 0) TOKEN0.safeTransfer(msg.sender, _amount0Out); + if (_tokenDelta.amount1 < 0) TOKEN1.safeTransfer(msg.sender, _amount1Out); + } + + /// @notice Collects accumulated fees for a position + /// @param _positionId The position identifier + /// @param _recipient Address to receive the fees + /// @param _amount0Requested Maximum TOKEN0 fees to collect, or 0 to collect all available + /// @param _amount1Requested Maximum TOKEN1 fees to collect, or 0 to collect all available + /// @return _amount0 Actual TOKEN0 fees collected + /// @return _amount1 Actual TOKEN1 fees collected + function collectFees( + bytes32 _positionId, + address _recipient, + uint128 _amount0Requested, + uint128 _amount1Requested + ) external returns (uint128 _amount0, uint128 _amount1) { + PositionInfo storage position = _requirePositionOwner(_positionId); + + // Accrue pending fees before collection + _accruePositionFees(position); + + // Calculate amounts to collect (0 means collect all, otherwise min of requested and owed) + _amount0 = (_amount0Requested == 0 || _amount0Requested > position.tokensOwed0) + ? position.tokensOwed0 + : _amount0Requested; + _amount1 = (_amount1Requested == 0 || _amount1Requested > position.tokensOwed1) + ? position.tokensOwed1 + : _amount1Requested; + + // Update owed amounts (CEI: update state before transfers) + position.tokensOwed0 -= _amount0; + position.tokensOwed1 -= _amount1; + + // Transfer fees + if (_amount0 > 0) TOKEN0.safeTransfer(_recipient, _amount0); + if (_amount1 > 0) TOKEN1.safeTransfer(_recipient, _amount1); + + emit FeesCollected(_positionId, _recipient, _amount0, _amount1); + } + + // ============ Internal Functions - Hook Overrides ============ + + /// @notice Hook called before liquidity is added via PoolManager + /// @dev Always reverts because liquidity must be managed through this contract's mint/increase + /// functions + function _beforeAddLiquidity( + address, + PoolKey calldata, + ModifyLiquidityParams calldata, + bytes calldata + ) internal virtual override returns (bytes4) { + revert FixedFeeSwapMarket_LiquidityOnlyViaHook(); + } + + /// @notice Hook called before liquidity is removed via PoolManager + /// @dev Always reverts because liquidity must be managed through this contract's decrease + /// function + function _beforeRemoveLiquidity( + address, + PoolKey calldata, + ModifyLiquidityParams calldata, + bytes calldata + ) internal virtual override returns (bytes4) { + revert FixedFeeSwapMarket_LiquidityOnlyViaHook(); + } + + /// @notice Hook called before a swap is executed via PoolManager + /// @dev Hook data schema: `abi.encode(int256 amountIn, bool principalForCash, int24 + /// tickLimit)`. If `principalForCash` is true, TOKEN0 is input and TOKEN1 is output, meaning + /// the tick increases and price decreases. + /// If `tickLimit` is `NO_TICK_LIMIT`, the swap only stops once `amountIn` is fully consumed. + /// amountIn is the gross amount before fees that the trader provides. + /// @param _sender The address initiating the swap + /// @param _hookParams Encoded swap parameters containing amountIn, principalForCash, and + /// tickLimit @return The hook selector, a zero delta because the swap is handled internally, + /// and zero fee override + function _beforeSwap( + address _sender, + PoolKey calldata, + SwapParams calldata, + bytes calldata _hookParams + ) internal virtual override returns (bytes4, BeforeSwapDelta, uint24) { + if (_hookParams.length == 0) { + return (BaseHook.beforeSwap.selector, toBeforeSwapDelta(0, 0), 0); + } + if (_hookParams.length != SWAP_HOOK_PARAMS_LENGTH) { + revert FixedFeeSwapMarket_InvalidHookParams(); + } + + (int256 _amountIn, bool _principalForCash, int24 _tickLimit) = + abi.decode(_hookParams, (int256, bool, int24)); + if (_amountIn <= 0) revert FixedFeeSwapMarket_SwapAmountCannotBeZero(); + if (block.timestamp >= MATURITY) revert FixedFeeSwapMarket_MarketExpired(); + if (activeLiquidity == 0) revert FixedFeeSwapMarket_NoActiveLiquidity(); + currentPrice = tickToPrice(currentTick); + + (uint256 _amountInUsed, uint256 _amountOutCalculated) = + _executeSwapLoop(uint256(_amountIn), _principalForCash, _tickLimit); + + // Execute token transfers + // TOKEN0 = principal token, TOKEN1 = cash token + if (_principalForCash) { + // User sends principal (TOKEN0), receives cash (TOKEN1) + if (_amountInUsed != 0) TOKEN0.safeTransferFrom(_sender, address(this), _amountInUsed); + if (_amountOutCalculated != 0) TOKEN1.safeTransfer(_sender, _amountOutCalculated); + } else { + // User sends cash (TOKEN1), receives principal (TOKEN0) + if (_amountInUsed != 0) TOKEN1.safeTransferFrom(_sender, address(this), _amountInUsed); + if (_amountOutCalculated != 0) TOKEN0.safeTransfer(_sender, _amountOutCalculated); + } + + emit Swap(_sender, _principalForCash, _amountInUsed, _amountOutCalculated); + return (BaseHook.beforeSwap.selector, toBeforeSwapDelta(0, 0), 0); + } + + // ============ Internal Functions - Swap Execution ============ + + /// @notice Executes the main swap loop, iterating through ticks until input is consumed or tick + /// limit is hit + /// @param _amountInGross The gross input amount (before fees) + /// @param _principalForCash True for PT→cash swap, false for cash→PT swap + /// @param _tickLimit Tick limit; NO_TICK_LIMIT means no limit + /// @return _amountInUsed Total gross input consumed + /// @return _amountOutCalculated Total output tokens to send to trader + function _executeSwapLoop(uint256 _amountInGross, bool _principalForCash, int24 _tickLimit) + internal + returns (uint256 _amountInUsed, uint256 _amountOutCalculated) + { + uint256 _amountInNet = _netOfFee(_amountInGross); + uint256 _amountInRemainingNet = _amountInNet; + SwapContext memory _ctx = SwapContext({ + principalForCash: _principalForCash, tickMovingLeft: !_principalForCash, tickLimit: _tickLimit + }); + + while (_amountInRemainingNet != 0) { + // Slippage protection: stop the swap if the tick has moved past the caller's limit. + // For principal→cash swaps, tick increases so we stop when tick reaches the limit. + // For cash→principal swaps, tick decreases so we stop when tick reaches the limit. + if (_ctx.tickLimit != NO_TICK_LIMIT) { + if (_ctx.principalForCash ? currentTick >= _ctx.tickLimit : currentTick <= _ctx.tickLimit) { + break; + } + } + + // Find next initialized tick + int24 _nextTick; + bool _initialized; + { + int24 _searchTick = currentTick; + if (_ctx.tickMovingLeft) { + int256 _candidate = int256(currentTick) - int256(YIELD_PER_TICK); + _searchTick = _candidate < type(int24).min ? type(int24).min : int24(_candidate); + } + (_nextTick, _initialized) = tickBitmap.nextInitializedTickWithinOneWord( + _searchTick, YIELD_PER_TICK, _ctx.tickMovingLeft + ); + } + + // Compute swap step and update state + (uint256 _stepNet, uint256 _stepOut, int24 _newTick) = + _executeSwapStep(_amountInRemainingNet, _nextTick, _initialized, _ctx); + + // If the step couldn't produce output (swap too small to move a full tick), stop. + if (_stepNet == 0) break; + + _amountInRemainingNet -= _stepNet; + _amountOutCalculated += _stepOut; + currentTick = _newTick; + } + + uint256 _totalNetUsed = _amountInNet - _amountInRemainingNet; + _amountInUsed = + FixedPointMathLib.mulDivUp(_totalNetUsed, FEE_DENOMINATOR, FEE_DENOMINATOR - FEE_RATE); + if (_amountInUsed > _amountInGross) _amountInUsed = _amountInGross; + } + + /// @notice Executes a single swap step: calculates amounts, updates fees, crosses tick if needed + /// @param _amountInNet Net input amount (after fees) available for this step + /// @param _nextTick The next initialized tick boundary + /// @param _initialized Whether _nextTick is an initialized tick with liquidity changes + /// @param _ctx Swap context containing direction flags + function _executeSwapStep( + uint256 _amountInNet, + int24 _nextTick, + bool _initialized, + SwapContext memory _ctx + ) internal returns (uint256, uint256, int24) { + (uint256 _netConsumed, uint256 _amountOut, int256 _stepPrice, int24 _stepTick) = _calculateSwap( + _amountInNet.toInt256(), currentPrice, _nextTick, _ctx.principalForCash, _ctx.tickLimit + ); + // If the swap step couldn't move a full tick (sub-tick snap to zero), stop gracefully. + if (_netConsumed == 0 || _amountOut == 0) return (0, 0, currentTick); + + // Compute fee for this step directly from net consumed and attribute to LPs. + // Per-step tracking is needed because activeLiquidity changes at tick crossings. + uint256 _stepFee = FixedPointMathLib.mulDiv(_netConsumed, FEE_RATE, FEE_DENOMINATOR - FEE_RATE); + if (_stepFee > 0 && activeLiquidity > 0) { + if (_ctx.principalForCash) { + feeGrowthGlobal0X128 += FixedPointMathLib.mulDiv(_stepFee, Q128, activeLiquidity); + } else { + feeGrowthGlobal1X128 += FixedPointMathLib.mulDiv(_stepFee, Q128, activeLiquidity); + } + } + + // Cross tick if needed + bool _hitBoundary = _stepTick == _nextTick; + if (_hitBoundary && _initialized) { + _crossTick(_nextTick); + int128 _liquidityNet = ticks[_nextTick].liquidityNet; + if (_ctx.tickMovingLeft) _liquidityNet = -_liquidityNet; + activeLiquidity = LiquidityMath.addDelta(activeLiquidity, _liquidityNet); + } + + // Update price and calculate new tick. + // The search offset (currentTick - YIELD_PER_TICK) is already applied in _executeSwapLoop, + // so we do NOT subtract here — that would double-adjust, pushing currentTick below the + // actually-traded price and inflating LP cash claims on withdrawal. + currentPrice = _stepPrice; + return (_netConsumed, _amountOut, _stepTick); + } + + /// @notice Routes swap calculation to the appropriate direction-specific function + /// @param _amountIn The input amount to swap + /// @param _currentPrice The current price at the start of this step + /// @param _nextTick The next initialized tick boundary + /// @param _principalToCash True for PT→cash, false for cash→PT + /// @param _tickLimit Tick limit; NO_TICK_LIMIT means no limit + /// @return amountInConsumed Amount of input consumed + /// @return amountOut Amount of output produced + /// @return newPrice Price after the swap step + /// @return newTick Tick after the swap step + function _calculateSwap( + int256 _amountIn, + int256 _currentPrice, + int24 _nextTick, + bool _principalToCash, + int24 _tickLimit + ) internal view returns (uint256, uint256, int256, int24) { + if (_principalToCash) { + return _getCashSwapAmount(_amountIn, _currentPrice, _nextTick, _tickLimit); + } + return _getPrincipalSwapAmount(_amountIn, _currentPrice, _nextTick, _tickLimit); + } + + // ============ Internal Functions - PT→Cash Swap Math ============ + // + // Worked example — PT→cash swap crossing an initialized tick boundary + // + // This function computes a single segment; `_executeSwapLoop` orchestrates multiple segments + // and handles tick crossing. + // + // Scenario: trader sells 9,000 PT for cash. Active segment liquidity L0 = 3,000,000. + // Next initialized tick is 25 bips away (650 − 625 = 25 bips). + // + // PT required to traverse exactly to the next boundary: + // PT_in_seg = L0 * (r0 − rNext) + // where r0 is the start yield, rNext is the boundary yield, L0 is the segment liquidity. + // Here 7,500 PT reaches the boundary; 1,500 PT remains for the next segment. + // (25 bips = 0.0025, so L0 = 7,500 / 0.0025 = 3,000,000 = 300 PT per bip.) + // + // Cash out for any segment is the integral of the discount-factor price over the yield move: + // CashOut = (Lseg / t) * (exp(-r0 * t) - exp(-r1 * t)) + // where t is time-to-maturity, r0/r1 are the start/end yields for the segment. + // + // After crossing, the remaining PT moves yield within the next segment (L1 = 1,000,000): + // Δr = X / L1 + // (1,500 / 1,000,000 = 0.0015 = 15 bips; L1 = 100 PT per bip.) + // + // Within-tick completion delegates to `_getCashSwapAmountWithinTick`. + // Total cash out = sum of per-segment cash outs. + + /// @notice Calculate cash out for PT in, up to the next tick boundary + /// @param _amountIn The amount of PT available to trade for cash + /// @param _currentPrice The price calculated with the current yield tick + /// @param _nextTick The next initialized tick boundary + /// @param _tickLimit Tick limit; NO_TICK_LIMIT means no limit + /// @return _amountInConsumed PT consumed in this step + /// @return _cashOut Cash tokens to send to the trader + /// @return _newPrice Price after this step + /// @return _newTick Tick after this step; if equal to `_nextTick`, caller should cross the tick + function _getCashSwapAmount( + int256 _amountIn, + int256 _currentPrice, + int24 _nextTick, + int24 _tickLimit + ) + internal + view + returns (uint256 _amountInConsumed, uint256 _cashOut, int256 _newPrice, int24 _newTick) + { + if (_amountIn <= 0) revert FixedFeeSwapMarket_SwapAmountCannotBeZero(); + if (_currentPrice <= 0) revert FixedFeeSwapMarket_InvalidPriceDirection(); + + uint256 _scaledTime = _scaledTimeRemainingWad(); + // Derive start yield directly from the integer tick to avoid exp/ln round-trip error. + // After integer-tick snapping, currentPrice == tickToPrice(currentTick), so the yield + // is exactly currentTick * WAD. Using _yieldWadFromPrice would introduce ~2000 wei of + // PT error per tick crossing, accumulating to insolvency over many crossings. + int256 _startYieldWad = _tickToYieldWad(currentTick); + + // For PT→cash, tick increases. Use the tick limit as target if it's lower (closer) than + // the next tick boundary. + int256 _targetYieldWad = _tickToYieldWad(_nextTick); + int256 _targetPrice = tickToPrice(_nextTick); + if (_targetPrice > _currentPrice) revert FixedFeeSwapMarket_InvalidPriceDirection(); + if (_targetYieldWad <= _startYieldWad) revert FixedFeeSwapMarket_InvalidTickTraversal(); + if (_tickLimit != NO_TICK_LIMIT && _tickLimit < _nextTick) { + _nextTick = _tickLimit; + _targetYieldWad = _tickToYieldWad(_tickLimit); + _targetPrice = _priceFromYieldWad(_targetYieldWad, _scaledTime); + } + + if (_targetPrice > _currentPrice) revert FixedFeeSwapMarket_InvalidPriceDirection(); + if (_targetYieldWad <= _startYieldWad) revert FixedFeeSwapMarket_InvalidTickTraversal(); + + uint256 _principalToTarget = FixedPointMathLib.mulDivUp( + uint256(_targetYieldWad - _startYieldWad), uint256(activeLiquidity), BIPS * WAD + ); + if (_principalToTarget == 0) revert FixedFeeSwapMarket_InvalidTickTraversal(); + + if (uint256(_amountIn) >= _principalToTarget) { + _amountInConsumed = _principalToTarget; + _newTick = _nextTick; + _newPrice = _targetPrice; + _cashOut = FixedPointMathLib.mulDiv( + uint256(activeLiquidity), uint256(_currentPrice - _newPrice), _scaledTime + ); + return (_amountInConsumed, _cashOut, _newPrice, _newTick); + } + + return _getCashSwapAmountWithinTick(uint256(_amountIn), _currentPrice, _startYieldWad); + } + + /// @notice Computes within-tick swap for PT→cash using exact exponential math + /// @dev Uses expWad to handle fractional tick movements: endPrice = exp(-endYield / BIPS * + /// scaledTime) @param _amountIn The amount of principal tokens to swap + /// @param _startPrice The price at the start of this swap step + /// @param _startYieldWad The WAD-scaled yield at the start of this swap step + /// @return _amountInConsumed Amount of input consumed + /// @return _cashOut Amount of cash tokens output + /// @return _newPrice Price after the swap + /// @return _newTick Tick after the swap + function _getCashSwapAmountWithinTick( + uint256 _amountIn, + int256 _startPrice, + int256 _startYieldWad + ) + internal + view + returns (uint256 _amountInConsumed, uint256 _cashOut, int256 _newPrice, int24 _newTick) + { + _amountInConsumed = _amountIn; + uint256 _scaledTime = _scaledTimeRemainingWad(); + + // Yield movement is linear in PT: Δyield = PT_in * BIPS / L + int256 _endYieldWad = _startYieldWad + + int256(FixedPointMathLib.mulDiv(_amountIn, BIPS * WAD, uint256(activeLiquidity))); + + // Snap to floored integer tick so swap output matches LP composition accounting. + // Returns zero consumed/output if the tick didn't move (swap too small). + _newTick = _wadToTick(_endYieldWad); + _newPrice = _priceFromYieldWad(_tickToYieldWad(_newTick), _scaledTime); + + if (_newPrice >= _startPrice) return (0, 0, _startPrice, _wadToTick(_startYieldWad)); + + // Cash_out = (L / scaledTime) * (startPrice - newPrice) + _cashOut = FixedPointMathLib.mulDiv( + uint256(activeLiquidity), uint256(_startPrice - _newPrice), _scaledTime + ); + } + + // ============ Internal Functions - Cash→PT Swap Math ============ + // + // Worked example — cash→PT swap crossing an initialized tick boundary + // + // This function computes a single segment; `_executeSwapLoop` orchestrates multiple segments + // and handles tick crossing. + // + // Scenario: t = 0.5, initial yield r0 = 665, next initialized tick rNext = 650. + // Segment 1 liquidity L1 = 3,500,000; after crossing, L2 = 1,000,000. Trader sells 6,000 cash. + // + // Cash required to reach the next tick boundary: + // Cash_in_seg = (L / t) * (exp(-rNext * t) - exp(-r * t)) + // + // If cash budget >= Cash_in_seg, the swap reaches the boundary. The caller crosses the tick + // (updating feeGrowthOutside and activeLiquidity) then re-invokes with remaining cash. + // + // If cash budget < Cash_in_seg, the swap stays within the segment. Solving for end yield: + // r1 = -(1 / t) * ln( exp(-r0 * t) + (t / L) * C ) + // + // Within-tick completion delegates to `_getPrincipalSwapAmountWithinTick`. + // Total PT out = sum of per-segment PT outs. + + /// @notice Calculate PT out for cash in, up to the next tick boundary + /// @param _amountIn The amount of cash available to trade for PT + /// @param _currentPrice The price calculated with the current yield tick + /// @param _nextTick The next initialized tick boundary + /// @param _tickLimit Tick limit; NO_TICK_LIMIT means no limit + /// @return _amountInConsumed Cash consumed in this step + /// @return _principalOut PT tokens to send to the trader + /// @return _newPrice Price after this step + /// @return _newTick Tick after this step; if equal to `_nextTick`, caller should cross the tick + function _getPrincipalSwapAmount( + int256 _amountIn, + int256 _currentPrice, + int24 _nextTick, + int24 _tickLimit + ) + internal + view + returns (uint256 _amountInConsumed, uint256 _principalOut, int256 _newPrice, int24 _newTick) + { + if (_amountIn <= 0) revert FixedFeeSwapMarket_SwapAmountCannotBeZero(); + if (_currentPrice <= 0) revert FixedFeeSwapMarket_InvalidPriceDirection(); + + uint256 _scaledTime = _scaledTimeRemainingWad(); + // Derive start yield directly from the integer tick (same rationale as _getCashSwapAmount). + int256 _startYieldWad = _tickToYieldWad(currentTick); + + // For cash→PT, tick decreases. Use the tick limit as target if it's higher (closer) than + // the next tick boundary. + int256 _targetYieldWad = _tickToYieldWad(_nextTick); + int256 _targetPrice = tickToPrice(_nextTick); + if (_targetPrice < _currentPrice) revert FixedFeeSwapMarket_InvalidPriceDirection(); + if (_targetYieldWad >= _startYieldWad) revert FixedFeeSwapMarket_InvalidTickTraversal(); + if (_tickLimit != NO_TICK_LIMIT && _tickLimit > _nextTick) { + _nextTick = _tickLimit; + _targetYieldWad = _tickToYieldWad(_tickLimit); + _targetPrice = _priceFromYieldWad(_targetYieldWad, _scaledTime); + } + + if (_targetPrice < _currentPrice) revert FixedFeeSwapMarket_InvalidPriceDirection(); + if (_targetYieldWad >= _startYieldWad) revert FixedFeeSwapMarket_InvalidTickTraversal(); + + uint256 _priceDiff = uint256(_targetPrice - _currentPrice); + uint256 _cashInToTarget = + FixedPointMathLib.mulDivUp(_priceDiff, uint256(activeLiquidity), _scaledTime); + if (_cashInToTarget == 0) revert FixedFeeSwapMarket_InvalidTickTraversal(); + + if (uint256(_amountIn) >= _cashInToTarget) { + _amountInConsumed = _cashInToTarget; + _newTick = _nextTick; + _newPrice = _targetPrice; + uint256 _yieldDeltaWad = uint256(_startYieldWad - _targetYieldWad); + _principalOut = FixedPointMathLib.mulDiv(uint256(activeLiquidity), _yieldDeltaWad, BIPS * WAD); + return (_amountInConsumed, _principalOut, _newPrice, _newTick); + } + + return _getPrincipalSwapAmountWithinTick(uint256(_amountIn), _currentPrice, _startYieldWad); + } + + /// @notice Computes within-tick swap for cash→PT using exact logarithm math + /// @dev Uses lnWad to compute: endYield = -ln(newPrice) / scaledTime * BIPS, then PT_out = L * + /// (startYield - endYield) / BIPS @param _amountIn The amount of cash tokens to swap + /// @param _startPrice The price at the start of this swap step + /// @param _startYieldWad The WAD-scaled yield at the start of this swap step + /// @return _amountInConsumed Amount of input consumed + /// @return _principalOut Amount of principal tokens output + /// @return _newPrice Price after the swap + /// @return _newTick Tick after the swap + function _getPrincipalSwapAmountWithinTick( + uint256 _amountIn, + int256 _startPrice, + int256 _startYieldWad + ) + internal + view + returns (uint256 _amountInConsumed, uint256 _principalOut, int256 _newPrice, int24 _newTick) + { + _amountInConsumed = _amountIn; + uint256 _scaledTime = _scaledTimeRemainingWad(); + + // newPrice = startPrice + (scaledTime / L) * cashIn + int256 _exactNewPrice = _startPrice + + int256(FixedPointMathLib.mulDiv(_scaledTime, _amountIn, uint256(activeLiquidity))); + if (_exactNewPrice < _startPrice) revert FixedFeeSwapMarket_InvalidPriceDirection(); + + // endYieldWad = -ln(newPrice) / scaledTime * BIPS * WAD + int256 _endYieldWad = _yieldWadFromPrice(_exactNewPrice, _scaledTime); + + // Snap to ceiled integer tick so swap output matches LP composition accounting. + // Cash→PT decreases yield, so ceiling rounds in favor of the pool (less PT output, higher + // price). Returns zero consumed/output if the tick didn't move (swap too small). + _newTick = _wadToTickCeil(_endYieldWad); + int256 _snappedYieldWad = _tickToYieldWad(_newTick); + _newPrice = _priceFromYieldWad(_snappedYieldWad, _scaledTime); + + // PT_out = L * (startYield - snappedYield) / BIPS using WAD precision + int256 _yieldMovedWad = _startYieldWad - _snappedYieldWad; + if (_yieldMovedWad <= 0) return (0, 0, _startPrice, _wadToTickCeil(_startYieldWad)); + _principalOut = + FixedPointMathLib.mulDiv(uint256(activeLiquidity), uint256(_yieldMovedWad), BIPS * WAD); + } + + // ============ Internal Functions - Liquidity Management ============ + + /// @notice Internal function to modify liquidity for a position + /// @dev Handles tick updates, bitmap flips, active liquidity changes, and fee accrual + /// @param _owner The position owner address + /// @param _tickUpper The upper tick boundary of the position + /// @param _tickLower The lower tick boundary of the position + /// @param _liquidity The liquidity delta (positive to add, negative to remove) + /// @return The token amounts required to deposit (positive) or to withdraw (negative) + function _modifyLiquidity(address _owner, int24 _tickUpper, int24 _tickLower, int128 _liquidity) + internal + returns (TokenDelta memory) + { + if (_tickLower >= _tickUpper) revert FixedFeeSwapMarket_InvalidTickRange(); + if (_liquidity == 0) return TokenDelta({amount0: 0, amount1: 0}); + + bytes32 _positionId = _getPositionId(_owner, _tickUpper, _tickLower); + PositionInfo storage position = positions[_positionId]; + + // Accrue fees before mutating ticks (must happen before tick data is cleared) + if (position.owner != address(0)) _accruePositionFees(position); + + // Mutate ticks / bitmap / activeLiquidity + (bool _flippedLower,) = _updateTickLiquidity(_tickLower, _liquidity, false); + (bool _flippedUpper,) = _updateTickLiquidity(_tickUpper, _liquidity, true); + + if (_flippedLower) { + tickBitmap.flipTick(_tickLower, YIELD_PER_TICK); + if (_liquidity < 0) delete ticks[_tickLower]; + } + if (_flippedUpper) { + tickBitmap.flipTick(_tickUpper, YIELD_PER_TICK); + if (_liquidity < 0) delete ticks[_tickUpper]; + } + + // Get token amounts owed for the liquidity + // TOKEN0 = PT, TOKEN1 = Cash + // Below current tick (unvisited yields) → LP holds cash (TOKEN1) + // Above current tick (visited yields) → LP holds principal (TOKEN0) + TokenDelta memory _delta; + if (currentTick < _tickLower) { + // All liquidity at higher yields (unvisited) — LP holds only cash + _delta = TokenDelta({ + amount0: 0, amount1: _getAmountCashDelta(_tickLower, _tickUpper, _liquidity).toInt128() + }); + } else if (currentTick < _tickUpper) { + activeLiquidity = LiquidityMath.addDelta(activeLiquidity, _liquidity); + + // Split at current position: + // [tickLower, currentTick] visited → principal (TOKEN0) + // [currentTick, tickUpper] unvisited → cash (TOKEN1) + _delta = TokenDelta({ + amount0: _getAmountPrincipalDelta(_tickLower, currentTick, _liquidity).toInt128(), + amount1: _getAmountCashDelta(currentTick, _tickUpper, _liquidity).toInt128() + }); + } else { + // All liquidity at lower yields (visited) — LP holds only principal + _delta = TokenDelta({ + amount0: _getAmountPrincipalDelta(_tickLower, _tickUpper, _liquidity).toInt128(), amount1: 0 + }); + } + + emit LiquidityModified( + _positionId, _owner, _tickLower, _tickUpper, _liquidity, _delta.amount0, _delta.amount1 + ); + return _delta; + } + + /// @notice Updates the liquidity at a tick and initializes fee growth if tick is newly + /// initialized @param _tick The tick that will be updated + /// @param _liquidityDelta Amount of liquidity to be added when tick is crossed from left to + /// right, or subtracted when crossed from right to left + /// @param _upper true for updating a position's upper tick, or + /// false for updating a position's lower tick + /// @return flipped Whether the tick was flipped from initialized to uninitialized or vice versa + /// @return liquidityGrossAfter The gross liquidity at the tick after the update + function _updateTickLiquidity(int24 _tick, int128 _liquidityDelta, bool _upper) + internal + returns (bool, uint128) + { + TickInfo storage info = ticks[_tick]; + + uint128 _liquidityGrossBefore = info.liquidityGross; + int128 _liquidityNetBefore = info.liquidityNet; + + uint128 _liquidityGrossAfter; + if (_liquidityDelta > 0) { + _liquidityGrossAfter = _liquidityGrossBefore + _liquidityDelta.toUint128(); + } else { + if (_liquidityGrossBefore < _liquidityDelta.abs()) { + revert FixedFeeSwapMarket_TickLiquidityGrossUnderflow(); + } + _liquidityGrossAfter = _liquidityGrossBefore - uint128(_liquidityDelta.abs()); + } + + bool _flipped = (_liquidityGrossAfter == 0) != (_liquidityGrossBefore == 0); + + // Initialize fee growth outside when tick becomes initialized + // By convention, assume all prior fee growth occurred below this tick + if (_liquidityGrossBefore == 0 && _liquidityGrossAfter > 0) { + if (_tick <= currentTick) { + info.feeGrowthOutside0X128 = feeGrowthGlobal0X128; + info.feeGrowthOutside1X128 = feeGrowthGlobal1X128; + } + // If tick > currentTick, feeGrowthOutside stays 0 (all growth is below) + } + + // when the lower (upper) tick is crossed left to right, liquidity must be added (removed) + // when the lower (upper) tick is crossed right to left, liquidity must be removed (added) + int128 _liquidityNet = + _upper ? _liquidityNetBefore - _liquidityDelta : _liquidityNetBefore + _liquidityDelta; + + info.liquidityGross = _liquidityGrossAfter; + info.liquidityNet = _liquidityNet; + + return (_flipped, _liquidityGrossAfter); + } + + /// @notice Validates position exists and caller is owner, returns storage pointer + function _requirePositionOwner(bytes32 _positionId) + internal + view + returns (PositionInfo storage position) + { + position = positions[_positionId]; + if (position.owner == address(0)) revert FixedFeeSwapMarket_PositionNotFound(); + if (position.owner != msg.sender) revert FixedFeeSwapMarket_NotPositionOwner(); + } + + // ============ Internal Functions - Token Amount Calculations ============ + + /// @notice Calculates the principal token amount for a liquidity position across a tick range + /// @dev Amount = liquidity * (upperYield - lowerYield) / BIPS, consistent with swap math + /// @param _lowerYield The lower tick boundary of the range + /// @param _upperYield The upper tick boundary of the range + /// @param _liquidity The liquidity amount, positive for mint and negative for burn + /// @return The principal token delta: positive values indicate deposit required, negative values + /// indicate withdrawal + function _getAmountPrincipalDelta(int24 _lowerYield, int24 _upperYield, int128 _liquidity) + internal + pure + returns (int256) + { + int256 _liq = int256(_liquidity); + uint256 _absLiquidity = _liq.abs(); + uint256 _amount = + FixedPointMathLib.mulDiv(_absLiquidity, uint256(uint24(_upperYield - _lowerYield)), BIPS); + return _liq < 0 ? -int256(_amount) : int256(_amount); + } + + /// @notice Calculates the cash token amount for a liquidity position across a tick range + /// @dev Uses elapsed-time pricing for LP accounting to guarantee solvency. The elapsed-time + /// cash component decreases over time, ensuring the contract always holds enough cash to + /// cover withdrawals. Swap pricing uses remaining-time independently. + /// amount = liquidity * (priceLower - priceUpper) / scaledTimeElapsed + /// @param _lowerYield The lower tick boundary of the range + /// @param _upperYield The upper tick boundary of the range + /// @param _liquidity The liquidity amount, positive for mint and negative for burn + /// @return The cash token delta: positive values indicate deposit required, negative values + /// indicate withdrawal + function _getAmountCashDelta(int24 _lowerYield, int24 _upperYield, int128 _liquidity) + internal + view + returns (int256) + { + int256 _liq = int256(_liquidity); + uint256 _absLiquidity = _liq.abs(); + uint256 _scaledTimeWad = _scaledTimeElapsedWad(); + int256 _upperTickPrice = _priceElapsed(int256(_upperYield) * int256(WAD), _scaledTimeWad); + int256 _lowerTickPrice = _priceElapsed(int256(_lowerYield) * int256(WAD), _scaledTimeWad); + if (_lowerTickPrice < _upperTickPrice) revert FixedFeeSwapMarket_InvalidPriceDirection(); + uint256 _priceDiff = uint256(_lowerTickPrice - _upperTickPrice); + uint256 _amount = FixedPointMathLib.mulDiv(_absLiquidity, _priceDiff, _scaledTimeWad); + return _liq < 0 ? -int256(_amount) : int256(_amount); + } + + /// @notice Calculates the scaled time elapsed since market start as a fraction of total duration + /// @dev Used exclusively for LP accounting to ensure solvency. Returns WAD at maturity. + /// @return The scaled time elapsed in WAD precision + function _scaledTimeElapsedWad() internal view returns (uint256) { + uint256 _elapsed = block.timestamp - START_TIME; + if (_elapsed == 0) return 1; // Avoid division by zero; 1 wei of elapsed time + uint256 _duration = MATURITY - START_TIME; + if (_elapsed > _duration) _elapsed = _duration; + return (_elapsed * WAD) / _duration; + } + + /// @notice Computes elapsed-time price for LP accounting: exp(-yield / BIPS * scaledTimeElapsed) + /// @param _yieldWad The yield in WAD-scaled basis points + /// @param _scaledTime The scaled elapsed time + /// @return The discount factor price in WAD + function _priceElapsed(int256 _yieldWad, uint256 _scaledTime) internal pure returns (int256) { + return _priceFromYieldWad(_yieldWad, _scaledTime); + } + + // ============ Internal Functions - Fee Tracking ============ + + /// @notice Accrues pending fees for a position and updates its fee growth snapshots + /// @param position The position storage pointer to update + function _accruePositionFees(PositionInfo storage position) internal { + if (position.liquidity <= 0) return; + + (uint256 _fg0, uint256 _fg1) = _getFeeGrowthInside(position.tickLower, position.tickUpper); + uint256 _liq = uint256(uint128(position.liquidity)); + + // Note: Cast to uint128 assumes fee amounts won't exceed type(uint128).max. + // This is safe in practice as it would require mass fee accumulation beyond realistic volumes. + position.tokensOwed0 += uint128( + FixedPointMathLib.mulDiv(_fg0 - position.feeGrowthInside0LastX128, _liq, Q128) + ); + position.tokensOwed1 += uint128( + FixedPointMathLib.mulDiv(_fg1 - position.feeGrowthInside1LastX128, _liq, Q128) + ); + + position.feeGrowthInside0LastX128 = _fg0; + position.feeGrowthInside1LastX128 = _fg1; + } + + /// @notice Refreshes fee growth snapshots for a position (used when liquidity goes from 0 to + /// positive) + /// @param position The position storage pointer to update + function _refreshFeeSnapshots(PositionInfo storage position) internal { + (uint256 _fg0, uint256 _fg1) = _getFeeGrowthInside(position.tickLower, position.tickUpper); + position.feeGrowthInside0LastX128 = _fg0; + position.feeGrowthInside1LastX128 = _fg1; + } + + /// @notice Calculates fee growth inside a tick range + /// @param _tickLower The lower tick boundary + /// @param _tickUpper The upper tick boundary + /// @return _fg0 Fee growth for TOKEN0 inside the range + /// @return _fg1 Fee growth for TOKEN1 inside the range + function _getFeeGrowthInside(int24 _tickLower, int24 _tickUpper) + internal + view + returns (uint256 _fg0, uint256 _fg1) + { + TickInfo storage lower = ticks[_tickLower]; + TickInfo storage upper = ticks[_tickUpper]; + + bool _belowLower = currentTick < _tickLower; + bool _aboveUpper = currentTick >= _tickUpper; + + uint256 _below0 = _belowLower + ? feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 + : lower.feeGrowthOutside0X128; + uint256 _below1 = _belowLower + ? feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128 + : lower.feeGrowthOutside1X128; + uint256 _above0 = _aboveUpper + ? feeGrowthGlobal0X128 - upper.feeGrowthOutside0X128 + : upper.feeGrowthOutside0X128; + uint256 _above1 = _aboveUpper + ? feeGrowthGlobal1X128 - upper.feeGrowthOutside1X128 + : upper.feeGrowthOutside1X128; + + _fg0 = feeGrowthGlobal0X128 - _below0 - _above0; + _fg1 = feeGrowthGlobal1X128 - _below1 - _above1; + } + + /// @notice Updates fee growth outside values when crossing a tick + /// @dev Crossing a tick flips the meaning of "outside" - subtracting from global inverts + /// the above/below perspective. This is the standard Uniswap v3 tick crossing pattern. + /// @param _tick The tick being crossed + function _crossTick(int24 _tick) internal { + TickInfo storage info = ticks[_tick]; + info.feeGrowthOutside0X128 = feeGrowthGlobal0X128 - info.feeGrowthOutside0X128; + info.feeGrowthOutside1X128 = feeGrowthGlobal1X128 - info.feeGrowthOutside1X128; + } + + // ============ Internal Functions - Fee Helpers ============ + + /// @notice Returns the amount available to swap after fees, given a gross exact-in amount. + function _netOfFee(uint256 _amountInGross) internal view returns (uint256) { + return FixedPointMathLib.mulDiv(_amountInGross, FEE_DENOMINATOR - FEE_RATE, FEE_DENOMINATOR); + } + + // ============ Internal Functions - Price/Yield Conversions ============ + + /// @notice Calculates the scaled time remaining until maturity as a fraction of total duration + /// @dev Returns a WAD-scaled value where 1e18 represents the full duration remaining. + /// At START_TIME returns WAD (full duration). Reverts at or after MATURITY. + /// @return The scaled time remaining in WAD precision + function _scaledTimeRemainingWad() internal view returns (uint256) { + if (block.timestamp >= MATURITY) revert FixedFeeSwapMarket_MarketExpired(); + uint256 _remaining = MATURITY - block.timestamp; + uint256 _duration = MATURITY - START_TIME; + return (_remaining * WAD) / _duration; + } + + /// @notice Converts a WAD-scaled yield value to an int24 tick using floor division + /// @param _yieldWad The WAD-scaled yield value to convert + /// @return The tick as int24, floored toward negative infinity + function _wadToTick(int256 _yieldWad) internal view returns (int24) { + int256 _yieldPerTickWad = int256(YIELD_PER_TICK) * int256(WAD); + if (_yieldWad >= 0) return int24(_yieldWad / _yieldPerTickWad); + return int24((_yieldWad - _yieldPerTickWad + 1) / _yieldPerTickWad); + } + + /// @notice Converts a WAD-scaled yield to an int24 tick using ceiling division + /// @dev Rounds toward positive infinity, the opposite of _wadToTick + /// @param _yieldWad The WAD-scaled yield value to convert + /// @return The tick as int24, ceiled toward positive infinity + function _wadToTickCeil(int256 _yieldWad) internal view returns (int24) { + int256 _yieldPerTickWad = int256(YIELD_PER_TICK) * int256(WAD); + if (_yieldWad >= 0) return int24((_yieldWad + _yieldPerTickWad - 1) / _yieldPerTickWad); + return int24(_yieldWad / _yieldPerTickWad); + } + + /// @notice Converts a tick index to WAD-scaled yield using the configured yield spacing + function _tickToYieldWad(int256 _tick) internal view returns (int256) { + return _tick * int256(YIELD_PER_TICK) * int256(WAD); + } + + /// @notice Derives the WAD-scaled yield from a discount factor price + /// @dev Uses the formula: yield = -ln(price) / scaledTime * BIPS + /// @param _price The discount factor price in WAD + /// @param _scaledTime The scaled time remaining until maturity + /// @return The yield in WAD-scaled basis points + function _yieldWadFromPrice(int256 _price, uint256 _scaledTime) internal pure returns (int256) { + int256 _lnP = FixedPointMathLib.lnWad(_price); + if (_lnP <= 0) { + return int256(FixedPointMathLib.mulDiv(uint256(-_lnP), BIPS * WAD, _scaledTime)); + } + return -int256(FixedPointMathLib.mulDiv(uint256(_lnP), BIPS * WAD, _scaledTime)); + } + + /// @notice Derives a discount factor price from a WAD-scaled yield + /// @dev Uses the formula: price = exp(-yield / BIPS * scaledTime) + /// @param _yieldWad The yield in WAD-scaled basis points + /// @param _scaledTime The scaled time remaining until maturity + /// @return The discount factor price in WAD + function _priceFromYieldWad(int256 _yieldWad, uint256 _scaledTime) + internal + pure + returns (int256) + { + int256 _exponentWad; + if (_yieldWad >= 0) { + _exponentWad = -int256(FixedPointMathLib.mulDiv(uint256(_yieldWad), _scaledTime, BIPS * WAD)); + } else { + _exponentWad = int256(FixedPointMathLib.mulDiv(uint256(-_yieldWad), _scaledTime, BIPS * WAD)); + } + return _exponentWad.expWad(); + } + + /// @dev Position IDs are hashed as keccak256(abi.encodePacked(tickUpper, tickLower, owner)). + function _getPositionId(address _owner, int24 _tickUpper, int24 _tickLower) + internal + pure + returns (bytes32) + { + return keccak256(abi.encodePacked(_tickUpper, _tickLower, _owner)); + } +} diff --git a/src/LPVault.sol b/src/LPVault.sol index 534b455..fdcc6b6 100644 --- a/src/LPVault.sol +++ b/src/LPVault.sol @@ -624,12 +624,12 @@ contract LPVault is VaultShareToken, IUnlockCallback { (uint256 _feeGrowthInside0NowX128, uint256 _feeGrowthInside1NowX128) = POOL_MANAGER.getFeeGrowthInside(_poolId, _position.tickLower, _position.tickUpper); - PositionCheckpoint storage _checkpoint = positionCheckpoints[_i]; + PositionCheckpoint storage checkpoint = positionCheckpoints[_i]; // Only compute fees if position has liquidity. if (_liquidity > 0) { - uint256 _feeGrowthInside0LastX128 = _checkpoint.feeGrowthInside0LastX128; - uint256 _feeGrowthInside1LastX128 = _checkpoint.feeGrowthInside1LastX128; + uint256 _feeGrowthInside0LastX128 = checkpoint.feeGrowthInside0LastX128; + uint256 _feeGrowthInside1LastX128 = checkpoint.feeGrowthInside1LastX128; // Compute growth deltas since last checkpoint. uint256 _deltaFeeGrowth0X128 = _feeGrowthInside0NowX128 - _feeGrowthInside0LastX128; @@ -644,8 +644,8 @@ contract LPVault is VaultShareToken, IUnlockCallback { } // Always advance checkpoints to prevent claiming fees from before we had liquidity. - _checkpoint.feeGrowthInside0LastX128 = _feeGrowthInside0NowX128; - _checkpoint.feeGrowthInside1LastX128 = _feeGrowthInside1NowX128; + checkpoint.feeGrowthInside0LastX128 = _feeGrowthInside0NowX128; + checkpoint.feeGrowthInside1LastX128 = _feeGrowthInside1NowX128; } // Update fee indexes if any fees accrued. diff --git a/test/FixedFeeSwapMarket.t.sol b/test/FixedFeeSwapMarket.t.sol new file mode 100644 index 0000000..290f22d --- /dev/null +++ b/test/FixedFeeSwapMarket.t.sol @@ -0,0 +1,4204 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {stdError} from "forge-std/StdError.sol"; +import {FixedFeeSwapMarket} from "src/FixedFeeSwapMarket.sol"; +import {FixedFeeSwapMarketHarness} from "test/harness/FixedFeeSwapMarketHarness.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {Hooks} from "v4-core/libraries/Hooks.sol"; +import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol"; +import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol"; +import {BeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol"; +import {Currency} from "v4-core/types/Currency.sol"; +import {IHooks} from "v4-core/interfaces/IHooks.sol"; +import {BaseHook} from "v4-periphery/src/utils/BaseHook.sol"; + +struct Amounts { + int256 amount0; + int256 amount1; +} + +struct SharedWithdrawalCtx { + uint128 _aliceLiquidity; + uint128 _bobLiquidity; + int24 _tickLower; + int24 _tickUpper; + bytes32 _alicePositionId; + int16 lowerWord; + uint8 lowerBit; + int16 upperWord; + uint8 upperBit; + uint256 _lowerWordAfterMint; + uint256 _upperWordAfterMint; +} + +abstract contract FixedFeeSwapMarketTest is Test { + FixedFeeSwapMarketHarness public hook; + MockERC20 public token0; + MockERC20 public token1; + PoolKey internal poolKey; + SwapParams internal defaultSwapParams; + + uint48 public constant MARKET_LENGTH = 365 days; + // Large balance to support very large fuzzed _liquidity ranges without incidental ERC20 reverts. + uint256 public constant INITIAL_BALANCE = 1_000_000_000_000e18; + uint256 public constant SCALE_FACTOR = 1e18; + uint256 public constant BIPS = 10_000; + int24 internal constant MAX_TICK = 100_000; + // Fee rate in hundredths of _a bip (3000 = 0.30%) + // Set to 0 for existing tests to pass without fee impact + uint24 public constant FEE_RATE = 0; + uint24 internal constant LP_FEE_RATE = 3000; // 0.30% + int24 public constant YIELD_PER_TICK = 1; + int24 public constant NO_TICK_LIMIT = type(int24).max; + + enum RangeScenario { + BelowCurrent, + InRange, + AboveCurrent, + LowerAtCurrent, + UpperAtCurrent + } + + address public alice = makeAddr("alice"); + address public bob = makeAddr("bob"); + + function setUp() public virtual { + token0 = new MockERC20("Token0", "TKN0", 18); + token1 = new MockERC20("Token1", "TKN1", 18); + + // Sort tokens + if (address(token0) > address(token1)) (token0, token1) = (token1, token0); + + // Use _a mock address for manager + address _mockManager = makeAddr("_mockManager"); + + // Deploy harness directly - validation is overridden to skip address checks + hook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + YIELD_PER_TICK, + IPoolManager(_mockManager), + 0 + ); + + // Warp forward for realistic test conditions (prices are near-fully discounted at START_TIME) + vm.warp(block.timestamp + 1 days); + + // Fund users + _fundUser(alice); + _fundUser(bob); + + // Approvals + _approveUser(alice); + _approveUser(bob); + } + + // ============ Helper Functions ============ + + function _deployHookWithSpacing(int24 _yieldPerTick) + internal + returns (FixedFeeSwapMarketHarness) + { + address _mockManager = makeAddr("yieldToTickManager"); + return new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + _yieldPerTick, + IPoolManager(_mockManager), + 0 + ); + } + + function _fundUser(address _user) internal { + token0.mint(_user, INITIAL_BALANCE); + token1.mint(_user, INITIAL_BALANCE); + } + + function _resetHookWithInitialTick(int24 _initialTick, string memory _managerLabel) internal { + hook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + YIELD_PER_TICK, + IPoolManager(makeAddr(_managerLabel)), + _initialTick + ); + _approveUser(alice); + _approveUser(bob); + } + + function _approveUser(address _user) internal { + vm.startPrank(_user); + token0.approve(address(hook), type(uint256).max); + token1.approve(address(hook), type(uint256).max); + vm.stopPrank(); + } + + function _boundTick(int24 _tick) internal pure returns (int24) { + return int24(bound(int256(_tick), 0, 100_000)); + } + + function _boundTickRange(int24 _lower, int24 _upper) internal pure returns (int24, int24) { + _lower = int24(bound(int256(_lower), 0, 9999)); + _upper = int24(bound(int256(_upper), int256(_lower) + 1, 10_000)); + return (_lower, _upper); + } + + function _boundLiquidity(uint128 _liquidity) internal pure returns (uint128) { + // Liquidity needs to be large enough to avoid rounding to 0 in _getAmountCashDelta + // With _remaining-_time pricing near START_TIME, scaledTime ≈ WAD, so 1e17 is adequate + // But not too large to cause overflow in other calculations + return uint128(bound(_liquidity, 1e17, 3e24)); + } + + function _boundFeeSwapAmount(uint256 _amount, uint128 _liquidity) + internal + pure + returns (uint256) + { + uint256 _minSwap = uint256(_liquidity) * 2 / BIPS; + uint256 _maxSwap = uint256(_liquidity) * 200 / BIPS; + return bound(_amount, _minSwap, _maxSwap); + } + + function _boundSwapForRange( + uint256 _amount, + uint128 _liquidity, + int24 _tickUpper, + uint256 _maxBipsDesired + ) internal pure returns (uint256) { + uint256 _maxBips = uint256(int256(_tickUpper)) - 2; + if (_maxBips > _maxBipsDesired) _maxBips = _maxBipsDesired; + return bound(_amount, uint256(_liquidity) * 2 / BIPS, uint256(_liquidity) * _maxBips / BIPS); + } + + function _boundTimeElapsed(uint256 _time) internal view returns (uint256) { + return bound(_time, 0, MARKET_LENGTH); + } + + function _warpFromStart(uint256 _timeElapsed) internal { + vm.warp(hook.getStartTime() + _timeElapsed); + } + + function _warpToMintTime() internal { + _warpFromStart(1); + } + + function _expectedMintAmounts(int24 _tickLower, int24 _tickUpper, uint128 _liquidity) + internal + returns (int256 expected0, int256 expected1) + { + int24 _currentTick = hook.getCurrentTick(); + int128 _liq = int128(uint128(_liquidity)); + + if (_currentTick < _tickLower) { + expected1 = hook.exposed_getAmountCashDelta(_tickLower, _tickUpper, _liq); + } else if (_currentTick < _tickUpper) { + expected0 = hook.exposed_getAmountPrincipalDelta(_tickLower, _currentTick, _liq); + expected1 = hook.exposed_getAmountCashDelta(_currentTick, _tickUpper, _liq); + } else { + expected0 = hook.exposed_getAmountPrincipalDelta(_tickLower, _tickUpper, _liq); + } + } + + function _boundScenario(uint8 _scenario) internal pure returns (RangeScenario) { + return RangeScenario(uint8(bound(_scenario, 0, uint8(type(RangeScenario).max)))); + } + + function _rangeForScenario(int24 _currentTick, RangeScenario _scenario, int24 _width) + internal + pure + returns (int24 _tickLower, int24 _tickUpper) + { + require(_width > 0, "Width must be positive"); + + if (_scenario == RangeScenario.BelowCurrent) { + _tickUpper = _currentTick - _width; + _tickLower = _tickUpper - _width; + } else if (_scenario == RangeScenario.InRange) { + _tickLower = _currentTick - _width; + _tickUpper = _currentTick + _width; + } else if (_scenario == RangeScenario.AboveCurrent) { + _tickLower = _currentTick + _width; + _tickUpper = _tickLower + _width; + } else if (_scenario == RangeScenario.LowerAtCurrent) { + _tickLower = _currentTick; + _tickUpper = _currentTick + _width; + } else { + _tickLower = _currentTick - _width; + _tickUpper = _currentTick; + } + } + + function _standardRange(RangeScenario _scenario) + internal + view + returns (int24 _tickLower, int24 _tickUpper) + { + return _rangeForScenario(hook.getCurrentTick(), _scenario, 100); + } + + /// @notice Computes the packed bitmap slot/bit for _a given _tick. + /// @dev Mirrors `TickBitmap.position` with `tickSpacing = 1` (so the compressed _tick equals + /// `_tick`). @param _tick The _tick to locate in the bitmap. + /// @return wordPos The bitmap word key (`_tick >> 8`). + /// @return bitPos The bit index within the word (`_tick & 0xff`). + function _bitmapPosition(int24 _tick) internal pure returns (int16 wordPos, uint8 bitPos) { + assembly ("memory-safe") { + wordPos := sar(8, signextend(2, _tick)) + bitPos := and(_tick, 0xff) + } + } + + function _boundSwapAmount(uint256 _amount) internal pure returns (uint256) { + return bound(_amount, 1e16, 1e20); + } + + function _mintDefaultLiquidity(address _user) internal returns (bytes32) { + vm.prank(_user); + hook.mintLiquidity(0, 200, 1e18, type(uint256).max, type(uint256).max); + return keccak256(abi.encodePacked(int24(200), int24(0), _user)); + } + + function _mintLiquidityAtTicks(address _user, int24 _lower, int24 _upper, uint128 _liquidity) + internal + returns (bytes32) + { + vm.prank(_user); + hook.mintLiquidity(_lower, _upper, _liquidity, type(uint256).max, type(uint256).max); + return keccak256(abi.encodePacked(_upper, _lower, _user)); + } + + function _mintLiquidity(address _user, int24 _tickLower, int24 _tickUpper, uint128 _liquidity) + internal + returns (uint256 token0Used, uint256 token1Used) + { + uint256 _balance0Before = token0.balanceOf(_user); + uint256 _balance1Before = token1.balanceOf(_user); + + vm.prank(_user); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + token0Used = _balance0Before - token0.balanceOf(_user); + token1Used = _balance1Before - token1.balanceOf(_user); + } + + function _increaseLiquidity(address _user, bytes32 _positionId, uint128 _additionalLiquidity) + internal + returns (int256 token0Used, int256 token1Used) + { + uint256 _balance0Before = token0.balanceOf(_user); + uint256 _balance1Before = token1.balanceOf(_user); + + vm.prank(_user); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + + token0Used = int256(_balance0Before - token0.balanceOf(_user)); + token1Used = int256(_balance1Before - token1.balanceOf(_user)); + } + + function _decreaseAllLiquidity(address _user, bytes32 _positionId, uint128 _liquidity) internal { + vm.prank(_user); + hook.decreaseLiquidity(_positionId, -int128(_liquidity), 0, 0); + } + + function _decreaseLiquidity(address _user, bytes32 _positionId, uint128 _removeAmount) + internal + returns (uint256 token0Received, uint256 token1Received) + { + uint256 _balance0Before = token0.balanceOf(_user); + uint256 _balance1Before = token1.balanceOf(_user); + + vm.prank(_user); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + token0Received = token0.balanceOf(_user) - _balance0Before; + token1Received = token1.balanceOf(_user) - _balance1Before; + } + + function _getPositionId(int24 _tickUpper, int24 _tickLower, address _owner) + internal + pure + returns (bytes32) + { + return keccak256(abi.encodePacked(_tickUpper, _tickLower, _owner)); + } + + /// @dev Mints _liquidity at _a high range and swaps to move _currentTick to _target. + /// Uses high _tick values to avoid contaminating _ticks used by normal tests. + function _moveTickTo(int24 _target) internal { + require(_target > 0, "Target must be positive"); + _warpToMintTime(); + // Use _a range far above normal test bounds to avoid contaminating _tick _liquidity + _mintLiquidityAtTicks(alice, 0, _target + 500, 1e22); + PoolKey memory _poolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(hook)) + }); + SwapParams memory _swapParams = + SwapParams({zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: 0}); + bytes memory _hookParams = abi.encode(int256(100e22), true, _target); + hook.exposed_beforeSwap(bob, _poolKey, _swapParams, _hookParams); + } + + function _encodeHookParams(int256 _amountIn, bool _principalForCash, int24 _tickLimit) + internal + pure + returns (bytes memory) + { + return abi.encode(_amountIn, _principalForCash, _tickLimit); + } + + function _swap(address _sender, int256 _amountIn, bool _principalForCash, int24 _tickLimit) + internal + returns (bytes4, BeforeSwapDelta, uint24) + { + bytes memory _hookParams = _encodeHookParams(_amountIn, _principalForCash, _tickLimit); + return hook.exposed_beforeSwap(_sender, poolKey, defaultSwapParams, _hookParams); + } + + function _deployHookForBeforeSwapTests(uint24 _feeRate, string memory _managerLabel) + internal + returns (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey) + { + uint256 _currentTimestamp = block.timestamp; + vm.warp(1); + + _localHook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + _feeRate, + YIELD_PER_TICK, + IPoolManager(makeAddr(_managerLabel)), + 0 + ); + + uint256 _resumeTimestamp = _currentTimestamp > 1 days ? _currentTimestamp : 1 days + 1; + vm.warp(_resumeTimestamp); + + _localPoolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(_localHook)) + }); + + vm.startPrank(alice); + token0.approve(address(_localHook), type(uint256).max); + token1.approve(address(_localHook), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(bob); + token0.approve(address(_localHook), type(uint256).max); + token1.approve(address(_localHook), type(uint256).max); + vm.stopPrank(); + } + + function _swapOnHook( + FixedFeeSwapMarketHarness _localHook, + PoolKey memory _localPoolKey, + address _sender, + int256 _amountIn, + bool _principalForCash, + int24 _tickLimit + ) internal { + bytes memory _hookParams = _encodeHookParams(_amountIn, _principalForCash, _tickLimit); + _localHook.exposed_beforeSwap(_sender, _localPoolKey, defaultSwapParams, _hookParams); + } + + function _mintOnHook( + FixedFeeSwapMarketHarness _localHook, + address _user, + int24 _tickLower, + int24 _tickUpper, + uint128 _liquidity + ) internal returns (bytes32 _positionId) { + vm.prank(_user); + _localHook.mintLiquidity( + _tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max + ); + _positionId = keccak256(abi.encodePacked(_tickUpper, _tickLower, _user)); + } + + function _measurePrincipalForCashSwap( + FixedFeeSwapMarketHarness _localHook, + PoolKey memory _localPoolKey, + uint256 _amountIn + ) internal returns (uint256 inputConsumed, uint256 outputReceived) { + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + _swapOnHook(_localHook, _localPoolKey, bob, int256(_amountIn), true, NO_TICK_LIMIT); + inputConsumed = _balance0Before - token0.balanceOf(bob); + outputReceived = token1.balanceOf(bob) - _balance1Before; + } + + function _ptForOneBip() internal view returns (uint256) { + return uint256(hook.getActiveLiquidity()) / BIPS; + } + + function _initializePrice() internal returns (int256) { + if (hook.getCurrentPrice() != 0) return hook.getCurrentPrice(); + + _swap(bob, 1, true, hook.getCurrentTick()); + + int256 _price = hook.getCurrentPrice(); + assertTrue(_price > 0, "Price must be initialized"); + return _price; + } + + /// @notice Upper bound on _tick movement for _a given swap input and active _liquidity. + /// @dev Each _tick consumes ~_activeLiquidity/BIPS of input, so _ticks ≈ _amountIn * BIPS / + /// _activeLiquidity. +1 for Solidity integer division truncation, +1 for the swap's _tick + /// snapping to integers. + function _maxTickMove(uint256 _amountIn, uint128 _activeLiquidity) internal pure returns (int24) { + return int24(int256(_amountIn * BIPS / uint256(_activeLiquidity))) + 2; + } + + /// @notice Minimum input required to move the _tick by _a given number of _ticks. + /// @dev Inverse of `_maxTickMove`. Each _tick consumes ~_activeLiquidity/BIPS of input. + function _minInputForTicks(uint24 _ticks) internal view returns (uint256) { + return _ptForOneBip() * uint256(_ticks); + } + + /// @notice Expected fee growth (Q128-scaled) for _a given _consumed _amount and active + /// _liquidity. @dev fee ≈ _consumed * FEE_RATE / FEE_DENOMINATOR; growth = fee * Q128 / + /// _liquidity. + function _expectedFeeGrowth(uint256 _consumed, uint128 _liquidity) + internal + pure + returns (uint256) + { + uint256 _fee = _consumed * LP_FEE_RATE / 1_000_000; + return _fee * (1 << 128) / uint256(_liquidity); + } + + /// @notice Max fee growth rounding error from per-step accumulation across _tick crossings. + /// @dev The contract accumulates fee growth per _tick step, each independently rounding the fee + /// (mulDivUp, +1 wei) and the Q128-scaled growth (mulDiv, -1 unit). Combined this introduces + /// up to (Q128 / _liquidity + 1) of error per _tick crossed. For typical values (1e20 + /// _liquidity, 100 _ticks) the _tolerance is ~3.4e20 in Q128 units — about 1e-13 relative to + /// _expected growth. + function _feeGrowthRoundingTolerance(uint256 _amountIn, uint128 _liquidity) + internal + pure + returns (uint256) + { + return uint256(int256(_maxTickMove(_amountIn, _liquidity))) + * ((1 << 128) / uint256(_liquidity) + 1); + } + + function _assertApproxEqRelBps(uint256 _a, uint256 _b, uint256 _toleranceBps, string memory _err) + internal + pure + { + if (_a == 0 && _b == 0) return; + uint256 _diff = _a > _b ? _a - _b : _b - _a; + uint256 _maxVal = _a > _b ? _a : _b; + uint256 _maxDiff = (_maxVal * _toleranceBps) / BIPS; + assertTrue(_diff <= _maxDiff, _err); + } +} + +contract Constructor is FixedFeeSwapMarketTest { + function testFuzz_SetsAllConstructorArgs( + uint48 _marketLength, + uint24 _feeRate, + int24 _yieldPerTick, + int24 _initialTick, + address _mockManager + ) public { + uint48 _startTime = uint48(block.timestamp); + uint48 _maxMarketLength = type(uint48).max - _startTime; + + _marketLength = uint48(bound(uint256(_marketLength), 0, uint256(_maxMarketLength))); + _feeRate = uint24(bound(_feeRate, 0, 999_999)); + _yieldPerTick = int24(bound(int256(_yieldPerTick), 1, type(int24).max)); + + FixedFeeSwapMarketHarness _hook = new FixedFeeSwapMarketHarness( + _marketLength, + address(token0), + address(token1), + _feeRate, + _yieldPerTick, + IPoolManager(_mockManager), + _initialTick + ); + + assertEq(_hook.getStartTime(), _startTime, "start _time"); + assertEq(_hook.getMaturity(), _startTime + _marketLength, "maturity"); + assertEq(_hook.getToken0(), address(token0), "token0"); + assertEq(_hook.getToken1(), address(token1), "token1"); + assertEq(_hook.FEE_RATE(), _feeRate, "fee rate"); + assertEq(_hook.YIELD_PER_TICK(), _yieldPerTick, "yield per _tick"); + assertEq(address(_hook.poolManager()), _mockManager, "pool manager"); + assertEq(_hook.getCurrentTick(), _initialTick, "initial _tick"); + } + + function testFuzz_RevertIf_FeeRateTooHigh(uint24 _feeRate) public { + _feeRate = uint24(bound(_feeRate, 1_000_000, type(uint24).max)); + address _mockManager = makeAddr("constructorMockManager"); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_FeeRateTooHigh.selector); + new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + _feeRate, + YIELD_PER_TICK, + IPoolManager(_mockManager), + 0 + ); + } + + function testFuzz_RevertIf_InvalidYieldSpacing(int24 _yieldPerTick) public { + _yieldPerTick = int24(bound(int256(_yieldPerTick), type(int24).min, 0)); + address _mockManager = makeAddr("constructorMockManagerSpacing"); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidYieldSpacing.selector); + new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + _yieldPerTick, + IPoolManager(_mockManager), + 0 + ); + } +} + +contract GetHookPermissions is FixedFeeSwapMarketTest { + function test_ReturnsCorrectPermissions() public view { + Hooks.Permissions memory _perms = hook.getHookPermissions(); + assertTrue(_perms.beforeAddLiquidity, "beforeAddLiquidity should be true"); + assertTrue(_perms.beforeRemoveLiquidity, "beforeRemoveLiquidity should be true"); + assertTrue(_perms.beforeSwap, "beforeSwap should be true"); + assertFalse(_perms.beforeInitialize, "beforeInitialize should be false"); + assertFalse(_perms.afterInitialize, "afterInitialize should be false"); + assertFalse(_perms.afterAddLiquidity, "afterAddLiquidity should be false"); + assertFalse(_perms.afterRemoveLiquidity, "afterRemoveLiquidity should be false"); + assertFalse(_perms.afterSwap, "afterSwap should be false"); + assertFalse(_perms.beforeDonate, "beforeDonate should be false"); + assertFalse(_perms.afterDonate, "afterDonate should be false"); + assertFalse(_perms.beforeSwapReturnDelta, "beforeSwapReturnDelta should be false"); + assertFalse(_perms.afterSwapReturnDelta, "afterSwapReturnDelta should be false"); + assertFalse(_perms.afterAddLiquidityReturnDelta, "afterAddLiquidityReturnDelta should be false"); + assertFalse( + _perms.afterRemoveLiquidityReturnDelta, "afterRemoveLiquidityReturnDelta should be false" + ); + } +} + +contract YieldToTick is FixedFeeSwapMarketTest { + function testFuzz_MatchesIntegerDivisionForAnySpacing(int24 _yieldPerTick, int256 _yieldBps) + public + { + _yieldPerTick = int24(bound(int256(_yieldPerTick), 1, type(int24).max)); + FixedFeeSwapMarketHarness _hook = _deployHookWithSpacing(_yieldPerTick); + + assertEq( + _hook.yieldToTick(_yieldBps), + int24(_yieldBps / int256(_yieldPerTick)), + "yieldToTick should divide by configured spacing" + ); + } + + function testFuzz_RawYieldMatchesTickMultipliedBySpacing(int24 _yieldPerTick, int24 _tick) + public + { + _yieldPerTick = int24(bound(int256(_yieldPerTick), 1, type(int24).max)); + FixedFeeSwapMarketHarness _hook = _deployHookWithSpacing(_yieldPerTick); + + int256 _alignedYield = int256(_tick) * int256(_yieldPerTick); + assertEq(_hook.yieldToTick(_alignedYield), _tick, "Aligned yields should round-trip exactly"); + } +} + +contract TickToPrice is FixedFeeSwapMarketTest { + using FixedPointMathLib for int256; + + function _priceAtElapsed(int24 _tick, uint256 _timeElapsed) internal returns (int256) { + _warpFromStart(_timeElapsed); + return hook.tickToPrice(_tick); + } + + /// @dev At START_TIME, _remaining = duration so scaledTime = WAD and the _exponent reduces to + /// -_tick/BIPS, so _price matches exp(-_tick/BIPS). + function testFuzz_PriceAtStartMatchesExpected(int24 _tick) public { + _tick = int24(bound(int256(_tick), 0, MAX_TICK)); + + int256 _actual = _priceAtElapsed(_tick, 0); + + int256 _exponent = (-int256(_tick) * 1e18) / int256(BIPS); + int256 _expected = _exponent.expWad(); + assertApproxEqRel(_actual, _expected, 1e12, "_price at start should match exp(-_tick/BIPS)"); + } + + /// @dev For any non-mature timestamp, strictly higher yield (_tick) implies strictly _lower + /// _price. + function testFuzz_PriceDecreasesAsYieldIncreases(int24 _tickA, int24 _tickB, uint256 _timeElapsed) + public + { + _tickA = int24(bound(int256(_tickA), 0, MAX_TICK - 1)); + _tickB = int24(bound(int256(_tickB), _tickA + 1, MAX_TICK)); + _timeElapsed = bound(_timeElapsed, 1, MARKET_LENGTH - 1); + + int256 _priceA = _priceAtElapsed(_tickA, _timeElapsed); + int256 _priceB = _priceAtElapsed(_tickB, _timeElapsed); + + assertGt(_priceA, _priceB, "Higher _tick should have strictly _lower _price before maturity"); + } + + /// @dev Price increases over _time for positive yields (converges to 1.0 at maturity) + /// @notice With exp(-yield * time_remaining), less _remaining _time => less discount. + function testFuzz_PriceIncreasesOverTimeSameYield(int24 _tick, uint256 _time1, uint256 _time2) + public + { + _tick = int24(bound(int256(_tick), 1, MAX_TICK)); + _time1 = bound(_time1, 0, MARKET_LENGTH - 2); + _time2 = bound(_time2, _time1 + 1, MARKET_LENGTH - 1); + + int256 _price1 = _priceAtElapsed(_tick, _time1); + int256 _price2 = _priceAtElapsed(_tick, _time2); + + assertGe(_price2, _price1, "Price should increase over _time (converge to 1)"); + } + + /// @dev At maturity, _price equals 1 regardless of _tick (PT redeemable 1:1) + /// @notice At maturity, _remaining _time = 0, so tickToPrice returns WAD directly + function testFuzz_PriceAtMaturityIsOne(int24 _tick) public { + _tick = int24(bound(int256(_tick), 0, MAX_TICK)); + vm.warp(hook.getMaturity()); + int256 _price = hook.tickToPrice(_tick); + assertEq(_price, 1e18, "Price at maturity should be 1 regardless of _tick"); + } + + /// @dev Remaining-_time additivity: _price(_rem1+_rem2) == _price(_rem1) * _price(_rem2) + /// @notice exp(-y*(r1+r2)/d) = exp(-y*r1/d) * exp(-y*r2/d) where r = _remaining _time + function testFuzz_RemainingTimeAdditivitiveWithPrice(int24 _tick, uint256 _rem1, uint256 _rem2) + public + { + _tick = int24(bound(int256(_tick), -MAX_TICK, MAX_TICK)); + _rem1 = bound(_rem1, 0, MARKET_LENGTH); + _rem2 = bound(_rem2, 0, MARKET_LENGTH - _rem1); + + vm.warp(hook.getMaturity() - _rem1); + int256 _price1 = hook.tickToPrice(_tick); + + vm.warp(hook.getMaturity() - _rem2); + int256 _price2 = hook.tickToPrice(_tick); + + vm.warp(hook.getMaturity() - (_rem1 + _rem2)); + int256 _price12 = hook.tickToPrice(_tick); + + int256 _combined = (_price1 * _price2) / 1e18; + assertApproxEqRel( + _price12, _combined, 1e12, "_price(_rem1+_rem2) should equal _price(_rem1)*_price(_rem2)" + ); + } +} + +contract MintLiquidity is FixedFeeSwapMarketTest { + function setUp() public virtual override { + super.setUp(); + _resetHookWithInitialTick(500, "mintLiquidityManager"); + } + + function testFuzz_CreatesPositionAndUpdatesTickState( + int24 _tickLower, + int24 _tickUpper, + uint128 _liquidity + ) public { + (_tickLower, _tickUpper) = _boundTickRange(_tickLower, _tickUpper); + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + (uint128 lowerGrossBefore, int128 lowerNetBefore) = hook.getTickInfo(_tickLower); + (uint128 upperGrossBefore, int128 upperNetBefore) = hook.getTickInfo(_tickUpper); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + { + bytes32 _positionId = _getPositionId(_tickUpper, _tickLower, alice); + (address _owner, int24 _lower, int24 _upper, int128 _liq) = hook.getPositionInfo(_positionId); + assertEq(_owner, alice, "position _owner"); + assertEq(_lower, _tickLower, "position _tickLower"); + assertEq(_upper, _tickUpper, "position _tickUpper"); + assertEq(uint128(_liq), _liquidity, "position _liquidity"); + } + + { + (uint128 lowerGrossAfter, int128 lowerNetAfter) = hook.getTickInfo(_tickLower); + (uint128 upperGrossAfter, int128 upperNetAfter) = hook.getTickInfo(_tickUpper); + assertEq(lowerGrossAfter - lowerGrossBefore, _liquidity, "_lower gross delta"); + assertEq(upperGrossAfter - upperGrossBefore, _liquidity, "_upper gross delta"); + assertEq(lowerNetAfter - lowerNetBefore, int128(_liquidity), "_lower net delta"); + assertEq(upperNetAfter - upperNetBefore, -int128(_liquidity), "_upper net delta"); + } + + { + (int16 lowerWord, uint8 lowerBit) = _bitmapPosition(_tickLower); + (int16 upperWord, uint8 upperBit) = _bitmapPosition(_tickUpper); + uint256 _lowerWordValue = hook.getTickBitmapWord(lowerWord); + uint256 _upperWordValue = hook.getTickBitmapWord(upperWord); + assertTrue((_lowerWordValue & (uint256(1) << lowerBit)) != 0, "_lower bitmap bit set"); + assertTrue((_upperWordValue & (uint256(1) << upperBit)) != 0, "_upper bitmap bit set"); + } + } + + function testFuzz_TransfersExpectedAmounts( + int24 _tickLower, + int24 _tickUpper, + uint128 _liquidity, + uint256 _timeElapsed + ) public { + (_tickLower, _tickUpper) = _boundTickRange(_tickLower, _tickUpper); + _liquidity = _boundLiquidity(_liquidity); + _timeElapsed = bound(_timeElapsed, 1, MARKET_LENGTH - 1); + + _warpFromStart(_timeElapsed); + + (int256 expected0, int256 expected1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity); + uint256 _user0Before = token0.balanceOf(alice); + uint256 _user1Before = token1.balanceOf(alice); + uint256 _hook0Before = token0.balanceOf(address(hook)); + uint256 _hook1Before = token1.balanceOf(address(hook)); + uint256 _total0Before = _user0Before + _hook0Before; + uint256 _total1Before = _user1Before + _hook1Before; + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + uint256 _user0After = token0.balanceOf(alice); + uint256 _user1After = token1.balanceOf(alice); + uint256 _hook0After = token0.balanceOf(address(hook)); + uint256 _hook1After = token1.balanceOf(address(hook)); + assertEq(int256(_user0Before - _user0After), expected0, "_user token0 debit"); + assertEq(int256(_user1Before - _user1After), expected1, "_user token1 debit"); + assertEq(int256(_hook0After - _hook0Before), expected0, "hook token0 credit"); + assertEq(int256(_hook1After - _hook1Before), expected1, "hook token1 credit"); + assertEq(_user0After + _hook0After, _total0Before, "token0 conservation"); + assertEq(_user1After + _hook1After, _total1Before, "token1 conservation"); + } + + function testFuzz_UpdatesActiveLiquidityWhenRangeIncludesCurrentTick( + uint128 _liquidity, + uint8 _scenario + ) public { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + // Map to the two inclusive cases: InRange or LowerAtCurrent. + uint8 _boundedScenario = uint8(bound(_scenario, 0, 1)); + RangeScenario _rangeScenario = + _boundedScenario == 0 ? RangeScenario.InRange : RangeScenario.LowerAtCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_rangeScenario); + uint128 _activeBefore = hook.getActiveLiquidity(); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + assertEq(hook.getActiveLiquidity(), _activeBefore + _liquidity, "active _liquidity increase"); + } + + function testFuzz_DoesNotUpdateActiveLiquidityWhenRangeExcludesCurrentTick( + uint128 _liquidity, + uint8 _scenario + ) public { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + // Map to excluded cases: BelowCurrent, AboveCurrent, UpperAtCurrent. + uint8 _boundedScenario = uint8(bound(_scenario, 0, 2)); + RangeScenario _rangeScenario = _boundedScenario == 0 + ? RangeScenario.BelowCurrent + : _boundedScenario == 1 ? RangeScenario.AboveCurrent : RangeScenario.UpperAtCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_rangeScenario); + uint128 _activeBefore = hook.getActiveLiquidity(); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + assertEq(hook.getActiveLiquidity(), _activeBefore, "active _liquidity unchanged"); + } + + function testFuzz_MultipleUsersMintInSameRange( + uint128 _liquidityAlice, + uint128 _liquidityBob, + uint8 _scenario + ) public { + _liquidityAlice = _boundLiquidity(_liquidityAlice); + _liquidityBob = _boundLiquidity(_liquidityBob); + + _warpToMintTime(); + + (int24 _tickLower, int24 _tickUpper) = _standardRange(_boundScenario(_scenario)); + + vm.prank(alice); + hook.mintLiquidity( + _tickLower, _tickUpper, _liquidityAlice, type(uint256).max, type(uint256).max + ); + vm.prank(bob); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidityBob, type(uint256).max, type(uint256).max); + + (uint128 lowerGross,) = hook.getTickInfo(_tickLower); + (uint128 upperGross,) = hook.getTickInfo(_tickUpper); + assertEq(lowerGross, _liquidityAlice + _liquidityBob, "_lower gross _combined"); + assertEq(upperGross, _liquidityAlice + _liquidityBob, "_upper gross _combined"); + + bytes32 _alicePositionId = _getPositionId(_tickUpper, _tickLower, alice); + bytes32 _bobPositionId = _getPositionId(_tickUpper, _tickLower, bob); + (address aliceOwner,,, int128 aliceLiq) = hook.getPositionInfo(_alicePositionId); + (address bobOwner,,, int128 bobLiq) = hook.getPositionInfo(_bobPositionId); + assertEq(aliceOwner, alice, "alice _owner"); + assertEq(bobOwner, bob, "bob _owner"); + assertEq(uint128(aliceLiq), _liquidityAlice, "alice _liquidity"); + assertEq(uint128(bobLiq), _liquidityBob, "bob _liquidity"); + } + + function testFuzz_LiquidityBelowCurrentTickIsOnlyToken0(uint128 _liquidity, uint8 _scenario) + public + { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + uint8 _boundedScenario = uint8(bound(_scenario, 0, 1)); + RangeScenario _rangeScenario = + _boundedScenario == 0 ? RangeScenario.BelowCurrent : RangeScenario.UpperAtCurrent; + int24 _currentTick = hook.getCurrentTick(); + (int24 _tickLower, int24 _tickUpper) = _rangeForScenario(_currentTick, _rangeScenario, 100); + (int256 expected0, int256 expected1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + assertGt(expected0, 0, "requires token0"); + assertEq(expected1, 0, "does not require token1"); + assertEq(int256(_balance0Before - token0.balanceOf(alice)), expected0, "token0 _amount"); + assertEq(int256(_balance1Before - token1.balanceOf(alice)), expected1, "token1 _amount"); + } + + function testFuzz_LiquidityAboveCurrentTickIsOnlyToken1(uint128 _liquidity, uint8 _scenario) + public + { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + uint8 _boundedScenario = uint8(bound(_scenario, 0, 1)); + RangeScenario _rangeScenario = + _boundedScenario == 0 ? RangeScenario.AboveCurrent : RangeScenario.LowerAtCurrent; + int24 _currentTick = hook.getCurrentTick(); + (int24 _tickLower, int24 _tickUpper) = _rangeForScenario(_currentTick, _rangeScenario, 100); + (int256 expected0, int256 expected1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + assertEq(expected0, 0, "does not require token0"); + assertGt(expected1, 0, "requires token1"); + assertEq(int256(_balance0Before - token0.balanceOf(alice)), expected0, "token0 _amount"); + assertEq(int256(_balance1Before - token1.balanceOf(alice)), expected1, "token1 _amount"); + } + + function testFuzz_CurrentTickIsInLiquidityRange(uint128 _liquidity) public { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + (int256 expected0, int256 expected1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + + assertGt(expected0, 0, "requires token0"); + assertGt(expected1, 0, "requires token1"); + assertEq(int256(_balance0Before - token0.balanceOf(alice)), expected0, "token0 _amount"); + assertEq(int256(_balance1Before - token1.balanceOf(alice)), expected1, "token1 _amount"); + } + + function testFuzz_TokenAmountsScaleWithLiquidity_BelowOrUpperAtCurrent( + uint128 _liquidity1, + uint128 _liquidity2, + uint8 _scenario + ) public { + _liquidity1 = uint128(bound(_liquidity1, 1e17, 1e18)); + _liquidity2 = uint128(bound(_liquidity2, _liquidity1 * 2, 1e20)); + + _warpToMintTime(); + + uint8 _boundedScenario = uint8(bound(_scenario, 0, 1)); + RangeScenario _rangeScenario = + _boundedScenario == 0 ? RangeScenario.BelowCurrent : RangeScenario.UpperAtCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_rangeScenario); + (int256 expected0_1,) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity1); + (int256 expected0_2,) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity2); + assertGe(expected0_2, expected0_1, "_expected token0 monotonic"); + + (uint256 token0Used1, uint256 token1Used1) = + _mintLiquidity(alice, _tickLower, _tickUpper, _liquidity1); + (uint256 token0Used2, uint256 token1Used2) = + _mintLiquidity(bob, _tickLower, _tickUpper, _liquidity2); + assertEq(int256(token0Used1), expected0_1, "token0 used _liquidity1"); + assertEq(token1Used1, 0, "zero _liquidity1 token1"); + assertEq(int256(token0Used2), expected0_2, "token0 used _liquidity2"); + assertEq(token1Used2, 0, "zero _liquidity2 token1"); + assertGt(token0Used2, token0Used1, "larger _liquidity token0"); + } + + function testFuzz_AddingLiquidityAboveCurrentTickIncreasesToken1Used( + uint128 _liquidity1, + uint128 _liquidity2, + uint8 _scenario + ) public { + _liquidity1 = uint128(bound(_liquidity1, 1e17, 1e18)); + _liquidity2 = uint128(bound(_liquidity2, _liquidity1 * 2, 1e20)); + + _warpToMintTime(); + + uint8 _boundedScenario = uint8(bound(_scenario, 0, 1)); + RangeScenario _rangeScenario = + _boundedScenario == 0 ? RangeScenario.AboveCurrent : RangeScenario.LowerAtCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_rangeScenario); + (, int256 expected1_1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity1); + (, int256 expected1_2) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity2); + + (uint256 token0Used1, uint256 token1Used1) = + _mintLiquidity(alice, _tickLower, _tickUpper, _liquidity1); + (uint256 token0Used2, uint256 token1Used2) = + _mintLiquidity(bob, _tickLower, _tickUpper, _liquidity2); + assertEq(int256(token1Used1), expected1_1, "token1 used _liquidity1"); + assertEq(int256(token1Used2), expected1_2, "token1 used _liquidity2"); + assertEq(token0Used1, 0, "zero _liquidity1 token0"); + assertEq(token0Used2, 0, "zero _liquidity2 token0"); + assertGt(token1Used2, token1Used1, "larger _liquidity token1"); + } + + function testFuzz_InRangeLiquidityScalesBothTokensWithLiquidity( + uint128 _liquidity1, + uint128 _liquidity2 + ) public { + _liquidity1 = uint128(bound(_liquidity1, 1e17, 1e18)); + _liquidity2 = uint128(bound(_liquidity2, _liquidity1 * 2, 1e20)); + + _warpToMintTime(); + + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + (int256 expected0_1, int256 expected1_1) = + _expectedMintAmounts(_tickLower, _tickUpper, _liquidity1); + (int256 expected0_2, int256 expected1_2) = + _expectedMintAmounts(_tickLower, _tickUpper, _liquidity2); + + (uint256 token0Used1, uint256 token1Used1) = + _mintLiquidity(alice, _tickLower, _tickUpper, _liquidity1); + (uint256 token0Used2, uint256 token1Used2) = + _mintLiquidity(bob, _tickLower, _tickUpper, _liquidity2); + assertEq(int256(token0Used1), expected0_1, "token0 used _liquidity1"); + assertEq(int256(token1Used1), expected1_1, "token1 used _liquidity1"); + assertEq(int256(token0Used2), expected0_2, "token0 used _liquidity2"); + assertEq(int256(token1Used2), expected1_2, "token1 used _liquidity2"); + assertGt(token0Used2, token0Used1, "larger _liquidity token0"); + assertGt(token1Used2, token1Used1, "larger _liquidity token1"); + } + + function testFuzz_AWiderLiquidityRangeBelowTheCurrentTickRequiresMoreToken1( + uint128 _liquidity, + int24 _narrowWidth, + int24 _wideWidth + ) public { + _liquidity = _boundLiquidity(_liquidity); + _narrowWidth = int24(bound(int256(_narrowWidth), 1, 50)); + _wideWidth = int24(bound(int256(_wideWidth), int256(_narrowWidth) + 1, 200)); + + _warpToMintTime(); + + (, int24 _tickUpper) = _standardRange(RangeScenario.UpperAtCurrent); + (uint256 token0Narrow, uint256 token1Narrow) = + _mintLiquidity(alice, _tickUpper - _narrowWidth, _tickUpper, _liquidity); + (uint256 token0Wide, uint256 token1Wide) = + _mintLiquidity(bob, _tickUpper - _wideWidth, _tickUpper, _liquidity); + + assertGt(token0Wide, token0Narrow, "wider range token0"); + assertEq(token1Narrow, 0, "below-range narrow token1"); + assertEq(token1Wide, 0, "below-range wide token1"); + } + + function testFuzz_MintIsSuccessfulWhenAmountsEqualSpecifiedMax( + int24 _tickLower, + int24 _tickUpper, + uint128 _liquidity + ) public { + (_tickLower, _tickUpper) = _boundTickRange(_tickLower, _tickUpper); + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + (int256 required0, int256 required1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, uint256(required0), uint256(required1)); + } + + function testFuzz_MintingToExistingPositionAccumulatesLiquidity( + uint128 _liq1, + uint128 _liq2, + uint8 _scenario + ) public { + _liq1 = _boundLiquidity(_liq1); + _liq2 = _boundLiquidity(_liq2); + + _warpToMintTime(); + + (int24 _tickLower, int24 _tickUpper) = _standardRange(_boundScenario(_scenario)); + + bytes32 _positionId1 = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _liq1); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liq2, type(uint256).max, type(uint256).max); + bytes32 _positionId2 = _getPositionId(_tickUpper, _tickLower, alice); + + assertEq(_positionId1, _positionId2, "same position id"); + + (address _owner,,, int128 totalLiquidity) = hook.getPositionInfo(_positionId1); + assertEq(_owner, alice, "_owner"); + assertEq(uint128(totalLiquidity), _liq1 + _liq2, "position _liquidity sum"); + + (uint128 lowerGross,) = hook.getTickInfo(_tickLower); + (uint128 upperGross,) = hook.getTickInfo(_tickUpper); + assertEq(lowerGross, _liq1 + _liq2, "_lower gross sum"); + assertEq(upperGross, _liq1 + _liq2, "_upper gross sum"); + } + + function testFuzz_EmitsLiquidityModified(int24 _tickLower, int24 _tickUpper, uint128 _liquidity) + public + { + (_tickLower, _tickUpper) = _boundTickRange(_tickLower, _tickUpper); + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + bytes32 _positionId = _getPositionId(_tickUpper, _tickLower, alice); + (int256 expected0, int256 expected1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity); + + vm.expectEmit(); + emit FixedFeeSwapMarket.LiquidityModified( + _positionId, + alice, + _tickLower, + _tickUpper, + int128(_liquidity), + int128(expected0), + int128(expected1) + ); + + vm.prank(alice); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + } + + function test_MintLiquidityIsAllowedAtMaturity() public { + vm.warp(hook.getMaturity()); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, 0, 500, 1e22); + + (address _owner,,, int128 _liq) = hook.getPositionInfo(_positionId); + assertEq(_owner, alice, "Position should be created"); + assertEq(uint128(_liq), 1e22, "Liquidity should match"); + } + + // ============ Revert Tests ============ + + function testFuzz_RevertIf_TickRangeIsInvalid( + int24 _tickLower, + int24 _tickUpper, + uint128 _liquidity + ) public { + _liquidity = _boundLiquidity(_liquidity); + vm.assume(_tickLower < 0 || _tickLower >= _tickUpper); + + _warpToMintTime(); + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidTickRange.selector); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + } + + function testFuzz_RevertIf_MaxTokenAmountTooLow(uint128 _liquidity, bool _checkToken0) public { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + RangeScenario _scenario = _checkToken0 ? RangeScenario.BelowCurrent : RangeScenario.AboveCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_scenario); + + (int256 required0, int256 required1) = _expectedMintAmounts(_tickLower, _tickUpper, _liquidity); + + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidLiquidityAmount.selector); + hook.mintLiquidity( + _tickLower, + _tickUpper, + _liquidity, + _checkToken0 ? uint256(required0) - 1 : type(uint256).max, + _checkToken0 ? type(uint256).max : uint256(required1) - 1 + ); + } + + function testFuzz_RevertIf_NoTokenAllowance(uint128 _liquidity, bool _checkToken0) public { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + RangeScenario _scenario = _checkToken0 ? RangeScenario.BelowCurrent : RangeScenario.AboveCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_scenario); + + vm.startPrank(alice); + token0.approve(address(hook), 0); + token1.approve(address(hook), 0); + vm.expectRevert(stdError.arithmeticError); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + vm.stopPrank(); + } + + function testFuzz_RevertIf_TokenBalanceIsTooLow(uint128 _liquidity, bool _checkToken0) public { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + + RangeScenario _scenario = _checkToken0 ? RangeScenario.BelowCurrent : RangeScenario.AboveCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_scenario); + + vm.startPrank(alice); + token0.transfer(makeAddr("sink"), token0.balanceOf(alice)); + token1.transfer(makeAddr("sink"), token1.balanceOf(alice)); + + vm.expectRevert(stdError.arithmeticError); + hook.mintLiquidity(_tickLower, _tickUpper, _liquidity, type(uint256).max, type(uint256).max); + vm.stopPrank(); + } +} + +contract IncreaseLiquidity is FixedFeeSwapMarketTest { + function setUp() public virtual override { + super.setUp(); + _resetHookWithInitialTick(500, "increaseLiquidityManager"); + } + + // ============ State Update Tests ============ + + function testFuzz_UpdatesPositionAndTickState( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.prank(alice); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + + (address _owner, int24 _lower, int24 _upper, int128 totalLiquidity) = + hook.getPositionInfo(_positionId); + (uint128 lowerGross, int128 lowerNet) = hook.getTickInfo(_tickLower); + (uint128 upperGross, int128 upperNet) = hook.getTickInfo(_tickUpper); + uint128 _expectedTotal = _initialLiquidity + _additionalLiquidity; + + assertEq(_owner, alice, "_owner"); + assertEq(_lower, _tickLower, "_tickLower"); + assertEq(_upper, _tickUpper, "_tickUpper"); + assertEq(uint128(totalLiquidity), _expectedTotal, "position _liquidity"); + assertEq(lowerGross, _expectedTotal, "_lower gross"); + assertEq(upperGross, _expectedTotal, "_upper gross"); + assertEq(lowerNet, int128(_expectedTotal), "_lower net"); + assertEq(upperNet, -int128(_expectedTotal), "_upper net"); + } + + function testFuzz_TickBitmapBitsRemainSetAfterIncrease( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + int24 _currentTick = hook.getCurrentTick(); + (int24 _tickLower, int24 _tickUpper) = + _rangeForScenario(_currentTick, RangeScenario.InRange, 200); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int16 lowerWord, uint8 lowerBit) = _bitmapPosition(_tickLower); + (int16 upperWord, uint8 upperBit) = _bitmapPosition(_tickUpper); + uint256 _lowerWordBefore = hook.getTickBitmapWord(lowerWord); + uint256 _upperWordBefore = hook.getTickBitmapWord(upperWord); + + vm.prank(alice); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + + uint256 _lowerWordAfter = hook.getTickBitmapWord(lowerWord); + uint256 _upperWordAfter = hook.getTickBitmapWord(upperWord); + + assertEq(_lowerWordAfter, _lowerWordBefore, "_lower bitmap word"); + if (upperWord != lowerWord) assertEq(_upperWordAfter, _upperWordBefore, "_upper bitmap word"); + assertTrue((_lowerWordAfter & (uint256(1) << lowerBit)) != 0, "_lower bitmap bit set"); + assertTrue((_upperWordAfter & (uint256(1) << upperBit)) != 0, "_upper bitmap bit set"); + } + + // ============ Token Accounting Tests ============ + + function testFuzz_TransfersExpectedAmountsAndConservesTotals( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + + uint256 _user0Before = token0.balanceOf(alice); + uint256 _user1Before = token1.balanceOf(alice); + uint256 _hook0Before = token0.balanceOf(address(hook)); + uint256 _hook1Before = token1.balanceOf(address(hook)); + uint256 _total0Before = _user0Before + _hook0Before; + uint256 _total1Before = _user1Before + _hook1Before; + + vm.prank(alice); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + + uint256 _user0After = token0.balanceOf(alice); + uint256 _user1After = token1.balanceOf(alice); + uint256 _hook0After = token0.balanceOf(address(hook)); + uint256 _hook1After = token1.balanceOf(address(hook)); + + assertEq(int256(_user0Before - _user0After), expected0, "_user token0 debit"); + assertEq(int256(_user1Before - _user1After), expected1, "_user token1 debit"); + assertEq(int256(_hook0After - _hook0Before), expected0, "hook token0 credit"); + assertEq(int256(_hook1After - _hook1Before), expected1, "hook token1 credit"); + assertEq(_user0After + _hook0After, _total0Before, "token0 conservation"); + assertEq(_user1After + _hook1After, _total1Before, "token1 conservation"); + } + + function testFuzz_BelowCurrentRangeIncreaseUsesOnlyToken0( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.BelowCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + (int256 used0, int256 used1) = _increaseLiquidity(alice, _positionId, _additionalLiquidity); + + assertGt(expected0, 0, "_expected token0"); + assertEq(expected1, 0, "_expected token1"); + assertEq(used0, expected0, "token0 used"); + assertEq(used1, expected1, "token1 used"); + } + + function testFuzz_AboveCurrentRangeIncreaseUsesOnlyToken1( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.AboveCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + (int256 used0, int256 used1) = _increaseLiquidity(alice, _positionId, _additionalLiquidity); + + assertEq(expected0, 0, "_expected token0"); + assertGt(expected1, 0, "_expected token1"); + assertEq(used0, expected0, "token0 used"); + assertEq(used1, expected1, "token1 used"); + } + + function testFuzz_InRangeIncreaseUsesBothTokens( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + (int256 used0, int256 used1) = _increaseLiquidity(alice, _positionId, _additionalLiquidity); + + assertGt(expected0, 0, "_expected token0"); + assertGt(expected1, 0, "_expected token1"); + assertEq(used0, expected0, "token0 used"); + assertEq(used1, expected1, "token1 used"); + } + + function test_CashRequiredForLiquidityIncreasesOverTime() public { + uint128 _initialLiquidity = 1e18; + uint128 _additionalLiquidity = 1e24; + + _warpFromStart(1); + int24 _currentTick = hook.getCurrentTick(); + (int24 _tickLower, int24 _tickUpper) = + _rangeForScenario(_currentTick, RangeScenario.InRange, 200); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + uint256 _snapshot = vm.snapshotState(); + + _warpFromStart(1); + Amounts memory _expectedAtT1; + (_expectedAtT1.amount0, _expectedAtT1.amount1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + Amounts memory _usedAtT1; + (_usedAtT1.amount0, _usedAtT1.amount1) = + _increaseLiquidity(alice, _positionId, _additionalLiquidity); + + vm.revertToState(_snapshot); + _warpFromStart(30 days); + Amounts memory _expectedAtT2; + (_expectedAtT2.amount0, _expectedAtT2.amount1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + Amounts memory _usedAtT2; + (_usedAtT2.amount0, _usedAtT2.amount1) = + _increaseLiquidity(alice, _positionId, _additionalLiquidity); + + assertEq(_usedAtT1.amount0, _expectedAtT1.amount0, "t1 token0"); + assertEq(_usedAtT1.amount1, _expectedAtT1.amount1, "t1 token1"); + assertEq(_usedAtT2.amount0, _expectedAtT2.amount0, "t2 token0"); + assertEq(_usedAtT2.amount1, _expectedAtT2.amount1, "t2 token1"); + assertGt(_expectedAtT1.amount0, 0, "t1 token0 required"); + assertGt(_expectedAtT1.amount1, 0, "t1 token1 required"); + assertEq(_expectedAtT2.amount0, _expectedAtT1.amount0, "principal invariant"); + assertTrue(_expectedAtT2.amount1 != _expectedAtT1.amount1, "cash changes over _time"); + } + + // ============ Active Liquidity Tests ============ + + function testFuzz_InRangeIncreaseUpdatesActiveLiquidity( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + uint128 _activeBefore = hook.getActiveLiquidity(); + + vm.prank(alice); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + + uint128 _activeAfter = hook.getActiveLiquidity(); + assertEq(_activeAfter, _activeBefore + _additionalLiquidity, "active _liquidity increase"); + } + + function testFuzz_OutOfRangeIncreaseDoesNotUpdateActiveLiquidity( + uint128 _initialLiquidity, + uint128 _additionalLiquidity, + uint8 _scenario + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + uint8 _boundedScenario = uint8(bound(_scenario, 0, 1)); + RangeScenario _rangeScenario = + _boundedScenario == 0 ? RangeScenario.BelowCurrent : RangeScenario.AboveCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_rangeScenario); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + uint128 _activeBefore = hook.getActiveLiquidity(); + + vm.prank(alice); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + + uint128 _activeAfter = hook.getActiveLiquidity(); + assertEq(_activeAfter, _activeBefore, "active _liquidity unchanged"); + } + + // ============ Multiple Operations Tests ============ + + function testFuzz_MultipleIncreasesAccumulateLiquidity( + uint128 _initialLiquidity, + uint128 _increase1, + uint128 _increase2 + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _increase1 = _boundLiquidity(_increase1); + _increase2 = _boundLiquidity(_increase2); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.startPrank(alice); + hook.increaseLiquidity(_positionId, _increase1, type(uint256).max, type(uint256).max); + hook.increaseLiquidity(_positionId, _increase2, type(uint256).max, type(uint256).max); + vm.stopPrank(); + + (,,, int128 totalLiquidity) = hook.getPositionInfo(_positionId); + (uint128 lowerGross,) = hook.getTickInfo(_tickLower); + (uint128 upperGross,) = hook.getTickInfo(_tickUpper); + uint128 _expectedTotal = _initialLiquidity + _increase1 + _increase2; + + assertEq(uint128(totalLiquidity), _expectedTotal, "position _liquidity"); + assertEq(lowerGross, _expectedTotal, "_lower gross"); + assertEq(upperGross, _expectedTotal, "_upper gross"); + } + + function testFuzz_MultipleUsersIncreaseIndependentPositions( + uint128 _aliceInitial, + uint128 _aliceIncrease + ) public { + _aliceInitial = _boundLiquidity(_aliceInitial); + _aliceIncrease = _boundLiquidity(_aliceIncrease); + uint128 _bobInitial = 1e18; + uint128 _bobIncrease = 1e18; + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _alicePositionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _aliceInitial); + bytes32 _bobPositionId = _mintLiquidityAtTicks(bob, _tickLower, _tickUpper, _bobInitial); + + vm.prank(alice); + hook.increaseLiquidity(_alicePositionId, _aliceIncrease, type(uint256).max, type(uint256).max); + vm.prank(bob); + hook.increaseLiquidity(_bobPositionId, _bobIncrease, type(uint256).max, type(uint256).max); + + (,,, int128 aliceLiq) = hook.getPositionInfo(_alicePositionId); + (,,, int128 bobLiq) = hook.getPositionInfo(_bobPositionId); + (uint128 lowerGross,) = hook.getTickInfo(_tickLower); + (uint128 upperGross,) = hook.getTickInfo(_tickUpper); + uint128 _expectedTotal = _aliceInitial + _aliceIncrease + _bobInitial + _bobIncrease; + + assertEq(uint128(aliceLiq), _aliceInitial + _aliceIncrease, "alice _liquidity"); + assertEq(uint128(bobLiq), _bobInitial + _bobIncrease, "bob _liquidity"); + assertEq(lowerGross, _expectedTotal, "_lower gross"); + assertEq(upperGross, _expectedTotal, "_upper gross"); + } + + function testFuzz_EmitsLiquidityModified(uint128 _initialLiquidity, uint128 _additionalLiquidity) + public + { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + + vm.expectEmit(); + emit FixedFeeSwapMarket.LiquidityModified( + _positionId, + alice, + _tickLower, + _tickUpper, + int128(_additionalLiquidity), + int128(expected0), + int128(expected1) + ); + + vm.prank(alice); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + } + + function testFuzz_LiquidityAddedWhenAmountEqualMaxAmounts( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (int256 required0, int256 required1) = + _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + + vm.prank(alice); + hook.increaseLiquidity( + _positionId, _additionalLiquidity, uint256(required0), uint256(required1) + ); + + (,,, int128 totalLiquidity) = hook.getPositionInfo(_positionId); + assertEq( + uint128(totalLiquidity), _initialLiquidity + _additionalLiquidity, "_liquidity increased" + ); + } + + function test_RevertIf_PositionNotFound() public { + bytes32 _missingPositionId = keccak256("missing position"); + + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_PositionNotFound.selector); + hook.increaseLiquidity(_missingPositionId, 1e18, type(uint256).max, type(uint256).max); + } + + function testFuzz_RevertIf_NotCalledByPositionOwner( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.prank(bob); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_NotPositionOwner.selector); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + } + + function testFuzz_RevertIf_MaxToken0AmountIsTooLow( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.BelowCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (int256 required0,) = _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + + uint256 _required0U = uint256(required0); + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidLiquidityAmount.selector); + hook.increaseLiquidity(_positionId, _additionalLiquidity, _required0U - 1, type(uint256).max); + } + + function testFuzz_RevertIf_MaxToken1AmountIsTooLow( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.AboveCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + (, int256 required1) = _expectedMintAmounts(_tickLower, _tickUpper, _additionalLiquidity); + + uint256 _required1U = uint256(required1); + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidLiquidityAmount.selector); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, _required1U - 1); + } + + function testFuzz_RevertIf_Token0AllowanceMissing( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.BelowCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.startPrank(alice); + token0.approve(address(hook), 0); + vm.expectRevert(stdError.arithmeticError); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + vm.stopPrank(); + } + + function testFuzz_RevertWhen_Token1AllowanceMissing( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.AboveCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.startPrank(alice); + token1.approve(address(hook), 0); + vm.expectRevert(stdError.arithmeticError); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + vm.stopPrank(); + } + + function testFuzz_RevertIf_Token0BalanceInsufficient( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.BelowCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.startPrank(alice); + token0.transfer(makeAddr("sink"), token0.balanceOf(alice)); + vm.expectRevert(stdError.arithmeticError); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + vm.stopPrank(); + } + + function testFuzz_RevertIf_Token1BalanceInsufficient( + uint128 _initialLiquidity, + uint128 _additionalLiquidity + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _additionalLiquidity = _boundLiquidity(_additionalLiquidity); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.AboveCurrent); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.startPrank(alice); + token1.transfer(makeAddr("sink"), token1.balanceOf(alice)); + vm.expectRevert(stdError.arithmeticError); + hook.increaseLiquidity(_positionId, _additionalLiquidity, type(uint256).max, type(uint256).max); + vm.stopPrank(); + } +} + +contract DecreaseLiquidity is FixedFeeSwapMarketTest { + uint24 internal constant SOLVENCY_FEE_RATE = 3000; + + function setUp() public virtual override { + super.setUp(); + defaultSwapParams = SwapParams({zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: 0}); + _moveTickTo(500); + } + + function _poolKeyForHook(FixedFeeSwapMarketHarness _localHook) + internal + view + returns (PoolKey memory) + { + return PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(_localHook)) + }); + } + + function _deployHookForSolvency(uint24 _feeRate, string memory _managerLabel) + internal + returns (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey, address charlie) + { + uint256 _currentTimestamp = block.timestamp; + vm.warp(1); + + _localHook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + _feeRate, + YIELD_PER_TICK, + IPoolManager(makeAddr(_managerLabel)), + 0 + ); + + uint256 _resumeTimestamp = _currentTimestamp > 1 days ? _currentTimestamp : 1 days + 1; + vm.warp(_resumeTimestamp); + + _localPoolKey = _poolKeyForHook(_localHook); + charlie = makeAddr(string.concat(_managerLabel, "-charlie")); + _fundUser(charlie); + + vm.startPrank(alice); + token0.approve(address(_localHook), type(uint256).max); + token1.approve(address(_localHook), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(bob); + token0.approve(address(_localHook), type(uint256).max); + token1.approve(address(_localHook), type(uint256).max); + vm.stopPrank(); + + vm.startPrank(charlie); + token0.approve(address(_localHook), type(uint256).max); + token1.approve(address(_localHook), type(uint256).max); + vm.stopPrank(); + } + + function _warpFromHookStart(FixedFeeSwapMarketHarness _localHook, uint256 _timeElapsed) internal { + vm.warp(_localHook.getStartTime() + _timeElapsed); + } + + function _trySwapOnHook( + FixedFeeSwapMarketHarness _localHook, + PoolKey memory _localPoolKey, + address _sender, + int256 _amountIn, + bool _principalForCash, + int24 _tickLimit + ) internal returns (bool) { + bytes memory _hookParams = _encodeHookParams(_amountIn, _principalForCash, _tickLimit); + try _localHook.exposed_beforeSwap(_sender, _localPoolKey, defaultSwapParams, _hookParams) { + return true; + } catch { + return false; + } + } + + // ============ Position Update Tests ============ + + /// @dev Decreasing updates the position and both _tick accounting dimensions consistently. + function testFuzz_UpdatesPositionAndTickState(uint128 _initialLiquidity, uint128 _removeAmount) + public + { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + uint128 _expectedRemaining = _initialLiquidity - _removeAmount; + + { + (address _owner, int24 _lower, int24 _upper, int128 remainingLiquidity) = + hook.getPositionInfo(_positionId); + assertEq(_owner, alice, "_owner"); + assertEq(_lower, _tickLower, "_tickLower"); + assertEq(_upper, _tickUpper, "_tickUpper"); + assertEq(uint128(remainingLiquidity), _expectedRemaining, "position _liquidity"); + } + + { + (uint128 lowerGross, int128 lowerNet) = hook.getTickInfo(_tickLower); + (uint128 upperGross, int128 upperNet) = hook.getTickInfo(_tickUpper); + assertEq(lowerGross, _expectedRemaining, "_lower gross"); + assertEq(upperGross, _expectedRemaining, "_upper gross"); + assertEq(lowerNet, int128(_expectedRemaining), "_lower net"); + assertEq(upperNet, -int128(_expectedRemaining), "_upper net"); + } + } + + /// @dev Full withdrawal clears _tick bitmap bits + function testFuzz_FullWithdrawalClearsTickBitmap(uint128 _liquidity) public { + _liquidity = _boundLiquidity(_liquidity); + + _warpToMintTime(); + int24 _currentTick = hook.getCurrentTick(); + int24 _tickLower = _currentTick - 300; + int24 _tickUpper = _currentTick + 300; + + (int16 lowerWord, uint8 lowerBit) = _bitmapPosition(_tickLower); + (int16 upperWord, uint8 upperBit) = _bitmapPosition(_tickUpper); + + uint256 _lowerWordBefore = hook.getTickBitmapWord(lowerWord); + uint256 _upperWordBefore = hook.getTickBitmapWord(upperWord); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _liquidity); + + // Verify bits are set after mint + uint256 _lowerMask = uint256(1) << lowerBit; + uint256 _upperMask = uint256(1) << upperBit; + assertTrue( + (hook.getTickBitmapWord(lowerWord) & _lowerMask) != 0, + "Lower _tick bit should be set after mint" + ); + assertTrue( + (hook.getTickBitmapWord(upperWord) & _upperMask) != 0, + "Upper _tick bit should be set after mint" + ); + + // Full withdrawal + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_liquidity), 0, 0); + + // Verify words are restored exactly + assertEq( + hook.getTickBitmapWord(lowerWord), _lowerWordBefore, "Lower bitmap word should be restored" + ); + if (upperWord != lowerWord) { + assertEq( + hook.getTickBitmapWord(upperWord), _upperWordBefore, "Upper bitmap word should be restored" + ); + } + + // Verify _tick _liquidity is zero + (uint128 lowerGross,) = hook.getTickInfo(_tickLower); + (uint128 upperGross,) = hook.getTickInfo(_tickUpper); + assertEq(lowerGross, 0, "Lower _tick should have zero _liquidity"); + assertEq(upperGross, 0, "Upper _tick should have zero _liquidity"); + } + + /// @dev Full withdrawal does not clear bitmap when other _liquidity remains at the same _ticks + function testFuzz_FullWithdrawalDoesNotClearBitmapWhenOtherLiquidityRemains( + uint128 _aliceLiquidity, + uint128 _bobLiquidity + ) public { + SharedWithdrawalCtx memory _ctx; + _ctx._aliceLiquidity = _boundLiquidity(_aliceLiquidity); + _ctx._bobLiquidity = _boundLiquidity(_bobLiquidity); + + _warpToMintTime(); + int24 _currentTick = hook.getCurrentTick(); + _ctx._tickLower = _currentTick - 300; + _ctx._tickUpper = _currentTick + 300; + + _ctx._alicePositionId = + _mintLiquidityAtTicks(alice, _ctx._tickLower, _ctx._tickUpper, _ctx._aliceLiquidity); + _warpToMintTime(); + _mintLiquidityAtTicks(bob, _ctx._tickLower, _ctx._tickUpper, _ctx._bobLiquidity); + + (_ctx.lowerWord, _ctx.lowerBit) = _bitmapPosition(_ctx._tickLower); + (_ctx.upperWord, _ctx.upperBit) = _bitmapPosition(_ctx._tickUpper); + _ctx._lowerWordAfterMint = hook.getTickBitmapWord(_ctx.lowerWord); + _ctx._upperWordAfterMint = hook.getTickBitmapWord(_ctx.upperWord); + + uint256 _lowerMask = uint256(1) << _ctx.lowerBit; + uint256 _upperMask = uint256(1) << _ctx.upperBit; + assertTrue((_ctx._lowerWordAfterMint & _lowerMask) != 0, "Lower _tick bit should be set"); + assertTrue((_ctx._upperWordAfterMint & _upperMask) != 0, "Upper _tick bit should be set"); + + // Alice fully withdraws; Bob remains + _decreaseAllLiquidity(alice, _ctx._alicePositionId, _ctx._aliceLiquidity); + + assertEq( + hook.getTickBitmapWord(_ctx.lowerWord), + _ctx._lowerWordAfterMint, + "Lower bitmap word should not change" + ); + if (_ctx.upperWord != _ctx.lowerWord) { + assertEq( + hook.getTickBitmapWord(_ctx.upperWord), + _ctx._upperWordAfterMint, + "Upper bitmap word should not change" + ); + } + + // Tick _liquidity should reflect _remaining _liquidity + (uint128 lowerGross,) = hook.getTickInfo(_ctx._tickLower); + (uint128 upperGross,) = hook.getTickInfo(_ctx._tickUpper); + assertEq(lowerGross, _ctx._bobLiquidity, "Lower _tick gross should equal _remaining _liquidity"); + assertEq(upperGross, _ctx._bobLiquidity, "Upper _tick gross should equal _remaining _liquidity"); + } + + /// @dev Partial withdrawal keeps _tick bitmap bits set + function testFuzz_PartialWithdrawalKeepsBitmapSet( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + // Need at least 2e17 to leave 1e17 _remaining after partial withdrawal + _initialLiquidity = uint128(bound(_initialLiquidity, 2e17, 3e24)); + // Ensure we leave at least 1e17 _remaining + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity - 1e17)); + vm.assume(_removeAmount < _initialLiquidity); + + _warpToMintTime(); + int24 _currentTick = hook.getCurrentTick(); + int24 _tickLower = _currentTick - 300; + int24 _tickUpper = _currentTick + 300; + + (int16 lowerWord, uint8 lowerBit) = _bitmapPosition(_tickLower); + (int16 upperWord, uint8 upperBit) = _bitmapPosition(_tickUpper); + uint256 _lowerWordBefore = hook.getTickBitmapWord(lowerWord); + uint256 _upperWordBefore = hook.getTickBitmapWord(upperWord); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + uint256 _lowerWordAfterMint = hook.getTickBitmapWord(lowerWord); + uint256 _upperWordAfterMint = hook.getTickBitmapWord(upperWord); + assertNotEq(_lowerWordAfterMint, _lowerWordBefore, "Lower word should change on mint"); + if (upperWord != lowerWord) { + assertNotEq(_upperWordAfterMint, _upperWordBefore, "Upper word should change on mint"); + } + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + uint256 _lowerMask = uint256(1) << lowerBit; + uint256 _upperMask = uint256(1) << upperBit; + assertEq( + hook.getTickBitmapWord(lowerWord), _lowerWordAfterMint, "Lower bitmap word should not change" + ); + if (upperWord != lowerWord) { + assertEq( + hook.getTickBitmapWord(upperWord), + _upperWordAfterMint, + "Upper bitmap word should not change" + ); + } + assertTrue( + (hook.getTickBitmapWord(lowerWord) & _lowerMask) != 0, + "Lower _tick bit should remain set after partial withdrawal" + ); + assertTrue( + (hook.getTickBitmapWord(upperWord) & _upperMask) != 0, + "Upper _tick bit should remain set after partial withdrawal" + ); + } + + // ============ Token Transfer Tests ============ + + /// @dev Decrease transfers _expected token amounts and preserves token conservation. + function testFuzz_TransfersExpectedAmountsAndConservesTotals( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + uint256 _user0Before = token0.balanceOf(alice); + uint256 _user1Before = token1.balanceOf(alice); + uint256 _hook0Before = token0.balanceOf(address(hook)); + uint256 _hook1Before = token1.balanceOf(address(hook)); + uint256 _total0Before = _user0Before + _hook0Before; + uint256 _total1Before = _user1Before + _hook1Before; + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + uint256 _user0After = token0.balanceOf(alice); + uint256 _user1After = token1.balanceOf(alice); + uint256 _hook0After = token0.balanceOf(address(hook)); + uint256 _hook1After = token1.balanceOf(address(hook)); + + assertEq(int256(_user0After - _user0Before), expected0, "_user token0 _received"); + assertEq(int256(_user1After - _user1Before), expected1, "_user token1 _received"); + assertEq(int256(_hook0Before - _hook0After), expected0, "hook token0 debited"); + assertEq(int256(_hook1Before - _hook1After), expected1, "hook token1 debited"); + assertEq(_user0After + _hook0After, _total0Before, "token0 conservation"); + assertEq(_user1After + _hook1After, _total1Before, "token1 conservation"); + } + + // ============ Active Liquidity Tests ============ + + /// @dev In-range decreases reduce active _liquidity by the removed _amount. + function testFuzz_InRangeDecreaseUpdatesActiveLiquidity( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + uint128 _activeBefore = hook.getActiveLiquidity(); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + assertEq(hook.getActiveLiquidity(), _activeBefore - _removeAmount, "active _liquidity decrease"); + } + + /// @dev Out-of-range decreases do not change active _liquidity. + function testFuzz_OutOfRangeDecreaseDoesNotUpdateActiveLiquidity( + uint128 _initialLiquidity, + uint128 _removeAmount, + uint8 _scenario + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + uint8 _boundedScenario = uint8(bound(_scenario, 0, 1)); + RangeScenario _rangeScenario = + _boundedScenario == 0 ? RangeScenario.BelowCurrent : RangeScenario.AboveCurrent; + (int24 _tickLower, int24 _tickUpper) = _standardRange(_rangeScenario); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + uint128 _activeBefore = hook.getActiveLiquidity(); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + assertEq(hook.getActiveLiquidity(), _activeBefore, "active _liquidity unchanged"); + } + + // ============ Multiple Operations Tests ============ + + /// @dev Multiple decreases on one position add up to the same final state. + function testFuzz_MultipleDecreasesAccumulateLiquidity( + uint128 _initialLiquidity, + uint128 _decrease1, + uint128 _decrease2 + ) public { + // Need at least 3e17 so that _maxDecrease >= 1e17 + _initialLiquidity = uint128(bound(_initialLiquidity, 3e17, 3e24)); + // Ensure we can do two decreases + uint128 _maxDecrease = _initialLiquidity / 3; + _decrease1 = uint128(bound(_decrease1, 1e17, _maxDecrease)); + _decrease2 = uint128(bound(_decrease2, 1e17, _maxDecrease)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.startPrank(alice); + hook.decreaseLiquidity(_positionId, -int128(_decrease1), 0, 0); + hook.decreaseLiquidity(_positionId, -int128(_decrease2), 0, 0); + vm.stopPrank(); + + (,,, int128 remainingLiquidity) = hook.getPositionInfo(_positionId); + assertEq( + uint128(remainingLiquidity), + _initialLiquidity - _decrease1 - _decrease2, + "Position _liquidity should equal initial minus all decreases" + ); + + (uint128 lowerGross,) = hook.getTickInfo(_tickLower); + assertEq( + lowerGross, + _initialLiquidity - _decrease1 - _decrease2, + "Tick _liquidity should equal initial minus all decreases" + ); + } + + /// @dev Decrease then increase is symmetric (same token amounts) + function testFuzz_DecreaseIncreaseSymmetry(uint128 _baseAmount, uint128 _deltaAmount) public { + // Need at least 2e17 so that _baseAmount / 2 >= 1e17 + _baseAmount = uint128(bound(_baseAmount, 2e17, 3e24)); + _deltaAmount = uint128(bound(_deltaAmount, 1e17, _baseAmount / 2)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _baseAmount); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + + vm.startPrank(alice); + hook.decreaseLiquidity(_positionId, -int128(_deltaAmount), 0, 0); + hook.increaseLiquidity(_positionId, _deltaAmount, type(uint256).max, type(uint256).max); + vm.stopPrank(); + + (,,, int128 finalLiquidity) = hook.getPositionInfo(_positionId); + assertEq(token0.balanceOf(alice), _balance0Before, "token0 symmetric"); + assertEq(token1.balanceOf(alice), _balance1Before, "token1 symmetric"); + assertEq(uint128(finalLiquidity), _baseAmount, "_liquidity restored"); + } + + /// @dev Different users can decrease independent positions in the same range. + function testFuzz_MultipleUsersDecreaseIndependentPositions( + uint128 _aliceInitial, + uint128 _aliceDecrease + ) public { + _aliceInitial = _boundLiquidity(_aliceInitial); + _aliceDecrease = uint128(bound(_aliceDecrease, 1e17, _aliceInitial)); + uint128 _bobInitial = 1e18; + uint128 _bobDecrease = 5e17; + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _alicePositionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _aliceInitial); + bytes32 _bobPositionId = _mintLiquidityAtTicks(bob, _tickLower, _tickUpper, _bobInitial); + + vm.prank(alice); + hook.decreaseLiquidity(_alicePositionId, -int128(_aliceDecrease), 0, 0); + + vm.prank(bob); + hook.decreaseLiquidity(_bobPositionId, -int128(_bobDecrease), 0, 0); + + (,,, int128 aliceLiq) = hook.getPositionInfo(_alicePositionId); + (,,, int128 bobLiq) = hook.getPositionInfo(_bobPositionId); + + assertEq(uint128(aliceLiq), _aliceInitial - _aliceDecrease, "Alice _liquidity should match"); + assertEq(uint128(bobLiq), _bobInitial - _bobDecrease, "Bob _liquidity should match"); + + (uint128 lowerGross,) = hook.getTickInfo(_tickLower); + uint128 _expectedTotal = (_aliceInitial - _aliceDecrease) + (_bobInitial - _bobDecrease); + assertEq(lowerGross, _expectedTotal, "Tick should have _combined _remaining _liquidity"); + } + + // ============ Token Amount Tests Based on Position ============ + + /// @dev Below-current positions return only token0 on decrease. + function testFuzz_BelowCurrentRangeDecreaseReturnsOnlyToken0( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.BelowCurrent); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + assertGt(expected0, 0, "_expected token0"); + assertEq(expected1, 0, "_expected token1"); + assertEq(int256(token0.balanceOf(alice) - _balance0Before), expected0, "token0 _received"); + assertEq(int256(token1.balanceOf(alice) - _balance1Before), expected1, "token1 _received"); + } + + /// @dev Above-current positions return only token1 on decrease. + function testFuzz_AboveCurrentRangeDecreaseReturnsOnlyToken1( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.AboveCurrent); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + assertEq(expected0, 0, "_expected token0"); + assertGt(expected1, 0, "_expected token1"); + assertEq(int256(token0.balanceOf(alice) - _balance0Before), expected0, "token0 _received"); + assertEq(int256(token1.balanceOf(alice) - _balance1Before), expected1, "token1 _received"); + } + + /// @dev In-range positions return both tokens on decrease. + function testFuzz_InRangeDecreaseReturnsBothTokens( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + assertGt(expected0, 0, "_expected token0"); + assertGt(expected1, 0, "_expected token1"); + assertEq(int256(token0.balanceOf(alice) - _balance0Before), expected0, "token0 _received"); + assertEq(int256(token1.balanceOf(alice) - _balance1Before), expected1, "token1 _received"); + } + + /// @dev For the same in-range burn size, token0 stays constant while token1 changes with _time. + function test_DecreaseCashReturnedChangesWithTime_ForInRangePosition() public { + uint128 _initialLiquidity = 1e24; + uint128 _removeAmount = 5e23; + + (int24 _tickLower, int24 _tickUpper) = + _rangeForScenario(hook.getCurrentTick(), RangeScenario.InRange, 200); + + _warpFromStart(1); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + uint256 _snapshot = vm.snapshotState(); + + _warpFromStart(1); + Amounts memory _expectedAtT1; + { + (int256 e0, int256 e1) = _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + _expectedAtT1.amount0 = e0; + _expectedAtT1.amount1 = e1; + } + Amounts memory _receivedAtT1; + { + (uint256 received0, uint256 received1) = _decreaseLiquidity(alice, _positionId, _removeAmount); + _receivedAtT1.amount0 = int256(received0); + _receivedAtT1.amount1 = int256(received1); + } + + vm.revertToState(_snapshot); + + _warpFromStart(30 days); + Amounts memory _expectedAtT2; + { + (int256 e0, int256 e1) = _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + _expectedAtT2.amount0 = e0; + _expectedAtT2.amount1 = e1; + } + Amounts memory _receivedAtT2; + { + (uint256 received0, uint256 received1) = _decreaseLiquidity(alice, _positionId, _removeAmount); + _receivedAtT2.amount0 = int256(received0); + _receivedAtT2.amount1 = int256(received1); + } + + assertEq(_receivedAtT1.amount0, _expectedAtT1.amount0, "t1 token0"); + assertEq(_receivedAtT1.amount1, _expectedAtT1.amount1, "t1 token1"); + assertEq(_receivedAtT2.amount0, _expectedAtT2.amount0, "t2 token0"); + assertEq(_receivedAtT2.amount1, _expectedAtT2.amount1, "t2 token1"); + assertEq(_expectedAtT2.amount0, _expectedAtT1.amount0, "principal invariant"); + assertNotEq(_expectedAtT2.amount1, _expectedAtT1.amount1, "cash changes over _time"); + } + + // ============ Slippage Tests ============ + + /// @dev Decrease succeeds when min amounts equal the exact returned amounts. + function testFuzz_AllowsWhen_MinTokenAmountsEqualReturnedAmounts( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int256 expected0, int256 expected1) = + _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + vm.prank(alice); + hook.decreaseLiquidity( + _positionId, -int128(_removeAmount), uint256(expected0), uint256(expected1) + ); + + (,,, int128 remainingLiquidity) = hook.getPositionInfo(_positionId); + assertEq(uint128(remainingLiquidity), _initialLiquidity - _removeAmount, "_liquidity decreased"); + } + + // ============ Revert Tests ============ + + /// @dev Reverts when position not found + function test_RevertWhen_PositionNotFound() public { + bytes32 _missingPositionId = keccak256("missing position"); + + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_PositionNotFound.selector); + hook.decreaseLiquidity(_missingPositionId, -1e18, 0, 0); + } + + /// @dev Reverts when caller is not position _owner + function testFuzz_RevertWhen_NotPositionOwner(uint128 _initialLiquidity, uint128 _removeAmount) + public + { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + vm.prank(bob); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_NotPositionOwner.selector); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + } + + /// @dev Reverts when min token0 exceeds what _a below-current decrease can return. + function testFuzz_RevertWhen_BelowCurrentToken0SlippageExceeded( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.BelowCurrent); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int256 expected0,) = _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidLiquidityAmount.selector); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), uint256(expected0) + 1, 0); + } + + /// @dev Reverts when min token1 exceeds what an above-current decrease can return. + function testFuzz_RevertWhen_AboveCurrentToken1SlippageExceeded( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.AboveCurrent); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (, int256 expected1) = _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidLiquidityAmount.selector); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, uint256(expected1) + 1); + } + + /// @dev Reverts when token0 _amount is below min (in-range position) + function testFuzz_RevertWhen_InRangeToken0SlippageExceeded( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (int256 expected0,) = _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidLiquidityAmount.selector); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), uint256(expected0) + 1, 0); + } + + /// @dev Reverts when token1 _amount is below min (in-range position) + function testFuzz_RevertWhen_InRangeToken1SlippageExceeded( + uint128 _initialLiquidity, + uint128 _removeAmount + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + (, int256 expected1) = _expectedMintAmounts(_tickLower, _tickUpper, _removeAmount); + + vm.prank(alice); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidLiquidityAmount.selector); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, uint256(expected1) + 1); + } + + // ============ Fuzzed Time Tests ============ + + /// @dev Decrease at any valid _time preserves conservation and updates _liquidity. + function testFuzz_DecreaseMaintainsConservationAtAnyTime( + uint128 _initialLiquidity, + uint128 _removeAmount, + uint256 _timeElapsed + ) public { + _initialLiquidity = _boundLiquidity(_initialLiquidity); + _removeAmount = uint128(bound(_removeAmount, 1e17, _initialLiquidity)); + _timeElapsed = bound(_timeElapsed, 1, MARKET_LENGTH - 1); + _warpFromStart(_timeElapsed); + + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _initialLiquidity); + + uint256 _total0Before = token0.balanceOf(alice) + token0.balanceOf(address(hook)); + uint256 _total1Before = token1.balanceOf(alice) + token1.balanceOf(address(hook)); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + + (,,, int128 remainingLiquidity) = hook.getPositionInfo(_positionId); + uint256 _total0After = token0.balanceOf(alice) + token0.balanceOf(address(hook)); + uint256 _total1After = token1.balanceOf(alice) + token1.balanceOf(address(hook)); + + assertEq(uint128(remainingLiquidity), _initialLiquidity - _removeAmount, "_liquidity decrease"); + assertEq(_total0After, _total0Before, "token0 conservation"); + assertEq(_total1After, _total1Before, "token1 conservation"); + } + + function test_EmitsLiquidityModified_OnDecrease() public { + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + uint128 _liquidity = 1e20; + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, _liquidity); + + uint128 _removeAmount = 5e19; + + vm.expectEmit(true, true, false, false, address(hook)); + emit FixedFeeSwapMarket.LiquidityModified(_positionId, alice, _tickLower, _tickUpper, 0, 0, 0); + + vm.prank(alice); + hook.decreaseLiquidity(_positionId, -int128(_removeAmount), 0, 0); + } + + function test_RevertWhen_LiquidityDeltaIsZero_OnDecrease() public { + _warpToMintTime(); + (int24 _tickLower, int24 _tickUpper) = _standardRange(RangeScenario.InRange); + + bytes32 _positionId = _mintLiquidityAtTicks(alice, _tickLower, _tickUpper, 1e20); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_LiquidityDeltaMustBeNegative.selector); + vm.prank(alice); + hook.decreaseLiquidity(_positionId, 0, 0, 0); + } + + // ============ Solvency Tests ============ + + function testFuzz_SingleLPCanWithdrawAfterPrincipalForCashSwap( + int24 _tickUpper, + uint128 _liquidity, + uint256 _swapAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey,) = + _deployHookForSolvency(0, "solvencySinglePrincipalForCash"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _swapAmount = _boundSwapForRange(_swapAmount, _liquidity, _tickUpper, 500); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId = _mintOnHook(_localHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(_localHook, _timeElapsed); + + _swapOnHook(_localHook, _localPoolKey, bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function testFuzz_SingleLPCanWithdrawAfterRoundTripSwap( + int24 _tickUpper, + uint128 _liquidity, + uint256 _swapAmount1, + uint256 _swapAmount2, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey,) = + _deployHookForSolvency(0, "solvencySingleRoundTrip"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _swapAmount1 = _boundSwapForRange(_swapAmount1, _liquidity, _tickUpper, 500); + _swapAmount2 = + bound(_swapAmount2, uint256(_liquidity) * 2 / BIPS, uint256(_liquidity) * 500 / BIPS); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId = _mintOnHook(_localHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(_localHook, _timeElapsed); + + _swapOnHook(_localHook, _localPoolKey, bob, int256(_swapAmount1), true, NO_TICK_LIMIT); + _trySwapOnHook(_localHook, _localPoolKey, bob, int256(_swapAmount2), false, NO_TICK_LIMIT); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function testFuzz_TwoLpsCanBothWithdrawAfterSwap( + int24 _tickUpper1, + int24 _tickLower2, + int24 _tickUpper2, + uint128 _liquidity1, + uint128 _liquidity2, + uint256 _swapAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey, address charlie) = + _deployHookForSolvency(0, "solvencyTwoLps"); + + _tickUpper1 = int24(bound(int256(_tickUpper1), 4, 2000)); + _tickLower2 = int24(bound(int256(_tickLower2), 0, 1999)); + _tickUpper2 = int24(bound(int256(_tickUpper2), int256(_tickLower2) + 1, 2000)); + _liquidity1 = uint128(bound(_liquidity1, 1e18, 1e24)); + _liquidity2 = uint128(bound(_liquidity2, 1e18, 1e24)); + _swapAmount = _boundSwapForRange(_swapAmount, _liquidity1, _tickUpper1, 500); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId1 = _mintOnHook(_localHook, alice, 0, _tickUpper1, _liquidity1); + bytes32 _positionId2 = _mintOnHook(_localHook, charlie, _tickLower2, _tickUpper2, _liquidity2); + + _warpFromHookStart(_localHook, _timeElapsed); + + _swapOnHook(_localHook, _localPoolKey, bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId1, -int128(uint128(_liquidity1)), 0, 0); + vm.prank(charlie); + _localHook.decreaseLiquidity(_positionId2, -int128(uint128(_liquidity2)), 0, 0); + } + + function testFuzz_PartialThenFullWithdrawIsSolventAcrossSwaps( + int24 _tickUpper, + uint128 _liquidity, + uint128 _partialRemove, + uint256 _swapAmount1, + uint256 _swapAmount2, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey,) = + _deployHookForSolvency(0, "solvencyPartialFull"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _partialRemove = uint128(bound(_partialRemove, 1e17, uint256(_liquidity) / 2)); + _swapAmount1 = _boundSwapForRange(_swapAmount1, _liquidity, _tickUpper, 500); + _swapAmount2 = + bound(_swapAmount2, uint256(_liquidity) * 2 / BIPS, uint256(_liquidity) * 500 / BIPS); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId = _mintOnHook(_localHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(_localHook, _timeElapsed); + + _swapOnHook(_localHook, _localPoolKey, bob, int256(_swapAmount1), true, NO_TICK_LIMIT); + + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(_partialRemove), 0, 0); + + _trySwapOnHook(_localHook, _localPoolKey, bob, int256(_swapAmount2), true, NO_TICK_LIMIT); + + uint128 _remaining = _liquidity - _partialRemove; + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(_remaining), 0, 0); + } + + function testFuzz_SingleLPCanWithdrawAfterCashForPrincipalSwap( + int24 _tickUpper, + uint128 _liquidity, + uint256 _setupSwapAmount, + uint256 _cashSwapAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey,) = + _deployHookForSolvency(0, "solvencySingleCashForPrincipal"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _setupSwapAmount = _boundSwapForRange(_setupSwapAmount, _liquidity, _tickUpper, 500); + _cashSwapAmount = + bound(_cashSwapAmount, uint256(_liquidity) * 2 / BIPS, uint256(_liquidity) * 500 / BIPS); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId = _mintOnHook(_localHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(_localHook, _timeElapsed); + + _swapOnHook(_localHook, _localPoolKey, bob, int256(_setupSwapAmount), true, NO_TICK_LIMIT); + + _trySwapOnHook(_localHook, _localPoolKey, bob, int256(_cashSwapAmount), false, NO_TICK_LIMIT); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function testFuzz_SingleLPCanWithdrawAfterCashThenPrincipalRoundTrip( + int24 _tickUpper, + uint128 _liquidity, + uint256 _setupAmount, + uint256 _cashForPrincipalAmount, + uint256 _principalForCashAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey,) = + _deployHookForSolvency(0, "solvencyCashThenPrincipal"); + + _tickUpper = int24(bound(int256(_tickUpper), 104, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _setupAmount = _boundSwapForRange(_setupAmount, _liquidity, _tickUpper, 500); + _cashForPrincipalAmount = bound( + _cashForPrincipalAmount, uint256(_liquidity) * 2 / BIPS, uint256(_liquidity) * 250 / BIPS + ); + _principalForCashAmount = bound( + _principalForCashAmount, uint256(_liquidity) * 2 / BIPS, uint256(_liquidity) * 250 / BIPS + ); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId = _mintOnHook(_localHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(_localHook, _timeElapsed); + + _swapOnHook(_localHook, _localPoolKey, bob, int256(_setupAmount), true, NO_TICK_LIMIT); + + _trySwapOnHook( + _localHook, _localPoolKey, bob, int256(_cashForPrincipalAmount), false, NO_TICK_LIMIT + ); + _trySwapOnHook( + _localHook, _localPoolKey, bob, int256(_principalForCashAmount), true, NO_TICK_LIMIT + ); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function testFuzz_MintSwapWaitWithdrawIsSolvent( + int24 _tickUpper, + uint128 _liquidity, + uint256 _swapAmount, + uint256 _swapTime, + uint256 _withdrawTime + ) public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey,) = + _deployHookForSolvency(0, "solvencyMintSwapWaitWithdraw"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _swapAmount = _boundSwapForRange(_swapAmount, _liquidity, _tickUpper, 500); + _swapTime = bound(_swapTime, 1 days, MARKET_LENGTH / 2); + _withdrawTime = bound(_withdrawTime, _swapTime + 1, MARKET_LENGTH - 1); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId = _mintOnHook(_localHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(_localHook, _swapTime); + _swapOnHook(_localHook, _localPoolKey, bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + _warpFromHookStart(_localHook, _withdrawTime); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function testFuzz_SingleLPCanWithdrawAfterPrincipalForCashSwap_WithFees( + int24 _tickUpper, + uint128 _liquidity, + uint256 _swapAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey,) = + _deployHookForSolvency(SOLVENCY_FEE_RATE, "solvencySinglePrincipalForCashFees"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _swapAmount = _boundSwapForRange(_swapAmount, _liquidity, _tickUpper, 500); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(feeHook, 1); + bytes32 _positionId = _mintOnHook(feeHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(feeHook, _timeElapsed); + + _swapOnHook(feeHook, feePoolKey, bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + feeHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function testFuzz_SingleLPCanWithdrawAfterCashForPrincipalSwap_WithFees( + int24 _tickUpper, + uint128 _liquidity, + uint256 _setupSwapAmount, + uint256 _cashSwapAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey,) = + _deployHookForSolvency(SOLVENCY_FEE_RATE, "solvencySingleCashForPrincipalFees"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _setupSwapAmount = _boundSwapForRange(_setupSwapAmount, _liquidity, _tickUpper, 500); + _cashSwapAmount = + bound(_cashSwapAmount, uint256(_liquidity) * 2 / BIPS, uint256(_liquidity) * 500 / BIPS); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(feeHook, 1); + bytes32 _positionId = _mintOnHook(feeHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(feeHook, _timeElapsed); + + _swapOnHook(feeHook, feePoolKey, bob, int256(_setupSwapAmount), true, NO_TICK_LIMIT); + + _trySwapOnHook(feeHook, feePoolKey, bob, int256(_cashSwapAmount), false, NO_TICK_LIMIT); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + feeHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function testFuzz_TwoLpsCanBothWithdrawAfterSwap_WithFees( + int24 _tickUpper1, + int24 _tickLower2, + int24 _tickUpper2, + uint128 _liquidity1, + uint128 _liquidity2, + uint256 _swapAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey, address charlie) = + _deployHookForSolvency(SOLVENCY_FEE_RATE, "solvencyTwoLpsFees"); + + _tickUpper1 = int24(bound(int256(_tickUpper1), 4, 2000)); + _tickLower2 = int24(bound(int256(_tickLower2), 0, 1999)); + _tickUpper2 = int24(bound(int256(_tickUpper2), int256(_tickLower2) + 1, 2000)); + _liquidity1 = uint128(bound(_liquidity1, 1e18, 1e24)); + _liquidity2 = uint128(bound(_liquidity2, 1e18, 1e24)); + _swapAmount = _boundSwapForRange(_swapAmount, _liquidity1, _tickUpper1, 500); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(feeHook, 1); + bytes32 _positionId1 = _mintOnHook(feeHook, alice, 0, _tickUpper1, _liquidity1); + bytes32 _positionId2 = _mintOnHook(feeHook, charlie, _tickLower2, _tickUpper2, _liquidity2); + + _warpFromHookStart(feeHook, _timeElapsed); + + _swapOnHook(feeHook, feePoolKey, bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + vm.prank(alice); + feeHook.decreaseLiquidity(_positionId1, -int128(uint128(_liquidity1)), 0, 0); + vm.prank(charlie); + feeHook.decreaseLiquidity(_positionId2, -int128(uint128(_liquidity2)), 0, 0); + } + + function testFuzz_WithdrawAfterFeeCollectionIsSolvent( + int24 _tickUpper, + uint128 _liquidity, + uint256 _swapAmount, + uint256 _timeElapsed + ) public { + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey,) = + _deployHookForSolvency(SOLVENCY_FEE_RATE, "solvencyWithdrawAfterCollectFees"); + + _tickUpper = int24(bound(int256(_tickUpper), 4, 2000)); + _liquidity = uint128(bound(_liquidity, 1e18, 1e24)); + _swapAmount = _boundSwapForRange(_swapAmount, _liquidity, _tickUpper, 500); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + + _warpFromHookStart(feeHook, 1); + bytes32 _positionId = _mintOnHook(feeHook, alice, 0, _tickUpper, _liquidity); + + _warpFromHookStart(feeHook, _timeElapsed); + + _swapOnHook(feeHook, feePoolKey, bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + vm.prank(alice); + feeHook.collectFees(_positionId, alice, 0, 0); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + feeHook.decreaseLiquidity(_positionId, -int128(uint128(_liquidity)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } + + function test_WithdrawIsAllowedAtMaturity() public { + (FixedFeeSwapMarketHarness _localHook, PoolKey memory _localPoolKey,) = + _deployHookForSolvency(0, "maturityWithdraw"); + + _warpFromHookStart(_localHook, 1); + bytes32 _positionId = _mintOnHook(_localHook, alice, 0, 500, 1e22); + + _warpFromHookStart(_localHook, MARKET_LENGTH / 2); + _swapOnHook(_localHook, _localPoolKey, bob, int256(1e18), true, NO_TICK_LIMIT); + + vm.warp(_localHook.getMaturity()); + + uint256 _balance0Before = token0.balanceOf(alice); + uint256 _balance1Before = token1.balanceOf(alice); + vm.prank(alice); + _localHook.decreaseLiquidity(_positionId, -int128(uint128(1e22)), 0, 0); + + assertGe(token0.balanceOf(alice), _balance0Before, "LP should receive token0"); + assertGe(token1.balanceOf(alice), _balance1Before, "LP should receive token1"); + } +} + +contract _BeforeSwap is FixedFeeSwapMarketTest { + function setUp() public virtual override { + super.setUp(); + + // Create _a mock pool key - only the hooks field matters for _beforeSwap + poolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(hook)) + }); + + // Default swap params (ignored by our hook, but required) + defaultSwapParams = SwapParams({zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: 0}); + + // Add _liquidity for swap tests and move _tick to 100 so below-range positions are possible + _moveTickTo(100); + // Main _liquidity spanning _a wide range + _mintLiquidityAtTicks(alice, 0, 2000, 1e22); + } + + // ============ Token Transfer Tests ============ + + /// @dev Principal for cash: _user sends TOKEN0, receives TOKEN1 + function testFuzz_PrincipalForCashSwapTransfersCorrectTokens(uint256 _amountIn) public { + _amountIn = _boundSwapAmount(_amountIn); + + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + _swap(bob, int256(_amountIn), true, NO_TICK_LIMIT); + + // Sub-_tick swaps may produce zero output; skip those runs + uint256 _consumed = _balance0Before - token0.balanceOf(bob); + vm.assume(_consumed > 0); + + uint256 _received = token1.balanceOf(bob) - _balance1Before; + uint256 _hookGained0 = token0.balanceOf(address(hook)) - _hookBalance0Before; + uint256 _hookLost1 = _hookBalance1Before - token1.balanceOf(address(hook)); + + // User sends token0, receives token1 + assertGt(_received, 0, "User should receive TOKEN1"); + assertLe(_consumed, _amountIn, "Consumed should not exceed _amountIn"); + + // Token conservation: what the _user sends is what the hook receives and vice versa + assertEq(_consumed, _hookGained0, "Hook TOKEN0 gain should equal _user TOKEN0 spent"); + assertEq(_received, _hookLost1, "Hook TOKEN1 loss should equal _user TOKEN1 _received"); + } + + /// @dev Cash for principal: _user sends TOKEN1, receives TOKEN0 + function testFuzz_CashForPrincipalSwapTransfersCorrectTokens(uint256 _amountIn) public { + _amountIn = _boundSwapAmount(_amountIn); + + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + _swap(bob, int256(_amountIn), false, NO_TICK_LIMIT); + + // Sub-_tick swaps may produce zero output; skip those runs + uint256 _received = token0.balanceOf(bob) - _balance0Before; + vm.assume(_received > 0); + + uint256 _consumed = _balance1Before - token1.balanceOf(bob); + uint256 _hookGained1 = token1.balanceOf(address(hook)) - _hookBalance1Before; + uint256 _hookLost0 = _hookBalance0Before - token0.balanceOf(address(hook)); + + // User sends token1, receives token0 + assertGt(_consumed, 0, "User should send TOKEN1"); + assertLe(_consumed, _amountIn, "Consumed should not exceed _amountIn"); + + // Token conservation: what the _user sends is what the hook receives and vice versa + assertEq(_consumed, _hookGained1, "Hook TOKEN1 gain should equal _user TOKEN1 spent"); + assertEq(_received, _hookLost0, "Hook TOKEN0 loss should equal _user TOKEN0 _received"); + } + + /// @dev Zero-output swaps are no-ops: no balances change for _user or hook. + function testFuzz_SmallAmountInCausesNoOpSwap(uint256 _amountIn, bool _principalForCash) public { + _amountIn = _boundSwapAmount(_amountIn); + + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + _swap(bob, int256(_amountIn), _principalForCash, NO_TICK_LIMIT); + + uint256 _outputToken = _principalForCash + ? token1.balanceOf(bob) - _balance1Before + : token0.balanceOf(bob) - _balance0Before; + vm.assume(_outputToken == 0); + + assertEq(token0.balanceOf(bob), _balance0Before, "User TOKEN0 should be unchanged"); + assertEq(token1.balanceOf(bob), _balance1Before, "User TOKEN1 should be unchanged"); + assertEq( + token0.balanceOf(address(hook)), _hookBalance0Before, "Hook TOKEN0 should be unchanged" + ); + assertEq( + token1.balanceOf(address(hook)), _hookBalance1Before, "Hook TOKEN1 should be unchanged" + ); + } + + // ============ State Update Tests ============ + + function testFuzz_PrincipalForCashSwapIncreasesCurrentTick(uint256 _amountIn) public { + _amountIn = _boundSwapAmount(_amountIn); + + int24 _tickBefore = hook.getCurrentTick(); + uint128 _activeLiq = hook.getActiveLiquidity(); + + _swap(bob, int256(_amountIn), true, NO_TICK_LIMIT); + + int24 _tickAfter = hook.getCurrentTick(); + int24 _maxTickMove = _maxTickMove(_amountIn, _activeLiq); + + assertGe(_tickAfter, _tickBefore, "Tick should increase for principal->cash"); + assertLe(_tickAfter - _tickBefore, _maxTickMove, "Tick should not move more than _expected"); + } + + function testFuzz_CashForPrincipalSwapDecreasesCurrentTick(uint256 _amountIn) public { + _amountIn = _boundSwapAmount(_amountIn); + + int24 _tickBefore = hook.getCurrentTick(); + uint128 _activeLiq = hook.getActiveLiquidity(); + + _swap(bob, int256(_amountIn), false, NO_TICK_LIMIT); + + int24 _tickAfter = hook.getCurrentTick(); + int24 _maxTickMove = _maxTickMove(_amountIn, _activeLiq); + + assertLe(_tickAfter, _tickBefore, "Tick should decrease for cash->principal"); + assertLe(_tickBefore - _tickAfter, _maxTickMove, "Tick should not move more than _expected"); + } + + /// @dev Active _liquidity unchanged when not crossing _ticks + function testFuzz_WithinTickSwapDoesNotUpdateActiveLiquidity(uint256 _amountIn) public { + // Use small _amount to stay within current _tick range + _amountIn = bound(_amountIn, 1e15, 1e17); + + uint128 _activeBefore = hook.getActiveLiquidity(); + + _swap(bob, int256(_amountIn), true, NO_TICK_LIMIT); + + // If no _tick was crossed, active _liquidity should be unchanged + // (This may not hold if the swap crosses _a _tick boundary) + uint128 _activeAfter = hook.getActiveLiquidity(); + assertEq(_activeAfter, _activeBefore, "Active _liquidity unchanged within _tick"); + } + + // ============ Tick Crossing Tests ============ + + function testFuzz_PrincipalForCashSwapCrossingLpUpperTickAndDecreasesActiveLiquidity(uint128 _narrowLiquidity) + public + { + _narrowLiquidity = uint128(bound(uint256(_narrowLiquidity), 1e18, 1e22)); + int24 _currentTick = hook.getCurrentTick(); + int24 _narrowUpper = _currentTick + 10; + _mintLiquidityAtTicks(alice, _currentTick - 10, _narrowUpper, _narrowLiquidity); + + uint128 _activeBefore = hook.getActiveLiquidity(); + int256 _priceBefore = hook.tickToPrice(_currentTick); + + (uint256 principalInToBoundary,, int256 boundaryPrice, int24 stepTick) = + hook.exposed_getCashSwapAmount(int256(1e36), _priceBefore, _narrowUpper); + + _swap(bob, int256(principalInToBoundary), true, NO_TICK_LIMIT); + + assertEq(stepTick, _narrowUpper, "Should step to _upper _tick"); + assertEq(hook.getCurrentTick(), _narrowUpper, "Should land on the _upper _tick"); + assertEq(hook.getCurrentPrice(), boundaryPrice, "Price should move to boundary _price"); + assertEq( + uint256(hook.getActiveLiquidity()), + uint256(_activeBefore) - uint256(_narrowLiquidity), + "Active _liquidity should decrease by narrow position's _liquidity" + ); + } + + function testFuzz_PrincipalForCashSwapCrossingIntoLpPositionIncreasesActiveLiquidity(uint128 _aboveLiquidity) + public + { + _aboveLiquidity = uint128(bound(uint256(_aboveLiquidity), 1e18, 1e22)); + int24 _currentTick = hook.getCurrentTick(); + int24 _aboveLower = _currentTick + 50; + _mintLiquidityAtTicks(alice, _aboveLower, _currentTick + 150, _aboveLiquidity); + + uint128 _activeBefore = hook.getActiveLiquidity(); + int256 _priceBefore = hook.tickToPrice(_currentTick); + + (uint256 principalInToBoundary,, int256 boundaryPrice, int24 stepTick) = + hook.exposed_getCashSwapAmount(int256(1e36), _priceBefore, _aboveLower); + + _swap(bob, int256(principalInToBoundary), true, NO_TICK_LIMIT); + + assertEq(stepTick, _aboveLower, "Should step to _lower _tick of above position"); + assertEq(hook.getCurrentTick(), _aboveLower, "Should land on the _lower _tick"); + assertEq(hook.getCurrentPrice(), boundaryPrice, "Price should move to boundary _price"); + assertEq( + uint256(hook.getActiveLiquidity()), + uint256(_activeBefore) + uint256(_aboveLiquidity), + "Active _liquidity should increase by above position's _liquidity" + ); + } + + function testFuzz_PrincipalForCashWithMultipleTickCrossings( + uint128 _posLiquidity, + uint128 _bgLiquidity + ) public { + _posLiquidity = uint128(bound(uint256(_posLiquidity), 1e18, 1e22)); + _bgLiquidity = uint128(bound(uint256(_bgLiquidity), 1e18, 1e22)); + int24 _currentTick = hook.getCurrentTick(); + + // Add fuzzed background _liquidity spanning the full range + _mintLiquidityAtTicks(alice, 0, 2000, _bgLiquidity); + + // Two adjacent positions: A at [current, current+100], B at [current+100, current+200] + // A's _lower _tick == _currentTick so A is already in-range at mint _time + _mintLiquidityAtTicks(alice, _currentTick, _currentTick + 100, _posLiquidity); + _mintLiquidityAtTicks(alice, _currentTick + 100, _currentTick + 200, _posLiquidity); + + uint128 _activeBefore = hook.getActiveLiquidity(); + + // Need > 200 _ticks; +1 for integer division truncation in this formula + uint256 _swapAmount = uint256(_activeBefore) * 201 / BIPS; + _swap(bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + int24 _tickAfter = hook.getCurrentTick(); + assertGe(_tickAfter, _currentTick + 200, "Should cross both _upper _ticks"); + // Crossing _currentTick+100: _upper of A (-posLiq), _lower of B (+posLiq) → net 0 + // Crossing _currentTick+200: _upper of B (-posLiq) → net -posLiq + // Total change from _activeBefore: -_posLiquidity + assertEq( + uint256(hook.getActiveLiquidity()), + uint256(_activeBefore) - uint256(_posLiquidity), + "Active _liquidity should decrease by one position's _liquidity" + ); + } + + function testFuzz_CrossingUninitializedBoundaryDoesNotChangeActiveLiquidity(uint128 _extraLiquidity) + public + { + _extraLiquidity = uint128(bound(uint256(_extraLiquidity), 1e18, 1e22)); + _mintLiquidityAtTicks(alice, 0, 2000, _extraLiquidity); + + uint128 _activeBefore = hook.getActiveLiquidity(); + int24 _tickBefore = hook.getCurrentTick(); + int256 _priceBefore = hook.tickToPrice(_tickBefore); + + // Pick the next uninitialized word boundary above current _tick + int24 _nextTick = int24((int256(_tickBefore) / 256 + 1) * 256 - 1); + (uint256 principalInToBoundary,, int256 boundaryPrice, int24 stepTick) = + hook.exposed_getCashSwapAmount(int256(1e36), _priceBefore, _nextTick); + + _swap(bob, int256(principalInToBoundary), true, NO_TICK_LIMIT); + + assertEq(stepTick, _nextTick, "Should step to the word boundary _tick"); + assertEq(hook.getCurrentTick(), _nextTick, "Should land on the boundary _tick"); + assertEq(hook.getCurrentPrice(), boundaryPrice, "Price should move to the boundary _price"); + assertEq(hook.getActiveLiquidity(), _activeBefore, "Active _liquidity should be unchanged"); + } + + function testFuzz_CashForPrincipalSwapCrossingLpUpperTickIncreasesActiveLiquidity(uint128 _belowRangeLiquidity) + public + { + _belowRangeLiquidity = uint128(bound(uint256(_belowRangeLiquidity), 1e18, 1e22)); + int24 _currentTick = hook.getCurrentTick(); + int24 _belowLower = _currentTick - 20; + int24 _belowUpper = _currentTick - 10; + _mintLiquidityAtTicks(alice, _belowLower, _belowUpper, _belowRangeLiquidity); + + uint128 _activeBefore = hook.getActiveLiquidity(); + int256 _priceBefore = hook.tickToPrice(_currentTick); + + (uint256 cashInToBoundary,, int256 boundaryPrice, int24 stepTick) = + hook.exposed_getPrincipalSwapAmount(int256(1e36), _priceBefore, _belowUpper); + + uint256 _balance1Before = token1.balanceOf(bob); + _swap(bob, int256(cashInToBoundary), false, NO_TICK_LIMIT); + + assertEq(stepTick, _belowUpper, "Should step to _upper _tick"); + assertEq( + _balance1Before - token1.balanceOf(bob), cashInToBoundary, "Should consume cash to boundary" + ); + assertEq(hook.getCurrentPrice(), boundaryPrice, "Price should move to boundary _price"); + assertEq(hook.getCurrentTick(), _belowUpper, "Moving left leaves _tick at boundary"); + assertEq( + uint256(hook.getActiveLiquidity()), + uint256(_activeBefore) + uint256(_belowRangeLiquidity), + "Active _liquidity should increase" + ); + } + + function testFuzz_CashForPrincipalSwapCrossingLpLowerTickDecreasesActiveLiquidity(uint128 _narrowLiquidity) + public + { + _narrowLiquidity = uint128(bound(uint256(_narrowLiquidity), 1e18, 1e22)); + int24 _currentTick = hook.getCurrentTick(); + int24 _narrowLower = _currentTick - 10; + int24 _narrowUpper = _currentTick + 10; + _mintLiquidityAtTicks(alice, _narrowLower, _narrowUpper, _narrowLiquidity); + + uint128 _activeBefore = hook.getActiveLiquidity(); + int256 _priceBefore = hook.tickToPrice(_currentTick); + + (uint256 cashInToBoundary,, int256 boundaryPrice, int24 stepTick) = + hook.exposed_getPrincipalSwapAmount(int256(1e36), _priceBefore, _narrowLower); + + uint256 _balance1Before = token1.balanceOf(bob); + _swap(bob, int256(cashInToBoundary), false, NO_TICK_LIMIT); + + assertEq(stepTick, _narrowLower, "Should step to _lower _tick"); + assertEq( + _balance1Before - token1.balanceOf(bob), cashInToBoundary, "Should consume cash to boundary" + ); + assertEq(hook.getCurrentPrice(), boundaryPrice, "Price should move to boundary _price"); + assertEq(hook.getCurrentTick(), _narrowLower, "Moving left leaves _tick at boundary"); + assertEq( + uint256(hook.getActiveLiquidity()), + uint256(_activeBefore) - uint256(_narrowLiquidity), + "Active _liquidity should decrease" + ); + } + + // ============ Tick Limit Tests ============ + + function testFuzz_PrincipalForCashSwapHitsTickLimitAndStopsEarly( + uint256 _amountIn, + uint24 _tickOffset + ) public { + _tickOffset = uint24(bound(_tickOffset, 5, 95)); + int24 _currentTick = hook.getCurrentTick(); + int24 _limitTick = _currentTick + int24(_tickOffset); + + uint256 _minAmount = _minInputForTicks(_tickOffset + 10); + _amountIn = bound(_amountIn, _minAmount, _minAmount * 5); + + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + _swap(bob, int256(_amountIn), true, _limitTick); + + uint256 _consumed = _balance0Before - token0.balanceOf(bob); + uint256 _received = token1.balanceOf(bob) - _balance1Before; + uint256 _hookGained0 = token0.balanceOf(address(hook)) - _hookBalance0Before; + uint256 _hookLost1 = _hookBalance1Before - token1.balanceOf(address(hook)); + + assertLt(_consumed, _amountIn, "Should stop early due to _tick limit"); + assertEq(hook.getCurrentTick(), _limitTick, "Tick should stop exactly at limit"); + assertEq(_consumed, _hookGained0, "Hook TOKEN0 gain should equal _user TOKEN0 spent"); + assertEq(_received, _hookLost1, "Hook TOKEN1 loss should equal _user TOKEN1 _received"); + } + + function testFuzz_CashForPrincipalSwapHitsTickLimitStopsEarly( + uint256 _amountIn, + uint24 _tickOffset + ) public { + _tickOffset = uint24(bound(_tickOffset, 5, 95)); + int24 _currentTick = hook.getCurrentTick(); + int24 _limitTick = _currentTick - int24(_tickOffset); + + uint256 _minAmount = _minInputForTicks(_tickOffset + 10); + _amountIn = bound(_amountIn, _minAmount, _minAmount * 5); + + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + _swap(bob, int256(_amountIn), false, _limitTick); + + uint256 _consumed = _balance1Before - token1.balanceOf(bob); + uint256 _received = token0.balanceOf(bob) - _balance0Before; + uint256 _hookGained1 = token1.balanceOf(address(hook)) - _hookBalance1Before; + uint256 _hookLost0 = _hookBalance0Before - token0.balanceOf(address(hook)); + + assertLt(_consumed, _amountIn, "Should stop early due to _tick limit"); + assertEq(hook.getCurrentTick(), _limitTick, "Tick should stop exactly at limit"); + assertEq(_consumed, _hookGained1, "Hook TOKEN1 gain should equal _user TOKEN1 spent"); + assertEq(_received, _hookLost0, "Hook TOKEN0 loss should equal _user TOKEN0 _received"); + } + + function testFuzz_UsingTheCurrentTickAsTheLimitIsANoOp(uint256 _amountIn, bool _principalForCash) + public + { + _amountIn = _boundSwapAmount(_amountIn); + int24 _currentTick = hook.getCurrentTick(); + uint128 _activeBefore = hook.getActiveLiquidity(); + int256 _expectedPrice = hook.tickToPrice(_currentTick); + + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + (bytes4 selector, BeforeSwapDelta delta, uint24 fee) = + _swap(bob, int256(_amountIn), _principalForCash, _currentTick); + + assertEq(selector, BaseHook.beforeSwap.selector, "Selector"); + assertEq(BeforeSwapDelta.unwrap(delta), 0, "Delta"); + assertEq(fee, 0, "Fee"); + + assertEq(hook.getCurrentTick(), _currentTick, "Tick unchanged"); + assertEq(hook.getActiveLiquidity(), _activeBefore, "Liquidity unchanged"); + assertEq(hook.getCurrentPrice(), _expectedPrice, "Price initialized"); + + assertEq(token0.balanceOf(bob), _balance0Before, "TOKEN0 unchanged"); + assertEq(token1.balanceOf(bob), _balance1Before, "TOKEN1 unchanged"); + assertEq(token0.balanceOf(address(hook)), _hookBalance0Before, "Hook TOKEN0 unchanged"); + assertEq(token1.balanceOf(address(hook)), _hookBalance1Before, "Hook TOKEN1 unchanged"); + } + + function testFuzz_PrincipalForCashSwapWithNoTickLimit(uint256 _amountIn) public { + // 2 bips minimum covers exp/ln rounding. _moveTickTo(100) creates _a position from 0-600 + // (_activeLiq 2e22), and setUp adds another from 0-2000 (total 2e22 up to _tick 600, then + // 1e22). Cap at 500 _ticks so input is _consumed before the _liquidity drop at _tick 600, since + // _minInputForTicks assumes constant _liquidity. + _amountIn = bound(_amountIn, _ptForOneBip() * 2, _minInputForTicks(500)); + + uint256 _balanceBefore = token0.balanceOf(bob); + _swap(bob, int256(_amountIn), true, NO_TICK_LIMIT); + + uint256 _consumed = _balanceBefore - token0.balanceOf(bob); + assertLe(_consumed, _amountIn, "Full _amount should be _consumed with no _tick limit"); + } + + function testFuzz_CashForPrincipalSwapWithNoTickLimit(uint256 _amountIn) public { + // 2 bips minimum covers exp/ln rounding. Cash→PT moves _tick down from 100; _liquidity ends + // at + // _tick 0 so cap at 95 _ticks to stay within range. + _amountIn = bound(_amountIn, _ptForOneBip() * 2, _minInputForTicks(95)); + + uint256 _balanceBefore = token1.balanceOf(bob); + _swap(bob, int256(_amountIn), false, NO_TICK_LIMIT); + + uint256 _consumed = _balanceBefore - token1.balanceOf(bob); + assertLe(_consumed, _amountIn, "Full _amount should be _consumed with no _tick limit"); + } + + // ============ Edge Case Tests ============ + + function test_EmptyHookParamsIsNoOp() public { + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + (bytes4 selector, BeforeSwapDelta delta, uint24 fee) = + hook.exposed_beforeSwap(bob, poolKey, defaultSwapParams, ""); + + // Should return the correct selector + assertEq(selector, BaseHook.beforeSwap.selector, "Should return beforeSwap selector"); + + // No tokens should be transferred + assertEq(token0.balanceOf(bob), _balance0Before, "TOKEN0 unchanged"); + assertEq(token1.balanceOf(bob), _balance1Before, "TOKEN1 unchanged"); + assertEq(token0.balanceOf(address(hook)), _hookBalance0Before, "Hook TOKEN0 unchanged"); + assertEq(token1.balanceOf(address(hook)), _hookBalance1Before, "Hook TOKEN1 unchanged"); + assertEq(BeforeSwapDelta.unwrap(delta), 0, "Delta should be zero"); + assertEq(fee, 0, "Fee should be zero"); + } + + function testFuzz_PrincipalForCashSwapNearMaturity(uint256 _timeToMaturity) public { + _timeToMaturity = bound(_timeToMaturity, 1, 1 days); + vm.warp(hook.getMaturity() - _timeToMaturity); + + // Use at least 2 bips to ensure output with integer-_tick snapping and exp/ln rounding + uint256 _swapAmount = _ptForOneBip() * 2; + + int24 _tickBefore = hook.getCurrentTick(); + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + _swap(bob, int256(_swapAmount), true, NO_TICK_LIMIT); + + uint256 _consumed = _balance0Before - token0.balanceOf(bob); + uint256 _received = token1.balanceOf(bob) - _balance1Before; + uint256 _hookGained0 = token0.balanceOf(address(hook)) - _hookBalance0Before; + uint256 _hookLost1 = _hookBalance1Before - token1.balanceOf(address(hook)); + + assertLe(_consumed, _swapAmount, "Consumed should not exceed input"); + assertGt(hook.getCurrentTick(), _tickBefore, "Tick should increase"); + assertEq(_consumed, _hookGained0, "Hook TOKEN0 gain should equal _user TOKEN0 spent"); + assertEq(_received, _hookLost1, "Hook TOKEN1 loss should equal _user TOKEN1 _received"); + } + + function testFuzz_CashForPrincipalSwapNearMaturity(uint256 _timeToMaturity) public { + _timeToMaturity = bound(_timeToMaturity, 1, 1 days); + vm.warp(hook.getMaturity() - _timeToMaturity); + + // Use at least 2 bips to ensure output with integer-_tick snapping and exp/ln rounding + uint256 _swapAmount = _ptForOneBip() * 2; + + int24 _tickBefore = hook.getCurrentTick(); + uint256 _balance0Before = token0.balanceOf(bob); + uint256 _balance1Before = token1.balanceOf(bob); + uint256 _hookBalance0Before = token0.balanceOf(address(hook)); + uint256 _hookBalance1Before = token1.balanceOf(address(hook)); + + _swap(bob, int256(_swapAmount), false, NO_TICK_LIMIT); + + uint256 _consumed = _balance1Before - token1.balanceOf(bob); + uint256 _received = token0.balanceOf(bob) - _balance0Before; + uint256 _hookGained1 = token1.balanceOf(address(hook)) - _hookBalance1Before; + uint256 _hookLost0 = _hookBalance0Before - token0.balanceOf(address(hook)); + + assertLe(_consumed, _swapAmount, "Consumed should not exceed input"); + assertLt(hook.getCurrentTick(), _tickBefore, "Tick should decrease"); + assertEq(_consumed, _hookGained1, "Hook TOKEN1 gain should equal _user TOKEN1 spent"); + assertEq(_received, _hookLost0, "Hook TOKEN0 loss should equal _user TOKEN0 _received"); + } + + function testFuzz_SequentialSwapsInTheSameDirection(uint256 _amount1, uint256 _amount2) public { + // Use amounts large enough to always move at least 1 _tick (2 bips covers exp/ln rounding) + _amount1 = bound(_amount1, _ptForOneBip() * 2, 100e18); + _amount2 = bound(_amount2, _ptForOneBip() * 2, 100e18); + + int24 _tickBefore = hook.getCurrentTick(); + + _swap(bob, int256(_amount1), true, NO_TICK_LIMIT); + int24 _tickAfter1 = hook.getCurrentTick(); + + _swap(bob, int256(_amount2), true, NO_TICK_LIMIT); + int24 _tickAfter2 = hook.getCurrentTick(); + + assertGt(_tickAfter1, _tickBefore, "First swap increases _tick"); + assertGt(_tickAfter2, _tickAfter1, "Second swap increases _tick further"); + } + + function testFuzz_SequentialSwapsInOppositeDirections( + uint256 _amount1, + uint256 _amount2, + uint256 _timeElapsed + ) public { + _amount1 = bound(_amount1, 1e15, 1e18); + _amount2 = bound(_amount2, 1e15, 1e18); + _timeElapsed = bound(_timeElapsed, 1 days, MARKET_LENGTH - 1); + _warpFromStart(_timeElapsed); + + int24 _tickBefore = hook.getCurrentTick(); + + // Swap principal for cash (_tick increases) + _swap(bob, int256(_amount1), true, NO_TICK_LIMIT); + int24 _tickAfter1 = hook.getCurrentTick(); + + // Swap cash for principal (_tick decreases) + _swap(bob, int256(_amount2), false, NO_TICK_LIMIT); + int24 _tickAfter2 = hook.getCurrentTick(); + + // First swap increases, second decreases + assertGe(_tickAfter1, _tickBefore, "First swap increases _tick"); + assertLe(_tickAfter2, _tickAfter1, "Second swap decreases _tick"); + } + + // ============ Revert Tests ============ + + function testFuzz_RevertIf_HookParamsHaveAnIncorrectLength(uint256 _length) public { + // Valid _hookParams is exactly 96 bytes (int256, bool, int24 encoded). + // Length 0 is _a valid no-op (empty _hookParams), so exclude it. + _length = bound(_length, 1, 256); + vm.assume(_length != 96); + + bytes memory _wrongLength = new bytes(_length); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidHookParams.selector); + hook.exposed_beforeSwap(bob, poolKey, defaultSwapParams, _wrongLength); + } + + function testFuzz_RevertIf_AmountInIsNotPositive(int256 _amountIn) public { + _amountIn = bound(_amountIn, type(int256).min, 0); + bytes memory _hookParams = _encodeHookParams(_amountIn, true, NO_TICK_LIMIT); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_SwapAmountCannotBeZero.selector); + hook.exposed_beforeSwap(bob, poolKey, defaultSwapParams, _hookParams); + } + + function testFuzz_RevertIf_MarketHasExpired(uint256 _secondsAfterMaturity) public { + _secondsAfterMaturity = bound(_secondsAfterMaturity, 0, 30 days); + vm.warp(hook.getMaturity() + _secondsAfterMaturity); + bytes memory _hookParams = _encodeHookParams(1e18, true, NO_TICK_LIMIT); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_MarketExpired.selector); + hook.exposed_beforeSwap(bob, poolKey, defaultSwapParams, _hookParams); + } + + function test_RevertIf_NoActiveLiquidity() public { + // Deploy fresh hook with no _liquidity + address _mockManager = makeAddr("freshMockManager"); + FixedFeeSwapMarketHarness _freshHook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + YIELD_PER_TICK, + IPoolManager(_mockManager), + 0 + ); + + vm.warp(block.timestamp + 1 days); + + PoolKey memory _freshPoolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(_freshHook)) + }); + + bytes memory _hookParams = _encodeHookParams(1e18, true, NO_TICK_LIMIT); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_NoActiveLiquidity.selector); + _freshHook.exposed_beforeSwap(bob, _freshPoolKey, defaultSwapParams, _hookParams); + } + + function testFuzz_RevertIf_PrincipalForCashSwapExhaustsLiquidity(uint128 _liq) public { + address _mockManager = makeAddr("freshMockManager2"); + FixedFeeSwapMarketHarness _freshHook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + YIELD_PER_TICK, + IPoolManager(_mockManager), + 0 + ); + vm.warp(block.timestamp + 1 days); + + PoolKey memory _freshPoolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(_freshHook)) + }); + + _liq = uint128(bound(uint256(_liq), 1e18, 1e22)); + vm.startPrank(alice); + token0.approve(address(_freshHook), type(uint256).max); + token1.approve(address(_freshHook), type(uint256).max); + _freshHook.mintLiquidity(0, 20, _liq, type(uint256).max, type(uint256).max); + vm.stopPrank(); + + int256 _price = _freshHook.tickToPrice(_freshHook.getCurrentTick()); + (uint256 principalToBoundary,,,) = + _freshHook.exposed_getCashSwapAmount(int256(1e36), _price, 20); + + bytes memory _hookParams = + _encodeHookParams(int256(principalToBoundary + 1), true, NO_TICK_LIMIT); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidTickTraversal.selector); + _freshHook.exposed_beforeSwap(bob, _freshPoolKey, defaultSwapParams, _hookParams); + } + + function testFuzz_RevertIf_CashForPrincipalSwapExhaustsLiquidity(uint128 _liq) public { + address _mockManager = makeAddr("freshMockManager3"); + FixedFeeSwapMarketHarness _freshHook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + FEE_RATE, + YIELD_PER_TICK, + IPoolManager(_mockManager), + 0 + ); + vm.warp(block.timestamp + 1 days); + + PoolKey memory _freshPoolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(_freshHook)) + }); + + _liq = uint128(bound(uint256(_liq), 1e18, 1e22)); + vm.startPrank(alice); + token0.approve(address(_freshHook), type(uint256).max); + token1.approve(address(_freshHook), type(uint256).max); + _freshHook.mintLiquidity(0, 20, _liq, type(uint256).max, type(uint256).max); + vm.stopPrank(); + + vm.startPrank(bob); + token0.approve(address(_freshHook), type(uint256).max); + token1.approve(address(_freshHook), type(uint256).max); + vm.stopPrank(); + + // Push _tick to middle of range so cash->principal has room to move left + int256 _price = _freshHook.tickToPrice(_freshHook.getCurrentTick()); + bytes memory _pushParams = abi.encode(int256(1e36), true, int24(10)); + _freshHook.exposed_beforeSwap(bob, _freshPoolKey, defaultSwapParams, _pushParams); + + // Now try to exhaust all _liquidity going cash->principal past _lower _tick + _price = _freshHook.tickToPrice(_freshHook.getCurrentTick()); + (uint256 cashInToBoundary,,,) = + _freshHook.exposed_getPrincipalSwapAmount(int256(1e36), _price, 0); + + bytes memory _hookParams = _encodeHookParams(int256(cashInToBoundary + 1), false, NO_TICK_LIMIT); + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidTickTraversal.selector); + _freshHook.exposed_beforeSwap(bob, _freshPoolKey, defaultSwapParams, _hookParams); + } + + function testFuzz_RevertIf_InsufficientInputTokenAllowance( + uint256 _amountIn, + bool _principalForCash + ) public { + // Use amounts large enough to always move _a _tick (2 bips covers exp/ln rounding) + _amountIn = bound(_amountIn, _ptForOneBip() * 2, 100e18); + + vm.prank(bob); + if (_principalForCash) token0.approve(address(hook), 0); + else token1.approve(address(hook), 0); + + bytes memory _hookParams = + _encodeHookParams(int256(_amountIn), _principalForCash, NO_TICK_LIMIT); + + vm.expectRevert(stdError.arithmeticError); + hook.exposed_beforeSwap(bob, poolKey, defaultSwapParams, _hookParams); + } + + function testFuzz_ReverIf_InsufficientBalance(uint256 _amountIn, bool _principalForCash) public { + // Use amounts large enough to always move at least 1 _tick (2 bips covers exp/ln rounding) + _amountIn = bound(_amountIn, _ptForOneBip() * 2, 100e18); + + address _poorUser = makeAddr("_poorUser"); + + // Approve but no balance + vm.startPrank(_poorUser); + token0.approve(address(hook), type(uint256).max); + token1.approve(address(hook), type(uint256).max); + vm.stopPrank(); + + bytes memory _hookParams = + _encodeHookParams(int256(_amountIn), _principalForCash, NO_TICK_LIMIT); + + vm.expectRevert(stdError.arithmeticError); + hook.exposed_beforeSwap(_poorUser, poolKey, defaultSwapParams, _hookParams); + } + + // ============ Swap Event Tests ============ + + function testFuzz_PrincipalForCashSwapEmitsSwapEvent(uint256 _amountIn) public { + _amountIn = _boundSwapAmount(_amountIn); + + vm.expectEmit(true, false, false, false, address(hook)); + emit FixedFeeSwapMarket.Swap(bob, true, 0, 0); + _swap(bob, int256(_amountIn), true, NO_TICK_LIMIT); + } + + function testFuzz_CashForPrincipalSwapEmitsSwap(uint256 _amountIn) public { + _amountIn = _boundSwapAmount(_amountIn); + + vm.expectEmit(true, false, false, false, address(hook)); + emit FixedFeeSwapMarket.Swap(bob, false, 0, 0); + _swap(bob, int256(_amountIn), false, NO_TICK_LIMIT); + } + + // ============ LP Fee Tests ============ + + function testFuzz_FeeRateIsSetAndInitialFeeGrowthIsZero(uint24 _feeRate) public { + _feeRate = uint24(bound(uint256(_feeRate), 0, 999_999)); + (FixedFeeSwapMarketHarness feeHook,) = + _deployHookForBeforeSwapTests(_feeRate, "feeRateHookManager"); + + assertEq(feeHook.FEE_RATE(), _feeRate); + assertEq(feeHook.feeGrowthGlobal0X128(), 0); + assertEq(feeHook.feeGrowthGlobal1X128(), 0); + } + + function testFuzz_PrincipalForCashSwapAccumulatesToken0FeeGrowth(uint256 _amountIn) public { + uint128 _liquidity = 1e23; + _amountIn = bound(_amountIn, 2e19, 2e21); + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "feeGrowth0Manager"); + _mintOnHook(feeHook, alice, 0, 200, _liquidity); + + uint256 _balance0Before = token0.balanceOf(bob); + _swapOnHook(feeHook, feePoolKey, bob, int256(_amountIn), true, NO_TICK_LIMIT); + uint256 _consumed = _balance0Before - token0.balanceOf(bob); + + // Per-step rounding (mulDivUp for fee, mulDiv for growth) introduces error bounded by + // ~(Q128 / _liquidity + 1) per _tick crossed. Use _maxTickMove as an _upper bound on _tick + // count. + uint256 _expected = _expectedFeeGrowth(_consumed, _liquidity); + uint256 _feeGrowth0 = feeHook.feeGrowthGlobal0X128(); + + assertApproxEqAbs( + _feeGrowth0, + _expected, + _feeGrowthRoundingTolerance(_amountIn, _liquidity), + "Fee growth should match _expected" + ); + } + + function testFuzz_SwapWithFeesDoesNotIncreaseOutputComparedToNoFee(uint256 _amountIn) public { + uint128 _liquidity = 1e23; + _amountIn = bound(_amountIn, 2e19, 2e21); + + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "feeOutputManager"); + (FixedFeeSwapMarketHarness noFeeHook, PoolKey memory noFeePoolKey) = + _deployHookForBeforeSwapTests(0, "noFeeOutputManager"); + + _mintOnHook(feeHook, alice, 0, 200, _liquidity); + _mintOnHook(noFeeHook, alice, 0, 200, _liquidity); + + uint256 _feeGrowth0Before = feeHook.feeGrowthGlobal0X128(); + (uint256 inputWithFee, uint256 outputWithFee) = + _measurePrincipalForCashSwap(feeHook, feePoolKey, _amountIn); + uint256 _feeGrowth0After = feeHook.feeGrowthGlobal0X128(); + + token0.mint(bob, inputWithFee); + + (uint256 inputNoFee, uint256 outputNoFee) = + _measurePrincipalForCashSwap(noFeeHook, noFeePoolKey, _amountIn); + + assertLe(outputWithFee, outputNoFee, "Output should not increase with fees"); + assertApproxEqAbs(inputWithFee, inputNoFee, 1, "Consumed input should match within 1 wei"); + assertLe(inputWithFee, _amountIn, "Consumed input should never exceed _amountIn"); + assertGt( + _feeGrowth0After, _feeGrowth0Before, "Fee growth should increase when fees are enabled" + ); + } + + function testFuzz_CollectFeesAfterPrincipalForCashSwaps(uint8 _swapCount, uint256 _amountIn) + public + { + _swapCount = uint8(bound(uint256(_swapCount), 1, 10)); + _amountIn = bound(_amountIn, 1e17, 2e17); + + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "collectFeeManager"); + bytes32 _positionId = _mintOnHook(feeHook, alice, 0, 300, 1e20); + + for (uint256 _i = 0; _i < _swapCount; _i++) { + _swapOnHook(feeHook, feePoolKey, bob, int256(_amountIn), true, NO_TICK_LIMIT); + } + + uint256 _aliceToken0Before = token0.balanceOf(alice); + vm.prank(alice); + (uint128 collected0, uint128 collected1) = feeHook.collectFees(_positionId, alice, 0, 0); + uint256 _aliceToken0After = token0.balanceOf(alice); + + assertGt(collected0, 0, "Should collect TOKEN0 fees"); + assertEq(collected1, 0, "Should not collect TOKEN1 fees from one-way swaps"); + assertEq( + _aliceToken0After - _aliceToken0Before, collected0, "Balance increase should match fees" + ); + } + + function testFuzz_FeeCollectionScalesWithLiquidityShare(uint8 _swapCount, uint256 _amountIn) + public + { + _swapCount = uint8(bound(uint256(_swapCount), 2, 20)); + _amountIn = bound(_amountIn, 5e16, 2e17); + + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "feeSplitManager"); + + bytes32 _alicePositionId = _mintOnHook(feeHook, alice, 0, 300, 2e20); + bytes32 _bobPositionId = _mintOnHook(feeHook, bob, 0, 300, 1e20); + + address _carol = makeAddr("feeSplitCarol"); + _fundUser(_carol); + vm.startPrank(_carol); + token0.approve(address(feeHook), type(uint256).max); + token1.approve(address(feeHook), type(uint256).max); + vm.stopPrank(); + + for (uint256 _i = 0; _i < _swapCount; _i++) { + _swapOnHook(feeHook, feePoolKey, _carol, int256(_amountIn), true, NO_TICK_LIMIT); + } + + vm.prank(alice); + (uint128 aliceFees0,) = feeHook.collectFees(_alicePositionId, alice, 0, 0); + vm.prank(bob); + (uint128 bobFees0,) = feeHook.collectFees(_bobPositionId, bob, 0, 0); + + vm.assume(bobFees0 > 0); + uint256 _expectedAliceFees = uint256(bobFees0) * 2; + uint256 _tolerance = _expectedAliceFees / 20 + 1; + assertApproxEqAbs(aliceFees0, _expectedAliceFees, _tolerance, "Alice should collect ~2x Bob"); + } + + function testFuzz_OutOfRangeLiquidityDoesNotEarnFees(uint8 _swapCount, uint256 _amountIn) public { + _swapCount = uint8(bound(uint256(_swapCount), 1, 10)); + _amountIn = bound(_amountIn, 5e16, 1e17); + + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "outOfRangeFeeManager"); + + bytes32 _alicePositionId = _mintOnHook(feeHook, alice, 0, 300, 1e20); + bytes32 _bobPositionId = _mintOnHook(feeHook, bob, 500, 600, 1e20); + + address _carol = makeAddr("outOfRangeCarol"); + _fundUser(_carol); + vm.startPrank(_carol); + token0.approve(address(feeHook), type(uint256).max); + token1.approve(address(feeHook), type(uint256).max); + vm.stopPrank(); + + for (uint256 _i = 0; _i < _swapCount; _i++) { + _swapOnHook(feeHook, feePoolKey, _carol, int256(_amountIn), true, NO_TICK_LIMIT); + } + + vm.prank(alice); + (uint128 aliceFees0,) = feeHook.collectFees(_alicePositionId, alice, 0, 0); + vm.prank(bob); + (uint128 bobFees0,) = feeHook.collectFees(_bobPositionId, bob, 0, 0); + + assertGt(aliceFees0, 0, "In-range LP should collect fees"); + assertEq(bobFees0, 0, "Out-of-range LP should not collect fees"); + } + + function testFuzz_IncreaseLiquidityDoesNotLoseAccruedFees( + uint256 _amountIn, + uint128 _additionalLiquidity + ) public { + _amountIn = bound(_amountIn, 1e17, 5e17); + _additionalLiquidity = uint128(bound(uint256(_additionalLiquidity), 1e18, 1e20)); + + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "increaseFeeManager"); + bytes32 _positionId = _mintOnHook(feeHook, alice, 0, 200, 1e20); + + _swapOnHook(feeHook, feePoolKey, bob, int256(_amountIn), true, NO_TICK_LIMIT); + + (,,, int128 liquidityBefore) = feeHook.getPositionInfo(_positionId); + vm.prank(alice); + feeHook.increaseLiquidity( + _positionId, _additionalLiquidity, type(uint256).max, type(uint256).max + ); + (,,, int128 liquidityAfter) = feeHook.getPositionInfo(_positionId); + + vm.prank(alice); + (uint128 collected0,) = feeHook.collectFees(_positionId, alice, 0, 0); + + assertEq( + liquidityAfter, + liquidityBefore + int128(_additionalLiquidity), + "Liquidity should increase by delta" + ); + assertGt(collected0, 0, "Accrued fees should remain collectible after increase"); + } + + function testFuzz_ReAddingLiquidityDoesNotBackfillPastFees(uint8 _swapCount, uint256 _amountIn) + public + { + _swapCount = uint8(bound(uint256(_swapCount), 1, 8)); + _amountIn = bound(_amountIn, 1e17, 2e17); + + (FixedFeeSwapMarketHarness feeHook, PoolKey memory feePoolKey) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "reAddFeeManager"); + _mintOnHook(feeHook, bob, 0, 300, 2e20); + bytes32 _alicePositionId = _mintOnHook(feeHook, alice, 0, 300, 1e20); + + _swapOnHook(feeHook, feePoolKey, bob, int256(_amountIn), true, NO_TICK_LIMIT); + + (,,, int128 liquidityBefore) = feeHook.getPositionInfo(_alicePositionId); + vm.prank(alice); + feeHook.decreaseLiquidity(_alicePositionId, -liquidityBefore, 0, 0); + + vm.prank(alice); + feeHook.collectFees(_alicePositionId, alice, 0, 0); + + for (uint256 _i = 0; _i < _swapCount; _i++) { + _swapOnHook(feeHook, feePoolKey, bob, int256(_amountIn), true, NO_TICK_LIMIT); + } + + vm.prank(alice); + feeHook.increaseLiquidity(_alicePositionId, 1e20, type(uint256).max, type(uint256).max); + vm.prank(alice); + (uint128 fees0, uint128 fees1) = feeHook.collectFees(_alicePositionId, alice, 0, 0); + + assertEq(fees0, 0, "Re-added _liquidity should not backfill TOKEN0 fees"); + assertEq(fees1, 0, "Re-added _liquidity should not backfill TOKEN1 fees"); + } + + function testFuzz_RevertIf_NonOwnerCollectsFees(address _caller) public { + vm.assume(_caller != alice && _caller != address(0)); + (FixedFeeSwapMarketHarness feeHook,) = + _deployHookForBeforeSwapTests(LP_FEE_RATE, "notOwnerCollectManager"); + bytes32 _positionId = _mintOnHook(feeHook, alice, 0, 200, 1e20); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_NotPositionOwner.selector); + vm.prank(_caller); + feeHook.collectFees(_positionId, _caller, 0, 0); + } +} + +contract _BeforeAddLiquidity is FixedFeeSwapMarketTest { + function test_RevertWhen_CalledDirectly() public { + PoolKey memory _poolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(hook)) + }); + ModifyLiquidityParams memory _params = + ModifyLiquidityParams({tickLower: 0, tickUpper: 100, liquidityDelta: 1e18, salt: bytes32(0)}); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_LiquidityOnlyViaHook.selector); + hook.exposed_beforeAddLiquidity(alice, _poolKey, _params, ""); + } +} + +contract _BeforeRemoveLiquidity is FixedFeeSwapMarketTest { + function test_RevertWhen_CalledDirectly() public { + PoolKey memory _poolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(hook)) + }); + ModifyLiquidityParams memory _params = ModifyLiquidityParams({ + tickLower: 0, tickUpper: 100, liquidityDelta: -1e18, salt: bytes32(0) + }); + + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_LiquidityOnlyViaHook.selector); + hook.exposed_beforeRemoveLiquidity(alice, _poolKey, _params, ""); + } +} + +contract CollectFees is FixedFeeSwapMarketTest { + FixedFeeSwapMarketHarness public feeHook; + PoolKey internal feePoolKey; + + function setUp() public override { + super.setUp(); + vm.warp(1); + address _mockManager = makeAddr("collectFeesMockManager"); + feeHook = new FixedFeeSwapMarketHarness( + MARKET_LENGTH, + address(token0), + address(token1), + LP_FEE_RATE, + YIELD_PER_TICK, + IPoolManager(_mockManager), + 0 + ); + vm.warp(block.timestamp + 1 days); + feePoolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(feeHook)) + }); + defaultSwapParams = SwapParams({zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: 0}); + vm.startPrank(alice); + token0.approve(address(feeHook), type(uint256).max); + token1.approve(address(feeHook), type(uint256).max); + vm.stopPrank(); + vm.startPrank(bob); + token0.approve(address(feeHook), type(uint256).max); + token1.approve(address(feeHook), type(uint256).max); + vm.stopPrank(); + } + + function _feeSwap(address _sender, int256 _amountIn, bool _principalForCash) internal { + bytes memory _hookParams = abi.encode(_amountIn, _principalForCash, int24(type(int24).max)); + feeHook.exposed_beforeSwap(_sender, feePoolKey, defaultSwapParams, _hookParams); + } + + function testFuzz_PartialFeeCollection(uint128 _liquidity, uint256 _swapAmount) public { + _liquidity = _boundLiquidity(_liquidity); + // 3 same-direction swaps, cap each at 90 bips so total stays within 300-_tick range + uint256 _minSwap = uint256(_liquidity) * 2 / BIPS; + uint256 _maxSwap = uint256(_liquidity) * 90 / BIPS; + _swapAmount = bound(_swapAmount, _minSwap, _maxSwap); + + vm.prank(alice); + feeHook.mintLiquidity(0, 300, _liquidity, type(uint256).max, type(uint256).max); + bytes32 _positionId = keccak256(abi.encodePacked(int24(300), int24(0), alice)); + + _feeSwap(bob, int256(_swapAmount), true); + + // Collect all + vm.prank(alice); + (uint128 _collected0,) = feeHook.collectFees(_positionId, alice, 0, 0); + assertGt(_collected0, 0, "Should have fees to collect"); + + // Generate more fees and collect all + _feeSwap(bob, int256(_swapAmount), true); + vm.prank(alice); + feeHook.collectFees(_positionId, alice, 0, 0); + // Generate fees again since we just collected all + _feeSwap(bob, int256(_swapAmount), true); + + // Now request only 1 wei + vm.prank(alice); + (uint128 _partial0,) = feeHook.collectFees(_positionId, alice, 1, 0); + assertEq(_partial0, 1, "Should collect exactly requested _amount"); + + // Remainder still available + vm.prank(alice); + (uint128 _remainder0,) = feeHook.collectFees(_positionId, alice, 0, 0); + assertGt(_remainder0, 0, "Should still have _remaining fees"); + } + + function testFuzz_AlreadyCollectedFeesCannotCollectMore(uint128 _liquidity, uint256 _swapAmount) + public + { + _liquidity = _boundLiquidity(_liquidity); + _swapAmount = _boundFeeSwapAmount(_swapAmount, _liquidity); + + vm.prank(alice); + feeHook.mintLiquidity(0, 300, _liquidity, type(uint256).max, type(uint256).max); + bytes32 _positionId = keccak256(abi.encodePacked(int24(300), int24(0), alice)); + + _feeSwap(bob, int256(_swapAmount), true); + + vm.prank(alice); + feeHook.collectFees(_positionId, alice, 0, 0); + + vm.prank(alice); + (uint128 _collected0, uint128 _collected1) = feeHook.collectFees(_positionId, alice, 0, 0); + assertEq(_collected0, 0, "Second collect should yield zero TOKEN0"); + assertEq(_collected1, 0, "Second collect should yield zero TOKEN1"); + } + + function testFuzz_FeesSentToDifferentRecipient( + uint128 _liquidity, + uint256 _swapAmount, + address _recipient + ) public { + _liquidity = _boundLiquidity(_liquidity); + _swapAmount = _boundFeeSwapAmount(_swapAmount, _liquidity); + vm.assume(_recipient != address(0) && _recipient != address(feeHook)); + + vm.prank(alice); + feeHook.mintLiquidity(0, 300, _liquidity, type(uint256).max, type(uint256).max); + bytes32 _positionId = keccak256(abi.encodePacked(int24(300), int24(0), alice)); + + _feeSwap(bob, int256(_swapAmount), true); + + uint256 _recipientToken0Before = token0.balanceOf(_recipient); + vm.prank(alice); + (uint128 _collected0,) = feeHook.collectFees(_positionId, _recipient, 0, 0); + assertGt(_collected0, 0, "Should collect fees"); + assertEq( + token0.balanceOf(_recipient) - _recipientToken0Before, + _collected0, + "Recipient should receive the fees" + ); + } + + function testFuzz_SwapInBothDirectionsCollectsBothTokenFees( + uint128 _liquidity, + uint256 _pushAmount, + uint256 _reverseAmount + ) public { + _liquidity = _boundLiquidity(_liquidity); + // Push: 4-200 bips of _liquidity + uint256 _minPush = uint256(_liquidity) * 4 / BIPS; + uint256 _maxPush = uint256(_liquidity) * 200 / BIPS; + _pushAmount = bound(_pushAmount, _minPush, _maxPush); + // Reverse: 2 bips to half of push + uint256 _minReverse = uint256(_liquidity) * 2 / BIPS; + uint256 _maxReverse = _pushAmount / 2; + vm.assume(_maxReverse >= _minReverse); + _reverseAmount = bound(_reverseAmount, _minReverse, _maxReverse); + + vm.prank(alice); + feeHook.mintLiquidity(0, 300, _liquidity, type(uint256).max, type(uint256).max); + bytes32 _positionId = keccak256(abi.encodePacked(int24(300), int24(0), alice)); + + // PT→Cash — push _tick up to create room for cash→PT + _feeSwap(bob, int256(_pushAmount), true); + // Cash→PT (smaller than push to stay in range) + _feeSwap(bob, int256(_reverseAmount), false); + + assertGt(feeHook.feeGrowthGlobal1X128(), 0, "feeGrowthGlobal1X128 should be > 0"); + + vm.prank(alice); + (uint128 _collected0, uint128 _collected1) = feeHook.collectFees(_positionId, alice, 0, 0); + assertGt(_collected0, 0, "Should have collected TOKEN0 fees"); + assertGt(_collected1, 0, "Should have collected TOKEN1 fees"); + } + + function testFuzz_EmitsFeesCollected(uint128 _liquidity, uint256 _swapAmount) public { + _liquidity = _boundLiquidity(_liquidity); + _swapAmount = _boundFeeSwapAmount(_swapAmount, _liquidity); + + vm.prank(alice); + feeHook.mintLiquidity(0, 300, _liquidity, type(uint256).max, type(uint256).max); + bytes32 _positionId = keccak256(abi.encodePacked(int24(300), int24(0), alice)); + + _feeSwap(bob, int256(_swapAmount), true); + + vm.expectEmit(true, true, false, false, address(feeHook)); + emit FixedFeeSwapMarket.FeesCollected(_positionId, alice, 0, 0); + + vm.prank(alice); + feeHook.collectFees(_positionId, alice, 0, 0); + } +} + +// ============================================================================ +// Liquidity Gap Tests - Swaps across ranges with zero _liquidity gaps +// ============================================================================ + +contract LiquidityGap is FixedFeeSwapMarketTest { + function setUp() public override { + super.setUp(); + poolKey = PoolKey({ + currency0: Currency.wrap(address(token0)), + currency1: Currency.wrap(address(token1)), + fee: 0, + tickSpacing: 1, + hooks: IHooks(address(hook)) + }); + defaultSwapParams = SwapParams({zeroForOne: true, amountSpecified: -1e18, sqrtPriceLimitX96: 0}); + } + + /// @dev Swap PT->Cash across contiguous positions: A at [0, 100], B at [100, 300] + function testFuzz_SwapAcrossContiguousPositions_PrincipalForCash(uint128 _liqA, uint128 _liqB) + public + { + _liqA = _boundLiquidity(_liqA); + _liqB = _boundLiquidity(_liqB); + _warpFromStart(1); + + // Position A: [0, 100], Position B: [100, 300] (contiguous, B is wider) + _mintLiquidityAtTicks(alice, 0, 100, _liqA); + _mintLiquidityAtTicks(bob, 100, 300, _liqB); + + // Swap enough to cross from A into B (_price limit at 150 caps traversal) + // 100 bips exhausts A, 110 gives margin to enter B + uint256 _swapAmount = uint256(_liqA) * 110 / BIPS; + _swap(alice, int256(_swapAmount), true, int24(150)); + + int24 _currentTick = hook.getCurrentTick(); + assertGe(_currentTick, 100, "Tick should cross into Position B's range"); + assertLt(_currentTick, 300, "Tick should be within Position B's range"); + // Active _liquidity should now include only B (A ends at 100) + assertEq(hook.getActiveLiquidity(), _liqB, "Active _liquidity should match Position B"); + } + + /// @dev Swap Cash->PT across contiguous positions (reverse direction) + function testFuzz_SwapAcrossContiguousPositions_CashForPrincipal(uint128 _liqA, uint128 _liqB) + public + { + _liqA = _boundLiquidity(_liqA); + _liqB = _boundLiquidity(_liqB); + _warpFromStart(1); + + // Position A: [0, 100], Position B: [100, 300] + _mintLiquidityAtTicks(alice, 0, 100, _liqA); + _mintLiquidityAtTicks(bob, 100, 300, _liqB); + + // Push _tick well into B's range: exhaust A's 100 _ticks + 20 _ticks into B (_price limit at + // 150) + uint256 _pushAmount = uint256(_liqA) * 100 / BIPS + uint256(_liqB) * 20 / BIPS; + _swap(alice, int256(_pushAmount), true, int24(150)); + assertGt(hook.getCurrentTick(), 100, "Should be past boundary into B's range after push"); + + // Swap cash->principal back into A: enough for B's _ticks + A's _ticks (_tick limit at 50) + uint256 _reverseAmount = uint256(_liqB) * 20 / BIPS + uint256(_liqA) * 100 / BIPS; + _swap(alice, int256(_reverseAmount), false, int24(50)); + + int24 _currentTick = hook.getCurrentTick(); + assertLe(_currentTick, 100, "Tick should be back in Position A's range"); + assertGe(_currentTick, 0, "Tick should be within Position A's range"); + } + + /// @dev Swap reverts when trying to continue past the only position's range (gap beyond) + function testFuzz_RevertIf_SwapExceedsRangePrincipalForCash(uint128 _liq) public { + _liq = _boundLiquidity(_liq); + _warpFromStart(1); + + // Only position at [0, 100], gap beyond _tick 100 + _mintLiquidityAtTicks(alice, 0, 100, _liq); + + // Compute exact _amount to exhaust the range + int256 _priceAtStart = hook.tickToPrice(hook.getCurrentTick()); + (uint256 _principalToBoundary,,,) = + hook.exposed_getCashSwapAmount(int256(1e36), _priceAtStart, 100); + + // Try to swap past the boundary — should revert because there's no _liquidity beyond + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidTickTraversal.selector); + _swap(bob, int256(_principalToBoundary + 1), true, NO_TICK_LIMIT); + } + + /// @dev Swap reverts when _liquidity is exhausted going cash->principal past _lower _tick + function testFuzz_RevertIf_SwapExceedsRangeCashForPrincipal(uint128 _liq) public { + _liq = _boundLiquidity(_liq); + _warpFromStart(1); + + // Position at [0, 100] — starts in range at _tick 0 + _mintLiquidityAtTicks(alice, 0, 100, _liq); + + // Push _tick positive into the range so cash->principal has room to move left + uint256 _pushAmount = uint256(_liq) * 50 / BIPS; + _swap(bob, int256(_pushAmount), true, NO_TICK_LIMIT); + assertGt(hook.getCurrentTick(), 0, "Should be above _lower _tick after push"); + + // Try to exhaust all _liquidity going cash->principal past _lower _tick (_tick 0) + int256 _price = hook.tickToPrice(hook.getCurrentTick()); + (uint256 _cashInToBoundary,,,) = hook.exposed_getPrincipalSwapAmount(int256(1e36), _price, 0); + + // Going past the _lower boundary with extra should revert + vm.expectRevert(FixedFeeSwapMarket.FixedFeeSwapMarket_InvalidTickTraversal.selector); + _swap(bob, int256(_cashInToBoundary + 1), false, NO_TICK_LIMIT); + } +} diff --git a/test/harness/FixedFeeSwapMarketHarness.sol b/test/harness/FixedFeeSwapMarketHarness.sol new file mode 100644 index 0000000..e8de4e2 --- /dev/null +++ b/test/harness/FixedFeeSwapMarketHarness.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {FixedFeeSwapMarket, TickInfo, PositionInfo} from "src/FixedFeeSwapMarket.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {BaseHook} from "v4-periphery/src/utils/BaseHook.sol"; +import {PoolKey} from "v4-core/types/PoolKey.sol"; +import {SwapParams, ModifyLiquidityParams} from "v4-core/types/PoolOperation.sol"; +import {BeforeSwapDelta} from "v4-core/types/BeforeSwapDelta.sol"; + +contract FixedFeeSwapMarketHarness is FixedFeeSwapMarket { + constructor( + uint48 _length, + address _t0, + address _t1, + uint24 _feeRate, + int24 _yieldPerTick, + IPoolManager _mgr, + int24 _initialTick + ) FixedFeeSwapMarket(_length, _t0, _t1, _feeRate, _yieldPerTick, _mgr, _initialTick) {} + + /// @notice Skip hook address validation for testing + function validateHookAddress(BaseHook) internal pure override { + // No-op for testing - allows deploying to any address + } + + function exposed_calculateSwap( + int256 amountIn, + int256 price, + int24 nextTick, + bool principalToCash + ) external returns (uint256, uint256, int256, int24) { + return _calculateSwap(amountIn, price, nextTick, principalToCash, NO_TICK_LIMIT); + } + + function exposed_getCashSwapAmount(int256 amountIn, int256 price, int24 nextTick) + external + returns (uint256, uint256, int256, int24) + { + return _getCashSwapAmount(amountIn, price, nextTick, NO_TICK_LIMIT); + } + + function exposed_getPrincipalSwapAmount(int256 amountIn, int256 price, int24 nextTick) + external + returns (uint256, uint256, int256, int24) + { + return _getPrincipalSwapAmount(amountIn, price, nextTick, NO_TICK_LIMIT); + } + + function exposed_getAmountPrincipalDelta(int24 lowerYield, int24 upperYield, int128 liquidity) + external + returns (int256) + { + return _getAmountPrincipalDelta(lowerYield, upperYield, liquidity); + } + + function exposed_getAmountCashDelta(int24 lowerYield, int24 upperYield, int128 liquidity) + external + returns (int256) + { + return _getAmountCashDelta(lowerYield, upperYield, liquidity); + } + + function exposed_updateTickLiquidity(int24 tick, int128 liquidityDelta, bool upper) + external + returns (bool, uint128) + { + return _updateTickLiquidity(tick, liquidityDelta, upper); + } + + /// @notice Exposes _beforeSwap for direct testing + /// @dev The sender is the actual caller - in tests, use vm.prank to set the sender + function exposed_beforeSwap( + address sender, + PoolKey calldata key, + SwapParams calldata params, + bytes calldata hookParams + ) external returns (bytes4 selector, BeforeSwapDelta delta, uint24 fee) { + return _beforeSwap(sender, key, params, hookParams); + } + + function exposed_beforeAddLiquidity( + address sender, + PoolKey calldata key, + ModifyLiquidityParams calldata params, + bytes calldata hookData + ) external returns (bytes4) { + return _beforeAddLiquidity(sender, key, params, hookData); + } + + function exposed_beforeRemoveLiquidity( + address sender, + PoolKey calldata key, + ModifyLiquidityParams calldata params, + bytes calldata hookData + ) external returns (bytes4) { + return _beforeRemoveLiquidity(sender, key, params, hookData); + } + + // State getters + function getActiveLiquidity() external view returns (uint128) { + return activeLiquidity; + } + + function getCurrentTick() external view returns (int24) { + return currentTick; + } + + function getCurrentPrice() external view returns (int256) { + return currentPrice; + } + + function getTickInfo(int24 tick) + external + view + returns (uint128 liquidityGross, int128 liquidityNet) + { + TickInfo storage info = ticks[tick]; + return (info.liquidityGross, info.liquidityNet); + } + + function getTickBitmapWord(int16 wordPos) external view returns (uint256) { + return tickBitmap[wordPos]; + } + + function getMaturity() external view returns (uint48) { + return MATURITY; + } + + function getStartTime() external view returns (uint48) { + return START_TIME; + } + + function getToken0() external view returns (address) { + return address(TOKEN0); + } + + function getToken1() external view returns (address) { + return address(TOKEN1); + } + + function getPositionInfo(bytes32 positionId) + external + view + returns (address owner, int24 tickLower, int24 tickUpper, int128 liquidity) + { + PositionInfo storage info = positions[positionId]; + return (info.owner, info.tickLower, info.tickUpper, info.liquidity); + } +}