From 040fb5331a1675b171381392fbf88e3ed95d8a14 Mon Sep 17 00:00:00 2001 From: Phil Lu Date: Mon, 23 Oct 2023 14:11:35 -0400 Subject: [PATCH] Enable `_tweak_price` for 3-coin Cryptopools --- curvesim/pool/cryptoswap/pool.py | 69 ++++++--- test/unit/test_cryptopool.py | 9 +- test/unit/test_tricrypto.py | 241 +++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 26 deletions(-) diff --git a/curvesim/pool/cryptoswap/pool.py b/curvesim/pool/cryptoswap/pool.py index 6cbc045fb..7bd39d9ef 100644 --- a/curvesim/pool/cryptoswap/pool.py +++ b/curvesim/pool/cryptoswap/pool.py @@ -294,9 +294,9 @@ def _tweak_price( # noqa: complexity: 12 A: int, gamma: int, _xp: List[int], - i: int, + i: Optional[int], p_i: Optional[int], - new_D: int, + new_D: Optional[int], K0_prev: int = 0, ) -> None: """ @@ -304,10 +304,16 @@ def _tweak_price( # noqa: complexity: 12 - EMA price update: price_oracle - Profit counters: D, virtual_price, xcp_profit - price adjustment: price_scale - - If p_i is None, the spot price will be used as the last price Also claims admin fees if appropriate (enough profit and price scale - and oracle is close enough). + and oracle are close enough). + + Note + ----- + - Always pass in p_i and i as None if self.n == 3. i and p_i are only + meaningful when self.n == 2 because all last prices are calculated at once when + self.n == 3. + - If p_i is None, the spot price(s) will be used as the last price(s). """ price_oracle: List[int] = self._price_oracle last_prices: List[int] = self.last_prices @@ -342,9 +348,12 @@ def _tweak_price( # noqa: complexity: 12 self._price_oracle = price_oracle self.last_prices_timestamp = block_timestamp - D_unadjusted: int = new_D # Withdrawal methods know new D already - if new_D == 0: - D_unadjusted = newton_D(A, gamma, _xp, K0_prev) + if new_D is None: + D_unadjusted: int = newton_D(A, gamma, _xp, K0_prev) + elif new_D > 0: + D_unadjusted = new_D # Withdrawal methods know new D already + else: + raise CalculationError(f"new_D cannot be {new_D}.") if p_i is None: if n_coins == 2: @@ -361,20 +370,29 @@ def _tweak_price( # noqa: complexity: 12 ) for k in range(1, n_coins) ] - else: + elif n_coins == 3: last_prices = get_p(_xp, D_unadjusted, A, gamma) last_prices = [ last_p * p // 10**18 for last_p, p in zip(last_prices, price_scale) ] elif p_i > 0: - # Save the last price - if i > 0: - last_prices[i - 1] = p_i - else: - # If 0th price changed - change all prices instead - for k in range(n_coins - 1): - last_prices[k] = last_prices[k] * 10**18 // p_i + if n_coins == 2: + if i is None: + raise CalculationError( + "i cannot be None when p_i is provided for 2-coin pools." + ) + elif i > 0: + # Save the last price + last_prices[i - 1] = p_i + else: + # If 0th price changed - change all prices instead + for k in range(n_coins - 1): + last_prices[k] = last_prices[k] * 10**18 // p_i + elif n_coins == 3: + raise CalculationError( + "Always pass p_i (last price) as None for 3-coin pools." + ) else: raise CalculationError(f"p_i (last price) cannot be {p_i}.") @@ -398,6 +416,10 @@ def _tweak_price( # noqa: complexity: 12 if virtual_price < old_virtual_price: raise CryptoPoolError("Loss") + # 3-coin cryptopool does this, but 2-coin cryptopool doesn't + if n_coins == 3 and virtual_price == old_virtual_price: + raise CryptoPoolError("Loss") + xcp_profit = old_xcp_profit * virtual_price // old_virtual_price self.xcp_profit = xcp_profit @@ -610,7 +632,6 @@ def _exchange( A = self.A gamma = self.gamma xp: List[int] = self.balances.copy() - ix: int = j y: int = xp[j] xp[i] += dx @@ -645,14 +666,14 @@ def _exchange( xp[j] = y p: Optional[int] = None + ix: Optional[int] = None K0_prev: int = 0 if self.n == 2: if dx > 10**5 and dy > 10**5: + ix = j _dx: int = dx * prec_i _dy: int = dy * prec_j - if i != 0 and j != 0: - p = self.last_prices[i - 1] * _dx // _dy - elif i == 0: + if i == 0: p = _dx * 10**18 // _dy else: # j == 0 p = _dy * 10**18 // _dx @@ -660,7 +681,7 @@ def _exchange( else: K0_prev = y_out[1] - self._tweak_price(A, gamma, xp, ix, p, 0, K0_prev) + self._tweak_price(A, gamma, xp, ix, p, None, K0_prev) return dy, fee @@ -768,7 +789,7 @@ def add_liquidity( # = sum{k!=i}(p_k * (dtoken / token_supply * xx_k - dx_k)) # only ix is nonzero p: Optional[int] = None - ix: int = -1 + ix: Optional[int] = None if d_token > 10**5: nonzero_indices = [i for i, a in enumerate(amounts) if a != 0] if len(nonzero_indices) == 1: @@ -896,7 +917,11 @@ def remove_liquidity_one_coin( self.balances[i] -= dy self.tokens -= token_amount - self._tweak_price(A, gamma, xp, i, p, D) + ix: Optional[int] = None + if self.n == 2: + ix = i + + self._tweak_price(A, gamma, xp, ix, p, D) return dy diff --git a/test/unit/test_cryptopool.py b/test/unit/test_cryptopool.py index 3f54b709d..f78854ebc 100644 --- a/test/unit/test_cryptopool.py +++ b/test/unit/test_cryptopool.py @@ -337,6 +337,7 @@ def test_get_y(vyper_cryptopool, A, gamma, x0, x1, i, delta_perc): def test_tweak_price( vyper_cryptopool, cryptopool_lp_token, A, gamma, x0, x_perc, price_perc ): + """Test _tweak_price against vyper implementation.""" # def test_tweak_price(vyper_cryptopool, cryptopool_lp_token): # A = 4196 # gamma = 10000050055 @@ -392,7 +393,7 @@ def test_tweak_price( pool._tweak_price(A, gamma, xp, 1, 0, 0) except Exception as err: assert isinstance(err, CalculationError) - pool._tweak_price(A, gamma, xp, 1, last_price, 0) + pool._tweak_price(A, gamma, xp, 1, last_price, None) vyper_cryptopool.eval(f"self.tweak_price({A_gamma}, {xp}, {last_price}, 0)") assert pool.price_scale == [vyper_cryptopool.price_scale()] @@ -412,7 +413,7 @@ def test_tweak_price( old_scale = pool.price_scale old_virtual_price = pool.virtual_price - pool._tweak_price(A, gamma, xp, 1, last_price, 0) + pool._tweak_price(A, gamma, xp, 1, last_price, None) vyper_cryptopool.eval(f"self.tweak_price({A_gamma}, {xp}, {last_price}, 0)") # check the pools are the same @@ -440,7 +441,7 @@ def test_tweak_price( xp[0] = xp[0] + pool.allowed_extra_profit // 10 # omitting price will calculate the spot price in `tweak_price` - pool._tweak_price(A, gamma, xp, 1, None, 0) + pool._tweak_price(A, gamma, xp, 1, None, None) vyper_cryptopool.eval(f"self.tweak_price({A_gamma}, {xp}, 0, 0)") assert pool.price_scale == [vyper_cryptopool.price_scale()] @@ -459,7 +460,7 @@ def test_tweak_price( xp[0] = xp[0] * 115 // 100 # omitting price will calculate the spot price in `tweak_price` - pool._tweak_price(A, gamma, xp, 1, None, 0) + pool._tweak_price(A, gamma, xp, 1, None, None) vyper_cryptopool.eval(f"self.tweak_price({A_gamma}, {xp}, 0, 0)") assert pool.price_scale == [vyper_cryptopool.price_scale()] diff --git a/test/unit/test_tricrypto.py b/test/unit/test_tricrypto.py index 9a94563bd..da7ee92ad 100644 --- a/test/unit/test_tricrypto.py +++ b/test/unit/test_tricrypto.py @@ -22,6 +22,8 @@ ) from ..fixtures.pool import pack_prices, unpack_prices +from .test_cryptopool import pack_A_gamma +from curvesim.exceptions import CalculationError def initialize_pool(vyper_tricrypto): @@ -643,3 +645,242 @@ def test_claim_admin_fees(vyper_tricrypto, tricrypto_math): assert expected_xcp_profit == pool.xcp_profit == vyper_tricrypto.xcp_profit() assert expected_xcp_profit == pool.xcp_profit_a == vyper_tricrypto.xcp_profit_a() assert expected_vprice == pool.virtual_price == vyper_tricrypto.virtual_price() + + +@given( + amplification_coefficient, + gamma_coefficient, + st.integers(min_value=0, max_value=2), + st.integers(min_value=0, max_value=2), + st.integers(min_value=1, max_value=50), + lp_tokens, +) +@settings( + suppress_health_check=[ + HealthCheck.function_scoped_fixture, + HealthCheck.filter_too_much, + HealthCheck.data_too_large, + ], + max_examples=5, + deadline=None, +) +def test_tweak_price( + vyper_tricrypto, + tricrypto_math, + A, + gamma, + i, + j, + dx_perc, + lp_amount, +): + """Test _tweak_price against vyper implementation.""" + n_coins = 3 + assume(i != j) + # withdraw only one coin (up to 1/n of total deposits) + assume(lp_amount < vyper_tricrypto.totalSupply() // n_coins) + + balances = [vyper_tricrypto.balances(i) for i in range(n_coins)] + xp = vyper_tricrypto.eval("self.xp()") + xp = list(xp) + + A_gamma = [A, gamma] + # need to set A_gamma since this is read when claiming admin fees + A_gamma_packed = pack_A_gamma(A, gamma) + vyper_tricrypto.eval(f"self.future_A_gamma={A_gamma_packed}") + + # need to update cached `D` and `virtual_price` + # (this requires adjusting LP token supply for consistency) + D = tricrypto_math.newton_D(A, gamma, xp) + vyper_tricrypto.eval(f"self.D={D}") + + totalSupply = vyper_tricrypto.eval(f"self.get_xcp({D})") + vyper_tricrypto.eval(f"self.totalSupply={totalSupply}") + # virtual price can't be below 10**18 + vyper_tricrypto.eval("self.virtual_price=10**18") + # reset profit counter also + vyper_tricrypto.eval("self.xcp_profit=10**18") + vyper_tricrypto.eval("self.xcp_profit_a=10**18") + + pool = initialize_pool(vyper_tricrypto) + + # pass in non-None p_i to trip the CalculationError + p_i = 10**18 if i == 0 else pool.last_prices[i - 1] + # pylint: disable=protected-access + try: + pool._tweak_price(A, gamma, xp, i, p_i, None) + except Exception as err: + assert isinstance(err, CalculationError) + + old_xp = xp.copy() + old_scale = pool.price_scale.copy() + old_oracle = pool._price_oracle.copy() + old_virtual_price = pool.virtual_price + + # donations will bork the pool's calcs, so take a snapshot here to revert to later + snapshot = pool.get_snapshot() + + with boa.env.anchor(): + # donate to the pool but not enough to adjust the price scale + xp[i] += pool.allowed_extra_profit // 10 + + pool._tweak_price(A, gamma, xp, None, None, None) + vyper_tricrypto.eval(f"self.tweak_price({A_gamma}, {xp}, 0, 0)") + + expected_price_scale = [ + vyper_tricrypto.price_scale(i) for i in range(n_coins - 1) + ] + expected_price_oracle = [ + vyper_tricrypto.price_oracle(i) for i in range(n_coins - 1) + ] + expected_last_prices = [ + vyper_tricrypto.last_prices(i) for i in range(n_coins - 1) + ] + + assert pool.price_scale == expected_price_scale + assert pool._price_oracle == expected_price_oracle + assert pool.last_prices == expected_last_prices + + assert pool.D == vyper_tricrypto.D() + assert pool.virtual_price == vyper_tricrypto.virtual_price() + assert pool.xcp_profit == vyper_tricrypto.xcp_profit() + + # profit increased but not enough to adjust the price scale + assert pool.virtual_price > old_virtual_price + assert pool.price_oracle != old_oracle + assert pool.price_scale == old_scale + + # revert xp + xp = old_xp.copy() + + # donate enough to the pool to change the price scale + xp[i] += xp[i] * dx_perc // 100 + + old_oracle = pool._price_oracle.copy() + old_virtual_price = pool.virtual_price + + pool._tweak_price(A, gamma, xp, None, None, None) + vyper_tricrypto.eval(f"self.tweak_price({A_gamma}, {xp}, 0, 0)") + + expected_price_scale = [ + vyper_tricrypto.price_scale(i) for i in range(n_coins - 1) + ] + expected_price_oracle = [ + vyper_tricrypto.price_oracle(i) for i in range(n_coins - 1) + ] + expected_last_prices = [ + vyper_tricrypto.last_prices(i) for i in range(n_coins - 1) + ] + + assert pool.price_scale == expected_price_scale + assert pool._price_oracle == expected_price_oracle + assert pool.last_prices == expected_last_prices + + assert pool.D == vyper_tricrypto.D() + assert pool.virtual_price == vyper_tricrypto.virtual_price() + assert pool.xcp_profit == vyper_tricrypto.xcp_profit() + + # profit increased from donation + assert pool.virtual_price > old_virtual_price + assert pool.price_oracle != old_oracle + + pool.revert_to_snapshot(snapshot) + + # exchange + old_oracle = pool._price_oracle.copy() + + dx = pool.balances[i] * dx_perc // 100 + + # tweak_price is called after all calculations in exchange + pool.exchange(i, j, dx, 0) + vyper_tricrypto.exchange(i, j, dx, 0) + + expected_price_scale = [vyper_tricrypto.price_scale(i) for i in range(n_coins - 1)] + expected_price_oracle = [ + vyper_tricrypto.price_oracle(i) for i in range(n_coins - 1) + ] + expected_last_prices = [vyper_tricrypto.last_prices(i) for i in range(n_coins - 1)] + + assert pool.price_scale == expected_price_scale + assert pool._price_oracle == expected_price_oracle + assert pool.last_prices == expected_last_prices + + assert pool.D == vyper_tricrypto.D() + assert pool.virtual_price == vyper_tricrypto.virtual_price() + assert pool.xcp_profit == vyper_tricrypto.xcp_profit() + + # can't guarantee that profit increases from rebalancing + assert pool.price_oracle != old_oracle + + # imbalanced deposit + old_oracle = pool._price_oracle.copy() + + balances = pool.balances.copy() + balances[i] += balances[i] * dx_perc // 100 + + old_xp = pool._xp().copy() + xp = pool._xp_mem(balances) + + amountsp = [xp[i] - old_xp[i] for i in range(n_coins)] + + new_D = newton_D(A, gamma, xp) + total_supply = pool.tokens + d_token = total_supply * new_D // pool.D - total_supply + d_token_fee = pool._calc_token_fee(amountsp, xp) + d_token -= d_token_fee + + pool.balances[i] = balances[i] + pool.tokens += d_token + + vyper_tricrypto.eval(f"self.balances[{i}]={balances[i]}") + vyper_tricrypto.eval(f"self.totalSupply+={d_token}") + + pool._tweak_price(A, gamma, xp, None, None, None) + vyper_tricrypto.eval(f"self.tweak_price({A_gamma}, {xp}, 0)") + + expected_price_scale = [vyper_tricrypto.price_scale(i) for i in range(n_coins - 1)] + expected_price_oracle = [ + vyper_tricrypto.price_oracle(i) for i in range(n_coins - 1) + ] + expected_last_prices = [vyper_tricrypto.last_prices(i) for i in range(n_coins - 1)] + + assert pool.price_scale == expected_price_scale + assert pool._price_oracle == expected_price_oracle + assert pool.last_prices == expected_last_prices + + assert pool.D == vyper_tricrypto.D() + assert pool.virtual_price == vyper_tricrypto.virtual_price() + assert pool.xcp_profit == vyper_tricrypto.xcp_profit() + + # can't guarantee that profit increases from rebalancing + assert pool.price_oracle != old_oracle + + # imbalanced withdrawal + old_oracle = pool._price_oracle.copy() + + dy, _, D, xp = pool._calc_withdraw_one_coin(A, gamma, lp_amount, i, False, False) + + pool.balances[i] -= dy + pool.tokens -= lp_amount + pool._tweak_price(A, gamma, xp, None, None, D) + + vyper_tricrypto.eval(f"self.balances[{i}]-={dy}") + vyper_tricrypto.eval(f"self.totalSupply-={lp_amount}") + vyper_tricrypto.eval(f"self.tweak_price({A_gamma}, {xp}, {D})") + + expected_price_scale = [vyper_tricrypto.price_scale(i) for i in range(n_coins - 1)] + expected_price_oracle = [ + vyper_tricrypto.price_oracle(i) for i in range(n_coins - 1) + ] + expected_last_prices = [vyper_tricrypto.last_prices(i) for i in range(n_coins - 1)] + + assert pool.price_scale == expected_price_scale + assert pool._price_oracle == expected_price_oracle + assert pool.last_prices == expected_last_prices + + assert pool.D == vyper_tricrypto.D() + assert pool.virtual_price == vyper_tricrypto.virtual_price() + assert pool.xcp_profit == vyper_tricrypto.xcp_profit() + + # can't guarantee that profit increases from rebalancing + assert pool.price_oracle != old_oracle