From 0d147196ed0eb3c3c30b82d31deeebd8606ce30c Mon Sep 17 00:00:00 2001 From: bout3fiddy <11488427+bout3fiddy@users.noreply.github.com> Date: Thu, 16 Nov 2023 20:38:34 +0100 Subject: [PATCH] fix: metapool impl should use static arrays for zap compatibility --- contracts/main/CurveStableSwapMetaNG.vy | 36 ++- contracts/mocks/Zap.vy | 314 ++++++++++++++++++++++++ poetry.lock | 6 +- pyproject.toml | 2 +- tests/pools/meta/test_meta_zap.py | 179 ++++++++++++++ 5 files changed, 523 insertions(+), 14 deletions(-) create mode 100644 contracts/mocks/Zap.vy create mode 100644 tests/pools/meta/test_meta_zap.py diff --git a/contracts/main/CurveStableSwapMetaNG.vy b/contracts/main/CurveStableSwapMetaNG.vy index ffb0df06..ad740a1e 100644 --- a/contracts/main/CurveStableSwapMetaNG.vy +++ b/contracts/main/CurveStableSwapMetaNG.vy @@ -1,6 +1,6 @@ # pragma version 0.3.10 # pragma optimize codesize -# pragma evm-version paris +# pragma evm-version shanghai """ @title CurveStableSwapMetaNG @author Curve.Fi @@ -741,7 +741,7 @@ def exchange_underlying( @external @nonreentrant('lock') def add_liquidity( - _amounts: DynArray[uint256, MAX_COINS], + _amounts: uint256[N_COINS], _min_mint_amount: uint256, _receiver: address = msg.sender ) -> uint256: @@ -857,7 +857,13 @@ def add_liquidity( self.total_supply = total_supply log Transfer(empty(address), _receiver, mint_amount) - log AddLiquidity(msg.sender, _amounts, fees, D1, total_supply) + log AddLiquidity( + msg.sender, + [_amounts[0], _amounts[1]], + fees, + D1, + total_supply + ) return mint_amount @@ -907,7 +913,7 @@ def remove_liquidity_one_coin( @external @nonreentrant('lock') def remove_liquidity_imbalance( - _amounts: DynArray[uint256, MAX_COINS], + _amounts: uint256[N_COINS], _max_burn_amount: uint256, _receiver: address = msg.sender ) -> uint256: @@ -976,7 +982,13 @@ def remove_liquidity_imbalance( self._burnFrom(msg.sender, burn_amount) - log RemoveLiquidityImbalance(msg.sender, _amounts, fees, D1, total_supply) + log RemoveLiquidityImbalance( + msg.sender, + [_amounts[0], _amounts[1]], + fees, + D1, + total_supply + ) return burn_amount @@ -985,10 +997,10 @@ def remove_liquidity_imbalance( @nonreentrant('lock') def remove_liquidity( _burn_amount: uint256, - _min_amounts: DynArray[uint256, MAX_COINS], + _min_amounts: uint256[N_COINS], _receiver: address = msg.sender, _claim_admin_fees: bool = True, -) -> DynArray[uint256, MAX_COINS]: +) -> uint256[N_COINS]: """ @notice Withdraw coins from the pool @dev Withdrawal amounts are based on current deposit ratios @@ -1047,7 +1059,7 @@ def remove_liquidity( if _claim_admin_fees: self._withdraw_admin_fees() - return amounts + return [amounts[0], amounts[1]] @external @@ -1729,7 +1741,7 @@ def get_virtual_price() -> uint256: @view @external def calc_token_amount( - _amounts: DynArray[uint256, MAX_COINS], + _amounts: uint256[N_COINS], _is_deposit: bool ) -> uint256: """ @@ -1738,7 +1750,11 @@ def calc_token_amount( @param _is_deposit set True for deposits, False for withdrawals @return Expected amount of LP tokens received """ - return StableSwapViews(factory.views_implementation()).calc_token_amount(_amounts, _is_deposit, self) + return StableSwapViews(factory.views_implementation()).calc_token_amount( + [_amounts[0], _amounts[1]], + _is_deposit, + self + ) @view diff --git a/contracts/mocks/Zap.vy b/contracts/mocks/Zap.vy new file mode 100644 index 00000000..b245916e --- /dev/null +++ b/contracts/mocks/Zap.vy @@ -0,0 +1,314 @@ +# @version 0.3.10 +""" +@title "Zap" Depositer for permissionless USD metapools +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2021 - all rights reserved +""" + +interface ERC20: + def transfer(_receiver: address, _amount: uint256): nonpayable + def transferFrom(_sender: address, _receiver: address, _amount: uint256): nonpayable + def approve(_spender: address, _amount: uint256): nonpayable + def decimals() -> uint256: view + def balanceOf(_owner: address) -> uint256: view + +interface CurveMeta: + def add_liquidity(amounts: uint256[N_COINS], min_mint_amount: uint256, _receiver: address) -> uint256: nonpayable + def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS]) -> uint256[N_COINS]: nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256, _receiver: address) -> uint256: nonpayable + def remove_liquidity_imbalance(amounts: uint256[N_COINS], max_burn_amount: uint256) -> uint256: nonpayable + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def calc_token_amount(amounts: uint256[N_COINS], deposit: bool) -> uint256: view + def coins(i: uint256) -> address: view + +interface CurveBase: + def add_liquidity(amounts: uint256[BASE_N_COINS], min_mint_amount: uint256): nonpayable + def remove_liquidity(_amount: uint256, min_amounts: uint256[BASE_N_COINS]): nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256): nonpayable + def remove_liquidity_imbalance(amounts: uint256[BASE_N_COINS], max_burn_amount: uint256): nonpayable + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def calc_token_amount(amounts: uint256[BASE_N_COINS], deposit: bool) -> uint256: view + def coins(i: uint256) -> address: view + def fee() -> uint256: view + + +N_COINS: constant(uint256) = 2 +MAX_COIN: constant(uint256) = N_COINS-1 +BASE_N_COINS: constant(uint256) = 3 +N_ALL_COINS: constant(uint256) = N_COINS + BASE_N_COINS - 1 + +N_COINS_128: constant(int128) = 2 +MAX_COIN_128: constant(int128) = N_COINS-1 +BASE_N_COINS_128: constant(int128) = 3 +N_ALL_COINS_128: constant(int128) = N_COINS + BASE_N_COINS - 1 + +FEE_DENOMINATOR: constant(uint256) = 10 ** 10 +FEE_IMPRECISION: constant(uint256) = 100 * 10 ** 8 # % of the fee + +BASE_POOL: immutable(address) +BASE_LP_TOKEN: immutable(address) +BASE_COINS: immutable(address[3]) + +# coin -> pool -> is approved to transfer? +is_approved: HashMap[address, HashMap[address, bool]] + + +@external +def __init__(_base_pool: address, _base_lp_token: address, _base_coins: address[3]): + """ + @notice Contract constructor + """ + + BASE_POOL = _base_pool + BASE_LP_TOKEN = _base_lp_token + BASE_COINS = _base_coins + + base_coins: address[3] = BASE_COINS + for coin in base_coins: + ERC20(coin).approve(BASE_POOL, MAX_UINT256) + + +@external +def add_liquidity( + _pool: address, + _deposit_amounts: uint256[N_ALL_COINS], + _min_mint_amount: uint256, + _receiver: address = msg.sender, +) -> uint256: + """ + @notice Wrap underlying coins and deposit them into `_pool` + @param _pool Address of the pool to deposit into + @param _deposit_amounts List of amounts of underlying coins to deposit + @param _min_mint_amount Minimum amount of LP tokens to mint from the deposit + @param _receiver Address that receives the LP tokens + @return Amount of LP tokens received by depositing + """ + meta_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + base_amounts: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + deposit_base: bool = False + base_coins: address[3] = BASE_COINS + + if _deposit_amounts[0] != 0: + coin: address = CurveMeta(_pool).coins(0) + if not self.is_approved[coin][_pool]: + ERC20(coin).approve(_pool, MAX_UINT256) + self.is_approved[coin][_pool] = True + ERC20(coin).transferFrom(msg.sender, self, _deposit_amounts[0]) + meta_amounts[0] = _deposit_amounts[0] + + for i in range(1, N_ALL_COINS): + amount: uint256 = _deposit_amounts[i] + if amount == 0: + continue + deposit_base = True + base_idx: uint256 = i - 1 + coin: address = base_coins[base_idx] + + ERC20(coin).transferFrom(msg.sender, self, amount) + # Handle potential Tether fees + if i == N_ALL_COINS - 1: + base_amounts[base_idx] = ERC20(coin).balanceOf(self) + else: + base_amounts[base_idx] = amount + + # Deposit to the base pool + if deposit_base: + coin: address = BASE_LP_TOKEN + CurveBase(BASE_POOL).add_liquidity(base_amounts, 0) + meta_amounts[MAX_COIN] = ERC20(coin).balanceOf(self) + if not self.is_approved[coin][_pool]: + ERC20(coin).approve(_pool, MAX_UINT256) + self.is_approved[coin][_pool] = True + + # Deposit to the meta pool + return CurveMeta(_pool).add_liquidity(meta_amounts, _min_mint_amount, _receiver) + + +@external +def remove_liquidity( + _pool: address, + _burn_amount: uint256, + _min_amounts: uint256[N_ALL_COINS], + _receiver: address = msg.sender +) -> uint256[N_ALL_COINS]: + """ + @notice Withdraw and unwrap coins from the pool + @dev Withdrawal amounts are based on current deposit ratios + @param _pool Address of the pool to deposit into + @param _burn_amount Quantity of LP tokens to burn in the withdrawal + @param _min_amounts Minimum amounts of underlying coins to receive + @param _receiver Address that receives the LP tokens + @return List of amounts of underlying coins that were withdrawn + """ + ERC20(_pool).transferFrom(msg.sender, self, _burn_amount) + + min_amounts_base: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + amounts: uint256[N_ALL_COINS] = empty(uint256[N_ALL_COINS]) + + # Withdraw from meta + meta_received: uint256[N_COINS] = CurveMeta(_pool).remove_liquidity( + _burn_amount, + [_min_amounts[0], convert(0, uint256)] + ) + + # Withdraw from base + for i in range(BASE_N_COINS): + min_amounts_base[i] = _min_amounts[MAX_COIN+i] + CurveBase(BASE_POOL).remove_liquidity(meta_received[1], min_amounts_base) + + # Transfer all coins out + coin: address = CurveMeta(_pool).coins(0) + ERC20(coin).transfer(_receiver, meta_received[0]) + amounts[0] = meta_received[0] + + base_coins: address[BASE_N_COINS] = BASE_COINS + for i in range(1, N_ALL_COINS): + coin = base_coins[i-1] + amounts[i] = ERC20(coin).balanceOf(self) + ERC20(coin).transfer(_receiver, amounts[i]) + + return amounts + + +@external +def remove_liquidity_one_coin( + _pool: address, + _burn_amount: uint256, + i: int128, + _min_amount: uint256, + _receiver: address=msg.sender +) -> uint256: + """ + @notice Withdraw and unwrap a single coin from the pool + @param _pool Address of the pool to deposit into + @param _burn_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the coin to withdraw + @param _min_amount Minimum amount of underlying coin to receive + @param _receiver Address that receives the LP tokens + @return Amount of underlying coin received + """ + ERC20(_pool).transferFrom(msg.sender, self, _burn_amount) + + coin_amount: uint256 = 0 + if i == 0: + coin_amount = CurveMeta(_pool).remove_liquidity_one_coin(_burn_amount, i, _min_amount, _receiver) + else: + base_coins: address[BASE_N_COINS] = BASE_COINS + coin: address = base_coins[i - MAX_COIN_128] + # Withdraw a base pool coin + coin_amount = CurveMeta(_pool).remove_liquidity_one_coin(_burn_amount, MAX_COIN_128, 0, self) + CurveBase(BASE_POOL).remove_liquidity_one_coin(coin_amount, i-MAX_COIN_128, _min_amount) + coin_amount = ERC20(coin).balanceOf(self) + ERC20(coin).transfer(_receiver, coin_amount) + + return coin_amount + + +@external +def remove_liquidity_imbalance( + _pool: address, + _amounts: uint256[N_ALL_COINS], + _max_burn_amount: uint256, + _receiver: address=msg.sender +) -> uint256: + """ + @notice Withdraw coins from the pool in an imbalanced amount + @param _pool Address of the pool to deposit into + @param _amounts List of amounts of underlying coins to withdraw + @param _max_burn_amount Maximum amount of LP token to burn in the withdrawal + @param _receiver Address that receives the LP tokens + @return Actual amount of the LP token burned in the withdrawal + """ + fee: uint256 = CurveBase(BASE_POOL).fee() * BASE_N_COINS / (4 * (BASE_N_COINS - 1)) + fee += fee * FEE_IMPRECISION / FEE_DENOMINATOR # Overcharge to account for imprecision + + # Transfer the LP token in + ERC20(_pool).transferFrom(msg.sender, self, _max_burn_amount) + + withdraw_base: bool = False + amounts_base: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + amounts_meta: uint256[N_COINS] = empty(uint256[N_COINS]) + + # determine amounts to withdraw from base pool + for i in range(BASE_N_COINS): + amount: uint256 = _amounts[MAX_COIN + i] + if amount != 0: + amounts_base[i] = amount + withdraw_base = True + + # determine amounts to withdraw from metapool + amounts_meta[0] = _amounts[0] + if withdraw_base: + amounts_meta[MAX_COIN] = CurveBase(BASE_POOL).calc_token_amount(amounts_base, False) + amounts_meta[MAX_COIN] += amounts_meta[MAX_COIN] * fee / FEE_DENOMINATOR + 1 + + # withdraw from metapool and return the remaining LP tokens + burn_amount: uint256 = CurveMeta(_pool).remove_liquidity_imbalance(amounts_meta, _max_burn_amount) + ERC20(_pool).transfer(msg.sender, _max_burn_amount - burn_amount) + + # withdraw from base pool + if withdraw_base: + CurveBase(BASE_POOL).remove_liquidity_imbalance(amounts_base, amounts_meta[MAX_COIN]) + coin: address = BASE_LP_TOKEN + leftover: uint256 = ERC20(coin).balanceOf(self) + + if leftover > 0: + # if some base pool LP tokens remain, re-deposit them for the caller + if not self.is_approved[coin][_pool]: + ERC20(coin).approve(_pool, MAX_UINT256) + self.is_approved[coin][_pool] = True + burn_amount -= CurveMeta(_pool).add_liquidity([convert(0, uint256), leftover], 0, msg.sender) + + # transfer withdrawn base pool tokens to caller + base_coins: address[BASE_N_COINS] = BASE_COINS + for i in range(BASE_N_COINS): + ERC20(base_coins[i]).transfer(_receiver, amounts_base[i]) + + # transfer withdrawn metapool tokens to caller + if _amounts[0] > 0: + coin: address = CurveMeta(_pool).coins(0) + ERC20(coin).transfer(_receiver, _amounts[0]) + + return burn_amount + + +@view +@external +def calc_withdraw_one_coin(_pool: address, _token_amount: uint256, i: int128) -> uint256: + """ + @notice Calculate the amount received when withdrawing and unwrapping a single coin + @param _pool Address of the pool to deposit into + @param _token_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the underlying coin to withdraw + @return Amount of coin received + """ + if i < MAX_COIN_128: + return CurveMeta(_pool).calc_withdraw_one_coin(_token_amount, i) + else: + _base_tokens: uint256 = CurveMeta(_pool).calc_withdraw_one_coin(_token_amount, MAX_COIN_128) + return CurveBase(BASE_POOL).calc_withdraw_one_coin(_base_tokens, i-MAX_COIN_128) + + +@view +@external +def calc_token_amount(_pool: address, _amounts: uint256[N_ALL_COINS], _is_deposit: bool) -> uint256: + """ + @notice Calculate addition or reduction in token supply from a deposit or withdrawal + @dev This calculation accounts for slippage, but not fees. + Needed to prevent front-running, not for precise calculations! + @param _pool Address of the pool to deposit into + @param _amounts Amount of each underlying coin being deposited + @param _is_deposit set True for deposits, False for withdrawals + @return Expected amount of LP tokens received + """ + meta_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + base_amounts: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + + meta_amounts[0] = _amounts[0] + for i in range(BASE_N_COINS): + base_amounts[i] = _amounts[i + MAX_COIN] + + base_tokens: uint256 = CurveBase(BASE_POOL).calc_token_amount(base_amounts, _is_deposit) + meta_amounts[MAX_COIN] = base_tokens + + return CurveMeta(_pool).calc_token_amount(meta_amounts, _is_deposit) diff --git a/poetry.lock b/poetry.lock index 0a56f54e..4b82f300 100644 --- a/poetry.lock +++ b/poetry.lock @@ -4136,8 +4136,8 @@ forking-recommended = ["ujson"] [package.source] type = "git" url = "https://github.com/vyperlang/titanoboa.git" -reference = "b5e9fb96d1424ed5cc5a6af03391d885439c83e5" -resolved_reference = "b5e9fb96d1424ed5cc5a6af03391d885439c83e5" +reference = "7171aee25c4d25fc1626a361a8c972e9316fd383" +resolved_reference = "7171aee25c4d25fc1626a361a8c972e9316fd383" [[package]] name = "tomli" @@ -4805,4 +4805,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "4c5b91e040b8a6e4e32780c15ee09a8f358b5d1dda054625320c40f163f72a61" +content-hash = "40670953f2e928ef535bfedc35d1c0ea4fafd22fff5e96d6123780ba2b92b3cb" diff --git a/pyproject.toml b/pyproject.toml index 08495a71..24b897d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ packages = [] [tool.poetry.dependencies] python = "^3.10" poetry = "1.5.1" -titanoboa = {git = "https://github.com/vyperlang/titanoboa.git", rev = "b5e9fb96d1424ed5cc5a6af03391d885439c83e5"} +titanoboa = {git = "https://github.com/vyperlang/titanoboa.git", rev = "7171aee25c4d25fc1626a361a8c972e9316fd383"} vyper = "0.3.10" pycryptodome = "^3.18.0" pre-commit = "^3.3.3" diff --git a/tests/pools/meta/test_meta_zap.py b/tests/pools/meta/test_meta_zap.py new file mode 100644 index 00000000..7e803fe9 --- /dev/null +++ b/tests/pools/meta/test_meta_zap.py @@ -0,0 +1,179 @@ +import warnings + +import boa +import pytest + +from tests.utils.tokens import mint_for_testing + +warnings.filterwarnings("ignore") + + +@pytest.fixture(scope="module") +def meta_token(deployer): + with boa.env.prank(deployer): + return boa.load( + "contracts/mocks/ERC20.vy", + "OTA", + "OTA", + 18, + ) + + +@pytest.fixture(scope="module") +def metapool_tokens(meta_token, base_pool): + return [meta_token, base_pool] + + +@pytest.fixture(scope="module") +def tokens_all(meta_token, base_pool_tokens): + return [meta_token] + base_pool_tokens + + +@pytest.fixture(scope="module") +def add_base_pool( + owner, + factory, + base_pool, + base_pool_lp_token, + base_pool_tokens, +): + with boa.env.prank(owner): + factory.add_base_pool( + base_pool.address, + base_pool_lp_token.address, + [0] * len(base_pool_tokens), + len(base_pool_tokens), + ) + + +@pytest.fixture(scope="module") +def empty_swap( + deployer, + factory, + zero_address, + meta_token, + base_pool, + amm_interface_meta, + add_base_pool, + set_metapool_implementations, +): + method_id = bytes(b"") + oracle = zero_address + offpeg_fee_multiplier = 20000000000 + asset_type = meta_token.asset_type() + A = 1000 + fee = 3000000 + + with boa.env.prank(deployer): + pool = factory.deploy_metapool( + base_pool.address, # _base_pool: address + "test", # _name: String[32], + "test", # _symbol: String[10], + meta_token.address, # _coin: address, + A, # _A: uint256, + fee, # _fee: uint256, + offpeg_fee_multiplier, + 866, # _ma_exp_time: uint256, + 0, # _implementation_idx: uint256 + asset_type, # _asset_type: uint8 + method_id, # _method_id: bytes4 + oracle, # _oracle: address + ) + + return amm_interface_meta.at(pool) + + +@pytest.fixture(scope="module") +def zap(base_pool, base_pool_tokens, base_pool_lp_token): + return boa.load( + "contracts/mocks/Zap.vy", base_pool.address, base_pool_lp_token.address, [a.address for a in base_pool_tokens] + ) + + +@pytest.fixture(scope="module") +def swap(zap, base_pool, empty_swap, charlie, tokens_all): + + for i in range(3): + assert base_pool.balances(i) == 0 + + deposit_amount = 100 * 10**18 + + for token in tokens_all: + mint_for_testing(charlie, deposit_amount, token, False) + token.approve(zap.address, 2**256 - 1, sender=charlie) + + deposit_amounts = [deposit_amount] * 4 + + out_amount = zap.add_liquidity(empty_swap.address, deposit_amounts, 0, sender=charlie) + assert out_amount > 0 + assert 0 not in empty_swap.get_balances() + assert empty_swap.totalSupply() > 0 + + return empty_swap + + +def test_calc_amts_add(zap, swap, charlie, tokens_all): + + deposit_amount = 2 * 100 * 10**18 + + for token in tokens_all: + mint_for_testing(charlie, deposit_amount, token, False) + token.approve(zap.address, 2**256 - 1, sender=charlie) + + deposit_amounts = [deposit_amount] * 4 + + calc_amt_zap = zap.calc_token_amount(swap.address, deposit_amounts, True) + out_amount = zap.add_liquidity(swap.address, deposit_amounts, 0, sender=charlie) + + assert calc_amt_zap == out_amount + + +def test_calc_amts_remove_imbalance(zap, swap, charlie, tokens_all): + + to_receive_amounts = [10 * 10**18] * 4 + + charlie_bal_before = [] + for token in tokens_all: + charlie_bal_before.append(token.balanceOf(charlie)) + + swap.approve(zap, 2**256 - 1, sender=charlie) + calc_burnt_amt_zap = zap.calc_token_amount(swap.address, to_receive_amounts, False) + actual_burnt_amt = zap.remove_liquidity_imbalance( + swap.address, [int(0.9 * amt) for amt in to_receive_amounts], calc_burnt_amt_zap, sender=charlie + ) + + assert actual_burnt_amt <= calc_burnt_amt_zap + + for i, token in enumerate(tokens_all): + assert token.balanceOf(charlie) > charlie_bal_before[i] + + +def test_calc_amts_remove(zap, swap, charlie, tokens_all, meta_token, base_pool, base_pool_tokens): + + charlie_bal_before = [] + for _t in tokens_all: + charlie_bal_before.append(_t.balanceOf(charlie)) + + charlie_lp_bal_before = swap.balanceOf(charlie) + + with boa.env.anchor(): + amts_received = swap.remove_liquidity(charlie_lp_bal_before, [0, 0], sender=charlie) + base_amts_received = base_pool.remove_liquidity(amts_received[1], [0, 0, 0], sender=charlie) + total_expected_received = [amts_received[0]] + base_amts_received + + total_token_balances = [meta_token.balanceOf(swap)] + [_t.balanceOf(base_pool) for _t in base_pool_tokens] + + swap.approve(zap, 2**256 - 1, sender=charlie) + total_received_amount = zap.remove_liquidity(swap.address, charlie_lp_bal_before, [0] * 4, sender=charlie) + + # tokens owned by zap: + zap_balances = [] + for token in tokens_all: + zap_balances.append(token.balanceOf(zap)) + + charlie_bal_after = [] + for _t in tokens_all: + charlie_bal_after.append(_t.balanceOf(charlie)) + + for i in range(len(tokens_all)): + assert total_token_balances[i] == total_received_amount[i] == total_expected_received[i]