Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: slippage checks #285

Merged
merged 5 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_burn_empty.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
47170
47167
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_burn_empty_native.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
46988
46984
Original file line number Diff line number Diff line change
@@ -1 +1 @@
123226
123586
Original file line number Diff line number Diff line change
@@ -1 +1 @@
122733
123093
Original file line number Diff line number Diff line change
@@ -1 +1 @@
130304
130664
Original file line number Diff line number Diff line change
@@ -1 +1 @@
129812
130172
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_collect_native.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
141698
142147
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_collect_sameRange.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
150546
150995
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_collect_withClose.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
150546
150995
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_collect_withTakePair.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
149918
150367
Original file line number Diff line number Diff line change
@@ -1 +1 @@
108833
109192
Original file line number Diff line number Diff line change
@@ -1 +1 @@
116089
116538
Original file line number Diff line number Diff line change
@@ -1 +1 @@
115461
115910
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_decrease_burnEmpty.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
134384
134740
Original file line number Diff line number Diff line change
@@ -1 +1 @@
127123
127479
Original file line number Diff line number Diff line change
@@ -1 +1 @@
128805
129254
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_decrease_take_take.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
116622
117071
Original file line number Diff line number Diff line change
@@ -1 +1 @@
152496
152799
Original file line number Diff line number Diff line change
@@ -1 +1 @@
151498
151801
Original file line number Diff line number Diff line change
@@ -1 +1 @@
134296
134599
Original file line number Diff line number Diff line change
@@ -1 +1 @@
130395
130944
Original file line number Diff line number Diff line change
@@ -1 +1 @@
171311
171860
Original file line number Diff line number Diff line change
@@ -1 +1 @@
141267
141816
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_mint_native.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
340639
341062
Original file line number Diff line number Diff line change
@@ -1 +1 @@
349131
349554
Original file line number Diff line number Diff line change
@@ -1 +1 @@
348433
348856
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_mint_onSameTickLower.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
318621
319044
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_mint_onSameTickUpper.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
319263
319686
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_mint_sameRange.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
244845
245268
Original file line number Diff line number Diff line change
@@ -1 +1 @@
374663
375086
Original file line number Diff line number Diff line change
@@ -1 +1 @@
324639
325062
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_mint_withClose.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
375939
376362
2 changes: 1 addition & 1 deletion .forge-snapshots/PositionManager_mint_withSettlePair.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
375079
375502
Original file line number Diff line number Diff line change
@@ -1 +1 @@
420413
420836
28 changes: 17 additions & 11 deletions src/PositionManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,10 @@ contract PositionManager is
bytes calldata hookData
) internal onlyValidConfig(tokenId, config) {
// Note: The tokenId is used as the salt for this position, so every minted position has unique storage in the pool manager.
BalanceDelta liquidityDelta = _modifyLiquidity(config, liquidity.toInt256(), bytes32(tokenId), hookData);
liquidityDelta.validateMaxInNegative(amount0Max, amount1Max);
(BalanceDelta liquidityDelta, BalanceDelta feesAccrued) =
_modifyLiquidity(config, liquidity.toInt256(), bytes32(tokenId), hookData);
// Slippage checks should be done on the principal liquidityDelta which is the liquidityDelta - feesAccrued
(liquidityDelta - feesAccrued).validateMaxIn(amount0Max, amount1Max);
}

/// @dev Calling decrease with 0 liquidity will credit the caller with any underlying fees of the position
Expand All @@ -231,8 +233,10 @@ contract PositionManager is
bytes calldata hookData
) internal onlyIfApproved(msgSender(), tokenId) onlyValidConfig(tokenId, config) {
// Note: the tokenId is used as the salt.
BalanceDelta liquidityDelta = _modifyLiquidity(config, -(liquidity.toInt256()), bytes32(tokenId), hookData);
liquidityDelta.validateMinOut(amount0Min, amount1Min);
(BalanceDelta liquidityDelta, BalanceDelta feesAccrued) =
_modifyLiquidity(config, -(liquidity.toInt256()), bytes32(tokenId), hookData);
// Slippage checks should be done on the principal liquidityDelta which is the liquidityDelta - feesAccrued
(liquidityDelta - feesAccrued).validateMinOut(amount0Min, amount1Min);
}

