From b24b71c2118e859004ef30badb13551874f93e29 Mon Sep 17 00:00:00 2001 From: Alberto Date: Thu, 9 May 2024 14:49:09 +0200 Subject: [PATCH] feat: `TestUpOnlyLiquidity` stateful testing * implemented new stateful test that also continuously deposits balanced liquidity in the pool (this didn't happen in the previous version of stateful tests) * addresses are now generated using boa's search strategy instead of using `boa.env.generate_address()` this will be greatly beneficial when https://github.com/vyperlang/titanoboa/issues/208 will be implemented. * search strategy to generate factories now makes sure the addresses of `fee_receiver`, `owner` and `deployer` are different. * `exchange_rule` now can only swap up to 60% of the pool liqudity since 85% would often break `newton_y` (and it seems like a reasonably unlikely to happen situtation). * `StatefulBase` now tracks depositors with a set instead of a list to avoid having duplicates addresses when someone deposits multiple times. * removed `sleep` invariant since this is already implemented as the `time_forward` rule (using `sleep` as name was leading to unexpected behavior because of python module with the same name). * more comments on what has been done so far. --- tests/unitary/pool/stateful/stateful_base2.py | 39 +++++++++---- tests/unitary/pool/stateful/strategies.py | 17 ++++-- tests/unitary/pool/stateful/test_stateful2.py | 55 +++++++++++++------ 3 files changed, 76 insertions(+), 35 deletions(-) diff --git a/tests/unitary/pool/stateful/stateful_base2.py b/tests/unitary/pool/stateful/stateful_base2.py index fcccd438..7a9daed5 100644 --- a/tests/unitary/pool/stateful/stateful_base2.py +++ b/tests/unitary/pool/stateful/stateful_base2.py @@ -9,6 +9,7 @@ rule, ) from hypothesis.strategies import integers +from strategies import address from strategies import pool as pool_strategy from contracts.mocks import ERC20Mock as ERC20 @@ -23,8 +24,9 @@ class StatefulBase(RuleBasedStateMachine): # TODO deposits can be as low as 1e11, but small deposits breaks swaps # I should do stateful testing only with deposit withdrawal amount=integers(min_value=int(1e20), max_value=int(1e30)), + user=address, ) - def initialize_pool(self, pool, amount): + def initialize_pool(self, pool, amount, user): # cahing the pool generated by the strategy self.pool = pool @@ -42,7 +44,7 @@ def initialize_pool(self, pool, amount): self.xcp_profit_a = 1e18 self.xcpx = 1e18 - self.depositors = [] + self.depositors = set() # deposit some initial liquidity balanced_amounts = self.get_balanced_deposit_amounts(amount) @@ -51,7 +53,9 @@ def initialize_pool(self, pool, amount): *balanced_amounts ) ) - self.add_liquidity(balanced_amounts, boa.env.generate_address()) + self.add_liquidity(balanced_amounts, user) + + # --------------- utility methods --------------- def get_balanced_deposit_amounts(self, amount: int): """Get the amounts of tokens that should be deposited @@ -72,7 +76,7 @@ def get_balanced_deposit_amounts(self, amount: int): # updated together. Calling pool methods directly will probably # lead to incorrect simulation and errors. - def add_liquidity(self, amounts: List[int], user: str) -> str: + def add_liquidity(self, amounts: List[int], user: str): """Wrapper around the `add_liquidity` method of the pool. Always prefer this instead of calling the pool method directly. @@ -83,12 +87,15 @@ def add_liquidity(self, amounts: List[int], user: str) -> str: Returns: str: the address of the depositor """ + # check to prevent revert on empty deposits if sum(amounts) == 0: event("empty deposit") return for coin, amount in zip(self.coins, amounts): + # infinite approval coin.approve(self.pool, 2**256 - 1, sender=user) + # mint the amount of tokens for the depositor mint_for_testing(coin, user, amount) # store the amount of lp tokens before the deposit @@ -110,7 +117,7 @@ def add_liquidity(self, amounts: List[int], user: str) -> str: self.xcp_profit = self.pool.xcp_profit() self.xcp_profit_a = self.pool.xcp_profit_a() - self.depositors.append(user) + self.depositors.add(user) def exchange(self, dx: int, i: int, user: str): """Wrapper around the `exchange` method of the pool. @@ -152,6 +159,15 @@ def exchange(self, dx: int, i: int, user: str): ) def remove_liquidity(self, amount, user): + amounts = [c.balanceOf(user) for c in self.coins] + tokens = self.swap.balanceOf(user) + self.pool.remove_liquidity(amount, [0] * 2, sender=user) + amounts = [ + (c.balanceOf(user) - a) for c, a in zip(self.coins, amounts) + ] + self.total_supply -= tokens + tokens -= self.swap.balanceOf(user) + self.balances = [b - a for a, b in zip(amounts, self.balances)] # virtual price resets if everything is withdrawn if self.total_supply == 0: @@ -194,23 +210,24 @@ def can_always_withdraw(self): to withdraw liquidity in a proportional way. """ - # TODO we need to check that: - # - y is not broken (through get_dy) + # anchor the environment to make sure that the balances are + # restored after the invariant is checked with boa.env.anchor(): + # remove all liquidity from all depositors for d in self.depositors: + # store the current balances of the pool prev_balances = [c.balanceOf(self.pool) for c in self.coins] + # withdraw all liquidity from the depositor tokens = self.pool.balanceOf(d) self.pool.remove_liquidity(tokens, [0] * 2, sender=d) # assert current balances are less as the previous ones for c, b in zip(self.coins, prev_balances): + # check that the balance of the pool is less than before assert c.balanceOf(self.pool) < b for c in self.coins: + # there should not be any liquidity left in the pool assert c.balanceOf(self.pool) == 0 - @invariant() - def sleep(self): - pass # TODO - @invariant() def balances(self): balances = [self.pool.balances(i) for i in range(2)] diff --git a/tests/unitary/pool/stateful/strategies.py b/tests/unitary/pool/stateful/strategies.py index 8fed08d2..0a6c2e16 100644 --- a/tests/unitary/pool/stateful/strategies.py +++ b/tests/unitary/pool/stateful/strategies.py @@ -5,7 +5,8 @@ """ import boa -from hypothesis import note +from boa.test import strategy +from hypothesis import assume, note from hypothesis.strategies import ( composite, integers, @@ -29,12 +30,14 @@ MIN_GAMMA, ) +# just a more hyptohesis-like way to get an address +# from boa's search strategy +address = strategy("address") + # ---------------- addresses ---------------- -# TODO this should use the boa address strategy -# when the recurring address feature gets added -deployer = just(boa.env.generate_address()) -fee_receiver = just(boa.env.generate_address()) -owner = just(boa.env.generate_address()) +deployer = address +fee_receiver = address +owner = address # ---------------- factory ---------------- @@ -46,6 +49,8 @@ def factory( _fee_receiver = draw(fee_receiver) _owner = draw(owner) + assume(_fee_receiver != _owner != _deployer) + with boa.env.prank(_deployer): amm_implementation = amm_deployer.deploy_as_blueprint() gauge_implementation = gauge_deployer.deploy_as_blueprint() diff --git a/tests/unitary/pool/stateful/test_stateful2.py b/tests/unitary/pool/stateful/test_stateful2.py index 32528bfa..c48e0428 100644 --- a/tests/unitary/pool/stateful/test_stateful2.py +++ b/tests/unitary/pool/stateful/test_stateful2.py @@ -1,8 +1,10 @@ -import boa -from hypothesis import note -from hypothesis.stateful import rule -from hypothesis.strategies import data, integers, just +from hypothesis import note, settings +from hypothesis.stateful import precondition, rule +from hypothesis.strategies import data, integers from stateful_base2 import StatefulBase +from strategies import address + +settings.register_profile("no_shrinking", settings(phases=[])) class OnlySwapStateful(StatefulBase): @@ -14,31 +16,48 @@ class OnlySwapStateful(StatefulBase): @rule( data=data(), i=integers(min_value=0, max_value=1), - user=just(boa.env.generate_address()), + user=address, ) - def exchange(self, data, i: int, user: str): + def exchange_rule(self, data, i: int, user: str): liquidity = self.coins[i].balanceOf(self.pool.address) + # we use a data strategy since the amount we want to swap + # depends on the pool liquidity which is only known at runtime dx = data.draw( integers( + # swap can be between 0.001% and 60% of the pool liquidity min_value=int(liquidity * 0.0001), - max_value=int(liquidity * 0.85), + max_value=int(liquidity * 0.60), ), label="dx", ) note("trying to swap: {:.3%} of pool liquidity".format(dx / liquidity)) - return super().exchange(dx, i, user) + self.exchange(dx, i, user) class UpOnlyLiquidityStateful(OnlySwapStateful): - """This test suite does everything as the OnlySwapStateful + """This test suite does everything as the `OnlySwapStateful` but also adds liquidity to the pool. It does not remove liquidity.""" - # TODO - pass + # too high liquidity can lead to overflows + @precondition(lambda self: self.pool.D() < 1e28) + @rule( + # we can only add liquidity up to 1e25, this was reduced + # from the initial deposit that can be up to 1e30 to avoid + # breaking newton_D + amount=integers(min_value=int(1e20), max_value=int(1e25)), + user=address, + ) + def add_liquidity_balanced_rule(self, amount: int, user: str): + balanced_amounts = self.get_balanced_deposit_amounts(amount) + note( + "increasing pool liquidity with balanced amounts: " + + "{:.2e} {:.2e}".format(*balanced_amounts) + ) + self.add_liquidity(balanced_amounts, user) class OnlyBalancedLiquidityStateful(UpOnlyLiquidityStateful): - """This test suite does everything as the UpOnlyLiquidityStateful + """This test suite does everything as the `UpOnlyLiquidityStateful` but also removes liquidity from the pool. Both deposits and withdrawals are balanced. """ @@ -48,9 +67,8 @@ class OnlyBalancedLiquidityStateful(UpOnlyLiquidityStateful): class UnbalancedLiquidityStateful(OnlyBalancedLiquidityStateful): - """This test suite does everything as the OnlyBalancedLiquidityStateful - but also removes liquidity from the pool. Deposits and withdrawals can - be unbalanced. + """This test suite does everything as the `OnlyBalancedLiquidityStateful` + Deposits and withdrawals can be unbalanced. This is the most complex test suite and should be used when making sure that some specific gamma and A can be used without unexpected behavior. @@ -61,7 +79,7 @@ class UnbalancedLiquidityStateful(OnlyBalancedLiquidityStateful): class RampingStateful(UnbalancedLiquidityStateful): - """This test suite does everything as the UnbalancedLiquidityStateful + """This test suite does everything as the `UnbalancedLiquidityStateful` but also ramps the pool. Because of this some of the invariant checks are disabled (loss is expected). """ @@ -70,8 +88,9 @@ class RampingStateful(UnbalancedLiquidityStateful): pass -TestOnlySwap = OnlySwapStateful.TestCase -# TestUpOnlyLiquidity = UpOnlyLiquidityStateful.TestCase +# TestOnlySwap = OnlySwapStateful.TestCase +TestUpOnlyLiquidity = UpOnlyLiquidityStateful.TestCase # TestOnlyBalancedLiquidity = OnlyBalancedLiquidityStateful.TestCase # TestUnbalancedLiquidity = UnbalancedLiquidityStateful.TestCase # RampingStateful = RampingStateful.TestCase +# TODO variable decimals