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

Test/invariant tests (Draft) #156

Merged
merged 26 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
710bdfc
test: fix wrong asset and numeraire pair in DopplerHandler
clemlak Oct 23, 2024
ba1984b
test: uncomment DopplerInvariantsTest, warp to default starting time
clemlak Oct 23, 2024
785fa52
test: add address labels
clemlak Oct 23, 2024
8ecf388
test: track actor asset balance and update reserves in handler function
clemlak Oct 23, 2024
c6e633d
test: track totalTokensSold, fix reserves tracking
clemlak Oct 23, 2024
526c0ed
test: add buyExactAmountOut handler
clemlak Oct 23, 2024
44469d5
test: add buyExactAmountOut selector, check totalTokensSold invariant
clemlak Oct 23, 2024
71cb95e
test: rename variables to improve readability
clemlak Oct 23, 2024
b356907
test: add missing transferFrom in sell
clemlak Oct 23, 2024
e0ed698
test: add sellExactIn handler
clemlak Oct 23, 2024
77645d3
test: add sellExactIn selector
clemlak Oct 23, 2024
797aa45
test: add sellExactOut handler function
clemlak Oct 23, 2024
e885b4d
test: add sellExactOut selector
clemlak Oct 23, 2024
6836159
test: add invariant_TracksTotalTokensSoldAndProceeds
clemlak Oct 23, 2024
085a92c
test: add ghost state variables to Handler
clemlak Oct 23, 2024
dc7f56f
chore: remove unused imports
clemlak Oct 23, 2024
09b5ecc
test: add invariant_CantSellMoreThanNumTokensToSell
clemlak Oct 23, 2024
0332247
test: add invariant_AlwaysProvidesAllAvailableTokens
clemlak Oct 23, 2024
55c388b
test: add invariant_LowerSlugWhenTokensSold
clemlak Oct 23, 2024
de26d48
test: add invariant_PositionsDifferentTicks invariant test
clemlak Oct 24, 2024
39121a6
test: add invariant_CannotTradeUnderLowerSlug invariant check
clemlak Oct 24, 2024
9480014
test: add invariant_NoPriceChangesBeforeStart invariant check
clemlak Oct 24, 2024
0f8d2e1
test: update invariant_NoPriceChangesBeforeStart, add comment
clemlak Oct 24, 2024
d935c3d
test: remove fail on revert flag from invariant_NoPriceChangesBeforeS…
clemlak Oct 24, 2024
0e681b5
test: add goNextEpoch handler function
clemlak Oct 24, 2024
3d019c3
test: skip broken invariant tests
clemlak Nov 4, 2024
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
122 changes: 111 additions & 11 deletions test/invariant/DopplerHandler.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {AddressSet, LibAddressSet} from "./AddressSet.sol";
import {DopplerImplementation} from "test/shared/DopplerImplementation.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {TestERC20} from "v4-core/src/test/TestERC20.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {Currency} from "v4-core/src/types/Currency.sol";
Expand All @@ -24,15 +22,20 @@ contract DopplerHandler is Test {
bool public isToken0;
bool public isUsingEth;

// Ghost variables are used to mimic the state of the hook contract.
uint256 public ghost_reserve0;
uint256 public ghost_reserve1;
uint256 public ghost_totalTokensSold;
uint256 public ghost_totalProceeds;

mapping(bytes4 => uint256) public calls;
uint256 public totalCalls;

AddressSet internal actors;
address internal currentActor;

mapping(address actor => uint256 balance) public assetBalanceOf;

modifier createActor() {
currentActor = msg.sender;
actors.add(msg.sender);
Expand Down Expand Up @@ -71,26 +74,123 @@ contract DopplerHandler is Test {
token1 = TestERC20(Currency.unwrap(poolKey.currency1));

if (isToken0) {
numeraire = token0;
asset = token1;
} else {
numeraire = token1;
asset = token0;
numeraire = token1;
} else {
asset = token1;
numeraire = token0;
}

ghost_reserve0 = token0.balanceOf(address(hook));
ghost_reserve1 = token1.balanceOf(address(hook));
}

/// @notice Buys an amount of asset tokens using an exact amount of numeraire tokens
function buyExactAmountIn(uint256 amount) public createActor countCall(this.buyExactAmountIn.selector) {
function buyExactAmountIn(uint256 amountToSpend) public createActor countCall(this.buyExactAmountIn.selector) {
amountToSpend = 1 ether;

if (isUsingEth) {
deal(currentActor, amount);
deal(currentActor, amountToSpend);
} else {
numeraire.mint(currentActor, amount);
numeraire.approve(address(router), amount);
numeraire.mint(currentActor, amountToSpend);
numeraire.approve(address(router), amountToSpend);
}

uint256 bought = router.buyExactIn{value: isUsingEth ? amount : 0}(amount);
uint256 bought = router.buyExactIn{value: isUsingEth ? amountToSpend : 0}(amountToSpend);
assetBalanceOf[currentActor] += bought;
ghost_totalTokensSold += bought;
ghost_totalProceeds += amountToSpend;

if (isToken0) {
ghost_reserve0 -= bought;
ghost_reserve1 += amountToSpend;
} else {
ghost_reserve1 -= bought;
ghost_reserve0 += amountToSpend;
}
}

function buyExactAmountOut(uint256 assetsToBuy) public createActor countCall(this.buyExactAmountOut.selector) {
assetsToBuy = 1 ether;
uint256 amountInRequired = router.computeBuyExactOut(assetsToBuy);

if (isUsingEth) {
deal(currentActor, amountInRequired);
} else {
numeraire.mint(currentActor, amountInRequired);
numeraire.approve(address(router), amountInRequired);
}

uint256 spent = router.buyExactOut{value: isUsingEth ? amountInRequired : 0}(assetsToBuy);
assetBalanceOf[currentActor] += assetsToBuy;
ghost_totalTokensSold += assetsToBuy;
ghost_totalProceeds += spent;

if (isToken0) {
ghost_reserve0 -= assetsToBuy;
ghost_reserve1 += spent;
} else {
ghost_reserve1 -= assetsToBuy;
ghost_reserve0 += spent;
}
}

function sellExactIn(uint256 seed)
public
useActor(uint256(uint160(msg.sender)))
countCall(this.sellExactIn.selector)
{
// If the currentActor is address(0), it means no one has bought any assets yet.
if (currentActor == address(0) || assetBalanceOf[currentActor] == 0) return;

uint256 assetsToSell = seed % assetBalanceOf[currentActor] + 1;
TestERC20(asset).approve(address(router), assetsToSell);
uint256 received = router.sellExactIn(assetsToSell);

assetBalanceOf[currentActor] -= assetsToSell;
ghost_totalTokensSold -= assetsToSell;
ghost_totalProceeds -= received;

if (isToken0) {
ghost_reserve0 += assetsToSell;
ghost_reserve1 -= received;
} else {
ghost_reserve1 += assetsToSell;
ghost_reserve0 -= received;
}
}

function sellExactOut(uint256 seed)
public
useActor(uint256(uint160(msg.sender)))
countCall(this.sellExactOut.selector)
{
// If the currentActor is address(0), it means no one has bought any assets yet.
if (currentActor == address(0) || assetBalanceOf[currentActor] == 0) return;

// We compute the maximum amount we can receive from our current balance.
uint256 maxAmountToReceive = router.computeSellExactOut(assetBalanceOf[currentActor]);

// Then we compute a random amount from that maximum.
uint256 amountToReceive = seed % maxAmountToReceive + 1;

TestERC20(asset).approve(address(router), router.computeSellExactOut(amountToReceive));
uint256 sold = router.sellExactOut(amountToReceive);

assetBalanceOf[currentActor] -= sold;
ghost_totalTokensSold -= sold;
ghost_totalProceeds -= amountToReceive;

if (isToken0) {
ghost_reserve0 += sold;
ghost_reserve1 -= amountToReceive;
} else {
ghost_reserve0 -= amountToReceive;
ghost_reserve1 += sold;
}
}

function goNextEpoch() public countCall(this.goNextEpoch.selector) {
vm.warp(block.timestamp + hook.getEpochLength());
}
}
137 changes: 119 additions & 18 deletions test/invariant/DopplerInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,130 @@ pragma solidity ^0.8.13;
import {console} from "forge-std/console.sol";
import {BaseTest} from "test/shared/BaseTest.sol";
import {DopplerHandler} from "test/invariant/DopplerHandler.sol";
import {State} from "src/Doppler.sol";
import {LiquidityAmounts} from "v4-core/test/utils/LiquidityAmounts.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";

/*

? totalTokensSold can't underflow
? totalProceeds can't underflow
? Computed ticks can't under/overflow
X Amount of asset tokens to be supplied to liquidity positions at once <= numTokensToSell - totalTokensSold
Likely further have to consider the actual available token balance as dust is lost due to rounding
Does not attempt to create liquidity positions with equal upper and lower ticks
Leads to divide by zero error (even just in computing the liquidity amount)
Also relevant for just doing relevant math, e.g. retrieving liquidity given an amount can result in a revert
Cannot trade the price to below the lower slug range
Else it allows for price manipulation
I think this is also a compliance requirement regardless
Always places a lower slug if totalTokensSold > 0
Selling all tokens back in to the curve must exceed the available liquidity
Single tick ranges do not exceed max liquidity per tick
Cannot modify the price in any way prior to the start time
*/

contract DopplerInvariantsTest is BaseTest {
// DopplerHandler public handler;
DopplerHandler public handler;

function setUp() public override {
super.setUp();
handler = new DopplerHandler(key, hook, router, isToken0, usingEth);

bytes4[] memory selectors = new bytes4[](3);
selectors[0] = handler.buyExactAmountIn.selector;
selectors[1] = handler.buyExactAmountOut.selector;
selectors[2] = handler.sellExactIn.selector;
// selectors[3] = handler.sellExactOut.selector;

targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
targetContract(address(handler));

vm.warp(DEFAULT_STARTING_TIME);
}

function afterInvariant() public view {
console.log("+-------------------+-----------------------+");
console.log("| Function Name | Calls |", handler.totalCalls());
console.log("+-------------------+-----------------------+");
console.log("| buyExactAmountIn |", handler.calls(handler.buyExactAmountIn.selector), " |");
console.log("| buyExactAmountOut |", handler.calls(handler.buyExactAmountOut.selector), " |");
console.log("| sellExactIn |", handler.calls(handler.sellExactIn.selector), " |");
console.log("| sellExactOut |", handler.calls(handler.sellExactOut.selector), " |");
console.log("+-------------------+-----------------------+");
}

/// forge-config: default.invariant.fail-on-revert = true
function invariant_TracksTotalTokensSoldAndProceeds() public view {
(,, uint256 totalTokensSold, uint256 totalProceeds,,) = hook.state();
assertEq(totalTokensSold, handler.ghost_totalTokensSold());
assertEq(totalProceeds, handler.ghost_totalProceeds());
}

/// forge-config: default.invariant.fail-on-revert = true
function invariant_CantSellMoreThanNumTokensToSell() public view {
uint256 numTokensToSell = hook.getNumTokensToSell();
assertLe(handler.ghost_totalTokensSold(), numTokensToSell);
}

/// forge-config: default.invariant.fail-on-revert = true
function invariant_AlwaysProvidesAllAvailableTokens() public {
vm.skip(true);
uint256 numTokensToSell = hook.getNumTokensToSell();
uint256 totalTokensProvided;
uint256 slugs = hook.getNumPDSlugs();

int24 currentTick = hook.getCurrentTick(poolId);

for (uint256 i = 1; i < 4 + slugs; i++) {
(int24 tickLower, int24 tickUpper, uint128 liquidity,) = hook.positions(bytes32(uint256(i)));
(uint256 amount0, uint256 amount1) = LiquidityAmounts.getAmountsForLiquidity(
TickMath.getSqrtPriceAtTick(currentTick),
TickMath.getSqrtPriceAtTick(tickLower),
TickMath.getSqrtPriceAtTick(tickUpper),
liquidity
);
totalTokensProvided += isToken0 ? amount0 : amount1;
}

(,, uint256 totalTokensSold,,,) = hook.state();
assertEq(totalTokensProvided, numTokensToSell - totalTokensSold);
}

function invariant_LowerSlugWhenTokensSold() public {
vm.skip(true);
(,, uint256 totalTokensSold,,,) = hook.state();

// function setUp() public override {
// super.setUp();
// handler = new DopplerHandler(key, hook, router, isToken0, usingEth);
if (totalTokensSold > 0) {
(,, uint128 liquidity,) = hook.positions(bytes32(uint256(1)));
assertTrue(liquidity > 0);
}
}

// bytes4[] memory selectors = new bytes4[](1);
// selectors[0] = handler.buyExactAmountIn.selector;
function invariant_CannotTradeUnderLowerSlug() public view {
(int24 tickLower,,,) = hook.positions(bytes32(uint256(1)));
int24 currentTick = hook.getCurrentTick(poolId);

// targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
// targetContract(address(handler));
// }
if (isToken0) {
assertTrue(currentTick >= tickLower);
} else {
assertTrue(currentTick <= tickLower);
}
}

// function afterInvariant() public view {
// console.log("Handler address", address(handler));
// console.log("Calls: ", handler.totalCalls());
// console.log("buyExactAmountIn: ", handler.calls(handler.buyExactAmountIn.selector));
// }
/// forge-config: default.invariant.fail-on-revert = true
function invariant_PositionsDifferentTicks() public view {
uint256 slugs = hook.getNumPDSlugs();
for (uint256 i = 1; i < 4 + slugs; i++) {
(int24 tickLower, int24 tickUpper, uint128 liquidity,) = hook.positions(bytes32(uint256(i)));
if (liquidity > 0) assertTrue(tickLower != tickUpper);
}
}

// /// forge-config: default.invariant.fail-on-revert = true
// function invariant_works() public {
// assertTrue(true);
// }
function invariant_NoPriceChangesBeforeStart() public {
vm.skip(true);
vm.warp(DEFAULT_STARTING_TIME - 1);
// TODO: I think this test is broken because we don't set the tick in the constructor.
assertEq(hook.getCurrentTick(poolId), hook.getStartingTick());
}
}
3 changes: 3 additions & 0 deletions test/shared/BaseTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ contract BaseTest is Test, Deployers {

// Deploy swapRouter
swapRouter = new PoolSwapTest(manager);
vm.label(address(swapRouter), "SwapRouter");

// Deploy modifyLiquidityRouter
// Note: Only used to validate that liquidity can't be manually modified
Expand All @@ -259,8 +260,10 @@ contract BaseTest is Test, Deployers {
TestERC20(token1).approve(address(modifyLiquidityRouter), type(uint256).max);

quoter = new Quoter(manager);
vm.label(address(quoter), "Quoter");

router = new CustomRouter(swapRouter, quoter, key, isToken0, usingEth);
vm.label(address(router), "Router");
}

function computeBuyExactOut(uint256 amountOut) public returns (uint256) {
Expand Down
1 change: 1 addition & 0 deletions test/shared/CustomRouter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ contract CustomRouter is Test {
/// @return Amount of numeraire tokens received.
function sell(int256 amount) public returns (uint256, uint256) {
uint256 approveAmount = amount < 0 ? uint256(-amount) : computeSellExactOut(uint256(amount));
TestERC20(asset).transferFrom(msg.sender, address(this), uint256(approveAmount));
TestERC20(asset).approve(address(swapRouter), uint256(approveAmount));

BalanceDelta delta = swapRouter.swap(
Expand Down
Loading