function _mint(
Expand All @@ -252,8 +256,10 @@ contract PositionManager is
_mint(owner, tokenId);

// _beforeModify is not called here because the tokenId is newly minted
BalanceDelta liquidityDelta = _modifyLiquidity(config, liquidity.toInt256(), bytes32(tokenId), hookData);
liquidityDelta.validateMaxIn(amount0Max, amount1Max);
(BalanceDelta liquidityDelta, BalanceDelta feesAccrued) =
_modifyLiquidity(config, liquidity.toInt256(), bytes32(tokenId), hookData);
// Slippage checks should be done on the principal liquidityDelta which is the liquidityDelta - feesAccrued
(liquidityDelta - feesAccrued).validateMaxIn(amount0Max, amount1Max);
positionConfigs.setConfigId(tokenId, config);

emit MintPosition(tokenId, config);
Expand All @@ -269,11 +275,12 @@ contract PositionManager is
) internal onlyIfApproved(msgSender(), tokenId) onlyValidConfig(tokenId, config) {
uint256 liquidity = uint256(getPositionLiquidity(tokenId, config));

BalanceDelta liquidityDelta;
// Can only call modify if there is non zero liquidity.
if (liquidity > 0) {
liquidityDelta = _modifyLiquidity(config, -(liquidity.toInt256()), bytes32(tokenId), hookData);
liquidityDelta.validateMinOut(amount0Min, amount1Min);
(BalanceDelta liquidityDelta, BalanceDelta feesAccrued) =
_modifyLiquidity(config, -(liquidity.toInt256()), bytes32(tokenId), hookData);
// Slippage checks should be done on the principal liquidityDelta which is the liquidityDelta - feesAccrued
(liquidityDelta - feesAccrued).validateMinOut(amount0Min, amount1Min);
}

delete positionConfigs[tokenId];
Expand Down Expand Up @@ -332,8 +339,7 @@ contract PositionManager is
int256 liquidityChange,
bytes32 salt,
bytes calldata hookData
) internal returns (BalanceDelta liquidityDelta) {
BalanceDelta feesAccrued;
) internal returns (BalanceDelta liquidityDelta, BalanceDelta feesAccrued) {
(liquidityDelta, feesAccrued) = poolManager.modifyLiquidity(
config.poolKey,
IPoolManager.ModifyLiquidityParams({
Expand Down
33 changes: 21 additions & 12 deletions src/libraries/SlippageCheck.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,42 @@
pragma solidity ^0.8.0;

import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {SafeCastTemp} from "./SafeCast.sol";

/// @title Slippage Check Library
/// @notice a library for checking if a delta exceeds a maximum ceiling or fails to meet a minimum floor
library SlippageCheckLibrary {
using SafeCastTemp for int128;

error MaximumAmountExceeded();
error MinimumAmountInsufficient();

/// @notice Revert if one or both deltas does not meet a minimum output
/// @dev to be used when removing liquidity to guarantee a minimum output
/// @param delta The principal amount of tokens to be removed, does not include any fees accrued
/// @param amount0Min The minimum amount of token0 to receive
/// @param amount1Min The minimum amount of token1 to receive
/// @dev This should be called when removing liquidity (burn or decrease)
function validateMinOut(BalanceDelta delta, uint128 amount0Min, uint128 amount1Min) internal pure {
if (uint128(delta.amount0()) < amount0Min || uint128(delta.amount1()) < amount1Min) {
// Called on burn or decrease, where we expect the returned delta to be positive.
// However, on pools where hooks can return deltas on modify liquidity, it is possible for a returned delta to be negative.
// Because we use SafeCast, this will revert in those cases when the delta is negative.
// This means this contract will NOT support pools where the hook returns a negative delta on burn/decrease.
if (delta.amount0().toUint128() < amount0Min || delta.amount1().toUint128() < amount1Min) {
revert MinimumAmountInsufficient();
}
}

/// @notice Revert if one or both deltas exceeds a maximum input
/// @dev to be used when minting liquidity to guarantee a maximum input
/// @param delta The principal amount of tokens to be added, does not include any fees accrued (which is possible on increase)
/// @param amount0Max The maximum amount of token0 to spend
/// @param amount1Max The maximum amount of token1 to spend
/// @dev This should be called when adding liquidity (mint or increase)
function validateMaxIn(BalanceDelta delta, uint128 amount0Max, uint128 amount1Max) internal pure {
if (uint128(-delta.amount0()) > amount0Max || uint128(-delta.amount1()) > amount1Max) {
revert MaximumAmountExceeded();
}
}

/// @notice Revert if one or both deltas exceeds a maximum input
/// @dev When increasing liquidity, delta can be positive when excess fees need to be collected
/// in those cases, slippage checks are not required
function validateMaxInNegative(BalanceDelta delta, uint128 amount0Max, uint128 amount1Max) internal pure {
// Called on mint or increase, where we expect the returned delta to be negative.
// However, on pools where hooks can return deltas on modify liquidity, it is possible for a returned delta to be positive (even after discounting fees accrued).
// Thus, we only cast the delta if it is guaranteed to be negative.
// And we do NOT revert in the positive delta case. Since a positive delta means the hook is crediting tokens to the user for minting/increasing liquidity, we do not check slippage.
// This means this contract will NOT support _positive_ slippage checks (minAmountOut checks) on pools where the hook returns a positive delta on mint/increase.
if (
delta.amount0() < 0 && amount0Max < uint128(-delta.amount0())
hensha256 marked this conversation as resolved.
Show resolved Hide resolved
|| delta.amount1() < 0 && amount1Max < uint128(-delta.amount1())
Expand Down
74 changes: 73 additions & 1 deletion test/position-managers/IncreaseLiquidity.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,75 @@ contract IncreaseLiquidityTest is Test, PosmTestSetup, Fuzzers {
config = PositionConfig({poolKey: key, tickLower: -300, tickUpper: 300});
}

/// @notice Increase liquidity by less than the amount of liquidity the position has earned, requiring a take
function test_increaseLiquidity_withCollection_takePair() 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.startPrank(alice);
uint256 tokenIdAlice = lpm.nextTokenId();
mint(config, liquidityAlice, alice, ZERO_BYTES);
vm.stopPrank();

// bob provides liquidity
vm.startPrank(bob);
mint(config, liquidityBob, bob, ZERO_BYTES);
vm.stopPrank();

// donate to create fees
uint256 amountDonate = 0.1e18;
donateRouter.donate(key, amountDonate, amountDonate, ZERO_BYTES);

// alice uses her half her fees to increase liquidity
// Slight error in this calculation vs. actual fees.. TODO: Fix this.
BalanceDelta feesOwedAlice = IPositionManager(lpm).getFeesOwed(manager, config, tokenIdAlice);
// Note: You can alternatively calculate Alice's fees owed from the swap amount, fee on the pool, and total liquidity in that range.
// swapAmount.mulWadDown(FEE_WAD).mulDivDown(liquidityAlice, liquidityAlice + liquidityBob);

(uint160 sqrtPriceX96,,,) = StateLibrary.getSlot0(manager, config.poolKey.toId());
uint256 liquidityDelta = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96,
TickMath.getSqrtPriceAtTick(config.tickLower),
TickMath.getSqrtPriceAtTick(config.tickUpper),
uint256(int256(feesOwedAlice.amount0() / 2)),
uint256(int256(feesOwedAlice.amount1() / 2))
);

uint256 balance0BeforeAlice = currency0.balanceOf(alice);
uint256 balance1BeforeAlice = currency1.balanceOf(alice);

// Set the slippage amounts to be exactly half the fees that alice is reinvesting.

Plan memory planner = Planner.init();
planner.add(
Actions.INCREASE_LIQUIDITY,
abi.encode(
tokenIdAlice,
config,
liquidityDelta,
feesOwedAlice.amount0() / 2,
feesOwedAlice.amount1() / 2,
ZERO_BYTES
)
);
bytes memory calls = planner.finalizeModifyLiquidityWithTakePair(config.poolKey, address(alice));
vm.startPrank(alice);
lpm.modifyLiquidities(calls, _deadline);
vm.stopPrank();

// alices current balance is the balanceBefore plus half of her fees owed
assertApproxEqAbs(
currency0.balanceOf(alice), balance0BeforeAlice + uint256(int256(feesOwedAlice.amount0() / 2)), tolerance
);
assertApproxEqAbs(
currency1.balanceOf(alice), balance1BeforeAlice + uint256(int256(feesOwedAlice.amount1() / 2)), tolerance
);
}

/// @notice Increase liquidity with exact fees, taking dust
function test_increaseLiquidity_withExactFees_take() public {
// Alice and Bob provide liquidity on the range
Expand Down Expand Up @@ -119,7 +188,10 @@ contract IncreaseLiquidityTest is Test, PosmTestSetup, Fuzzers {

Plan memory planner = Planner.init();
planner.add(
Actions.INCREASE_LIQUIDITY, abi.encode(tokenIdAlice, config, liquidityDelta, 0 wei, 0 wei, ZERO_BYTES)
Actions.INCREASE_LIQUIDITY,
abi.encode(
tokenIdAlice, config, liquidityDelta, feesOwedAlice.amount0(), feesOwedAlice.amount1(), ZERO_BYTES
hensha256 marked this conversation as resolved.
Show resolved Hide resolved
)
);
bytes memory calls = planner.finalizeModifyLiquidityWithClose(config.poolKey);
vm.startPrank(alice);
Expand Down