diff --git a/.gitignore b/.gitignore index fbdad4b7..570b231e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ __pycache__ .history .hypothesis/ +.idea/ build/ reports/ .venv/ diff --git a/contracts/pools/rai/CurveCryptoMath3.vy b/contracts/pools/rai/CurveCryptoMath3.vy new file mode 100644 index 00000000..b4ab4d74 --- /dev/null +++ b/contracts/pools/rai/CurveCryptoMath3.vy @@ -0,0 +1,312 @@ +# @version 0.2.12 +# (c) Curve.Fi, 2021 +# Math for crypto pools +# +# Unless otherwise agreed on, only contracts owned by Curve DAO or +# Swiss Stake GmbH are allowed to call this contract. + +N_COINS: constant(int128) = 3 # <- change +A_MULTIPLIER: constant(uint256) = 10000 + +MIN_GAMMA: constant(uint256) = 10**10 +MAX_GAMMA: constant(uint256) = 5 * 10**16 + +MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 100 +MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000 + + +@internal +@pure +def sort(A0: uint256[N_COINS]) -> uint256[N_COINS]: + """ + Insertion sort from high to low + """ + A: uint256[N_COINS] = A0 + for i in range(1, N_COINS): + x: uint256 = A[i] + cur: uint256 = i + for j in range(N_COINS): + y: uint256 = A[cur-1] + if y > x: + break + A[cur] = y + cur -= 1 + if cur == 0: + break + A[cur] = x + return A + + +@internal +@view +def _geometric_mean(unsorted_x: uint256[N_COINS], sort: bool = True) -> uint256: + """ + (x[0] * x[1] * ...) ** (1/N) + """ + x: uint256[N_COINS] = unsorted_x + if sort: + x = self.sort(x) + D: uint256 = x[0] + diff: uint256 = 0 + for i in range(255): + D_prev: uint256 = D + tmp: uint256 = 10**18 + for _x in x: + tmp = tmp * _x / D + D = D * ((N_COINS - 1) * 10**18 + tmp) / (N_COINS * 10**18) + if D > D_prev: + diff = D - D_prev + else: + diff = D_prev - D + if diff <= 1 or diff * 10**18 < D: + return D + raise "Did not converge" + + +@external +@view +def geometric_mean(unsorted_x: uint256[N_COINS], sort: bool = True) -> uint256: + return self._geometric_mean(unsorted_x, sort) + + +@external +@view +def reduction_coefficient(x: uint256[N_COINS], fee_gamma: uint256) -> uint256: + """ + fee_gamma / (fee_gamma + (1 - K)) + where + K = prod(x) / (sum(x) / N)**N + (all normalized to 1e18) + """ + K: uint256 = 10**18 + S: uint256 = 0 + for x_i in x: + S += x_i + # Could be good to pre-sort x, but it is used only for dynamic fee, + # so that is not so important + for x_i in x: + K = K * N_COINS * x_i / S + if fee_gamma > 0: + K = fee_gamma * 10**18 / (fee_gamma + 10**18 - K) + return K + + +@external +@view +def newton_D(ANN: uint256, gamma: uint256, x_unsorted: uint256[N_COINS]) -> uint256: + """ + Finding the invariant using Newton method. + ANN is higher by the factor A_MULTIPLIER + ANN is already A * N**N + + Currently uses 60k gas + """ + # Safety checks + assert ANN > MIN_A - 1 and ANN < MAX_A + 1 # dev: unsafe values A + assert gamma > MIN_GAMMA - 1 and gamma < MAX_GAMMA + 1 # dev: unsafe values gamma + + # Initial value of invariant D is that for constant-product invariant + x: uint256[N_COINS] = self.sort(x_unsorted) + + assert x[0] > 10**9 - 1 and x[0] < 10**15 * 10**18 + 1 # dev: unsafe values x[0] + for i in range(1, N_COINS): + frac: uint256 = x[i] * 10**18 / x[0] + assert frac > 10**11-1 # dev: unsafe values x[i] + + D: uint256 = N_COINS * self._geometric_mean(x, False) + S: uint256 = 0 + for x_i in x: + S += x_i + + for i in range(255): + D_prev: uint256 = D + + K0: uint256 = 10**18 + for _x in x: + K0 = K0 * _x * N_COINS / D + + _g1k0: uint256 = gamma + 10**18 + if _g1k0 > K0: + _g1k0 = _g1k0 - K0 + 1 + else: + _g1k0 = K0 - _g1k0 + 1 + + # D / (A * N**N) * _g1k0**2 / gamma**2 + mul1: uint256 = 10**18 * D / gamma * _g1k0 / gamma * _g1k0 * A_MULTIPLIER / ANN + + # 2*N*K0 / _g1k0 + mul2: uint256 = (2 * 10**18) * N_COINS * K0 / _g1k0 + + neg_fprime: uint256 = (S + S * mul2 / 10**18) + mul1 * N_COINS / K0 - mul2 * D / 10**18 + + # D -= f / fprime + D_plus: uint256 = D * (neg_fprime + S) / neg_fprime + D_minus: uint256 = D*D / neg_fprime + if 10**18 > K0: + D_minus += D * (mul1 / neg_fprime) / 10**18 * (10**18 - K0) / K0 + else: + D_minus -= D * (mul1 / neg_fprime) / 10**18 * (K0 - 10**18) / K0 + + if D_plus > D_minus: + D = D_plus - D_minus + else: + D = (D_minus - D_plus) / 2 + + diff: uint256 = 0 + if D > D_prev: + diff = D - D_prev + else: + diff = D_prev - D + if diff * 10**14 < max(10**16, D): # Could reduce precision for gas efficiency here + # Test that we are safe with the next newton_y + for _x in x: + frac: uint256 = _x * 10**18 / D + assert (frac > 10**16 - 1) and (frac < 10**20 + 1) # dev: unsafe values x[i] + return D + + raise "Did not converge" + + +@external +@view +def newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: uint256) -> uint256: + """ + Calculating x[i] given other balances x[0..N_COINS-1] and invariant D + ANN = A * N**N + """ + # Safety checks + assert ANN > MIN_A - 1 and ANN < MAX_A + 1 # dev: unsafe values A + assert gamma > MIN_GAMMA - 1 and gamma < MAX_GAMMA + 1 # dev: unsafe values gamma + assert D > 10**17 - 1 and D < 10**15 * 10**18 + 1 # dev: unsafe values D + for k in range(3): + if k != i: + frac: uint256 = x[k] * 10**18 / D + assert (frac > 10**16 - 1) and (frac < 10**20 + 1) # dev: unsafe values x[i] + + y: uint256 = D / N_COINS + K0_i: uint256 = 10**18 + S_i: uint256 = 0 + + x_sorted: uint256[N_COINS] = x + x_sorted[i] = 0 + x_sorted = self.sort(x_sorted) # From high to low + + convergence_limit: uint256 = max(max(x_sorted[0] / 10**14, D / 10**14), 100) + for j in range(2, N_COINS+1): + _x: uint256 = x_sorted[N_COINS-j] + y = y * D / (_x * N_COINS) # Small _x first + S_i += _x + for j in range(N_COINS-1): + K0_i = K0_i * x_sorted[j] * N_COINS / D # Large _x first + + for j in range(255): + y_prev: uint256 = y + + K0: uint256 = K0_i * y * N_COINS / D + S: uint256 = S_i + y + + _g1k0: uint256 = gamma + 10**18 + if _g1k0 > K0: + _g1k0 = _g1k0 - K0 + 1 + else: + _g1k0 = K0 - _g1k0 + 1 + + # D / (A * N**N) * _g1k0**2 / gamma**2 + mul1: uint256 = 10**18 * D / gamma * _g1k0 / gamma * _g1k0 * A_MULTIPLIER / ANN + + # 2*K0 / _g1k0 + mul2: uint256 = 10**18 + (2 * 10**18) * K0 / _g1k0 + + yfprime: uint256 = 10**18 * y + S * mul2 + mul1 + _dyfprime: uint256 = D * mul2 + if yfprime < _dyfprime: + y = y_prev / 2 + continue + else: + yfprime -= _dyfprime + fprime: uint256 = yfprime / y + + # y -= f / f_prime; y = (y * fprime - f) / fprime + # y = (yfprime + 10**18 * D - 10**18 * S) // fprime + mul1 // fprime * (10**18 - K0) // K0 + y_minus: uint256 = mul1 / fprime + y_plus: uint256 = (yfprime + 10**18 * D) / fprime + y_minus * 10**18 / K0 + y_minus += 10**18 * S / fprime + + if y_plus < y_minus: + y = y_prev / 2 + else: + y = y_plus - y_minus + + diff: uint256 = 0 + if y > y_prev: + diff = y - y_prev + else: + diff = y_prev - y + if diff < max(convergence_limit, y / 10**14): + frac: uint256 = y * 10**18 / D + assert (frac > 10**16 - 1) and (frac < 10**20 + 1) # dev: unsafe value for y + return y + + raise "Did not converge" + + +@external +@view +def halfpow(power: uint256, precision: uint256) -> uint256: + """ + 1e18 * 0.5 ** (power/1e18) + + Inspired by: https://github.com/balancer-labs/balancer-core/blob/master/contracts/BNum.sol#L128 + """ + intpow: uint256 = power / 10**18 + otherpow: uint256 = power - intpow * 10**18 + if intpow > 59: + return 0 + result: uint256 = 10**18 / (2**intpow) + if otherpow == 0: + return result + + term: uint256 = 10**18 + x: uint256 = 5 * 10**17 + S: uint256 = 10**18 + neg: bool = False + + for i in range(1, 256): + K: uint256 = i * 10**18 + c: uint256 = K - 10**18 + if otherpow > c: + c = otherpow - c + neg = not neg + else: + c -= otherpow + term = term * (c * x / 10**18) / K + if neg: + S -= term + else: + S += term + if term < precision: + return result * S / 10**18 + + raise "Did not converge" + + +@external +@view +def sqrt_int(x: uint256) -> uint256: + """ + Originating from: https://github.com/vyperlang/vyper/issues/1266 + """ + + if x == 0: + return 0 + + z: uint256 = (x + 10**18) / 2 + y: uint256 = x + + for i in range(256): + if z == y: + return y + y = z + z = (x * 10**18 / z + z) / 2 + + raise "Did not converge" diff --git a/contracts/pools/rai/DepositRAI.vy b/contracts/pools/rai/DepositRAI.vy new file mode 100644 index 00000000..b7c1f776 --- /dev/null +++ b/contracts/pools/rai/DepositRAI.vy @@ -0,0 +1,376 @@ +# @version 0.2.12 +""" +@title "Zap" Depositer for metapool +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2020 - all rights reserved +@notice deposit/withdraw to Curve pool without too many transactions +""" + +from vyper.interfaces import ERC20 + + +interface CurveMeta: + def add_liquidity(amounts: uint256[N_COINS], min_mint_amount: uint256) -> uint256: nonpayable + def remove_liquidity(_amount: uint256, min_amounts: uint256[N_COINS]) -> uint256[N_COINS]: nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256) -> uint256: nonpayable + def remove_liquidity_imbalance(amounts: uint256[N_COINS], max_burn_amount: uint256) -> uint256: nonpayable + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def calc_token_amount(amounts: uint256[N_COINS], deposit: bool) -> uint256: view + def base_pool() -> address: view + def coins(i: uint256) -> address: view + +interface CurveBase: + def add_liquidity(amounts: uint256[BASE_N_COINS], min_mint_amount: uint256): nonpayable + def remove_liquidity(_amount: uint256, min_amounts: uint256[BASE_N_COINS]): nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256): nonpayable + def remove_liquidity_imbalance(amounts: uint256[BASE_N_COINS], max_burn_amount: uint256): nonpayable + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def calc_token_amount(amounts: uint256[BASE_N_COINS], deposit: bool) -> uint256: view + def coins(i: uint256) -> address: view + def fee() -> uint256: view + + +N_COINS: constant(int128) = 2 +MAX_COIN: constant(int128) = N_COINS-1 +BASE_N_COINS: constant(int128) = 3 +N_ALL_COINS: constant(int128) = N_COINS + BASE_N_COINS - 1 + +# An asset which may have a transfer fee (USDT) +FEE_ASSET: constant(address) = 0xdAC17F958D2ee523a2206206994597C13D831ec7 + +FEE_DENOMINATOR: constant(uint256) = 10 ** 10 +FEE_IMPRECISION: constant(uint256) = 100 * 10 ** 8 # % of the fee + + +pool: public(address) +token: public(address) +base_pool: public(address) + +coins: public(address[N_COINS]) +base_coins: public(address[BASE_N_COINS]) + + +@external +def __init__(_pool: address, _token: address): + """ + @notice Contract constructor + @param _pool Metapool address + @param _token Pool LP token address + """ + self.pool = _pool + self.token = _token + base_pool: address = CurveMeta(_pool).base_pool() + self.base_pool = base_pool + + for i in range(N_COINS): + coin: address = CurveMeta(_pool).coins(i) + self.coins[i] = coin + # approve coins for infinite transfers + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("approve(address,uint256)"), + convert(_pool, bytes32), + convert(MAX_UINT256, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + for i in range(BASE_N_COINS): + coin: address = CurveBase(base_pool).coins(i) + self.base_coins[i] = coin + # approve underlying coins for infinite transfers + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("approve(address,uint256)"), + convert(base_pool, bytes32), + convert(MAX_UINT256, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + +@external +def add_liquidity(_amounts: uint256[N_ALL_COINS], _min_mint_amount: uint256) -> uint256: + """ + @notice Wrap underlying coins and deposit them in the pool + @param _amounts List of amounts of underlying coins to deposit + @param _min_mint_amount Minimum amount of LP tokens to mint from the deposit + @return Amount of LP tokens received by depositing + """ + meta_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + base_amounts: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + deposit_base: bool = False + + # Transfer all coins in + for i in range(N_ALL_COINS): + amount: uint256 = _amounts[i] + if amount == 0: + continue + coin: address = ZERO_ADDRESS + if i < MAX_COIN: + coin = self.coins[i] + meta_amounts[i] = amount + else: + x: int128 = i - MAX_COIN + coin = self.base_coins[x] + base_amounts[x] = amount + deposit_base = True + # "safeTransferFrom" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransferFrom" + # Handle potential Tether fees + if coin == FEE_ASSET: + amount = ERC20(FEE_ASSET).balanceOf(self) + if i < MAX_COIN: + meta_amounts[i] = amount + else: + base_amounts[i - MAX_COIN] = amount + + # Deposit to the base pool + if deposit_base: + CurveBase(self.base_pool).add_liquidity(base_amounts, 0) + meta_amounts[MAX_COIN] = ERC20(self.coins[MAX_COIN]).balanceOf(self) + + # Deposit to the meta pool + CurveMeta(self.pool).add_liquidity(meta_amounts, _min_mint_amount) + + # Transfer meta token back + lp_token: address = self.token + lp_amount: uint256 = ERC20(lp_token).balanceOf(self) + assert ERC20(lp_token).transfer(msg.sender, lp_amount) + + return lp_amount + + +@external +def remove_liquidity(_amount: uint256, _min_amounts: uint256[N_ALL_COINS]) -> uint256[N_ALL_COINS]: + """ + @notice Withdraw and unwrap coins from the pool + @dev Withdrawal amounts are based on current deposit ratios + @param _amount Quantity of LP tokens to burn in the withdrawal + @param _min_amounts Minimum amounts of underlying coins to receive + @return List of amounts of underlying coins that were withdrawn + """ + _token: address = self.token + assert ERC20(_token).transferFrom(msg.sender, self, _amount) + + min_amounts_meta: uint256[N_COINS] = empty(uint256[N_COINS]) + min_amounts_base: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + amounts: uint256[N_ALL_COINS] = empty(uint256[N_ALL_COINS]) + + # Withdraw from meta + for i in range(MAX_COIN): + min_amounts_meta[i] = _min_amounts[i] + CurveMeta(self.pool).remove_liquidity(_amount, min_amounts_meta) + + # Withdraw from base + _base_amount: uint256 = ERC20(self.coins[MAX_COIN]).balanceOf(self) + for i in range(BASE_N_COINS): + min_amounts_base[i] = _min_amounts[MAX_COIN+i] + CurveBase(self.base_pool).remove_liquidity(_base_amount, min_amounts_base) + + # Transfer all coins out + for i in range(N_ALL_COINS): + coin: address = ZERO_ADDRESS + if i < MAX_COIN: + coin = self.coins[i] + else: + coin = self.base_coins[i - MAX_COIN] + amounts[i] = ERC20(coin).balanceOf(self) + # "safeTransfer" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(amounts[i], bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransfer" + + return amounts + + +@external +def remove_liquidity_one_coin(_token_amount: uint256, i: int128, _min_amount: uint256) -> uint256: + """ + @notice Withdraw and unwrap a single coin from the pool + @param _token_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 + @return Amount of underlying coin received + """ + assert ERC20(self.token).transferFrom(msg.sender, self, _token_amount) + + coin: address = ZERO_ADDRESS + if i < MAX_COIN: + coin = self.coins[i] + # Withdraw a metapool coin + CurveMeta(self.pool).remove_liquidity_one_coin(_token_amount, i, _min_amount) + else: + coin = self.base_coins[i - MAX_COIN] + # Withdraw a base pool coin + CurveMeta(self.pool).remove_liquidity_one_coin(_token_amount, MAX_COIN, 0) + CurveBase(self.base_pool).remove_liquidity_one_coin( + ERC20(self.coins[MAX_COIN]).balanceOf(self), i-MAX_COIN, _min_amount + ) + + # Tranfer the coin out + coin_amount: uint256 = ERC20(coin).balanceOf(self) + # "safeTransfer" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(coin_amount, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransfer" + + return coin_amount + + +@external +def remove_liquidity_imbalance(_amounts: uint256[N_ALL_COINS], _max_burn_amount: uint256) -> uint256: + """ + @notice Withdraw coins from the pool in an imbalanced amount + @param _amounts List of amounts of underlying coins to withdraw + @param _max_burn_amount Maximum amount of LP token to burn in the withdrawal + @return Actual amount of the LP token burned in the withdrawal + """ + base_pool: address = self.base_pool + meta_pool: address = self.pool + base_coins: address[BASE_N_COINS] = self.base_coins + meta_coins: address[N_COINS] = self.coins + lp_token: address = self.token + + fee: uint256 = CurveBase(base_pool).fee() * BASE_N_COINS / (4 * (BASE_N_COINS - 1)) + fee += fee * FEE_IMPRECISION / FEE_DENOMINATOR # Overcharge to account for imprecision + + # Transfer the LP token in + assert ERC20(lp_token).transferFrom(msg.sender, self, _max_burn_amount) + + withdraw_base: bool = False + amounts_base: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + amounts_meta: uint256[N_COINS] = empty(uint256[N_COINS]) + leftover_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + + # Prepare quantities + for i in range(MAX_COIN): + amounts_meta[i] = _amounts[i] + + for i in range(BASE_N_COINS): + amount: uint256 = _amounts[MAX_COIN + i] + if amount != 0: + amounts_base[i] = amount + withdraw_base = True + + if withdraw_base: + amounts_meta[MAX_COIN] = CurveBase(self.base_pool).calc_token_amount(amounts_base, False) + amounts_meta[MAX_COIN] += amounts_meta[MAX_COIN] * fee / FEE_DENOMINATOR + 1 + + # Remove liquidity and deposit leftovers back + CurveMeta(meta_pool).remove_liquidity_imbalance(amounts_meta, _max_burn_amount) + if withdraw_base: + CurveBase(base_pool).remove_liquidity_imbalance(amounts_base, amounts_meta[MAX_COIN]) + leftover_amounts[MAX_COIN] = ERC20(meta_coins[MAX_COIN]).balanceOf(self) + if leftover_amounts[MAX_COIN] > 0: + CurveMeta(meta_pool).add_liquidity(leftover_amounts, 0) + + # Transfer all coins out + for i in range(N_ALL_COINS): + coin: address = ZERO_ADDRESS + amount: uint256 = 0 + if i < MAX_COIN: + coin = meta_coins[i] + amount = amounts_meta[i] + else: + coin = base_coins[i - MAX_COIN] + amount = amounts_base[i - MAX_COIN] + # "safeTransfer" which works for ERC20s which return bool or not + if amount > 0: + _response: Bytes[32] = raw_call( + coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransfer" + + # Transfer the leftover LP token out + leftover: uint256 = ERC20(lp_token).balanceOf(self) + if leftover > 0: + assert ERC20(lp_token).transfer(msg.sender, leftover) + + return _max_burn_amount - leftover + + +@view +@external +def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: + """ + @notice Calculate the amount received when withdrawing and unwrapping a single coin + @param _token_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the underlying coin to withdraw + @return Amount of coin received + """ + if i < MAX_COIN: + return CurveMeta(self.pool).calc_withdraw_one_coin(_token_amount, i) + else: + base_tokens: uint256 = CurveMeta(self.pool).calc_withdraw_one_coin(_token_amount, MAX_COIN) + return CurveBase(self.base_pool).calc_withdraw_one_coin(base_tokens, i-MAX_COIN) + + +@view +@external +def calc_token_amount(_amounts: uint256[N_ALL_COINS], _is_deposit: bool) -> uint256: + """ + @notice Calculate addition or reduction in token supply from a deposit or withdrawal + @dev This calculation accounts for slippage, but not fees. + Needed to prevent front-running, not for precise calculations! + @param _amounts Amount of each underlying coin being deposited + @param _is_deposit set True for deposits, False for withdrawals + @return Expected amount of LP tokens received + """ + meta_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + base_amounts: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + + for i in range(MAX_COIN): + meta_amounts[i] = _amounts[i] + + for i in range(BASE_N_COINS): + base_amounts[i] = _amounts[i + MAX_COIN] + + base_tokens: uint256 = CurveBase(self.base_pool).calc_token_amount(base_amounts, _is_deposit) + meta_amounts[MAX_COIN] = base_tokens + + return CurveMeta(self.pool).calc_token_amount(meta_amounts, _is_deposit) diff --git a/contracts/pools/rai/README.md b/contracts/pools/rai/README.md new file mode 100644 index 00000000..ab2d56d3 --- /dev/null +++ b/contracts/pools/rai/README.md @@ -0,0 +1,27 @@ +# curve-contract/contracts/pools/rai + +[Curve RAI metapool](https://www.curve.fi/rai), allowing swaps via the Curve [tri-pool](../3pool). + +## Contracts + +* [`DepositRAI`](DepositRAI.vy): Depositor contract, used to wrap underlying tokens prior to depositing them into the pool +* [`StableSwapRAI`](StableSwapRAI.vy): Curve stablecoin AMM contract + +## Stablecoins + +Curve RAI metapool supports swaps between the following assets: + +### Direct swaps + +Direct swaps are possible between RAI and the Curve tri-pool LP token. +0x03ab458634910AaD20eF5f1C8ee96F1D6ac54919 +* `RAI`: [0x03ab458634910AaD20eF5f1C8ee96F1D6ac54919](https://etherscan.io/address/0x03ab458634910AaD20eF5f1C8ee96F1D6ac54919) +* `3CRV`: [0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490](https://etherscan.io/address/0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490) + +### Base Pool coins + +The tri-pool LP token may be wrapped or unwrapped to provide swaps between RAI and the following stablecoins: + +* `DAI`: [0x6b175474e89094c44da98b954eedeac495271d0f](https://etherscan.io/address/0x6b175474e89094c44da98b954eedeac495271d0f) +* `USDC`: [0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48](https://etherscan.io/address/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48) +* `USDT`: [0xdac17f958d2ee523a2206206994597c13d831ec7](https://etherscan.io/address/0xdac17f958d2ee523a2206206994597c13d831ec7) diff --git a/contracts/pools/rai/StableSwapRAI.vy b/contracts/pools/rai/StableSwapRAI.vy new file mode 100644 index 00000000..78020b93 --- /dev/null +++ b/contracts/pools/rai/StableSwapRAI.vy @@ -0,0 +1,1160 @@ +# @version 0.2.12 +""" +@title StableSwap +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2021 - all rights reserved +@notice Metapool implementation +@dev Swaps between 3pool and RAI +""" + +from vyper.interfaces import ERC20 + +interface CurveToken: + def totalSupply() -> uint256: view + def mint(_to: address, _value: uint256) -> bool: nonpayable + def burnFrom(_to: address, _value: uint256) -> bool: nonpayable + +interface Curve: + def coins(i: uint256) -> address: view + def get_virtual_price() -> uint256: view + def calc_token_amount(amounts: uint256[BASE_N_COINS], deposit: bool) -> uint256: view + def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: view + def fee() -> uint256: view + def get_dy(i: int128, j: int128, dx: uint256) -> uint256: view + def get_dy_underlying(i: int128, j: int128, dx: uint256) -> uint256: view + def exchange(i: int128, j: int128, dx: uint256, min_dy: uint256): nonpayable + def add_liquidity(amounts: uint256[BASE_N_COINS], min_mint_amount: uint256): nonpayable + def remove_liquidity_one_coin(_token_amount: uint256, i: int128, min_amount: uint256): nonpayable + +interface RedemptionPriceSnap: + def snappedRedemptionPrice() -> uint256: view + +# Events +event TokenExchange: + buyer: indexed(address) + sold_id: int128 + tokens_sold: uint256 + bought_id: int128 + tokens_bought: uint256 + +event TokenExchangeUnderlying: + buyer: indexed(address) + sold_id: int128 + tokens_sold: uint256 + bought_id: int128 + tokens_bought: uint256 + +event AddLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + invariant: uint256 + token_supply: uint256 + +event RemoveLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + token_supply: uint256 + +event RemoveLiquidityOne: + provider: indexed(address) + token_amount: uint256 + coin_amount: uint256 + token_supply: uint256 + +event RemoveLiquidityImbalance: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + invariant: uint256 + token_supply: uint256 + +event CommitNewAdmin: + deadline: indexed(uint256) + admin: indexed(address) + +event NewAdmin: + admin: indexed(address) + +event CommitNewFee: + deadline: indexed(uint256) + fee: uint256 + admin_fee: uint256 + +event NewFee: + fee: uint256 + admin_fee: uint256 + +event RampA: + old_A: uint256 + new_A: uint256 + initial_time: uint256 + future_time: uint256 + +event StopRampA: + A: uint256 + t: uint256 + + +N_COINS: constant(uint256) = 2 +MAX_COIN: constant(uint256) = N_COINS - 1 +REDEMPTION_COIN: constant(uint256) = 0 # Index of asset with moving target redemption price +REDMPTION_PRICE_SCALE: constant(uint256) = 10 ** 9 + +FEE_DENOMINATOR: constant(uint256) = 10 ** 10 +PRECISION: constant(uint256) = 10 ** 18 # The precision to convert to +BASE_N_COINS: constant(uint256) = 3 + +# An asset which may have a transfer fee (USDT) +FEE_ASSET: constant(address) = 0xdAC17F958D2ee523a2206206994597C13D831ec7 + +MAX_ADMIN_FEE: constant(uint256) = 10 * 10 ** 9 +MAX_FEE: constant(uint256) = 5 * 10 ** 9 +MAX_A: constant(uint256) = 10 ** 6 +MAX_A_CHANGE: constant(uint256) = 10 + +ADMIN_ACTIONS_DELAY: constant(uint256) = 3 * 86400 +MIN_RAMP_TIME: constant(uint256) = 86400 + +coins: public(address[N_COINS]) +balances: public(uint256[N_COINS]) +fee: public(uint256) # fee * 1e10 +admin_fee: public(uint256) # admin_fee * 1e10 + +owner: public(address) +lp_token: public(address) +redemption_price_snap: public(address) + +# Token corresponding to the pool is always the last one +BASE_CACHE_EXPIRES: constant(int128) = 10 * 60 # 10 min +base_pool: public(address) +base_virtual_price: public(uint256) +base_cache_updated: public(uint256) +base_coins: public(address[BASE_N_COINS]) + +A_PRECISION: constant(uint256) = 100 +initial_A: public(uint256) +future_A: public(uint256) +initial_A_time: public(uint256) +future_A_time: public(uint256) + +admin_actions_deadline: public(uint256) +transfer_ownership_deadline: public(uint256) +future_fee: public(uint256) +future_admin_fee: public(uint256) +future_owner: public(address) + +is_killed: bool +kill_deadline: uint256 +KILL_DEADLINE_DT: constant(uint256) = 2 * 30 * 86400 + + +@external +def __init__( + _owner: address, + _coins: address[N_COINS], + _pool_token: address, + _base_pool: address, + _redemption_price_snap: address, + _A: uint256, + _fee: uint256, + _admin_fee: uint256 +): + """ + @notice Contract constructor + @param _owner Contract owner address + @param _coins Addresses of ERC20 conracts of coins + @param _pool_token Address of the token representing LP share + @param _base_pool Address of the base pool (which will have a virtual price) + @param _redemption_price_snap Address of contract providing snapshot of redemption price + @param _A Amplification coefficient multiplied by n * (n - 1) + @param _fee Fee to charge for exchanges + @param _admin_fee Admin fee + """ + for i in range(N_COINS): + assert _coins[i] != ZERO_ADDRESS + self.coins = _coins + self.initial_A = _A * A_PRECISION + self.future_A = _A * A_PRECISION + self.fee = _fee + self.admin_fee = _admin_fee + self.owner = _owner + self.kill_deadline = block.timestamp + KILL_DEADLINE_DT + self.lp_token = _pool_token + + self.base_pool = _base_pool + self.base_virtual_price = Curve(_base_pool).get_virtual_price() + self.base_cache_updated = block.timestamp + self.redemption_price_snap = _redemption_price_snap + for i in range(BASE_N_COINS): + base_coin: address = Curve(_base_pool).coins(i) + self.base_coins[i] = base_coin + + # approve underlying coins for infinite transfers + response: Bytes[32] = raw_call( + base_coin, + concat( + method_id("approve(address,uint256)"), + convert(_base_pool, bytes32), + convert(MAX_UINT256, bytes32), + ), + max_outsize=32, + ) + if len(response) > 0: + assert convert(response, bool) + +@view +@internal +def _A() -> uint256: + """ + Handle ramping A up or down + """ + t1: uint256 = self.future_A_time + A1: uint256 = self.future_A + + if block.timestamp < t1: + A0: uint256 = self.initial_A + t0: uint256 = self.initial_A_time + # Expressions in uint256 cannot have negative numbers, thus "if" + if A1 > A0: + return A0 + (A1 - A0) * (block.timestamp - t0) / (t1 - t0) + else: + return A0 - (A0 - A1) * (block.timestamp - t0) / (t1 - t0) + + else: # when t1 == 0 or block.timestamp >= t1 + return A1 + +@internal +@view +def sqrt(x: uint256) -> uint256: + """ + Originating from: https://github.com/vyperlang/vyper/issues/1266 + """ + + if x == 0: + return 0 + + z: uint256 = (x + 10**18) / 2 + y: uint256 = x + + for i in range(256): + if z == y: + return y + y = z + z = (x * 10**18 / z + z) / 2 + + raise "Did not converge" + +@view +@external +def A() -> uint256: + return self._A() / A_PRECISION + + +@view +@external +def A_precise() -> uint256: + return self._A() + + +@view +@internal +def _get_scaled_redemption_price() -> uint256: + """ + @notice Reads a snapshot view of redemption price + @dev The fetched redemption price uses 27 decimals + @return The redemption price with appropriate scaling to match LP tokens vitual price. + """ + rate: uint256 = RedemptionPriceSnap(self.redemption_price_snap).snappedRedemptionPrice() + return rate / REDMPTION_PRICE_SCALE + + +@view +@internal +def _xp(_vp_rate: uint256) -> uint256[N_COINS]: + result: uint256[N_COINS] = [self._get_scaled_redemption_price(), _vp_rate] + for i in range(N_COINS): + result[i] = result[i] * self.balances[i] / PRECISION + return result + + +@pure +@internal +def _xp_mem(_rates: uint256[N_COINS], _balances: uint256[N_COINS]) -> uint256[N_COINS]: + result: uint256[N_COINS] = _rates + for i in range(N_COINS): + result[i] = result[i] * _balances[i] / PRECISION + return result + + +@internal +def _vp_rate() -> uint256: + if block.timestamp > self.base_cache_updated + BASE_CACHE_EXPIRES: + vprice: uint256 = Curve(self.base_pool).get_virtual_price() + self.base_virtual_price = vprice + self.base_cache_updated = block.timestamp + return vprice + else: + return self.base_virtual_price + + +@internal +@view +def _vp_rate_ro() -> uint256: + if block.timestamp > self.base_cache_updated + BASE_CACHE_EXPIRES: + return Curve(self.base_pool).get_virtual_price() + else: + return self.base_virtual_price + + +@pure +@internal +def _get_D(_xp: uint256[N_COINS], _amp: uint256) -> uint256: + S: uint256 = 0 + Dprev: uint256 = 0 + + for _x in _xp: + S += _x + if S == 0: + return 0 + + D: uint256 = S + Ann: uint256 = _amp * N_COINS + for _i in range(255): + D_P: uint256 = D + for _x in _xp: + D_P = D_P * D / (_x * N_COINS) # If division by 0, this will be borked: only withdrawal will work. And that is good + Dprev = D + D = (Ann * S / A_PRECISION + D_P * N_COINS) * D / ((Ann - A_PRECISION) * D / A_PRECISION + (N_COINS + 1) * D_P) + # Equality with the precision of 1 + if D > Dprev: + if D - Dprev <= 1: + return D + else: + if Dprev - D <= 1: + return D + # convergence typically occurs in 4 rounds or less, this should be unreachable! + # if it does happen the pool is borked and LPs can withdraw via `remove_liquidity` + raise + + +@view +@internal +def _get_D_mem(_rates: uint256[N_COINS], _balances: uint256[N_COINS], _amp: uint256) -> uint256: + return self._get_D(self._xp_mem(_rates, _balances), _amp) + + +@view +@internal +def _get_virtual_price() -> uint256: + """ + @notice The current virtual price of the pool LP token + @return LP token virtual price normalized to 1e18 + """ + amp: uint256 = self._A() + vp_rate: uint256 = self._vp_rate_ro() + xp: uint256[N_COINS] = self._xp(vp_rate) + D: uint256 = self._get_D(xp, amp) + # D is in the units similar to DAI (e.g. converted to precision 1e18) + # When balanced, D = n * x_u - total virtual value of the portfolio + token_supply: uint256 = CurveToken(self.lp_token).totalSupply() + return D * PRECISION / token_supply + + +@view +@external +def get_virtual_price() -> uint256: + """ + @notice The current virtual price of the pool LP token + @dev Useful for calculating profits + @return LP token virtual price normalized to 1e18 + """ + return self._get_virtual_price() + + +@view +@external +def get_virtual_price_2() -> uint256: + """ + @notice Smoother changing virtual price to accomodate for redemption price swings + @return LP token smoothed virtual price normalized to 1e18 + """ + return ( self._get_virtual_price() * PRECISION ) / self.sqrt(self._get_scaled_redemption_price()) + + +@view +@external +def calc_token_amount(_amounts: uint256[N_COINS], _is_deposit: bool) -> uint256: + """ + @notice Calculate addition or reduction in token supply from a deposit or withdrawal + @dev This calculation accounts for slippage, but not fees. + Needed to prevent front-running, not for precise calculations! + @param _amounts Amount of each coin being deposited + @param _is_deposit set True for deposits, False for withdrawals + @return Expected amount of LP tokens received + """ + amp: uint256 = self._A() + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), self._vp_rate_ro()] + balances: uint256[N_COINS] = self.balances + D0: uint256 = self._get_D_mem(rates, balances, amp) + for i in range(N_COINS): + if _is_deposit: + balances[i] += _amounts[i] + else: + balances[i] -= _amounts[i] + D1: uint256 = self._get_D_mem(rates, balances, amp) + token_amount: uint256 = CurveToken(self.lp_token).totalSupply() + diff: uint256 = 0 + if _is_deposit: + diff = D1 - D0 + else: + diff = D0 - D1 + return diff * token_amount / D0 + + +@external +@nonreentrant('lock') +def add_liquidity(_amounts: uint256[N_COINS], _min_mint_amount: uint256) -> uint256: + """ + @notice Deposit coins into the pool + @param _amounts List of amounts of coins to deposit + @param _min_mint_amount Minimum amount of LP tokens to mint from the deposit + @return Amount of LP tokens received by depositing + """ + assert not self.is_killed # dev: is killed + + amp: uint256 = self._A() + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), self._vp_rate_ro()] + old_balances: uint256[N_COINS] = self.balances + + # Initial invariant + D0: uint256 = self._get_D_mem(rates, old_balances, amp) + + lp_token: address = self.lp_token + token_supply: uint256 = CurveToken(lp_token).totalSupply() + new_balances: uint256[N_COINS] = old_balances + + for i in range(N_COINS): + if token_supply == 0: + assert _amounts[i] > 0 # dev: initial deposit requires all coins + # balances store amounts of c-tokens + new_balances[i] = old_balances[i] + _amounts[i] + + # Invariant after change + D1: uint256 = self._get_D_mem(rates, new_balances, amp) + assert D1 > D0 + + # We need to recalculate the invariant accounting for fees + # to calculate fair user's share + fees: uint256[N_COINS] = empty(uint256[N_COINS]) + D2: uint256 = D1 + mint_amount: uint256 = 0 + if token_supply > 0: + fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + admin_fee: uint256 = self.admin_fee + # Only account for fees if we are not the first to deposit + for i in range(N_COINS): + ideal_balance: uint256 = D1 * old_balances[i] / D0 + difference: uint256 = 0 + if ideal_balance > new_balances[i]: + difference = ideal_balance - new_balances[i] + else: + difference = new_balances[i] - ideal_balance + fees[i] = fee * difference / FEE_DENOMINATOR + self.balances[i] = new_balances[i] - (fees[i] * admin_fee / FEE_DENOMINATOR) + new_balances[i] -= fees[i] + D2 = self._get_D_mem(rates, new_balances, amp) + mint_amount = token_supply * (D2 - D0) / D0 + else: + self.balances = new_balances + mint_amount = D1 # Take the dust if there was any + + assert mint_amount >= _min_mint_amount, "Slippage screwed you" + + # Take coins from the sender + for i in range(N_COINS): + if _amounts[i] > 0: + # "safeTransferFrom" which works for ERC20s which return bool or not + response: Bytes[32] = raw_call( + self.coins[i], + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(_amounts[i], bytes32), + ), + max_outsize=32, + ) + if len(response) > 0: + assert convert(response, bool) # dev: failed transfer + # end "safeTransferFrom" + + # Mint pool tokens + CurveToken(lp_token).mint(msg.sender, mint_amount) + + log AddLiquidity(msg.sender, _amounts, fees, D1, token_supply + mint_amount) + + return mint_amount + + +@view +@internal +def _get_y(i: int128, j: int128, x: uint256, _xp: uint256[N_COINS]) -> uint256: + """ + Calculate x[j] if one makes x[i] = x + + Done by solving quadratic equation iteratively. + x_1**2 + x_1 * (sum' - (A*n**n - 1) * D / (A * n**n)) = D ** (n + 1) / (n ** (2 * n) * prod' * A) + x_1**2 + b*x_1 = c + + x_1 = (x_1**2 + c) / (2*x_1 + b) + """ + # x in the input is converted to the same price/precision + + assert i != j # dev: same coin + assert j >= 0 # dev: j below zero + assert j < N_COINS # dev: j above N_COINS + + # should be unreachable, but good for safety + assert i >= 0 + assert i < N_COINS + + A: uint256 = self._A() + D: uint256 = self._get_D(_xp, A) + Ann: uint256 = A * N_COINS + c: uint256 = D + S: uint256 = 0 + _x: uint256 = 0 + y_prev: uint256 = 0 + + for _i in range(N_COINS): + if _i == i: + _x = x + elif _i != j: + _x = _xp[_i] + else: + continue + S += _x + c = c * D / (_x * N_COINS) + c = c * D * A_PRECISION / (Ann * N_COINS) + b: uint256 = S + D * A_PRECISION / Ann # - D + y: uint256 = D + for _i in range(255): + y_prev = y + y = (y*y + c) / (2 * y + b - D) + # Equality with the precision of 1 + if y > y_prev: + if y - y_prev <= 1: + return y + else: + if y_prev - y <= 1: + return y + raise + + +@view +@external +def get_dy(i: int128, j: int128, _dx: uint256) -> uint256: + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), self._vp_rate_ro()] + xp: uint256[N_COINS] = self._xp(rates[MAX_COIN]) + + x: uint256 = xp[i] + (_dx * rates[i] / PRECISION) + y: uint256 = self._get_y(i, j, x, xp) + dy: uint256 = xp[j] - y - 1 + fee: uint256 = self.fee * dy / FEE_DENOMINATOR + return (dy - fee) * PRECISION / rates[j] + + +@view +@external +def get_dy_underlying(i: int128, j: int128, _dx: uint256) -> uint256: + # dx and dy in underlying units + vp_rate: uint256 = self._vp_rate_ro() + xp: uint256[N_COINS] = self._xp(vp_rate) + base_pool: address = self.base_pool + + # Use base_i or base_j if they are >= 0 + base_i: int128 = i - MAX_COIN + base_j: int128 = j - MAX_COIN + meta_i: int128 = MAX_COIN + meta_j: int128 = MAX_COIN + if base_i < 0: + meta_i = i + if base_j < 0: + meta_j = j + + x: uint256 = 0 + if base_i < 0: + x = xp[i] + (_dx * self._get_scaled_redemption_price() / PRECISION) + else: + if base_j < 0: + # i is from BasePool + # At first, get the amount of pool tokens + base_inputs: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + base_inputs[base_i] = _dx + # Token amount transformed to underlying "dollars" + x = Curve(base_pool).calc_token_amount(base_inputs, True) * vp_rate / PRECISION + # Accounting for deposit/withdraw fees approximately + x -= x * Curve(base_pool).fee() / (2 * FEE_DENOMINATOR) + # Adding number of pool tokens + x += xp[MAX_COIN] + else: + # If both are from the base pool + return Curve(base_pool).get_dy(base_i, base_j, _dx) + + # This pool is involved only when in-pool assets are used + y: uint256 = self._get_y(meta_i, meta_j, x, xp) + dy: uint256 = xp[meta_j] - y - 1 + dy = (dy - self.fee * dy / FEE_DENOMINATOR) + if j == REDEMPTION_COIN: + dy = (dy * PRECISION) / self._get_scaled_redemption_price() + + # If output is going via the metapool + if base_j >= 0: + # j is from BasePool + # The fee is already accounted for + dy = Curve(base_pool).calc_withdraw_one_coin(dy * PRECISION / vp_rate, base_j) + + return dy + + +@external +@nonreentrant('lock') +def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256) -> uint256: + """ + @notice Perform an exchange between two coins + @dev Index values can be found via the `coins` public getter method + @param i Index value for the coin to send + @param j Index valie of the coin to recieve + @param _dx Amount of `i` being exchanged + @param _min_dy Minimum amount of `j` to receive + @return Actual amount of `j` received + """ + assert not self.is_killed # dev: is killed + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), self._vp_rate()] + + old_balances: uint256[N_COINS] = self.balances + xp: uint256[N_COINS] = self._xp_mem(rates, old_balances) + + x: uint256 = xp[i] + _dx * rates[i] / PRECISION + y: uint256 = self._get_y(i, j, x, xp) + + dy: uint256 = xp[j] - y - 1 # -1 just in case there were some rounding errors + dy_fee: uint256 = dy * self.fee / FEE_DENOMINATOR + + # Convert all to real units + dy = (dy - dy_fee) * PRECISION / rates[j] + assert dy >= _min_dy, "Too few coins in result" + + dy_admin_fee: uint256 = dy_fee * self.admin_fee / FEE_DENOMINATOR + dy_admin_fee = dy_admin_fee * PRECISION / rates[j] + + # Change balances exactly in same way as we change actual ERC20 coin amounts + self.balances[i] = old_balances[i] + _dx + # When rounding errors happen, we undercharge admin fee in favor of LP + self.balances[j] = old_balances[j] - dy - dy_admin_fee + + response: Bytes[32] = raw_call( + self.coins[i], + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(_dx, bytes32), + ), + max_outsize=32, + ) + if len(response) > 0: + assert convert(response, bool) + + response = raw_call( + self.coins[j], + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(dy, bytes32), + ), + max_outsize=32, + ) + if len(response) > 0: + assert convert(response, bool) + + log TokenExchange(msg.sender, i, _dx, j, dy) + + return dy + + +@external +@nonreentrant('lock') +def exchange_underlying(i: int128, j: int128, _dx: uint256, _min_dy: uint256) -> uint256: + """ + @notice Perform an exchange between two underlying coins + @dev Index values can be found via the `underlying_coins` public getter method + @param i Index value for the underlying coin to send + @param j Index value of the underlying coin to receive + @param _dx Amount of `i` being exchanged + @param _min_dy Minimum amount of `j` to receive + @return Actual amount of `j` received + """ + assert not self.is_killed # dev: is killed + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), self._vp_rate()] + base_pool: address = self.base_pool + + # Use base_i or base_j if they are >= 0 + base_i: int128 = i - MAX_COIN + base_j: int128 = j - MAX_COIN + meta_i: int128 = MAX_COIN + meta_j: int128 = MAX_COIN + if base_i < 0: + meta_i = i + if base_j < 0: + meta_j = j + dy: uint256 = 0 + + # Addresses for input and output coins + input_coin: address = ZERO_ADDRESS + output_coin: address = ZERO_ADDRESS + if base_i < 0: + input_coin = self.coins[i] + else: + input_coin = self.base_coins[base_i] + if base_j < 0: + output_coin = self.coins[j] + else: + output_coin = self.base_coins[base_j] + + # Handle potential Tether fees + dx_w_fee: uint256 = _dx + if input_coin == FEE_ASSET: + dx_w_fee = ERC20(FEE_ASSET).balanceOf(self) + + response: Bytes[32] = raw_call( + input_coin, + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(_dx, bytes32), + ), + max_outsize=32, + ) + if len(response) > 0: + assert convert(response, bool) + + # Handle potential Tether fees + if input_coin == FEE_ASSET: + dx_w_fee = ERC20(FEE_ASSET).balanceOf(self) - dx_w_fee + + if base_i < 0 or base_j < 0: + old_balances: uint256[N_COINS] = self.balances + xp: uint256[N_COINS] = self._xp_mem(rates, old_balances) + + x: uint256 = 0 + if base_i < 0: + x = xp[i] + dx_w_fee * rates[i] / PRECISION + else: + # i is from BasePool + # At first, get the amount of pool tokens + base_inputs: uint256[BASE_N_COINS] = empty(uint256[BASE_N_COINS]) + base_inputs[base_i] = dx_w_fee + coin_i: address = self.coins[MAX_COIN] + # Deposit and measure delta + x = ERC20(coin_i).balanceOf(self) + Curve(base_pool).add_liquidity(base_inputs, 0) + # Need to convert pool token to "virtual" units using rates + # dx is also different now + dx_w_fee = ERC20(coin_i).balanceOf(self) - x + x = dx_w_fee * rates[MAX_COIN] / PRECISION + # Adding number of pool tokens + x += xp[MAX_COIN] + + y: uint256 = self._get_y(meta_i, meta_j, x, xp) + + # Either a real coin or token + dy = xp[meta_j] - y - 1 # -1 just in case there were some rounding errors + dy_fee: uint256 = dy * self.fee / FEE_DENOMINATOR + + # Convert all to real units + # Works for both pool coins and real coins + dy = (dy - dy_fee) * PRECISION / rates[meta_j] + + dy_admin_fee: uint256 = dy_fee * self.admin_fee / FEE_DENOMINATOR + dy_admin_fee = dy_admin_fee * PRECISION / rates[meta_j] + + # Change balances exactly in same way as we change actual ERC20 coin amounts + self.balances[meta_i] = old_balances[meta_i] + dx_w_fee + # When rounding errors happen, we undercharge admin fee in favor of LP + self.balances[meta_j] = old_balances[meta_j] - dy - dy_admin_fee + + # Withdraw from the base pool if needed + if base_j >= 0: + out_amount: uint256 = ERC20(output_coin).balanceOf(self) + Curve(base_pool).remove_liquidity_one_coin(dy, base_j, 0) + dy = ERC20(output_coin).balanceOf(self) - out_amount + + assert dy >= _min_dy, "Too few coins in result" + + else: + # If both are from the base pool + dy = ERC20(output_coin).balanceOf(self) + Curve(base_pool).exchange(base_i, base_j, dx_w_fee, _min_dy) + dy = ERC20(output_coin).balanceOf(self) - dy + + # "safeTransfer" which works for ERC20s which return bool or not + response = raw_call( + output_coin, + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(dy, bytes32), + ), + max_outsize=32, + ) # dev: failed transfer + if len(response) > 0: + assert convert(response, bool) # dev: failed transfer + # end "safeTransfer" + + log TokenExchangeUnderlying(msg.sender, i, _dx, j, dy) + + return dy + + +@external +@nonreentrant('lock') +def remove_liquidity(_amount: uint256, _min_amounts: uint256[N_COINS]) -> uint256[N_COINS]: + """ + @notice Withdraw coins from the pool + @dev Withdrawal amounts are based on current deposit ratios + @param _amount Quantity of LP tokens to burn in the withdrawal + @param _min_amounts Minimum amounts of underlying coins to receive + @return List of amounts of coins that were withdrawn + """ + lp_token: address = self.lp_token + total_supply: uint256 = CurveToken(lp_token).totalSupply() + amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + + for i in range(N_COINS): + old_balance: uint256 = self.balances[i] + value: uint256 = old_balance * _amount / total_supply + assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected" + self.balances[i] = old_balance - value + amounts[i] = value + ERC20(self.coins[i]).transfer(msg.sender, value) + + CurveToken(lp_token).burnFrom(msg.sender, _amount) # dev: insufficient funds + + log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply - _amount) + + return amounts + + +@external +@nonreentrant('lock') +def remove_liquidity_imbalance(_amounts: uint256[N_COINS], _max_burn_amount: uint256) -> uint256: + """ + @notice Withdraw coins from the pool in an imbalanced amount + @param _amounts List of amounts of underlying coins to withdraw + @param _max_burn_amount Maximum amount of LP token to burn in the withdrawal + @return Actual amount of the LP token burned in the withdrawal + """ + assert not self.is_killed # dev: is killed + + amp: uint256 = self._A() + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), self._vp_rate_ro()] + old_balances: uint256[N_COINS] = self.balances + new_balances: uint256[N_COINS] = old_balances + D0: uint256 = self._get_D_mem(rates, old_balances, amp) + for i in range(N_COINS): + new_balances[i] -= _amounts[i] + D1: uint256 = self._get_D_mem(rates, new_balances, amp) + + fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + admin_fee: uint256 = self.admin_fee + fees: uint256[N_COINS] = empty(uint256[N_COINS]) + for i in range(N_COINS): + ideal_balance: uint256 = D1 * old_balances[i] / D0 + difference: uint256 = 0 + if ideal_balance > new_balances[i]: + difference = ideal_balance - new_balances[i] + else: + difference = new_balances[i] - ideal_balance + fees[i] = fee * difference / FEE_DENOMINATOR + self.balances[i] = new_balances[i] - (fees[i] * admin_fee / FEE_DENOMINATOR) + new_balances[i] -= fees[i] + D2: uint256 = self._get_D_mem(rates, new_balances, amp) + + lp_token: address = self.lp_token + token_supply: uint256 = CurveToken(lp_token).totalSupply() + token_amount: uint256 = (D0 - D2) * token_supply / D0 + assert token_amount != 0 # dev: zero tokens burned + token_amount += 1 # In case of rounding errors - make it unfavorable for the "attacker" + assert token_amount <= _max_burn_amount, "Slippage screwed you" + + CurveToken(lp_token).burnFrom(msg.sender, token_amount) # dev: insufficient funds + for i in range(N_COINS): + if _amounts[i] != 0: + ERC20(self.coins[i]).transfer(msg.sender, _amounts[i]) + + log RemoveLiquidityImbalance(msg.sender, _amounts, fees, D1, token_supply - token_amount) + + return token_amount + + +@pure +@internal +def _get_y_D(A: uint256, i: int128, _xp: uint256[N_COINS], D: uint256) -> uint256: + """ + Calculate x[i] if one reduces D from being calculated for xp to D + + Done by solving quadratic equation iteratively. + x_1**2 + x_1 * (sum' - (A*n**n - 1) * D / (A * n**n)) = D ** (n + 1) / (n ** (2 * n) * prod' * A) + x_1**2 + b*x_1 = c + + x_1 = (x_1**2 + c) / (2*x_1 + b) + """ + # x in the input is converted to the same price/precision + + assert i >= 0 # dev: i below zero + assert i < N_COINS # dev: i above N_COINS + + Ann: uint256 = A * N_COINS + c: uint256 = D + S: uint256 = 0 + _x: uint256 = 0 + y_prev: uint256 = 0 + + for _i in range(N_COINS): + if _i != i: + _x = _xp[_i] + else: + continue + S += _x + c = c * D / (_x * N_COINS) + c = c * D * A_PRECISION / (Ann * N_COINS) + b: uint256 = S + D * A_PRECISION / Ann + y: uint256 = D + + for _i in range(255): + y_prev = y + y = (y*y + c) / (2 * y + b - D) + # Equality with the precision of 1 + if y > y_prev: + if y - y_prev <= 1: + return y + else: + if y_prev - y <= 1: + return y + raise + + +@view +@internal +def _calc_withdraw_one_coin(_token_amount: uint256, i: int128, _vp_rate: uint256) -> (uint256, uint256, uint256): + # First, need to calculate + # * Get current D + # * Solve Eqn against y_i for D - _token_amount + amp: uint256 = self._A() + xp: uint256[N_COINS] = self._xp(_vp_rate) + D0: uint256 = self._get_D(xp, amp) + + total_supply: uint256 = CurveToken(self.lp_token).totalSupply() + D1: uint256 = D0 - _token_amount * D0 / total_supply + new_y: uint256 = self._get_y_D(amp, i, xp, D1) + + fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), _vp_rate] + + xp_reduced: uint256[N_COINS] = xp + dy_0: uint256 = (xp[i] - new_y) * PRECISION / rates[i] # w/o fees + + for j in range(N_COINS): + dx_expected: uint256 = 0 + if j == i: + dx_expected = xp[j] * D1 / D0 - new_y + else: + dx_expected = xp[j] - xp[j] * D1 / D0 + xp_reduced[j] -= fee * dx_expected / FEE_DENOMINATOR + + dy: uint256 = xp_reduced[i] - self._get_y_D(amp, i, xp_reduced, D1) + dy = (dy - 1) * PRECISION / rates[i] # Withdraw less to account for rounding errors + + return dy, dy_0 - dy, total_supply + + +@view +@external +def calc_withdraw_one_coin(_token_amount: uint256, i: int128) -> uint256: + """ + @notice Calculate the amount received when withdrawing a single coin + @param _token_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the coin to withdraw + @return Amount of coin received + """ + vp_rate: uint256 = self._vp_rate_ro() + return self._calc_withdraw_one_coin(_token_amount, i, vp_rate)[0] + + +@external +@nonreentrant('lock') +def remove_liquidity_one_coin(_token_amount: uint256, i: int128, _min_amount: uint256) -> uint256: + """ + @notice Withdraw a single coin from the pool + @param _token_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 coin to receive + @return Amount of coin received + """ + assert not self.is_killed # dev: is killed + + vp_rate: uint256 = self._vp_rate() + dy: uint256 = 0 + dy_fee: uint256 = 0 + total_supply: uint256 = 0 + dy, dy_fee, total_supply = self._calc_withdraw_one_coin(_token_amount, i, vp_rate) + assert dy >= _min_amount, "Not enough coins removed" + + self.balances[i] -= (dy + dy_fee * self.admin_fee / FEE_DENOMINATOR) + CurveToken(self.lp_token).burnFrom(msg.sender, _token_amount) # dev: insufficient funds + + ERC20(self.coins[i]).transfer(msg.sender, dy) + + log RemoveLiquidityOne(msg.sender, _token_amount, dy, total_supply - _token_amount) + + return dy + + +### Admin functions ### +@external +def ramp_A(_future_A: uint256, _future_time: uint256): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.initial_A_time + MIN_RAMP_TIME + assert _future_time >= block.timestamp + MIN_RAMP_TIME # dev: insufficient time + + initial_A: uint256 = self._A() + future_A_p: uint256 = _future_A * A_PRECISION + + assert _future_A > 0 and _future_A < MAX_A + if future_A_p < initial_A: + assert future_A_p * MAX_A_CHANGE >= initial_A + else: + assert future_A_p <= initial_A * MAX_A_CHANGE + + self.initial_A = initial_A + self.future_A = future_A_p + self.initial_A_time = block.timestamp + self.future_A_time = _future_time + + log RampA(initial_A, future_A_p, block.timestamp, _future_time) + + +@external +def stop_ramp_A(): + assert msg.sender == self.owner # dev: only owner + + current_A: uint256 = self._A() + self.initial_A = current_A + self.future_A = current_A + self.initial_A_time = block.timestamp + self.future_A_time = block.timestamp + # now (block.timestamp < t1) is always False, so we return saved A + + log StopRampA(current_A, block.timestamp) + + +@external +def commit_new_fee(_new_fee: uint256, _new_admin_fee: uint256): + assert msg.sender == self.owner # dev: only owner + assert self.admin_actions_deadline == 0 # dev: active action + assert _new_fee <= MAX_FEE # dev: fee exceeds maximum + assert _new_admin_fee <= MAX_ADMIN_FEE # dev: admin fee exceeds maximum + + deadline: uint256 = block.timestamp + ADMIN_ACTIONS_DELAY + self.admin_actions_deadline = deadline + self.future_fee = _new_fee + self.future_admin_fee = _new_admin_fee + + log CommitNewFee(deadline, _new_fee, _new_admin_fee) + + +@external +def apply_new_fee(): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.admin_actions_deadline # dev: insufficient time + assert self.admin_actions_deadline != 0 # dev: no active action + + self.admin_actions_deadline = 0 + fee: uint256 = self.future_fee + admin_fee: uint256 = self.future_admin_fee + self.fee = fee + self.admin_fee = admin_fee + + log NewFee(fee, admin_fee) + + +@external +def revert_new_parameters(): + assert msg.sender == self.owner # dev: only owner + + self.admin_actions_deadline = 0 + + +@external +def commit_transfer_ownership(_owner: address): + assert msg.sender == self.owner # dev: only owner + assert self.transfer_ownership_deadline == 0 # dev: active transfer + + deadline: uint256 = block.timestamp + ADMIN_ACTIONS_DELAY + self.transfer_ownership_deadline = deadline + self.future_owner = _owner + + log CommitNewAdmin(deadline, _owner) + + +@external +def apply_transfer_ownership(): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.transfer_ownership_deadline # dev: insufficient time + assert self.transfer_ownership_deadline != 0 # dev: no active transfer + + self.transfer_ownership_deadline = 0 + owner: address = self.future_owner + self.owner = owner + + log NewAdmin(owner) + + +@external +def revert_transfer_ownership(): + assert msg.sender == self.owner # dev: only owner + + self.transfer_ownership_deadline = 0 + + +@view +@external +def admin_balances(i: uint256) -> uint256: + return ERC20(self.coins[i]).balanceOf(self) - self.balances[i] + + +@external +def withdraw_admin_fees(): + assert msg.sender == self.owner # dev: only owner + + for i in range(N_COINS): + coin: address = self.coins[i] + value: uint256 = ERC20(coin).balanceOf(self) - self.balances[i] + if value > 0: + ERC20(coin).transfer(msg.sender, value) + +@external +def kill_me(): + assert msg.sender == self.owner # dev: only owner + assert self.kill_deadline > block.timestamp # dev: deadline has passed + self.is_killed = True + + +@external +def unkill_me(): + assert msg.sender == self.owner # dev: only owner + self.is_killed = False diff --git a/contracts/pools/rai/pooldata.json b/contracts/pools/rai/pooldata.json new file mode 100644 index 00000000..13727c79 --- /dev/null +++ b/contracts/pools/rai/pooldata.json @@ -0,0 +1,35 @@ +{ + "base_pool": "3pool", + "pool_types": ["meta"], + "lp_contract": "CurveTokenV3", + + "swap_address": "0x0000000000000000000000000000000000000000", + "lp_token_address": "0x0000000000000000000000000000000000000000", + "zap_address": "0x0000000000000000000000000000000000000000", + "gauge_addresses": ["0x0000000000000000000000000000000000000000"], + + "lp_constructor": { + "symbol": "rai3CRV", + "name": "Curve.fi RAI/3Crv" + }, + "swap_constructor": { + "_redemption_price_snap": "0x0000000000000000000000000000000000000000", + "_A": 100, + "_fee": 4000000, + "_admin_fee": 5000000000 + }, + "coins": [ + { + "name": "RAI", + "decimals": 18, + "tethered": false, + "underlying_address": "0x03ab458634910AaD20eF5f1C8ee96F1D6ac54919" + }, + { + "name": "3CRV", + "decimals": 18, + "base_pool_token": true, + "underlying_address": "0x6c3F90f043a72FA612cbac8115EE7e52BDe6E490" + } + ] +} diff --git a/contracts/pools/raiust/README.md b/contracts/pools/raiust/README.md new file mode 100644 index 00000000..565d72da --- /dev/null +++ b/contracts/pools/raiust/README.md @@ -0,0 +1,14 @@ +# curve-contract/contracts/pools/raiust + +[Curve RAI/UST pool](): Two coins pool which includes RAI(non-peggie) and UST(peggie) with 18 decimals. + +## Contracts + +* [`StableSwapRaiUst`](StableSwapRaiUst.vy): Curve stablecoin AMM contract to swap between a non-peggie(RAI) and a peggie(UST), without lending. + +## Stablecoins + +Curve RAI/UST pool supports swaps between the following stablecoins: + +* `RAI`: [0x03ab458634910AaD20eF5f1C8ee96F1D6ac54919](https://etherscan.io/address/0x03ab458634910AaD20eF5f1C8ee96F1D6ac54919) +* `UST`: [0xa47c8bf37f92aBed4A126BDA807A7b7498661acD](https://etherscan.io/address/0xa47c8bf37f92aBed4A126BDA807A7b7498661acD) diff --git a/contracts/pools/raiust/StableSwapRaiUst.vy b/contracts/pools/raiust/StableSwapRaiUst.vy new file mode 100644 index 00000000..ec28beed --- /dev/null +++ b/contracts/pools/raiust/StableSwapRaiUst.vy @@ -0,0 +1,1005 @@ +# @version 0.2.12 +""" +@title StableSwapRAIUST +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2020-2021 - all rights reserved +@notice 2 coin pool implementation with a non-peggie and a peggie, without lending +@dev Swaps between a non-peggie(RAI) and a peggie(UST) with 18 decimals. +""" + +from vyper.interfaces import ERC20 + +interface CurveToken: + def totalSupply() -> uint256: view + def mint(_to: address, _value: uint256) -> bool: nonpayable + def burnFrom(_to: address, _value: uint256) -> bool: nonpayable + +### redemption price snap interface +interface RedemptionPriceSnap: + def snappedRedemptionPrice() -> uint256: view + + +event Transfer: + sender: indexed(address) + receiver: indexed(address) + value: uint256 + +event Approval: + owner: indexed(address) + spender: indexed(address) + value: uint256 + +event TokenExchange: + buyer: indexed(address) + sold_id: int128 + tokens_sold: uint256 + bought_id: int128 + tokens_bought: uint256 + +event AddLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + invariant: uint256 + token_supply: uint256 + +event RemoveLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + token_supply: uint256 + +event RemoveLiquidityOne: + provider: indexed(address) + token_amount: uint256 + coin_amount: uint256 + token_supply: uint256 + +event RemoveLiquidityImbalance: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fees: uint256[N_COINS] + invariant: uint256 + token_supply: uint256 + +event RampA: + old_A: uint256 + new_A: uint256 + initial_time: uint256 + future_time: uint256 + +event StopRampA: + A: uint256 + t: uint256 + +event CommitNewAdmin: + deadline: indexed(uint256) + admin: indexed(address) + +event NewAdmin: + admin: indexed(address) + +event CommitNewFee: + deadline: indexed(uint256) + fee: uint256 + admin_fee: uint256 + +event NewFee: + fee: uint256 + admin_fee: uint256 + + +N_COINS: constant(int128) = 2 +PRECISION: constant(int128) = 10 ** 18 + +FEE_DENOMINATOR: constant(uint256) = 10 ** 10 + +A_PRECISION: constant(uint256) = 100 +MAX_A: constant(uint256) = 10 ** 6 +MAX_A_CHANGE: constant(uint256) = 10 +MIN_RAMP_TIME: constant(uint256) = 86400 +ADMIN_ACTIONS_DELAY: constant(uint256) = 3 * 86400 +### peggie rate; set prior to compiling; this PEGGIE_RATE set to be 1e18 +PEGGIE_RATE: constant(uint256) = 1000000000000000000 +### fetched redemption price has 27 decimals, scale to match 18 decmials +REDMPTION_PRICE_SCALE: constant(uint256) = 10 ** 9 + +MAX_ADMIN_FEE: constant(uint256) = 10 * 10 ** 9 +MAX_FEE: constant(uint256) = 5 * 10 ** 9 +future_fee: public(uint256) +future_admin_fee: public(uint256) +fee: public(uint256) # fee * 1e10 +admin_fee: public(uint256) # admin_fee * 1e10 + + +# owner, lp tokern +owner: public(address) +lp_token: public(address) + +coins: public(address[N_COINS]) +balances: public(uint256[N_COINS]) + +redemption_price_snap: public(address) ### address to get redemption price snap + + +initial_A: public(uint256) +future_A: public(uint256) +initial_A_time: public(uint256) +future_A_time: public(uint256) + +admin_actions_deadline: public(uint256) +transfer_ownership_deadline: public(uint256) +future_owner: public(address) + + +is_killed: bool +kill_deadline: uint256 +KILL_DEADLINE_DT: constant(uint256) = 2 * 30 * 86400 + + +@external +def __init__( + _owner: address, + _coins: address[N_COINS], + _pool_token: address, ### lp token address + _redemption_price_snap: address, ### initialise rp snap address + _A: uint256, + _fee: uint256, + _admin_fee: uint256 +): + """ + @notice Contract constructor + @param _owner Contract owner address + @param _coins Addresses of ERC20 conracts of coins + @param _pool_token Address of the token representing LP share + @param _redemption_price_snap Address of contract providing snapshot of redemption price + @param _A Amplification coefficient multiplied by n ** (n - 1) + @param _fee Fee to charge for exchanges + @param _admin_fee Admin fee + """ + + for i in range(N_COINS): + coin: address = _coins[i] + if coin == ZERO_ADDRESS: + break + self.coins[i] = coin + # assert _rate_multipliers[i] == PRECISION + + A: uint256 = _A * A_PRECISION + self.initial_A = A + self.future_A = A + self.fee = _fee + self.admin_fee = _admin_fee + # set owner + self.owner = _owner + self.kill_deadline = block.timestamp + KILL_DEADLINE_DT + + ### Initialise rp snap address + self.redemption_price_snap = _redemption_price_snap + ### LP Token Address + self.lp_token = _pool_token + + # fire a transfer event so block explorers identify the contract as an ERC20 + log Transfer(ZERO_ADDRESS, self, 0) + + +### StableSwap Functionality ### + +@view +@external +def get_balances() -> uint256[N_COINS]: + return self.balances + +@internal +@view +def sqrt(x: uint256) -> uint256: + """ + Originating from: https://github.com/vyperlang/vyper/issues/1266 + """ + + if x == 0: + return 0 + + z: uint256 = (x + 10**18) / 2 + y: uint256 = x + + for i in range(256): + if z == y: + return y + y = z + z = (x * 10**18 / z + z) / 2 + + raise "Did not converge" + +@view +@internal +def _A() -> uint256: + """ + Handle ramping A up or down + """ + t1: uint256 = self.future_A_time + A1: uint256 = self.future_A + + if block.timestamp < t1: + A0: uint256 = self.initial_A + t0: uint256 = self.initial_A_time + # Expressions in uint256 cannot have negative numbers, thus "if" + if A1 > A0: + return A0 + (A1 - A0) * (block.timestamp - t0) / (t1 - t0) + else: + return A0 - (A0 - A1) * (block.timestamp - t0) / (t1 - t0) + + else: # when t1 == 0 or block.timestamp >= t1 + return A1 + +### Reads a snapshot view of redemption price +@view +@internal +def _get_scaled_redemption_price() -> uint256: + """ + @notice Reads a snapshot view of redemption price + @dev The fetched redemption price uses 27 decimals + @return The redemption price with appropriate scaling to match LP tokens vitual price. + """ + rate: uint256 = RedemptionPriceSnap(self.redemption_price_snap).snappedRedemptionPrice() + return rate / REDMPTION_PRICE_SCALE + + +@view +@external +def A() -> uint256: + return self._A() / A_PRECISION + + +@view +@external +def A_precise() -> uint256: + return self._A() + + +### calculate _xp by self coin's balance and coin's price +@view +@internal +def _xp() -> uint256[N_COINS]: + result: uint256[N_COINS] = [self._get_scaled_redemption_price(), PEGGIE_RATE] + for i in range(N_COINS): + result[i] = result[i] * self.balances[i] / PRECISION + return result + + +### calculate _xp_mem by coin's balance and coin's price +@view +@internal +def _xp_mem(_balances: uint256[N_COINS]) -> uint256[N_COINS]: + result: uint256[N_COINS] = [self._get_scaled_redemption_price(), PEGGIE_RATE] + for i in range(N_COINS): + result[i] = result[i] * _balances[i] / PRECISION + return result + + +### D invariant calculation by _xp +@pure +@internal +def _get_D(_xp: uint256[N_COINS], _amp: uint256) -> uint256: + """ + D invariant calculation in non-overflowing integer operations + iteratively + + A * sum(x_i) * n**n + D = A * D * n**n + D**(n+1) / (n**n * prod(x_i)) + + Converging solution: + D[j+1] = (A * n**n * sum(x_i) - D[j]**(n+1) / (n**n prod(x_i))) / (A * n**n - 1) + """ + S: uint256 = 0 + for x in _xp: + S += x + if S == 0: + return 0 + + D: uint256 = S + Ann: uint256 = _amp * N_COINS + for i in range(255): + D_P: uint256 = D * D / _xp[0] * D / _xp[1] / (N_COINS)**2 + Dprev: uint256 = D + D = (Ann * S / A_PRECISION + D_P * N_COINS) * D / ((Ann - A_PRECISION) * D / A_PRECISION + (N_COINS + 1) * D_P) + # Equality with the precision of 1 + if D > Dprev: + if D - Dprev <= 1: + return D + else: + if Dprev - D <= 1: + return D + # convergence typically occurs in 4 rounds or less, this should be unreachable! + # if it does happen the pool is borked and LPs can withdraw via `remove_liquidity` + raise + +### D invariant calculation by balance and price +@view +@internal +def _get_D_mem(_balances: uint256[N_COINS], _amp: uint256) -> uint256: + return self._get_D(self._xp_mem(_balances), _amp) + +@view +@internal +def _get_virtual_price() -> uint256: + """ + @notice The current virtual price of the pool LP token + @dev Useful for calculating profits + @return LP token virtual price normalized to 1e18 + """ + amp: uint256 = self._A() + D: uint256 = self._get_D(self._xp(), amp) ### _xp as the input + # D is in the units similar to DAI (e.g. converted to precision 1e18) + # When balanced, D = n * x_u - total virtual value of the portfolio + token_supply: uint256 = ERC20(self.lp_token).totalSupply() + return D * PRECISION / token_supply + +@view +@external +def get_virtual_price() -> uint256: + """ + @notice The current virtual price of the pool LP token + @dev Useful for calculating profits + @return LP token virtual price normalized to 1e18 + """ + return self._get_virtual_price() + + +@view +@external +def get_virtual_price_2() -> uint256: + """ + @notice Smoother changing virtual price to accomodate for redemption price swings + @return LP token smoothed virtual price normalized to 1e18 + """ + return ( self._get_virtual_price() * PRECISION ) / self.sqrt(self._get_scaled_redemption_price()) + + +@view +@external +def calc_token_amount(_amounts: uint256[N_COINS], _is_deposit: bool) -> uint256: + """ + @notice Calculate addition or reduction in token supply from a deposit or withdrawal + @dev This calculation accounts for slippage, but not fees. + Needed to prevent front-running, not for precise calculations! + @param _amounts Amount of each coin being deposited + @param _is_deposit set True for deposits, False for withdrawals + @return Expected amount of LP tokens received + """ + amp: uint256 = self._A() + balances: uint256[N_COINS] = self.balances + D0: uint256 = self._get_D_mem(balances, amp) ### + for i in range(N_COINS): + amount: uint256 = _amounts[i] + if _is_deposit: + balances[i] += amount + else: + balances[i] -= amount + D1: uint256 = self._get_D_mem(balances, amp) ### + token_amount: uint256 = CurveToken(self.lp_token).totalSupply() + diff: uint256 = 0 + if _is_deposit: + diff = D1 - D0 + else: + diff = D0 - D1 + return diff * token_amount / D0 + + +@external +@nonreentrant('lock') +def add_liquidity( + _amounts: uint256[N_COINS], + _min_mint_amount: uint256 +) -> uint256: + """ + @notice Deposit coins into the pool + @param _amounts List of amounts of coins to deposit + @param _min_mint_amount Minimum amount of LP tokens to mint from the deposit + @return Amount of LP tokens received by depositing + """ + assert not self.is_killed # dev: is killed + + amp: uint256 = self._A() + old_balances: uint256[N_COINS] = self.balances + + # Initial invariant + D0: uint256 = self._get_D_mem(old_balances, amp) ### + + total_supply: uint256 = CurveToken(self.lp_token).totalSupply() + new_balances: uint256[N_COINS] = old_balances + for i in range(N_COINS): + amount: uint256 = _amounts[i] + if total_supply == 0: + assert amount > 0 # dev: initial deposit requires all coins + new_balances[i] += amount + + # Invariant after change + D1: uint256 = self._get_D_mem(new_balances, amp) + assert D1 > D0 + + # We need to recalculate the invariant accounting for fees + # to calculate fair user's share + fees: uint256[N_COINS] = empty(uint256[N_COINS]) + mint_amount: uint256 = 0 + if total_supply > 0: + # Only account for fees if we are not the first to deposit + base_fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + for i in range(N_COINS): + ideal_balance: uint256 = D1 * old_balances[i] / D0 + difference: uint256 = 0 + new_balance: uint256 = new_balances[i] + if ideal_balance > new_balance: + difference = ideal_balance - new_balance + else: + difference = new_balance - ideal_balance + fees[i] = base_fee * difference / FEE_DENOMINATOR + self.balances[i] = new_balance - (fees[i] * self.admin_fee / FEE_DENOMINATOR) + new_balances[i] -= fees[i] + D2: uint256 = self._get_D_mem(new_balances, amp) ### + mint_amount = total_supply * (D2 - D0) / D0 + else: + # No fee if you are the first to deposit + self.balances = new_balances + mint_amount = D1 # Take the dust if there was any + + assert mint_amount >= _min_mint_amount, "Slippage screwed you" + + # Take coins from the sender + for i in range(N_COINS): + amount: uint256 = _amounts[i] + if amount > 0: + # "safeTransferFrom" which works for ERC20s which return bool or not + _response: Bytes[32] = raw_call( + self.coins[i], + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) # dev: failed transfer + # end "safeTransferFrom" + + # Mint pool tokens + CurveToken(self.lp_token).mint(msg.sender, mint_amount) + log Transfer(ZERO_ADDRESS, msg.sender, mint_amount) + + log AddLiquidity(msg.sender, _amounts, fees, D1, total_supply + mint_amount) + + return mint_amount + + + +@view +@internal +def _get_y(i: int128, j: int128, x: uint256, xp: uint256[N_COINS]) -> uint256: + """ + Calculate x[j] if one makes x[i] = x + + Done by solving quadratic equation iteratively. + x_1**2 + x_1 * (sum' - (A*n**n - 1) * D / (A * n**n)) = D ** (n + 1) / (n ** (2 * n) * prod' * A) + x_1**2 + b*x_1 = c + + x_1 = (x_1**2 + c) / (2*x_1 + b) + """ + # x in the input is converted to the same price/precision + + assert i != j # dev: same coin + assert j >= 0 # dev: j below zero + assert j < N_COINS # dev: j above N_COINS + + # should be unreachable, but good for safety + assert i >= 0 + assert i < N_COINS + + amp: uint256 = self._A() + D: uint256 = self._get_D(xp, amp) ### + S_: uint256 = 0 + _x: uint256 = 0 + y_prev: uint256 = 0 + c: uint256 = D + Ann: uint256 = amp * N_COINS + + for _i in range(N_COINS): + if _i == i: + _x = x + elif _i != j: + _x = xp[_i] + else: + continue + S_ += _x + c = c * D / (_x * N_COINS) + + c = c * D * A_PRECISION / (Ann * N_COINS) + b: uint256 = S_ + D * A_PRECISION / Ann # - D + y: uint256 = D + + for _i in range(255): + y_prev = y + y = (y*y + c) / (2 * y + b - D) + # Equality with the precision of 1 + if y > y_prev: + if y - y_prev <= 1: + return y + else: + if y_prev - y <= 1: + return y + raise + +### Get the new price to calculate the current output dy given input dx +@view +@external +def get_dy(i: int128, j: int128, dx: uint256) -> uint256: + """ + @notice Calculate the current output dy given input dx + @dev Index values can be found via the `coins` public getter method + @param i Index value for the coin to send + @param j Index valie of the coin to recieve + @param dx Amount of `i` being exchanged + @return Amount of `j` predicted + """ + + xp: uint256[N_COINS] = self._xp() ### + ### get coin price + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), PEGGIE_RATE] + + x: uint256 = xp[i] + (dx * rates[i] / PRECISION) ### + y: uint256 = self._get_y(i, j, x, xp) + dy: uint256 = xp[j] - y - 1 + fee: uint256 = self.fee * dy / FEE_DENOMINATOR + return (dy - fee) * PRECISION / rates[j] ### + + +### Perform an exchange between two coins +@external +@nonreentrant('lock') +def exchange( + i: int128, + j: int128, + _dx: uint256, + _min_dy: uint256 +) -> uint256: + """ + @notice Perform an exchange between two coins + @dev Index values can be found via the `coins` public getter method + @param i Index value for the coin to send + @param j Index valie of the coin to recieve + @param _dx Amount of `i` being exchanged + @param _min_dy Minimum amount of `j` to receive + @return Actual amount of `j` received + """ + assert not self.is_killed # dev: is killed + old_balances: uint256[N_COINS] = self.balances + + xp: uint256[N_COINS] = self._xp_mem(old_balances) ### + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), PEGGIE_RATE] ### + x: uint256 = xp[i] + _dx * rates[i] / PRECISION ### + + y: uint256 = self._get_y(i, j, x, xp) + + dy: uint256 = xp[j] - y - 1 # -1 just in case there were some rounding errors + dy_fee: uint256 = dy * self.fee / FEE_DENOMINATOR + + # Convert all to real units + dy = (dy - dy_fee) * PRECISION / rates[j] + assert dy >= _min_dy, "Exchange resulted in fewer coins than expected" + + dy_admin_fee: uint256 = dy_fee * self.admin_fee / FEE_DENOMINATOR + dy_admin_fee = dy_admin_fee * PRECISION / rates[j] + + # Change balances exactly in same way as we change actual ERC20 coin amounts + self.balances[i] = old_balances[i] + _dx + # When rounding errors happen, we undercharge admin fee in favor of LP + self.balances[j] = old_balances[j] - dy - dy_admin_fee + + _response: Bytes[32] = raw_call( + self.coins[i], + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(_dx, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + _response = raw_call( + self.coins[j], + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(dy, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + log TokenExchange(msg.sender, i, _dx, j, dy) + + return dy + + +@external +@nonreentrant('lock') +def remove_liquidity( + _burn_amount: uint256, + _min_amounts: uint256[N_COINS] +) -> uint256[N_COINS]: + """ + @notice Withdraw coins from the pool + @dev Withdrawal amounts are based on current deposit ratios + @param _burn_amount Quantity of LP tokens to burn in the withdrawal + @param _min_amounts Minimum amounts of underlying coins to receive + @return List of amounts of coins that were withdrawn + """ + lp_token: address = self.lp_token + total_supply: uint256 = CurveToken(self.lp_token).totalSupply() + amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + + # remove liquidity by the ratio of _burn_amount in total_supply + for i in range(N_COINS): + old_balance: uint256 = self.balances[i] + value: uint256 = old_balance * _burn_amount / total_supply + assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected" + self.balances[i] = old_balance - value + amounts[i] = value + + _response: Bytes[32] = raw_call( + self.coins[i], + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(value, bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + CurveToken(self.lp_token).burnFrom(msg.sender, _burn_amount) # dev: insufficient funds + log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount) + + log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply - _burn_amount) + + return amounts + +### Withdraw coins from the pool in an imbalanced amount +@external +@nonreentrant('lock') +def remove_liquidity_imbalance( + _amounts: uint256[N_COINS], + _max_burn_amount: uint256 +) -> uint256: + """ + @notice Withdraw coins from the pool in an imbalanced amount + @param _amounts List of amounts of underlying coins to withdraw + @param _max_burn_amount Maximum amount of LP token to burn in the withdrawal + @return Actual amount of the LP token burned in the withdrawal + """ + assert not self.is_killed # dev: is killed + amp: uint256 = self._A() + old_balances: uint256[N_COINS] = self.balances + D0: uint256 = self._get_D_mem(old_balances, amp) ### + + new_balances: uint256[N_COINS] = old_balances + for i in range(N_COINS): + new_balances[i] -= _amounts[i] + D1: uint256 = self._get_D_mem(new_balances, amp) ### + + fees: uint256[N_COINS] = empty(uint256[N_COINS]) + base_fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + for i in range(N_COINS): + ideal_balance: uint256 = D1 * old_balances[i] / D0 + difference: uint256 = 0 + new_balance: uint256 = new_balances[i] + if ideal_balance > new_balance: + difference = ideal_balance - new_balance + else: + difference = new_balance - ideal_balance + fees[i] = base_fee * difference / FEE_DENOMINATOR + self.balances[i] = new_balance - (fees[i] * self.admin_fee / FEE_DENOMINATOR) + new_balances[i] -= fees[i] + D2: uint256 = self._get_D_mem(new_balances, amp) + + lp_token: address = self.lp_token + total_supply: uint256 = CurveToken(self.lp_token).totalSupply() + burn_amount: uint256 = ((D0 - D2) * total_supply / D0) + 1 + assert burn_amount > 1 # dev: zero tokens burned + assert burn_amount <= _max_burn_amount, "Slippage screwed you" + + CurveToken(self.lp_token).burnFrom(msg.sender, burn_amount) # dev: insufficient funds + log Transfer(msg.sender, ZERO_ADDRESS, burn_amount) + + for i in range(N_COINS): + if _amounts[i] != 0: + _response: Bytes[32] = raw_call( + self.coins[i], + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(_amounts[i], bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + log RemoveLiquidityImbalance(msg.sender, _amounts, fees, D1, total_supply - burn_amount) + + return burn_amount + + +@pure +@internal +def _get_y_D(A: uint256, i: int128, xp: uint256[N_COINS], D: uint256) -> uint256: + """ + Calculate x[i] if one reduces D from being calculated for xp to D + + Done by solving quadratic equation iteratively. + x_1**2 + x_1 * (sum' - (A*n**n - 1) * D / (A * n**n)) = D ** (n + 1) / (n ** (2 * n) * prod' * A) + x_1**2 + b*x_1 = c + + x_1 = (x_1**2 + c) / (2*x_1 + b) + """ + # x in the input is converted to the same price/precision + + assert i >= 0 # dev: i below zero + assert i < N_COINS # dev: i above N_COINS + + S_: uint256 = 0 + _x: uint256 = 0 + y_prev: uint256 = 0 + c: uint256 = D + Ann: uint256 = A * N_COINS + + for _i in range(N_COINS): + if _i != i: + _x = xp[_i] + else: + continue + S_ += _x + c = c * D / (_x * N_COINS) + + c = c * D * A_PRECISION / (Ann * N_COINS) + b: uint256 = S_ + D * A_PRECISION / Ann + y: uint256 = D + + for _i in range(255): + y_prev = y + y = (y*y + c) / (2 * y + b - D) + # Equality with the precision of 1 + if y > y_prev: + if y - y_prev <= 1: + return y + else: + if y_prev - y <= 1: + return y + raise + +### Calculate the amount of i coin to withdraw when burning _burn_amount amount of LP tokens +### Need to get new price +@view +@internal +def _calc_withdraw_one_coin(_burn_amount: uint256, i: int128) -> uint256[2]: + # First, need to calculate + # * Get current D + # * Solve Eqn against y_i for D - _token_amount + amp: uint256 = self._A() + xp: uint256[N_COINS] = self._xp() ### + D0: uint256 = self._get_D(xp, amp) ### + + total_supply: uint256 = CurveToken(self.lp_token).totalSupply() + D1: uint256 = D0 - _burn_amount * D0 / total_supply + new_y: uint256 = self._get_y_D(amp, i, xp, D1) ### + + base_fee: uint256 = self.fee * N_COINS / (4 * (N_COINS - 1)) + xp_reduced: uint256[N_COINS] = xp ### + rates: uint256[N_COINS] = [self._get_scaled_redemption_price(), PEGGIE_RATE] + + for j in range(N_COINS): + dx_expected: uint256 = 0 + if j == i: + dx_expected = xp[j] * D1 / D0 - new_y ### + else: + dx_expected = xp[j] - xp[j] * D1 / D0 ### + xp_reduced[j] -= self.fee * dx_expected / FEE_DENOMINATOR ### + dy: uint256 = xp_reduced[i] - self._get_y_D(amp, i, xp_reduced, D1) ### + dy = (dy - 1) * PRECISION / rates[i] # Withdraw less to account for rounding errors ### + dy_0: uint256 = (xp[i] - new_y) * PRECISION / rates[i] # w/o fees ### + + return [dy, dy_0 - dy] + + +@view +@external +def calc_withdraw_one_coin(_burn_amount: uint256, i: int128, _previous: bool = False) -> uint256: + """ + @notice Calculate the amount received when withdrawing a single coin + @param _burn_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the coin to withdraw + @return Amount of coin received + """ + return self._calc_withdraw_one_coin(_burn_amount, i)[0] + + +@external +@nonreentrant('lock') +def remove_liquidity_one_coin( + _burn_amount: uint256, + i: int128, + _min_received: uint256 +) -> uint256: + """ + @notice Withdraw a single coin from the pool + @param _burn_amount Amount of LP tokens to burn in the withdrawal + @param i Index value of the coin to withdraw + @param _min_received Minimum amount of coin to receive + @return Amount of coin received + """ + assert not self.is_killed # dev: is killed + dy: uint256[2] = self._calc_withdraw_one_coin(_burn_amount, i) + assert dy[0] >= _min_received, "Not enough coins removed" + + # update balances + self.balances[i] -= (dy[0] + dy[1] * self.admin_fee / FEE_DENOMINATOR) + CurveToken(self.lp_token).burnFrom(msg.sender, _burn_amount) # dev: insufficient funds + log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount) + + _response: Bytes[32] = raw_call( + self.coins[i], + concat( + method_id("transfer(address,uint256)"), + convert(msg.sender, bytes32), + convert(dy[0], bytes32), + ), + max_outsize=32, + ) + if len(_response) > 0: + assert convert(_response, bool) + + total_supply: uint256 = CurveToken(self.lp_token).totalSupply() + log RemoveLiquidityOne(msg.sender, _burn_amount, dy[0], total_supply) + + return dy[0] + + +@external +def ramp_A(_future_A: uint256, _future_time: uint256): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.initial_A_time + MIN_RAMP_TIME + assert _future_time >= block.timestamp + MIN_RAMP_TIME # dev: insufficient time + + _initial_A: uint256 = self._A() + _future_A_p: uint256 = _future_A * A_PRECISION + + assert _future_A > 0 and _future_A < MAX_A + if _future_A_p < _initial_A: + assert _future_A_p * MAX_A_CHANGE >= _initial_A + else: + assert _future_A_p <= _initial_A * MAX_A_CHANGE + + self.initial_A = _initial_A + self.future_A = _future_A_p + self.initial_A_time = block.timestamp + self.future_A_time = _future_time + + log RampA(_initial_A, _future_A_p, block.timestamp, _future_time) + + +@external +def stop_ramp_A(): + assert msg.sender == self.owner # dev: only owner + + current_A: uint256 = self._A() + self.initial_A = current_A + self.future_A = current_A + self.initial_A_time = block.timestamp + self.future_A_time = block.timestamp + # now (block.timestamp < t1) is always False, so we return saved A + + log StopRampA(current_A, block.timestamp) + + +@external +def commit_new_fee(_new_fee: uint256, _new_admin_fee: uint256): + assert msg.sender == self.owner # dev: only owner + assert self.admin_actions_deadline == 0 # dev: active action + assert _new_fee <= MAX_FEE # dev: fee exceeds maximum + assert _new_admin_fee <= MAX_ADMIN_FEE # dev: admin fee exceeds maximum + + deadline: uint256 = block.timestamp + ADMIN_ACTIONS_DELAY + self.admin_actions_deadline = deadline + self.future_fee = _new_fee + self.future_admin_fee = _new_admin_fee + + log CommitNewFee(deadline, _new_fee, _new_admin_fee) + + +@external +def apply_new_fee(): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.admin_actions_deadline # dev: insufficient time + assert self.admin_actions_deadline != 0 # dev: no active action + + self.admin_actions_deadline = 0 + fee: uint256 = self.future_fee + admin_fee: uint256 = self.future_admin_fee + self.fee = fee + self.admin_fee = admin_fee + + log NewFee(fee, admin_fee) + +@external +def revert_new_parameters(): + assert msg.sender == self.owner # dev: only owner + + self.admin_actions_deadline = 0 + + +@external +def commit_transfer_ownership(_owner: address): + assert msg.sender == self.owner # dev: only owner + assert self.transfer_ownership_deadline == 0 # dev: active transfer + + deadline: uint256 = block.timestamp + ADMIN_ACTIONS_DELAY + self.transfer_ownership_deadline = deadline + self.future_owner = _owner + + log CommitNewAdmin(deadline, _owner) + +@external +def apply_transfer_ownership(): + assert msg.sender == self.owner # dev: only owner + assert block.timestamp >= self.transfer_ownership_deadline # dev: insufficient time + assert self.transfer_ownership_deadline != 0 # dev: no active transfer + + self.transfer_ownership_deadline = 0 + owner: address = self.future_owner + self.owner = owner + + log NewAdmin(owner) + + +@external +def revert_transfer_ownership(): + assert msg.sender == self.owner # dev: only owner + + self.transfer_ownership_deadline = 0 + + + +@view +@external +def admin_balances(i: uint256) -> uint256: + return ERC20(self.coins[i]).balanceOf(self) - self.balances[i] + + +@external +def withdraw_admin_fees(): + assert msg.sender == self.owner # dev: only owner + + for i in range(N_COINS): + coin: address = self.coins[i] + fees: uint256 = ERC20(coin).balanceOf(self) - self.balances[i] + + if fees > 0: + ERC20(coin).transfer(msg.sender, fees) + + +@external +def kill_me(): + assert msg.sender == self.owner # dev: only owner + assert self.kill_deadline > block.timestamp # dev: deadline has passed + self.is_killed = True + + +@external +def unkill_me(): + assert msg.sender == self.owner # dev: only owner + self.is_killed = False diff --git a/contracts/pools/raiust/pooldata.json b/contracts/pools/raiust/pooldata.json new file mode 100644 index 00000000..aad6351d --- /dev/null +++ b/contracts/pools/raiust/pooldata.json @@ -0,0 +1,31 @@ +{ + "pool_types": ["arate"], + "lp_contract": "CurveTokenV3", + "lp_token_address": "0x0000000000000000000000000000000000000000", + "swap_address": "0x0000000000000000000000000000000000000000", + "gauge_addresses": ["0x0000000000000000000000000000000000000000"], + "lp_constructor": { + "symbol": "Raiust", + "name": "Curve.fi RAI/UST" + }, + "swap_constructor": { + "_redemption_price_snap": "0x0000000000000000000000000000000000000000", + "_A": 100, + "_fee": 4000000 + }, + "coins": [ + { + "name": "RAI", + "decimals": 18, + "tethered": false, + "underlying_address": "0x03ab458634910AaD20eF5f1C8ee96F1D6ac54919" + }, + { + "name": "UST", + "decimals": 18, + "tethered": false, + "underlying_address": "0xa47c8bf37f92aBed4A126BDA807A7b7498661acD" + + } + ] +} diff --git a/contracts/testing/RedemptionPriceSnapMock.sol b/contracts/testing/RedemptionPriceSnapMock.sol new file mode 100644 index 00000000..c5b05176 --- /dev/null +++ b/contracts/testing/RedemptionPriceSnapMock.sol @@ -0,0 +1,19 @@ +pragma solidity ^0.6.7; + + +contract RedemptionPriceSnapMock { + uint256 internal internalSnappedRedemptionPrice; + + constructor() public { + // Set redemption price to $1 (ray) so existing common tests which expect pegged coin can run. + internalSnappedRedemptionPrice = 1000000000000000000000000000; + } + + function setRedemptionPriceSnap(uint256 newPrice) external { + internalSnappedRedemptionPrice = newPrice; + } + + function snappedRedemptionPrice() public view returns (uint256) { + return internalSnappedRedemptionPrice; + } +} diff --git a/tests/fixtures/deployments.py b/tests/fixtures/deployments.py index 16545008..61900d2a 100644 --- a/tests/fixtures/deployments.py +++ b/tests/fixtures/deployments.py @@ -12,6 +12,8 @@ def _swap( swap_mock, base_swap, aave_lending_pool, + redemption_price_snap, + math ): deployer = getattr(project, pool_data["swap_contract"]) @@ -31,6 +33,8 @@ def _swap( "_reward_claimant": alice, "_y_pool": swap_mock, "_aave_lending_pool": aave_lending_pool, + "_redemption_price_snap": redemption_price_snap, + "_math": math, } deployment_args = [args[i["name"]] for i in abi] + [({"from": alice})] @@ -55,6 +59,8 @@ def swap( swap_mock, base_swap, aave_lending_pool, + redemption_price_snap, + math, ): return _swap( project, @@ -66,6 +72,8 @@ def swap( swap_mock, base_swap, aave_lending_pool, + redemption_price_snap, + math, ) @@ -85,6 +93,8 @@ def base_swap(project, charlie, _base_coins, base_pool_token, base_pool_data, is None, None, None, + None, + None ) @@ -120,3 +130,20 @@ def aave_lending_pool(AaveLendingPoolMock, pool_data, alice, is_forked): return Contract("0x7d2768dE32b0b80b7a3454c06BdAc94A69DDc7A9") else: return AaveLendingPoolMock.deploy({"from": alice}) + + +@pytest.fixture(scope="module") +def redemption_price_snap(RedemptionPriceSnapMock, pool_data, alice, is_forked): + if pool_data["name"] in ("rai", "raiust"): + if is_forked: + return Contract("0x0000000000000000000000000000000000000000") # todo after deployment replace this. + else: + return RedemptionPriceSnapMock.deploy({"from": alice}) + +@pytest.fixture(scope="module") +def math(CurveCryptoMath3, pool_data, alice, is_forked): + if pool_data["name"] in ("rai",): + if is_forked: + return Contract("0x0000000000000000000000000000000000000000") # todo after deployment replace this. + else: + return CurveCryptoMath3.deploy({"from": alice}) diff --git a/tests/pools/rai/integration/test_redemption_rate_handling.py b/tests/pools/rai/integration/test_redemption_rate_handling.py new file mode 100644 index 00000000..54afd1d6 --- /dev/null +++ b/tests/pools/rai/integration/test_redemption_rate_handling.py @@ -0,0 +1,247 @@ +import pytest +from brownie import ETH_ADDRESS, chain +from brownie.test import strategy + +pytestmark = [pytest.mark.usefixtures("add_initial_liquidity")] + + +class StateMachine: + """ + Stateful test that performs a series of deposits, swaps, withdrawals and redemption price modifications + on a meta pool and confirms that the virtual price only goes up. + """ + + st_pct = strategy("decimal", min_value="0.5", max_value="1", places=2) + st_red_prices = strategy('uint256', + min_value="980000000000000000000000000", + max_value="1020000000000000000000000000") + + def __init__( + cls, + alice, + base_swap, + swap, + wrapped_coins, + wrapped_decimals, + underlying_coins, + underlying_decimals, + redemption_price_snap + ): + cls.alice = alice + cls.swap = swap + cls.base_swap = base_swap + cls.coins = wrapped_coins + cls.decimals = wrapped_decimals + cls.underlying_coins = underlying_coins + cls.underlying_decimals = underlying_decimals + cls.redemption_price_snap = redemption_price_snap + cls.n_coins = len(wrapped_coins) + cls.virtual_price_base = None + cls.virtual_price = None + cls.redemption_price = None + + # approve base pool for swaps + base_coins = cls.underlying_coins[cls.n_coins - 1:] + for idx in range(len(base_coins)): + base_coins[idx].approve(cls.base_swap, 2 ** 256 - 1, {"from": cls.alice}) + + def setup(self): + # reset the virtual price between each test run + self.virtual_price_base = self.base_swap.get_virtual_price() + self.virtual_price = self.swap.get_virtual_price() + self.redemption_price = self.redemption_price_snap.snappedRedemptionPrice() + + def _min_max(self): + # get index values for the coins with the smallest and largest balances in the meta pool + balances = [self.swap.balances(i) / (10 ** self.decimals[i]) for i in range(self.n_coins)] + min_idx = balances.index(min(balances)) + max_idx = balances.index(max(balances)) + if min_idx == max_idx: + min_idx = abs(min_idx - 1) + + return min_idx, max_idx + + def _min_max_underlying(self, base=False): + # get index values for underlying coins with smallest and largest balances + balances = [] + for i in range(len(self.underlying_coins)): + if i < self.n_coins - 1: + balances.append(self.swap.balances(i) / 10 ** self.underlying_decimals[i]) + else: + base_i = i - (self.n_coins - 1) + balances.append( + self.base_swap.balances(base_i) / (10 ** self.underlying_decimals[base_i]) + ) + + if base: + for i in range(self.n_coins - 1): + balances.pop(0) + + min_idx = balances.index(min(balances)) + max_idx = balances.index(max(balances)) + if min_idx == max_idx: + min_idx = abs(min_idx - 1) + + return min_idx, max_idx + + def rule_ramp_A(self, st_pct): + """ + Increase the amplification coefficient. + + This action happens at most once per test. If A has already + been ramped, a swap is performed instead. + """ + if self.swap.future_A_time(): + return self.rule_exchange_underlying(st_pct) + + new_A = int(self.swap.A() * (1 + st_pct)) + self.swap.ramp_A(new_A, chain.time() + 86410, {"from": self.alice}) + + def rule_increase_rates(self, st_pct): + """ + Increase the virtual price of the base pool. + """ + if not hasattr(self.base_swap, "donate_admin_fees"): + # not all base pools include `donate_admin_fees` + return self.rule_generate_fees() + + for i, coin in enumerate(self.underlying_coins): + if i < self.n_coins - 1: + continue + amount = int(10 ** self.underlying_decimals[i] * (1 + st_pct)) + coin._mint_for_testing(self.base_swap, amount, {"from": self.alice}) + self.base_swap.donate_admin_fees() + + def rule_exchange(self, st_pct): + """ + Perform a swap using wrapped coins. + """ + send, recv = self._min_max() + + amount = int(10 ** self.decimals[send] * st_pct) + value = amount if self.underlying_coins[send] == ETH_ADDRESS else 0 + self.swap.exchange(send, recv, amount, 0, {"from": self.alice, "value": value}) + + def rule_generate_fees(self): + """ + Pushes base pool to be heavily imbalanced and then rebalances it to generate a lot of fees + and thereby increase the virtual price. + """ + min_idx, max_idx = self._min_max_underlying(base=True) + dx = self.base_swap.balances(max_idx) + base_decimals = self.underlying_decimals[self.n_coins - 1:] + if base_decimals[max_idx] > base_decimals[min_idx]: + dx = dx / 10 ** (base_decimals[max_idx] - base_decimals[min_idx]) + elif base_decimals[min_idx] > base_decimals[max_idx]: + dx = dx * 10 ** (base_decimals[min_idx] - base_decimals[max_idx]) + + base_coins = self.underlying_coins[self.n_coins - 1:] + base_coins[min_idx]._mint_for_testing(self.alice, dx, {"from": self.alice}) + + tx = self.base_swap.exchange(min_idx, max_idx, dx, 0, {"from": self.alice}) + dy = tx.events["TokenExchange"]["tokens_bought"] + self.base_swap.exchange(max_idx, min_idx, dy, 0, {"from": self.alice}) + + def rule_exchange_underlying(self, st_pct): + """ + Perform a swap using underlying coins. + """ + send, recv = self._min_max_underlying() + + amount = int(10 ** self.underlying_decimals[send] * st_pct) + value = amount if self.underlying_coins[send] == ETH_ADDRESS else 0 + self.swap.exchange_underlying(send, recv, amount, 0, {"from": self.alice, "value": value}) + + def rule_remove_one_coin(self, st_pct): + """ + Remove liquidity from the pool in only one coin. + """ + if not hasattr(self.swap, "remove_liquidity_one_coin"): + # not all pools include `remove_liquidity_one_coin` + return self.rule_remove_imbalance(st_pct) + + idx = self._min_max()[1] + amount = int(10 ** self.decimals[idx] * st_pct) + self.swap.remove_liquidity_one_coin(amount, idx, 0, {"from": self.alice}) + + def rule_remove_imbalance(self, st_pct): + """ + Remove liquidity from the pool in an imbalanced manner. + """ + idx = self._min_max()[1] + amounts = [0] * self.n_coins + amounts[idx] = 10 ** self.decimals[idx] * st_pct + self.swap.remove_liquidity_imbalance(amounts, 2 ** 256 - 1, {"from": self.alice}) + + def rule_remove(self, st_pct): + """ + Remove liquidity from the pool. + """ + amount = int(10 ** 18 * st_pct) + self.swap.remove_liquidity(amount, [0] * self.n_coins, {"from": self.alice}) + + def rule_adjust_redemption_price(self, st_red_prices): + self.redemption_price_snap.setRedemptionPriceSnap(st_red_prices) + + def invariant_check_virtual_price(self): + """ + Verify that base virtual price never goes down, swap virtual price follows the redemptionPrice when it goes up. + """ + virtual_price_base = self.base_swap.get_virtual_price() + assert virtual_price_base >= self.virtual_price_base + self.virtual_price_base = virtual_price_base + + virtual_price = self.swap.get_virtual_price() + redemption_price = self.redemption_price_snap.snappedRedemptionPrice() + + if redemption_price >= self.redemption_price: + assert virtual_price + 1 >= self.virtual_price + + self.redemption_price = redemption_price + self.virtual_price = virtual_price + + def invariant_advance_time(self): + """ + Advance the clock by 1 hour between each action. + """ + chain.sleep(3600) + + +def test_number_always_go_up( + add_initial_liquidity, + state_machine, + base_swap, + swap, + alice, + bob, + underlying_coins, + underlying_decimals, + wrapped_coins, + wrapped_decimals, + base_amount, + set_fees, + redemption_price_snap, +): + set_fees(10 ** 7, 0) + + for underlying, wrapped in zip(underlying_coins, wrapped_coins): + amount = 10 ** 18 * base_amount + if underlying == ETH_ADDRESS: + bob.transfer(alice, amount) + else: + underlying._mint_for_testing(alice, amount, {"from": alice}) + if underlying != wrapped: + wrapped._mint_for_testing(alice, amount, {"from": alice}) + + state_machine( + StateMachine, + alice, + base_swap, + swap, + wrapped_coins, + wrapped_decimals, + underlying_coins, + underlying_decimals, + redemption_price_snap, + settings={"max_examples": 25, "stateful_step_count": 50}, + ) diff --git a/tests/pools/rai/unitary/test_add_liquidity_initial_moving_rp.py b/tests/pools/rai/unitary/test_add_liquidity_initial_moving_rp.py new file mode 100644 index 00000000..f126241d --- /dev/null +++ b/tests/pools/rai/unitary/test_add_liquidity_initial_moving_rp.py @@ -0,0 +1,37 @@ +import brownie +import pytest + +pytestmark = pytest.mark.usefixtures("mint_alice", "approve_alice") + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.parametrize("min_amount", [0, 2 * 10 ** 18]) +def test_initial(alice, swap, wrapped_coins, pool_token, min_amount, wrapped_decimals, n_coins, initial_amounts, + redemption_price_scale, redemption_price_snap): + amounts = [10 ** i for i in wrapped_decimals] + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + imbalance_scale = 0.5 + 0.5 * redemption_price_scale + min_amount *= imbalance_scale * (1 - 1e-3) + swap.add_liquidity(amounts, min_amount, {"from": alice, "value": 0}) + + for coin, amount, initial in zip(wrapped_coins, amounts, initial_amounts): + assert coin.balanceOf(alice) == initial - amount + assert coin.balanceOf(swap) == amount + + std_amount = (n_coins * 10 ** 18) + + expected_balance = std_amount * imbalance_scale + assert pytest.approx(pool_token.balanceOf(alice), rel=1e-3) == expected_balance + assert pytest.approx(pool_token.totalSupply(), rel=1e-3) == expected_balance + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("idx") +def test_initial_liquidity_missing_coin(alice, swap, pool_token, idx, wrapped_decimals, redemption_price_scale, + redemption_price_snap): + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + amounts = [10 ** i for i in wrapped_decimals] + amounts[idx] = 0 + + with brownie.reverts(): + swap.add_liquidity(amounts, 0, {"from": alice}) diff --git a/tests/pools/rai/unitary/test_add_liquidity_moving_rp.py b/tests/pools/rai/unitary/test_add_liquidity_moving_rp.py new file mode 100644 index 00000000..48d2f00f --- /dev/null +++ b/tests/pools/rai/unitary/test_add_liquidity_moving_rp.py @@ -0,0 +1,26 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity", "mint_bob", "approve_bob") +redemption_index = 0 +lp_index = 1 + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("zero_idx") +def test_add_liquidity(bob, swap, wrapped_coins, pool_token, initial_amounts, base_amount, n_coins, zero_idx, + redemption_price_scale, redemption_price_snap): + initial_pool_token_total_supply = pool_token.totalSupply() + new_to_initial_deposit_scale = 1e-21 + deposit_amounts = [initial_amounts[i] * new_to_initial_deposit_scale for i in range(n_coins)] + deposit_amounts[zero_idx] = 0 + + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + swap.add_liquidity(deposit_amounts, 0, {"from": bob, "value": 0}) + pool_tokens_earned = pool_token.balanceOf(bob) + + tvl_prop = 0.5 + 0.5 * redemption_price_scale # half of liquidity val has been scaled by redemption price + deposited_coin_val = 1 + if zero_idx == lp_index: + deposited_coin_val = redemption_price_scale + expected = initial_pool_token_total_supply * new_to_initial_deposit_scale / 2 * deposited_coin_val / tvl_prop + assert pytest.approx(pool_tokens_earned, rel=1e-3) == expected diff --git a/tests/pools/rai/unitary/test_exchange_moving_rp.py b/tests/pools/rai/unitary/test_exchange_moving_rp.py new file mode 100644 index 00000000..1b70be36 --- /dev/null +++ b/tests/pools/rai/unitary/test_exchange_moving_rp.py @@ -0,0 +1,31 @@ +import pytest +from pytest import approx + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity", "approve_bob") +redemption_index = 0 +lp_index = 1 + + +@pytest.mark.parametrize("redemption_price_scale", [0.75, 1.0, 1.25]) +def test_exchange_results_with_moving_redemption_price( + bob, + swap, + wrapped_coins, + base_amount, + get_admin_balances, + redemption_price_scale, + redemption_price_snap, +): + redemption_coin = wrapped_coins[redemption_index] + lp_coin = wrapped_coins[lp_index] + precision = 10 ** 18 + trade_quantity = 10 * precision + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + redemption_coin._mint_for_testing(bob, trade_quantity, {"from": bob}) + swap.exchange(redemption_index, lp_index, trade_quantity, 0, {"from": bob, "value": 0}) + assert redemption_coin.balanceOf(bob) == 0 + received = lp_coin.balanceOf(bob) + assert trade_quantity * redemption_price_scale == approx(received, rel=1e-3) + swap.exchange(lp_index, redemption_index, received, 0, {"from": bob, "value": 0}) + assert approx(redemption_coin.balanceOf(bob)) == trade_quantity + assert lp_coin.balanceOf(bob) == 0 diff --git a/tests/pools/rai/unitary/test_exchange_underlying_moving_rp.py b/tests/pools/rai/unitary/test_exchange_underlying_moving_rp.py new file mode 100644 index 00000000..9ddf6885 --- /dev/null +++ b/tests/pools/rai/unitary/test_exchange_underlying_moving_rp.py @@ -0,0 +1,57 @@ +from itertools import permutations + +import pytest +from pytest import approx + +pytestmark = [pytest.mark.usefixtures("add_initial_liquidity", "approve_bob"), pytest.mark.lending] +redemption_index = 0 + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("sending", "receiving", underlying=True) +@pytest.mark.parametrize("fee,admin_fee", set(permutations([0, 0], 2))) +def test_amounts( + bob, + swap, + underlying_coins, + sending, + receiving, + fee, + admin_fee, + underlying_decimals, + set_fees, + n_coins, + is_metapool, + redemption_price_scale, + redemption_price_snap, +): + if fee or admin_fee: + set_fees(10 ** 10 * fee, 10 ** 10 * admin_fee) + + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + amount = 10 ** underlying_decimals[sending] + underlying_coins[sending]._mint_for_testing(bob, amount, {"from": bob}) + swap.exchange_underlying(sending, receiving, amount, 0, {"from": bob}) + + assert underlying_coins[sending].balanceOf(bob) == 0 + + received = underlying_coins[receiving].balanceOf(bob) + if receiving == redemption_index: + expected = 1 / redemption_price_scale + elif sending == redemption_index: + expected = redemption_price_scale + else: + expected = 1 + assert approx(expected, rel=1e-2) == received / 10 ** underlying_decimals[receiving] + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("sending", "receiving", underlying=True) +def test_min_dy_underlying(bob, swap, underlying_coins, sending, receiving, underlying_decimals, redemption_price_scale, + redemption_price_snap): + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + amount = 100 * 10 ** underlying_decimals[sending] + underlying_coins[sending]._mint_for_testing(bob, amount, {"from": bob}) + min_dy = swap.get_dy_underlying(sending, receiving, amount) + swap.exchange_underlying(sending, receiving, amount, min_dy - 1, {"from": bob}) + assert abs(underlying_coins[receiving].balanceOf(bob) - min_dy) <= 1 diff --git a/tests/pools/rai/unitary/test_remove_liquidity_imbalance_moving_rp.py b/tests/pools/rai/unitary/test_remove_liquidity_imbalance_moving_rp.py new file mode 100644 index 00000000..a7220c88 --- /dev/null +++ b/tests/pools/rai/unitary/test_remove_liquidity_imbalance_moving_rp.py @@ -0,0 +1,68 @@ +import brownie +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity") +redemption_index = 0 +lp_index = 1 + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("zero_idx") +def test_remove_some_pool_token(alice, swap, wrapped_coins, pool_token, initial_amounts, n_coins, base_amount, + redemption_price_scale, zero_idx, redemption_price_snap): + amounts = [i // 2 for i in initial_amounts] + amounts[zero_idx] = 0 + initial_pool_token_total_supply = pool_token.totalSupply() + # The redemption price is being doubled or halved, and half of either the redemption or base lp token is removed. + # Each component of liquidity now accounts for either one or two thirds of the total. + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + swap.remove_liquidity_imbalance(amounts, n_coins * 10 ** 18 * base_amount, {"from": alice}) + for i, coin in enumerate(wrapped_coins): + assert coin.balanceOf(alice) == amounts[i] + assert coin.balanceOf(swap) == initial_amounts[i] - amounts[i] + + actual_balance = pool_token.balanceOf(alice) + actual_total_supply = pool_token.totalSupply() + assert actual_balance == actual_total_supply + + # Ensure a fair amount of LP tokens have been destroyed relative to the proportion of total liquidity value removed. + # Approx used because there will be some small slippage. + if (zero_idx == redemption_index) == (redemption_price_scale == 2): + expected_pool_tokens_remaining_proportion = 5 / 6 + else: + expected_pool_tokens_remaining_proportion = 2 / 3 + remaining_proportion = actual_total_supply / initial_pool_token_total_supply + assert expected_pool_tokens_remaining_proportion == pytest.approx(remaining_proportion, rel=1e-3) + + +@pytest.mark.parametrize("divisor", [1, 2, 10]) +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +def test_exceed_max_burn(alice, swap, wrapped_coins, pool_token, divisor, initial_amounts, base_amount, n_coins, + redemption_price_scale, redemption_price_snap): + amounts = [i // divisor for i in initial_amounts] + max_burn = (n_coins * 10 ** 18 * base_amount) // divisor + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + + # Ensure when withdrawing equal amounts of each coin the redemption price should not effect results compared to the + # common version of this test. + with brownie.reverts("Slippage screwed you"): + swap.remove_liquidity_imbalance(amounts, max_burn - 1, {"from": alice}) + + +@pytest.mark.parametrize("divisor", [2, 10]) +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("zero_idx") +def test_exceed_max_burn_imbalanced(alice, swap, wrapped_coins, pool_token, divisor, initial_amounts, base_amount, + n_coins, redemption_price_scale, redemption_price_snap, zero_idx): + amounts = [i // divisor for i in initial_amounts] + amounts[zero_idx] = 0 + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + if (zero_idx == redemption_index) == (redemption_price_scale == 2): + burn_scale = 2 / 3 + else: + burn_scale = 4 / 3 + max_burn = (burn_scale * (n_coins - 1) * 10 ** 18 * base_amount) // divisor + + # Ensure the max burn moves with the redemption price to reflect the proportion of liquidity value removed + with brownie.reverts("Slippage screwed you"): + swap.remove_liquidity_imbalance(amounts, max_burn * 0.999, {"from": alice}) diff --git a/tests/pools/rai/unitary/test_remove_liquidity_moving_rp.py b/tests/pools/rai/unitary/test_remove_liquidity_moving_rp.py new file mode 100644 index 00000000..f82a3295 --- /dev/null +++ b/tests/pools/rai/unitary/test_remove_liquidity_moving_rp.py @@ -0,0 +1,27 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity") + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +def test_remove_liquidity(alice, swap, wrapped_coins, pool_token, initial_amounts, base_amount, n_coins, + redemption_price_scale, redemption_price_snap): + # For clarity this is the state post setup. Alice has deposited 1MM each of redemption coin and lp token when the + # redemption price was 1. + assert pool_token.balanceOf(alice) == n_coins * 10 ** 18 * base_amount == pool_token.totalSupply() + for coin, amount in zip(wrapped_coins, initial_amounts): + assert coin.balanceOf(swap) == 1000000 * 10**18 + + # Now modify the redemption price and remove half the liquidity. The received tokens should be independent of the + # redemption price, half the LP tokens should give half the underlying tokens. + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + swap.remove_liquidity( + n_coins * 10 ** 18 * base_amount / 2, [0, 0], {"from": alice} + ) + + for coin, amount in zip(wrapped_coins, initial_amounts): + assert coin.balanceOf(alice) == amount / 2 + assert coin.balanceOf(swap) == amount / 2 + + assert pool_token.balanceOf(alice) == n_coins * 10 ** 18 * base_amount / 2 + assert pool_token.totalSupply() == n_coins * 10 ** 18 * base_amount / 2 diff --git a/tests/pools/rai/unitary/test_rp_caching.py b/tests/pools/rai/unitary/test_rp_caching.py new file mode 100644 index 00000000..97be258c --- /dev/null +++ b/tests/pools/rai/unitary/test_rp_caching.py @@ -0,0 +1,20 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity") + + +def test_redemption_price(chain, bob, swap, initial_amounts, n_coins, redemption_price_snap): + redemption_price = redemption_price_snap.snappedRedemptionPrice() + + chain.sleep(86400) + chain.mine() + + assert redemption_price == redemption_price_snap.snappedRedemptionPrice() + + redemption_price += 1e25 + redemption_price_snap.setRedemptionPriceSnap(redemption_price) + + chain.sleep(86400) + chain.mine() + + assert redemption_price == redemption_price_snap.snappedRedemptionPrice() diff --git a/tests/pools/raiust/integration/test_rp_moving_raiust.py b/tests/pools/raiust/integration/test_rp_moving_raiust.py new file mode 100644 index 00000000..6349ef42 --- /dev/null +++ b/tests/pools/raiust/integration/test_rp_moving_raiust.py @@ -0,0 +1,181 @@ +import pytest +from brownie import ETH_ADDRESS, chain +from brownie.test import strategy + +pytestmark = [pytest.mark.usefixtures("add_initial_liquidity")] + +class StateMachine: + """ + Stateful test that performs a series of deposits, swaps and withdrawals + and confirms that the virtual price only goes up. + """ + # a decimal number between 0.5 and 1 with two points + st_pct = strategy("decimal", min_value="0.5", max_value="1", places=2) + ### set red_prices, red price has 27 decimals, here is 0.98 to 1.02 + st_red_prices = strategy('uint256', + min_value="980000000000000000000000000", + max_value="1020000000000000000000000000") + st_rates = strategy("decimal[8]", min_value="1.001", max_value="1.004", places=4, unique=True) + + def __init__(cls, alice, swap, wrapped_coins, wrapped_decimals, redemption_price_snap): + cls.alice = alice + cls.swap = swap + cls.coins = wrapped_coins + cls.decimals = wrapped_decimals + cls.n_coins = len(wrapped_coins) + ### add rp snap + cls.redemption_price_snap = redemption_price_snap + + # update LP Token Price and redemption price + def setup(self): + # reset the virtual price between each test run + self.virtual_price = self.swap.get_virtual_price() + ### update the rp + self.redemption_price = self.redemption_price_snap.snappedRedemptionPrice() + + # get index values for the coins with the smallest and largest balances in the pool + def _min_max(self): + # get index values for the coins with the smallest and largest balances in the pool + balances = [self.swap.balances(i) / (10 ** self.decimals[i]) for i in range(self.n_coins)] + min_idx = balances.index(min(balances)) + max_idx = balances.index(max(balances)) + if min_idx == max_idx: + min_idx = abs(min_idx - 1) + + return min_idx, max_idx + + def rule_ramp_A(self, st_pct): + """ + Increase the amplification coefficient. + + This action happens at most once per test. If A has already + been ramped, a swap is performed instead. + """ + ## if swap has no ramp_A or swap is set to have no ramp_A, exchange directly. + if not hasattr(self.swap, "ramp_A") or self.swap.future_A_time(): + return self.rule_exchange_underlying(st_pct) + + # increase A + new_A = int(self.swap.A() * (1 + st_pct)) + self.swap.ramp_A(new_A, chain.time() + 86410, {"from": self.alice}) + + + ### Adpat RP + def rule_adjust_redemption_price(self, st_red_prices): + self.redemption_price_snap.setRedemptionPriceSnap(st_red_prices) + + + def rule_increase_rates(self, st_rates): + """ + Increase the stored rate for each wrapped coin. + """ + for rate, coin in zip(self.coins, st_rates): + if hasattr(coin, "set_exchange_rate"): + coin.set_exchange_rate(int(coin.get_rate() * rate), {"from": self.alice}) + + # send min-indexed coin, and receive max-indexed coin + def rule_exchange(self, st_pct): + """ + Perform a swap using wrapped coins. + """ + send, recv = self._min_max() + amount = int(10 ** self.decimals[send] * st_pct) + value = amount if self.coins[send] == ETH_ADDRESS else 0 + self.swap.exchange(send, recv, amount, 0, {"from": self.alice, "value": value}) + + # swap must have exchange_underlying, otherwise directly swap (rule_exchange) + def rule_exchange_underlying(self, st_pct): + """ + Perform a swap using underlying coins. + """ + if not hasattr(self.swap, "exchange_underlying"): + # if underlying coins aren't available, use wrapped instead + return self.rule_exchange(st_pct) + + send, recv = self._min_max() + amount = int(10 ** self.decimals[send] * st_pct) + value = amount if self.coins[send] == ETH_ADDRESS else 0 + self.swap.exchange_underlying(send, recv, amount, 0, {"from": self.alice, "value": value}) + + def rule_remove_one_coin(self, st_pct): + """ + Remove liquidity from the pool in only one coin. + """ + if not hasattr(self.swap, "remove_liquidity_one_coin"): + # not all pools include `remove_liquidity_one_coin` + return self.rule_remove_imbalance(st_pct) + + # get max-indexed coin + idx = self._min_max()[1] + amount = int(10 ** self.decimals[idx] * st_pct) + self.swap.remove_liquidity_one_coin(amount, idx, 0, {"from": self.alice}) + + def rule_remove_imbalance(self, st_pct): + """ + Remove liquidity from the pool in an imbalanced manner. + """ + idx = self._min_max()[1] + amounts = [0] * self.n_coins + amounts[idx] = 10 ** self.decimals[idx] * st_pct + self.swap.remove_liquidity_imbalance(amounts, 2 ** 256 - 1, {"from": self.alice}) + + def rule_remove(self, st_pct): + """ + Remove liquidity from the pool. + """ + amount = int(10 ** 18 * st_pct) + self.swap.remove_liquidity(amount, [0] * self.n_coins, {"from": self.alice}) + + def invariant_check_virtual_price(self): + """ + Verify that the pool's virtual price has either increased or stayed the same. + """ + virtual_price = self.swap.get_virtual_price() + #### RP up, VP up + redemption_price = self.redemption_price_snap.snappedRedemptionPrice() + if redemption_price >= self.redemption_price: + assert virtual_price + 1 >= self.virtual_price + #### + self.redemption_price = redemption_price + self.virtual_price = virtual_price + + def invariant_advance_time(self): + """ + Advance the clock by 1 hour between each action. + """ + chain.sleep(3600) + + +def test_number_always_go_up( + add_initial_liquidity, + state_machine, + swap, + alice, + bob, + underlying_coins, + wrapped_coins, + wrapped_decimals, + base_amount, + set_fees, + redemption_price_snap, +): + set_fees(10 ** 7, 0) + + for underlying, wrapped in zip(underlying_coins, wrapped_coins): + amount = 10 ** 18 * base_amount + if underlying == ETH_ADDRESS: + bob.transfer(alice, amount) + else: + underlying._mint_for_testing(alice, amount, {"from": alice}) + if underlying != wrapped: + wrapped._mint_for_testing(alice, amount, {"from": alice}) + + state_machine( + StateMachine, + alice, + swap, + wrapped_coins, + wrapped_decimals, + redemption_price_snap, + settings={"max_examples": 25, "stateful_step_count": 50}, + ) diff --git a/tests/pools/raiust/unitary/test_add_liquidity_initial_moving_rp_raiust.py b/tests/pools/raiust/unitary/test_add_liquidity_initial_moving_rp_raiust.py new file mode 100644 index 00000000..2a471eeb --- /dev/null +++ b/tests/pools/raiust/unitary/test_add_liquidity_initial_moving_rp_raiust.py @@ -0,0 +1,36 @@ +import brownie +import pytest + +pytestmark = pytest.mark.usefixtures("mint_alice", "approve_alice") + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.parametrize("min_amount", [0, 2 * 10 ** 18]) +def test_initial( + alice, swap, wrapped_coins, pool_token, min_amount, wrapped_decimals, n_coins, initial_amounts, + redemption_price_scale, redemption_price_snap): + amounts = [10 ** i for i in wrapped_decimals] + ### + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + imbalance_scale = 0.5 + 0.5 * redemption_price_scale + min_amount *= imbalance_scale * (1 - 1e-3) + swap.add_liquidity(amounts, min_amount, {"from": alice, "value": 0}) + + for coin, amount, initial in zip(wrapped_coins, amounts, initial_amounts): + assert coin.balanceOf(alice) == initial - amount + assert coin.balanceOf(swap) == amount + + std_amount = (n_coins * 10 ** 18) + expected_balance = std_amount * imbalance_scale + assert pytest.approx(pool_token.balanceOf(alice), rel=1e-3) == expected_balance + assert pytest.approx(pool_token.totalSupply(), rel=1e-3) == expected_balance + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("idx") +def test_initial_liquidity_missing_coin(alice, swap, pool_token, idx, wrapped_decimals, redemption_price_scale, redemption_price_snap): + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + amounts = [10 ** i for i in wrapped_decimals] + # idx-indexed coin is missing + amounts[idx] = 0 + + with brownie.reverts(): + swap.add_liquidity(amounts, 0, {"from": alice}) diff --git a/tests/pools/raiust/unitary/test_add_liquidity_moving_rp_raiust.py b/tests/pools/raiust/unitary/test_add_liquidity_moving_rp_raiust.py new file mode 100644 index 00000000..36c7dc93 --- /dev/null +++ b/tests/pools/raiust/unitary/test_add_liquidity_moving_rp_raiust.py @@ -0,0 +1,23 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity", "mint_bob", "approve_bob") +lp_index = 1 + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("zero_idx") +def test_add_liquidity(bob, swap, wrapped_coins, pool_token, initial_amounts, base_amount, n_coins, zero_idx, + redemption_price_scale, redemption_price_snap): + initial_pool_token_total_supply = pool_token.totalSupply() + new_to_initial_deposit_scale = 1e-21 + deposit_amounts = [initial_amounts[i] * new_to_initial_deposit_scale for i in range(n_coins)] + deposit_amounts[zero_idx] = 0 + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + swap.add_liquidity(deposit_amounts, 0, {"from": bob, "value": 0}) + pool_tokens_earned = pool_token.balanceOf(bob) + + tvl_prop = 0.5 + 0.5 * redemption_price_scale # half of liquidity val has been scaled by redemption price + deposited_coin_val = 1 + if zero_idx == lp_index: + deposited_coin_val = redemption_price_scale + expected = initial_pool_token_total_supply * new_to_initial_deposit_scale / 2 * deposited_coin_val / tvl_prop + assert pytest.approx(pool_tokens_earned, rel=1e-3) == expected diff --git a/tests/pools/raiust/unitary/test_exchange_moving_rp_raiust.py b/tests/pools/raiust/unitary/test_exchange_moving_rp_raiust.py new file mode 100644 index 00000000..090b46ad --- /dev/null +++ b/tests/pools/raiust/unitary/test_exchange_moving_rp_raiust.py @@ -0,0 +1,30 @@ +import pytest +from pytest import approx + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity", "approve_bob") +redemption_index = 0 +lp_index = 1 + +@pytest.mark.parametrize("redemption_price_scale", [0.75, 1.0, 1.25]) +def test_exchange_results_with_moving_redemption_price( + bob, + swap, + wrapped_coins, + redemption_price_scale, + redemption_price_snap, +): + redemption_coin = wrapped_coins[redemption_index] + lp_coin = wrapped_coins[lp_index] + precision = 10 ** 18 + trade_quantity = 10 * precision + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + redemption_coin._mint_for_testing(bob, trade_quantity, {"from": bob}) + # send from redemption_index, receive from lp_index + swap.exchange(redemption_index, lp_index, trade_quantity, 0, {"from": bob, "value": 0}) + assert redemption_coin.balanceOf(bob) == 0 + received = lp_coin.balanceOf(bob) + assert trade_quantity * redemption_price_scale == approx(received, rel=1e-3) + # exchange back + swap.exchange(lp_index, redemption_index, received, 0, {"from": bob, "value": 0}) + assert approx(redemption_coin.balanceOf(bob)) == trade_quantity + assert lp_coin.balanceOf(bob) == 0 diff --git a/tests/pools/raiust/unitary/test_remove_liquidity_imbalance_moving_rp_raiust.py b/tests/pools/raiust/unitary/test_remove_liquidity_imbalance_moving_rp_raiust.py new file mode 100644 index 00000000..c040b091 --- /dev/null +++ b/tests/pools/raiust/unitary/test_remove_liquidity_imbalance_moving_rp_raiust.py @@ -0,0 +1,88 @@ +import brownie +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity") +redemption_index = 0 +lp_index = 1 + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("zero_idx") +def test_remove_some_pool_token(alice, swap, wrapped_coins, pool_token, initial_amounts, n_coins, base_amount, + redemption_price_scale, zero_idx, redemption_price_snap): + # Draw half amount of coins + amounts = [i // 2 for i in initial_amounts] + # imbalance, set one coin to draw 0 + amounts[zero_idx] = 0 + + # Get the initianl LP Token + initial_pool_token_total_supply = pool_token.totalSupply() + + # The redemption price is being doubled or halved, and half of either the redemption or base lp token is removed. + # Each component of liquidity now accounts for either one or two thirds of the total. + + # Update RP first, then remove + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + swap.remove_liquidity_imbalance(amounts, n_coins * 10 ** 18 * base_amount, {"from": alice}) + + # After remove, coin amount should change on the coin balance of swap and alice + for i, coin in enumerate(wrapped_coins): + assert coin.balanceOf(alice) == amounts[i] + assert coin.balanceOf(swap) == initial_amounts[i] - amounts[i] + + # After remove liquidity, assure LP token has been burned + actual_balance = pool_token.balanceOf(alice) + actual_total_supply = pool_token.totalSupply() + assert actual_balance == actual_total_supply + + # Ensure a fair amount of LP tokens have been destroyed relative to the proportion of total liquidity value removed. + # Approx used because there will be some small slippage. + # # # Case 1 Explanation: + # # # The initial proportion between R and L is 1:1, if redemption_price_scale is 2, then + # # # the proportion between R and L is 2:1. Draw [0, 0.5], then remaining_proportion is 1.5/(2+1)= 5/6 + # # # Case 2 Explanation: + # # # The initial proportion between R and L is 1:1, if redemption_price_scale is 0.5, then + # # # the proportion between R and L is 0.5:1. Draw [0.25, 0], then remaining_proportion is 1.25/(0.5+1)= 5/6 + if (zero_idx == redemption_index) == (redemption_price_scale == 2): + expected_pool_tokens_remaining_proportion = 5 / 6 + else: + # Logic is the same as above + expected_pool_tokens_remaining_proportion = 2 / 3 + + remaining_proportion = actual_total_supply / initial_pool_token_total_supply + + assert expected_pool_tokens_remaining_proportion == pytest.approx(remaining_proportion, rel=1e-3) + + +@pytest.mark.parametrize("divisor", [1, 2, 10]) +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +def test_exceed_max_burn(alice, swap, wrapped_coins, pool_token, divisor, initial_amounts, base_amount, n_coins, + redemption_price_scale, redemption_price_snap): + amounts = [i // divisor for i in initial_amounts] + max_burn = (n_coins * 10 ** 18 * base_amount) // divisor + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + + # Ensure when withdrawing equal amounts of each coin the redemption price should not effect results compared to the + # common version of this test. + with brownie.reverts("Slippage screwed you"): + swap.remove_liquidity_imbalance(amounts, max_burn - 1, {"from": alice}) + +# +@pytest.mark.parametrize("divisor", [2, 10]) +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +@pytest.mark.itercoins("zero_idx") +def test_exceed_max_burn_imbalanced(alice, swap, wrapped_coins, pool_token, divisor, initial_amounts, base_amount, + n_coins, redemption_price_scale, redemption_price_snap, zero_idx): + + amounts = [i // divisor for i in initial_amounts] + # imbalanced + amounts[zero_idx] = 0 + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + if (zero_idx == redemption_index) == (redemption_price_scale == 2): + burn_scale = 2 / 3 + else: + burn_scale = 4 / 3 + max_burn = (burn_scale * (n_coins - 1) * 10 ** 18 * base_amount) // divisor + + # Ensure the max burn moves with the redemption price to reflect the proportion of liquidity value removed + with brownie.reverts("Slippage screwed you"): + swap.remove_liquidity_imbalance(amounts, max_burn * 0.999, {"from": alice}) \ No newline at end of file diff --git a/tests/pools/raiust/unitary/test_remove_liquidity_moving_rp_raiust.py b/tests/pools/raiust/unitary/test_remove_liquidity_moving_rp_raiust.py new file mode 100644 index 00000000..d0b5dfc6 --- /dev/null +++ b/tests/pools/raiust/unitary/test_remove_liquidity_moving_rp_raiust.py @@ -0,0 +1,27 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity") + + +@pytest.mark.parametrize("redemption_price_scale", [0.5, 2]) +def test_remove_liquidity(alice, swap, wrapped_coins, pool_token, initial_amounts, base_amount, n_coins, + redemption_price_scale, redemption_price_snap): + # For clarity this is the state post setup. Alice has deposited 1MM each of redemption coin and lp token when the + # redemption price was 1. + assert pool_token.balanceOf(alice) == n_coins * 10 ** 18 * base_amount == pool_token.totalSupply() + for coin, amount in zip(wrapped_coins, initial_amounts): + assert coin.balanceOf(swap) == 1000000 * 10**18 + + # Now modify the redemption price and remove half the liquidity. The received tokens should be independent of the + # redemption price, half the LP tokens should give half the underlying tokens. + redemption_price_snap.setRedemptionPriceSnap(redemption_price_scale * 1e27) + swap.remove_liquidity( + n_coins * 10 ** 18 * base_amount / 2, [0, 0], {"from": alice} + ) + + for coin, amount in zip(wrapped_coins, initial_amounts): + assert coin.balanceOf(alice) == amount / 2 + assert coin.balanceOf(swap) == amount / 2 + + assert pool_token.balanceOf(alice) == n_coins * 10 ** 18 * base_amount / 2 + assert pool_token.totalSupply() == n_coins * 10 ** 18 * base_amount / 2 diff --git a/tests/pools/raiust/unitary/test_rp_caching_raiust.py b/tests/pools/raiust/unitary/test_rp_caching_raiust.py new file mode 100644 index 00000000..a2cd3cf8 --- /dev/null +++ b/tests/pools/raiust/unitary/test_rp_caching_raiust.py @@ -0,0 +1,20 @@ +import pytest + +pytestmark = pytest.mark.usefixtures("add_initial_liquidity") + + +def test_redemption_price(chain, bob, swap, initial_amounts, n_coins, redemption_price_snap): + redemption_price = redemption_price_snap.snappedRedemptionPrice() + + chain.sleep(86400) + chain.mine() + + assert redemption_price == redemption_price_snap.snappedRedemptionPrice() + + redemption_price += 1e25 + redemption_price_snap.setRedemptionPriceSnap(redemption_price) + + chain.sleep(86400) + chain.mine() + + assert redemption_price == redemption_price_snap.snappedRedemptionPrice() \ No newline at end of file diff --git a/tests/zaps/meta/integration/test_remove_liquidity_imbalance_zap.py b/tests/zaps/meta/integration/test_remove_liquidity_imbalance_zap_meta.py similarity index 100% rename from tests/zaps/meta/integration/test_remove_liquidity_imbalance_zap.py rename to tests/zaps/meta/integration/test_remove_liquidity_imbalance_zap_meta.py