From 148b847c8754f642d04201ef12bebf52cca8fd67 Mon Sep 17 00:00:00 2001 From: Michael <151919150+heswithme@users.noreply.github.com> Date: Tue, 15 Oct 2024 08:57:03 +0200 Subject: [PATCH] test: stableswap (n=2,3) integration tests (#27) * chore: add leveldb for caching * test: update address book * test: add interface for tests * test: wip test * test: running integration tests, wip * ci: adding boa cache * ci: fixing boa cache * revert: rm boa cache from ci * test: wip fixtures for pools * feat: functioning pool fixture * refactor: group pool tests * test: stableswap integration * ci: test matrix * test: remove debug prints * test: remove cryptoswaps * chore: resolving reviews * test: disable boa caching because does not allow parallel testing * chore: remove prints --------- Co-authored-by: Alberto --- .github/workflows/ci.yaml | 9 +- scripts/debug_tests.py | 4 +- tests/integration/address_book.py | 27 ++ tests/integration/conftest.py | 9 +- tests/integration/curve_pools/conftest.py | 110 ++++++ .../curve_pools/test_stableswap.py | 133 ++++++++ tests/integration/curve_pools/utils.py | 20 ++ .../interfaces/CurveStableSwapNG.vyi | 316 ++++++++++++++++++ 8 files changed, 620 insertions(+), 8 deletions(-) create mode 100644 tests/integration/curve_pools/conftest.py create mode 100644 tests/integration/curve_pools/test_stableswap.py create mode 100644 tests/integration/curve_pools/utils.py create mode 100644 tests/integration/interfaces/CurveStableSwapNG.vyi diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8166a58..8c34423 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,11 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false + matrix: + folder: + - "tests/unitary" + - "tests/integration" + - "tests/hypothesis" steps: - uses: actions/checkout@v4 @@ -46,8 +51,8 @@ jobs: run: uv sync --extra=dev # Run tests with environment variables - - name: Run Tests + - name: Run Tests in ${{ matrix.folder }} env: ETH_RPC_URL: ${{ secrets.ETH_RPC_URL }} ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} - run: uv run pytest -n auto + run: uv run pytest -n auto ${{ matrix.folder }} diff --git a/scripts/debug_tests.py b/scripts/debug_tests.py index 8e25db3..8efd838 100644 --- a/scripts/debug_tests.py +++ b/scripts/debug_tests.py @@ -11,7 +11,7 @@ def main(): # Pytest arguments pytest_args = [ "-s", # Do not capture output, allowing you to see print statements and debug info - "tests/unitary/twa", # Specific test to run + "tests/integration/curve_pools/test_stableswap.py::test_stableswap_pool_prices_with_vault_growth", # Specific test to run # '--maxfail=1', # Stop after the firstD failure "--tb=short", # Shorter traceback for easier reading "-rA", # Show extra test summary info @@ -19,7 +19,7 @@ def main(): if not is_debug_mode(): pass - pytest_args.append("-n=auto") # Automatically determine the number of workers + # pytest_args.append("-n=auto") # Automatically determine the number of workers # Run pytest with the specified arguments pytest.main(pytest_args) diff --git a/tests/integration/address_book.py b/tests/integration/address_book.py index 22e7ebc..87e0472 100644 --- a/tests/integration/address_book.py +++ b/tests/integration/address_book.py @@ -6,3 +6,30 @@ fee_splitter = "0x22556558419eed2d0a1af2e7fd60e63f3199aca3" dao_agent = "0x40907540d8a6C65c637785e8f8B742ae6b0b9968" vault_original = "0xcA78AF7443f3F8FA0148b746Cb18FF67383CDF3f" + +# curve factories +factory_stableswap_ng = "0x6A8cbed756804B16E05E741eDaBd5cB544AE21bf" +factory_twocrypto_ng = "0x98EE851a00abeE0d95D08cF4CA2BdCE32aeaAF7F" +factory_tricrypto_ng = "0x0c0e5f2fF0ff18a3be9b835635039256dC4B4963" + +stables = [ + {"name": "dai", "address": "0x6b175474e89094c44da98b954eedeac495271d0f", "asset_type": 0}, + {"name": "usdt", "address": "0xdac17f958d2ee523a2206206994597c13d831ec7", "asset_type": 0}, + {"name": "usdc", "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "asset_type": 0}, + {"name": "usde", "address": "0x4c9edd5852cd905f086c759e8383e09bff1e68b3", "asset_type": 0}, + {"name": "frax", "address": "0x853d955acef822db058eb8505911ed77f175b99e", "asset_type": 0}, +] + +yield_stables = [ + {"name": "sdai", "address": "0x83f20f44975d03b1b09e64809b757c47f942beea", "asset_type": 3}, + {"name": "sfrax", "address": "0xa663b02cf0a4b149d2ad41910cb81e23e1c41c32", "asset_type": 3}, + {"name": "susde", "address": "0x9d39a5de30e57443bff2a8307a4256c8797a3497", "asset_type": 3}, +] +all_stables = [*stables, *yield_stables] + +cryptos = { + "weth": {"address": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"}, + "steth": {"address": "0xae7ab96520de3a18e5e111b5eaab095312d7fe84"}, + "wbtc": {"address": "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599"}, + "tbtc": {"address": "0x18084fba666a33d37592fa2633fd49a74dd93a88"}, +} diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index ec800b0..59d6ed0 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,10 +1,10 @@ import os - import address_book as ab import boa import pytest boa.set_etherscan(api_key=os.getenv("ETHERSCAN_API_KEY")) +BOA_CACHE = False @pytest.fixture(autouse=True) @@ -23,9 +23,10 @@ def rpc_url(): def forked_env(rpc_url): block_to_fork = 20928372 with boa.swap_env(boa.Env()): - boa.fork(url=rpc_url, block_identifier=block_to_fork) - # use this to disable caching - # boa.fork(url=rpc_url, block_identifier=block_to_fork, cache_file=None) + if BOA_CACHE: + boa.fork(url=rpc_url, block_identifier=block_to_fork) + else: + boa.fork(url=rpc_url, block_identifier=block_to_fork, cache_file=None) boa.env.enable_fast_mode() yield diff --git a/tests/integration/curve_pools/conftest.py b/tests/integration/curve_pools/conftest.py new file mode 100644 index 0000000..8db3a8c --- /dev/null +++ b/tests/integration/curve_pools/conftest.py @@ -0,0 +1,110 @@ +import address_book as ab +import boa +import pytest + + +@pytest.fixture() +def alice(): + return boa.env.generate_address() + + +@pytest.fixture() +def dev_address(): + return boa.env.generate_address() + + +@pytest.fixture() +def crvusd_init_balance(): + return 1_000 * 10**18 + + +@pytest.fixture() +def stableswap_factory(): + return boa.from_etherscan(ab.factory_stableswap_ng, "factory_stableswap_ng") + + +@pytest.fixture() +def paired_tokens(request): + # This fixture is used to get upstream parametrization and populate the contracts + # Retrieve paired token combination [token1, token2] via request.param + tokens_list = request.param + # update the dict with contracts + for token in tokens_list: + token["contract"] = boa.from_etherscan(token["address"], token["name"]) + token["decimals"] = token["contract"].decimals() + return tokens_list + + +@pytest.fixture() +def pool_tokens(paired_tokens, vault): + # in any pool first is scrvusd, then one or two other tokens + return [ + { + "name": "scrvusd", + "address": vault.address, + "contract": vault, + "asset_type": 3, + "decimals": 18, + }, + *paired_tokens, + ] + + +@pytest.fixture() +def stableswap_pool(stableswap_factory, vault, dev_address, pool_tokens): + # Retrieve token addresses and asset types from request.param + coins = [token["address"] for token in pool_tokens] + asset_types = [token.get("asset_type") for token in pool_tokens] + + pool_size = len(coins) + # pool parameters + A, fee, ma_exp_time, implementation_idx = (2000, 1000000, 866, 0) + method_ids = [b""] * pool_size + oracles = ["0x0000000000000000000000000000000000000000"] * pool_size + offpeg_fee_mp = 20000000000 + # deploy pool + with boa.env.prank(dev_address): + pool_address = stableswap_factory.deploy_plain_pool( + "pool_name", + "POOL", + coins, + A, + fee, + offpeg_fee_mp, + ma_exp_time, + implementation_idx, + asset_types, + method_ids, + oracles, + ) + pool_interface = boa.load_vyi("tests/integration/interfaces/CurveStableSwapNG.vyi") + pool = pool_interface.at(pool_address) + # fund dev with tokens (free-mint erc20s and deposit vaults) + AMOUNT_STABLE = 1_000_000 + dev_balances = [] + for token in pool_tokens: + if token["asset_type"] == 0: + boa.deal(token["contract"], dev_address, AMOUNT_STABLE * 10 ** token["decimals"]) + elif token["asset_type"] == 3: + underlying_token = token["contract"].asset() + underlying_contract = boa.from_etherscan(underlying_token, "token") + decimals = underlying_contract.decimals() + boa.deal( + underlying_contract, + dev_address, + AMOUNT_STABLE * 10**decimals + + underlying_contract.balanceOf( + dev_address + ), # in case of dai + sdai deal would overwrite, so we add the previous balance + ) + underlying_contract.approve( + token["contract"], + AMOUNT_STABLE * 10**decimals, + sender=dev_address, + ) + token["contract"].deposit(AMOUNT_STABLE * 10**decimals, dev_address, sender=dev_address) + # Approve pool to spend vault tokens + token["contract"].approve(pool, 2**256 - 1, sender=dev_address) + dev_balances.append(token["contract"].balanceOf(dev_address)) + pool.add_liquidity(dev_balances, 0, dev_address, sender=dev_address) + return pool diff --git a/tests/integration/curve_pools/test_stableswap.py b/tests/integration/curve_pools/test_stableswap.py new file mode 100644 index 0000000..341c046 --- /dev/null +++ b/tests/integration/curve_pools/test_stableswap.py @@ -0,0 +1,133 @@ +import pytest +import boa +from utils import generate_list_combinations +import address_book as ab + + +N_COMBINATIONS = 40 # num of combinations in stableswap tests (>=36 => all combinations) + +# produce tokens for stableswap to pair against crvusd +paired_token_combinations = generate_list_combinations(ab.all_stables, [1, 2], randomize=False) +tokens_subset = paired_token_combinations[0:N_COMBINATIONS] + + +def test_accrue_value(alice, dev_address, vault, crvusd, crvusd_init_balance): + # fund alice + assert crvusd.balanceOf(alice) == 0 + boa.deal(crvusd, alice, crvusd_init_balance) + with boa.env.prank(alice): + crvusd.approve(vault, crvusd_init_balance) + vault.deposit(crvusd_init_balance, alice) + + # basic boilerplate test to check vault functionality of rewards accrual without pools involved + alice_value_0 = vault.convertToAssets(vault.balanceOf(alice)) + + # deposit crvusd rewards into vault & time travel + boa.deal(crvusd, dev_address, crvusd_init_balance) + crvusd.transfer(vault, crvusd_init_balance, sender=dev_address) + vault.process_report(vault, sender=ab.dao_agent) + boa.env.time_travel(seconds=86_400 * 7) + + # check alice's assets value increased + alice_value_1 = vault.convertToAssets(vault.balanceOf(alice)) + assert alice_value_1 > alice_value_0 + assert alice_value_1 == alice_value_0 + crvusd_init_balance + + +@pytest.mark.parametrize( + "paired_tokens", + tokens_subset, + indirect=True, + ids=[f"scrvusd+{'+'.join([token['name'] for token in tokens])}" for tokens in tokens_subset], +) +def test_stableswap_pool_liquidity( + stableswap_pool, + paired_tokens, + vault, + alice, + dev_address, + crvusd_init_balance, + crvusd, +): + # test where we check that value grows even when deposited as LP + n_coins = stableswap_pool.N_COINS() + # fund alice + assert crvusd.balanceOf(alice) == 0 + boa.deal(crvusd, alice, crvusd_init_balance) + with boa.env.prank(alice): + crvusd.approve(vault, crvusd_init_balance) + vault.deposit(crvusd_init_balance, alice) + + alice_value_0 = vault.convertToAssets(vault.balanceOf(alice)) + alice_rate = alice_value_0 / vault.totalAssets() + # deposit single-sided scrvusd liq into pool (i.e. trade into equal parts) + add_liq_amounts = [0] * n_coins + add_liq_amounts[0] = vault.balanceOf(alice) + vault.approve(stableswap_pool, add_liq_amounts[0], sender=alice) + pool_shares = stableswap_pool.add_liquidity(add_liq_amounts, 0, alice, sender=alice) + assert stableswap_pool.balanceOf(alice) > 0 + + # now increase shares value by 5% + amt_reward = int(vault.totalAssets() * 0.05) + boa.deal(crvusd, dev_address, amt_reward) + crvusd.transfer(vault, amt_reward, sender=dev_address) + vault.process_report(vault, sender=ab.dao_agent) + boa.env.time_travel(seconds=86_400 * 7) + + # remove liq (one-sided) + stableswap_pool.remove_liquidity_one_coin(pool_shares, 0, 0, alice, sender=alice) + alice_value_1 = vault.convertToAssets(vault.balanceOf(alice)) + + alice_expected_full_reward = alice_rate * amt_reward + # because we deposited LP, we only get 1/N_COINS of the reward (50% or 33% for 2, 3 coins) + # relative tolerance because of one-sided LP deposit & withdraw eat fees + # 5% relative tolerance to expected reward + assert alice_value_1 - alice_value_0 == pytest.approx( + alice_expected_full_reward / n_coins, rel=0.05 + ) + + +@pytest.mark.parametrize( + "paired_tokens", + tokens_subset, + indirect=True, + ids=[f"scrvusd+{'+'.join([token['name'] for token in tokens])}" for tokens in tokens_subset], +) +def test_stableswap_pool_prices_with_vault_growth( + stableswap_pool, pool_tokens, vault, dev_address, crvusd, paired_tokens +): + """ + Test where vault shares grow (rewards airdropped), and we expect that pool prices change accordingly. + To balance the pool, dev removes liquidity in a balanced way at each iteration. + """ + n_coins = stableswap_pool.N_COINS() + growth_rate = 0.01 # Vault growth per iteration (1%) + airdropper = boa.env.generate_address() + decimals = [token["decimals"] for token in pool_tokens] + prev_dy = [stableswap_pool.get_dy(0, i, 10 ** decimals[0]) for i in range(1, n_coins)] + # Iteratively grow vault and adjust pool prices + for _ in range(10): # Run 10 iterations + # Step 1: Inflate vault by 1% + current_assets = vault.totalAssets() + amt_reward = int(current_assets * growth_rate) + boa.deal(crvusd, airdropper, amt_reward) + crvusd.transfer(vault, amt_reward, sender=airdropper) + vault.process_report(vault, sender=ab.dao_agent) + boa.env.time_travel(seconds=86_400 * 7) + + # Step 2: Dev removes 5% of liquidity in a balanced way + stableswap_pool.remove_liquidity( + stableswap_pool.balanceOf(dev_address) // 20, + [0] * n_coins, + dev_address, + True, + sender=dev_address, + ) + # Check pool prices after each iteration + cur_dy = [stableswap_pool.get_dy(0, i, 10 ** decimals[0]) for i in range(1, n_coins)] + for i in range(n_coins - 1): + assert cur_dy[i] > prev_dy[i] # important that in balanced pool dy increases + assert ( + cur_dy[i] / prev_dy[i] - 1 == pytest.approx(growth_rate, rel=0.2) + ) # price should grow along with vault (fees tolerated, we approx 1% growth with 20% tolerance) + prev_dy[i] = cur_dy[i] diff --git a/tests/integration/curve_pools/utils.py b/tests/integration/curve_pools/utils.py new file mode 100644 index 0000000..8bfcd0c --- /dev/null +++ b/tests/integration/curve_pools/utils.py @@ -0,0 +1,20 @@ +from itertools import combinations +import address_book as ab +import random + + +def generate_list_combinations(data_list, combo_sizes, randomize=False): + combos = [] + for count in combo_sizes: + for combo in combinations(data_list, count): + combos.append(list(combo)) # Convert each combination to a list + if randomize: + random.shuffle(combos) + return combos + + +# test functionality if run as a script +if __name__ == "__main__": + combos = generate_list_combinations(ab.all_stables, [1, 2]) + print(combos) + print(len(combos)) diff --git a/tests/integration/interfaces/CurveStableSwapNG.vyi b/tests/integration/interfaces/CurveStableSwapNG.vyi new file mode 100644 index 0000000..feb4065 --- /dev/null +++ b/tests/integration/interfaces/CurveStableSwapNG.vyi @@ -0,0 +1,316 @@ +# Events + +event Transfer: + sender: address + receiver: address + value: uint256 +event Approval: + owner: address + spender: address + value: uint256 +event TokenExchange: + buyer: address + sold_id: int128 + tokens_sold: uint256 + bought_id: int128 + tokens_bought: uint256 +event TokenExchangeUnderlying: + buyer: address + sold_id: int128 + tokens_sold: uint256 + bought_id: int128 + tokens_bought: uint256 +event AddLiquidity: + provider: address + token_amounts: DynArray[uint256, 8] + fees: DynArray[uint256, 8] + invariant: uint256 + token_supply: uint256 +event RemoveLiquidity: + provider: address + token_amounts: DynArray[uint256, 8] + fees: DynArray[uint256, 8] + token_supply: uint256 +event RemoveLiquidityOne: + provider: address + token_id: int128 + token_amount: uint256 + coin_amount: uint256 + token_supply: uint256 +event RemoveLiquidityImbalance: + provider: address + token_amounts: DynArray[uint256, 8] + fees: DynArray[uint256, 8] + invariant: uint256 + token_supply: uint256 +event RampA: + old_A: uint256 + new_A: uint256 + initial_time: uint256 + future_time: uint256 +event StopRampA: + A: uint256 + t: uint256 +event ApplyNewFee: + fee: uint256 + offpeg_fee_multiplier: uint256 +event SetNewMATime: + ma_exp_time: uint256 + D_ma_time: uint256 + +# Functions + +@external +def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256, _receiver: address) -> uint256: + ... + +@external +def exchange_received(i: int128, j: int128, _dx: uint256, _min_dy: uint256, _receiver: address) -> uint256: + ... + +@external +def add_liquidity(_amounts: DynArray[uint256, 8], _min_mint_amount: uint256, _receiver: address) -> uint256: + ... + +@external +def remove_liquidity_one_coin(_burn_amount: uint256, i: int128, _min_received: uint256, _receiver: address) -> uint256: + ... + +@external +def remove_liquidity_imbalance(_amounts: DynArray[uint256, 8], _max_burn_amount: uint256, _receiver: address) -> uint256: + ... + +@external +def remove_liquidity(_burn_amount: uint256, _min_amounts: DynArray[uint256, 8], _receiver: address, _claim_admin_fees: bool) -> DynArray[uint256, 8]: + ... + +@external +def withdraw_admin_fees(): + ... + +@view +@external +def last_price(i: uint256) -> uint256: + ... + +@view +@external +def ema_price(i: uint256) -> uint256: + ... + +@view +@external +def get_p(i: uint256) -> uint256: + ... + +@view +@external +def price_oracle(i: uint256) -> uint256: + ... + +@view +@external +def D_oracle() -> uint256: + ... + +@external +def transfer(_to: address, _value: uint256) -> bool: + ... + +@external +def transferFrom(_from: address, _to: address, _value: uint256) -> bool: + ... + +@external +def approve(_spender: address, _value: uint256) -> bool: + ... + +@external +def permit(_owner: address, _spender: address, _value: uint256, _deadline: uint256, _v: uint8, _r: bytes32, _s: bytes32) -> bool: + ... + +@view +@external +def DOMAIN_SEPARATOR() -> bytes32: + ... + +@view +@external +def get_dx(i: int128, j: int128, dy: uint256) -> uint256: + ... + +@view +@external +def get_dy(i: int128, j: int128, dx: uint256) -> uint256: + ... + +@view +@external +def calc_withdraw_one_coin(_burn_amount: uint256, i: int128) -> uint256: + ... + +@view +@external +def totalSupply() -> uint256: + ... + +@view +@external +def get_virtual_price() -> uint256: + ... + +@view +@external +def calc_token_amount(_amounts: DynArray[uint256, 8], _is_deposit: bool) -> uint256: + ... + +@view +@external +def A() -> uint256: + ... + +@view +@external +def A_precise() -> uint256: + ... + +@view +@external +def balances(i: uint256) -> uint256: + ... + +@view +@external +def get_balances() -> DynArray[uint256, 8]: + ... + +@view +@external +def stored_rates() -> DynArray[uint256, 8]: + ... + +@view +@external +def dynamic_fee(i: int128, j: int128) -> uint256: + ... + +@external +def ramp_A(_future_A: uint256, _future_time: uint256): + ... + +@external +def stop_ramp_A(): + ... + +@external +def set_new_fee(_new_fee: uint256, _new_offpeg_fee_multiplier: uint256): + ... + +@external +def set_ma_exp_time(_ma_exp_time: uint256, _D_ma_time: uint256): + ... + +@view +@external +def N_COINS() -> uint256: + ... + +@view +@external +def coins(arg0: uint256) -> address: + ... + +@view +@external +def fee() -> uint256: + ... + +@view +@external +def offpeg_fee_multiplier() -> uint256: + ... + +@view +@external +def admin_fee() -> uint256: + ... + +@view +@external +def initial_A() -> uint256: + ... + +@view +@external +def future_A() -> uint256: + ... + +@view +@external +def initial_A_time() -> uint256: + ... + +@view +@external +def future_A_time() -> uint256: + ... + +@view +@external +def admin_balances(arg0: uint256) -> uint256: + ... + +@view +@external +def ma_exp_time() -> uint256: + ... + +@view +@external +def D_ma_time() -> uint256: + ... + +@view +@external +def ma_last_time() -> uint256: + ... + +@view +@external +def name() -> String[64]: + ... + +@view +@external +def symbol() -> String[32]: + ... + +@view +@external +def decimals() -> uint8: + ... + +@view +@external +def version() -> String[8]: + ... + +@view +@external +def balanceOf(arg0: address) -> uint256: + ... + +@view +@external +def allowance(arg0: address, arg1: address) -> uint256: + ... + +@view +@external +def nonces(arg0: address) -> uint256: + ... + +@view +@external +def salt() -> bytes32: + ...