Files
v4-periphery/test/position-managers/PositionManager.modifyLiquidities.t.sol

983 lines
41 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {IERC20} from "forge-std/interfaces/IERC20.sol";
import {IERC721} from "forge-std/interfaces/IERC721.sol";
import {CustomRevert} from "@luxamm/v4-core/src/libraries/CustomRevert.sol";
import {PoolManager} from "@luxamm/v4-core/src/PoolManager.sol";
import {IPoolManager} from "@luxamm/v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "@luxamm/v4-core/src/interfaces/IHooks.sol";
import {Hooks} from "@luxamm/v4-core/src/libraries/Hooks.sol";
import {Currency, CurrencyLibrary} from "@luxamm/v4-core/src/types/Currency.sol";
import {PoolId} from "@luxamm/v4-core/src/types/PoolId.sol";
import {PoolKey} from "@luxamm/v4-core/src/types/PoolKey.sol";
import {BalanceDelta} from "@luxamm/v4-core/src/types/BalanceDelta.sol";
import {StateLibrary} from "@luxamm/v4-core/src/libraries/StateLibrary.sol";
import {Position} from "@luxamm/v4-core/src/libraries/Position.sol";
import {SafeCast} from "@luxamm/v4-core/src/libraries/SafeCast.sol";
import {LiquidityAmounts} from "@luxamm/v4-core/test/utils/LiquidityAmounts.sol";
import {TickMath} from "@luxamm/v4-core/src/libraries/TickMath.sol";
import {Pool} from "@luxamm/v4-core/src/libraries/Pool.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {IPositionManager} from "../../src/interfaces/IPositionManager.sol";
import {IMulticall_v4} from "../../src/interfaces/IMulticall_v4.sol";
import {ReentrancyLock} from "../../src/base/ReentrancyLock.sol";
import {Actions} from "../../src/libraries/Actions.sol";
import {PositionConfig} from "../shared/PositionConfig.sol";
import {BipsLibrary} from "../../src/libraries/BipsLibrary.sol";
import {LiquidityFuzzers} from "../shared/fuzz/LiquidityFuzzers.sol";
import {Planner, Plan} from "../shared/Planner.sol";
import {PosmTestSetup} from "../shared/PosmTestSetup.sol";
import {ActionConstants} from "../../src/libraries/ActionConstants.sol";
import {DeltaResolver} from "../../src/base/DeltaResolver.sol";
import {MockFOT} from "../mocks/MockFeeOnTransfer.sol";
contract PositionManagerModifyLiquiditiesTest is Test, PosmTestSetup, LiquidityFuzzers {
using StateLibrary for IPoolManager;
using BipsLibrary for uint256;
PoolId poolId;
address alice;
uint256 alicePK;
address bob;
PoolKey fotKey;
PositionConfig config;
PositionConfig wethConfig;
PositionConfig nativeConfig;
PositionConfig fotConfig;
MockERC20 fotToken;
function setUp() public {
(alice, alicePK) = makeAddrAndKey("ALICE");
(bob,) = makeAddrAndKey("BOB");
deployFreshManagerAndRouters();
deployMintAndApprove2Currencies();
// Requires currency0 and currency1 to be set in base Deployers contract.
deployAndApprovePosm(manager);
seedBalance(alice);
approvePosmFor(alice);
// must deploy after posm
// Deploys a hook which can accesses IPositionManager.modifyLiquiditiesWithoutUnlock
deployPosmHookModifyLiquidities();
seedBalance(address(hookModifyLiquidities));
(key, poolId) = initPool(currency0, currency1, IHooks(hookModifyLiquidities), 3000, SQRT_PRICE_1_1);
wethKey = initPoolUnsorted(Currency.wrap(address(_WETH9)), currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1);
seedWeth(address(this));
approvePosmCurrency(Currency.wrap(address(_WETH9)));
nativeKey = PoolKey(CurrencyLibrary.ADDRESS_ZERO, currency1, 3000, 60, IHooks(address(0)));
manager.initialize(nativeKey, SQRT_PRICE_1_1);
config = PositionConfig({poolKey: key, tickLower: -60, tickUpper: 60});
wethConfig = PositionConfig({
poolKey: wethKey,
tickLower: TickMath.minUsableTick(wethKey.tickSpacing),
tickUpper: TickMath.maxUsableTick(wethKey.tickSpacing)
});
nativeConfig = PositionConfig({poolKey: nativeKey, tickLower: -120, tickUpper: 120});
vm.deal(address(this), 1000 ether);
fotToken = new MockFOT(lpm);
approvePosmCurrency(Currency.wrap(address(fotToken)));
seedToken(fotToken, address(this));
fotKey = initPoolUnsorted(Currency.wrap(address(fotToken)), currency1, IHooks(address(0)), 3000, SQRT_PRICE_1_1);
}
/// @dev minting liquidity without approval is allowable
function test_hook_mint() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// hook mints a new position in beforeSwap via hookData
uint256 hookTokenId = lpm.nextTokenId();
uint256 newLiquidity = 10e18;
bytes memory calls = getMintEncoded(config, newLiquidity, address(hookModifyLiquidities), ZERO_BYTES);
swap(key, true, -1e18, calls);
uint256 liquidity = lpm.getPositionLiquidity(tokenId);
// original liquidity unchanged
assertEq(liquidity, initialLiquidity);
// hook minted its own position
liquidity = lpm.getPositionLiquidity(hookTokenId);
assertEq(liquidity, newLiquidity);
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this)); // original position owned by this contract
assertEq(IERC721(address(lpm)).ownerOf(hookTokenId), address(hookModifyLiquidities)); // hook position owned by hook
}
/// @dev hook must be approved to increase liquidity
function test_hook_increaseLiquidity() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// approve the hook for increasing liquidity
IERC721(address(lpm)).approve(address(hookModifyLiquidities), tokenId);
// hook increases liquidity in beforeSwap via hookData
uint256 newLiquidity = 10e18;
bytes memory calls = getIncreaseEncoded(tokenId, config, newLiquidity, ZERO_BYTES);
swap(key, true, -1e18, calls);
uint256 liquidity = lpm.getPositionLiquidity(tokenId);
assertEq(liquidity, initialLiquidity + newLiquidity);
}
/// @dev hook can decrease liquidity with approval
function test_hook_decreaseLiquidity() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// approve the hook for decreasing liquidity
IERC721(address(lpm)).approve(address(hookModifyLiquidities), tokenId);
// hook decreases liquidity in beforeSwap via hookData
uint256 liquidityToDecrease = 10e18;
bytes memory calls = getDecreaseEncoded(tokenId, config, liquidityToDecrease, ZERO_BYTES);
swap(key, true, -1e18, calls);
uint256 liquidity = lpm.getPositionLiquidity(tokenId);
assertEq(liquidity, initialLiquidity - liquidityToDecrease);
}
/// @dev hook can collect liquidity with approval
function test_hook_collect() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// approve the hook for collecting liquidity
IERC721(address(lpm)).approve(address(hookModifyLiquidities), tokenId);
// donate to generate revenue
uint256 feeRevenue0 = 1e18;
uint256 feeRevenue1 = 0.1e18;
donateRouter.donate(config.poolKey, feeRevenue0, feeRevenue1, ZERO_BYTES);
uint256 balance0HookBefore = currency0.balanceOf(address(hookModifyLiquidities));
uint256 balance1HookBefore = currency1.balanceOf(address(hookModifyLiquidities));
// hook collects liquidity in beforeSwap via hookData
bytes memory calls = getCollectEncoded(tokenId, config, ZERO_BYTES);
swap(key, true, -1e18, calls);
uint256 liquidity = lpm.getPositionLiquidity(tokenId);
// liquidity unchanged
assertEq(liquidity, initialLiquidity);
// hook collected the fee revenue
assertEq(currency0.balanceOf(address(hookModifyLiquidities)), balance0HookBefore + feeRevenue0 - 1 wei); // imprecision, core is keeping 1 wei
assertEq(currency1.balanceOf(address(hookModifyLiquidities)), balance1HookBefore + feeRevenue1 - 1 wei);
}
/// @dev hook can burn liquidity with approval
function test_hook_burn() public {
// mint some liquidity that is NOT burned in beforeSwap
mint(config, 100e18, address(this), ZERO_BYTES);
// the position to be burned by the hook
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// TODO: make this less jank since HookModifyLiquidites also has delta saving capabilities
// BalanceDelta mintDelta = getLastDelta();
BalanceDelta mintDelta = hookModifyLiquidities.deltas(hookModifyLiquidities.numberDeltasReturned() - 1);
// approve the hook for burning liquidity
IERC721(address(lpm)).approve(address(hookModifyLiquidities), tokenId);
uint256 balance0HookBefore = currency0.balanceOf(address(hookModifyLiquidities));
uint256 balance1HookBefore = currency1.balanceOf(address(hookModifyLiquidities));
// hook burns liquidity in beforeSwap via hookData
bytes memory calls = getBurnEncoded(tokenId, config, ZERO_BYTES);
swap(key, true, -1e18, calls);
uint256 liquidity = lpm.getPositionLiquidity(tokenId);
// liquidity burned
assertEq(liquidity, 0);
// 721 will revert if the token does not exist
vm.expectRevert();
IERC721(address(lpm)).ownerOf(tokenId);
// hook claimed the burned liquidity
assertEq(
currency0.balanceOf(address(hookModifyLiquidities)),
balance0HookBefore + uint128(-mintDelta.amount0() - 1 wei) // imprecision since core is keeping 1 wei
);
assertEq(
currency1.balanceOf(address(hookModifyLiquidities)),
balance1HookBefore + uint128(-mintDelta.amount1() - 1 wei)
);
}
// --- Revert Scenarios --- //
/// @dev Hook does not have approval so increasing liquidity should revert
function test_hook_increaseLiquidity_revert() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// hook decreases liquidity in beforeSwap via hookData
uint256 liquidityToAdd = 10e18;
bytes memory calls = getIncreaseEncoded(tokenId, config, liquidityToAdd, ZERO_BYTES);
// should revert because hook is not approved
vm.expectRevert(
abi.encodeWithSelector(
CustomRevert.WrappedError.selector,
address(hookModifyLiquidities),
IHooks.beforeSwap.selector,
abi.encodeWithSelector(IPositionManager.NotApproved.selector, address(hookModifyLiquidities)),
abi.encodeWithSelector(Hooks.HookCallFailed.selector)
)
);
swap(key, true, -1e18, calls);
}
/// @dev Hook does not have approval so decreasing liquidity should revert
function test_hook_decreaseLiquidity_revert() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// hook decreases liquidity in beforeSwap via hookData
uint256 liquidityToDecrease = 10e18;
bytes memory calls = getDecreaseEncoded(tokenId, config, liquidityToDecrease, ZERO_BYTES);
// should revert because hook is not approved
vm.expectRevert(
abi.encodeWithSelector(
CustomRevert.WrappedError.selector,
address(hookModifyLiquidities),
IHooks.beforeSwap.selector,
abi.encodeWithSelector(IPositionManager.NotApproved.selector, address(hookModifyLiquidities)),
abi.encodeWithSelector(Hooks.HookCallFailed.selector)
)
);
swap(key, true, -1e18, calls);
}
/// @dev hook does not have approval so collecting liquidity should revert
function test_hook_collect_revert() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// donate to generate revenue
uint256 feeRevenue0 = 1e18;
uint256 feeRevenue1 = 0.1e18;
donateRouter.donate(config.poolKey, feeRevenue0, feeRevenue1, ZERO_BYTES);
// hook collects liquidity in beforeSwap via hookData
bytes memory calls = getCollectEncoded(tokenId, config, ZERO_BYTES);
// should revert because hook is not approved
vm.expectRevert(
abi.encodeWithSelector(
CustomRevert.WrappedError.selector,
address(hookModifyLiquidities),
IHooks.beforeSwap.selector,
abi.encodeWithSelector(IPositionManager.NotApproved.selector, address(hookModifyLiquidities)),
abi.encodeWithSelector(Hooks.HookCallFailed.selector)
)
);
swap(key, true, -1e18, calls);
}
/// @dev hook does not have approval so burning liquidity should revert
function test_hook_burn_revert() public {
// the position to be burned by the hook
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
// hook burns liquidity in beforeSwap via hookData
bytes memory calls = getBurnEncoded(tokenId, config, ZERO_BYTES);
// should revert because hook is not approved
vm.expectRevert(
abi.encodeWithSelector(
CustomRevert.WrappedError.selector,
address(hookModifyLiquidities),
IHooks.beforeSwap.selector,
abi.encodeWithSelector(IPositionManager.NotApproved.selector, address(hookModifyLiquidities)),
abi.encodeWithSelector(Hooks.HookCallFailed.selector)
)
);
swap(key, true, -1e18, calls);
}
/// @dev hook cannot re-enter modifyLiquiditiesWithoutUnlock in beforeRemoveLiquidity
function test_hook_increaseLiquidity_reenter_revert() public {
uint256 initialLiquidity = 100e18;
uint256 tokenId = lpm.nextTokenId();
mint(config, initialLiquidity, address(this), ZERO_BYTES);
uint256 newLiquidity = 10e18;
// to be provided as hookData, so beforeAddLiquidity attempts to increase liquidity
bytes memory hookCall = getIncreaseEncoded(tokenId, config, newLiquidity, ZERO_BYTES);
bytes memory calls = getIncreaseEncoded(tokenId, config, newLiquidity, hookCall);
// should revert because hook is re-entering modifyLiquiditiesWithoutUnlock
vm.expectRevert(
abi.encodeWithSelector(
CustomRevert.WrappedError.selector,
address(hookModifyLiquidities),
IHooks.beforeAddLiquidity.selector,
abi.encodeWithSelector(ReentrancyLock.ContractLocked.selector),
abi.encodeWithSelector(Hooks.HookCallFailed.selector)
)
);
lpm.modifyLiquidities(calls, _deadline);
}
function test_wrap_mint_usingContractBalance() public {
// weth-currency1 pool initialized as wethKey
// input: eth, currency1
// modifyLiquidities call to mint liquidity weth and currency1
// 1 _wrap with contract balance
// 2 _mint
// 3 _settle weth where the payer is the contract
// 4 _close currency1, payer is caller
// 5 _sweep weth since eth was entirely wrapped
uint256 balanceEthBefore = address(this).balance;
uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
uint256 tokenId = lpm.nextTokenId();
uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(wethConfig.tickLower),
TickMath.getSqrtPriceAtTick(wethConfig.tickUpper),
100 ether,
100 ether
);
Plan memory planner = Planner.init();
planner.add(Actions.WRAP, abi.encode(ActionConstants.CONTRACT_BALANCE));
planner.add(
Actions.MINT_POSITION,
abi.encode(
wethConfig.poolKey,
wethConfig.tickLower,
wethConfig.tickUpper,
liquidityAmount,
MAX_SLIPPAGE_INCREASE,
MAX_SLIPPAGE_INCREASE,
ActionConstants.MSG_SENDER,
ZERO_BYTES
)
);
// weth9 payer is the contract
planner.add(Actions.SETTLE, abi.encode(address(_WETH9), ActionConstants.OPEN_DELTA, false));
// other currency can close normally
planner.add(Actions.CLOSE_CURRENCY, abi.encode(currency1));
// we wrapped the full contract balance so we sweep back in the wrapped currency
planner.add(Actions.SWEEP, abi.encode(address(_WETH9), ActionConstants.MSG_SENDER));
bytes memory actions = planner.encode();
// Overestimate eth amount.
lpm.modifyLiquidities{value: 102 ether}(actions, _deadline);
uint256 balanceEthAfter = address(this).balance;
uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
// The full eth amount was "spent" because some was wrapped into weth and refunded.
assertApproxEqAbs(balanceEthBefore - balanceEthAfter, 102 ether, 1 wei);
assertApproxEqAbs(balance1Before - balance1After, 100 ether, 1 wei);
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this));
assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount);
assertEq(_WETH9.balanceOf(address(lpm)), 0);
assertEq(address(lpm).balance, 0);
}
function test_wrap_mint_openDelta() public {
// weth-currency1 pool initialized as wethKey
// input: eth, currency1
// modifyLiquidities call to mint liquidity weth and currency1
// 1 _mint
// 2 _wrap with open delta
// 3 _settle weth where the payer is the contract
// 4 _close currency1, payer is caller
// 5 _sweep eth since only the open delta amount was wrapped
uint256 balanceEthBefore = address(this).balance;
uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
uint256 tokenId = lpm.nextTokenId();
uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(wethConfig.tickLower),
TickMath.getSqrtPriceAtTick(wethConfig.tickUpper),
100 ether,
100 ether
);
Plan memory planner = Planner.init();
planner.add(
Actions.MINT_POSITION,
abi.encode(
wethConfig.poolKey,
wethConfig.tickLower,
wethConfig.tickUpper,
liquidityAmount,
MAX_SLIPPAGE_INCREASE,
MAX_SLIPPAGE_INCREASE,
ActionConstants.MSG_SENDER,
ZERO_BYTES
)
);
planner.add(Actions.WRAP, abi.encode(ActionConstants.OPEN_DELTA));
// weth9 payer is the contract
planner.add(Actions.SETTLE, abi.encode(address(_WETH9), ActionConstants.OPEN_DELTA, false));
// other currency can close normally
planner.add(Actions.CLOSE_CURRENCY, abi.encode(currency1));
// we wrapped the open delta balance so we sweep back in the native currency
planner.add(Actions.SWEEP, abi.encode(CurrencyLibrary.ADDRESS_ZERO, ActionConstants.MSG_SENDER));
bytes memory actions = planner.encode();
lpm.modifyLiquidities{value: 102 ether}(actions, _deadline);
uint256 balanceEthAfter = address(this).balance;
uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
// Approx 100 eth was spent because the extra 2 were refunded.
assertApproxEqAbs(balanceEthBefore - balanceEthAfter, 100 ether, 1 wei);
assertApproxEqAbs(balance1Before - balance1After, 100 ether, 1 wei);
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this));
assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount);
assertEq(_WETH9.balanceOf(address(lpm)), 0);
assertEq(address(lpm).balance, 0);
}
function test_wrap_mint_usingExactAmount() public {
// weth-currency1 pool initialized as wethKey
// input: eth, currency1
// modifyLiquidities call to mint liquidity weth and currency1
// 1 _wrap with an amount
// 2 _mint
// 3 _settle weth where the payer is the contract
// 4 _close currency1, payer is caller
// 5 _sweep weth since eth was entirely wrapped
uint256 balanceEthBefore = address(this).balance;
uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
uint256 tokenId = lpm.nextTokenId();
uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(wethConfig.tickLower),
TickMath.getSqrtPriceAtTick(wethConfig.tickUpper),
100 ether,
100 ether
);
Plan memory planner = Planner.init();
planner.add(Actions.WRAP, abi.encode(100 ether));
planner.add(
Actions.MINT_POSITION,
abi.encode(
wethConfig.poolKey,
wethConfig.tickLower,
wethConfig.tickUpper,
liquidityAmount,
MAX_SLIPPAGE_INCREASE,
MAX_SLIPPAGE_INCREASE,
ActionConstants.MSG_SENDER,
ZERO_BYTES
)
);
// weth9 payer is the contract
planner.add(Actions.SETTLE, abi.encode(address(_WETH9), ActionConstants.OPEN_DELTA, false));
// other currency can close normally
planner.add(Actions.CLOSE_CURRENCY, abi.encode(currency1));
// we wrapped all 100 eth so we sweep back in the wrapped currency for safety measure
planner.add(Actions.SWEEP, abi.encode(address(_WETH9), ActionConstants.MSG_SENDER));
bytes memory actions = planner.encode();
lpm.modifyLiquidities{value: 100 ether}(actions, _deadline);
uint256 balanceEthAfter = address(this).balance;
uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
// The full eth amount was "spent" because some was wrapped into weth and refunded.
assertApproxEqAbs(balanceEthBefore - balanceEthAfter, 100 ether, 1 wei);
assertApproxEqAbs(balance1Before - balance1After, 100 ether, 1 wei);
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this));
assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount);
assertEq(_WETH9.balanceOf(address(lpm)), 0);
assertEq(address(lpm).balance, 0);
}
function test_wrap_mint_revertsInsufficientBalance() public {
// 1 _wrap with more eth than is sent in
Plan memory planner = Planner.init();
// Wrap more eth than what is sent in.
planner.add(Actions.WRAP, abi.encode(101 ether));
bytes memory actions = planner.encode();
vm.expectRevert(DeltaResolver.InsufficientBalance.selector);
lpm.modifyLiquidities{value: 100 ether}(actions, _deadline);
}
function test_unwrap_usingContractBalance() public {
// weth-currency1 pool
// output: eth, currency1
// modifyLiquidities call to mint liquidity weth and currency1
// 1 _burn
// 2 _take where the weth is sent to the lpm contract
// 3 _take where currency1 is sent to the msg sender
// 4 _unwrap using contract balance
// 5 _sweep where eth is sent to msg sender
uint256 tokenId = lpm.nextTokenId();
uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(wethConfig.tickLower),
TickMath.getSqrtPriceAtTick(wethConfig.tickUpper),
100 ether,
100 ether
);
bytes memory actions = getMintEncoded(wethConfig, liquidityAmount, address(this), ZERO_BYTES);
lpm.modifyLiquidities(actions, _deadline);
assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount);
uint256 balanceEthBefore = address(this).balance;
uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
Plan memory planner = Planner.init();
planner.add(
Actions.BURN_POSITION, abi.encode(tokenId, MIN_SLIPPAGE_DECREASE, MIN_SLIPPAGE_DECREASE, ZERO_BYTES)
);
// take the weth to the position manager to be unwrapped
planner.add(Actions.TAKE, abi.encode(address(_WETH9), ActionConstants.ADDRESS_THIS, ActionConstants.OPEN_DELTA));
planner.add(
Actions.TAKE,
abi.encode(address(Currency.unwrap(currency1)), ActionConstants.MSG_SENDER, ActionConstants.OPEN_DELTA)
);
planner.add(Actions.UNWRAP, abi.encode(ActionConstants.CONTRACT_BALANCE));
planner.add(Actions.SWEEP, abi.encode(CurrencyLibrary.ADDRESS_ZERO, ActionConstants.MSG_SENDER));
actions = planner.encode();
lpm.modifyLiquidities(actions, _deadline);
uint256 balanceEthAfter = address(this).balance;
uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
assertApproxEqAbs(balanceEthAfter - balanceEthBefore, 100 ether, 1 wei);
assertApproxEqAbs(balance1After - balance1Before, 100 ether, 1 wei);
assertEq(lpm.getPositionLiquidity(tokenId), 0);
assertEq(_WETH9.balanceOf(address(lpm)), 0);
assertEq(address(lpm).balance, 0);
}
function test_unwrap_openDelta_reinvest() public {
// weth-currency1 pool rolls half to eth-currency1 pool
// output: eth, currency1
// modifyLiquidities call to mint liquidity weth and currency1
// 1 _burn (weth-currency1)
// 2 _take where the weth is sent to the lpm contract
// 4 _mint to an eth pool
// 4 _unwrap using open delta (pool managers ETH balance)
// 3 _take where leftover currency1 is sent to the msg sender
// 5 _settle eth open delta
// 5 _sweep leftover weth
uint256 tokenId = lpm.nextTokenId();
uint128 liquidityAmount = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(wethConfig.tickLower),
TickMath.getSqrtPriceAtTick(wethConfig.tickUpper),
100 ether,
100 ether
);
bytes memory actions = getMintEncoded(wethConfig, liquidityAmount, address(this), ZERO_BYTES);
lpm.modifyLiquidities(actions, _deadline);
assertEq(lpm.getPositionLiquidity(tokenId), liquidityAmount);
uint256 balanceEthBefore = address(this).balance;
uint256 balance1Before = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
uint256 balanceWethBefore = _WETH9.balanceOf(address(this));
uint128 newLiquidityAmount = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(nativeConfig.tickLower),
TickMath.getSqrtPriceAtTick(nativeConfig.tickUpper),
50 ether,
50 ether
);
Plan memory planner = Planner.init();
planner.add(
Actions.BURN_POSITION, abi.encode(tokenId, MIN_SLIPPAGE_DECREASE, MIN_SLIPPAGE_DECREASE, ZERO_BYTES)
);
// take the weth to the position manager to be unwrapped
planner.add(Actions.TAKE, abi.encode(address(_WETH9), ActionConstants.ADDRESS_THIS, ActionConstants.OPEN_DELTA));
planner.add(
Actions.MINT_POSITION,
abi.encode(
nativeConfig.poolKey,
nativeConfig.tickLower,
nativeConfig.tickUpper,
newLiquidityAmount,
MAX_SLIPPAGE_INCREASE,
MAX_SLIPPAGE_INCREASE,
ActionConstants.MSG_SENDER,
ZERO_BYTES
)
);
planner.add(Actions.UNWRAP, abi.encode(ActionConstants.OPEN_DELTA));
// pay the eth
planner.add(Actions.SETTLE, abi.encode(CurrencyLibrary.ADDRESS_ZERO, ActionConstants.OPEN_DELTA, false));
// take the leftover currency1
planner.add(
Actions.TAKE,
abi.encode(address(Currency.unwrap(currency1)), ActionConstants.MSG_SENDER, ActionConstants.OPEN_DELTA)
);
planner.add(Actions.SWEEP, abi.encode(address(_WETH9), ActionConstants.MSG_SENDER));
actions = planner.encode();
lpm.modifyLiquidities(actions, _deadline);
uint256 balanceEthAfter = address(this).balance;
uint256 balance1After = IERC20(Currency.unwrap(currency1)).balanceOf(address(this));
uint256 balanceWethAfter = _WETH9.balanceOf(address(this));
// Eth balance should not change.
assertEq(balanceEthAfter, balanceEthBefore);
// Only half of the original liquidity was reinvested.
assertApproxEqAbs(balance1After - balance1Before, 50 ether, 1 wei);
assertApproxEqAbs(balanceWethAfter - balanceWethBefore, 50 ether, 1 wei);
assertEq(lpm.getPositionLiquidity(tokenId), 0);
assertEq(_WETH9.balanceOf(address(lpm)), 0);
assertEq(address(lpm).balance, 0);
}
function test_unwrap_revertsInsufficientBalance() public {
// 1 _unwrap with more than is in the contract
Plan memory planner = Planner.init();
// unwraps more eth than what is in the contract
planner.add(Actions.UNWRAP, abi.encode(101 ether));
bytes memory actions = planner.encode();
vm.expectRevert(DeltaResolver.InsufficientBalance.selector);
lpm.modifyLiquidities(actions, _deadline);
}
function test_mintFromDeltas_fot() public {
// Use a 1% fee.
MockFOT(address(fotToken)).setFee(100);
uint256 tokenId = lpm.nextTokenId();
uint256 fotBalanceBefore = Currency.wrap(address(fotToken)).balanceOf(address(this));
uint256 amountAfterTransfer = 990e18;
uint256 amountToSendFot = 1000e18;
(uint256 amount0, uint256 amount1) = fotKey.currency0 == Currency.wrap(address(fotToken))
? (amountToSendFot, amountAfterTransfer)
: (amountAfterTransfer, amountToSendFot);
// Calculcate the expected liquidity from the amounts after the transfer. They are the same for both currencies.
uint256 expectedLiquidity = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(LIQUIDITY_PARAMS.tickLower),
TickMath.getSqrtPriceAtTick(LIQUIDITY_PARAMS.tickUpper),
amountAfterTransfer,
amountAfterTransfer
);
Plan memory planner = Planner.init();
planner.add(Actions.SETTLE, abi.encode(fotKey.currency0, amount0, true));
planner.add(Actions.SETTLE, abi.encode(fotKey.currency1, amount1, true));
planner.add(
Actions.MINT_POSITION_FROM_DELTAS,
abi.encode(
fotKey,
LIQUIDITY_PARAMS.tickLower,
LIQUIDITY_PARAMS.tickUpper,
MAX_SLIPPAGE_INCREASE,
MAX_SLIPPAGE_INCREASE,
ActionConstants.MSG_SENDER,
ZERO_BYTES
)
);
bytes memory plan = planner.encode();
lpm.modifyLiquidities(plan, _deadline);
uint256 fotBalanceAfter = Currency.wrap(address(fotToken)).balanceOf(address(this));
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this));
assertEq(lpm.getPositionLiquidity(tokenId), expectedLiquidity);
assertEq(fotBalanceBefore - fotBalanceAfter, 1000e18);
}
function test_increaseFromDeltas() public {
uint128 initialLiquidity = 1000e18;
uint256 tokenId = lpm.nextTokenId();
fotConfig = PositionConfig({poolKey: fotKey, tickLower: -120, tickUpper: 120});
mint(fotConfig, initialLiquidity, address(this), ZERO_BYTES);
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this));
assertEq(lpm.getPositionLiquidity(tokenId), initialLiquidity);
Plan memory planner = Planner.init();
planner.add(Actions.SETTLE, abi.encode(fotKey.currency0, 10e18, true));
planner.add(Actions.SETTLE, abi.encode(fotKey.currency1, 10e18, true));
planner.add(
Actions.INCREASE_LIQUIDITY_FROM_DELTAS,
abi.encode(tokenId, MAX_SLIPPAGE_INCREASE, MAX_SLIPPAGE_INCREASE, ZERO_BYTES)
);
bytes memory actions = planner.encode();
lpm.modifyLiquidities(actions, _deadline);
uint128 newLiquidity = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(fotConfig.tickLower),
TickMath.getSqrtPriceAtTick(fotConfig.tickUpper),
10e18,
10e18
);
assertEq(lpm.getPositionLiquidity(tokenId), initialLiquidity + newLiquidity);
}
function test_increaseFromDeltas_fot() public {
uint128 initialLiquidity = 1000e18;
uint256 tokenId = lpm.nextTokenId();
fotConfig = PositionConfig({poolKey: fotKey, tickLower: -120, tickUpper: 120});
mint(fotConfig, initialLiquidity, address(this), ZERO_BYTES);
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this));
assertEq(lpm.getPositionLiquidity(tokenId), initialLiquidity);
// Use a 1% fee.
MockFOT(address(fotToken)).setFee(100);
// Set the fee on transfer amount 1% higher.
(uint256 amount0, uint256 amount1) =
fotKey.currency0 == Currency.wrap(address(fotToken)) ? (100e18, 99e18) : (99e18, 100e18);
Plan memory planner = Planner.init();
planner.add(Actions.SETTLE, abi.encode(fotKey.currency0, amount0, true));
planner.add(Actions.SETTLE, abi.encode(fotKey.currency1, amount1, true));
planner.add(
Actions.INCREASE_LIQUIDITY_FROM_DELTAS,
abi.encode(tokenId, MAX_SLIPPAGE_INCREASE, MAX_SLIPPAGE_INCREASE, ZERO_BYTES)
);
bytes memory actions = planner.encode();
lpm.modifyLiquidities(actions, _deadline);
(uint256 amount0AfterTransfer, uint256 amount1AfterTransfer) =
fotKey.currency0 == Currency.wrap(address(fotToken)) ? (99e18, 100e18) : (100e18, 99e18);
uint128 newLiquidity = LiquidityAmounts.getLiquidityForAmounts(
SQRT_PRICE_1_1,
TickMath.getSqrtPriceAtTick(fotConfig.tickLower),
TickMath.getSqrtPriceAtTick(fotConfig.tickUpper),
amount0AfterTransfer,
amount1AfterTransfer
);
assertEq(lpm.getPositionLiquidity(tokenId), initialLiquidity + newLiquidity);
}
struct BalanceDiff {
uint256 _before;
uint256 _after;
}
struct Balance {
uint256 _0;
uint256 _1;
}
function test_fuzz_mintFromDeltas_burn_fot(
uint256 bips,
uint256 amount0,
uint256 amount1,
int24 tickLower,
int24 tickUpper
) public {
bips = bound(bips, 1, 10_000);
MockFOT(address(fotToken)).setFee(bips);
tickLower = int24(
bound(
tickLower,
fotKey.tickSpacing * (TickMath.MIN_TICK / fotKey.tickSpacing),
fotKey.tickSpacing * (TickMath.MAX_TICK / fotKey.tickSpacing)
)
);
tickUpper = int24(
bound(
tickUpper,
fotKey.tickSpacing * (TickMath.MIN_TICK / fotKey.tickSpacing),
fotKey.tickSpacing * (TickMath.MAX_TICK / fotKey.tickSpacing)
)
);
tickLower = fotKey.tickSpacing * (tickLower / fotKey.tickSpacing);
tickUpper = fotKey.tickSpacing * (tickUpper / fotKey.tickSpacing);
vm.assume(tickUpper > tickLower);
(uint160 sqrtPriceX96,,,) = manager.getSlot0(fotKey.toId());
{
uint128 maxLiquidityPerTick = Pool.tickSpacingToMaxLiquidityPerTick(fotKey.tickSpacing);
(uint256 maxAmount0, uint256 maxAmount1) = LiquidityAmounts.getAmountsForLiquidity(
sqrtPriceX96,
TickMath.getSqrtPriceAtTick(tickLower),
TickMath.getSqrtPriceAtTick(tickUpper),
maxLiquidityPerTick
);
maxAmount0 = maxAmount0 == 0 ? 1 : maxAmount0 > STARTING_USER_BALANCE ? STARTING_USER_BALANCE : maxAmount0;
maxAmount1 = maxAmount1 == 0 ? 1 : maxAmount1 > STARTING_USER_BALANCE ? STARTING_USER_BALANCE : maxAmount1;
amount0 = bound(amount0, 1, maxAmount0);
amount1 = bound(amount1, 1, maxAmount1);
}
uint256 tokenId = lpm.nextTokenId();
BalanceDiff memory balance0 = BalanceDiff(fotKey.currency0.balanceOf(address(this)), 0);
BalanceDiff memory balance1 = BalanceDiff(fotKey.currency1.balanceOf(address(this)), 0);
BalanceDiff memory balance0PM = BalanceDiff(fotKey.currency0.balanceOf(address(manager)), 0);
BalanceDiff memory balance1PM = BalanceDiff(fotKey.currency1.balanceOf(address(manager)), 0);
Plan memory planner = Planner.init();
planner.add(Actions.SETTLE, abi.encode(fotKey.currency0, amount0, true));
planner.add(Actions.SETTLE, abi.encode(fotKey.currency1, amount1, true));
planner.add(
Actions.MINT_POSITION_FROM_DELTAS,
abi.encode(
fotKey,
tickLower,
tickUpper,
MAX_SLIPPAGE_INCREASE,
MAX_SLIPPAGE_INCREASE,
ActionConstants.MSG_SENDER,
ZERO_BYTES
)
);
// take the excess of each currency
planner.add(Actions.TAKE_PAIR, abi.encode(fotKey.currency0, fotKey.currency1, ActionConstants.MSG_SENDER));
// needed to remove variables because of stack too deep
// Read below as:
// bool currency0IsFOT = fotKey.currency0 == Currency.wrap(address(fotToken));
// bool positionIsEntirelyInOtherToken = currency0IsFOT
// ? TickMath.getSqrtPriceAtTick(tickUpper) <= sqrtPriceX96
// : TickMath.getSqrtPriceAtTick(tickLower) >= sqrtPriceX96;
// if (bips == 10000 && !positionIsEntirelyInOtherToken) {
if (
bips == 10000
&& !(fotKey.currency0 == Currency.wrap(address(fotToken))
? TickMath.getSqrtPriceAtTick(tickUpper) <= sqrtPriceX96
: TickMath.getSqrtPriceAtTick(tickLower) >= sqrtPriceX96)
) {
vm.expectRevert(Position.CannotUpdateEmptyPosition.selector);
lpm.modifyLiquidities(planner.encode(), _deadline);
} else {
// MINT FROM DELTAS.
lpm.modifyLiquidities(planner.encode(), _deadline);
balance0._after = fotKey.currency0.balanceOf(address(this));
balance1._after = fotKey.currency1.balanceOf(address(this));
balance0PM._after = fotKey.currency0.balanceOf(address(manager));
balance1PM._after = fotKey.currency1.balanceOf(address(manager));
// Calculate the expected resulting balances used to create liquidity after the fee is applied.
Balance memory expected;
{
bool currency0IsFOT = fotKey.currency0 == Currency.wrap(address(fotToken));
uint256 expectedFee = (currency0IsFOT ? amount0 : amount1).calculatePortion(bips);
(expected._0, expected._1) = currency0IsFOT
? (balance0._before - balance0._after - expectedFee, balance1._before - balance1._after)
: (balance0._before - balance0._after, balance1._before - balance1._after - expectedFee);
}
assertEq(expected._0, balance0PM._after - balance0PM._before);
assertEq(expected._1, balance1PM._after - balance1PM._before);
{
// the liquidity that was created is a diff of the balance change
uint128 expectedLiquidity = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96,
TickMath.getSqrtPriceAtTick(tickLower),
TickMath.getSqrtPriceAtTick(tickUpper),
expected._0,
expected._1
);
assertEq(IERC721(address(lpm)).ownerOf(tokenId), address(this));
assertEq(lpm.getPositionLiquidity(tokenId), expectedLiquidity);
}
// BURN.
planner = Planner.init();
// Note that the slippage does not include the fee from the transfer.
planner.add(
Actions.BURN_POSITION,
abi.encode(
tokenId, expected._0 == 0 ? 0 : expected._0 - 1, expected._1 == 0 ? 0 : expected._1 - 1, ZERO_BYTES
)
);
planner.add(Actions.TAKE_PAIR, abi.encode(fotKey.currency0, fotKey.currency1, ActionConstants.MSG_SENDER));
lpm.modifyLiquidities(planner.encode(), _deadline);
assertEq(lpm.getPositionLiquidity(tokenId), 0);
}
}
}