Skip to content

Commit

Permalink
v3 fuzzer (#658)
Browse files Browse the repository at this point in the history
* Initial commit v3 fuzzer

* use signed integer bound

* remove msg.value

* use liquidity amounts helper to generate the liquidity delta from a random amount0 and amount1

* added library comparison test

* Update test/PoolManager.swap.t.sol

Co-authored-by: Shuhui Luo <107524008+shuhuiluo@users.noreply.github.com>

* integrate new liquidity fuzzer

* fuzz swap amount

* fuzz fee tier

* fuzz fees and tickspacing

* fuzz against v3 with multiple LP positions

* regenerate gas snapshots

* regenerate gas snapshots

---------

Co-authored-by: Shuhui Luo <107524008+shuhuiluo@users.noreply.github.com>
  • Loading branch information
gretzke and shuhuiluo authored May 24, 2024
1 parent 3118115 commit 5eb17c9
Show file tree
Hide file tree
Showing 8 changed files with 275 additions and 21 deletions.
2 changes: 1 addition & 1 deletion .forge-snapshots/simple swap with native.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
114887
114924
2 changes: 1 addition & 1 deletion .forge-snapshots/simple swap.snap
Original file line number Diff line number Diff line change
@@ -1 +1 @@
130052
130089
37 changes: 23 additions & 14 deletions src/test/Fuzzers.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,26 @@ contract Fuzzers is StdUtils {

// Finally bound the seeded liquidity by either the max per tick, or by the amount allowed in the position range.
int256 liquidityMax = liquidityMaxByAmount > liquidityMaxPerTick ? liquidityMaxPerTick : liquidityMaxByAmount;
_vm.assume(liquidityMax != 0);
return bound(liquidityDeltaUnbounded, 1, liquidityMax);
}

// Uses tickSpacingToMaxLiquidityPerTick/2 as one of the possible bounds.
// Potentially adjust this value to be more strict for positions that touch the same tick.
function boundLiquidityDeltaTightly(PoolKey memory key, int256 liquidityDeltaUnbounded, int256 liquidityMaxByAmount)
internal
pure
returns (int256)
{
function boundLiquidityDeltaTightly(
PoolKey memory key,
int256 liquidityDeltaUnbounded,
int256 liquidityMaxByAmount,
uint256 maxPositions
) internal pure returns (int256) {
// Divide by half to bound liquidity more. TODO: Probably a better way to do this.
int256 liquidityMaxTightBound = int256(uint256(Pool.tickSpacingToMaxLiquidityPerTick(key.tickSpacing)) / 2);
int256 liquidityMaxTightBound =
int256(uint256(Pool.tickSpacingToMaxLiquidityPerTick(key.tickSpacing)) / maxPositions);

// Finally bound the seeded liquidity by either the max per tick, or by the amount allowed in the position range.
int256 liquidityMax =
liquidityMaxByAmount > liquidityMaxTightBound ? liquidityMaxTightBound : liquidityMaxByAmount;
_vm.assume(liquidityMax != 0);
return bound(liquidityDeltaUnbounded, 1, liquidityMax);
}

Expand Down Expand Up @@ -104,7 +108,10 @@ contract Fuzzers is StdUtils {

(tickLower, tickUpper) = tickLower < tickUpper ? (tickLower, tickUpper) : (tickUpper, tickLower);

_vm.assume(tickLower != tickUpper);
if (tickLower == tickUpper) {
if (tickLower != TickMath.minUsableTick(tickSpacing)) tickLower = tickLower - tickSpacing;
else tickUpper = tickUpper + tickSpacing;
}

return (tickLower, tickUpper);
}
Expand All @@ -113,11 +120,10 @@ contract Fuzzers is StdUtils {
return boundTicks(tickLower, tickUpper, key.tickSpacing);
}

function createRandomSqrtPriceX96(PoolKey memory key, int256 seed) internal pure returns (uint160) {
int24 tickSpacing = key.tickSpacing;
function createRandomSqrtPriceX96(int24 tickSpacing, int256 seed) internal pure returns (uint160) {
int256 min = int256(TickMath.minUsableTick(tickSpacing));
int256 max = int256(TickMath.maxUsableTick(tickSpacing));
int256 randomTick = bound(seed, min, max);
int256 randomTick = bound(seed, min + 1, max - 1);
return TickMath.getSqrtPriceAtTick(int24(randomTick));
}

Expand All @@ -140,13 +146,15 @@ contract Fuzzers is StdUtils {
function createFuzzyLiquidityParamsWithTightBound(
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
uint160 sqrtPriceX96
uint160 sqrtPriceX96,
uint256 maxPositions
) internal pure returns (IPoolManager.ModifyLiquidityParams memory result) {
(result.tickLower, result.tickUpper) = boundTicks(key, params.tickLower, params.tickUpper);
int256 liquidityDeltaFromAmounts =
getLiquidityDeltaFromAmounts(result.tickLower, result.tickUpper, sqrtPriceX96);

result.liquidityDelta = boundLiquidityDeltaTightly(key, params.liquidityDelta, liquidityDeltaFromAmounts);
result.liquidityDelta =
boundLiquidityDeltaTightly(key, params.liquidityDelta, liquidityDeltaFromAmounts, maxPositions);
}

function createFuzzyLiquidity(
Expand All @@ -166,9 +174,10 @@ contract Fuzzers is StdUtils {
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
uint160 sqrtPriceX96,
bytes memory hookData
bytes memory hookData,
uint256 maxPositions
) internal returns (IPoolManager.ModifyLiquidityParams memory result, BalanceDelta delta) {
result = createFuzzyLiquidityParamsWithTightBound(key, params, sqrtPriceX96);
result = createFuzzyLiquidityParamsWithTightBound(key, params, sqrtPriceX96, maxPositions);
delta = modifyLiquidityRouter.modifyLiquidity(key, result, hookData);
}
}
2 changes: 2 additions & 0 deletions src/test/SwapRouterNoChecks.sol
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,7 @@ contract SwapRouterNoChecks is PoolTestBase {
data.key.currency1.settle(manager, data.sender, uint256(int256(-delta.amount1())), false);
data.key.currency0.take(manager, data.sender, uint256(int256(delta.amount0())), false);
}

return "";
}
}
191 changes: 191 additions & 0 deletions test/PoolManager.swap.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import {V3Helper, IUniswapV3Pool, IUniswapV3MintCallback, IUniswapV3SwapCallback} from "./utils/V3Helper.sol";
import {Deployers} from "./utils/Deployers.sol";
import {Currency, CurrencyLibrary} from "../src/types/Currency.sol";
import {Fuzzers} from "../src/test/Fuzzers.sol";
import {IHooks} from "../src/interfaces/IHooks.sol";
import {IPoolManager} from "../src/interfaces/IPoolManager.sol";
import {BalanceDelta, BalanceDeltaLibrary, toBalanceDelta} from "../src/types/BalanceDelta.sol";
import {PoolSwapTest} from "../src/test/PoolSwapTest.sol";
import {PoolKey} from "../src/types/PoolKey.sol";
import {SqrtPriceMath} from "../src/libraries/SqrtPriceMath.sol";
import {TickMath} from "../src/libraries/TickMath.sol";
import {SafeCast} from "../src/libraries/SafeCast.sol";
import {LiquidityAmounts} from "./utils/LiquidityAmounts.sol";

abstract contract V3Fuzzer is V3Helper, Deployers, Fuzzers, IUniswapV3MintCallback, IUniswapV3SwapCallback {
using CurrencyLibrary for Currency;

function setUp() public virtual override {
super.setUp();
deployFreshManagerAndRouters();
deployMintAndApprove2Currencies();
}

function initPools(uint24 fee, int24 tickSpacing, int256 sqrtPriceX96seed)
internal
returns (IUniswapV3Pool v3Pool, PoolKey memory key_, uint160 sqrtPriceX96)
{
fee = uint24(bound(fee, 0, 999999));
tickSpacing = int24(bound(tickSpacing, 1, 16383));
// v3 pools don't allow overwriting existing fees, 500, 3000, 10000 are set by default in the constructor
if (fee == 500) tickSpacing = 10;
else if (fee == 3000) tickSpacing = 60;
else if (fee == 10000) tickSpacing = 200;
else v3Factory.enableFeeAmount(fee, tickSpacing);

sqrtPriceX96 = createRandomSqrtPriceX96(tickSpacing, sqrtPriceX96seed);

v3Pool = IUniswapV3Pool(v3Factory.createPool(Currency.unwrap(currency0), Currency.unwrap(currency1), fee));
v3Pool.initialize(sqrtPriceX96);

key_ = PoolKey(currency0, currency1, fee, tickSpacing, IHooks(address(0)));
manager.initialize(key_, sqrtPriceX96, "");
}

function addLiquidity(
IUniswapV3Pool v3Pool,
PoolKey memory key_,
uint160 sqrtPriceX96,
int24 lowerTickUnsanitized,
int24 upperTickUnsanitized,
int256 liquidityDeltaUnbound,
bool tight
) internal {
IPoolManager.ModifyLiquidityParams memory v4LiquidityParams = IPoolManager.ModifyLiquidityParams({
tickLower: lowerTickUnsanitized,
tickUpper: upperTickUnsanitized,
liquidityDelta: liquidityDeltaUnbound,
salt: 0
});

v4LiquidityParams = tight
? createFuzzyLiquidityParamsWithTightBound(key_, v4LiquidityParams, sqrtPriceX96, 20)
: createFuzzyLiquidityParams(key_, v4LiquidityParams, sqrtPriceX96);

v3Pool.mint(
address(this),
v4LiquidityParams.tickLower,
v4LiquidityParams.tickUpper,
uint128(int128(v4LiquidityParams.liquidityDelta)),
""
);

modifyLiquidityRouter.modifyLiquidity(key_, v4LiquidityParams, "");
}

function swap(IUniswapV3Pool pool, PoolKey memory key_, bool zeroForOne, int128 amountSpecified)
internal
returns (int256 amount0Diff, int256 amount1Diff)
{
if (amountSpecified == 0) amountSpecified = 1;
if (amountSpecified == type(int128).min) amountSpecified = type(int128).min + 1;
// v3 swap
(int256 amount0Delta, int256 amount1Delta) = pool.swap(
// invert amountSpecified because v3 swaps use inverted signs
address(this),
zeroForOne,
amountSpecified * -1,
zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT,
""
);
// v3 can handle bigger numbers than v4, so if we exceed int128, check that the next call reverts
bool overflows = false;
if (
amount0Delta > type(int128).max || amount1Delta > type(int128).max || amount0Delta < type(int128).min
|| amount1Delta < type(int128).min
) {
overflows = true;
}
// v4 swap
IPoolManager.SwapParams memory swapParams = IPoolManager.SwapParams({
zeroForOne: zeroForOne,
amountSpecified: amountSpecified,
sqrtPriceLimitX96: zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT
});
PoolSwapTest.TestSettings memory testSettings =
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false});

BalanceDelta delta;
try swapRouter.swap(key_, swapParams, testSettings, "") returns (BalanceDelta delta_) {
delta = delta_;
} catch (bytes memory reason) {
require(overflows, "v4 should not overflow");
assertEq(bytes4(reason), SafeCast.SafeCastOverflow.selector);
delta = toBalanceDelta(0, 0);
amount0Delta = 0;
amount1Delta = 0;
}

// because signs for v3 and v4 swaps are inverted, add values up to get the difference
amount0Diff = amount0Delta + delta.amount0();
amount1Diff = amount1Delta + delta.amount1();
}

function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external {
currency0.transfer(msg.sender, amount0Owed);
currency1.transfer(msg.sender, amount1Owed);
}

function uniswapV3SwapCallback(int256 amount0Delta, int256 amount1Delta, bytes calldata) external {
if (amount0Delta > 0) currency0.transfer(msg.sender, uint256(amount0Delta));
if (amount1Delta > 0) currency1.transfer(msg.sender, uint256(amount1Delta));
}
}

