diff --git a/.forge-snapshots/FullRangeAddInitialLiquidity.snap b/.forge-snapshots/FullRangeAddInitialLiquidity.snap index 404cf12a..e915877b 100644 --- a/.forge-snapshots/FullRangeAddInitialLiquidity.snap +++ b/.forge-snapshots/FullRangeAddInitialLiquidity.snap @@ -1 +1 @@ -311181 \ No newline at end of file +354477 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeAddLiquidity.snap b/.forge-snapshots/FullRangeAddLiquidity.snap index a4a14676..03960543 100644 --- a/.forge-snapshots/FullRangeAddLiquidity.snap +++ b/.forge-snapshots/FullRangeAddLiquidity.snap @@ -1 +1 @@ -122990 \ No newline at end of file +161786 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeFirstSwap.snap b/.forge-snapshots/FullRangeFirstSwap.snap index da120795..e9aad527 100644 --- a/.forge-snapshots/FullRangeFirstSwap.snap +++ b/.forge-snapshots/FullRangeFirstSwap.snap @@ -1 +1 @@ -80220 \ No newline at end of file +146400 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeInitialize.snap b/.forge-snapshots/FullRangeInitialize.snap index 7a0170eb..9661da18 100644 --- a/.forge-snapshots/FullRangeInitialize.snap +++ b/.forge-snapshots/FullRangeInitialize.snap @@ -1 +1 @@ -1015181 \ No newline at end of file +1039616 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeRemoveLiquidity.snap b/.forge-snapshots/FullRangeRemoveLiquidity.snap index feea4936..7e064748 100644 --- a/.forge-snapshots/FullRangeRemoveLiquidity.snap +++ b/.forge-snapshots/FullRangeRemoveLiquidity.snap @@ -1 +1 @@ -110566 \ No newline at end of file +146394 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap b/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap index e0df7eb7..ae347ed1 100644 --- a/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap +++ b/.forge-snapshots/FullRangeRemoveLiquidityAndRebalance.snap @@ -1 +1 @@ -240044 \ No newline at end of file +281672 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeSecondSwap.snap b/.forge-snapshots/FullRangeSecondSwap.snap index e68df8d3..bb224d94 100644 --- a/.forge-snapshots/FullRangeSecondSwap.snap +++ b/.forge-snapshots/FullRangeSecondSwap.snap @@ -1 +1 @@ -45930 \ No newline at end of file +116110 \ No newline at end of file diff --git a/.forge-snapshots/FullRangeSwap.snap b/.forge-snapshots/FullRangeSwap.snap index b50d0ea2..9355d4d2 100644 --- a/.forge-snapshots/FullRangeSwap.snap +++ b/.forge-snapshots/FullRangeSwap.snap @@ -1 +1 @@ -79351 \ No newline at end of file +145819 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow10Slots.snap b/.forge-snapshots/OracleGrow10Slots.snap index 3dada479..96c9f369 100644 --- a/.forge-snapshots/OracleGrow10Slots.snap +++ b/.forge-snapshots/OracleGrow10Slots.snap @@ -1 +1 @@ -232960 \ No newline at end of file +254164 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow10SlotsCardinalityGreater.snap b/.forge-snapshots/OracleGrow10SlotsCardinalityGreater.snap index f623cfa5..9fc5bce2 100644 --- a/.forge-snapshots/OracleGrow10SlotsCardinalityGreater.snap +++ b/.forge-snapshots/OracleGrow10SlotsCardinalityGreater.snap @@ -1 +1 @@ -223649 \ No newline at end of file +249653 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow1Slot.snap b/.forge-snapshots/OracleGrow1Slot.snap index 137baa16..ced15d76 100644 --- a/.forge-snapshots/OracleGrow1Slot.snap +++ b/.forge-snapshots/OracleGrow1Slot.snap @@ -1 +1 @@ -32845 \ No newline at end of file +54049 \ No newline at end of file diff --git a/.forge-snapshots/OracleGrow1SlotCardinalityGreater.snap b/.forge-snapshots/OracleGrow1SlotCardinalityGreater.snap index e6dc42ce..8ad5646e 100644 --- a/.forge-snapshots/OracleGrow1SlotCardinalityGreater.snap +++ b/.forge-snapshots/OracleGrow1SlotCardinalityGreater.snap @@ -1 +1 @@ -23545 \ No newline at end of file +49549 \ No newline at end of file diff --git a/.forge-snapshots/OracleInitialize.snap b/.forge-snapshots/OracleInitialize.snap index e4e9e6b2..a9ee0288 100644 --- a/.forge-snapshots/OracleInitialize.snap +++ b/.forge-snapshots/OracleInitialize.snap @@ -1 +1 @@ -51310 \ No newline at end of file +72794 \ No newline at end of file diff --git a/.forge-snapshots/TWAMMSubmitOrder.snap b/.forge-snapshots/TWAMMSubmitOrder.snap index eb3b0f6b..c3858465 100644 --- a/.forge-snapshots/TWAMMSubmitOrder.snap +++ b/.forge-snapshots/TWAMMSubmitOrder.snap @@ -1 +1 @@ -122336 \ No newline at end of file +156828 \ No newline at end of file diff --git a/.forge-snapshots/autocompound_exactUnclaimedFees.snap b/.forge-snapshots/autocompound_exactUnclaimedFees.snap new file mode 100644 index 00000000..25c6c9a1 --- /dev/null +++ b/.forge-snapshots/autocompound_exactUnclaimedFees.snap @@ -0,0 +1 @@ +208172 \ No newline at end of file diff --git a/.forge-snapshots/autocompound_exactUnclaimedFees_exactCustodiedFees.snap b/.forge-snapshots/autocompound_exactUnclaimedFees_exactCustodiedFees.snap new file mode 100644 index 00000000..d2cec2fa --- /dev/null +++ b/.forge-snapshots/autocompound_exactUnclaimedFees_exactCustodiedFees.snap @@ -0,0 +1 @@ +129307 \ No newline at end of file diff --git a/.forge-snapshots/autocompound_excessFeesCredit.snap b/.forge-snapshots/autocompound_excessFeesCredit.snap new file mode 100644 index 00000000..2ca8e9b9 --- /dev/null +++ b/.forge-snapshots/autocompound_excessFeesCredit.snap @@ -0,0 +1 @@ +228687 \ No newline at end of file diff --git a/.forge-snapshots/decreaseLiquidity_erc20.snap b/.forge-snapshots/decreaseLiquidity_erc20.snap new file mode 100644 index 00000000..539775ae --- /dev/null +++ b/.forge-snapshots/decreaseLiquidity_erc20.snap @@ -0,0 +1 @@ +150019 \ No newline at end of file diff --git a/.forge-snapshots/decreaseLiquidity_erc6909.snap b/.forge-snapshots/decreaseLiquidity_erc6909.snap new file mode 100644 index 00000000..539775ae --- /dev/null +++ b/.forge-snapshots/decreaseLiquidity_erc6909.snap @@ -0,0 +1 @@ +150019 \ No newline at end of file diff --git a/.forge-snapshots/increaseLiquidity_erc20.snap b/.forge-snapshots/increaseLiquidity_erc20.snap new file mode 100644 index 00000000..227ab8f7 --- /dev/null +++ b/.forge-snapshots/increaseLiquidity_erc20.snap @@ -0,0 +1 @@ +96576 \ No newline at end of file diff --git a/.forge-snapshots/increaseLiquidity_erc6909.snap b/.forge-snapshots/increaseLiquidity_erc6909.snap new file mode 100644 index 00000000..227ab8f7 --- /dev/null +++ b/.forge-snapshots/increaseLiquidity_erc6909.snap @@ -0,0 +1 @@ +96576 \ No newline at end of file diff --git a/.forge-snapshots/mint.snap b/.forge-snapshots/mint.snap new file mode 100644 index 00000000..5d250ba5 --- /dev/null +++ b/.forge-snapshots/mint.snap @@ -0,0 +1 @@ +422785 \ No newline at end of file diff --git a/.forge-snapshots/mintWithLiquidity.snap b/.forge-snapshots/mintWithLiquidity.snap new file mode 100644 index 00000000..2848a86d --- /dev/null +++ b/.forge-snapshots/mintWithLiquidity.snap @@ -0,0 +1 @@ +487667 \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index d2dc450b..b6d49e52 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,15 +1,3 @@ -[submodule "lib/forge-std"] - path = lib/forge-std - url = https://github.com/foundry-rs/forge-std -[submodule "lib/openzeppelin-contracts"] - path = lib/openzeppelin-contracts - url = https://github.com/OpenZeppelin/openzeppelin-contracts -[submodule "lib/forge-gas-snapshot"] - path = lib/forge-gas-snapshot - url = https://github.com/marktoda/forge-gas-snapshot [submodule "lib/v4-core"] path = lib/v4-core url = https://github.com/Uniswap/v4-core -[submodule "lib/solmate"] - path = lib/solmate - url = https://github.com/transmissions11/solmate diff --git a/README.md b/README.md index b3355a10..b5be65fa 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ contract CoolHook is BaseHook { address, IPoolManager.PoolKey calldata key, IPoolManager.ModifyLiquidityParams calldata params - ) external override poolManagerOnly returns (bytes4) { + ) external override onlyByManager returns (bytes4) { // hook logic return BaseHook.beforeAddLiquidity.selector; } diff --git a/contracts/NonfungiblePositionManager.sol b/contracts/NonfungiblePositionManager.sol new file mode 100644 index 00000000..b71d633b --- /dev/null +++ b/contracts/NonfungiblePositionManager.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {ERC721Permit} from "./base/ERC721Permit.sol"; +import {INonfungiblePositionManager, Actions} from "./interfaces/INonfungiblePositionManager.sol"; +import {BaseLiquidityManagement} from "./base/BaseLiquidityManagement.sol"; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {CurrencySettleTake} from "./libraries/CurrencySettleTake.sol"; +import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "./types/LiquidityRange.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; + +import {LiquidityAmounts} from "./libraries/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {TransientStateLibrary} from "@uniswap/v4-core/src/libraries/TransientStateLibrary.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import {TransientLiquidityDelta} from "./libraries/TransientLiquidityDelta.sol"; + +contract NonfungiblePositionManager is INonfungiblePositionManager, BaseLiquidityManagement, ERC721Permit { + using CurrencyLibrary for Currency; + using CurrencySettleTake for Currency; + using PoolIdLibrary for PoolKey; + using LiquidityRangeIdLibrary for LiquidityRange; + using StateLibrary for IPoolManager; + using TransientStateLibrary for IPoolManager; + using SafeCast for uint256; + using TransientLiquidityDelta for Currency; + + /// @dev The ID of the next token that will be minted. Skips 0 + uint256 public nextTokenId = 1; + + // maps the ERC721 tokenId to the keys that uniquely identify a liquidity position (owner, range) + mapping(uint256 tokenId => TokenPosition position) public tokenPositions; + + constructor(IPoolManager _manager) + BaseLiquidityManagement(_manager) + ERC721Permit("Uniswap V4 Positions NFT-V1", "UNI-V4-POS", "1") + {} + + /// @param unlockData is an encoding of actions, params, and currencies + /// @return returnData is the endocing of each actions return information + function modifyLiquidities(bytes calldata unlockData) public returns (bytes[] memory) { + // TODO: Edit the encoding/decoding. + return abi.decode(manager.unlock(abi.encode(unlockData, msg.sender)), (bytes[])); + } + + function _unlockCallback(bytes calldata payload) internal override returns (bytes memory) { + // TODO: Fix double encode/decode + (bytes memory unlockData, address sender) = abi.decode(payload, (bytes, address)); + + (Actions[] memory actions, bytes[] memory params, Currency[] memory currencies) = + abi.decode(unlockData, (Actions[], bytes[], Currency[])); + + bytes[] memory returnData = _dispatch(actions, params, sender); + + for (uint256 i; i < currencies.length; i++) { + currencies[i].close(manager, sender, false); // TODO: support claims + currencies[i].close(manager, address(this), true); // position manager always takes 6909 + } + + return abi.encode(returnData); + } + + function _dispatch(Actions[] memory actions, bytes[] memory params, address sender) + internal + returns (bytes[] memory returnData) + { + returnData = new bytes[](actions.length); + + for (uint256 i; i < actions.length; i++) { + if (actions[i] == Actions.INCREASE) { + (uint256 tokenId, uint256 liquidity, bytes memory hookData, bool claims) = + abi.decode(params[i], (uint256, uint256, bytes, bool)); + returnData[i] = abi.encode(increaseLiquidity(tokenId, liquidity, hookData, claims, sender)); + } else if (actions[i] == Actions.DECREASE) { + (uint256 tokenId, uint256 liquidity, bytes memory hookData, bool claims) = + abi.decode(params[i], (uint256, uint256, bytes, bool)); + returnData[i] = abi.encode(decreaseLiquidity(tokenId, liquidity, hookData, claims, sender)); + } else if (actions[i] == Actions.MINT) { + (LiquidityRange memory range, uint256 liquidity, uint256 deadline, address owner, bytes memory hookData) + = abi.decode(params[i], (LiquidityRange, uint256, uint256, address, bytes)); + (BalanceDelta delta, uint256 tokenId) = mint(range, liquidity, deadline, owner, hookData, sender); + returnData[i] = abi.encode(delta, tokenId); + } else if (actions[i] == Actions.BURN) { + (uint256 tokenId) = abi.decode(params[i], (uint256)); + burn(tokenId, sender); + } else if (actions[i] == Actions.COLLECT) { + (uint256 tokenId, address recipient, bytes memory hookData, bool claims) = + abi.decode(params[i], (uint256, address, bytes, bool)); + returnData[i] = abi.encode(collect(tokenId, recipient, hookData, claims, sender)); + } else { + revert UnsupportedAction(); + } + } + } + + function mint( + LiquidityRange memory range, + uint256 liquidity, + uint256 deadline, + address owner, + bytes memory hookData, + address sender + ) internal checkDeadline(deadline) returns (BalanceDelta delta, uint256 tokenId) { + delta = _increaseLiquidity(owner, range, liquidity, hookData, sender); + + // mint receipt token + _mint(owner, (tokenId = nextTokenId++)); + tokenPositions[tokenId] = TokenPosition({owner: owner, range: range}); + } + + function increaseLiquidity(uint256 tokenId, uint256 liquidity, bytes memory hookData, bool claims, address sender) + internal + isAuthorizedForToken(tokenId, sender) + returns (BalanceDelta delta) + { + TokenPosition memory tokenPos = tokenPositions[tokenId]; + + delta = _increaseLiquidity(tokenPos.owner, tokenPos.range, liquidity, hookData, sender); + } + + function decreaseLiquidity(uint256 tokenId, uint256 liquidity, bytes memory hookData, bool claims, address sender) + internal + isAuthorizedForToken(tokenId, sender) + returns (BalanceDelta delta) + { + TokenPosition memory tokenPos = tokenPositions[tokenId]; + + delta = _decreaseLiquidity(tokenPos.owner, tokenPos.range, liquidity, hookData); + } + + function burn(uint256 tokenId, address sender) internal isAuthorizedForToken(tokenId, sender) { + // We do not need to enforce the pool manager to be unlocked bc this function is purely clearing storage for the minted tokenId. + TokenPosition memory tokenPos = tokenPositions[tokenId]; + // Checks that the full position's liquidity has been removed and all tokens have been collected from tokensOwed. + _validateBurn(tokenPos.owner, tokenPos.range); + delete tokenPositions[tokenId]; + // Burn the token. + _burn(tokenId); + } + + function collect(uint256 tokenId, address recipient, bytes memory hookData, bool claims, address sender) + internal + isAuthorizedForToken(tokenId, sender) + returns (BalanceDelta delta) + { + TokenPosition memory tokenPos = tokenPositions[tokenId]; + + delta = _collect(recipient, tokenPos.owner, tokenPos.range, hookData, sender); + } + + function feesOwed(uint256 tokenId) external view returns (uint256 token0Owed, uint256 token1Owed) { + TokenPosition memory tokenPosition = tokenPositions[tokenId]; + return feesOwed(tokenPosition.owner, tokenPosition.range); + } + + // TODO: Bug - Positions are overrideable unless we can allow two of the same users to have distinct positions. + function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal override { + TokenPosition storage tokenPosition = tokenPositions[tokenId]; + LiquidityRangeId rangeId = tokenPosition.range.toId(); + Position storage position = positions[from][rangeId]; + position.operator = address(0x0); + + // transfer position data to destination + positions[to][rangeId] = position; + delete positions[from][rangeId]; + + // update token position + tokenPositions[tokenId] = TokenPosition({owner: to, range: tokenPosition.range}); + } + + function _getAndIncrementNonce(uint256 tokenId) internal override returns (uint256) { + TokenPosition memory tokenPosition = tokenPositions[tokenId]; + return uint256(positions[tokenPosition.owner][tokenPosition.range.toId()].nonce++); + } + + modifier isAuthorizedForToken(uint256 tokenId, address sender) { + require(_isApprovedOrOwner(sender, tokenId), "Not approved"); + _; + } + + modifier checkDeadline(uint256 deadline) { + if (block.timestamp > deadline) revert DeadlinePassed(); + _; + } +} diff --git a/contracts/base/BaseLiquidityManagement.sol b/contracts/base/BaseLiquidityManagement.sol new file mode 100644 index 00000000..a72cbad3 --- /dev/null +++ b/contracts/base/BaseLiquidityManagement.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {TransientStateLibrary} from "@uniswap/v4-core/src/libraries/TransientStateLibrary.sol"; +import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "../types/LiquidityRange.sol"; +import {SafeCallback} from "./SafeCallback.sol"; +import {ImmutableState} from "./ImmutableState.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; + +import {CurrencySettleTake} from "../libraries/CurrencySettleTake.sol"; +import {CurrencyDeltas} from "../libraries/CurrencyDeltas.sol"; + +import {FeeMath} from "../libraries/FeeMath.sol"; +import {LiquiditySaltLibrary} from "../libraries/LiquiditySaltLibrary.sol"; +import {IBaseLiquidityManagement} from "../interfaces/IBaseLiquidityManagement.sol"; +import {PositionLibrary} from "../libraries/Position.sol"; +import {BalanceDeltaExtensionLibrary} from "../libraries/BalanceDeltaExtensionLibrary.sol"; +import {LiquidityDeltaAccounting} from "../libraries/LiquidityDeltaAccounting.sol"; +import {TransientLiquidityDelta} from "../libraries/TransientLiquidityDelta.sol"; + +import "forge-std/console2.sol"; + +abstract contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { + using LiquidityRangeIdLibrary for LiquidityRange; + using CurrencyLibrary for Currency; + using CurrencySettleTake for Currency; + using CurrencyDeltas for IPoolManager; + using PoolIdLibrary for PoolKey; + using StateLibrary for IPoolManager; + using TransientStateLibrary for IPoolManager; + using SafeCast for uint256; + using LiquiditySaltLibrary for IHooks; + using PositionLibrary for IBaseLiquidityManagement.Position; + using BalanceDeltaExtensionLibrary for BalanceDelta; + using LiquidityDeltaAccounting for BalanceDelta; + using TransientLiquidityDelta for BalanceDelta; + using TransientLiquidityDelta for Currency; + using TransientLiquidityDelta for address; + + mapping(address owner => mapping(LiquidityRangeId rangeId => Position)) public positions; + + constructor(IPoolManager _manager) ImmutableState(_manager) {} + + function _modifyLiquidity(address owner, LiquidityRange memory range, int256 liquidityChange, bytes memory hookData) + internal + returns (BalanceDelta liquidityDelta, BalanceDelta totalFeesAccrued) + { + (liquidityDelta, totalFeesAccrued) = manager.modifyLiquidity( + range.poolKey, + IPoolManager.ModifyLiquidityParams({ + tickLower: range.tickLower, + tickUpper: range.tickUpper, + liquidityDelta: liquidityChange, + salt: range.poolKey.hooks.getLiquiditySalt(owner) + }), + hookData + ); + } + + /// @dev The delta returned from this call must be settled by the caller. + /// Zeroing out the full balance of open deltas accounted to this address is unsafe until the callerDeltas are handled. + function _increaseLiquidity( + address owner, + LiquidityRange memory range, + uint256 liquidityToAdd, + bytes memory hookData, + address sender + ) internal returns (BalanceDelta) { + // Note that the liquidityDelta includes totalFeesAccrued. The totalFeesAccrued is returned separately for accounting purposes. + (BalanceDelta liquidityDelta, BalanceDelta totalFeesAccrued) = + _modifyLiquidity(owner, range, liquidityToAdd.toInt256(), hookData); + + Position storage position = positions[owner][range.toId()]; + + // Calculates the fee growth since the last time the positions feeGrowthInside was updated. + // Also updates the feeGrowthInsideLast variables in storage. + (BalanceDelta callerFeesAccrued) = _updateFeeGrowth(range, position); + + // Calculate the portion of the liquidityDelta that is attributable to the caller. + // We must account for fees that might be owed to other users on the same range. + (BalanceDelta callerDelta, BalanceDelta thisDelta) = liquidityDelta.split(callerFeesAccrued, totalFeesAccrued); + + // Update position storage, flushing the callerDelta value to tokensOwed first if necessary. + // If callerDelta > 0, then even after investing callerFeesAccrued, the caller still has some amount to collect that were not added into the position so they are accounted to tokensOwed and removed from the final callerDelta returned. + BalanceDelta tokensOwed; + if (callerDelta.amount0() > 0) { + (tokensOwed, callerDelta, thisDelta) = + _moveCallerDeltaToTokensOwed(true, tokensOwed, callerDelta, thisDelta); + } + + if (callerDelta.amount1() > 0) { + (tokensOwed, callerDelta, thisDelta) = + _moveCallerDeltaToTokensOwed(false, tokensOwed, callerDelta, thisDelta); + } + + // Accrue all deltas to the caller. + callerDelta.flush(sender, range.poolKey.currency0, range.poolKey.currency1); + thisDelta.flush(address(this), range.poolKey.currency0, range.poolKey.currency1); + + position.addTokensOwed(tokensOwed); + position.addLiquidity(liquidityToAdd); + return liquidityDelta; + } + + function _moveCallerDeltaToTokensOwed( + bool useAmount0, + BalanceDelta tokensOwed, + BalanceDelta callerDelta, + BalanceDelta thisDelta + ) private pure returns (BalanceDelta, BalanceDelta, BalanceDelta) { + // credit the excess tokens to the position's tokensOwed + tokensOwed = + useAmount0 ? tokensOwed.setAmount0(callerDelta.amount0()) : tokensOwed.setAmount1(callerDelta.amount1()); + + // this contract is responsible for custodying the excess tokens + thisDelta = + useAmount0 ? thisDelta.addAmount0(callerDelta.amount0()) : thisDelta.addAmount1(callerDelta.amount1()); + + // the caller is not expected to collect the excess tokens + callerDelta = useAmount0 ? callerDelta.setAmount0(0) : callerDelta.setAmount1(0); + + return (tokensOwed, callerDelta, thisDelta); + } + + /// Any outstanding amounts owed to the caller from the underlying modify call must be collected explicitly with `collect`. + function _decreaseLiquidity( + address owner, + LiquidityRange memory range, + uint256 liquidityToRemove, + bytes memory hookData + ) internal returns (BalanceDelta) { + (BalanceDelta liquidityDelta, BalanceDelta totalFeesAccrued) = + _modifyLiquidity(owner, range, -(liquidityToRemove.toInt256()), hookData); + + Position storage position = positions[owner][range.toId()]; + + // Calculates the fee growth since the last time the positions feeGrowthInside was updated + // Also updates the position's the feeGrowthInsideLast variables in storage. + (BalanceDelta callerFeesAccrued) = _updateFeeGrowth(range, position); + + // Account for fees accrued to other users on the same range. + (BalanceDelta callerDelta, BalanceDelta thisDelta) = liquidityDelta.split(callerFeesAccrued, totalFeesAccrued); + + BalanceDelta tokensOwed; + + // Flush the callerDelta, incrementing the tokensOwed to the user and the amount claimable to this contract. + if (callerDelta.amount0() > 0) { + (tokensOwed, callerDelta, thisDelta) = + _moveCallerDeltaToTokensOwed(true, tokensOwed, callerDelta, thisDelta); + } + + if (callerDelta.amount1() > 0) { + (tokensOwed, callerDelta, thisDelta) = + _moveCallerDeltaToTokensOwed(false, tokensOwed, callerDelta, thisDelta); + } + callerDelta.flush(owner, range.poolKey.currency0, range.poolKey.currency1); + thisDelta.flush(address(this), range.poolKey.currency0, range.poolKey.currency1); + + position.addTokensOwed(tokensOwed); + position.subtractLiquidity(liquidityToRemove); + return liquidityDelta; + } + + // The recipient may not be the original owner. + function _collect( + address recipient, + address owner, + LiquidityRange memory range, + bytes memory hookData, + address sender + ) internal returns (BalanceDelta) { + BalanceDelta callerDelta; + BalanceDelta thisDelta; + Position storage position = positions[owner][range.toId()]; + + // Only call modify if there is still liquidty in this position. + if (position.liquidity != 0) { + // Do not add or decrease liquidity, just trigger fee updates. + (BalanceDelta liquidityDelta, BalanceDelta totalFeesAccrued) = _modifyLiquidity(owner, range, 0, hookData); + + // Also updates the position's the feeGrowthInsideLast variables in storage. + (BalanceDelta callerFeesAccrued) = _updateFeeGrowth(range, position); + + // Account for fees accrued to other users on the same range. + // TODO: Opt when liquidityDelta == 0 + (callerDelta, thisDelta) = liquidityDelta.split(callerFeesAccrued, totalFeesAccrued); + } + + // Allow the caller to collect the tokens owed. + // Tokens owed that the caller collects is paid for by this contract. + // ie. Transfer the tokensOwed amounts to the caller from the position manager through the pool manager. + // TODO case where this contract does not have enough credits to pay the caller? + BalanceDelta tokensOwed = + toBalanceDelta(uint256(position.tokensOwed0).toInt128(), uint256(position.tokensOwed1).toInt128()); + callerDelta = callerDelta + tokensOwed; + thisDelta = thisDelta - tokensOwed; + + if (recipient == sender) { + callerDelta.flush(recipient, range.poolKey.currency0, range.poolKey.currency1); + } else { + TransientLiquidityDelta.closeDelta( + manager, recipient, range.poolKey.currency0, range.poolKey.currency1, false + ); // TODO: allow recipient to receive claims, and add test! + } + thisDelta.flush(address(this), range.poolKey.currency0, range.poolKey.currency1); + + position.clearTokensOwed(); + return callerDelta; + } + + function _validateBurn(address owner, LiquidityRange memory range) internal { + LiquidityRangeId rangeId = range.toId(); + Position storage position = positions[owner][rangeId]; + if (position.liquidity > 0) revert PositionMustBeEmpty(); + if (position.tokensOwed0 != 0 && position.tokensOwed1 != 0) revert TokensMustBeCollected(); + delete positions[owner][rangeId]; + } + + function _updateFeeGrowth(LiquidityRange memory range, Position storage position) + internal + returns (BalanceDelta callerFeesAccrued) + { + (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = + manager.getFeeGrowthInside(range.poolKey.toId(), range.tickLower, range.tickUpper); + + callerFeesAccrued = FeeMath.getFeesOwed( + feeGrowthInside0X128, + feeGrowthInside1X128, + position.feeGrowthInside0LastX128, + position.feeGrowthInside1LastX128, + position.liquidity + ); + + position.updateFeeGrowthInside(feeGrowthInside0X128, feeGrowthInside1X128); + } + + // --- View Functions --- // + function feesOwed(address owner, LiquidityRange memory range) + public + view + returns (uint256 token0Owed, uint256 token1Owed) + { + Position memory position = positions[owner][range.toId()]; + + (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = + manager.getFeeGrowthInside(range.poolKey.toId(), range.tickLower, range.tickUpper); + + (token0Owed) = FeeMath.getFeeOwed(feeGrowthInside0X128, position.feeGrowthInside0LastX128, position.liquidity); + (token1Owed) = FeeMath.getFeeOwed(feeGrowthInside1X128, position.feeGrowthInside1LastX128, position.liquidity); + token0Owed += position.tokensOwed0; + token1Owed += position.tokensOwed1; + } +} diff --git a/contracts/base/ERC721Permit.sol b/contracts/base/ERC721Permit.sol new file mode 100644 index 00000000..8eb86521 --- /dev/null +++ b/contracts/base/ERC721Permit.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +import {ChainId} from "../libraries/ChainId.sol"; +import {IERC721Permit} from "../interfaces/IERC721Permit.sol"; +import {IERC1271} from "../interfaces/external/IERC1271.sol"; + +/// @title ERC721 with permit +/// @notice Nonfungible tokens that support an approve via signature, i.e. permit +abstract contract ERC721Permit is ERC721, IERC721Permit { + /// @dev Gets the current nonce for a token ID and then increments it, returning the original value + function _getAndIncrementNonce(uint256 tokenId) internal virtual returns (uint256); + + /// @dev The hash of the name used in the permit signature verification + bytes32 private immutable nameHash; + + /// @dev The hash of the version string used in the permit signature verification + bytes32 private immutable versionHash; + + /// @notice Computes the nameHash and versionHash + constructor(string memory name_, string memory symbol_, string memory version_) ERC721(name_, symbol_) { + nameHash = keccak256(bytes(name_)); + versionHash = keccak256(bytes(version_)); + } + + /// @inheritdoc IERC721Permit + function DOMAIN_SEPARATOR() public view override returns (bytes32) { + return keccak256( + abi.encode( + // keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)') + 0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f, + nameHash, + versionHash, + ChainId.get(), + address(this) + ) + ); + } + + /// @inheritdoc IERC721Permit + /// @dev Value is equal to keccak256("Permit(address spender,uint256 tokenId,uint256 nonce,uint256 deadline)"); + bytes32 public constant override PERMIT_TYPEHASH = + 0x49ecf333e5b8c95c40fdafc95c1ad136e8914a8fb55e9dc8bb01eaa83a2df9ad; + + /// @inheritdoc IERC721Permit + function permit(address spender, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external + payable + override + { + require(block.timestamp <= deadline, "Permit expired"); + + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DOMAIN_SEPARATOR(), + keccak256(abi.encode(PERMIT_TYPEHASH, spender, tokenId, _getAndIncrementNonce(tokenId), deadline)) + ) + ); + address owner = ownerOf(tokenId); + require(spender != owner, "ERC721Permit: approval to current owner"); + + if (Address.isContract(owner)) { + require(IERC1271(owner).isValidSignature(digest, abi.encodePacked(r, s, v)) == 0x1626ba7e, "Unauthorized"); + } else { + address recoveredAddress = ecrecover(digest, v, r, s); + require(recoveredAddress != address(0), "Invalid signature"); + require(recoveredAddress == owner, "Unauthorized"); + } + + approve(spender, tokenId); + } +} diff --git a/contracts/base/SelfPermit.sol b/contracts/base/SelfPermit.sol index 40449636..2f626496 100644 --- a/contracts/base/SelfPermit.sol +++ b/contracts/base/SelfPermit.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.19; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/draft-IERC20Permit.sol"; import {IERC20PermitAllowed} from "../interfaces/external/IERC20PermitAllowed.sol"; import {ISelfPermit} from "../interfaces/ISelfPermit.sol"; @@ -10,7 +10,7 @@ import {ISelfPermit} from "../interfaces/ISelfPermit.sol"; /// @title Self Permit /// @notice Functionality to call permit on any EIP-2612-compliant token for use in the route /// @dev These functions are expected to be embedded in multicalls to allow EOAs to approve a contract and call a function -/// that requires an approval in a single transaction. +/// that requires an approval in a single transactions. abstract contract SelfPermit is ISelfPermit { /// @inheritdoc ISelfPermit function selfPermit(address token, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) diff --git a/contracts/interfaces/IBaseLiquidityManagement.sol b/contracts/interfaces/IBaseLiquidityManagement.sol new file mode 100644 index 00000000..6bcb6e5b --- /dev/null +++ b/contracts/interfaces/IBaseLiquidityManagement.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {LiquidityRange, LiquidityRangeId} from "../types/LiquidityRange.sol"; + +interface IBaseLiquidityManagement { + error PositionMustBeEmpty(); + error TokensMustBeCollected(); + + // details about the liquidity position + struct Position { + // the nonce for permits + uint96 nonce; + // the address that is approved for spending this token + address operator; + uint256 liquidity; + // the fee growth of the aggregate position as of the last action on the individual position + uint256 feeGrowthInside0LastX128; + uint256 feeGrowthInside1LastX128; + // how many uncollected tokens are owed to the position, as of the last computation + uint128 tokensOwed0; + uint128 tokensOwed1; + } + + error LockFailure(); + + /// @notice Fees owed for a given liquidity position. Includes materialized fees and uncollected fees. + /// @param owner The owner of the liquidity position + /// @param range The range of the liquidity position + /// @return token0Owed The amount of token0 owed to the owner + /// @return token1Owed The amount of token1 owed to the owner + function feesOwed(address owner, LiquidityRange memory range) + external + view + returns (uint256 token0Owed, uint256 token1Owed); +} diff --git a/contracts/interfaces/IERC721Permit.sol b/contracts/interfaces/IERC721Permit.sol new file mode 100644 index 00000000..daa27030 --- /dev/null +++ b/contracts/interfaces/IERC721Permit.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.5; + +/// @title ERC721 with permit +/// @notice Extension to ERC721 that includes a permit function for signature based approvals +interface IERC721Permit { + /// @notice The permit typehash used in the permit signature + /// @return The typehash for the permit + function PERMIT_TYPEHASH() external pure returns (bytes32); + + /// @notice The domain separator used in the permit signature + /// @return The domain seperator used in encoding of permit signature + function DOMAIN_SEPARATOR() external view returns (bytes32); + + /// @notice Approve of a specific token ID for spending by spender via signature + /// @param spender The account that is being approved + /// @param tokenId The ID of the token that is being approved for spending + /// @param deadline The deadline timestamp by which the call must be mined for the approve to work + /// @param v Must produce valid secp256k1 signature from the holder along with `r` and `s` + /// @param r Must produce valid secp256k1 signature from the holder along with `v` and `s` + /// @param s Must produce valid secp256k1 signature from the holder along with `r` and `v` + function permit(address spender, uint256 tokenId, uint256 deadline, uint8 v, bytes32 r, bytes32 s) + external + payable; +} diff --git a/contracts/interfaces/INonfungiblePositionManager.sol b/contracts/interfaces/INonfungiblePositionManager.sol new file mode 100644 index 00000000..40047a33 --- /dev/null +++ b/contracts/interfaces/INonfungiblePositionManager.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {LiquidityRange} from "../types/LiquidityRange.sol"; +// TODO: ADD/REMOVE ACTIONS + +enum Actions { + MINT, + BURN, + COLLECT, + INCREASE, + DECREASE +} + +interface INonfungiblePositionManager { + struct TokenPosition { + address owner; + LiquidityRange range; + } + + error MustBeUnlockedByThisContract(); + error DeadlinePassed(); + error UnsupportedAction(); + + /// @notice Batches many liquidity modification calls to pool manager + /// @param payload is an encoding of actions, params, and currencies + /// @return returnData is the endocing of each actions return information + function modifyLiquidities(bytes calldata payload) external returns (bytes[] memory); + + // TODO Can decide if we want burn to auto encode a decrease/collect. + /// @notice Burn a position and delete the tokenId + /// @dev It enforces that there is no open liquidity or tokens to be collected + /// @param tokenId The ID of the position + // function burn(uint256 tokenId) external; + + /// @notice Returns the fees owed for a position. Includes unclaimed fees + custodied fees + claimable fees + /// @param tokenId The ID of the position + /// @return token0Owed The amount of token0 owed + /// @return token1Owed The amount of token1 owed + function feesOwed(uint256 tokenId) external view returns (uint256 token0Owed, uint256 token1Owed); + + function nextTokenId() external view returns (uint256); +} diff --git a/contracts/lens/Quoter.sol b/contracts/lens/Quoter.sol index 9e9bfda2..a4c25e42 100644 --- a/contracts/lens/Quoter.sol +++ b/contracts/lens/Quoter.sol @@ -54,8 +54,8 @@ contract Quoter is IQuoter, IUnlockCallback { _; } - constructor(address _poolManager) { - manager = IPoolManager(_poolManager); + constructor(address _manager) { + manager = IPoolManager(_manager); } /// @inheritdoc IQuoter diff --git a/contracts/libraries/BalanceDeltaExtensionLibrary.sol b/contracts/libraries/BalanceDeltaExtensionLibrary.sol new file mode 100644 index 00000000..e8b3a7f0 --- /dev/null +++ b/contracts/libraries/BalanceDeltaExtensionLibrary.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; + +library BalanceDeltaExtensionLibrary { + function setAmount0(BalanceDelta a, int128 amount0) internal pure returns (BalanceDelta) { + assembly { + // set the upper 128 bits of a to amount0 + a := or(shl(128, amount0), and(sub(shl(128, 1), 1), a)) + } + return a; + } + + function setAmount1(BalanceDelta a, int128 amount1) internal pure returns (BalanceDelta) { + assembly { + // set the lower 128 bits of a to amount1 + a := or(and(shl(128, sub(shl(128, 1), 1)), a), amount1) + } + return a; + } + + function addAmount0(BalanceDelta a, int128 amount0) internal pure returns (BalanceDelta) { + assembly { + let a0 := sar(128, a) + let res0 := add(a0, amount0) + a := or(shl(128, res0), and(sub(shl(128, 1), 1), a)) + } + return a; + } + + function addAmount1(BalanceDelta a, int128 amount1) internal pure returns (BalanceDelta) { + assembly { + let a1 := signextend(15, a) + let res1 := add(a1, amount1) + a := or(and(shl(128, sub(shl(128, 1), 1)), a), res1) + } + return a; + } + + function addAndAssign(BalanceDelta a, BalanceDelta b) internal pure returns (BalanceDelta) { + assembly { + let a0 := sar(128, a) + let a1 := signextend(15, a) + let b0 := sar(128, b) + let b1 := signextend(15, b) + let res0 := add(a0, b0) + let res1 := add(a1, b1) + a := or(shl(128, res0), and(sub(shl(128, 1), 1), res1)) + } + return a; + } +} diff --git a/contracts/libraries/ChainId.sol b/contracts/libraries/ChainId.sol new file mode 100644 index 00000000..7e67989c --- /dev/null +++ b/contracts/libraries/ChainId.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.7.0; + +/// @title Function for getting the current chain ID +library ChainId { + /// @dev Gets the current chain ID + /// @return chainId The current chain ID + function get() internal view returns (uint256 chainId) { + assembly { + chainId := chainid() + } + } +} diff --git a/contracts/libraries/CurrencyDeltas.sol b/contracts/libraries/CurrencyDeltas.sol new file mode 100644 index 00000000..55389e4f --- /dev/null +++ b/contracts/libraries/CurrencyDeltas.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; + +library CurrencyDeltas { + using SafeCast for int256; + + /// @notice Get the current delta for a caller in the two given currencies + /// @param caller_ The address of the caller + /// @param currency0 The currency for which to lookup the delta + /// @param currency1 The other currency for which to lookup the delta + function currencyDeltas(IPoolManager manager, address caller_, Currency currency0, Currency currency1) + internal + view + returns (BalanceDelta) + { + bytes32 key0; + bytes32 key1; + assembly { + mstore(0, caller_) + mstore(32, currency0) + key0 := keccak256(0, 64) + + mstore(0, caller_) + mstore(32, currency1) + key1 := keccak256(0, 64) + } + bytes32[] memory slots = new bytes32[](2); + slots[0] = key0; + slots[1] = key1; + bytes32[] memory result = manager.exttload(slots); + return toBalanceDelta(int256(uint256(result[0])).toInt128(), int256(uint256(result[1])).toInt128()); + } +} diff --git a/contracts/libraries/CurrencySettleTake.sol b/contracts/libraries/CurrencySettleTake.sol new file mode 100644 index 00000000..30f1d868 --- /dev/null +++ b/contracts/libraries/CurrencySettleTake.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol"; +import {IERC20Minimal} from "v4-core/interfaces/external/IERC20Minimal.sol"; + +/// @notice Library used to interact with PoolManager.sol to settle any open deltas. +/// To settle a positive delta (a credit to the user), a user may take or mint. +/// To settle a negative delta (a debt on the user), a user make transfer or burn to pay off a debt. +/// @dev Note that sync() is called before any erc-20 transfer in `settle`. +library CurrencySettleTake { + /// @notice Settle (pay) a currency to the PoolManager + /// @param currency Currency to settle + /// @param manager IPoolManager to settle to + /// @param payer Address of the payer, the token sender + /// @param amount Amount to send + /// @param burn If true, burn the ERC-6909 token, otherwise ERC20-transfer to the PoolManager + function settle(Currency currency, IPoolManager manager, address payer, uint256 amount, bool burn) internal { + // for native currencies or burns, calling sync is not required + // short circuit for ERC-6909 burns to support ERC-6909-wrapped native tokens + if (burn) { + manager.burn(payer, currency.toId(), amount); + } else if (currency.isNative()) { + manager.settle{value: amount}(currency); + } else { + manager.sync(currency); + if (payer != address(this)) { + IERC20Minimal(Currency.unwrap(currency)).transferFrom(payer, address(manager), amount); + } else { + IERC20Minimal(Currency.unwrap(currency)).transfer(address(manager), amount); + } + manager.settle(currency); + } + } + + /// @notice Take (receive) a currency from the PoolManager + /// @param currency Currency to take + /// @param manager IPoolManager to take from + /// @param recipient Address of the recipient, the token receiver + /// @param amount Amount to receive + /// @param claims If true, mint the ERC-6909 token, otherwise ERC20-transfer from the PoolManager to recipient + function take(Currency currency, IPoolManager manager, address recipient, uint256 amount, bool claims) internal { + claims ? manager.mint(recipient, currency.toId(), amount) : manager.take(currency, recipient, amount); + } +} diff --git a/contracts/libraries/FeeMath.sol b/contracts/libraries/FeeMath.sol new file mode 100644 index 00000000..9a459252 --- /dev/null +++ b/contracts/libraries/FeeMath.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.24; + +import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol"; +import {FixedPoint128} from "@uniswap/v4-core/src/libraries/FixedPoint128.sol"; +import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; + +library FeeMath { + using SafeCast for uint256; + + function getFeesOwed( + uint256 feeGrowthInside0X128, + uint256 feeGrowthInside1X128, + uint256 feeGrowthInside0LastX128, + uint256 feeGrowthInside1LastX128, + uint256 liquidity + ) internal pure returns (BalanceDelta feesOwed) { + uint128 token0Owed = getFeeOwed(feeGrowthInside0X128, feeGrowthInside0LastX128, liquidity); + uint128 token1Owed = getFeeOwed(feeGrowthInside1X128, feeGrowthInside1LastX128, liquidity); + feesOwed = toBalanceDelta(uint256(token0Owed).toInt128(), uint256(token1Owed).toInt128()); + } + + function getFeeOwed(uint256 feeGrowthInsideX128, uint256 feeGrowthInsideLastX128, uint256 liquidity) + internal + pure + returns (uint128 tokenOwed) + { + tokenOwed = + (FullMath.mulDiv(feeGrowthInsideX128 - feeGrowthInsideLastX128, liquidity, FixedPoint128.Q128)).toUint128(); + } +} diff --git a/contracts/libraries/LiquidityDeltaAccounting.sol b/contracts/libraries/LiquidityDeltaAccounting.sol new file mode 100644 index 00000000..9c82d1c9 --- /dev/null +++ b/contracts/libraries/LiquidityDeltaAccounting.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; + +import "forge-std/console2.sol"; + +library LiquidityDeltaAccounting { + function split(BalanceDelta liquidityDelta, BalanceDelta callerFeesAccrued, BalanceDelta totalFeesAccrued) + internal + pure + returns (BalanceDelta callerDelta, BalanceDelta thisDelta) + { + if (totalFeesAccrued == callerFeesAccrued) { + // when totalFeesAccrued == callerFeesAccrued, the caller is not sharing the range + // therefore, the caller is responsible for the entire liquidityDelta + callerDelta = liquidityDelta; + } else { + // the delta for increasing liquidity assuming that totalFeesAccrued was not applied + BalanceDelta principalDelta = liquidityDelta - totalFeesAccrued; + + // outstanding deltas the caller is responsible for, after their fees are credited to the principal delta + callerDelta = principalDelta + callerFeesAccrued; + + // outstanding deltas this contract is responsible for, intuitively the contract is responsible for taking fees external to the caller's accrued fees + thisDelta = totalFeesAccrued - callerFeesAccrued; + } + } +} diff --git a/contracts/libraries/LiquiditySaltLibrary.sol b/contracts/libraries/LiquiditySaltLibrary.sol new file mode 100644 index 00000000..c0a4fda8 --- /dev/null +++ b/contracts/libraries/LiquiditySaltLibrary.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol"; + +/// @notice Library used to interact with PoolManager.sol to settle any open deltas. +/// To settle a positive delta (a credit to the user), a user may take or mint. +/// To settle a negative delta (a debt on the user), a user make transfer or burn to pay off a debt. +/// @dev Note that sync() is called before any erc-20 transfer in `settle`. +library LiquiditySaltLibrary { + /// @notice Calculates the salt parameters for IPoolManager.ModifyLiquidityParams + /// If the hook uses after*LiquidityReturnDelta, the salt is the address of the sender + /// otherwise, use 0 for warm-storage gas savings + function getLiquiditySalt(IHooks hooks, address sender) internal pure returns (bytes32 salt) { + salt = Hooks.hasPermission(hooks, Hooks.AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG) + || Hooks.hasPermission(hooks, Hooks.AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG) + ? bytes32(uint256(uint160(sender))) + : bytes32(0); + } +} diff --git a/contracts/libraries/Position.sol b/contracts/libraries/Position.sol new file mode 100644 index 00000000..11ef1771 --- /dev/null +++ b/contracts/libraries/Position.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity >=0.8.20; + +import {IBaseLiquidityManagement} from "../interfaces/IBaseLiquidityManagement.sol"; +import {BalanceDelta} from "v4-core/types/BalanceDelta.sol"; + +// Updates Position storage +library PositionLibrary { + error InsufficientLiquidity(); + + // TODO ensure this is one sstore. + function addTokensOwed(IBaseLiquidityManagement.Position storage position, BalanceDelta tokensOwed) internal { + position.tokensOwed0 += uint128(tokensOwed.amount0()); + position.tokensOwed1 += uint128(tokensOwed.amount1()); + } + + function clearTokensOwed(IBaseLiquidityManagement.Position storage position) internal { + position.tokensOwed0 = 0; + position.tokensOwed1 = 0; + } + + function addLiquidity(IBaseLiquidityManagement.Position storage position, uint256 liquidity) internal { + unchecked { + position.liquidity += liquidity; + } + } + + function subtractLiquidity(IBaseLiquidityManagement.Position storage position, uint256 liquidity) internal { + if (position.liquidity < liquidity) revert InsufficientLiquidity(); + unchecked { + position.liquidity -= liquidity; + } + } + + // TODO ensure this is one sstore. + function updateFeeGrowthInside( + IBaseLiquidityManagement.Position storage position, + uint256 feeGrowthInside0X128, + uint256 feeGrowthInside1X128 + ) internal { + position.feeGrowthInside0LastX128 = feeGrowthInside0X128; + position.feeGrowthInside1LastX128 = feeGrowthInside1X128; + } +} diff --git a/contracts/libraries/TransientLiquidityDelta.sol b/contracts/libraries/TransientLiquidityDelta.sol new file mode 100644 index 00000000..df7608ba --- /dev/null +++ b/contracts/libraries/TransientLiquidityDelta.sol @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.24; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; + +import {CurrencySettleTake} from "../libraries/CurrencySettleTake.sol"; +import {TransientStateLibrary} from "@uniswap/v4-core/src/libraries/TransientStateLibrary.sol"; + +import "forge-std/console2.sol"; + +/// @title a library to store callers' currency deltas in transient storage +/// @dev this library implements the equivalent of a mapping, as transient storage can only be accessed in assembly +library TransientLiquidityDelta { + using CurrencySettleTake for Currency; + using TransientStateLibrary for IPoolManager; + + /// @notice calculates which storage slot a delta should be stored in for a given caller and currency + function _computeSlot(address caller_, Currency currency) internal pure returns (bytes32 hashSlot) { + assembly { + mstore(0, caller_) + mstore(32, currency) + hashSlot := keccak256(0, 64) + } + } + + /// @notice Flush a BalanceDelta into transient storage for a given holder + function flush(BalanceDelta delta, address holder, Currency currency0, Currency currency1) internal { + addDelta(currency0, holder, delta.amount0()); + addDelta(currency1, holder, delta.amount1()); + } + + function addDelta(Currency currency, address caller, int128 delta) internal { + bytes32 hashSlot = _computeSlot(caller, currency); + assembly { + let oldValue := tload(hashSlot) + let newValue := add(oldValue, delta) + tstore(hashSlot, newValue) + } + } + + function subtractDelta(Currency currency, address caller, int128 delta) internal { + bytes32 hashSlot = _computeSlot(caller, currency); + assembly { + let oldValue := tload(hashSlot) + let newValue := sub(oldValue, delta) + tstore(hashSlot, newValue) + } + } + + function close(Currency currency, IPoolManager manager, address holder, bool claims) + internal + returns (int128 delta) + { + // getDelta(currency, holder); + bytes32 hashSlot = _computeSlot(holder, currency); + assembly { + delta := tload(hashSlot) + } + + if (delta < 0) { + currency.settle(manager, holder, uint256(-int256(delta)), claims); + } else { + currency.take(manager, holder, uint256(int256(delta)), claims); + } + + // setDelta(0); + assembly { + tstore(hashSlot, 0) + } + } + + function closeDelta(IPoolManager manager, address holder, Currency currency0, Currency currency1, bool claims) + internal + { + close(currency0, manager, holder, claims); + close(currency1, manager, holder, claims); + } + + function getBalanceDelta(address holder, Currency currency0, Currency currency1) + internal + view + returns (BalanceDelta delta) + { + delta = toBalanceDelta(getDelta(currency0, holder), getDelta(currency1, holder)); + } + + /// Copied from v4-core/src/libraries/CurrencyDelta.sol: + /// @notice sets a new currency delta for a given caller and currency + function setDelta(Currency currency, address caller, int128 delta) internal { + bytes32 hashSlot = _computeSlot(caller, currency); + + assembly { + tstore(hashSlot, delta) + } + } + + /// @notice gets a new currency delta for a given caller and currency + // TODO: is returning 128 bits safe? + function getDelta(Currency currency, address caller) internal view returns (int128 delta) { + bytes32 hashSlot = _computeSlot(caller, currency); + + assembly { + delta := tload(hashSlot) + } + } +} diff --git a/contracts/types/LiquidityRange.sol b/contracts/types/LiquidityRange.sol new file mode 100644 index 00000000..4f664027 --- /dev/null +++ b/contracts/types/LiquidityRange.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.24; + +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; + +struct LiquidityRange { + PoolKey poolKey; + int24 tickLower; + int24 tickUpper; +} + +type LiquidityRangeId is bytes32; + +/// @notice Library for computing the ID of a liquidity range +library LiquidityRangeIdLibrary { + function toId(LiquidityRange memory position) internal pure returns (LiquidityRangeId) { + // TODO: gas, is it better to encodePacked? + return LiquidityRangeId.wrap(keccak256(abi.encode(position))); + } +} diff --git a/lib/forge-gas-snapshot b/lib/forge-gas-snapshot deleted file mode 160000 index 2f884282..00000000 --- a/lib/forge-gas-snapshot +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2f884282b4cd067298e798974f5b534288b13bc2 diff --git a/lib/forge-std b/lib/forge-std deleted file mode 160000 index 2b58ecbc..00000000 --- a/lib/forge-std +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2b58ecbcf3dfde7a75959dc7b4eb3d0670278de6 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts deleted file mode 160000 index 5ae63068..00000000 --- a/lib/openzeppelin-contracts +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5ae630684a0f57de400ef69499addab4c32ac8fb diff --git a/lib/solmate b/lib/solmate deleted file mode 160000 index bfc9c258..00000000 --- a/lib/solmate +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bfc9c25865a274a7827fea5abf6e4fb64fc64e6c diff --git a/remappings.txt b/remappings.txt index e05c5bd6..11b1a65e 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,4 +1,2 @@ @uniswap/v4-core/=lib/v4-core/ -solmate/=lib/solmate/src/ -forge-std/=lib/forge-std/src/ -@openzeppelin/=lib/openzeppelin-contracts/ +@openzeppelin/=lib/v4-core/lib/openzeppelin-contracts/ diff --git a/test/LimitOrder.t.sol b/test/LimitOrder.t.sol index 17f5aecb..29b1093f 100644 --- a/test/LimitOrder.t.sol +++ b/test/LimitOrder.t.sol @@ -21,7 +21,7 @@ contract TestLimitOrder is Test, Deployers { using PoolIdLibrary for PoolKey; using StateLibrary for IPoolManager; - uint160 constant SQRT_RATIO_10_1 = 250541448375047931186413801569; + uint160 constant SQRT_PRICE_10_1 = 250541448375047931186413801569; HookEnabledSwapRouter router; TestERC20 token0; @@ -65,7 +65,7 @@ contract TestLimitOrder is Test, Deployers { function testGetTickLowerLastWithDifferentPrice() public { PoolKey memory differentKey = PoolKey(Currency.wrap(address(token0)), Currency.wrap(address(token1)), 3000, 61, limitOrder); - manager.initialize(differentKey, SQRT_RATIO_10_1, ZERO_BYTES); + manager.initialize(differentKey, SQRT_PRICE_10_1, ZERO_BYTES); assertEq(limitOrder.getTickLowerLast(differentKey.toId()), 22997); } diff --git a/test/Quoter.t.sol b/test/Quoter.t.sol index 0767cadd..f434fd19 100644 --- a/test/Quoter.t.sol +++ b/test/Quoter.t.sol @@ -31,8 +31,8 @@ contract QuoterTest is Test, Deployers { // Max tick for full range with tick spacing of 60 int24 internal constant MAX_TICK = -MIN_TICK; - uint160 internal constant SQRT_RATIO_100_102 = 78447570448055484695608110440; - uint160 internal constant SQRT_RATIO_102_100 = 80016521857016594389520272648; + uint160 internal constant SQRT_PRICE_100_102 = 78447570448055484695608110440; + uint160 internal constant SQRT_PRICE_102_100 = 80016521857016594389520272648; uint256 internal constant CONTROLLER_GAS_LIMIT = 500000; @@ -327,13 +327,13 @@ contract QuoterTest is Test, Deployers { zeroForOne: true, recipient: address(this), exactAmount: type(uint128).max, - sqrtPriceLimitX96: SQRT_RATIO_100_102, + sqrtPriceLimitX96: SQRT_PRICE_100_102, hookData: ZERO_BYTES }) ); assertEq(deltaAmounts[0], 9981); - assertEq(sqrtPriceX96After, SQRT_RATIO_100_102); + assertEq(sqrtPriceX96After, SQRT_PRICE_100_102); assertEq(initializedTicksLoaded, 0); } @@ -345,13 +345,13 @@ contract QuoterTest is Test, Deployers { zeroForOne: false, recipient: address(this), exactAmount: type(uint128).max, - sqrtPriceLimitX96: SQRT_RATIO_102_100, + sqrtPriceLimitX96: SQRT_PRICE_102_100, hookData: ZERO_BYTES }) ); assertEq(deltaAmounts[1], 9981); - assertEq(sqrtPriceX96After, SQRT_RATIO_102_100); + assertEq(sqrtPriceX96After, SQRT_PRICE_102_100); assertEq(initializedTicksLoaded, 0); } diff --git a/test/position-managers/Execute.t.sol b/test/position-managers/Execute.t.sol new file mode 100644 index 00000000..9c568936 --- /dev/null +++ b/test/position-managers/Execute.t.sol @@ -0,0 +1,182 @@ +// // SPDX-License-Identifier: MIT +// pragma solidity ^0.8.24; + +// import "forge-std/Test.sol"; +// import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +// import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +// import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +// import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +// import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +// import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +// import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +// import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +// import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +// import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +// import {LiquidityAmounts} from "../../contracts/libraries/LiquidityAmounts.sol"; +// import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +// import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +// import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +// import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol"; + +// import {IERC20} from "forge-std/interfaces/IERC20.sol"; +// import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +// import {INonfungiblePositionManager} from "../../contracts/interfaces/INonfungiblePositionManager.sol"; +// import {NonfungiblePositionManager} from "../../contracts/NonfungiblePositionManager.sol"; +// import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "../../contracts/types/LiquidityRange.sol"; + +// import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; + +// import {LiquidityOperations} from "../shared/LiquidityOperations.sol"; +// import {Planner} from "../utils/Planner.sol"; + +// contract ExecuteTest is Test, Deployers, GasSnapshot, LiquidityFuzzers, LiquidityOperations { +// using FixedPointMathLib for uint256; +// using CurrencyLibrary for Currency; +// using LiquidityRangeIdLibrary for LiquidityRange; +// using PoolIdLibrary for PoolKey; +// using SafeCast for uint256; +// using Planner for Planner.Plan; + +// PoolId poolId; +// address alice = makeAddr("ALICE"); +// address bob = makeAddr("BOB"); + +// uint256 constant STARTING_USER_BALANCE = 10_000_000 ether; + +// // expresses the fee as a wad (i.e. 3000 = 0.003e18 = 0.30%) +// uint256 FEE_WAD; + +// LiquidityRange range; + +// function setUp() public { +// Deployers.deployFreshManagerAndRouters(); +// Deployers.deployMintAndApprove2Currencies(); + +// (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); +// FEE_WAD = uint256(key.fee).mulDivDown(FixedPointMathLib.WAD, 1_000_000); + +// lpm = new NonfungiblePositionManager(manager); +// IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); +// IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + +// // Give tokens to Alice and Bob, with approvals +// IERC20(Currency.unwrap(currency0)).transfer(alice, STARTING_USER_BALANCE); +// IERC20(Currency.unwrap(currency1)).transfer(alice, STARTING_USER_BALANCE); +// IERC20(Currency.unwrap(currency0)).transfer(bob, STARTING_USER_BALANCE); +// IERC20(Currency.unwrap(currency1)).transfer(bob, STARTING_USER_BALANCE); +// vm.startPrank(alice); +// IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); +// IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); +// vm.stopPrank(); +// vm.startPrank(bob); +// IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); +// IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); +// vm.stopPrank(); + +// // define a reusable range +// range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); +// } + +// function test_execute_increaseLiquidity_once(uint256 initialLiquidity, uint256 liquidityToAdd) public { +// initialLiquidity = bound(initialLiquidity, 1e18, 1000e18); +// liquidityToAdd = bound(liquidityToAdd, 1e18, 1000e18); +// _mint(range, initialLiquidity, block.timestamp, address(this), ZERO_BYTES); +// uint256 tokenId = lpm.nextTokenId() - 1; + +// bytes[] memory data = new bytes[](1); +// data[0] = abi.encodeWithSelector( +// INonfungiblePositionManager.increaseLiquidity.selector, tokenId, liquidityToAdd, ZERO_BYTES, false +// ); + +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; +// lpm.modifyLiquidities(data, currencies); + +// (,, uint256 liquidity,,,,) = lpm.positions(address(this), range.toId()); +// assertEq(liquidity, initialLiquidity + liquidityToAdd); +// } + +// function test_execute_increaseLiquidity_twice( +// uint256 initialiLiquidity, +// uint256 liquidityToAdd, +// uint256 liquidityToAdd2 +// ) public { +// initialiLiquidity = bound(initialiLiquidity, 1e18, 1000e18); +// liquidityToAdd = bound(liquidityToAdd, 1e18, 1000e18); +// liquidityToAdd2 = bound(liquidityToAdd2, 1e18, 1000e18); +// _mint(range, initialiLiquidity, block.timestamp, address(this), ZERO_BYTES); +// uint256 tokenId = lpm.nextTokenId() - 1; + +// bytes[] memory data = new bytes[](2); +// data[0] = abi.encodeWithSelector( +// INonfungiblePositionManager.increaseLiquidity.selector, tokenId, liquidityToAdd, ZERO_BYTES, false +// ); +// data[1] = abi.encodeWithSelector( +// INonfungiblePositionManager.increaseLiquidity.selector, tokenId, liquidityToAdd2, ZERO_BYTES, false +// ); + +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; +// lpm.modifyLiquidities(data, currencies); + +// (,, uint256 liquidity,,,,) = lpm.positions(address(this), range.toId()); +// assertEq(liquidity, initialiLiquidity + liquidityToAdd + liquidityToAdd2); +// } + +// // this case doesnt make sense in real world usage, so it doesnt have a cool name. but its a good test case +// function test_execute_mintAndIncrease(uint256 intialLiquidity, uint256 liquidityToAdd) public { +// intialLiquidity = bound(intialLiquidity, 1e18, 1000e18); +// liquidityToAdd = bound(liquidityToAdd, 1e18, 1000e18); + +// uint256 tokenId = 1; // assume that the .mint() produces tokenId=1, to be used in increaseLiquidity +// bytes[] memory data = new bytes[](2); +// data[0] = abi.encodeWithSelector( +// INonfungiblePositionManager.mint.selector, +// range, +// intialLiquidity, +// block.timestamp + 1, +// address(this), +// ZERO_BYTES +// ); +// data[1] = abi.encodeWithSelector( +// INonfungiblePositionManager.increaseLiquidity.selector, tokenId, liquidityToAdd, ZERO_BYTES, false +// ); + +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; +// lpm.modifyLiquidities(data, currencies); + +// (,, uint256 liquidity,,,,) = lpm.positions(address(this), range.toId()); +// assertEq(liquidity, intialLiquidity + liquidityToAdd); +// } + +// // rebalance: burn and mint +// function test_execute_rebalance() public {} +// // coalesce: burn and increase +// function test_execute_coalesce() public {} +// // split: decrease and mint +// function test_execute_split() public {} +// // shift: decrease and increase +// function test_execute_shift() public {} +// // shard: collect and mint +// function test_execute_shard() public {} +// // feed: collect and increase +// function test_execute_feed() public {} + +// // transplant: burn and mint on different keys +// function test_execute_transplant() public {} +// // cross-coalesce: burn and increase on different keys +// function test_execute_crossCoalesce() public {} +// // cross-split: decrease and mint on different keys +// function test_execute_crossSplit() public {} +// // cross-shift: decrease and increase on different keys +// function test_execute_crossShift() public {} +// // cross-shard: collect and mint on different keys +// function test_execute_crossShard() public {} +// // cross-feed: collect and increase on different keys +// function test_execute_crossFeed() public {} +// } diff --git a/test/position-managers/FeeCollection.t.sol b/test/position-managers/FeeCollection.t.sol new file mode 100644 index 00000000..a6e48e18 --- /dev/null +++ b/test/position-managers/FeeCollection.t.sol @@ -0,0 +1,280 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {LiquidityAmounts} from "../../contracts/libraries/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +import {NonfungiblePositionManager} from "../../contracts/NonfungiblePositionManager.sol"; +import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "../../contracts/types/LiquidityRange.sol"; + +import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; + +import {LiquidityOperations} from "../shared/LiquidityOperations.sol"; + +contract FeeCollectionTest is Test, Deployers, GasSnapshot, LiquidityFuzzers, LiquidityOperations { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using LiquidityRangeIdLibrary for LiquidityRange; + + PoolId poolId; + address alice = makeAddr("ALICE"); + address bob = makeAddr("BOB"); + + uint256 constant STARTING_USER_BALANCE = 10_000_000 ether; + + // expresses the fee as a wad (i.e. 3000 = 0.003e18) + uint256 FEE_WAD; + + function setUp() public { + Deployers.deployFreshManagerAndRouters(); + Deployers.deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + FEE_WAD = uint256(key.fee).mulDivDown(FixedPointMathLib.WAD, 1_000_000); + + lpm = new NonfungiblePositionManager(manager); + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + + // Give tokens to Alice and Bob, with approvals + IERC20(Currency.unwrap(currency0)).transfer(alice, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency1)).transfer(alice, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency0)).transfer(bob, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency1)).transfer(bob, STARTING_USER_BALANCE); + vm.startPrank(alice); + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + vm.stopPrank(); + vm.startPrank(bob); + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + vm.stopPrank(); + } + + // TODO: we dont accept collecting fees as 6909 yet + // function test_collect_6909(IPoolManager.ModifyLiquidityParams memory params) public { + // params.liquidityDelta = bound(params.liquidityDelta, 10e18, 10_000e18); + // uint256 tokenId; + // (tokenId, params) = createFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + // vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + + // // swap to create fees + // uint256 swapAmount = 0.01e18; + // swap(key, false, -int256(swapAmount), ZERO_BYTES); + + // // collect fees + // BalanceDelta delta = _collect(tokenId, address(this), ZERO_BYTES, true); + + // assertEq(delta.amount0(), 0); + + // assertApproxEqAbs(uint256(int256(delta.amount1())), swapAmount.mulWadDown(FEE_WAD), 1 wei); + + // assertEq(uint256(int256(delta.amount1())), manager.balanceOf(address(this), currency1.toId())); + // } + + function test_collect_erc20(IPoolManager.ModifyLiquidityParams memory params) public { + params.liquidityDelta = bound(params.liquidityDelta, 10e18, 10_000e18); + uint256 tokenId; + (tokenId, params) = createFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + + // swap to create fees + uint256 swapAmount = 0.01e18; + swap(key, false, -int256(swapAmount), ZERO_BYTES); + + // collect fees + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + BalanceDelta delta = _collect(tokenId, address(this), ZERO_BYTES, false); + + assertEq(delta.amount0(), 0); + + // express key.fee as wad (i.e. 3000 = 0.003e18) + assertApproxEqAbs(uint256(int256(delta.amount1())), swapAmount.mulWadDown(FEE_WAD), 1 wei); + + assertEq(uint256(int256(delta.amount1())), currency1.balanceOfSelf() - balance1Before); + } + + // TODO: we dont accept collecting fees as 6909 yet + // two users with the same range; one user cannot collect the other's fees + // function test_collect_sameRange_6909(IPoolManager.ModifyLiquidityParams memory params, uint256 liquidityDeltaBob) + // public + // { + // params.liquidityDelta = bound(params.liquidityDelta, 10e18, 10_000e18); + // params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + // vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + + // liquidityDeltaBob = bound(liquidityDeltaBob, 100e18, 100_000e18); + + // LiquidityRange memory range = + // LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + // vm.prank(alice); + // _mint(range, uint256(params.liquidityDelta), block.timestamp + 1, alice, ZERO_BYTES); + // uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // vm.prank(bob); + // _mint(range, liquidityDeltaBob, block.timestamp + 1, bob, ZERO_BYTES); + // uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // // swap to create fees + // uint256 swapAmount = 0.01e18; + // swap(key, false, -int256(swapAmount), ZERO_BYTES); + + // // alice collects only her fees + // vm.prank(alice); + // BalanceDelta delta = _collect(tokenIdAlice, alice, ZERO_BYTES, true); + // assertEq(uint256(uint128(delta.amount0())), manager.balanceOf(alice, currency0.toId())); + // assertEq(uint256(uint128(delta.amount1())), manager.balanceOf(alice, currency1.toId())); + // assertTrue(delta.amount1() != 0); + + // // bob collects only his fees + // vm.prank(bob); + // delta = _collect(tokenIdBob, bob, ZERO_BYTES, true); + // assertEq(uint256(uint128(delta.amount0())), manager.balanceOf(bob, currency0.toId())); + // assertEq(uint256(uint128(delta.amount1())), manager.balanceOf(bob, currency1.toId())); + // assertTrue(delta.amount1() != 0); + + // // position manager holds no fees now + // assertApproxEqAbs(manager.balanceOf(address(lpm), currency0.toId()), 0, 1 wei); + // assertApproxEqAbs(manager.balanceOf(address(lpm), currency1.toId()), 0, 1 wei); + // } + + function test_collect_sameRange_erc20(IPoolManager.ModifyLiquidityParams memory params, uint256 liquidityDeltaBob) + public + { + params.liquidityDelta = bound(params.liquidityDelta, 10e18, 10_000e18); + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + + liquidityDeltaBob = bound(liquidityDeltaBob, 100e18, 100_000e18); + + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + vm.prank(alice); + _mint(range, uint256(params.liquidityDelta), block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + vm.prank(bob); + _mint(range, liquidityDeltaBob, block.timestamp + 1, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // confirm the positions are same range + (, LiquidityRange memory rangeAlice) = lpm.tokenPositions(tokenIdAlice); + (, LiquidityRange memory rangeBob) = lpm.tokenPositions(tokenIdBob); + assertEq(rangeAlice.tickLower, rangeBob.tickLower); + assertEq(rangeAlice.tickUpper, rangeBob.tickUpper); + + // swap to create fees + uint256 swapAmount = 0.01e18; + swap(key, false, -int256(swapAmount), ZERO_BYTES); + + // alice collects only her fees + uint256 balance0AliceBefore = currency0.balanceOf(alice); + uint256 balance1AliceBefore = currency1.balanceOf(alice); + vm.startPrank(alice); + BalanceDelta delta = _collect(tokenIdAlice, alice, ZERO_BYTES, false); + vm.stopPrank(); + uint256 balance0AliceAfter = currency0.balanceOf(alice); + uint256 balance1AliceAfter = currency1.balanceOf(alice); + + assertEq(balance0AliceBefore, balance0AliceAfter); + assertEq(uint256(uint128(delta.amount1())), balance1AliceAfter - balance1AliceBefore); + assertTrue(delta.amount1() != 0); + + // bob collects only his fees + uint256 balance0BobBefore = currency0.balanceOf(bob); + uint256 balance1BobBefore = currency1.balanceOf(bob); + vm.startPrank(bob); + delta = _collect(tokenIdBob, bob, ZERO_BYTES, false); + vm.stopPrank(); + uint256 balance0BobAfter = currency0.balanceOf(bob); + uint256 balance1BobAfter = currency1.balanceOf(bob); + + assertEq(balance0BobBefore, balance0BobAfter); + assertEq(uint256(uint128(delta.amount1())), balance1BobAfter - balance1BobBefore); + assertTrue(delta.amount1() != 0); + + // position manager holds no fees now + assertApproxEqAbs(manager.balanceOf(address(lpm), currency0.toId()), 0, 1 wei); + assertApproxEqAbs(manager.balanceOf(address(lpm), currency1.toId()), 0, 1 wei); + } + + function test_collect_donate() public {} + function test_collect_donate_sameRange() public {} + + /// @dev Alice and bob create liquidity on the same range + /// when alice decreases liquidity, she should only collect her fees + /// TODO Add back fuzz test on liquidityDeltaBob + /// TODO Assert state changes for lpm balance, position state, and return values + function test_decreaseLiquidity_sameRange_exact() public { + // alice and bob create liquidity on the same range [-120, 120] + LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: -120, tickUpper: 120}); + + // alice provisions 3x the amount of liquidity as bob + uint256 liquidityAlice = 3000e18; + uint256 liquidityBob = 1000e18; + vm.prank(alice); + BalanceDelta lpDeltaAlice = _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + vm.prank(bob); + BalanceDelta lpDeltaBob = _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // alice decreases liquidity + vm.prank(alice); + lpm.approve(address(this), tokenIdAlice); + _decreaseLiquidity(tokenIdAlice, liquidityAlice, ZERO_BYTES, true); + + uint256 tolerance = 0.000000001 ether; + + uint256 lpmBalance0 = manager.balanceOf(address(lpm), currency0.toId()); + uint256 lpmBalance1 = manager.balanceOf(address(lpm), currency1.toId()); + + // lpm collects alice's principal + all fees accrued on the range + assertApproxEqAbs( + lpmBalance0, uint256(int256(-lpDeltaAlice.amount0())) + swapAmount.mulWadDown(FEE_WAD), tolerance + ); + assertApproxEqAbs( + lpmBalance1, uint256(int256(-lpDeltaAlice.amount1())) + swapAmount.mulWadDown(FEE_WAD), tolerance + ); + + // bob decreases half of his liquidity + vm.prank(bob); + lpm.approve(address(this), tokenIdBob); + _decreaseLiquidity(tokenIdBob, liquidityBob / 2, ZERO_BYTES, true); + + // lpm collects half of bobs principal + // the fee amount has already been collected with alice's calls + assertApproxEqAbs( + manager.balanceOf(address(lpm), currency0.toId()) - lpmBalance0, + uint256(int256(-lpDeltaBob.amount0()) / 2), + tolerance + ); + assertApproxEqAbs( + manager.balanceOf(address(lpm), currency1.toId()) - lpmBalance1, + uint256(int256(-lpDeltaBob.amount1()) / 2), + tolerance + ); + } +} diff --git a/test/position-managers/Gas.t.sol b/test/position-managers/Gas.t.sol new file mode 100644 index 00000000..63c6b48d --- /dev/null +++ b/test/position-managers/Gas.t.sol @@ -0,0 +1,313 @@ +// // SPDX-License-Identifier: MIT +// pragma solidity ^0.8.24; + +// import "forge-std/Test.sol"; +// import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +// import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +// import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +// import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +// import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +// import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +// import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +// import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +// import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +// import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +// import {LiquidityAmounts} from "../../contracts/libraries/LiquidityAmounts.sol"; +// import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +// import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +// import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +// import {IERC20} from "forge-std/interfaces/IERC20.sol"; +// import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +// import {NonfungiblePositionManager} from "../../contracts/NonfungiblePositionManager.sol"; +// import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "../../contracts/types/LiquidityRange.sol"; + +// import {LiquidityOperations} from "../shared/LiquidityOperations.sol"; + +// contract GasTest is Test, Deployers, GasSnapshot, LiquidityOperations { +// using FixedPointMathLib for uint256; +// using CurrencyLibrary for Currency; +// using LiquidityRangeIdLibrary for LiquidityRange; +// using PoolIdLibrary for PoolKey; + +// PoolId poolId; +// address alice = makeAddr("ALICE"); +// address bob = makeAddr("BOB"); + +// uint256 constant STARTING_USER_BALANCE = 10_000_000 ether; + +// // expresses the fee as a wad (i.e. 3000 = 0.003e18 = 0.30%) +// uint256 FEE_WAD; + +// LiquidityRange range; + +// function setUp() public { +// Deployers.deployFreshManagerAndRouters(); +// Deployers.deployMintAndApprove2Currencies(); + +// (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); +// FEE_WAD = uint256(key.fee).mulDivDown(FixedPointMathLib.WAD, 1_000_000); + +// lpm = new NonfungiblePositionManager(manager); +// IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); +// IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + +// // Give tokens to Alice and Bob, with approvals +// IERC20(Currency.unwrap(currency0)).transfer(alice, STARTING_USER_BALANCE); +// IERC20(Currency.unwrap(currency1)).transfer(alice, STARTING_USER_BALANCE); +// IERC20(Currency.unwrap(currency0)).transfer(bob, STARTING_USER_BALANCE); +// IERC20(Currency.unwrap(currency1)).transfer(bob, STARTING_USER_BALANCE); +// vm.startPrank(alice); +// IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); +// IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); +// vm.stopPrank(); +// vm.startPrank(bob); +// IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); +// IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); +// vm.stopPrank(); + +// // mint some ERC6909 tokens +// claimsRouter.deposit(currency0, address(this), 100_000_000 ether); +// claimsRouter.deposit(currency1, address(this), 100_000_000 ether); +// manager.setOperator(address(lpm), true); + +// // define a reusable range +// range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); +// } + +// // function test_gas_mint() public { +// // uint256 amount0Desired = 148873216119575134691; // 148 ether tokens, 10_000 liquidity +// // uint256 amount1Desired = 148873216119575134691; // 148 ether tokens, 10_000 liquidity +// // INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({ +// // range: range, +// // amount0Desired: amount0Desired, +// // amount1Desired: amount1Desired, +// // amount0Min: 0, +// // amount1Min: 0, +// // deadline: block.timestamp + 1, +// // recipient: address(this), +// // hookData: ZERO_BYTES +// // }); +// // snapStart("mint"); +// // lpm.mint(params); +// // snapLastCall(); +// // } + +// function test_gas_mintWithLiquidity() public { +// bytes[] memory calls = new bytes[](1); +// calls[0] = abi.encodeWithSelector( +// lpm.mint.selector, range, 10_000 ether, block.timestamp + 1, address(this), ZERO_BYTES +// ); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("mintWithLiquidity"); +// } + +// function test_gas_increaseLiquidity_erc20() public { +// _mint(range, 10_000 ether, block.timestamp + 1, address(this), ZERO_BYTES); +// uint256 tokenId = lpm.nextTokenId() - 1; + +// bytes[] memory calls = new bytes[](1); +// calls[0] = abi.encodeWithSelector(lpm.increaseLiquidity.selector, tokenId, 10_000 ether, ZERO_BYTES, false); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("increaseLiquidity_erc20"); +// } + +// function test_gas_increaseLiquidity_erc6909() public { +// _mint(range, 10_000 ether, block.timestamp + 1, address(this), ZERO_BYTES); +// uint256 tokenId = lpm.nextTokenId() - 1; + +// bytes[] memory calls = new bytes[](1); +// calls[0] = abi.encodeWithSelector(lpm.increaseLiquidity.selector, tokenId, 10_000 ether, ZERO_BYTES, true); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("increaseLiquidity_erc6909"); +// } + +// function test_gas_autocompound_exactUnclaimedFees() public { +// // Alice and Bob provide liquidity on the range +// // Alice uses her exact fees to increase liquidity (compounding) + +// uint256 liquidityAlice = 3_000e18; +// uint256 liquidityBob = 1_000e18; + +// // alice provides liquidity +// vm.prank(alice); +// _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); +// uint256 tokenIdAlice = lpm.nextTokenId() - 1; + +// // bob provides liquidity +// vm.prank(bob); +// _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + +// // donate to create fees +// donateRouter.donate(key, 0.2e18, 0.2e18, ZERO_BYTES); + +// // alice uses her exact fees to increase liquidity +// (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + +// (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); +// uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( +// sqrtPriceX96, +// TickMath.getSqrtPriceAtTick(range.tickLower), +// TickMath.getSqrtPriceAtTick(range.tickUpper), +// token0Owed, +// token1Owed +// ); + +// bytes[] memory calls = new bytes[](1); +// calls[0] = +// abi.encodeWithSelector(lpm.increaseLiquidity.selector, tokenIdAlice, liquidityDelta, ZERO_BYTES, false); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// vm.prank(alice); +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("autocompound_exactUnclaimedFees"); +// } + +// function test_gas_autocompound_exactUnclaimedFees_exactCustodiedFees() public { +// // Alice and Bob provide liquidity on the range +// // Alice uses her fees to increase liquidity. Both unclaimed fees and cached fees are used to exactly increase the liquidity +// uint256 liquidityAlice = 3_000e18; +// uint256 liquidityBob = 1_000e18; + +// // alice provides liquidity +// vm.prank(alice); +// _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); +// uint256 tokenIdAlice = lpm.nextTokenId() - 1; + +// // bob provides liquidity +// vm.prank(bob); +// _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); +// uint256 tokenIdBob = lpm.nextTokenId() - 1; + +// // donate to create fees +// donateRouter.donate(key, 20e18, 20e18, ZERO_BYTES); + +// // bob collects fees so some of alice's fees are now cached +// bytes[] memory calls = new bytes[](1); +// calls[0] = abi.encodeWithSelector(lpm.collect.selector, tokenIdBob, bob, ZERO_BYTES, false); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// vm.prank(bob); +// lpm.modifyLiquidities(calls, currencies); + +// // donate to create more fees +// donateRouter.donate(key, 20e18, 20e18, ZERO_BYTES); + +// (uint256 newToken0Owed, uint256 newToken1Owed) = lpm.feesOwed(tokenIdAlice); + +// // alice will use ALL of her fees to increase liquidity +// { +// (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); +// uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( +// sqrtPriceX96, +// TickMath.getSqrtPriceAtTick(range.tickLower), +// TickMath.getSqrtPriceAtTick(range.tickUpper), +// newToken0Owed, +// newToken1Owed +// ); + +// calls = new bytes[](1); +// calls[0] = +// abi.encodeWithSelector(lpm.increaseLiquidity.selector, tokenIdAlice, liquidityDelta, ZERO_BYTES, false); +// currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// vm.prank(alice); +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("autocompound_exactUnclaimedFees_exactCustodiedFees"); +// } +// } + +// // autocompounding but the excess fees are credited to tokensOwed +// function test_gas_autocompound_excessFeesCredit() public { +// // Alice and Bob provide liquidity on the range +// // Alice uses her fees to increase liquidity. Excess fees are accounted to alice +// uint256 liquidityAlice = 3_000e18; +// uint256 liquidityBob = 1_000e18; + +// // alice provides liquidity +// vm.prank(alice); +// _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); +// uint256 tokenIdAlice = lpm.nextTokenId() - 1; + +// // bob provides liquidity +// vm.prank(bob); +// _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); +// uint256 tokenIdBob = lpm.nextTokenId() - 1; + +// // donate to create fees +// donateRouter.donate(key, 20e18, 20e18, ZERO_BYTES); + +// // alice will use half of her fees to increase liquidity +// (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + +// (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); +// uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( +// sqrtPriceX96, +// TickMath.getSqrtPriceAtTick(range.tickLower), +// TickMath.getSqrtPriceAtTick(range.tickUpper), +// token0Owed / 2, +// token1Owed / 2 +// ); + +// bytes[] memory calls = new bytes[](1); +// calls[0] = +// abi.encodeWithSelector(lpm.increaseLiquidity.selector, tokenIdAlice, liquidityDelta, ZERO_BYTES, false); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// vm.prank(alice); +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("autocompound_excessFeesCredit"); +// } + +// function test_gas_decreaseLiquidity_erc20() public { +// _mint(range, 10_000 ether, block.timestamp + 1, address(this), ZERO_BYTES); +// uint256 tokenId = lpm.nextTokenId() - 1; + +// bytes[] memory calls = new bytes[](1); +// calls[0] = abi.encodeWithSelector(lpm.decreaseLiquidity.selector, tokenId, 10_000 ether, ZERO_BYTES, false); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("decreaseLiquidity_erc20"); +// } + +// function test_gas_decreaseLiquidity_erc6909() public { +// _mint(range, 10_000 ether, block.timestamp + 1, address(this), ZERO_BYTES); +// uint256 tokenId = lpm.nextTokenId() - 1; + +// bytes[] memory calls = new bytes[](1); +// calls[0] = abi.encodeWithSelector(lpm.decreaseLiquidity.selector, tokenId, 10_000 ether, ZERO_BYTES, true); +// Currency[] memory currencies = new Currency[](2); +// currencies[0] = currency0; +// currencies[1] = currency1; + +// lpm.modifyLiquidities(calls, currencies); +// snapLastCall("decreaseLiquidity_erc6909"); +// } + +// function test_gas_burn() public {} +// function test_gas_burnEmpty() public {} +// function test_gas_collect() public {} +// } diff --git a/test/position-managers/IncreaseLiquidity.t.sol b/test/position-managers/IncreaseLiquidity.t.sol new file mode 100644 index 00000000..8f91a055 --- /dev/null +++ b/test/position-managers/IncreaseLiquidity.t.sol @@ -0,0 +1,468 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {LiquidityAmounts} from "../../contracts/libraries/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {StateLibrary} from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +import {NonfungiblePositionManager} from "../../contracts/NonfungiblePositionManager.sol"; +import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "../../contracts/types/LiquidityRange.sol"; + +import {Fuzzers} from "@uniswap/v4-core/src/test/Fuzzers.sol"; + +import {LiquidityOperations} from "../shared/LiquidityOperations.sol"; + +import "forge-std/console2.sol"; + +contract IncreaseLiquidityTest is Test, Deployers, GasSnapshot, Fuzzers, LiquidityOperations { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using LiquidityRangeIdLibrary for LiquidityRange; + using PoolIdLibrary for PoolKey; + + PoolId poolId; + address alice = makeAddr("ALICE"); + address bob = makeAddr("BOB"); + + uint256 constant STARTING_USER_BALANCE = 10_000_000 ether; + + // expresses the fee as a wad (i.e. 3000 = 0.003e18 = 0.30%) + uint256 FEE_WAD; + + LiquidityRange range; + + function setUp() public { + Deployers.deployFreshManagerAndRouters(); + Deployers.deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + FEE_WAD = uint256(key.fee).mulDivDown(FixedPointMathLib.WAD, 1_000_000); + + lpm = new NonfungiblePositionManager(manager); + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + + // Give tokens to Alice and Bob, with approvals + IERC20(Currency.unwrap(currency0)).transfer(alice, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency1)).transfer(alice, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency0)).transfer(bob, STARTING_USER_BALANCE); + IERC20(Currency.unwrap(currency1)).transfer(bob, STARTING_USER_BALANCE); + vm.startPrank(alice); + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + vm.stopPrank(); + vm.startPrank(bob); + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + vm.stopPrank(); + + // define a reusable range + range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); + } + + function test_increaseLiquidity_withExactFees1() public { + // Alice and Bob provide liquidity on the range + // Alice uses her exact fees to increase liquidity (compounding) + + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + + // alice provides liquidity + vm.prank(alice); + _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // alice uses her exact fees to increase liquidity + (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + token0Owed, + token1Owed + ); + + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + + vm.startPrank(alice); + _increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + vm.stopPrank(); + + // alice did not spend any tokens + assertEq(balance0BeforeAlice, currency0.balanceOf(alice)); + assertEq(balance1BeforeAlice, currency1.balanceOf(alice)); + + // alice spent all of the fees, approximately + (token0Owed, token1Owed) = lpm.feesOwed(tokenIdAlice); + assertApproxEqAbs(token0Owed, 0, 20 wei); + assertApproxEqAbs(token1Owed, 0, 20 wei); + } + + // uses donate to simulate fee revenue + function test_increaseLiquidity_withExactFees_donate() public { + // Alice and Bob provide liquidity on the range + // Alice uses her exact fees to increase liquidity (compounding) + + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + + // alice provides liquidity + vm.prank(alice); + _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + + // donate to create fees + donateRouter.donate(key, 0.2e18, 0.2e18, ZERO_BYTES); + + // alice uses her exact fees to increase liquidity + (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + token0Owed, + token1Owed + ); + + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + + vm.startPrank(alice); + _increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + vm.stopPrank(); + + // alice did not spend any tokens + assertEq(balance0BeforeAlice, currency0.balanceOf(alice)); + assertEq(balance1BeforeAlice, currency1.balanceOf(alice)); + + // alice spent all of the fees + (token0Owed, token1Owed) = lpm.feesOwed(tokenIdAlice); + assertEq(token0Owed, 0); + assertEq(token1Owed, 0); + } + + function test_increaseLiquidity_withExcessFees() public { + // Alice and Bob provide liquidity on the range + // Alice uses her fees to increase liquidity. Excess fees are accounted to alice + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + uint256 totalLiquidity = liquidityAlice + liquidityBob; + + // alice provides liquidity + vm.prank(alice); + _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // alice will use half of her fees to increase liquidity + (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + { + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + token0Owed / 2, + token1Owed / 2 + ); + + vm.startPrank(alice); + _increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + vm.stopPrank(); + } + + { + // bob collects his fees + uint256 balance0BeforeBob = currency0.balanceOf(bob); + uint256 balance1BeforeBob = currency1.balanceOf(bob); + vm.startPrank(bob); + _collect(tokenIdBob, bob, ZERO_BYTES, false); + vm.stopPrank(); + uint256 balance0AfterBob = currency0.balanceOf(bob); + uint256 balance1AfterBob = currency1.balanceOf(bob); + assertApproxEqAbs( + balance0AfterBob - balance0BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + 1 wei + ); + assertApproxEqAbs( + balance1AfterBob - balance1BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + 1 wei + ); + } + + { + // alice collects her fees, which should be about half of the fees + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + vm.startPrank(alice); + _collect(tokenIdAlice, alice, ZERO_BYTES, false); + vm.stopPrank(); + uint256 balance0AfterAlice = currency0.balanceOf(alice); + uint256 balance1AfterAlice = currency1.balanceOf(alice); + assertApproxEqAbs( + balance0AfterAlice - balance0BeforeAlice, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, totalLiquidity) / 2, + 9 wei + ); + assertApproxEqAbs( + balance1AfterAlice - balance1BeforeAlice, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, totalLiquidity) / 2, + 1 wei + ); + } + } + + function test_increaseLiquidity_withInsufficientFees() public { + // Alice and Bob provide liquidity on the range + // Alice uses her fees to increase liquidity. Additional funds are used by alice to increase liquidity + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + uint256 totalLiquidity = liquidityAlice + liquidityBob; + + // alice provides liquidity + vm.prank(alice); + _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // alice will use all of her fees + additional capital to increase liquidity + (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + { + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + token0Owed * 2, + token1Owed * 2 + ); + + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + vm.startPrank(alice); + _increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + vm.stopPrank(); + uint256 balance0AfterAlice = currency0.balanceOf(alice); + uint256 balance1AfterAlice = currency1.balanceOf(alice); + + assertApproxEqAbs(balance0BeforeAlice - balance0AfterAlice, token0Owed, 37 wei); + assertApproxEqAbs(balance1BeforeAlice - balance1AfterAlice, token1Owed, 1 wei); + } + + { + // bob collects his fees + uint256 balance0BeforeBob = currency0.balanceOf(bob); + uint256 balance1BeforeBob = currency1.balanceOf(bob); + vm.startPrank(bob); + _collect(tokenIdBob, bob, ZERO_BYTES, false); + vm.stopPrank(); + uint256 balance0AfterBob = currency0.balanceOf(bob); + uint256 balance1AfterBob = currency1.balanceOf(bob); + assertApproxEqAbs( + balance0AfterBob - balance0BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + 1 wei + ); + assertApproxEqAbs( + balance1AfterBob - balance1BeforeBob, + swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityBob, totalLiquidity), + 1 wei + ); + } + } + + function test_increaseLiquidity_withExactFees_withExactCachedFees1() public { + // Alice and Bob provide liquidity on the range + // Alice uses her fees to increase liquidity. Both unclaimed fees and cached fees are used to exactly increase the liquidity + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + uint256 totalLiquidity = liquidityAlice + liquidityBob; + + // alice provides liquidity + vm.prank(alice); + _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // swap to create fees + uint256 swapAmount = 0.001e18; + swap(key, true, -int256(swapAmount), ZERO_BYTES); + swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + + // bob collects fees so some of alice's fees are now cached + + vm.startPrank(bob); + _collect(tokenIdBob, bob, ZERO_BYTES, false); + vm.stopPrank(); + //swap to create more fees + // swap(key, true, -int256(swapAmount), ZERO_BYTES); + // swap(key, false, -int256(swapAmount), ZERO_BYTES); // move the price back + + // (uint256 newToken0Owed, uint256 newToken1Owed) = lpm.feesOwed(tokenIdAlice); + // // alice's fees should be doubled + // assertApproxEqAbs(newToken0Owed, token0Owed * 2, 2 wei); + // assertApproxEqAbs(newToken1Owed, token1Owed * 2, 2 wei); + + // uint256 balance0AliceBefore = currency0.balanceOf(alice); + // uint256 balance1AliceBefore = currency1.balanceOf(alice); + + // // alice will use ALL of her fees to increase liquidity + // { + // (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + // uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + // sqrtPriceX96, + // TickMath.getSqrtPriceAtTick(range.tickLower), + // TickMath.getSqrtPriceAtTick(range.tickUpper), + // newToken0Owed, + // newToken1Owed + // ); + + // vm.startPrank(alice); + // _increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + // vm.stopPrank(); + // } + + // // alice did not spend any tokens + // assertEq(balance0AliceBefore, currency0.balanceOf(alice)); + // assertEq(balance1AliceBefore, currency1.balanceOf(alice)); + + // // some dust was credited to alice's tokensOwed + // (token0Owed, token1Owed) = lpm.feesOwed(tokenIdAlice); + // assertApproxEqAbs(token0Owed, 0, 80 wei); + // assertApproxEqAbs(token1Owed, 0, 80 wei); + } + + // uses donate to simulate fee revenue + function test_increaseLiquidity_withExactFees_withExactCachedFees_donate() public { + // Alice and Bob provide liquidity on the range + // Alice uses her fees to increase liquidity. Both unclaimed fees and cached fees are used to exactly increase the liquidity + uint256 liquidityAlice = 3_000e18; + uint256 liquidityBob = 1_000e18; + uint256 totalLiquidity = liquidityAlice + liquidityBob; + + // alice provides liquidity + vm.prank(alice); + _mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + uint256 tokenIdAlice = lpm.nextTokenId() - 1; + + // bob provides liquidity + vm.prank(bob); + _mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + uint256 tokenIdBob = lpm.nextTokenId() - 1; + + // donate to create fees + donateRouter.donate(key, 20e18, 20e18, ZERO_BYTES); + + (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + + // bob collects fees so some of alice's fees are now cached + vm.startPrank(bob); + _collect(tokenIdBob, bob, ZERO_BYTES, false); + vm.stopPrank(); + + // donate to create more fees + donateRouter.donate(key, 20e18, 20e18, ZERO_BYTES); + + (uint256 newToken0Owed, uint256 newToken1Owed) = lpm.feesOwed(tokenIdAlice); + // alice's fees should be doubled + assertApproxEqAbs(newToken0Owed, token0Owed * 2, 1 wei); + assertApproxEqAbs(newToken1Owed, token1Owed * 2, 1 wei); + + uint256 balance0AliceBefore = currency0.balanceOf(alice); + uint256 balance1AliceBefore = currency1.balanceOf(alice); + + // alice will use ALL of her fees to increase liquidity + { + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); + uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( + sqrtPriceX96, + TickMath.getSqrtPriceAtTick(range.tickLower), + TickMath.getSqrtPriceAtTick(range.tickUpper), + newToken0Owed, + newToken1Owed + ); + + vm.startPrank(alice); + _increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + vm.stopPrank(); + } + + // alice did not spend any tokens + assertEq(balance0AliceBefore, currency0.balanceOf(alice), "alice spent token0"); + assertEq(balance1AliceBefore, currency1.balanceOf(alice), "alice spent token1"); + + (token0Owed, token1Owed) = lpm.feesOwed(tokenIdAlice); + assertEq(token0Owed, 0); + assertEq(token1Owed, 0); + + // bob still collects 5 + (token0Owed, token1Owed) = lpm.feesOwed(tokenIdBob); + assertApproxEqAbs(token0Owed, 5e18, 1 wei); + assertApproxEqAbs(token1Owed, 5e18, 1 wei); + + vm.startPrank(bob); + BalanceDelta result = _collect(tokenIdBob, bob, ZERO_BYTES, false); + vm.stopPrank(); + assertApproxEqAbs(result.amount0(), 5e18, 1 wei); + assertApproxEqAbs(result.amount1(), 5e18, 1 wei); + } +} diff --git a/test/position-managers/NonfungiblePositionManager.t.sol b/test/position-managers/NonfungiblePositionManager.t.sol new file mode 100644 index 00000000..7892f4b3 --- /dev/null +++ b/test/position-managers/NonfungiblePositionManager.t.sol @@ -0,0 +1,300 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {GasSnapshot} from "forge-gas-snapshot/GasSnapshot.sol"; +import {PoolManager} from "@uniswap/v4-core/src/PoolManager.sol"; +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import {Deployers} from "@uniswap/v4-core/test/utils/Deployers.sol"; +import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol"; +import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {PoolSwapTest} from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import {LiquidityAmounts} from "../../contracts/libraries/LiquidityAmounts.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {Constants} from "@uniswap/v4-core/test/utils/Constants.sol"; + +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +import {INonfungiblePositionManager, Actions} from "../../contracts/interfaces/INonfungiblePositionManager.sol"; +import {NonfungiblePositionManager} from "../../contracts/NonfungiblePositionManager.sol"; +import {LiquidityRange, LiquidityRangeId, LiquidityRangeIdLibrary} from "../../contracts/types/LiquidityRange.sol"; + +import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol"; + +import {LiquidityOperations} from "../shared/LiquidityOperations.sol"; +import {Planner} from "../utils/Planner.sol"; + +import "forge-std/console2.sol"; + +contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, LiquidityFuzzers, LiquidityOperations { + using FixedPointMathLib for uint256; + using CurrencyLibrary for Currency; + using LiquidityRangeIdLibrary for LiquidityRange; + using Planner for Planner.Plan; + + PoolId poolId; + address alice = makeAddr("ALICE"); + + function setUp() public { + Deployers.deployFreshManagerAndRouters(); + Deployers.deployMintAndApprove2Currencies(); + + (key, poolId) = initPool(currency0, currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1, ZERO_BYTES); + + lpm = new NonfungiblePositionManager(manager); + + IERC20(Currency.unwrap(currency0)).approve(address(lpm), type(uint256).max); + IERC20(Currency.unwrap(currency1)).approve(address(lpm), type(uint256).max); + } + + function test_mint_withLiquidityDelta(IPoolManager.ModifyLiquidityParams memory params) public { + params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); + // liquidity is a uint + uint256 liquidityToAdd = + params.liquidityDelta < 0 ? uint256(-params.liquidityDelta) : uint256(params.liquidityDelta); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + Planner.Plan memory planner = Planner.init(); + planner = planner.add( + Actions.MINT, abi.encode(range, liquidityToAdd, uint256(block.timestamp + 1), address(this), ZERO_BYTES) + ); + + Currency[] memory currencies = new Currency[](2); + currencies[0] = currency0; + currencies[1] = currency1; + + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + + bytes[] memory result = lpm.modifyLiquidities(abi.encode(planner.actions, planner.params, currencies)); + + (BalanceDelta delta, uint256 tokenId) = abi.decode(result[0], (BalanceDelta, uint256)); + + assertEq(lpm.ownerOf(1), address(this)); + (,, uint256 liquidity,,,,) = lpm.positions(address(this), range.toId()); + assertEq(liquidity, uint256(params.liquidityDelta)); + assertEq(balance0Before - currency0.balanceOfSelf(), uint256(int256(-delta.amount0())), "incorrect amount0"); + assertEq(balance1Before - currency1.balanceOfSelf(), uint256(int256(-delta.amount1())), "incorrect amount1"); + } + + function test_mint_exactTokenRatios() public { + int24 tickLower = -int24(key.tickSpacing); + int24 tickUpper = int24(key.tickSpacing); + uint256 amount0Desired = 100e18; + uint256 amount1Desired = 100e18; + uint256 liquidityToAdd = LiquidityAmounts.getLiquidityForAmounts( + SQRT_PRICE_1_1, + TickMath.getSqrtPriceAtTick(tickLower), + TickMath.getSqrtPriceAtTick(tickUpper), + amount0Desired, + amount1Desired + ); + + LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + + Currency[] memory currencies = new Currency[](2); + currencies[0] = currency0; + currencies[1] = currency1; + + Planner.Plan memory planner = Planner.init(); + planner = planner.add( + Actions.MINT, abi.encode(range, liquidityToAdd, uint256(block.timestamp + 1), address(this), ZERO_BYTES) + ); + + bytes[] memory result = lpm.modifyLiquidities(abi.encode(planner.actions, planner.params, currencies)); + (BalanceDelta delta, uint256 tokenId) = abi.decode(result[0], (BalanceDelta, uint256)); + + uint256 balance0After = currency0.balanceOfSelf(); + uint256 balance1After = currency1.balanceOfSelf(); + + assertEq(tokenId, 1); + assertEq(lpm.ownerOf(1), address(this)); + assertEq(uint256(int256(-delta.amount0())), amount0Desired); + assertEq(uint256(int256(-delta.amount1())), amount1Desired); + assertEq(balance0Before - balance0After, uint256(int256(-delta.amount0()))); + assertEq(balance1Before - balance1After, uint256(int256(-delta.amount1()))); + } + + // function test_mint_recipient(int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired) + // public + // { + // (tickLower, tickUpper) = createFuzzyLiquidityParams(key, tickLower, tickUpper, DEAD_VALUE); + // (amount0Desired, amount1Desired) = + // createFuzzyAmountDesired(key, tickLower, tickUpper, amount0Desired, amount1Desired); + + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + // INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({ + // range: range, + // amount0Desired: amount0Desired, + // amount1Desired: amount1Desired, + // amount0Min: 0, + // amount1Min: 0, + // deadline: block.timestamp + 1, + // recipient: alice, + // hookData: ZERO_BYTES + // }); + // (uint256 tokenId,) = lpm.mint(params); + // assertEq(tokenId, 1); + // assertEq(lpm.ownerOf(tokenId), alice); + // } + + // function test_mint_slippageRevert(int24 tickLower, int24 tickUpper, uint256 amount0Desired, uint256 amount1Desired) + // public + // { + // (tickLower, tickUpper) = createFuzzyLiquidityParams(key, tickLower, tickUpper, DEAD_VALUE); + // vm.assume(tickLower < 0 && 0 < tickUpper); + + // (amount0Desired, amount1Desired) = + // createFuzzyAmountDesired(key, tickLower, tickUpper, amount0Desired, amount1Desired); + // vm.assume(0.00001e18 < amount0Desired); + // vm.assume(0.00001e18 < amount1Desired); + + // uint256 amount0Min = amount0Desired - 1; + // uint256 amount1Min = amount1Desired - 1; + + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); + // INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({ + // range: range, + // amount0Desired: amount0Desired, + // amount1Desired: amount1Desired, + // amount0Min: amount0Min, + // amount1Min: amount1Min, + // deadline: block.timestamp + 1, + // recipient: address(this), + // hookData: ZERO_BYTES + // }); + + // // seed some liquidity so we can move the price + // modifyLiquidityRouter.modifyLiquidity( + // key, + // IPoolManager.ModifyLiquidityParams({ + // tickLower: TickMath.minUsableTick(key.tickSpacing), + // tickUpper: TickMath.maxUsableTick(key.tickSpacing), + // liquidityDelta: 100_000e18, + // salt: 0 + // }), + // ZERO_BYTES + // ); + + // // swap to move the price + // swap(key, true, -1000e18, ZERO_BYTES); + + // // will revert because amount0Min and amount1Min are very strict + // vm.expectRevert(); + // lpm.mint(params); + // } + + function test_burn(IPoolManager.ModifyLiquidityParams memory params) public { + uint256 balance0Start = currency0.balanceOfSelf(); + uint256 balance1Start = currency1.balanceOfSelf(); + + // create liquidity we can burn + uint256 tokenId; + (tokenId, params) = createFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + assertEq(tokenId, 1); + assertEq(lpm.ownerOf(1), address(this)); + (,, uint256 liquidity,,,,) = lpm.positions(address(this), range.toId()); + assertEq(liquidity, uint256(params.liquidityDelta)); + + // burn liquidity + uint256 balance0BeforeBurn = currency0.balanceOfSelf(); + uint256 balance1BeforeBurn = currency1.balanceOfSelf(); + // TODO, encode this under one call + BalanceDelta deltaDecrease = _decreaseLiquidity(tokenId, liquidity, ZERO_BYTES, false); + BalanceDelta deltaCollect = _collect(tokenId, address(this), ZERO_BYTES, false); + _burn(tokenId); + (,, liquidity,,,,) = lpm.positions(address(this), range.toId()); + assertEq(liquidity, 0); + + // TODO: slightly off by 1 bip (0.0001%) + assertApproxEqRel( + currency0.balanceOfSelf(), + balance0BeforeBurn + uint256(uint128(deltaDecrease.amount0())) + uint256(uint128(deltaCollect.amount0())), + 0.0001e18 + ); + assertApproxEqRel( + currency1.balanceOfSelf(), + balance1BeforeBurn + uint256(uint128(deltaDecrease.amount1())) + uint256(uint128(deltaCollect.amount1())), + 0.0001e18 + ); + + // OZ 721 will revert if the token does not exist + vm.expectRevert(); + lpm.ownerOf(1); + + // no tokens were lost, TODO: fuzzer showing off by 1 sometimes + assertApproxEqAbs(currency0.balanceOfSelf(), balance0Start, 1 wei); + assertApproxEqAbs(currency1.balanceOfSelf(), balance1Start, 1 wei); + } + + function test_decreaseLiquidity1(IPoolManager.ModifyLiquidityParams memory params, uint256 decreaseLiquidityDelta) + public + { + uint256 tokenId; + (tokenId, params) = createFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + vm.assume(0 < decreaseLiquidityDelta); + vm.assume(decreaseLiquidityDelta < uint256(type(int256).max)); + vm.assume(int256(decreaseLiquidityDelta) <= params.liquidityDelta); + + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + uint256 balance0Before = currency0.balanceOfSelf(); + uint256 balance1Before = currency1.balanceOfSelf(); + BalanceDelta delta = _decreaseLiquidity(tokenId, decreaseLiquidityDelta, ZERO_BYTES, false); + + (,, uint256 liquidity,,,,) = lpm.positions(address(this), range.toId()); + assertEq(liquidity, uint256(params.liquidityDelta) - decreaseLiquidityDelta); + + // On decrease, balance doesn't change (currenct functionality). + assertEq(currency0.balanceOfSelf() - balance0Before, 0); + assertEq(currency1.balanceOfSelf() - balance1Before, 0); + } + + // function test_decreaseLiquidity_collectFees( + // IPoolManager.ModifyLiquidityParams memory params, + // uint256 decreaseLiquidityDelta + // ) public { + // uint256 tokenId; + // (tokenId, params) = createFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); + // vm.assume(params.tickLower < 0 && 0 < params.tickUpper); // require two-sided liquidity + // vm.assume(0 < decreaseLiquidityDelta); + // vm.assume(decreaseLiquidityDelta < uint256(type(int256).max)); + // vm.assume(int256(decreaseLiquidityDelta) <= params.liquidityDelta); + + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + // // swap to create fees + // uint256 swapAmount = 0.01e18; + // swap(key, false, int256(swapAmount), ZERO_BYTES); + + // uint256 balance0Before = currency0.balanceOfSelf(); + // uint256 balance1Before = currency1.balanceOfSelf(); + // BalanceDelta delta = lpm.decreaseLiquidity(tokenId, decreaseLiquidityDelta, ZERO_BYTES, false); + // (,, uint256 liquidity,,,,) = lpm.positions(address(this), range.toId()); + // assertEq(liquidity, uint256(params.liquidityDelta) - decreaseLiquidityDelta); + + // // express key.fee as wad (i.e. 3000 = 0.003e18) + // uint256 feeWad = uint256(key.fee).mulDivDown(FixedPointMathLib.WAD, 1_000_000); + + // assertEq(currency0.balanceOfSelf() - balance0Before, uint256(int256(-delta.amount0())), "boo"); + // assertEq(currency1.balanceOfSelf() - balance1Before, uint256(int256(-delta.amount1())), "guh"); + // } + + function test_mintTransferBurn() public {} + function test_mintTransferCollect() public {} + function test_mintTransferIncrease() public {} + function test_mintTransferDecrease() public {} +} diff --git a/test/shared/LiquidityOperations.sol b/test/shared/LiquidityOperations.sol new file mode 100644 index 00000000..683e5919 --- /dev/null +++ b/test/shared/LiquidityOperations.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; + +import {NonfungiblePositionManager, Actions} from "../../contracts/NonfungiblePositionManager.sol"; +import {LiquidityRange} from "../../contracts/types/LiquidityRange.sol"; +import {Planner} from "../utils/Planner.sol"; + +contract LiquidityOperations { + NonfungiblePositionManager lpm; + + using Planner for Planner.Plan; + + function _mint( + LiquidityRange memory _range, + uint256 liquidity, + uint256 deadline, + address recipient, + bytes memory hookData + ) internal returns (BalanceDelta) { + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.MINT, abi.encode(_range, liquidity, deadline, recipient, hookData)); + + Currency[] memory currencies = new Currency[](2); + currencies[0] = _range.poolKey.currency0; + currencies[1] = _range.poolKey.currency1; + bytes[] memory result = lpm.modifyLiquidities(abi.encode(planner.actions, planner.params, currencies)); + return abi.decode(result[0], (BalanceDelta)); + } + + function _increaseLiquidity(uint256 tokenId, uint256 liquidityToAdd, bytes memory hookData, bool claims) internal { + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.INCREASE, abi.encode(tokenId, liquidityToAdd, hookData, claims)); + + (, LiquidityRange memory _range) = lpm.tokenPositions(tokenId); + + Currency[] memory currencies = new Currency[](2); + currencies[0] = _range.poolKey.currency0; + currencies[1] = _range.poolKey.currency1; + lpm.modifyLiquidities(abi.encode(planner.actions, planner.params, currencies)); + } + + function _decreaseLiquidity(uint256 tokenId, uint256 liquidityToRemove, bytes memory hookData, bool claims) + internal + returns (BalanceDelta) + { + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.DECREASE, abi.encode(tokenId, liquidityToRemove, hookData, claims)); + + (, LiquidityRange memory _range) = lpm.tokenPositions(tokenId); + + Currency[] memory currencies = new Currency[](2); + currencies[0] = _range.poolKey.currency0; + currencies[1] = _range.poolKey.currency1; + bytes[] memory result = lpm.modifyLiquidities(abi.encode(planner.actions, planner.params, currencies)); + return abi.decode(result[0], (BalanceDelta)); + } + + function _collect(uint256 tokenId, address recipient, bytes memory hookData, bool claims) + internal + returns (BalanceDelta) + { + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.COLLECT, abi.encode(tokenId, recipient, hookData, claims)); + + (, LiquidityRange memory _range) = lpm.tokenPositions(tokenId); + + Currency[] memory currencies = new Currency[](2); + currencies[0] = _range.poolKey.currency0; + currencies[1] = _range.poolKey.currency1; + bytes[] memory result = lpm.modifyLiquidities(abi.encode(planner.actions, planner.params, currencies)); + return abi.decode(result[0], (BalanceDelta)); + } + + function _burn(uint256 tokenId) internal { + Currency[] memory currencies = new Currency[](0); + Planner.Plan memory planner = Planner.init(); + planner = planner.add(Actions.BURN, abi.encode(tokenId)); + bytes[] memory result = lpm.modifyLiquidities(abi.encode(planner.actions, planner.params, currencies)); + } +} diff --git a/test/shared/fuzz/LiquidityFuzzers.sol b/test/shared/fuzz/LiquidityFuzzers.sol new file mode 100644 index 00000000..5def37bc --- /dev/null +++ b/test/shared/fuzz/LiquidityFuzzers.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; +import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import {Currency} from "@uniswap/v4-core/src/types/Currency.sol"; +import {Fuzzers} from "@uniswap/v4-core/src/test/Fuzzers.sol"; + +import {INonfungiblePositionManager, Actions} from "../../../contracts/interfaces/INonfungiblePositionManager.sol"; +import {LiquidityRange} from "../../../contracts/types/LiquidityRange.sol"; +import {Planner} from "../../utils/Planner.sol"; + +contract LiquidityFuzzers is Fuzzers { + using Planner for Planner.Plan; + + function createFuzzyLiquidity( + INonfungiblePositionManager lpm, + address recipient, + PoolKey memory key, + IPoolManager.ModifyLiquidityParams memory params, + uint160 sqrtPriceX96, + bytes memory hookData + ) internal returns (uint256, IPoolManager.ModifyLiquidityParams memory) { + params = Fuzzers.createFuzzyLiquidityParams(key, params, sqrtPriceX96); + LiquidityRange memory range = + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + + Planner.Plan memory plan = Planner.init().add( + Actions.MINT, abi.encode(range, uint256(params.liquidityDelta), block.timestamp, recipient, hookData) + ); + + Currency[] memory currencies = new Currency[](2); + currencies[0] = key.currency0; + currencies[1] = key.currency1; + + lpm.modifyLiquidities(abi.encode(plan.actions, plan.params, currencies)); + + uint256 tokenId = lpm.nextTokenId() - 1; + return (tokenId, params); + } +} diff --git a/test/utils/Planner.sol b/test/utils/Planner.sol new file mode 100644 index 00000000..302c0f83 --- /dev/null +++ b/test/utils/Planner.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {INonfungiblePositionManager, Actions} from "../../contracts/interfaces/INonfungiblePositionManager.sol"; + +library Planner { + struct Plan { + Actions[] actions; + bytes[] params; + } + + function init() public returns (Plan memory plan) { + return Plan({actions: new Actions[](0), params: new bytes[](0)}); + } + + function add(Plan memory plan, Actions action, bytes memory param) public returns (Plan memory) { + Actions[] memory actions = new Actions[](plan.actions.length + 1); + bytes[] memory params = new bytes[](plan.params.length + 1); + + for (uint256 i; i < actions.length - 1; i++) { + actions[i] = actions[i]; + params[i] = params[i]; + } + + actions[actions.length - 1] = action; + params[params.length - 1] = param; + + return Plan({actions: actions, params: params}); + } + + function zip(Plan memory plan) public returns (bytes memory) { + return abi.encode(plan.actions, plan.params); + } +}