From 80d879a37e4b6786ef75e4b12eb9dfb044a759a1 Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Thu, 28 Jul 2022 17:43:28 +0300 Subject: [PATCH 01/10] feat: add zap for stable swap factory meta pools --- contracts/zaps/ZapStableSwapFactory.vy | 779 +++++++++++++++++++++++++ 1 file changed, 779 insertions(+) create mode 100644 contracts/zaps/ZapStableSwapFactory.vy diff --git a/contracts/zaps/ZapStableSwapFactory.vy b/contracts/zaps/ZapStableSwapFactory.vy new file mode 100644 index 0000000..c2e4f75 --- /dev/null +++ b/contracts/zaps/ZapStableSwapFactory.vy @@ -0,0 +1,779 @@ +# @version 0.3.3 +""" +@title Zap for Curve Factory +@license MIT +@author Curve.Fi +@notice Zap for StableSwap Factory metapools created via CryptoSwap Factory. + Coins are set as [[meta0, base0, base1, ...], [meta1, ...]], + where meta is coin that is used in CryptoSwap(LP token for base pools) and + base is base pool coins or ZERO_ADDRESS when there is no such coins. +@dev Does not work if 2 ETH used in pools, e.g. (ETH, Plain2ETH) +""" + + +interface ERC20: # Custom ERC20 which works for USDT, WETH, WBTC and Curve LP Tokens + def transfer(_receiver: address, _amount: uint256): nonpayable + def transferFrom(_sender: address, _receiver: address, _amount: uint256): nonpayable + def approve(_spender: address, _amount: uint256): nonpayable + def balanceOf(_owner: address) -> uint256: view + + +interface wETH: + def deposit(): payable + def withdraw(_amount: uint256): nonpayable + + +# CurveCryptoSwap2ETH from Crypto Factory +interface CurveMeta: + def coins(i: uint256) -> address: view + def token() -> address: view + def price_oracle() -> uint256: view + def price_scale() -> uint256: view + def lp_price() -> uint256: view + def get_dy(i: uint256, j: uint256, dx: uint256) -> uint256: view + def calc_token_amount(amounts: uint256[META_N_COINS]) -> uint256: view + def calc_withdraw_one_coin(token_amount: uint256, i: uint256) -> uint256: view + def exchange(i: uint256, j: uint256, dx: uint256, min_dy: uint256, use_eth: bool = False, receiver: address = msg.sender) -> uint256: payable + def add_liquidity(amounts: uint256[META_N_COINS], min_mint_amount: uint256, use_eth: bool = False, receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_amount: uint256, min_amounts: uint256[META_N_COINS], use_eth: bool = False, receiver: address = msg.sender): nonpayable + def remove_liquidity_one_coin(token_amount: uint256, i: uint256, min_amount: uint256, use_eth: bool = False, receiver: address = msg.sender) -> uint256: nonpayable + + +interface Factory: + def get_coins(_pool: address) -> address[BASE_MAX_N_COINS]: view + def get_n_coins(_pool: address) -> (uint256): view + + +# Plain2* from StableSwap Factory +interface CurveBase: + def get_virtual_price() -> uint256: view + def coins(i: uint256) -> address: view + def get_dy(i: int128, j: int128, dx: uint256) -> uint256: view + def calc_withdraw_one_coin(_burn_amount: uint256, i: int128) -> uint256: view + def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity_one_coin(_burn_amount: uint256, i: int128, _min_received: uint256, _receiver: address = msg.sender) -> uint256: nonpayable + + +interface CurveBase2: + def add_liquidity(_amounts: uint256[2], _min_mint_amount: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[2], _receiver: address = msg.sender) -> uint256[2]: nonpayable + def calc_token_amount(_amounts: uint256[2], _is_deposit: bool) -> uint256: view + + +interface CurveBase3: + def add_liquidity(_amounts: uint256[3], _min_mint_amount: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[3], _receiver: address = msg.sender) -> uint256[3]: nonpayable + def calc_token_amount(_amounts: uint256[3], _is_deposit: bool) -> uint256: view + + +interface CurveBase4: + def add_liquidity(_amounts: uint256[4], _min_mint_amount: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[4], _receiver: address = msg.sender) -> uint256[4]: nonpayable + def calc_token_amount(_amounts: uint256[4], _is_deposit: bool) -> uint256: view + + +META_N_COINS: constant(uint256) = 2 +BASE_MAX_N_COINS: constant(uint256) = 4 +POOL_N_COINS: constant(uint256) = 1 + BASE_MAX_N_COINS + +ETH_ADDRESS: constant(address) = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE +WETH: immutable(wETH) +STABLE_FACTORY: immutable(Factory) + +# coin -> pool -> is approved to transfer? +is_approved: HashMap[address, HashMap[address, bool]] + + +@external +def __init__(_weth: address, _stable_factory: address): + """ + @notice Contract constructor + """ + WETH = wETH(_weth) + STABLE_FACTORY = Factory(_stable_factory) + + +@external +@payable +def __default__(): + assert msg.sender.is_contract # dev: receive only from pools and WETH + + +# ------------ View methods ------------ + + +@external +@view +def get_coins(_pool: address) -> address[POOL_N_COINS][META_N_COINS]: + """ + @notice Get coins of the pool in current zap representation + @param _pool Address of the pool + @return Addresses of coins used in zap + """ + coins: address[POOL_N_COINS][META_N_COINS] = empty(address[POOL_N_COINS][META_N_COINS]) + + for i in range(META_N_COINS): + # Set i-th meta coin + coins[i][0] = CurveMeta(_pool).coins(i) + + # Set base coins + # If meta coin is not LP Token, `get_coins()` will return [ZERO_ADDRESS] * 4 + base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(coins[i][0]) + for j in range(BASE_MAX_N_COINS): + coins[i][1 + j] = base_coins[j] + + return coins + + +@external +@view +def coins(_pool: address, _i: uint256) -> address: + """ + @notice Get coins of the pool in current zap representation + @param _pool Address of the pool + @param _i Index of the coin + @return Address of `_i` coin used in zap + """ + i_pool: uint256 = _i / POOL_N_COINS + coin: address = CurveMeta(_pool).coins(i_pool) + + # If coin is in base pool + if _i % POOL_N_COINS > 0: + adjusted_i: uint256 = _i % POOL_N_COINS - 1 + coin = CurveBase(coin).coins(adjusted_i) + + return coin + + +@internal +@view +def _calc_price(_pool: address, _0_in_1: bool, _meta_price: uint256) -> uint256: + """ + @notice Calculate price from base token to another base token + @param _pool Address of base pool + @param _0_in_1 If True, calculate price of `0` coin in `1` coin, inverse otherwise + @param _meta_price Price of `meta1` in terms of `meta0` + @return Price with base = 10 ** 18 + """ + vprices: uint256[META_N_COINS] = [10 ** 18, 10 ** 18] + for i in range(META_N_COINS): + coin: address = CurveMeta(_pool).coins(i) + n_coins: uint256 = STABLE_FACTORY.get_n_coins(coin) + # n_coins = 0 for not LP Tokens + if n_coins > 0: + vprices[i] = CurveBase(coin).get_virtual_price() + + # base0 <--vp[0]-- meta0 <--_meta_price-- meta1 --vp[1]--> base1 + if _0_in_1: + return (vprices[1] * 10 ** 18 / vprices[0]) * 10 ** 18 / _meta_price + else: + return vprices[0] * _meta_price / vprices[1] + + +@external +@view +def price_oracle(_pool: address, _0_in_1: bool = True) -> uint256: + """ + @notice Oracle price for underlying assets + @param _pool Address of the pool + @param _0_in_1 If True, calculate price of `0` coin in `1` coin, inverse otherwise + @return Price with base = 10 ** 18 + """ + price: uint256 = CurveMeta(_pool).price_oracle() + return self._calc_price(_pool, _0_in_1, price) + + +@external +@view +def price_scale(_pool: address, _0_in_1: bool = True) -> uint256: + """ + @notice Price scale for underlying assets + @param _pool Address of the pool + @param _0_in_1 If True, calculate price of `0` coin in `1` coin, inverse otherwise + @return Price with base = 10 ** 18 + """ + price: uint256 = CurveMeta(_pool).price_scale() + return self._calc_price(_pool, _0_in_1, price) + + +@external +@view +def lp_price(_pool: address, _i: uint256 = 0) -> uint256: + """ + @notice Price of LP token calculated in `_i` underlying asset + @param _pool Address of the pool + @param _i Index of asset to calculate the price in (0 or 1) + @return LP Token price with base = 10 ** 18 + """ + p: uint256 = CurveMeta(_pool).lp_price() # Price in `0` + coin: address = CurveMeta(_pool).coins(_i) + n_coins: uint256 = STABLE_FACTORY.get_n_coins(coin) + vprice: uint256 = 10 ** 18 + if n_coins > 0: + vprice = CurveBase(_pool).get_virtual_price() + + # lp --p--> meta0 + if _i == 0: + # --vp--> base0 + return p * vprice / 10 ** 18 + else: + # <--price-- meta1 --vp--> base1 + price: uint256 = CurveMeta(_pool).price_oracle() + return p * vprice / price + + +# --------------- Helpers -------------- + + +@internal +@payable +def _receive(_coin: address, _amount: uint256, _use_eth: bool, _eth: bool) -> uint256: + """ + @notice Transfer coin to zap + @param _coin Address of the coin + @param _amount Amount of coin + @param _from Sender of the coin + @param _eth_value Eth value sent + @param _use_eth Use raw ETH + @param _eth Pool uses ETH_ADDRESS for ETH + @return Received ETH amount + """ + coin: address = _coin + if coin == ETH_ADDRESS: + coin = WETH.address # Receive weth if not _use_eth + + if _use_eth and coin == WETH.address: + assert msg.value == _amount # dev: incorrect ETH amount + if _eth and _coin == WETH.address and _amount > 0: + WETH.deposit(value=_amount) + else: + return _amount + elif _amount > 0: + response: Bytes[32] = raw_call( + coin, + _abi_encode( + msg.sender, + self, + _amount, + method_id=method_id("transferFrom(address,address,uint256)"), + ), + max_outsize=32, + ) + if len(response) != 0: + assert convert(response, bool) # dev: failed transfer + + if _coin == ETH_ADDRESS: + WETH.withdraw(_amount) + return _amount + return 0 + + +@internal +def _send(_coin: address, _to: address, _use_eth: bool) -> uint256: + """ + @notice Send coin from zap + @dev Sends all available amount + @param _coin Address of the coin + @param _to Sender of the coin + @param _use_eth Use raw ETH + @return Amount of coin sent + """ + coin: address = _coin + if coin == ETH_ADDRESS: + coin = WETH.address # Send weth if not _use_eth + + amount: uint256 = 0 + if _use_eth and coin == WETH.address: + amount = ERC20(coin).balanceOf(self) + if amount > 0: + WETH.withdraw(amount) + + amount = self.balance + if amount > 0: + raw_call(_to, b"", value=amount) + else: + if coin == WETH.address and self.balance > 0: + WETH.deposit(value=self.balance) + + amount = ERC20(coin).balanceOf(self) + if amount > 0: + response: Bytes[32] = raw_call( + coin, + _abi_encode(_to, amount, method_id=method_id("transfer(address,uint256)")), + max_outsize=32, + ) + if len(response) != 0: + assert convert(response, bool) # dev: failed transfer + return amount + + +@internal +def _approve(_coin: address, _pool: address): + if _coin != ETH_ADDRESS and not self.is_approved[_coin][_pool]: + ERC20(_coin).approve(_pool, MAX_UINT256) + self.is_approved[_coin][_pool] = True + + +@internal +@pure +def _need_to_handle_eth(_use_eth: bool, _coin: address) -> bool: + """ + @notice Handle _use_eth feature for StableSwaps + """ + return (not _use_eth and _coin == ETH_ADDRESS) or (_use_eth and _coin == WETH.address) + + +# -------------- Exchange -------------- + + +@internal +def _add_to_base_one(_pool: address, _amount: uint256, _min_lp: uint256, _i: uint256, + _receiver: address, _eth_amount: uint256) -> uint256: + """ + @notice Provide one token to base pool + @param _pool Address of base pool + @param _amount Amount of token to provide + @param _min_lp Minimum LP amount to receive + @param _i Adjusted index of coin to provide + @param _receiver Receiver of the coin + @param _eth_amount Raw ETH amount to provide + @return LP Token amount received + """ + n_coins: uint256 = STABLE_FACTORY.get_n_coins(_pool) + + if n_coins == 2: + amounts: uint256[2] = empty(uint256[2]) + amounts[_i] = _amount + return CurveBase2(_pool).add_liquidity(amounts, _min_lp, _receiver, value=_eth_amount) + elif n_coins == 3: + amounts: uint256[3] = empty(uint256[3]) + amounts[_i] = _amount + return CurveBase3(_pool).add_liquidity(amounts, _min_lp, _receiver, value=_eth_amount) + elif n_coins == 4: + amounts: uint256[4] = empty(uint256[4]) + amounts[_i] = _amount + return CurveBase4(_pool).add_liquidity(amounts, _min_lp, _receiver, value=_eth_amount) + else: + raise "Incorrect indexes" + + +@external +@payable +def exchange(_pool: address, i: uint256, j: uint256, _dx: uint256, _min_dy: uint256, _use_eth: bool = False, _receiver: address = msg.sender) -> uint256: + """ + @notice Exchange using wETH by default. Indexing = [[0, 1, ...], [5, ..., 9]] + @dev Index values can be found via the `coins` public getter method + @param _pool Address of the pool for the exchange + @param i Index value for the coin to send + @param j Index value of the coin to receive + @param _dx Amount of `i` being exchanged + @param _min_dy Minimum amount of `j` to receive + @param _use_eth Use raw ETH + @param _receiver Address that will receive `j` + @return Actual amount of `j` received + """ + assert i != j # dev: indexes are similar + if not _use_eth: + assert msg.value == 0 # dev: nonzero ETH amount + + i_pool: uint256 = i / POOL_N_COINS + j_pool: uint256 = j / POOL_N_COINS + + pool: address = CurveMeta(_pool).coins(i_pool) + adjusted_i: uint256 = i % POOL_N_COINS + coin: address = pool + if adjusted_i > 0: + adjusted_i -= 1 + coin = CurveBase(pool).coins(adjusted_i) + self._approve(coin, pool) + + eth_amount: uint256 = self._receive(coin, _dx, _use_eth, coin != pool) + if _use_eth and coin not in [ETH_ADDRESS, WETH.address]: + assert msg.value == 0, "Invalid ETH amount" + + receiver: address = self + min_dy: uint256 = 0 + amount: uint256 = _dx + + if coin != pool: # Deposit coin to the pool + if i_pool == j_pool: + if j % POOL_N_COINS > 0: # Exchange in Base + adjusted_j: uint256 = j % POOL_N_COINS - 1 + coin = CurveBase(pool).coins(adjusted_j) + if not self._need_to_handle_eth(_use_eth, coin): + receiver = _receiver + + amount = CurveBase(pool).exchange( + convert(adjusted_i, int128), convert(adjusted_j, int128), amount, _min_dy, receiver, + value=eth_amount, + ) + if receiver == self: + amount = self._send(coin, _receiver, _use_eth) + return amount + else: # `j` is lp token of `i` + receiver = _receiver + min_dy = _min_dy + amount = self._add_to_base_one(pool, amount, min_dy, adjusted_i, receiver, eth_amount) + eth_amount = 0 + coin = pool + + if i_pool != j_pool: # Exchange in Meta + self._approve(coin, _pool) + + if j % POOL_N_COINS == 0: # `j` is meta coin + receiver = _receiver + min_dy = _min_dy + amount = CurveMeta(_pool).exchange(i_pool, j_pool, amount, min_dy, _use_eth, receiver, value=eth_amount) + + coin = CurveMeta(_pool).coins(j_pool) + + if j % POOL_N_COINS > 0: # Remove 1 coin + pool = coin + adjusted_j: uint256 = j % POOL_N_COINS - 1 + coin = CurveBase(pool).coins(adjusted_j) + self._approve(coin, pool) + if not self._need_to_handle_eth(_use_eth, coin): + receiver = _receiver + amount = CurveBase(pool).remove_liquidity_one_coin(amount, convert(adjusted_j, int128), _min_dy, receiver) + + if receiver == _receiver: + return amount + return self._send(coin, _receiver, _use_eth) + + +@internal +@view +def _calc_in_base_one(_pool: address, _amount: uint256, _i: int128) -> uint256: + """ + @notice Calculate base LP token received for providing 1 token + @param _pool Address of the base pool + @param _amount Amount of token to provide + @param _i Adjusted index to provide + @return LP Token amount to receive + """ + n_coins: uint256 = STABLE_FACTORY.get_n_coins(_pool) + + if n_coins == 2: + amounts: uint256[2] = empty(uint256[2]) + amounts[_i] = _amount + return CurveBase2(_pool).calc_token_amount(amounts, True) + elif n_coins == 3: + amounts: uint256[3] = empty(uint256[3]) + amounts[_i] = _amount + return CurveBase3(_pool).calc_token_amount(amounts, True) + elif n_coins == 4: + amounts: uint256[4] = empty(uint256[4]) + amounts[_i] = _amount + return CurveBase4(_pool).calc_token_amount(amounts, True) + else: + raise "Invalid indexes" + + +@external +@view +def get_dy(_pool: address, i: uint256, j: uint256, _dx: uint256) -> uint256: + """ + @notice Calculate the amount received in exchange. Indexing = [[0, 1, ...], [5, ..., 9]] + @dev Index values can be found via the `coins` public getter method + @param _pool Address of the pool for the exchange + @param i Index value for the coin to send + @param j Index value of the coin to receive + @param _dx Amount of `i` being exchanged + @return Expected amount of `j` to receive + """ + assert i != j # dev: indexes are similar + + i_pool: uint256 = i / POOL_N_COINS + j_pool: uint256 = j / POOL_N_COINS + + if i_pool == j_pool: # Coins are in the same pool + pool: address = CurveMeta(_pool).coins(i_pool) + if i % POOL_N_COINS > 0: + adjusted_i: int128 = convert(i % POOL_N_COINS - 1, int128) + if j % POOL_N_COINS > 0: # Exchange in Base + return CurveBase(pool).get_dy(adjusted_i, convert(j % POOL_N_COINS - 1, int128), _dx) + else: # Add 1 coin (j == lp token) + return self._calc_in_base_one(pool, _dx, adjusted_i) + + # Exchange LP token to one of the underlying coins = Remove 1 coin + return CurveBase(pool).calc_withdraw_one_coin(_dx, convert(j % POOL_N_COINS - 1, int128)) + + # Coins are from different pools + + amount: uint256 = _dx + if i % POOL_N_COINS > 0: # Deposit coin to the pool + pool: address = CurveMeta(_pool).coins(i_pool) + amount = self._calc_in_base_one(pool, _dx, convert(i % POOL_N_COINS - 1, int128)) + + # Exchange in Meta + amount = CurveMeta(_pool).get_dy(i_pool, j_pool, amount) + + if j % POOL_N_COINS > 0: # Remove 1 coin + pool: address = CurveMeta(_pool).coins(j_pool) + return CurveBase(pool).calc_withdraw_one_coin(amount, convert(j % POOL_N_COINS - 1, int128)) + + return amount + + +# ------------ Add Liquidity ----------- + + +@internal +def _add_to_base(_pool: address, _amounts: uint256[POOL_N_COINS], _use_eth: bool) -> uint256: + """ + @notice Deposit tokens to base pool + @param _pool Address of the basepool to deposit into + @param _amounts List of amounts of coins to deposit. If only one coin per base pool given, lp token will be used. + @param _use_eth Use raw ETH + @return Amount of LP tokens received by depositing + """ + base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(_pool) + eth_amount: uint256 = 0 + n_coins: uint256 = BASE_MAX_N_COINS + for i in range(BASE_MAX_N_COINS): + coin: address = base_coins[i] + if coin == ZERO_ADDRESS: + n_coins = i + break + eth_amount += self._receive(coin, _amounts[1 + i], _use_eth, True) + self._approve(coin, _pool) + + if n_coins == 2: + amounts: uint256[2] = [_amounts[1], _amounts[2]] + return CurveBase2(_pool).add_liquidity(amounts, 0, self, value=eth_amount) + elif n_coins == 3: + amounts: uint256[3] = [_amounts[1], _amounts[2], _amounts[3]] + return CurveBase3(_pool).add_liquidity(amounts, 0, self, value=eth_amount) + elif n_coins == 4: + amounts: uint256[4] = [_amounts[1], _amounts[2], _amounts[3], _amounts[4]] + return CurveBase4(_pool).add_liquidity(amounts, 0, self, value=eth_amount) + else: + raise "Incorrect amounts" + + +@external +@payable +def add_liquidity( + _pool: address, + _deposit_amounts: uint256[POOL_N_COINS][META_N_COINS], + _min_mint_amount: uint256, + _use_eth: bool = False, + _receiver: address = msg.sender, +) -> uint256: + """ + @notice Deposit tokens to base and meta pools + @dev Providing ETH with _use_eth=True will result in ETH remained in zap. It can be recovered via removing liquidity. + @param _pool Address of the metapool 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 _use_eth Use raw ETH + @param _receiver Address that receives the LP tokens + @return Amount of LP tokens received by depositing + """ + if not _use_eth: + assert msg.value == 0 # dev: nonzero ETH amount + eth_amount: uint256 = 0 + meta_amounts: uint256[META_N_COINS] = empty(uint256[META_N_COINS]) + for i in range(META_N_COINS): + meta_amounts[i] = _deposit_amounts[i][0] + coin: address = CurveMeta(_pool).coins(i) + eth_amount += self._receive(coin, meta_amounts[i], _use_eth, False) + self._approve(coin, _pool) + + for j in range(1, POOL_N_COINS): + if _deposit_amounts[i][j] > 0: + meta_amounts[i] += self._add_to_base(coin, _deposit_amounts[i], _use_eth) + break + + return CurveMeta(_pool).add_liquidity(meta_amounts, _min_mint_amount, _use_eth, _receiver, value=eth_amount) + + +@internal +@view +def _calc_in_base(_pool: address, _amounts: uint256[POOL_N_COINS]) -> uint256: + """ + @notice Calculate base pool LP received after providing `_amounts` + @param _pool Address of the base pool + @param _amounts Amounts to add + @return LP Token amount to receive + """ + n_coins: uint256 = STABLE_FACTORY.get_n_coins(_pool) + + if n_coins == 2: + amounts: uint256[2] = [_amounts[1], _amounts[2]] + return CurveBase2(_pool).calc_token_amount(amounts, True) + elif n_coins == 3: + amounts: uint256[3] = [_amounts[1], _amounts[2], _amounts[3]] + return CurveBase3(_pool).calc_token_amount(amounts, True) + elif n_coins == 4: + amounts: uint256[4] = [_amounts[1], _amounts[2], _amounts[3], _amounts[4]] + return CurveBase4(_pool).calc_token_amount(amounts, True) + else: + raise "Incorrect amounts" + + +@external +@view +def calc_token_amount(_pool: address, _amounts: uint256[POOL_N_COINS][META_N_COINS]) -> uint256: + """ + @notice Calculate addition in token supply from a deposit + @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 + @return Expected amount of LP tokens received + """ + meta_amounts: uint256[META_N_COINS] = empty(uint256[META_N_COINS]) + for i in range(META_N_COINS): + meta_amounts[i] = _amounts[i][0] + for j in range(1, BASE_MAX_N_COINS): + if _amounts[i][j] > 0: + meta_amounts[i] += self._calc_in_base(CurveMeta(_pool).coins(i), _amounts[i]) + break + + return CurveMeta(_pool).calc_token_amount(meta_amounts) + + +# ---------- Remove Liquidity ---------- + + +@internal +def _remove_from_base(_pool: address, _min_amounts: uint256[POOL_N_COINS], _use_eth: bool, _receiver: address) -> uint256[POOL_N_COINS]: + """ + @notice Remove tokens from base pool + @param _pool Address of base pool + @param _min_amounts Minimum amounts to receive + @param _use_eth Use raw ETH + @param _receiver Receiver of coins + @return Received amounts + """ + receiver: address = _receiver + base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(_pool) + n_coins: uint256 = BASE_MAX_N_COINS + for i in range(BASE_MAX_N_COINS): + if base_coins[i] == ZERO_ADDRESS: + n_coins = i + break + if self._need_to_handle_eth(_use_eth, base_coins[i]): # Need to wrap ETH + receiver = self + + burn_amount: uint256 = ERC20(_pool).balanceOf(self) + returned: uint256[POOL_N_COINS] = empty(uint256[POOL_N_COINS]) + if n_coins == 2: + min_amounts: uint256[2] = [_min_amounts[1], _min_amounts[2]] + amounts: uint256[2] = CurveBase2(_pool).remove_liquidity(burn_amount, min_amounts, receiver) + for i in range(2): + returned[1 + i] = amounts[i] + elif n_coins == 3: + min_amounts: uint256[3] = [_min_amounts[1], _min_amounts[2], _min_amounts[3]] + amounts: uint256[3] = CurveBase3(_pool).remove_liquidity(burn_amount, min_amounts, receiver) + for i in range(3): + returned[1 + i] = amounts[i] + elif n_coins == 4: + min_amounts: uint256[4] = [_min_amounts[1], _min_amounts[2], _min_amounts[3], _min_amounts[4]] + amounts: uint256[4] = CurveBase4(_pool).remove_liquidity(burn_amount, min_amounts, receiver) + for i in range(4): + returned[1 + i] = amounts[i] + else: + raise "Invalid min_amounts" + + if receiver == self: + for coin in base_coins: + if coin == ZERO_ADDRESS: + break + self._send(coin, _receiver, _use_eth) + return returned + + +@external +def remove_liquidity( + _pool: address, + _burn_amount: uint256, + _min_amounts: uint256[POOL_N_COINS][META_N_COINS], + _use_eth: bool = False, + _receiver: address = msg.sender, +) -> uint256[POOL_N_COINS][META_N_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 withdraw from + @param _burn_amount Quantity of LP tokens to burn in the withdrawal + @param _min_amounts Minimum amounts of underlying coins to receive. + Amounts for meta coins will be ignored if base amounts provided. + @param _use_eth Use raw ETH + @param _receiver Address that receives the LP tokens + @return List of amounts of underlying coins that were withdrawn + """ + token: address = CurveMeta(_pool).token() + ERC20(token).transferFrom(msg.sender, self, _burn_amount) + CurveMeta(_pool).remove_liquidity(_burn_amount, [_min_amounts[0][0], _min_amounts[1][0]], _use_eth) + + returned: uint256[POOL_N_COINS][META_N_COINS] = empty(uint256[POOL_N_COINS][META_N_COINS]) + for i in range(META_N_COINS): + removed_from_base: bool = False + pool: address = CurveMeta(_pool).coins(i) + for j in range(1, POOL_N_COINS): + if _min_amounts[i][j] > 0: + returned[i] = self._remove_from_base(pool, _min_amounts[i], _use_eth, _receiver) + removed_from_base = True + break + if not removed_from_base: + returned[i][0] = self._send(pool, _receiver, _use_eth) + return returned + + +@external +def remove_liquidity_one_coin( + _pool: address, + _burn_amount: uint256, + i: uint256, + _min_amount: uint256, + _use_eth: bool = False, + _receiver: address = msg.sender, +) -> uint256: + """ + @notice Withdraw and unwrap a single coin from the pool + @param _pool Address of the pool to withdraw from + @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 _use_eth Use raw ETH + @param _receiver Address that receives the LP tokens + @return Amount of underlying coin received + """ + token: address = CurveMeta(_pool).token() + ERC20(token).transferFrom(msg.sender, self, _burn_amount) + + pool_i: uint256 = i / POOL_N_COINS + + if i % POOL_N_COINS == 0: + return CurveMeta(_pool).remove_liquidity_one_coin(_burn_amount, pool_i, _min_amount, _use_eth, _receiver) + amount: uint256 = CurveMeta(_pool).remove_liquidity_one_coin(_burn_amount, pool_i, 0, _use_eth) + + pool: address = CurveMeta(_pool).coins(pool_i) + adjusted_i: uint256 = i % POOL_N_COINS - 1 + + coin: address = CurveBase(pool).coins(adjusted_i) + if not self._need_to_handle_eth(_use_eth, coin): + return CurveBase(pool).remove_liquidity_one_coin(amount, convert(adjusted_i, int128), _min_amount, _receiver) + CurveBase(pool).remove_liquidity_one_coin(amount, convert(adjusted_i, int128), _min_amount) + return self._send(WETH.address, _receiver, _use_eth) + + +@external +@view +def calc_withdraw_one_coin(_pool: address, _token_amount: uint256, i: uint256) -> uint256: + """ + @notice Calculate the amount received when withdrawing and unwrapping a single coin + @param _pool Address of the pool to withdraw from + @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 to receive + """ + pool_i: uint256 = i / POOL_N_COINS + amount: uint256 = CurveMeta(_pool).calc_withdraw_one_coin(_token_amount, pool_i) + if i % POOL_N_COINS > 0: + pool: address = CurveMeta(_pool).coins(pool_i) + adjusted_i: int128 = convert(i % POOL_N_COINS - 1, int128) + return CurveBase(pool).calc_withdraw_one_coin(amount, adjusted_i) + return amount From 1d9405efb3dc6e8f0869bab06ebe8b5cce230bed Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Thu, 28 Jul 2022 17:46:53 +0300 Subject: [PATCH 02/10] feat: add tests for stable swap factory zap --- tests/zaps_factory/forked/conftest.py | 264 ++++++++++++++++++ .../zaps_factory/forked/test_add_liquidity.py | 115 ++++++++ tests/zaps_factory/forked/test_exchange.py | 107 +++++++ .../forked/test_remove_liquidity.py | 74 +++++ .../forked/test_remove_liquidity_one_coin.py | 46 +++ tests/zaps_factory/forked/test_view.py | 50 ++++ 6 files changed, 656 insertions(+) create mode 100644 tests/zaps_factory/forked/conftest.py create mode 100644 tests/zaps_factory/forked/test_add_liquidity.py create mode 100644 tests/zaps_factory/forked/test_exchange.py create mode 100644 tests/zaps_factory/forked/test_remove_liquidity.py create mode 100644 tests/zaps_factory/forked/test_remove_liquidity_one_coin.py create mode 100644 tests/zaps_factory/forked/test_view.py diff --git a/tests/zaps_factory/forked/conftest.py b/tests/zaps_factory/forked/conftest.py new file mode 100644 index 0000000..134d5f6 --- /dev/null +++ b/tests/zaps_factory/forked/conftest.py @@ -0,0 +1,264 @@ +import json + +import pytest +from brownie import ETH_ADDRESS, ZERO_ADDRESS, Contract +from brownie.project.main import get_loaded_projects + +_data = {} + + +def pytest_addoption(parser): + parser.addoption("--deployed_data", help="addresses of deployed contracts") + + +def pytest_generate_tests(metafunc): + deployed_data = metafunc.config.getoption("deployed_data", "mainnet") + if deployed_data: + project = get_loaded_projects()[0] + with open( + f"{project._path}/contracts/testing/stable_factory/data/{deployed_data}.json", "r" + ) as f: + global _data + _data = json.load(f) + if "mintable_fork_token" in metafunc.fixturenames: + metafunc.parametrize("mintable_fork_token", [deployed_data], indirect=True, scope="session") + + +@pytest.fixture(scope="session", autouse=True) +def debug_available(): + """debug_traceTransaction""" + yield _data.get("debug_available", True) + + +@pytest.fixture(scope="module") +def weth(mintable_fork_token): + yield mintable_fork_token(_data["weth"]) + + +@pytest.fixture(scope="module") +def new_coin(ERC20Mock, alice): + def deploy(): + return ERC20Mock.deploy("Test coin", "TST", 18, {"from": alice}) + + return deploy + + +@pytest.fixture( + scope="module", + params=[ + ("weth", "coin"), + ("coin", "coin"), + ("coin", "weth"), + ("weth", (False, 2)), + ("coin", (False, 4)), + ((False, 2), "weth"), + ((True, 3), "coin"), + ((False, 2), (False, 2)), + ((True, 2), (False, 2)), + (("weth", 2), "coin"), + ((False, 3), ("weth", 4)), + ], +) +def all_coins(weth, stable_factory, new_coin, mintable_fork_token, alice, request): + coins = [] + for i in range(2): + coin_type = request.param[i] + if isinstance(coin_type, str): + if coin_type == "weth": + coins.append([weth]) + else: + coins.append([new_coin()]) + elif coin_type[0] == "weth": + _coins = [new_coin() for _ in range(0, coin_type[1] - 1)] + [weth] + address = stable_factory.deploy_plain_pool( + "Test pool with WETH", + "TST", + _coins + [ZERO_ADDRESS] * (4 - len(_coins)), + 100, + 4000000, + 3, # asset type + 1, # implementation idx + {"from": alice}, + ).return_value + implementation = Contract( + stable_factory.plain_implementations(len(_coins), 2 if coin_type[0] else 3) + ) + coins.append([Contract.from_abi("StableSwap", address, implementation.abi)] + _coins) + else: + address = _data["base_pool"][str(coin_type[1])]["eth" if coin_type[0] else "no_eth"] + if address: + coins.append([mintable_fork_token(address)]) + coins[-1] += [ + mintable_fork_token(weth.address if coin == ETH_ADDRESS else coin) + for coin in stable_factory.get_coins(coins[-1][0]) + if coin != ZERO_ADDRESS + ] + else: + _coins = [ETH_ADDRESS if coin_type[0] else new_coin()] + _coins += [new_coin() for _ in range(1, coin_type[1])] + address = stable_factory.deploy_plain_pool( + f"Test pool{' with ETH' if coin_type[0] else ''}", + "TST", + _coins + [ZERO_ADDRESS] * (4 - len(_coins)), + 100, + 4000000, + 3, # asset type + 2 if coin_type[0] else 3, # implementation idx + {"from": alice}, + ).return_value + implementation = Contract( + stable_factory.plain_implementations(len(_coins), 2 if coin_type[0] else 3) + ) + coins.append( + [Contract.from_abi("StableSwap", address, implementation.abi)] + _coins + ) + coins[-1] = [coin if coin != ETH_ADDRESS else weth for coin in coins[-1]] + yield coins + + +@pytest.fixture(scope="module") +def coins_flat(all_coins): + """List of all coins instead of structured.""" + return [c for cs in all_coins for c in cs] + + +@pytest.fixture(scope="module") +def meta_coins(all_coins): + return [all_coins[i][0] for i in range(2)] + + +@pytest.fixture(scope="module") +def initial_price(): + return 10**18 + + +@pytest.fixture(scope="module") +def meta_swap(CurveCryptoSwap2ETH, factory, coins, meta_coins, initial_price, alice): + symbol = f"{meta_coins[0].symbol()[:4]}/{meta_coins[1].symbol()[:5]}" # String[10] + address = factory.deploy_pool( + f"{symbol} metapool", + symbol, + meta_coins, + 90 * 2**2 * 10000, # A + int(2.8e-4 * 1e18), # gamma + int(5e-4 * 1e10), # mid_fee + int(4e-3 * 1e10), # out_fee + 10**10, # allowed_extra_profit + int(0.012 * 1e18), # fee_gamma + int(0.55e-5 * 1e18), # adjustment_step + 0, # admin_fee + 600, # ma_half_time + initial_price, + {"from": alice}, + ).return_value + yield CurveCryptoSwap2ETH.at(address) + + +@pytest.fixture(scope="module") +def meta_token(CurveTokenV5, meta_swap): + yield CurveTokenV5.at(meta_swap.token()) + + +@pytest.fixture(scope="module") +def zap(ZapStableSwapFactory, weth, stable_factory, alice): + contract = ZapStableSwapFactory.deploy(weth, stable_factory, {"from": alice}) + return contract + + +@pytest.fixture(scope="session") +def default_amount(): + return 10 # Small for tiny pools + + +@pytest.fixture(scope="module") +def default_amounts(all_coins, default_amount): + amounts = [] + for base_coins in all_coins: + amounts.append([]) + if len(base_coins) == 1: + amounts[-1] += [default_amount * 10 ** base_coins[0].decimals()] + else: + amounts[-1] += [0] + for c in base_coins[1:]: + amounts[-1] += [default_amount * 10 ** c.decimals()] + amounts[-1] += [0] * (5 - len(amounts[-1])) + return amounts + + +@pytest.fixture(scope="module") +def factory(factory, Factory): + yield Factory.at(_data["crypto_factory"]) + + +@pytest.fixture(scope="module") +def stable_factory(): + yield Contract(_data["stable_factory"]) + + +@pytest.fixture(scope="module", autouse=True) +def pre_mining(alice, coins_flat, weth, default_amount): + """Mint a bunch of test tokens""" + if weth.balanceOf(alice) > 0: + weth.transfer(ZERO_ADDRESS, weth.balanceOf(alice), {"from": alice}) + for coin in coins_flat: + amount = 2 * default_amount * 10 ** coin.decimals() + if hasattr(coin, "_mint_for_testing"): + coin._mint_for_testing(alice, amount, {"from": alice}) + + # Get ETH + amount = 2 * default_amount * 10 ** weth.decimals() + weth._mint_for_testing(alice, amount, {"from": alice}) + weth.withdraw(amount, {"from": alice}) + + +@pytest.fixture(scope="module", autouse=True) +def approve_zap(zap, alice, bob, coins_flat, meta_token): + MAX_UINT256 = 2**256 - 1 + for coin in coins_flat + [meta_token]: + coin.approve(zap, MAX_UINT256, {"from": alice}) + coin.approve(zap, MAX_UINT256, {"from": bob}) + + +@pytest.fixture(scope="module", autouse=True) +def add_initial_liquidity( + pre_mining, zap, all_coins, weth, default_amount, meta_swap, meta_coins, alice +): + """Always add initial liquidity to get LP Tokens and have liquidity in meta pool.""" + amounts = [default_amount * 10 ** coin.decimals() for coin in meta_coins] + for i, base_coins in enumerate(all_coins): + if len(base_coins) > 1: + pool = base_coins[0] + underlying_coins = base_coins[1:] + underlying_amounts = [ + default_amount * 10 ** coin.decimals() for coin in underlying_coins + ] + + for coin, amount in zip(underlying_coins, underlying_amounts): + coin.approve(pool, amount, {"from": alice}) + pool.add_liquidity( + underlying_amounts, + 0, + { + "from": alice, + "value": default_amount * 10**18 if weth == underlying_coins[0] else 0, + }, + ) + if weth == underlying_coins[0]: # Remove extra weth + weth.transfer(ZERO_ADDRESS, default_amount * 10**18, {"from": alice}) + + # Keep some LP for tests + amounts[i] = pool.balanceOf(alice) // 2 + base_coins[0].approve(meta_swap, amounts[i], {"from": alice}) + + meta_swap.add_liquidity(amounts, 0, {"from": alice}) + + +@pytest.fixture(scope="module") +def zap_is_broke(zap, coins_flat, meta_token): + def inner(): + assert zap.balance() == 0 + for coin in coins_flat + [meta_token]: + if coin != ETH_ADDRESS: + assert coin.balanceOf(zap) == 0 + + return inner diff --git a/tests/zaps_factory/forked/test_add_liquidity.py b/tests/zaps_factory/forked/test_add_liquidity.py new file mode 100644 index 0000000..0410009 --- /dev/null +++ b/tests/zaps_factory/forked/test_add_liquidity.py @@ -0,0 +1,115 @@ +import brownie +import pytest +from brownie import chain + + +def get_amounts(use_coins, default_amounts, all_coins, alice): + if use_coins == "underlying": + amounts = default_amounts + elif use_coins == "all": + amounts = [ + [ + all_coins[i][j].balanceOf(alice) if j < len(all_coins[i]) else 0 + for j in range(len(default_amounts[i])) + ] + for i in range(len(default_amounts)) + ] + else: + amounts = [ + [all_coins[i][0].balanceOf(alice)] + [0] * 4 for i in range(len(default_amounts)) + ] + return amounts + + +def get_send_eth(amounts, all_coins, weth, use_eth): + if use_eth: + for i in range(len(amounts)): + if (sum(amounts[i][1:]) > 0 and weth in all_coins[i][1:]) or ( + amounts[i][0] > 0 and weth == all_coins[i][0] + ): + return True + return False + + +@pytest.mark.parametrize("use_eth", [False, True]) +def test_multiple_coins( + zap, + weth, + all_coins, + meta_swap, + meta_token, + alice, + bob, + balances_do_not_change, + debug_available, + default_amount, + default_amounts, + use_eth, + zap_is_broke, +): + use_coins_variations = ["meta"] + ( + ["underlying", "all"] if len(all_coins[0] + all_coins[1]) > 2 else [] + ) + for use_coins in use_coins_variations: + amounts = get_amounts(use_coins, default_amounts, all_coins, alice) + send_eth = get_send_eth(amounts, all_coins, weth, use_eth) + + with balances_do_not_change([meta_token], alice): + calculated = zap.calc_token_amount(meta_swap, amounts) + tx = zap.add_liquidity( + meta_swap, + amounts, + 0.99 * calculated, + use_eth, + bob, + {"from": alice, "value": default_amount * 10**18 if send_eth else 0}, + ) + lp_received = tx.return_value if debug_available else meta_token.balanceOf(bob) + + zap_is_broke() + for i in range(len(amounts)): + for j in range(len(amounts[i])): + if amounts[i][j] > 0 and not (all_coins[i][j] == weth and use_eth): + assert all_coins[i][j].balanceOf(alice) == 0 + + assert meta_token.balanceOf(bob) == lp_received > 0 + assert abs(lp_received - calculated) <= 10 ** meta_token.decimals() + + chain.undo() + + +@pytest.mark.parametrize("use_eth", [False, True]) +def test_bad_arguments( + zap, + weth, + all_coins, + meta_swap, + alice, + bob, + default_amount, + default_amounts, + use_eth, +): + use_coins_variations = ["meta"] + ( + ["underlying", "all"] if len(all_coins[0] + all_coins[1]) > 2 else [] + ) + for use_coins in use_coins_variations: + amounts = get_amounts(use_coins, default_amounts, all_coins, alice) + send_eth = get_send_eth(amounts, all_coins, weth, use_eth) + + # Currently can receive ETH + # with brownie.reverts(): # incorrect ETH amount + # zap.add_liquidity( + # meta_swap, amounts, 0, use_eth, bob, {"from": alice, "value": default_amount * 10 ** 18 if not send_eth else 0} + # ) + + with brownie.reverts(): # slippage + calculated = zap.calc_token_amount(meta_swap, amounts) + zap.add_liquidity( + meta_swap, + amounts, + 1.01 * calculated, + use_eth, + bob, + {"from": alice, "value": default_amount * 10**18 if send_eth else 0}, + ) diff --git a/tests/zaps_factory/forked/test_exchange.py b/tests/zaps_factory/forked/test_exchange.py new file mode 100644 index 0000000..e17b8c1 --- /dev/null +++ b/tests/zaps_factory/forked/test_exchange.py @@ -0,0 +1,107 @@ +import itertools + +import brownie +import pytest +from brownie import chain + + +@pytest.mark.parametrize("use_eth", [False, True]) +def test_exchange( + zap, + weth, + all_coins, + coins_flat, + meta_swap, + meta_token, + alice, + bob, + balances_do_not_change, + debug_available, + use_eth, + zap_is_broke, +): + indexes = [5 * j + i for j in range(len(all_coins)) for i in range(len(all_coins[j]))] + for i, j in itertools.combinations(indexes, 2): + initial_balances = {alice: alice.balance(), bob: bob.balance()} + coin_i = all_coins[i // 5][i % 5] + coin_j = all_coins[j // 5][j % 5] + dx = coin_i.balanceOf(alice) + send_eth = use_eth and coin_i == weth + + with balances_do_not_change( + [meta_token] + [coin for coin in coins_flat if coin != coin_i], alice + ): + calculated = zap.get_dy(meta_swap, i, j, dx) + tx = zap.exchange( + meta_swap, + i, + j, + dx, + 0.99 * calculated, + use_eth, + bob, + {"from": alice, "value": dx if send_eth else 0}, + ) + received = tx.return_value if debug_available else all_coins[j].balanceOf(bob) + + zap_is_broke() + + # Check Alice's balances + if use_eth and coin_i == weth: + assert initial_balances[alice] - alice.balance() == dx > 0 + else: + assert initial_balances[alice] == alice.balance() + assert coin_i.balanceOf(alice) == 0 + + # Check Bob's balances + assert coin_i.balanceOf(bob) == 0 + if use_eth and coin_j == weth: + assert bob.balance() - initial_balances[bob] == received > 0 + else: + assert bob.balance() == initial_balances[bob] + assert coin_j.balanceOf(bob) == received + assert abs(received - calculated) <= 10 ** coin_j.decimals() + + chain.undo() + + +@pytest.mark.parametrize("use_eth", [False, True]) +def test_bad_values( + zap, + weth, + all_coins, + meta_swap, + alice, + bob, + use_eth, +): + indexes = [5 * j + i for j in range(len(all_coins)) for i in range(len(all_coins[j]))] + for i, j in itertools.combinations(indexes, 2): + coin_i = all_coins[i // 5][i % 5] + dx = coin_i.balanceOf(alice) + send_eth = use_eth and coin_i == weth + + with brownie.reverts(): # invalid ETH amount + zap.exchange( + meta_swap, + i, + j, + dx, + 0, + use_eth, + bob, + {"from": alice, "value": dx if not send_eth else 0}, + ) + + with brownie.reverts(): # slippage + calculated = zap.get_dy(meta_swap, i, j, dx) + zap.exchange( + meta_swap, + i, + j, + dx, + 1.01 * calculated, + use_eth, + bob, + {"from": alice, "value": dx if send_eth else 0}, + ) diff --git a/tests/zaps_factory/forked/test_remove_liquidity.py b/tests/zaps_factory/forked/test_remove_liquidity.py new file mode 100644 index 0000000..f03dbca --- /dev/null +++ b/tests/zaps_factory/forked/test_remove_liquidity.py @@ -0,0 +1,74 @@ +import pytest +from brownie import ETH_ADDRESS, chain + + +@pytest.mark.parametrize("use_eth", [False, True]) +def test_remove_liquidity( + zap, + weth, + all_coins, + coins_flat, + default_amount, + default_amounts, + meta_swap, + meta_token, + alice, + bob, + balances_do_not_change, + debug_available, + use_eth, + zap_is_broke, +): + use_coins_variations = ["meta"] + ( + ["underlying"] if len(all_coins[0] + all_coins[1]) > 2 else [] + ) + for use_coins in use_coins_variations: + lp_initial_amount = meta_token.balanceOf(alice) + initial_balance = bob.balance() + + if use_coins == "underlying": + min_amounts = [[amount // 5 for amount in amounts] for amounts in default_amounts] + else: + min_amounts = [ + [len(all_coins[i]) * default_amount * 10**18 // 10] + [0] * 4 + for i in range(len(all_coins)) + ] + + with balances_do_not_change(coins_flat + [ETH_ADDRESS], alice): + tx = zap.remove_liquidity( + meta_swap, + lp_initial_amount // 2, + min_amounts, + use_eth, + bob, + {"from": alice}, + ) + if debug_available: + amounts_received = tx.return_value + else: + raise NotImplementedError + + zap_is_broke() + + for i, amounts in enumerate(amounts_received): + for j, amount in enumerate(amounts): + if j >= len(all_coins[i]): + assert amount == 0, "Received coin that should not be sent" + else: + if min_amounts[i][j] == 0: + assert amount == 0, "Received coin that should not be sent" + else: + assert amount >= min_amounts[i][j], "Received less than expected" + + coin = all_coins[i][j] + if coin == weth and use_eth: + assert ( + bob.balance() - initial_balance == amount + ), "Probably received WETH instead of raw ETH" + assert coin.balanceOf(bob) == 0, "Received WETH instead of raw ETH" + continue + assert coin.balanceOf(bob) == amount, "Received incorrect amount of coin" + if weth not in coins_flat: + assert bob.balance() == initial_balance + + chain.undo() diff --git a/tests/zaps_factory/forked/test_remove_liquidity_one_coin.py b/tests/zaps_factory/forked/test_remove_liquidity_one_coin.py new file mode 100644 index 0000000..1e8072b --- /dev/null +++ b/tests/zaps_factory/forked/test_remove_liquidity_one_coin.py @@ -0,0 +1,46 @@ +import pytest +from brownie import ETH_ADDRESS, chain + + +@pytest.mark.parametrize("use_eth", [False, True]) +def test_remove_liquidity_one_coin( + zap, + weth, + all_coins, + coins_flat, + meta_swap, + meta_token, + alice, + bob, + balances_do_not_change, + debug_available, + use_eth, + zap_is_broke, +): + indexes = [5 * j + i for j in range(len(all_coins)) for i in range(len(all_coins[j]))] + for idx in indexes: + initial_lp_balance = meta_token.balanceOf(alice) + lp_amount = initial_lp_balance // 2 + initial_balances = {bob: bob.balance()} + + coin = all_coins[idx // 5][idx % 5] + + with balances_do_not_change(coins_flat + [ETH_ADDRESS], alice): + calculated = zap.calc_withdraw_one_coin(meta_swap, lp_amount, idx) + tx = zap.remove_liquidity_one_coin( + meta_swap, lp_amount, idx, 0.99 * calculated, use_eth, bob, {"from": alice} + ) + received = tx.return_value if debug_available else coin.balanceOf(bob) + + zap_is_broke() + + assert meta_token.balanceOf(alice) + lp_amount == initial_lp_balance + + if use_eth and coin == weth: + assert bob.balance() == initial_balances[bob] + received + assert coin.balanceOf(bob) == 0 + else: + assert bob.balance() == initial_balances[bob] + assert coin.balanceOf(bob) == received > 0 + assert abs(received - calculated) <= 10 ** coin.decimals() + chain.undo() diff --git a/tests/zaps_factory/forked/test_view.py b/tests/zaps_factory/forked/test_view.py new file mode 100644 index 0000000..f0057d0 --- /dev/null +++ b/tests/zaps_factory/forked/test_view.py @@ -0,0 +1,50 @@ +import brownie +import pytest +from brownie import ETH_ADDRESS, ZERO_ADDRESS + + +def test_get_coins(zap, meta_swap, all_coins, weth): + zap_coins = zap.get_coins(meta_swap) + for base_coins_zap, base_coins in zip(zap_coins, all_coins): + base_coins += [ZERO_ADDRESS] * (len(base_coins_zap) - len(base_coins)) + for coin, coin_zap in zip(base_coins, base_coins_zap): + if coin_zap == ETH_ADDRESS: + assert coin == weth, "Coins do not match" + else: + assert coin_zap == coin, "Coins do not match" + + +def test_coins(zap, meta_swap, all_coins, weth): + for i in range(10): + if i % 5 < len(all_coins[i // 5]) and all_coins[i // 5][i % 5] != ZERO_ADDRESS: + real_coin = all_coins[i // 5][i % 5] + zap_coin = zap.coins(meta_swap, i) + if zap_coin == brownie.ETH_ADDRESS: + zap_coin = weth + assert zap_coin == real_coin, "Coins do not match" + else: + with brownie.reverts(): + zap.coins(meta_swap, i) + + +def test_price_oracle(zap, meta_swap, initial_price): + _0_to_1 = int(zap.price_oracle(meta_swap)) + _1_to_0 = int(zap.price_oracle(meta_swap, False)) + assert _0_to_1 == pytest.approx(initial_price) + assert _1_to_0 == pytest.approx(initial_price) + assert _0_to_1 * _1_to_0 == pytest.approx(10 ** (2 * 18)) + + +def test_price_scale(zap, meta_swap, initial_price): + _0_to_1 = int(zap.price_scale(meta_swap)) + _1_to_0 = int(zap.price_scale(meta_swap, False)) + assert _0_to_1 == pytest.approx(initial_price) + assert _1_to_0 == pytest.approx(initial_price) + assert _0_to_1 * _1_to_0 == pytest.approx(10 ** (2 * 18)) + + +def test_lp_price(zap, meta_swap): + lp_price = int(zap.lp_price(meta_swap)) + lp_price_inverse = int(zap.lp_price(meta_swap, 1)) + assert lp_price == pytest.approx(2 * 10**18) + assert lp_price * lp_price_inverse == pytest.approx((2 * 10**18) ** 2) From a9962daf5f56a51b3d02b1e6360bb3ee2ac2272b Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Mon, 1 Aug 2022 16:28:46 +0300 Subject: [PATCH 03/10] fix: lp price calculation and range in calc token amount --- contracts/zaps/ZapStableSwapFactory.vy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/zaps/ZapStableSwapFactory.vy b/contracts/zaps/ZapStableSwapFactory.vy index c2e4f75..04e72dd 100644 --- a/contracts/zaps/ZapStableSwapFactory.vy +++ b/contracts/zaps/ZapStableSwapFactory.vy @@ -210,7 +210,7 @@ def lp_price(_pool: address, _i: uint256 = 0) -> uint256: n_coins: uint256 = STABLE_FACTORY.get_n_coins(coin) vprice: uint256 = 10 ** 18 if n_coins > 0: - vprice = CurveBase(_pool).get_virtual_price() + vprice = CurveBase(coin).get_virtual_price() # lp --p--> meta0 if _i == 0: @@ -626,7 +626,7 @@ def calc_token_amount(_pool: address, _amounts: uint256[POOL_N_COINS][META_N_COI meta_amounts: uint256[META_N_COINS] = empty(uint256[META_N_COINS]) for i in range(META_N_COINS): meta_amounts[i] = _amounts[i][0] - for j in range(1, BASE_MAX_N_COINS): + for j in range(1, POOL_N_COINS): if _amounts[i][j] > 0: meta_amounts[i] += self._calc_in_base(CurveMeta(_pool).coins(i), _amounts[i]) break From e6bc764ddf5138f7d68c38d60e30065946ba73cb Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Mon, 1 Aug 2022 19:54:47 +0300 Subject: [PATCH 04/10] feat: add zap with only one stable factory as base --- .../testing/stable_factory/data/mainnet.json | 28 + contracts/zaps/ZapStableSwapFactoryOne.vy | 746 ++++++++++++++++++ tests/zaps/conftest.py | 18 +- tests/zaps/forked/conftest.py | 4 +- tests/zaps/forked/test_view.py | 5 +- tests/zaps_factory/forked/conftest.py | 2 +- 6 files changed, 796 insertions(+), 7 deletions(-) create mode 100644 contracts/testing/stable_factory/data/mainnet.json create mode 100644 contracts/zaps/ZapStableSwapFactoryOne.vy diff --git a/contracts/testing/stable_factory/data/mainnet.json b/contracts/testing/stable_factory/data/mainnet.json new file mode 100644 index 0000000..510fd2d --- /dev/null +++ b/contracts/testing/stable_factory/data/mainnet.json @@ -0,0 +1,28 @@ +{ + "swap": "0x3CFAa1596777CAD9f5004F9a0c443d912E262243", + "token": "0x3CFAa1596777CAD9f5004F9a0c443d912E262243", + "coins": [ + "0x68037790A0229e9Ce6EaA8A99ea92964106C4703", + "0xC581b735A1688071A1746c968e0798D642EDE491", + "0xdB25f211AB05b1c97D595516F45794528a807ad8", + "0xD71eCFF9342A5Ced620049e616c5035F1dB98620" + ], + "weth_idx": 0, + "weth": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "base_pool": { + "2": { + "eth": "", + "no_eth": "" + }, + "3": { + "eth": "", + "no_eth": "" + }, + "4": { + "eth": "", + "no_eth": "" + } + }, + "factory": "0xF18056Bbd320E96A48e3Fbf8bC061322531aac99", + "stable_factory": "0xB9fC157394Af804a3578134A6585C0dc9cc990d4" +} \ No newline at end of file diff --git a/contracts/zaps/ZapStableSwapFactoryOne.vy b/contracts/zaps/ZapStableSwapFactoryOne.vy new file mode 100644 index 0000000..63bf34c --- /dev/null +++ b/contracts/zaps/ZapStableSwapFactoryOne.vy @@ -0,0 +1,746 @@ +# @version 0.3.3 +""" +@title Zap for Curve Factory +@license MIT +@author Curve.Fi +@notice Zap for StableSwap Factory metapools created via CryptoSwap Factory. + Coins are set as [meta0, meta1, base0, base1, ...], + where meta is coin that is used in CryptoSwap(LP token for base pools) and + base is base pool coins or ZERO_ADDRESS when there is no such coins. +@dev Does not work if 2 ETH used in pools, e.g. (ETH, Plain2ETH) +""" + + +interface ERC20: # Custom ERC20 which works for USDT, WETH, WBTC and Curve LP Tokens + def transfer(_receiver: address, _amount: uint256): nonpayable + def transferFrom(_sender: address, _receiver: address, _amount: uint256): nonpayable + def approve(_spender: address, _amount: uint256): nonpayable + def balanceOf(_owner: address) -> uint256: view + + +interface wETH: + def deposit(): payable + def withdraw(_amount: uint256): nonpayable + + +# CurveCryptoSwap2ETH from Crypto Factory +interface CurveMeta: + def coins(i: uint256) -> address: view + def token() -> address: view + def price_oracle() -> uint256: view + def price_scale() -> uint256: view + def lp_price() -> uint256: view + def get_dy(i: uint256, j: uint256, dx: uint256) -> uint256: view + def calc_token_amount(amounts: uint256[META_N_COINS]) -> uint256: view + def calc_withdraw_one_coin(token_amount: uint256, i: uint256) -> uint256: view + def exchange(i: uint256, j: uint256, dx: uint256, min_dy: uint256, use_eth: bool = False, receiver: address = msg.sender) -> uint256: payable + def add_liquidity(amounts: uint256[META_N_COINS], min_mint_amount: uint256, use_eth: bool = False, receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_amount: uint256, min_amounts: uint256[META_N_COINS], use_eth: bool = False, receiver: address = msg.sender): nonpayable + def remove_liquidity_one_coin(token_amount: uint256, i: uint256, min_amount: uint256, use_eth: bool = False, receiver: address = msg.sender) -> uint256: nonpayable + + +interface Factory: + def get_coins(_pool: address) -> address[BASE_MAX_N_COINS]: view + def get_n_coins(_pool: address) -> (uint256): view + + +# Plain2* from StableSwap Factory +interface CurveBase: + def get_virtual_price() -> uint256: view + def coins(i: uint256) -> address: view + def get_dy(i: int128, j: int128, dx: uint256) -> uint256: view + def calc_withdraw_one_coin(_burn_amount: uint256, i: int128) -> uint256: view + def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity_one_coin(_burn_amount: uint256, i: int128, _min_received: uint256, _receiver: address = msg.sender) -> uint256: nonpayable + + +interface CurveBase2: + def add_liquidity(_amounts: uint256[2], _min_mint_amount: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[2], _receiver: address = msg.sender) -> uint256[2]: nonpayable + def calc_token_amount(_amounts: uint256[2], _is_deposit: bool) -> uint256: view + + +interface CurveBase3: + def add_liquidity(_amounts: uint256[3], _min_mint_amount: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[3], _receiver: address = msg.sender) -> uint256[3]: nonpayable + def calc_token_amount(_amounts: uint256[3], _is_deposit: bool) -> uint256: view + + +interface CurveBase4: + def add_liquidity(_amounts: uint256[4], _min_mint_amount: uint256, _receiver: address = msg.sender) -> uint256: payable + def remove_liquidity(_burn_amount: uint256, _min_amounts: uint256[4], _receiver: address = msg.sender) -> uint256[4]: nonpayable + def calc_token_amount(_amounts: uint256[4], _is_deposit: bool) -> uint256: view + + +META_N_COINS: constant(uint256) = 2 +META_INDEX: constant(uint256) = META_N_COINS - 1 +BASE_MAX_N_COINS: constant(uint256) = 4 +ALL_N_COINS: constant(uint256) = META_N_COINS - 1 + BASE_MAX_N_COINS + +ETH_ADDRESS: constant(address) = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE +WETH: immutable(wETH) +STABLE_FACTORY: immutable(Factory) + +# coin -> pool -> is approved to transfer? +is_approved: HashMap[address, HashMap[address, bool]] + + +@external +def __init__(_weth: address, _stable_factory: address): + """ + @notice Contract constructor + """ + WETH = wETH(_weth) + STABLE_FACTORY = Factory(_stable_factory) + + +@external +@payable +def __default__(): + assert msg.sender.is_contract # dev: receive only from pools and WETH + + +# ------------ View methods ------------ + + +@external +@view +def get_coins(_pool: address) -> address[ALL_N_COINS]: + """ + @notice Get coins of the pool in current zap representation + @param _pool Address of the pool + @return Addresses of coins used in zap + """ + coins: address[ALL_N_COINS] = empty(address[ALL_N_COINS]) + coins[0] = CurveMeta(_pool).coins(0) + + coins[1] = CurveMeta(_pool).coins(1) + + # Set base coins + # If meta coin is not LP Token, `get_coins()` will return [ZERO_ADDRESS] * 4 + base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(coins[1]) + for i in range(BASE_MAX_N_COINS): + if base_coins[i] == ZERO_ADDRESS: + coins[1 + i] = base_coins[i] + + return coins + + +@external +@view +def coins(_pool: address, _i: uint256) -> address: + """ + @notice Get coins of the pool in current zap representation + @param _pool Address of the pool + @param _i Index of the coin + @return Address of `_i` coin used in zap + """ + if _i == 0: + return CurveMeta(_pool).coins(0) + + coin: address = CurveMeta(_pool).coins(1) + return CurveBase(coin).coins(_i - 1) + + +@internal +@view +def _calc_price(_pool: address, _0_in_1: bool, _meta_price: uint256) -> uint256: + """ + @notice Calculate price from base token to another base token + @param _pool Address of base pool + @param _0_in_1 If True, calculate price of `0` coin in `1` coin, inverse otherwise + @param _meta_price Price of `meta1` in terms of `meta0` + @return Price with base = 10 ** 18 + """ + vprice: uint256 = 10 ** 18 + coin: address = CurveMeta(_pool).coins(1) + vprice = CurveBase(coin).get_virtual_price() + + # meta0 <--_meta_price-- meta1 --vp[1]--> base1 + if _0_in_1: + return vprice * 10 ** 18 / _meta_price + else: + return _meta_price * 10 ** 18 / vprice + + +@external +@view +def price_oracle(_pool: address, _0_in_1: bool = True) -> uint256: + """ + @notice Oracle price for underlying assets + @param _pool Address of the pool + @param _0_in_1 If True, calculate price of `0` coin in `1` coin, inverse otherwise + @return Price with base = 10 ** 18 + """ + price: uint256 = CurveMeta(_pool).price_oracle() + return self._calc_price(_pool, _0_in_1, price) + + +@external +@view +def price_scale(_pool: address, _0_in_1: bool = True) -> uint256: + """ + @notice Price scale for underlying assets + @param _pool Address of the pool + @param _0_in_1 If True, calculate price of `0` coin in `1` coin, inverse otherwise + @return Price with base = 10 ** 18 + """ + price: uint256 = CurveMeta(_pool).price_scale() + return self._calc_price(_pool, _0_in_1, price) + + +@external +@view +def lp_price(_pool: address, _i: uint256 = 0) -> uint256: + """ + @notice Price of LP token calculated in `_i` underlying asset + @param _pool Address of the pool + @param _i Index of asset to calculate the price in (0 or 1) + @return LP Token price with base = 10 ** 18 + """ + p: uint256 = CurveMeta(_pool).lp_price() # Price in `0` + coin: address = CurveMeta(_pool).coins(_i) + vprice: uint256 = CurveBase(coin).get_virtual_price() + + # lp --p--> meta0 + if _i == 0: + # --vp--> base0 + return p * vprice / 10 ** 18 + else: + # <--price-- meta1 --vp--> base1 + price: uint256 = CurveMeta(_pool).price_oracle() + return p * vprice / price + + +# --------------- Helpers -------------- + + +@internal +@payable +def _receive(_coin: address, _amount: uint256, _use_eth: bool, _eth: bool) -> uint256: + """ + @notice Transfer coin to zap + @param _coin Address of the coin + @param _amount Amount of coin + @param _from Sender of the coin + @param _eth_value Eth value sent + @param _use_eth Use raw ETH + @param _eth Pool uses ETH_ADDRESS for ETH + @return Received ETH amount + """ + coin: address = _coin + if coin == ETH_ADDRESS: + coin = WETH.address # Receive weth if not _use_eth + + if _use_eth and coin == WETH.address: + assert msg.value == _amount # dev: incorrect ETH amount + if _eth and _coin == WETH.address and _amount > 0: + WETH.deposit(value=_amount) + else: + return _amount + elif _amount > 0: + response: Bytes[32] = raw_call( + coin, + _abi_encode( + msg.sender, + self, + _amount, + method_id=method_id("transferFrom(address,address,uint256)"), + ), + max_outsize=32, + ) + if len(response) != 0: + assert convert(response, bool) # dev: failed transfer + + if _coin == ETH_ADDRESS: + WETH.withdraw(_amount) + return _amount + return 0 + + +@internal +def _send(_coin: address, _to: address, _use_eth: bool) -> uint256: + """ + @notice Send coin from zap + @dev Sends all available amount + @param _coin Address of the coin + @param _to Sender of the coin + @param _use_eth Use raw ETH + @return Amount of coin sent + """ + coin: address = _coin + if coin == ETH_ADDRESS: + coin = WETH.address # Send weth if not _use_eth + + amount: uint256 = 0 + if _use_eth and coin == WETH.address: + amount = ERC20(coin).balanceOf(self) + if amount > 0: + WETH.withdraw(amount) + + amount = self.balance + if amount > 0: + raw_call(_to, b"", value=amount) + else: + if coin == WETH.address and self.balance > 0: + WETH.deposit(value=self.balance) + + amount = ERC20(coin).balanceOf(self) + if amount > 0: + response: Bytes[32] = raw_call( + coin, + _abi_encode(_to, amount, method_id=method_id("transfer(address,uint256)")), + max_outsize=32, + ) + if len(response) != 0: + assert convert(response, bool) # dev: failed transfer + return amount + + +@internal +def _approve(_coin: address, _pool: address): + if _coin != ETH_ADDRESS and not self.is_approved[_coin][_pool]: + ERC20(_coin).approve(_pool, MAX_UINT256) + self.is_approved[_coin][_pool] = True + + +@internal +@pure +def _need_to_handle_eth(_use_eth: bool, _coin: address) -> bool: + """ + @notice Handle _use_eth feature for StableSwaps + """ + return (not _use_eth and _coin == ETH_ADDRESS) or (_use_eth and _coin == WETH.address) + + +# -------------- Exchange -------------- + + +@internal +def _add_to_base_one(_pool: address, _amount: uint256, _min_lp: uint256, _i: uint256, + _receiver: address, _eth_amount: uint256) -> uint256: + """ + @notice Provide one token to base pool + @param _pool Address of base pool + @param _amount Amount of token to provide + @param _min_lp Minimum LP amount to receive + @param _i Adjusted index of coin to provide + @param _receiver Receiver of the coin + @param _eth_amount Raw ETH amount to provide + @return LP Token amount received + """ + n_coins: uint256 = STABLE_FACTORY.get_n_coins(_pool) + + if n_coins == 2: + amounts: uint256[2] = empty(uint256[2]) + amounts[_i] = _amount + return CurveBase2(_pool).add_liquidity(amounts, _min_lp, _receiver, value=_eth_amount) + elif n_coins == 3: + amounts: uint256[3] = empty(uint256[3]) + amounts[_i] = _amount + return CurveBase3(_pool).add_liquidity(amounts, _min_lp, _receiver, value=_eth_amount) + elif n_coins == 4: + amounts: uint256[4] = empty(uint256[4]) + amounts[_i] = _amount + return CurveBase4(_pool).add_liquidity(amounts, _min_lp, _receiver, value=_eth_amount) + else: + raise "Incorrect indexes" + + +@external +@payable +def exchange(_pool: address, i: uint256, j: uint256, _dx: uint256, _min_dy: uint256, _use_eth: bool = False, _receiver: address = msg.sender) -> uint256: + """ + @notice Exchange using wETH by default. Indexing = [0, 1, ...] + @dev Index values can be found via the `coins` public getter method + @param _pool Address of the pool for the exchange + @param i Index value for the coin to send + @param j Index value of the coin to receive + @param _dx Amount of `i` being exchanged + @param _min_dy Minimum amount of `j` to receive + @param _use_eth Use raw ETH + @param _receiver Address that will receive `j` + @return Actual amount of `j` received + """ + assert i != j # dev: indexes are similar + + receiver: address = self + amount: uint256 = _dx + eth_amount: uint256 = 0 + + i_pool: uint256 = min(i, META_INDEX) + j_pool: uint256 = min(j, META_INDEX) + pool: address = CurveMeta(_pool).coins(i_pool) + coin: address = pool + + if i_pool == META_INDEX: + adjusted_i: uint256 = i - META_INDEX + coin = CurveBase(pool).coins(adjusted_i) + eth_amount = self._receive(coin, amount, _use_eth, True) + self._approve(coin, pool) + + if j_pool == META_INDEX: + adjusted_j: uint256 = j - META_INDEX + coin = CurveBase(pool).coins(adjusted_j) + if not self._need_to_handle_eth(_use_eth, coin): + receiver = _receiver + + amount = CurveBase(pool).exchange(convert(adjusted_i, int128), convert(adjusted_j, int128), amount, _min_dy, receiver, value=eth_amount) + + if receiver == self: + return self._send(coin, _receiver, _use_eth) + return amount + + amount = self._add_to_base_one(pool, amount, 0, adjusted_i, self, eth_amount) + coin = pool + eth_amount = 0 + else: + eth_amount = self._receive(coin, amount, _use_eth, False) + + self._approve(coin, _pool) + min_dy: uint256 = 0 + if j_pool != META_INDEX: + receiver = _receiver + min_dy = _min_dy + amount = CurveMeta(_pool).exchange(i_pool, j_pool, amount, min_dy, _use_eth, receiver, value=eth_amount) + + if j_pool == META_INDEX: + adjusted_j: uint256 = j - META_INDEX + pool = CurveMeta(_pool).coins(META_INDEX) + coin = CurveBase(pool).coins(adjusted_j) + self._approve(coin, pool) + if not self._need_to_handle_eth(_use_eth, coin): + receiver = _receiver + amount = CurveBase(pool).remove_liquidity_one_coin(amount, convert(adjusted_j, int128), _min_dy, receiver) + + if receiver == self: + return self._send(coin, _receiver, _use_eth) + return amount + + +@internal +@view +def _calc_in_base_one(_pool: address, _amount: uint256, _i: uint256) -> uint256: + """ + @notice Calculate base LP token received for providing 1 token + @param _pool Address of the base pool + @param _amount Amount of token to provide + @param _i Adjusted index to provide + @return LP Token amount to receive + """ + n_coins: uint256 = STABLE_FACTORY.get_n_coins(_pool) + + if n_coins == 2: + amounts: uint256[2] = empty(uint256[2]) + amounts[_i] = _amount + return CurveBase2(_pool).calc_token_amount(amounts, True) + elif n_coins == 3: + amounts: uint256[3] = empty(uint256[3]) + amounts[_i] = _amount + return CurveBase3(_pool).calc_token_amount(amounts, True) + elif n_coins == 4: + amounts: uint256[4] = empty(uint256[4]) + amounts[_i] = _amount + return CurveBase4(_pool).calc_token_amount(amounts, True) + else: + raise "Invalid indexes" + + +@external +@view +def get_dy(_pool: address, i: uint256, j: uint256, _dx: uint256) -> uint256: + """ + @notice Calculate the amount received in exchange. Indexing = [[0, 1, ...], [5, ..., 9]] + @dev Index values can be found via the `coins` public getter method + @param _pool Address of the pool for the exchange + @param i Index value for the coin to send + @param j Index value of the coin to receive + @param _dx Amount of `i` being exchanged + @return Expected amount of `j` to receive + """ + assert i != j # dev: indexes are similar + + amount: uint256 = _dx + + i_pool: uint256 = min(i, META_INDEX) + j_pool: uint256 = min(j, META_INDEX) + + if i_pool == META_INDEX: + pool: address = CurveMeta(_pool).coins(META_INDEX) + adjusted_i: uint256 = i - META_INDEX + if j_pool == META_INDEX: + adjusted_j: int128 = convert(j - META_INDEX, int128) + return CurveBase(pool).get_dy(convert(adjusted_i, int128), adjusted_j, amount) + amount = self._calc_in_base_one(pool, amount, adjusted_i) + + amount = CurveMeta(_pool).get_dy(i_pool, j_pool, amount) + + if j_pool == META_INDEX: + pool: address = CurveMeta(_pool).coins(META_INDEX) + adjusted_j: uint256 = j - META_INDEX + amount = CurveBase(pool).calc_withdraw_one_coin(amount, convert(adjusted_j, int128)) + + return amount + + +# ------------ Add Liquidity ----------- + + +@internal +def _add_to_base(_pool: address, _amounts: uint256[ALL_N_COINS], _use_eth: bool) -> uint256: + """ + @notice Deposit tokens to base pool + @param _pool Address of the basepool to deposit into + @param _amounts List of amounts of coins to deposit. If only one coin per base pool given, lp token will be used. + @param _use_eth Use raw ETH + @return Amount of LP tokens received by depositing + """ + base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(_pool) + eth_amount: uint256 = 0 + n_coins: uint256 = BASE_MAX_N_COINS + for i in range(BASE_MAX_N_COINS): + coin: address = base_coins[i] + if coin == ZERO_ADDRESS: + n_coins = i + break + eth_amount += self._receive(coin, _amounts[1 + i], _use_eth, True) + self._approve(coin, _pool) + + if n_coins == 2: + amounts: uint256[2] = [_amounts[1], _amounts[2]] + return CurveBase2(_pool).add_liquidity(amounts, 0, self, value=eth_amount) + elif n_coins == 3: + amounts: uint256[3] = [_amounts[1], _amounts[2], _amounts[3]] + return CurveBase3(_pool).add_liquidity(amounts, 0, self, value=eth_amount) + elif n_coins == 4: + amounts: uint256[4] = [_amounts[1], _amounts[2], _amounts[3], _amounts[4]] + return CurveBase4(_pool).add_liquidity(amounts, 0, self, value=eth_amount) + else: + raise "Incorrect amounts" + + +@external +@payable +def add_liquidity( + _pool: address, + _deposit_amounts: uint256[ALL_N_COINS], + _min_mint_amount: uint256, + _use_eth: bool = False, + _receiver: address = msg.sender, +) -> uint256: + """ + @notice Deposit tokens to base and meta pools + @dev Providing ETH with _use_eth=True will result in ETH remained in zap. It can be recovered via removing liquidity. + @param _pool Address of the metapool 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 _use_eth Use raw ETH + @param _receiver Address that receives the LP tokens + @return Amount of LP tokens received by depositing + """ + if not _use_eth: + assert msg.value == 0 # dev: nonzero ETH amount + eth_amount: uint256 = 0 + meta_amounts: uint256[META_N_COINS] = empty(uint256[META_N_COINS]) + if _deposit_amounts[0] > 0: + meta_amounts[0] = _deposit_amounts[0] + coin: address = CurveMeta(_pool).coins(0) + eth_amount += self._receive(coin, meta_amounts[0], _use_eth, False) + self._approve(coin, _pool) + + for i in range(1, ALL_N_COINS): + if _deposit_amounts[i] > 0: + coin: address = CurveMeta(_pool).coins(1) + meta_amounts[1] = self._add_to_base(coin, _deposit_amounts, _use_eth) + self._approve(coin, _pool) + break + + return CurveMeta(_pool).add_liquidity(meta_amounts, _min_mint_amount, _use_eth, _receiver, value=eth_amount) + + +@internal +@view +def _calc_in_base(_pool: address, _amounts: uint256[ALL_N_COINS]) -> uint256: + """ + @notice Calculate base pool LP received after providing `_amounts` + @param _pool Address of the base pool + @param _amounts Amounts to add + @return LP Token amount to receive + """ + n_coins: uint256 = STABLE_FACTORY.get_n_coins(_pool) + + if n_coins == 2: + amounts: uint256[2] = [_amounts[1], _amounts[2]] + return CurveBase2(_pool).calc_token_amount(amounts, True) + elif n_coins == 3: + amounts: uint256[3] = [_amounts[1], _amounts[2], _amounts[3]] + return CurveBase3(_pool).calc_token_amount(amounts, True) + elif n_coins == 4: + amounts: uint256[4] = [_amounts[1], _amounts[2], _amounts[3], _amounts[4]] + return CurveBase4(_pool).calc_token_amount(amounts, True) + else: + raise "Incorrect amounts" + + +@external +@view +def calc_token_amount(_pool: address, _amounts: uint256[ALL_N_COINS]) -> uint256: + """ + @notice Calculate addition in token supply from a deposit + @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 + @return Expected amount of LP tokens received + """ + pool: address = CurveMeta(_pool).coins(1) + meta_amounts: uint256[META_N_COINS] = [_amounts[0], 0] + for i in range(1, ALL_N_COINS): + if _amounts[i] > 0: + coin: address = CurveMeta(_pool).coins(1) + meta_amounts[1] = self._calc_in_base(coin, _amounts) + break + + return CurveMeta(_pool).calc_token_amount(meta_amounts) + + +# ---------- Remove Liquidity ---------- + + +@internal +def _remove_from_base(_pool: address, _min_amounts: uint256[ALL_N_COINS], _use_eth: bool, _receiver: address) -> uint256[ALL_N_COINS]: + """ + @notice Remove tokens from base pool + @param _pool Address of base pool + @param _min_amounts Minimum amounts to receive + @param _use_eth Use raw ETH + @param _receiver Receiver of coins + @return Received amounts + """ + receiver: address = _receiver + base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(_pool) + n_coins: uint256 = BASE_MAX_N_COINS + for i in range(BASE_MAX_N_COINS): + if base_coins[i] == ZERO_ADDRESS: + n_coins = i + break + if self._need_to_handle_eth(_use_eth, base_coins[i]): # Need to wrap ETH + receiver = self + + burn_amount: uint256 = ERC20(_pool).balanceOf(self) + returned: uint256[ALL_N_COINS] = empty(uint256[ALL_N_COINS]) + if n_coins == 2: + min_amounts: uint256[2] = [_min_amounts[1], _min_amounts[2]] + amounts: uint256[2] = CurveBase2(_pool).remove_liquidity(burn_amount, min_amounts, receiver) + for i in range(2): + returned[1 + i] = amounts[i] + elif n_coins == 3: + min_amounts: uint256[3] = [_min_amounts[1], _min_amounts[2], _min_amounts[3]] + amounts: uint256[3] = CurveBase3(_pool).remove_liquidity(burn_amount, min_amounts, receiver) + for i in range(3): + returned[1 + i] = amounts[i] + elif n_coins == 4: + min_amounts: uint256[4] = [_min_amounts[1], _min_amounts[2], _min_amounts[3], _min_amounts[4]] + amounts: uint256[4] = CurveBase4(_pool).remove_liquidity(burn_amount, min_amounts, receiver) + for i in range(4): + returned[1 + i] = amounts[i] + else: + raise "Invalid min_amounts" + + if receiver == self: + for coin in base_coins: + if coin == ZERO_ADDRESS: + break + self._send(coin, _receiver, _use_eth) + return returned + + +@external +def remove_liquidity( + _pool: address, + _burn_amount: uint256, + _min_amounts: uint256[ALL_N_COINS], + _use_eth: bool = False, + _receiver: address = msg.sender, +) -> uint256[ALL_N_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 withdraw from + @param _burn_amount Quantity of LP tokens to burn in the withdrawal + @param _min_amounts Minimum amounts of underlying coins to receive. + @param _use_eth Use raw ETH + @param _receiver Address that receives the LP tokens + @return List of amounts of underlying coins that were withdrawn + """ + token: address = CurveMeta(_pool).token() + ERC20(token).transferFrom(msg.sender, self, _burn_amount) + CurveMeta(_pool).remove_liquidity(_burn_amount, [_min_amounts[0], 0], _use_eth) + + returned: uint256[ALL_N_COINS] = empty(uint256[ALL_N_COINS]) + + removed_from_base: bool = False + coin: address = CurveMeta(_pool).coins(1) + returned = self._remove_from_base(coin, _min_amounts, _use_eth, _receiver) + + coin = CurveMeta(_pool).coins(0) + returned[0] = self._send(coin, _receiver, _use_eth) + + return returned + + +@external +def remove_liquidity_one_coin( + _pool: address, + _burn_amount: uint256, + i: uint256, + _min_amount: uint256, + _use_eth: bool = False, + _receiver: address = msg.sender, +) -> uint256: + """ + @notice Withdraw and unwrap a single coin from the pool + @param _pool Address of the pool to withdraw from + @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 _use_eth Use raw ETH + @param _receiver Address that receives the LP tokens + @return Amount of underlying coin received + """ + token: address = CurveMeta(_pool).token() + ERC20(token).transferFrom(msg.sender, self, _burn_amount) + + pool_i: uint256 = min(i, 1) + + if pool_i != META_INDEX: + return CurveMeta(_pool).remove_liquidity_one_coin(_burn_amount, pool_i, _min_amount, _use_eth, _receiver) + amount: uint256 = CurveMeta(_pool).remove_liquidity_one_coin(_burn_amount, pool_i, 0, _use_eth) + + pool: address = CurveMeta(_pool).coins(META_INDEX) + adjusted_i: uint256 = i - META_INDEX + + coin: address = CurveBase(pool).coins(adjusted_i) + if not self._need_to_handle_eth(_use_eth, coin): + return CurveBase(pool).remove_liquidity_one_coin(amount, convert(adjusted_i, int128), _min_amount, _receiver) + CurveBase(pool).remove_liquidity_one_coin(amount, convert(adjusted_i, int128), _min_amount) + return self._send(WETH.address, _receiver, _use_eth) + + +@external +@view +def calc_withdraw_one_coin(_pool: address, _token_amount: uint256, i: uint256) -> uint256: + """ + @notice Calculate the amount received when withdrawing and unwrapping a single coin + @param _pool Address of the pool to withdraw from + @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 to receive + """ + pool_i: uint256 = min(i, META_INDEX) + amount: uint256 = CurveMeta(_pool).calc_withdraw_one_coin(_token_amount, pool_i) + if pool_i == META_INDEX: + pool: address = CurveMeta(_pool).coins(pool_i) + adjusted_i: int128 = convert(i % ALL_N_COINS - 1, int128) + return CurveBase(pool).calc_withdraw_one_coin(amount, adjusted_i) + return amount diff --git a/tests/zaps/conftest.py b/tests/zaps/conftest.py index 1fe67b7..d0d5e5e 100644 --- a/tests/zaps/conftest.py +++ b/tests/zaps/conftest.py @@ -51,6 +51,8 @@ def initial_price(zap_base): return 10 ** (18 - 4) elif zap_base == "tricrypto": return int(LP_PRICE_USD // 10 ** 4 + 1) # usd to eth, added 1 for errors + else: + return 10 ** 18 @pytest.fixture(scope="module") @@ -88,9 +90,12 @@ def meta_token(CurveTokenV5, meta_swap): @pytest.fixture(scope="module") -def zap(ZapETH, ZapETHZap, Zap3PoolETH, zap_base, base_swap, base_token, weth, base_coins, accounts): +def zap(ZapETH, ZapETHZap, Zap3PoolETH, ZapStableSwapFactoryOne, zap_base, base_swap, base_token, weth, base_coins, accounts): if zap_base == "3pool": contract = Zap3PoolETH + elif zap_base == "stable_factory": + # from tests.zaps.forked.conftest import _data + return ZapStableSwapFactoryOne.deploy(weth, "0xB9fC157394Af804a3578134A6585C0dc9cc990d4", {"from": accounts[0]}) elif len(base_coins) == 3: contract = ZapETH else: @@ -143,11 +148,18 @@ def base_swap(zap_base, tricrypto, tripool): @pytest.fixture(scope="module") -def initial_prices_base(zap_base, tricrypto_initial_prices, tripool_initial_prices): +def initial_prices_base(zap_base, tricrypto_initial_prices, tripool_initial_prices, base_swap, base_coins): if zap_base == "tricrypto": return tricrypto_initial_prices elif zap_base == "3pool": return tripool_initial_prices + else: + initial_prices = [10 ** 18] + for i, coin in zip(range(1, len(base_coins)), base_coins[1:]): + initial_prices.append( + base_swap.get_dy(i, 0, 10 ** coin.decimals()) * 10 ** (18 - base_coins[0].decimals()) + ) + return initial_prices @pytest.fixture(scope="module") @@ -171,7 +183,7 @@ def initial_prices(zap_base, Tricrypto, is_forked, base_swap, meta_swap, initial if not is_forked: return [lp_price_usd * 10 ** 18 // initial_price, lp_price_usd + 1000] - if zap_base == "3pool": + if zap_base in ["3pool", "stable_factory"]: vp = base_swap.get_virtual_price() return [10 ** 36 // meta_swap.price_scale(), vp] diff --git a/tests/zaps/forked/conftest.py b/tests/zaps/forked/conftest.py index a48eb92..cdf3deb 100644 --- a/tests/zaps/forked/conftest.py +++ b/tests/zaps/forked/conftest.py @@ -80,6 +80,8 @@ def base_token(zap_base, base_token, is_forked, CurveTokenV2, CurveTokenV4): return CurveTokenV2.at(_data["token"]) elif zap_base == "tricrypto": return CurveTokenV4.at(_data["token"]) + else: + return Contract.from_explorer(_data["token"]) @pytest.fixture(scope="module") @@ -95,7 +97,7 @@ def initial_amount_usd(initial_amount_usd, is_forked): if not is_forked: return initial_amount_usd else: - return 1_000 + return 10 @pytest.fixture(scope="module") diff --git a/tests/zaps/forked/test_view.py b/tests/zaps/forked/test_view.py index a10b4b5..3db420b 100644 --- a/tests/zaps/forked/test_view.py +++ b/tests/zaps/forked/test_view.py @@ -1,3 +1,4 @@ def test_params(zap, base_swap, base_token): - assert zap.base_pool() == base_swap - assert zap.base_token() == base_token + if hasattr(zap, "base_pool"): + assert zap.base_pool() == base_swap + assert zap.base_token() == base_token diff --git a/tests/zaps_factory/forked/conftest.py b/tests/zaps_factory/forked/conftest.py index 134d5f6..ceb6f69 100644 --- a/tests/zaps_factory/forked/conftest.py +++ b/tests/zaps_factory/forked/conftest.py @@ -187,7 +187,7 @@ def default_amounts(all_coins, default_amount): @pytest.fixture(scope="module") def factory(factory, Factory): - yield Factory.at(_data["crypto_factory"]) + yield Factory.at(_data["factory"]) @pytest.fixture(scope="module") From 0f36584ed1809ea9a2aec438c6aba2081df4121b Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Mon, 1 Aug 2022 20:51:01 +0300 Subject: [PATCH 05/10] tests: add tests for base pool with 3 coins --- .../testing/stable_factory/data/mainnet-3.json | 14 ++++++++++++++ tests/fixtures/functions.py | 11 +++++++++-- tests/zaps/conftest.py | 7 ++++--- tests/zaps/forked/conftest.py | 13 +++++++++---- tests/zaps/forked/test_remove_liquidity.py | 11 ++++++++--- 5 files changed, 44 insertions(+), 12 deletions(-) create mode 100644 contracts/testing/stable_factory/data/mainnet-3.json diff --git a/contracts/testing/stable_factory/data/mainnet-3.json b/contracts/testing/stable_factory/data/mainnet-3.json new file mode 100644 index 0000000..d831c57 --- /dev/null +++ b/contracts/testing/stable_factory/data/mainnet-3.json @@ -0,0 +1,14 @@ +{ + "swap": "0xb9446c4Ef5EBE66268dA6700D26f96273DE3d571", + "token": "0xb9446c4Ef5EBE66268dA6700D26f96273DE3d571", + "coins": [ + "0x1a7e4e63778B4f12a199C062f3eFdD288afCBce8", + "0xC581b735A1688071A1746c968e0798D642EDE491", + "0xdB25f211AB05b1c97D595516F45794528a807ad8", + "0x0000000000000000000000000000000000000000" + ], + "weth_idx": 0, + "weth": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "factory": "0xF18056Bbd320E96A48e3Fbf8bC061322531aac99", + "stable_factory": "0xB9fC157394Af804a3578134A6585C0dc9cc990d4" +} \ No newline at end of file diff --git a/tests/fixtures/functions.py b/tests/fixtures/functions.py index 266ffd5..d61bf8d 100644 --- a/tests/fixtures/functions.py +++ b/tests/fixtures/functions.py @@ -1,7 +1,7 @@ from contextlib import contextmanager import pytest -from brownie import ETH_ADDRESS +from brownie import ETH_ADDRESS, ZERO_ADDRESS MAX_UINT = 2 ** 256 - 1 @@ -12,6 +12,8 @@ def mint_alice_underlying( ): base_token.approve(meta_swap, MAX_UINT, {"from": alice}) for coin, amount in zip(underlying_coins, initial_amounts_underlying): + if coin == ZERO_ADDRESS: + continue coin._mint_for_testing(alice, amount, {"from": alice}) coin.approve(base_swap, MAX_UINT, {"from": alice}) coin.approve(meta_swap, MAX_UINT, {"from": alice}) @@ -23,6 +25,8 @@ def mint_bob_underlying( ): base_token.approve(meta_swap, MAX_UINT, {"from": bob}) for coin, amount in zip(underlying_coins, initial_amounts_underlying): + if coin == ZERO_ADDRESS: + continue coin._mint_for_testing(bob, amount, {"from": bob}) coin.approve(base_swap, MAX_UINT, {"from": bob}) coin.approve(meta_swap, MAX_UINT, {"from": bob}) @@ -40,7 +44,7 @@ def add_initial_liquidity( is_forked, ): base_swap.add_liquidity( - initial_amounts_underlying[len(initial_amounts) - 1:], 0, {"from": alice} + [amount for amount in initial_amounts_underlying[len(initial_amounts) - 1:] if amount > 0], 0, {"from": alice} ) if is_forked: # Sometimes calculations fail assert int(base_token.balanceOf(alice)) == pytest.approx( @@ -69,6 +73,7 @@ def inner(i: int, j: int, dx: int): def balances_do_not_change(): @contextmanager def inner(tokens, acc): + tokens = [token for token in tokens if token != ZERO_ADDRESS] initial_amounts = [ acc.balance() if token == ETH_ADDRESS else token.balanceOf(acc) for token in tokens ] @@ -89,6 +94,8 @@ def zap_has_zero_amounts(meta_token, base_token, underlying_coins, zap): def check(): assert zap.balance() == 0 for token in [meta_token, base_token] + underlying_coins: + if token == ZERO_ADDRESS: + continue assert token.balanceOf(zap) == 0 yield check diff --git a/tests/zaps/conftest.py b/tests/zaps/conftest.py index d0d5e5e..7120ad7 100644 --- a/tests/zaps/conftest.py +++ b/tests/zaps/conftest.py @@ -1,4 +1,5 @@ import pytest +from brownie import ZERO_ADDRESS from tests.fixtures.tricrypto import LP_PRICE_USD @@ -157,20 +158,20 @@ def initial_prices_base(zap_base, tricrypto_initial_prices, tripool_initial_pric initial_prices = [10 ** 18] for i, coin in zip(range(1, len(base_coins)), base_coins[1:]): initial_prices.append( - base_swap.get_dy(i, 0, 10 ** coin.decimals()) * 10 ** (18 - base_coins[0].decimals()) + base_swap.get_dy(i, 0, 10 ** coin.decimals()) * 10 ** (18 - base_coins[0].decimals()) if coin != ZERO_ADDRESS else 0 ) return initial_prices @pytest.fixture(scope="module") def initial_amounts_base(base_coins, initial_amount_usd, initial_prices_base): - usd_cnt = len(base_coins) - 2 + usd_cnt = len([c for c in base_coins if c != ZERO_ADDRESS]) - 2 amounts = [ initial_amount_usd * 10 ** (18 + coin.decimals()) // (usd_cnt * price) for price, coin in zip(initial_prices_base[:usd_cnt], base_coins[:usd_cnt]) ] return amounts + [ - initial_amount_usd * 10 ** (18 + coin.decimals()) // price + initial_amount_usd * 10 ** (18 + coin.decimals()) // price if price > 0 else 0 for price, coin in zip(initial_prices_base[usd_cnt:], base_coins[usd_cnt:]) ] diff --git a/tests/zaps/forked/conftest.py b/tests/zaps/forked/conftest.py index cdf3deb..c6a0146 100644 --- a/tests/zaps/forked/conftest.py +++ b/tests/zaps/forked/conftest.py @@ -1,7 +1,7 @@ import json import pytest -from brownie import Contract, interface +from brownie import Contract, interface, ZERO_ADDRESS from brownie.project.main import get_loaded_projects _data = {} @@ -23,7 +23,8 @@ def pytest_generate_tests(metafunc): _data = json.load(f) metafunc.parametrize(["zap_base", "weth_idx"], [(zap_base, _data["weth_idx"])], indirect=True, scope="session") if "mintable_fork_token" in metafunc.fixturenames: - metafunc.parametrize("mintable_fork_token", [deployed_data], indirect=True, scope="session") + network_name = deployed_data.split('-')[0] + metafunc.parametrize("mintable_fork_token", [network_name], indirect=True, scope="session") @pytest.fixture(scope="session", autouse=True) @@ -55,7 +56,7 @@ def base_coins(base_coins, mintable_fork_token, is_forked): if not is_forked: yield base_coins else: - yield [mintable_fork_token(addr) for addr in _data["coins"]] + yield [mintable_fork_token(addr) if addr != ZERO_ADDRESS else addr for addr in _data["coins"]] @pytest.fixture(scope="module") @@ -70,6 +71,8 @@ def base_swap(zap_base, Tricrypto, StableSwap3Pool, base_swap, base_coins, is_fo if len(base_coins) == 3 else Contract.from_abi("TricryptoZap", _data["swap"], interface.TricryptoZap.abi) ) + else: + return Contract.from_explorer(_data["swap"]) @pytest.fixture(scope="module") @@ -103,7 +106,7 @@ def initial_amount_usd(initial_amount_usd, is_forked): @pytest.fixture(scope="module") def amounts_underlying(underlying_coins): """Small amounts""" - return [10 ** coin.decimals() for coin in underlying_coins] + return [10 ** coin.decimals() if coin != ZERO_ADDRESS else 0 for coin in underlying_coins] @pytest.fixture(scope="module", autouse=True) @@ -114,6 +117,8 @@ def pre_mining( meta_token.approve(zap, 2 ** 256 - 1, {"from": alice}) base_token.approve(zap, 2 ** 256 - 1, {"from": alice}) for coin, amount in zip(underlying_coins, amounts_underlying): + if coin == ZERO_ADDRESS: + break coin._mint_for_testing(alice, amount, {"from": alice}) coin.approve(zap, 2 ** 256 - 1, {"from": alice}) diff --git a/tests/zaps/forked/test_remove_liquidity.py b/tests/zaps/forked/test_remove_liquidity.py index 622cfe7..b709793 100644 --- a/tests/zaps/forked/test_remove_liquidity.py +++ b/tests/zaps/forked/test_remove_liquidity.py @@ -1,4 +1,4 @@ -from brownie import ETH_ADDRESS +from brownie import ETH_ADDRESS, ZERO_ADDRESS def test_remove_liquidity( @@ -33,7 +33,10 @@ def test_remove_liquidity( zap_has_zero_amounts() for coin, amount in zip(underlying_coins, amounts_received): - assert coin.balanceOf(bob) == amount > 0 + if coin == ZERO_ADDRESS: + assert amount == 0 + else: + assert coin.balanceOf(bob) == amount > 0 assert bob.balance() - initial_balance == 0 @@ -70,7 +73,9 @@ def test_remove_liquidity_use_eth( zap_has_zero_amounts() for i, (coin, amount) in enumerate(zip(underlying_coins, amounts_received)): - if i == weth_idx: + if coin == ZERO_ADDRESS: + assert amount == 0 + elif i == weth_idx: assert coin.balanceOf(bob) == 0 else: assert coin.balanceOf(bob) == amount > 0 From 8169c3483add164347f2b6f2a3d91786d968aa76 Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Mon, 1 Aug 2022 20:58:26 +0300 Subject: [PATCH 06/10] docs: add readme for zaps --- contracts/zaps/README.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 contracts/zaps/README.md diff --git a/contracts/zaps/README.md b/contracts/zaps/README.md new file mode 100644 index 0000000..bf39d86 --- /dev/null +++ b/contracts/zaps/README.md @@ -0,0 +1,7 @@ +## Zaps +[Zap3PoolETH](Zap3PoolETH.vy) – Zap for crypto meta pools with 3pool as base. +[ZapETH](ZapETH.vy) – Zap for crypto meta pools with tricrypto as base. +[ZapETHZap](ZapETHZap.vy) – Zap for crypto meta pools with meta tricrypto(zap for it) as base. +[ZapStableSwapFactoryOne](ZapStableSwapFactoryOne.vy) – Zap for crypto meta pools with plain stable swap factory pool as base. +[ZapStableSwapFactory](ZapStableSwapFactory.vy) – Zap for crypto meta pools with plain stable swap factory pools as base on any positions. +Works with (coin, coin), (coin, LP), (LP, coin), (LP, LP). From 2ce2534406e259e9e1c877a628aae09addf114da Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Fri, 5 Aug 2022 23:31:34 +0300 Subject: [PATCH 07/10] chore: update vyper to 0.3.4 --- contracts/zaps/ZapStableSwapFactory.vy | 4 ++-- contracts/zaps/ZapStableSwapFactoryOne.vy | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/zaps/ZapStableSwapFactory.vy b/contracts/zaps/ZapStableSwapFactory.vy index 04e72dd..c9bb134 100644 --- a/contracts/zaps/ZapStableSwapFactory.vy +++ b/contracts/zaps/ZapStableSwapFactory.vy @@ -1,4 +1,4 @@ -# @version 0.3.3 +# @version 0.3.4 """ @title Zap for Curve Factory @license MIT @@ -310,7 +310,7 @@ def _send(_coin: address, _to: address, _use_eth: bool) -> uint256: @internal def _approve(_coin: address, _pool: address): if _coin != ETH_ADDRESS and not self.is_approved[_coin][_pool]: - ERC20(_coin).approve(_pool, MAX_UINT256) + ERC20(_coin).approve(_pool, max_value(uint256)) self.is_approved[_coin][_pool] = True diff --git a/contracts/zaps/ZapStableSwapFactoryOne.vy b/contracts/zaps/ZapStableSwapFactoryOne.vy index 63bf34c..9bce114 100644 --- a/contracts/zaps/ZapStableSwapFactoryOne.vy +++ b/contracts/zaps/ZapStableSwapFactoryOne.vy @@ -1,4 +1,4 @@ -# @version 0.3.3 +# @version 0.3.4 """ @title Zap for Curve Factory @license MIT @@ -300,7 +300,7 @@ def _send(_coin: address, _to: address, _use_eth: bool) -> uint256: @internal def _approve(_coin: address, _pool: address): if _coin != ETH_ADDRESS and not self.is_approved[_coin][_pool]: - ERC20(_coin).approve(_pool, MAX_UINT256) + ERC20(_coin).approve(_pool, max_value(uint256)) self.is_approved[_coin][_pool] = True From 4a5d44c8e5e2cd713e9691e39eb93731759a4e9d Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Fri, 5 Aug 2022 23:35:36 +0300 Subject: [PATCH 08/10] chore: update docs and few bugs --- contracts/zaps/ZapStableSwapFactory.vy | 3 ++- contracts/zaps/ZapStableSwapFactoryOne.vy | 32 +++++++++++------------ 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/contracts/zaps/ZapStableSwapFactory.vy b/contracts/zaps/ZapStableSwapFactory.vy index c9bb134..4c06b51 100644 --- a/contracts/zaps/ZapStableSwapFactory.vy +++ b/contracts/zaps/ZapStableSwapFactory.vy @@ -562,7 +562,8 @@ def add_liquidity( ) -> uint256: """ @notice Deposit tokens to base and meta pools - @dev Providing ETH with _use_eth=True will result in ETH remained in zap. It can be recovered via removing liquidity. + @dev Providing ETH with _use_eth=True and no ETH actually used will result in ETH remained in zap. + It can be recovered via removing liquidity. @param _pool Address of the metapool 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 diff --git a/contracts/zaps/ZapStableSwapFactoryOne.vy b/contracts/zaps/ZapStableSwapFactoryOne.vy index 9bce114..80c62e8 100644 --- a/contracts/zaps/ZapStableSwapFactoryOne.vy +++ b/contracts/zaps/ZapStableSwapFactoryOne.vy @@ -4,7 +4,7 @@ @license MIT @author Curve.Fi @notice Zap for StableSwap Factory metapools created via CryptoSwap Factory. - Coins are set as [meta0, meta1, base0, base1, ...], + Coins are set as [meta0, base0, base1, ...], where meta is coin that is used in CryptoSwap(LP token for base pools) and base is base pool coins or ZERO_ADDRESS when there is no such coins. @dev Does not work if 2 ETH used in pools, e.g. (ETH, Plain2ETH) @@ -114,14 +114,13 @@ def get_coins(_pool: address) -> address[ALL_N_COINS]: coins: address[ALL_N_COINS] = empty(address[ALL_N_COINS]) coins[0] = CurveMeta(_pool).coins(0) - coins[1] = CurveMeta(_pool).coins(1) + coins[META_INDEX] = CurveMeta(_pool).coins(META_INDEX) # Set base coins # If meta coin is not LP Token, `get_coins()` will return [ZERO_ADDRESS] * 4 - base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(coins[1]) + base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(coins[META_INDEX]) for i in range(BASE_MAX_N_COINS): - if base_coins[i] == ZERO_ADDRESS: - coins[1 + i] = base_coins[i] + coins[META_INDEX + i] = base_coins[i] return coins @@ -135,11 +134,11 @@ def coins(_pool: address, _i: uint256) -> address: @param _i Index of the coin @return Address of `_i` coin used in zap """ - if _i == 0: - return CurveMeta(_pool).coins(0) + if _i < META_INDEX: + return CurveMeta(_pool).coins(_i) - coin: address = CurveMeta(_pool).coins(1) - return CurveBase(coin).coins(_i - 1) + coin: address = CurveMeta(_pool).coins(META_INDEX) + return CurveBase(coin).coins(_i - META_INDEX) @internal @@ -351,7 +350,7 @@ def _add_to_base_one(_pool: address, _amount: uint256, _min_lp: uint256, _i: uin @payable def exchange(_pool: address, i: uint256, j: uint256, _dx: uint256, _min_dy: uint256, _use_eth: bool = False, _receiver: address = msg.sender) -> uint256: """ - @notice Exchange using wETH by default. Indexing = [0, 1, ...] + @notice Exchange using wETH by default @dev Index values can be found via the `coins` public getter method @param _pool Address of the pool for the exchange @param i Index value for the coin to send @@ -450,7 +449,7 @@ def _calc_in_base_one(_pool: address, _amount: uint256, _i: uint256) -> uint256: @view def get_dy(_pool: address, i: uint256, j: uint256, _dx: uint256) -> uint256: """ - @notice Calculate the amount received in exchange. Indexing = [[0, 1, ...], [5, ..., 9]] + @notice Calculate the amount received in exchange @dev Index values can be found via the `coins` public getter method @param _pool Address of the pool for the exchange @param i Index value for the coin to send @@ -530,7 +529,8 @@ def add_liquidity( ) -> uint256: """ @notice Deposit tokens to base and meta pools - @dev Providing ETH with _use_eth=True will result in ETH remained in zap. It can be recovered via removing liquidity. + @dev Providing ETH with _use_eth=True and no ETH actually used will result in ETH remained in zap. + It can be recovered via removing liquidity. @param _pool Address of the metapool 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 @@ -664,11 +664,11 @@ def remove_liquidity( _receiver: address = msg.sender, ) -> uint256[ALL_N_COINS]: """ - @notice Withdraw and unwrap coins from the pool. + @notice Withdraw and unwrap coins from the pool @dev Withdrawal amounts are based on current deposit ratios @param _pool Address of the pool to withdraw from @param _burn_amount Quantity of LP tokens to burn in the withdrawal - @param _min_amounts Minimum amounts of underlying coins to receive. + @param _min_amounts Minimum amounts of underlying coins to receive @param _use_eth Use raw ETH @param _receiver Address that receives the LP tokens @return List of amounts of underlying coins that were withdrawn @@ -711,7 +711,7 @@ def remove_liquidity_one_coin( token: address = CurveMeta(_pool).token() ERC20(token).transferFrom(msg.sender, self, _burn_amount) - pool_i: uint256 = min(i, 1) + pool_i: uint256 = min(i, META_INDEX) if pool_i != META_INDEX: return CurveMeta(_pool).remove_liquidity_one_coin(_burn_amount, pool_i, _min_amount, _use_eth, _receiver) @@ -741,6 +741,6 @@ def calc_withdraw_one_coin(_pool: address, _token_amount: uint256, i: uint256) - amount: uint256 = CurveMeta(_pool).calc_withdraw_one_coin(_token_amount, pool_i) if pool_i == META_INDEX: pool: address = CurveMeta(_pool).coins(pool_i) - adjusted_i: int128 = convert(i % ALL_N_COINS - 1, int128) + adjusted_i: int128 = convert(i - META_INDEX, int128) return CurveBase(pool).calc_withdraw_one_coin(amount, adjusted_i) return amount From a5a821a529659a7c52db6836069271961b054d70 Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Fri, 5 Aug 2022 23:52:50 +0300 Subject: [PATCH 09/10] chore: use empty(address) instead of ZERO_ADDRESS --- contracts/zaps/ZapStableSwapFactory.vy | 6 +++--- contracts/zaps/ZapStableSwapFactoryOne.vy | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/zaps/ZapStableSwapFactory.vy b/contracts/zaps/ZapStableSwapFactory.vy index 4c06b51..4979337 100644 --- a/contracts/zaps/ZapStableSwapFactory.vy +++ b/contracts/zaps/ZapStableSwapFactory.vy @@ -532,7 +532,7 @@ def _add_to_base(_pool: address, _amounts: uint256[POOL_N_COINS], _use_eth: bool n_coins: uint256 = BASE_MAX_N_COINS for i in range(BASE_MAX_N_COINS): coin: address = base_coins[i] - if coin == ZERO_ADDRESS: + if coin == empty(address): n_coins = i break eth_amount += self._receive(coin, _amounts[1 + i], _use_eth, True) @@ -652,7 +652,7 @@ def _remove_from_base(_pool: address, _min_amounts: uint256[POOL_N_COINS], _use_ base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(_pool) n_coins: uint256 = BASE_MAX_N_COINS for i in range(BASE_MAX_N_COINS): - if base_coins[i] == ZERO_ADDRESS: + if base_coins[i] == empty(address): n_coins = i break if self._need_to_handle_eth(_use_eth, base_coins[i]): # Need to wrap ETH @@ -680,7 +680,7 @@ def _remove_from_base(_pool: address, _min_amounts: uint256[POOL_N_COINS], _use_ if receiver == self: for coin in base_coins: - if coin == ZERO_ADDRESS: + if coin == empty(address): break self._send(coin, _receiver, _use_eth) return returned diff --git a/contracts/zaps/ZapStableSwapFactoryOne.vy b/contracts/zaps/ZapStableSwapFactoryOne.vy index 80c62e8..3e5787b 100644 --- a/contracts/zaps/ZapStableSwapFactoryOne.vy +++ b/contracts/zaps/ZapStableSwapFactoryOne.vy @@ -499,7 +499,7 @@ def _add_to_base(_pool: address, _amounts: uint256[ALL_N_COINS], _use_eth: bool) n_coins: uint256 = BASE_MAX_N_COINS for i in range(BASE_MAX_N_COINS): coin: address = base_coins[i] - if coin == ZERO_ADDRESS: + if coin == empty(address): n_coins = i break eth_amount += self._receive(coin, _amounts[1 + i], _use_eth, True) @@ -621,7 +621,7 @@ def _remove_from_base(_pool: address, _min_amounts: uint256[ALL_N_COINS], _use_e base_coins: address[BASE_MAX_N_COINS] = STABLE_FACTORY.get_coins(_pool) n_coins: uint256 = BASE_MAX_N_COINS for i in range(BASE_MAX_N_COINS): - if base_coins[i] == ZERO_ADDRESS: + if base_coins[i] == empty(address): n_coins = i break if self._need_to_handle_eth(_use_eth, base_coins[i]): # Need to wrap ETH @@ -649,7 +649,7 @@ def _remove_from_base(_pool: address, _min_amounts: uint256[ALL_N_COINS], _use_e if receiver == self: for coin in base_coins: - if coin == ZERO_ADDRESS: + if coin == empty(address): break self._send(coin, _receiver, _use_eth) return returned From 5779afa5921c1bc1dc1e1859545fd3ed9ef54fbf Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Fri, 5 Aug 2022 23:53:29 +0300 Subject: [PATCH 10/10] chore: add deployment addresses --- deployment-logs/mainnet.log | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deployment-logs/mainnet.log b/deployment-logs/mainnet.log index 210c34f..b5c2244 100644 --- a/deployment-logs/mainnet.log +++ b/deployment-logs/mainnet.log @@ -4,3 +4,5 @@ Gauge template: 0xdc892358d55d5Ae1Ec47a531130D62151EBA36E5 Factory: 0xF18056Bbd320E96A48e3Fbf8bC061322531aac99 Meta Tricrypto Zap(ZapETH): 0x070A5C8a99002F50C18B52B90e938BC477611b16 Meta 3Pool Zap(Zap3PoolETH): 0x97aDC08FA1D849D2C48C5dcC1DaB568B169b0267 +StableSwap Factory Zap(ZapStableSwapFactoryOne): 0xA588Cf5e85851f8234AA89DA0aBe7D3DE2Adfa30 +Universal StableSwap Factory Zap(ZapStableSwapFactory): 0x5Af79133999f7908953E94b7A5CF367740Ebee35