contract V3SwapTests is V3Fuzzer {
function test_shouldSwapEqual(
uint24 feeSeed,
int24 tickSpacingSeed,
int24 lowerTickUnsanitized,
int24 upperTickUnsanitized,
int256 liquidityDeltaUnbound,
int256 sqrtPriceX96seed,
int128 swapAmount,
bool zeroForOne
) public {
(IUniswapV3Pool pool, PoolKey memory key_, uint160 sqrtPriceX96) =
initPools(feeSeed, tickSpacingSeed, sqrtPriceX96seed);
addLiquidity(pool, key_, sqrtPriceX96, lowerTickUnsanitized, upperTickUnsanitized, liquidityDeltaUnbound, false);
(int256 amount0Diff, int256 amount1Diff) = swap(pool, key_, zeroForOne, swapAmount);
assertEq(amount0Diff, 0);
assertEq(amount1Diff, 0);
}

struct TightLiquidityParams {
int24 lowerTickUnsanitized;
int24 upperTickUnsanitized;
int256 liquidityDeltaUnbound;
}

function test_shouldSwapEqualMultipleLP(
uint24 feeSeed,
int24 tickSpacingSeed,
TightLiquidityParams[] memory liquidityParams,
int256 sqrtPriceX96seed,
int128 swapAmount,
bool zeroForOne
) public {
(IUniswapV3Pool pool, PoolKey memory key_, uint160 sqrtPriceX96) =
initPools(feeSeed, tickSpacingSeed, sqrtPriceX96seed);
for (uint256 i = 0; i < liquidityParams.length; ++i) {
if (i == 20) break;
addLiquidity(
pool,
key_,
sqrtPriceX96,
liquidityParams[i].lowerTickUnsanitized,
liquidityParams[i].upperTickUnsanitized,
liquidityParams[i].liquidityDeltaUnbound,
true
);
}

(int256 amount0Diff, int256 amount1Diff) = swap(pool, key_, zeroForOne, swapAmount);
assertEq(amount0Diff, 0);
assertEq(amount1Diff, 0);
}
}
10 changes: 6 additions & 4 deletions test/libraries/StateLibrary.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ contract StateLibraryTest is Test, Deployers, Fuzzers, GasSnapshot {
IPoolManager.ModifyLiquidityParams memory paramsA,
IPoolManager.ModifyLiquidityParams memory paramsB
) public {
(IPoolManager.ModifyLiquidityParams memory _paramsA,) =
Fuzzers.createFuzzyLiquidityWithTightBound(modifyLiquidityRouter, key, paramsA, SQRT_PRICE_1_1, ZERO_BYTES);
(IPoolManager.ModifyLiquidityParams memory _paramsB,) =
Fuzzers.createFuzzyLiquidityWithTightBound(modifyLiquidityRouter, key, paramsB, SQRT_PRICE_1_1, ZERO_BYTES);
(IPoolManager.ModifyLiquidityParams memory _paramsA,) = Fuzzers.createFuzzyLiquidityWithTightBound(
modifyLiquidityRouter, key, paramsA, SQRT_PRICE_1_1, ZERO_BYTES, 2
);
(IPoolManager.ModifyLiquidityParams memory _paramsB,) = Fuzzers.createFuzzyLiquidityWithTightBound(
modifyLiquidityRouter, key, paramsB, SQRT_PRICE_1_1, ZERO_BYTES, 2
);

uint128 liquidityDeltaA = uint128(uint256(_paramsA.liquidityDelta));
uint128 liquidityDeltaB = uint128(uint256(_paramsB.liquidityDelta));
Expand Down
2 changes: 1 addition & 1 deletion test/utils/LiquidityAmounts.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ library LiquidityAmounts {
/// @param x The uint258 to be downcasted
/// @return y The passed value, downcasted to uint128
function toUint128(uint256 x) private pure returns (uint128 y) {
require((y = uint128(x)) == x);
require((y = uint128(x)) == x, "liquidity overflow");
}

/// @notice Computes the amount of liquidity received for a given amount of token0 and price range
Expand Down
50 changes: 50 additions & 0 deletions test/utils/V3Helper.sol

Large diffs are not rendered by default.

0 comments on commit 5eb17c9

Please sign in to comment.