-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: added new stateful testing class (wip)
- Loading branch information
1 parent
b61d99b
commit 866e545
Showing
1 changed file
with
208 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,208 @@ | ||
from typing import List | ||
|
||
import boa | ||
from hypothesis import event, note | ||
from hypothesis.stateful import ( | ||
RuleBasedStateMachine, | ||
initialize, | ||
invariant, | ||
rule, | ||
) | ||
from hypothesis.strategies import integers | ||
from strategies import pool as pool_strategy | ||
|
||
from contracts.mocks import ERC20Mock as ERC20 | ||
from tests.utils.tokens import mint_for_testing | ||
|
||
|
||
class StatefulBase(RuleBasedStateMachine): | ||
pool = None | ||
user_balances = dict() | ||
total_supply = 0 | ||
|
||
# try bigger amounts than 30 and e11 for low | ||
@initialize( | ||
pool=pool_strategy(), | ||
amount=integers(min_value=int(1e11), max_value=int(1e30)), | ||
) | ||
def initialize_pool(self, pool, amount): | ||
# cahing the pool generated by the strategy | ||
self.pool = pool | ||
|
||
# caching coins here for easier access | ||
self.coins = [ERC20.at(pool.coins(i)) for i in range(2)] | ||
|
||
# these balances should follow the pool balances | ||
self.balances = [0, 0] | ||
|
||
# initial profit is 1e18 | ||
self.xcp_profit = 1e18 | ||
self.xcp_profit_a = 1e18 | ||
self.xcpx = 1e18 | ||
|
||
# deposit some initial liquidity | ||
balanced_amounts = self.get_balanced_deposit_amounts(amount) | ||
note( | ||
"seeding pool with balanced amounts: {:.2e} {:.2e}".format( | ||
*balanced_amounts | ||
) | ||
) | ||
self.add_liquidity(balanced_amounts, boa.env.generate_address()) | ||
|
||
def get_balanced_deposit_amounts(self, amount: int): | ||
"""Get the amounts of tokens that should be deposited | ||
to the pool to have balanced amounts of the two tokens. | ||
Args: | ||
amount (int): the amount of the first token | ||
Returns: | ||
List[int]: the amounts of the two tokens | ||
""" | ||
return [int(amount), int(amount * 1e18 // self.pool.price_scale())] | ||
|
||
# --------------- pool methods --------------- | ||
# methods that wrap the pool methods that should be used in | ||
# the rules of the state machine. These methods make sure that | ||
# both the state of the pool and of the state machine are | ||
# updated together. Calling pool methods directly will probably | ||
# lead to incorrect simulation and errors. | ||
|
||
def add_liquidity(self, amounts: List[int], user: str) -> str: | ||
"""Wrapper around the `add_liquidity` method of the pool. | ||
Always prefer this instead of calling the pool method directly. | ||
Args: | ||
amounts (List[int]): amounts of tokens to be deposited | ||
user (str): the sender of the transaction | ||
Returns: | ||
str: the address of the depositor | ||
""" | ||
if sum(amounts) == 0: | ||
event("empty deposit") | ||
return | ||
|
||
for coin, amount in zip(self.coins, amounts): | ||
coin.approve(self.pool, 2**256 - 1, sender=user) | ||
mint_for_testing(coin, user, amount) | ||
|
||
# store the amount of lp tokens before the deposit | ||
lp_tokens = self.pool.balanceOf(user) | ||
|
||
# TODO stricter since no slippage | ||
self.pool.add_liquidity(amounts, 0, sender=user) | ||
|
||
# find the increase in lp tokens | ||
lp_tokens = self.pool.balanceOf(user) - lp_tokens | ||
# increase the total supply by the amount of lp tokens | ||
self.total_supply += lp_tokens | ||
|
||
# pool balances should increase by the amounts | ||
self.balances = [x + y for x, y in zip(self.balances, amounts)] | ||
|
||
# update the profit since it increases through `tweak_price` | ||
# which is called by `add_liquidity` | ||
self.xcp_profit = self.pool.xcp_profit() | ||
self.xcp_profit_a = self.pool.xcp_profit_a() | ||
|
||
return user | ||
|
||
def exchange(self, dx: int, i: int, user: str): | ||
"""Wrapper around the `exchange` method of the pool. | ||
Always prefer this instead of calling the pool method directly. | ||
Args: | ||
dx (int): amount in | ||
i (int): the token the user sends to swap | ||
user (str): the sender of the transaction | ||
""" | ||
# j is the index of the coin that comes out of the pool | ||
j = 1 - i | ||
|
||
# TODO if this fails... handle it | ||
expected_dy = self.pool.get_dy(i, j, dx) | ||
|
||
mint_for_testing(self.coins[i], user, dx) | ||
self.coins[i].approve(self.pool(dx, sender=user)) | ||
actual_dy = self.pool.exchange(i, j, dx, expected_dy, sender=user) | ||
assert ( | ||
actual_dy == expected_dy | ||
) # TODO == self.coins[j].balanceOf(user) | ||
|
||
def remove_liquidity(self, amount, user): | ||
|
||
# virtual price resets if everything is withdrawn | ||
if self.total_supply == 0: | ||
event("full liquidity removal") | ||
self.virtual_price = 1e18 | ||
|
||
def remove_liquidity_one_coin(self, percentage): | ||
pass | ||
|
||
@rule(time_increase=integers(min_value=1, max_value=86400 * 7)) | ||
def time_forward(self, time_increase): | ||
"""Make the time moves forward by `sleep_time` seconds. | ||
Useful for ramping, oracle updates, etc. | ||
Up to 1 week. | ||
""" | ||
boa.env.time_travel(time_increase) | ||
|
||
# --------------- pool invariants ---------------------- | ||
|
||
@invariant() | ||
def sleep(self): | ||
pass # TODO | ||
|
||
@invariant() | ||
def balances(self): | ||
balances = [self.pool.balances(i) for i in range(2)] | ||
balances_of = [c.balanceOf(self.pool) for c in self.coins] | ||
for i in range(2): | ||
assert self.balances[i] == balances[i] | ||
assert self.balances[i] == balances_of[i] | ||
|
||
@invariant() | ||
def sanity_check(self): | ||
"""Make sure the stateful simulations matches the contract state.""" | ||
assert self.xcp_profit == self.pool.xcp_profit() | ||
assert self.total_supply == self.pool.totalSupply() | ||
|
||
# profit, cached vp and current vp should be at least 1e18 | ||
assert self.xcp_profit >= 1e18 | ||
assert self.pool.virtual_price() >= 1e18 | ||
assert self.pool.get_virtual_price() >= 1e18 | ||
|
||
@invariant() | ||
def virtual_price(self): | ||
pass # TODO | ||
|
||
@invariant() | ||
def up_only_profit(self): | ||
"""This method checks if the pool is profitable, since it should | ||
never lose money. | ||
To do so we use the so called `xcpx`. This is an emprical measure | ||
of profit that is even stronger than `xcp`. We have to use this | ||
because `xcp` goes down when claiming admin fees. | ||
You can imagine `xcpx` as a value that that is always between the | ||
interval [xcp_profit, xcp_profit_a]. When `xcp` goes down | ||
when claiming fees, `xcp_a` goes up. Averagin them creates this | ||
measure of profit that only goes down when something went wrong. | ||
""" | ||
xcp_profit = self.pool.xcp_profit() | ||
xcp_profit_a = self.pool.xcp_profit_a() | ||
xcpx = (xcp_profit + xcp_profit_a + 1e18) // 2 | ||
|
||
# make sure that the previous profit is smaller than the current | ||
assert xcpx >= self.xcpx | ||
# updates the previous profit | ||
self.xcpx = xcpx | ||
|
||
|
||
TestBase = StatefulBase.TestCase | ||
|
||
# TODO make sure that xcp goes down when claiming admin fees | ||
# TODO add an invariant with withdrawal simulations to make sure | ||
# it is always possible |