From 0cff6ef693958d4f6b6fc6791bf34513904a3691 Mon Sep 17 00:00:00 2001 From: Sara Reynolds <30504811+snreynolds@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:04:47 -0400 Subject: [PATCH] change add liq accounting (#126) * change add liq accounting * remove rand comments * fix exact fees * use closeAllDeltas * comments cleanup * additional liquidity tests (#129) * additional increase liquidity tests * edge case of using cached fees for autocompound * wip * fix autocompound bug, use custodied and unclaimed fees in the autocompound * fix tests and use BalanceDeltas (#130) * fix some assertions * use BalanceDeltas for arithmetic * cleanest code in the game??? * additional cleaning * typo lol * autocompound gas benchmarks * autocompound excess credit gas benchmark * save 600 gas, cleaner code when moving caller delta to tokensOwed --------- Co-authored-by: saucepoint <98790946+saucepoint@users.noreply.github.com> --- .../autocompound_exactUnclaimedFees.snap | 1 + ...exactUnclaimedFees_exactCustodiedFees.snap | 1 + .../autocompound_excessFeesCredit.snap | 1 + .forge-snapshots/decreaseLiquidity_erc20.snap | 2 +- .../decreaseLiquidity_erc6909.snap | 2 +- .forge-snapshots/increaseLiquidity_erc20.snap | 2 +- .../increaseLiquidity_erc6909.snap | 2 +- .forge-snapshots/mintWithLiquidity.snap | 2 +- contracts/NonfungiblePositionManager.sol | 4 +- contracts/base/BaseLiquidityManagement.sol | 181 +++++++++++----- .../interfaces/IBaseLiquidityManagement.sol | 9 - .../BalanceDeltaExtensionLibrary.sol | 53 +++++ contracts/libraries/CurrencySenderLibrary.sol | 4 +- contracts/libraries/FeeMath.sol | 8 +- contracts/libraries/Position.sol | 30 +++ contracts/types/LiquidityRange.sol | 2 +- test/position-managers/FeeCollection.t.sol | 8 +- test/position-managers/Gas.t.sol | 129 ++++++++++- .../position-managers/IncreaseLiquidity.t.sol | 200 +++++++++++++++++- .../NonfungiblePositionManager.t.sol | 16 +- test/shared/fuzz/LiquidityFuzzers.sol | 2 +- 21 files changed, 567 insertions(+), 92 deletions(-) create mode 100644 .forge-snapshots/autocompound_exactUnclaimedFees.snap create mode 100644 .forge-snapshots/autocompound_exactUnclaimedFees_exactCustodiedFees.snap create mode 100644 .forge-snapshots/autocompound_excessFeesCredit.snap create mode 100644 contracts/libraries/BalanceDeltaExtensionLibrary.sol create mode 100644 contracts/libraries/Position.sol diff --git a/.forge-snapshots/autocompound_exactUnclaimedFees.snap b/.forge-snapshots/autocompound_exactUnclaimedFees.snap new file mode 100644 index 00000000..40ad7ac8 --- /dev/null +++ b/.forge-snapshots/autocompound_exactUnclaimedFees.snap @@ -0,0 +1 @@ +258477 \ 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..e2e7eb05 --- /dev/null +++ b/.forge-snapshots/autocompound_exactUnclaimedFees_exactCustodiedFees.snap @@ -0,0 +1 @@ +190850 \ 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..bcf9757d --- /dev/null +++ b/.forge-snapshots/autocompound_excessFeesCredit.snap @@ -0,0 +1 @@ +279016 \ No newline at end of file diff --git a/.forge-snapshots/decreaseLiquidity_erc20.snap b/.forge-snapshots/decreaseLiquidity_erc20.snap index db4b2042..ae013013 100644 --- a/.forge-snapshots/decreaseLiquidity_erc20.snap +++ b/.forge-snapshots/decreaseLiquidity_erc20.snap @@ -1 +1 @@ -187556 \ No newline at end of file +190026 \ No newline at end of file diff --git a/.forge-snapshots/decreaseLiquidity_erc6909.snap b/.forge-snapshots/decreaseLiquidity_erc6909.snap index 407e1fdc..4d5e683a 100644 --- a/.forge-snapshots/decreaseLiquidity_erc6909.snap +++ b/.forge-snapshots/decreaseLiquidity_erc6909.snap @@ -1 +1 @@ -166551 \ No newline at end of file +168894 \ No newline at end of file diff --git a/.forge-snapshots/increaseLiquidity_erc20.snap b/.forge-snapshots/increaseLiquidity_erc20.snap index b0e4e46d..4ea517e8 100644 --- a/.forge-snapshots/increaseLiquidity_erc20.snap +++ b/.forge-snapshots/increaseLiquidity_erc20.snap @@ -1 +1 @@ -183251 \ No newline at end of file +171241 \ No newline at end of file diff --git a/.forge-snapshots/increaseLiquidity_erc6909.snap b/.forge-snapshots/increaseLiquidity_erc6909.snap index 00ea1e2a..c2e421fa 100644 --- a/.forge-snapshots/increaseLiquidity_erc6909.snap +++ b/.forge-snapshots/increaseLiquidity_erc6909.snap @@ -1 +1 @@ -158833 \ No newline at end of file +146823 \ No newline at end of file diff --git a/.forge-snapshots/mintWithLiquidity.snap b/.forge-snapshots/mintWithLiquidity.snap index 140676d9..d2591995 100644 --- a/.forge-snapshots/mintWithLiquidity.snap +++ b/.forge-snapshots/mintWithLiquidity.snap @@ -1 +1 @@ -478540 \ No newline at end of file +466530 \ No newline at end of file diff --git a/contracts/NonfungiblePositionManager.sol b/contracts/NonfungiblePositionManager.sol index 8a74b2e4..a461d1db 100644 --- a/contracts/NonfungiblePositionManager.sol +++ b/contracts/NonfungiblePositionManager.sol @@ -34,7 +34,7 @@ contract NonfungiblePositionManager is INonfungiblePositionManager, BaseLiquidit constructor(IPoolManager _manager) BaseLiquidityManagement(_manager) - ERC721Permit("Uniswap V4 Positions NFT-V1", "UNI-V3-POS", "1") + ERC721Permit("Uniswap V4 Positions NFT-V1", "UNI-V4-POS", "1") {} // NOTE: more gas efficient as LiquidityAmounts is used offchain @@ -56,7 +56,7 @@ contract NonfungiblePositionManager is INonfungiblePositionManager, BaseLiquidit // NOTE: more expensive since LiquidityAmounts is used onchain // function mint(MintParams calldata params) external payable returns (uint256 tokenId, BalanceDelta delta) { - // (uint160 sqrtPriceX96,,,) = manager.getSlot0(params.range.key.toId()); + // (uint160 sqrtPriceX96,,,) = manager.getSlot0(params.range.poolKey.toId()); // (tokenId, delta) = mint( // params.range, // LiquidityAmounts.getLiquidityForAmounts( diff --git a/contracts/base/BaseLiquidityManagement.sol b/contracts/base/BaseLiquidityManagement.sol index 45542c93..bc9ab1da 100644 --- a/contracts/base/BaseLiquidityManagement.sol +++ b/contracts/base/BaseLiquidityManagement.sol @@ -22,6 +22,10 @@ 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 "forge-std/console2.sol"; contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { using LiquidityRangeIdLibrary for LiquidityRange; @@ -34,17 +38,30 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { using TransientStateLibrary for IPoolManager; using SafeCast for uint256; using LiquiditySaltLibrary for IHooks; + using PositionLibrary for IBaseLiquidityManagement.Position; + using BalanceDeltaExtensionLibrary for BalanceDelta; mapping(address owner => mapping(LiquidityRangeId rangeId => Position)) public positions; constructor(IPoolManager _manager) ImmutableState(_manager) {} - function zeroOut(BalanceDelta delta, Currency currency0, Currency currency1, address owner, bool claims) public { - if (delta.amount0() < 0) currency0.settle(manager, owner, uint256(int256(-delta.amount0())), claims); - else if (delta.amount0() > 0) currency0.send(manager, owner, uint128(delta.amount0()), claims); + function _closeCallerDeltas( + BalanceDelta callerDeltas, + Currency currency0, + Currency currency1, + address owner, + bool claims + ) internal { + int128 callerDelta0 = callerDeltas.amount0(); + int128 callerDelta1 = callerDeltas.amount1(); + // On liquidity increase, the deltas should never be > 0. + // We always 0 out a caller positive delta because it is instead accounted for in position.tokensOwed. - if (delta.amount1() < 0) currency1.settle(manager, owner, uint256(int256(-delta.amount1())), claims); - else if (delta.amount1() > 0) currency1.send(manager, owner, uint128(delta.amount1()), claims); + if (callerDelta0 < 0) currency0.settle(manager, owner, uint256(int256(-callerDelta0)), claims); + else if (callerDelta0 > 0) currency0.send(manager, owner, uint128(callerDelta0), claims); + + if (callerDelta1 < 0) currency1.settle(manager, owner, uint256(int256(-callerDelta1)), claims); + else if (callerDelta1 > 0) currency1.send(manager, owner, uint128(callerDelta1), claims); } function _unlockCallback(bytes calldata data) internal override returns (bytes memory) { @@ -73,62 +90,75 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { returns (BalanceDelta liquidityDelta, BalanceDelta totalFeesAccrued) { (liquidityDelta, totalFeesAccrued) = manager.modifyLiquidity( - range.key, + range.poolKey, IPoolManager.ModifyLiquidityParams({ tickLower: range.tickLower, tickUpper: range.tickUpper, liquidityDelta: liquidityChange, - salt: range.key.hooks.getLiquiditySalt(owner) + 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 - ) internal returns (BalanceDelta) { + ) internal returns (BalanceDelta callerDelta, BalanceDelta thisDelta) { // 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()]; - // Account for fees that were potentially collected to other users on the same range. - BalanceDelta callerFeesAccrued = _updateFeeGrowth(range, position); - BalanceDelta feesToCollect = totalFeesAccrued - callerFeesAccrued; - range.key.currency0.take(manager, address(this), uint128(feesToCollect.amount0()), true); - range.key.currency1.take(manager, address(this), uint128(feesToCollect.amount1()), true); + // 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. + (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = + manager.getFeeGrowthInside(range.poolKey.toId(), range.tickLower, range.tickUpper); + + BalanceDelta callerFeesAccrued = FeeMath.getFeesOwed( + feeGrowthInside0X128, + feeGrowthInside1X128, + position.feeGrowthInside0LastX128, + position.feeGrowthInside1LastX128, + position.liquidity + ); - // the delta applied from the above actions is liquidityDelta - feesToCollect, note that the actual total delta for the caller may be different because actions can be chained - BalanceDelta callerDelta = liquidityDelta - feesToCollect; + 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; - // update liquidity after feeGrowth is updated - position.liquidity += liquidityToAdd; + // 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; + } - // Update the tokensOwed0 and tokensOwed1 values for the caller. - // if callerDelta < 0, existing fees were re-invested AND net new tokens are required for the liquidity increase - // if callerDelta == 0, existing fees were reinvested (autocompounded) - // if callerDelta > 0, some but not all existing fees were used to increase liquidity. Any remainder is added to the position's owed tokens + // 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) { - position.tokensOwed0 += uint128(callerDelta.amount0()); - range.key.currency0.take(manager, address(this), uint128(callerDelta.amount0()), true); - callerDelta = toBalanceDelta(0, callerDelta.amount1()); - } else { - position.tokensOwed0 = 0; + (tokensOwed, callerDelta, thisDelta) = + _moveCallerDeltaToTokensOwed(true, tokensOwed, callerDelta, thisDelta); } if (callerDelta.amount1() > 0) { - position.tokensOwed1 += uint128(callerDelta.amount1()); - range.key.currency1.take(manager, address(this), uint128(callerDelta.amount1()), true); - callerDelta = toBalanceDelta(callerDelta.amount0(), 0); - } else { - position.tokensOwed1 = 0; + (tokensOwed, callerDelta, thisDelta) = + _moveCallerDeltaToTokensOwed(false, tokensOwed, callerDelta, thisDelta); } - return callerDelta; + position.addTokensOwed(tokensOwed); + position.addLiquidity(liquidityToAdd); + position.updateFeeGrowthInside(feeGrowthInside0X128, feeGrowthInside1X128); } function _increaseLiquidityAndZeroOut( @@ -137,9 +167,60 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { uint256 liquidityToAdd, bytes memory hookData, bool claims - ) internal returns (BalanceDelta delta) { - delta = _increaseLiquidity(owner, range, liquidityToAdd, hookData); - zeroOut(delta, range.key.currency0, range.key.currency1, owner, claims); + ) internal returns (BalanceDelta callerDelta) { + BalanceDelta thisDelta; + // TODO move callerDelta and thisDelta to transient storage? + (callerDelta, thisDelta) = _increaseLiquidity(owner, range, liquidityToAdd, hookData); + _closeCallerDeltas(callerDelta, range.poolKey.currency0, range.poolKey.currency1, owner, claims); + _closeThisDeltas(thisDelta, range.poolKey.currency0, range.poolKey.currency1); + } + + // When chaining many actions, this should be called at the very end to close out any open deltas owed to or by this contract for other users on the same range. + // This is safe because any amounts the caller should not pay or take have already been accounted for in closeCallerDeltas. + function _closeThisDeltas(BalanceDelta delta, Currency currency0, Currency currency1) internal { + int128 delta0 = delta.amount0(); + int128 delta1 = delta.amount1(); + + // Mint a receipt for the tokens owed to this address. + if (delta0 > 0) currency0.take(manager, address(this), uint128(delta0), true); + if (delta1 > 0) currency1.take(manager, address(this), uint128(delta1), true); + // Burn the receipt for tokens owed to this address. + if (delta0 < 0) currency0.settle(manager, address(this), uint256(int256(-delta0)), true); + if (delta1 < 0) currency1.settle(manager, address(this), uint256(int256(-delta1)), true); + } + + //TODO @sara deprecate when moving to _closeThisDeltas for decreaes and collect + function _closeAllDeltas(Currency currency0, Currency currency1) internal { + (BalanceDelta delta) = manager.currencyDeltas(address(this), currency0, currency1); + int128 delta0 = delta.amount0(); + int128 delta1 = delta.amount1(); + + // Mint a receipt for the tokens owed to this address. + if (delta0 > 0) currency0.take(manager, address(this), uint128(delta0), true); + if (delta1 > 0) currency1.take(manager, address(this), uint128(delta1), true); + // Burn the receipt for tokens owed to this address. + if (delta0 < 0) currency0.settle(manager, address(this), uint256(int256(-delta0)), true); + if (delta1 < 0) currency1.settle(manager, address(this), uint256(int256(-delta1)), true); + } + + function _moveCallerDeltaToTokensOwed( + bool useAmount0, + BalanceDelta tokensOwed, + BalanceDelta callerDelta, + BalanceDelta thisDelta + ) private 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); } function _lockAndIncreaseLiquidity( @@ -168,10 +249,10 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { // do NOT take tokens directly to the owner because this contract might be holding fees // that need to be paid out (position.tokensOwed) if (liquidityDelta.amount0() > 0) { - range.key.currency0.take(manager, address(this), uint128(liquidityDelta.amount0()), true); + range.poolKey.currency0.take(manager, address(this), uint128(liquidityDelta.amount0()), true); } if (liquidityDelta.amount1() > 0) { - range.key.currency1.take(manager, address(this), uint128(liquidityDelta.amount1()), true); + range.poolKey.currency1.take(manager, address(this), uint128(liquidityDelta.amount1()), true); } // when decreasing liquidity, the user collects: 1) principal liquidity, 2) new fees, 3) old fees (position.tokensOwed) @@ -200,7 +281,8 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { bool claims ) internal returns (BalanceDelta delta) { delta = _decreaseLiquidity(owner, range, liquidityToRemove, hookData); - zeroOut(delta, range.key.currency0, range.key.currency1, owner, claims); + _closeCallerDeltas(delta, range.poolKey.currency0, range.poolKey.currency1, owner, claims); + _closeAllDeltas(range.poolKey.currency0, range.poolKey.currency1); } function _lockAndDecreaseLiquidity( @@ -222,7 +304,7 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { { (, BalanceDelta totalFeesAccrued) = _modifyLiquidity(owner, range, 0, hookData); - PoolKey memory key = range.key; + PoolKey memory key = range.poolKey; Position storage position = positions[owner][range.toId()]; // take all fees first then distribute @@ -249,7 +331,8 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { returns (BalanceDelta delta) { delta = _collect(owner, range, hookData); - zeroOut(delta, range.key.currency0, range.key.currency1, owner, claims); + _closeCallerDeltas(delta, range.poolKey.currency0, range.poolKey.currency1, owner, claims); + _closeAllDeltas(range.poolKey.currency0, range.poolKey.currency1); } function _lockAndCollect(address owner, LiquidityRange memory range, bytes memory hookData, bool claims) @@ -261,21 +344,22 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { ); } + // TODO: I deprecated this bc I liked to see the accounting in line in the top level function... and I like to do all the position updates at once. + // can keep but should at at least use the position library in here. function _updateFeeGrowth(LiquidityRange memory range, Position storage position) internal returns (BalanceDelta _feesOwed) { (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = - manager.getFeeGrowthInside(range.key.toId(), range.tickLower, range.tickUpper); + manager.getFeeGrowthInside(range.poolKey.toId(), range.tickLower, range.tickUpper); - (uint128 token0Owed, uint128 token1Owed) = FeeMath.getFeesOwed( + _feesOwed = FeeMath.getFeesOwed( feeGrowthInside0X128, feeGrowthInside1X128, position.feeGrowthInside0LastX128, position.feeGrowthInside1LastX128, position.liquidity ); - _feesOwed = toBalanceDelta(uint256(token0Owed).toInt128(), uint256(token1Owed).toInt128()); position.feeGrowthInside0LastX128 = feeGrowthInside0X128; position.feeGrowthInside1LastX128 = feeGrowthInside1X128; @@ -290,15 +374,10 @@ contract BaseLiquidityManagement is IBaseLiquidityManagement, SafeCallback { Position memory position = positions[owner][range.toId()]; (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) = - manager.getFeeGrowthInside(range.key.toId(), range.tickLower, range.tickUpper); + manager.getFeeGrowthInside(range.poolKey.toId(), range.tickLower, range.tickUpper); - (token0Owed, token1Owed) = FeeMath.getFeesOwed( - feeGrowthInside0X128, - feeGrowthInside1X128, - position.feeGrowthInside0LastX128, - position.feeGrowthInside1LastX128, - position.liquidity - ); + (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/interfaces/IBaseLiquidityManagement.sol b/contracts/interfaces/IBaseLiquidityManagement.sol index 550f58c7..893d991e 100644 --- a/contracts/interfaces/IBaseLiquidityManagement.sol +++ b/contracts/interfaces/IBaseLiquidityManagement.sol @@ -27,15 +27,6 @@ interface IBaseLiquidityManagement { COLLECT } - /// @notice Zero-out outstanding deltas for the PoolManager - /// @dev To be called for batched operations where delta-zeroing happens once at the end of a sequence of operations - /// @param delta The amounts to zero out. Negatives are paid by the sender, positives are collected by the sender - /// @param currency0 The currency of the token0 - /// @param currency1 The currency of the token1 - /// @param user The user zero'ing the deltas. I.e. negative delta (debit) is paid by the user, positive delta (credit) is collected to the user - /// @param claims Whether deltas are zeroed with ERC-6909 claim tokens - function zeroOut(BalanceDelta delta, Currency currency0, Currency currency1, address user, bool claims) external; - /// @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 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/CurrencySenderLibrary.sol b/contracts/libraries/CurrencySenderLibrary.sol index ce343325..656a9439 100644 --- a/contracts/libraries/CurrencySenderLibrary.sol +++ b/contracts/libraries/CurrencySenderLibrary.sol @@ -23,8 +23,8 @@ library CurrencySenderLibrary { if (useClaims) { manager.transfer(recipient, currency.toId(), amount); } else { - currency.settle(manager, address(this), amount, true); - currency.take(manager, recipient, amount, false); + // currency.settle(manager, address(this), amount, true); // sends in tokens into PM from this address + currency.take(manager, recipient, amount, false); // takes out tokens from PM to recipient } } } diff --git a/contracts/libraries/FeeMath.sol b/contracts/libraries/FeeMath.sol index cf202dc2..9a459252 100644 --- a/contracts/libraries/FeeMath.sol +++ b/contracts/libraries/FeeMath.sol @@ -4,6 +4,7 @@ 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; @@ -14,9 +15,10 @@ library FeeMath { uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint256 liquidity - ) internal pure returns (uint128 token0Owed, uint128 token1Owed) { - token0Owed = getFeeOwed(feeGrowthInside0X128, feeGrowthInside0LastX128, liquidity); - token1Owed = getFeeOwed(feeGrowthInside1X128, feeGrowthInside1LastX128, 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) diff --git a/contracts/libraries/Position.sol b/contracts/libraries/Position.sol new file mode 100644 index 00000000..79cd02c0 --- /dev/null +++ b/contracts/libraries/Position.sol @@ -0,0 +1,30 @@ +// 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 { + // 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 addLiquidity(IBaseLiquidityManagement.Position storage position, uint256 liquidity) internal { + 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/types/LiquidityRange.sol b/contracts/types/LiquidityRange.sol index 4d00fb4b..4f664027 100644 --- a/contracts/types/LiquidityRange.sol +++ b/contracts/types/LiquidityRange.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.24; import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol"; struct LiquidityRange { - PoolKey key; + PoolKey poolKey; int24 tickLower; int24 tickUpper; } diff --git a/test/position-managers/FeeCollection.t.sol b/test/position-managers/FeeCollection.t.sol index a0b78ac0..643f6303 100644 --- a/test/position-managers/FeeCollection.t.sol +++ b/test/position-managers/FeeCollection.t.sol @@ -125,7 +125,7 @@ contract FeeCollectionTest is Test, Deployers, GasSnapshot, LiquidityFuzzers { liquidityDeltaBob = bound(liquidityDeltaBob, 100e18, 100_000e18); LiquidityRange memory range = - LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); vm.prank(alice); (tokenIdAlice,) = lpm.mint(range, uint256(params.liquidityDelta), block.timestamp + 1, alice, ZERO_BYTES); @@ -167,7 +167,7 @@ contract FeeCollectionTest is Test, Deployers, GasSnapshot, LiquidityFuzzers { liquidityDeltaBob = bound(liquidityDeltaBob, 100e18, 100_000e18); LiquidityRange memory range = - LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); vm.prank(alice); (tokenIdAlice,) = lpm.mint(range, uint256(params.liquidityDelta), block.timestamp + 1, alice, ZERO_BYTES); @@ -229,7 +229,7 @@ contract FeeCollectionTest is Test, Deployers, GasSnapshot, LiquidityFuzzers { liquidityDeltaBob = bound(liquidityDeltaBob, 100e18, 100_000e18); LiquidityRange memory range = - LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); vm.prank(alice); (tokenIdAlice,) = lpm.mint(range, uint256(params.liquidityDelta), block.timestamp + 1, alice, ZERO_BYTES); @@ -261,7 +261,7 @@ contract FeeCollectionTest is Test, Deployers, GasSnapshot, LiquidityFuzzers { /// when alice decreases liquidity, she should only collect her fees function test_decreaseLiquidity_sameRange_exact() public { // alice and bob create liquidity on the same range [-120, 120] - LiquidityRange memory range = LiquidityRange({key: key, tickLower: -120, tickUpper: 120}); + LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: -120, tickUpper: 120}); // alice provisions 3x the amount of liquidity as bob uint256 liquidityAlice = 3000e18; diff --git a/test/position-managers/Gas.t.sol b/test/position-managers/Gas.t.sol index 495d6f22..fe2005e2 100644 --- a/test/position-managers/Gas.t.sol +++ b/test/position-managers/Gas.t.sol @@ -56,13 +56,27 @@ contract GasTest is Test, Deployers, GasSnapshot { 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({key: key, tickLower: -300, tickUpper: 300}); + range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); } // function test_gas_mint() public { @@ -102,6 +116,119 @@ contract GasTest is Test, Deployers, GasSnapshot { 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); + (uint256 tokenIdAlice,) = lpm.mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + + // bob provides liquidity + vm.prank(bob); + lpm.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 + ); + + vm.prank(alice); + lpm.increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + 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); + (uint256 tokenIdAlice,) = lpm.mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + + // bob provides liquidity + vm.prank(bob); + (uint256 tokenIdBob,) = lpm.mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + + // donate to create fees + donateRouter.donate(key, 20e18, 20e18, ZERO_BYTES); + + // bob collects fees so some of alice's fees are now cached + vm.prank(bob); + lpm.collect(tokenIdBob, bob, ZERO_BYTES, false); + + // 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 + ); + + vm.prank(alice); + lpm.increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + 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); + (uint256 tokenIdAlice,) = lpm.mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + + // bob provides liquidity + vm.prank(bob); + (uint256 tokenIdBob,) = lpm.mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + + // 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 + ); + + vm.prank(alice); + lpm.increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + snapLastCall("autocompound_excessFeesCredit"); + } + function test_gas_decreaseLiquidity_erc20() public { (uint256 tokenId,) = lpm.mint(range, 10_000 ether, block.timestamp + 1, address(this), ZERO_BYTES); diff --git a/test/position-managers/IncreaseLiquidity.t.sol b/test/position-managers/IncreaseLiquidity.t.sol index c3863b9f..1fa62382 100644 --- a/test/position-managers/IncreaseLiquidity.t.sol +++ b/test/position-managers/IncreaseLiquidity.t.sol @@ -73,7 +73,7 @@ contract IncreaseLiquidityTest is Test, Deployers, GasSnapshot, Fuzzers { vm.stopPrank(); // define a reusable range - range = LiquidityRange({key: key, tickLower: -300, tickUpper: 300}); + range = LiquidityRange({poolKey: key, tickLower: -300, tickUpper: 300}); } function test_increaseLiquidity_withExactFees() public { @@ -99,7 +99,7 @@ contract IncreaseLiquidityTest is Test, Deployers, GasSnapshot, Fuzzers { // alice uses her exact fees to increase liquidity (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); - (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.key.toId()); + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, TickMath.getSqrtPriceAtTick(range.tickLower), @@ -108,10 +108,67 @@ contract IncreaseLiquidityTest is Test, Deployers, GasSnapshot, Fuzzers { token1Owed ); + uint256 balance0BeforeAlice = currency0.balanceOf(alice); + uint256 balance1BeforeAlice = currency1.balanceOf(alice); + vm.prank(alice); lpm.increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); - // TODO: assertions, currently increasing liquidity does not perfectly use the fees + // 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); + (uint256 tokenIdAlice,) = lpm.mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + + // bob provides liquidity + vm.prank(bob); + lpm.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.prank(alice); + lpm.increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + + // 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 { @@ -137,7 +194,7 @@ contract IncreaseLiquidityTest is Test, Deployers, GasSnapshot, Fuzzers { // alice will use half of her fees to increase liquidity (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); { - (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.key.toId()); + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, TickMath.getSqrtPriceAtTick(range.tickLower), @@ -214,7 +271,7 @@ contract IncreaseLiquidityTest is Test, Deployers, GasSnapshot, Fuzzers { // 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.key.toId()); + (uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, range.poolKey.toId()); uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts( sqrtPriceX96, TickMath.getSqrtPriceAtTick(range.tickLower), @@ -254,4 +311,137 @@ contract IncreaseLiquidityTest is Test, Deployers, GasSnapshot, Fuzzers { ); } } + + function test_increaseLiquidity_withExactFees_withExactCachedFees() 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); + (uint256 tokenIdAlice,) = lpm.mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + + // bob provides liquidity + vm.prank(bob); + (uint256 tokenIdBob,) = lpm.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 + + (uint256 token0Owed, uint256 token1Owed) = lpm.feesOwed(tokenIdAlice); + + // bob collects fees so some of alice's fees are now cached + vm.prank(bob); + lpm.collect(tokenIdBob, bob, ZERO_BYTES, false); + + // 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.prank(alice); + lpm.increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + } + + // 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); + (uint256 tokenIdAlice,) = lpm.mint(range, liquidityAlice, block.timestamp + 1, alice, ZERO_BYTES); + + // bob provides liquidity + vm.prank(bob); + (uint256 tokenIdBob,) = lpm.mint(range, liquidityBob, block.timestamp + 1, bob, ZERO_BYTES); + + // 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.prank(bob); + lpm.collect(tokenIdBob, bob, ZERO_BYTES, false); + + // 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.prank(alice); + lpm.increaseLiquidity(tokenIdAlice, liquidityDelta, ZERO_BYTES, false); + } + + // 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.prank(bob); + BalanceDelta result = lpm.collect(tokenIdBob, bob, ZERO_BYTES, false); + 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 index 4f9a74dc..c1cad0c1 100644 --- a/test/position-managers/NonfungiblePositionManager.t.sol +++ b/test/position-managers/NonfungiblePositionManager.t.sol @@ -52,7 +52,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi function test_mint_withLiquidityDelta(IPoolManager.ModifyLiquidityParams memory params) public { params = createFuzzyLiquidityParams(key, params, SQRT_PRICE_1_1); LiquidityRange memory range = - LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); uint256 balance0Before = currency0.balanceOfSelf(); uint256 balance1Before = currency1.balanceOfSelf(); @@ -74,7 +74,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi // (amount0Desired, amount1Desired) = // createFuzzyAmountDesired(key, tickLower, tickUpper, amount0Desired, amount1Desired); - // LiquidityRange memory range = LiquidityRange({key: key, tickLower: tickLower, tickUpper: tickUpper}); + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); // uint256 balance0Before = currency0.balanceOfSelf(); // uint256 balance1Before = currency1.balanceOfSelf(); @@ -104,7 +104,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi // int24 tickUpper = int24(key.tickSpacing); // uint256 amount0Desired = 100e18; // uint256 amount1Desired = 100e18; - // LiquidityRange memory range = LiquidityRange({key: key, tickLower: tickLower, tickUpper: tickUpper}); + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); // uint256 balance0Before = currency0.balanceOfSelf(); // uint256 balance1Before = currency1.balanceOfSelf(); @@ -137,7 +137,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi // (amount0Desired, amount1Desired) = // createFuzzyAmountDesired(key, tickLower, tickUpper, amount0Desired, amount1Desired); - // LiquidityRange memory range = LiquidityRange({key: key, tickLower: tickLower, tickUpper: tickUpper}); + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); // INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({ // range: range, // amount0Desired: amount0Desired, @@ -167,7 +167,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi // uint256 amount0Min = amount0Desired - 1; // uint256 amount1Min = amount1Desired - 1; - // LiquidityRange memory range = LiquidityRange({key: key, tickLower: tickLower, tickUpper: tickUpper}); + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: tickLower, tickUpper: tickUpper}); // INonfungiblePositionManager.MintParams memory params = INonfungiblePositionManager.MintParams({ // range: range, // amount0Desired: amount0Desired, @@ -207,7 +207,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi uint256 tokenId; (tokenId, params,) = createFuzzyLiquidity(lpm, address(this), key, params, SQRT_PRICE_1_1, ZERO_BYTES); LiquidityRange memory range = - LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + 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()); @@ -243,7 +243,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi vm.assume(int256(decreaseLiquidityDelta) <= params.liquidityDelta); LiquidityRange memory range = - LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); uint256 balance0Before = currency0.balanceOfSelf(); uint256 balance1Before = currency1.balanceOfSelf(); @@ -267,7 +267,7 @@ contract NonfungiblePositionManagerTest is Test, Deployers, GasSnapshot, Liquidi // vm.assume(decreaseLiquidityDelta < uint256(type(int256).max)); // vm.assume(int256(decreaseLiquidityDelta) <= params.liquidityDelta); - // LiquidityRange memory range = LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); + // LiquidityRange memory range = LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}); // // swap to create fees // uint256 swapAmount = 0.01e18; diff --git a/test/shared/fuzz/LiquidityFuzzers.sol b/test/shared/fuzz/LiquidityFuzzers.sol index 03e50f9b..cc401555 100644 --- a/test/shared/fuzz/LiquidityFuzzers.sol +++ b/test/shared/fuzz/LiquidityFuzzers.sol @@ -21,7 +21,7 @@ contract LiquidityFuzzers is Fuzzers { params = Fuzzers.createFuzzyLiquidityParams(key, params, sqrtPriceX96); (uint256 tokenId, BalanceDelta delta) = lpm.mint( - LiquidityRange({key: key, tickLower: params.tickLower, tickUpper: params.tickUpper}), + LiquidityRange({poolKey: key, tickLower: params.tickLower, tickUpper: params.tickUpper}), uint256(params.liquidityDelta), block.timestamp, recipient,