diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 268e6cb8..49143321 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -17,7 +17,7 @@ jobs: with: path: | ~/.vvm - key: compiler-cache + key: compiler-cache-${{ hashFiles('**/requirements.txt') }} - name: Setup Python 3.10.4 uses: actions/setup-python@v2 diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index da34f219..1fcced00 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -6,7 +6,7 @@ env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} jobs: - boa-tests: + unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -16,7 +16,7 @@ jobs: with: path: | ~/.vvm - key: compiler-cache + key: compiler-cache-${{ hashFiles('**/requirements.txt') }} - name: Setup Python 3.10.4 uses: actions/setup-python@v2 @@ -27,4 +27,50 @@ jobs: run: pip install -r requirements.txt - name: Run Tests - run: python -m pytest tests/unitary -n auto + run: python -m pytest tests/unitary -n auto --ignore=tests/unitary/pool/stateful --ignore=tests/unitary/math + + stateful-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Cache Compiler Installations + uses: actions/cache@v2 + with: + path: | + ~/.vvm + key: compiler-cache-${{ hashFiles('**/requirements.txt') }} + + - name: Setup Python 3.10.4 + uses: actions/setup-python@v2 + with: + python-version: 3.10.4 + + - name: Install Requirements + run: pip install -r requirements.txt + + - name: Run Stateful Tests + run: python -m pytest tests/unitary/pool/stateful -n auto --ignore=tests/unitary/pool/stateful/legacy + + math-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Cache Compiler Installations + uses: actions/cache@v2 + with: + path: | + ~/.vvm + key: compiler-cache-${{ hashFiles('**/requirements.txt') }} + + - name: Setup Python 3.10.4 + uses: actions/setup-python@v2 + with: + python-version: 3.10.4 + + - name: Install Requirements + run: pip install -r requirements.txt + + - name: Run Tests + run: python -m pytest tests/unitary/math -n auto diff --git a/.gitignore b/.gitignore index f4677ea1..e32b67ea 100644 --- a/.gitignore +++ b/.gitignore @@ -139,7 +139,6 @@ reports/ .idea **/.idea .vscode -*.csv # misc /data diff --git a/contracts/experimental/initial_guess/CurveCryptoMathOptimized2.vy b/contracts/experimental/initial_guess/CurveCryptoMathOptimized2.vy new file mode 100644 index 00000000..1c00f83a --- /dev/null +++ b/contracts/experimental/initial_guess/CurveCryptoMathOptimized2.vy @@ -0,0 +1,587 @@ +# pragma version 0.3.10 +# pragma optimize gas +# pragma evm-version paris +# (c) Curve.Fi, 2020-2023 +# AMM Math for 2-coin Curve Cryptoswap Pools +# +# Unless otherwise agreed on, only contracts owned by Curve DAO or +# Swiss Stake GmbH are allowed to call this contract. + +""" +@title CurveTwocryptoMathOptimized +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2020-2023 - all rights reserved +@notice Curve AMM Math for 2 unpegged assets (e.g. ETH <> USD). +""" + +N_COINS: constant(uint256) = 2 +A_MULTIPLIER: constant(uint256) = 10000 + +MIN_GAMMA: constant(uint256) = 10**10 +MAX_GAMMA_SMALL: constant(uint256) = 2 * 10**16 +MAX_GAMMA: constant(uint256) = 199 * 10**15 # 1.99 * 10**17 + +MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 10 +MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000 + +version: public(constant(String[8])) = "v2.0.0" + + +# ------------------------ AMM math functions -------------------------------- + + +@internal +@pure +def _snekmate_log_2(x: uint256, roundup: bool) -> uint256: + """ + @notice An `internal` helper function that returns the log in base 2 + of `x`, following the selected rounding direction. + @dev This implementation is derived from Snekmate, which is authored + by pcaversaccio (Snekmate), distributed under the AGPL-3.0 license. + https://github.com/pcaversaccio/snekmate + @dev Note that it returns 0 if given 0. The implementation is + inspired by OpenZeppelin's implementation here: + https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/math/Math.sol. + @param x The 32-byte variable. + @param roundup The Boolean variable that specifies whether + to round up or not. The default `False` is round down. + @return uint256 The 32-byte calculation result. + """ + value: uint256 = x + result: uint256 = empty(uint256) + + # The following lines cannot overflow because we have the well-known + # decay behaviour of `log_2(max_value(uint256)) < max_value(uint256)`. + if x >> 128 != empty(uint256): + value = x >> 128 + result = 128 + if value >> 64 != empty(uint256): + value = value >> 64 + result = unsafe_add(result, 64) + if value >> 32 != empty(uint256): + value = value >> 32 + result = unsafe_add(result, 32) + if value >> 16 != empty(uint256): + value = value >> 16 + result = unsafe_add(result, 16) + if value >> 8 != empty(uint256): + value = value >> 8 + result = unsafe_add(result, 8) + if value >> 4 != empty(uint256): + value = value >> 4 + result = unsafe_add(result, 4) + if value >> 2 != empty(uint256): + value = value >> 2 + result = unsafe_add(result, 2) + if value >> 1 != empty(uint256): + result = unsafe_add(result, 1) + + if (roundup and (1 << result) < x): + result = unsafe_add(result, 1) + + return result + + +@internal +@pure +def _cbrt(x: uint256) -> uint256: + + xx: uint256 = 0 + if x >= 115792089237316195423570985008687907853269 * 10**18: + xx = x + elif x >= 115792089237316195423570985008687907853269: + xx = unsafe_mul(x, 10**18) + else: + xx = unsafe_mul(x, 10**36) + + log2x: int256 = convert(self._snekmate_log_2(xx, False), int256) + + # When we divide log2x by 3, the remainder is (log2x % 3). + # So if we just multiply 2**(log2x/3) and discard the remainder to calculate our + # guess, the newton method will need more iterations to converge to a solution, + # since it is missing that precision. It's a few more calculations now to do less + # calculations later: + # pow = log2(x) // 3 + # remainder = log2(x) % 3 + # initial_guess = 2 ** pow * cbrt(2) ** remainder + # substituting -> 2 = 1.26 ≈ 1260 / 1000, we get: + # + # initial_guess = 2 ** pow * 1260 ** remainder // 1000 ** remainder + + remainder: uint256 = convert(log2x, uint256) % 3 + a: uint256 = unsafe_div( + unsafe_mul( + pow_mod256(2, unsafe_div(convert(log2x, uint256), 3)), # <- pow + pow_mod256(1260, remainder), + ), + pow_mod256(1000, remainder), + ) + + # Because we chose good initial values for cube roots, 7 newton raphson iterations + # are just about sufficient. 6 iterations would result in non-convergences, and 8 + # would be one too many iterations. Without initial values, the iteration count + # can go up to 20 or greater. The iterations are unrolled. This reduces gas costs + # but takes up more bytecode: + a = unsafe_div(unsafe_add(unsafe_mul(2, a), unsafe_div(xx, unsafe_mul(a, a))), 3) + a = unsafe_div(unsafe_add(unsafe_mul(2, a), unsafe_div(xx, unsafe_mul(a, a))), 3) + a = unsafe_div(unsafe_add(unsafe_mul(2, a), unsafe_div(xx, unsafe_mul(a, a))), 3) + a = unsafe_div(unsafe_add(unsafe_mul(2, a), unsafe_div(xx, unsafe_mul(a, a))), 3) + a = unsafe_div(unsafe_add(unsafe_mul(2, a), unsafe_div(xx, unsafe_mul(a, a))), 3) + a = unsafe_div(unsafe_add(unsafe_mul(2, a), unsafe_div(xx, unsafe_mul(a, a))), 3) + a = unsafe_div(unsafe_add(unsafe_mul(2, a), unsafe_div(xx, unsafe_mul(a, a))), 3) + + if x >= 115792089237316195423570985008687907853269 * 10**18: + a = unsafe_mul(a, 10**12) + elif x >= 115792089237316195423570985008687907853269: + a = unsafe_mul(a, 10**6) + + return a + + +@internal +@pure +def _newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: uint256, lim_mul: uint256) -> uint256: + """ + Calculating x[i] given other balances x[0..N_COINS-1] and invariant D + ANN = A * N**N + This is computationally expensive. + """ + + x_j: uint256 = x[1 - i] + y: uint256 = D**2 / (x_j * N_COINS**2) + K0_i: uint256 = (10**18 * N_COINS) * x_j / D + + assert (K0_i >= unsafe_div(10**36, lim_mul)) and (K0_i <= lim_mul) # dev: unsafe values x[i] + + convergence_limit: uint256 = max(max(x_j / 10**14, D / 10**14), 100) + + for j in range(255): + y_prev: uint256 = y + + K0: uint256 = K0_i * y * N_COINS / D + S: uint256 = x_j + 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): + return y + + raise "Did not converge" + + +@external +@pure +def newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: uint256) -> uint256: + + # 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 + lim_mul: uint256 = 100 * 10**18 # 100.0 + if gamma > MAX_GAMMA_SMALL: + lim_mul = unsafe_div(unsafe_mul(lim_mul, MAX_GAMMA_SMALL), gamma) # smaller than 100.0 + + y: uint256 = self._newton_y(ANN, gamma, x, D, i, lim_mul) + frac: uint256 = y * 10**18 / D + assert (frac >= unsafe_div(10**36 / N_COINS, lim_mul)) and (frac <= unsafe_div(lim_mul, N_COINS)) # dev: unsafe value for y + + return y + + +@external +@pure +def get_y( + _ANN: uint256, + _gamma: uint256, + _x: uint256[N_COINS], + _D: uint256, + i: uint256 +) -> uint256[2]: + + # 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 + lim_mul: uint256 = 100 * 10**18 # 100.0 + if _gamma > MAX_GAMMA_SMALL: + lim_mul = unsafe_div(unsafe_mul(lim_mul, MAX_GAMMA_SMALL), _gamma) # smaller than 100.0 + lim_mul_signed: int256 = convert(lim_mul, int256) + + ANN: int256 = convert(_ANN, int256) + gamma: int256 = convert(_gamma, int256) + D: int256 = convert(_D, int256) + x_j: int256 = convert(_x[1 - i], int256) + gamma2: int256 = unsafe_mul(gamma, gamma) + + # savediv by x_j done here: + y: int256 = D**2 / (x_j * N_COINS**2) + + # K0_i: int256 = (10**18 * N_COINS) * x_j / D + K0_i: int256 = unsafe_div(10**18 * N_COINS * x_j, D) + assert (K0_i >= unsafe_div(10**36, lim_mul_signed)) and (K0_i <= lim_mul_signed) # dev: unsafe values x[i] + + ann_gamma2: int256 = ANN * gamma2 + + # a = 10**36 / N_COINS**2 + a: int256 = 10**32 + + # b = ANN*D*gamma2/4/10000/x_j/10**4 - 10**32*3 - 2*gamma*10**14 + b: int256 = ( + D*ann_gamma2/400000000/x_j + - convert(unsafe_mul(10**32, 3), int256) + - unsafe_mul(unsafe_mul(2, gamma), 10**14) + ) + + # c = 10**32*3 + 4*gamma*10**14 + gamma2/10**4 + 4*ANN*gamma2*x_j/D/10000/4/10**4 - 4*ANN*gamma2/10000/4/10**4 + c: int256 = ( + unsafe_mul(10**32, convert(3, int256)) + + unsafe_mul(unsafe_mul(4, gamma), 10**14) + + unsafe_div(gamma2, 10**4) + + unsafe_div(unsafe_div(unsafe_mul(4, ann_gamma2), 400000000) * x_j, D) + - unsafe_div(unsafe_mul(4, ann_gamma2), 400000000) + ) + + # d = -(10**18+gamma)**2 / 10**4 + d: int256 = -unsafe_div(unsafe_add(10**18, gamma) ** 2, 10**4) + + # delta0: int256 = 3*a*c/b - b + delta0: int256 = 3 * a * c / b - b # safediv by b + + # delta1: int256 = 9*a*c/b - 2*b - 27*a**2/b*d/b + delta1: int256 = 3 * delta0 + b - 27*a**2/b*d/b + + divider: int256 = 1 + threshold: int256 = min(min(abs(delta0), abs(delta1)), a) + if threshold > 10**48: + divider = 10**30 + elif threshold > 10**46: + divider = 10**28 + elif threshold > 10**44: + divider = 10**26 + elif threshold > 10**42: + divider = 10**24 + elif threshold > 10**40: + divider = 10**22 + elif threshold > 10**38: + divider = 10**20 + elif threshold > 10**36: + divider = 10**18 + elif threshold > 10**34: + divider = 10**16 + elif threshold > 10**32: + divider = 10**14 + elif threshold > 10**30: + divider = 10**12 + elif threshold > 10**28: + divider = 10**10 + elif threshold > 10**26: + divider = 10**8 + elif threshold > 10**24: + divider = 10**6 + elif threshold > 10**20: + divider = 10**2 + + a = unsafe_div(a, divider) + b = unsafe_div(b, divider) + c = unsafe_div(c, divider) + d = unsafe_div(d, divider) + + # delta0 = 3*a*c/b - b: here we can do more unsafe ops now: + delta0 = unsafe_div(unsafe_mul(unsafe_mul(3, a), c), b) - b + + # delta1 = 9*a*c/b - 2*b - 27*a**2/b*d/b + delta1 = 3 * delta0 + b - unsafe_div(unsafe_mul(unsafe_div(unsafe_mul(27, a**2), b), d), b) + + # sqrt_arg: int256 = delta1**2 + 4*delta0**2/b*delta0 + sqrt_arg: int256 = delta1**2 + unsafe_mul(unsafe_div(4*delta0**2, b), delta0) + sqrt_val: int256 = 0 + if sqrt_arg > 0: + sqrt_val = convert(isqrt(convert(sqrt_arg, uint256)), int256) + else: + return [ + self._newton_y(_ANN, _gamma, _x, _D, i, lim_mul), + 0 + ] + + b_cbrt: int256 = 0 + if b > 0: + b_cbrt = convert(self._cbrt(convert(b, uint256)), int256) + else: + b_cbrt = -convert(self._cbrt(convert(-b, uint256)), int256) + + second_cbrt: int256 = 0 + if delta1 > 0: + # second_cbrt = convert(self._cbrt(convert((delta1 + sqrt_val), uint256) / 2), int256) + second_cbrt = convert(self._cbrt(convert(unsafe_add(delta1, sqrt_val), uint256) / 2), int256) + else: + # second_cbrt = -convert(self._cbrt(convert(unsafe_sub(sqrt_val, delta1), uint256) / 2), int256) + second_cbrt = -convert(self._cbrt(unsafe_div(convert(unsafe_sub(sqrt_val, delta1), uint256), 2)), int256) + + # C1: int256 = b_cbrt**2/10**18*second_cbrt/10**18 + C1: int256 = unsafe_div(unsafe_mul(unsafe_div(b_cbrt**2, 10**18), second_cbrt), 10**18) + + # root: int256 = (10**18*C1 - 10**18*b - 10**18*b*delta0/C1)/(3*a), keep 2 safe ops here. + root: int256 = (unsafe_mul(10**18, C1) - unsafe_mul(10**18, b) - unsafe_mul(10**18, b)/C1*delta0)/unsafe_mul(3, a) + + # y_out: uint256[2] = [ + # convert(D**2/x_j*root/4/10**18, uint256), # <--- y + # convert(root, uint256) # <----------------------- K0Prev + # ] + y_out: uint256[2] = [convert(unsafe_div(unsafe_div(unsafe_mul(unsafe_div(D**2, x_j), root), 4), 10**18), uint256), convert(root, uint256)] + + frac: uint256 = unsafe_div(y_out[0] * 10**18, _D) + assert (frac >= unsafe_div(10**36 / N_COINS, lim_mul)) and (frac <= unsafe_div(lim_mul, N_COINS)) # dev: unsafe value for y + + return y_out + + +@external +@view +def newton_D(ANN: uint256, gamma: uint256, x_unsorted: uint256[N_COINS], initial_D: uint256 = 0) -> uint256: + """ + Finding the invariant using Newton method. + ANN is higher by the factor A_MULTIPLIER + ANN is already 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 + + # Initial value of invariant D is that for constant-product invariant + x: uint256[N_COINS] = x_unsorted + if x[0] < x[1]: + x = [x_unsorted[1], x_unsorted[0]] + + assert x[0] > 10**9 - 1 and x[0] < 10**15 * 10**18 + 1 # dev: unsafe values x[0] + assert unsafe_div(x[1] * 10**18, x[0]) > 10**14 - 1 # dev: unsafe values x[i] (input) + + S: uint256 = unsafe_add(x[0], x[1]) # can unsafe add here because we checked x[0] bounds + + D: uint256 = initial_D + if D == 0: + D = N_COINS * isqrt(unsafe_mul(x[0], x[1])) + else: + # initial_D = isqrt(x[0] * x[1] * 4 / K0_prev * 10**18) + # K0_prev is derived from from get_y + if S < D: + D = S + + __g1k0: uint256 = gamma + 10**18 + diff: uint256 = 0 + + for i in range(255): + D_prev: uint256 = D + assert D > 0 + # Unsafe division by D and D_prev is now safe + + # K0: uint256 = 10**18 + # for _x in x: + # K0 = K0 * _x * N_COINS / D + # collapsed for 2 coins + K0: uint256 = unsafe_div(unsafe_div((10**18 * N_COINS**2) * x[0], D) * x[1], D) + + _g1k0: uint256 = __g1k0 + if _g1k0 > K0: + _g1k0 = unsafe_add(unsafe_sub(_g1k0, K0), 1) # > 0 + else: + _g1k0 = unsafe_add(unsafe_sub(K0, _g1k0), 1) # > 0 + # K0 is greater than 0 + # _g1k0 is greater than 0 + + # D / (A * N**N) * _g1k0**2 / gamma**2 + mul1: uint256 = unsafe_div(unsafe_div(unsafe_div(10**18 * D, gamma) * _g1k0, gamma) * _g1k0 * A_MULTIPLIER, ANN) + + # 2*N*K0 / _g1k0 + mul2: uint256 = unsafe_div(((2 * 10**18) * N_COINS) * K0, _g1k0) + + # calculate neg_fprime. here K0 > 0 is being validated (safediv). + neg_fprime: uint256 = ( + S + + unsafe_div(S * mul2, 10**18) + + mul1 * N_COINS / K0 - + unsafe_div(mul2 * D, 10**18) + ) + + # D -= f / fprime; neg_fprime safediv being validated + D_plus: uint256 = D * (neg_fprime + S) / neg_fprime + D_minus: uint256 = unsafe_div(D * D, neg_fprime) + if 10**18 > K0: + D_minus += unsafe_div(unsafe_div(D * unsafe_div(mul1, neg_fprime), 10**18) * unsafe_sub(10**18, K0), K0) + else: + D_minus -= unsafe_div(unsafe_div(D * unsafe_div(mul1, neg_fprime), 10**18) * unsafe_sub(K0, 10**18), K0) + + if D_plus > D_minus: + D = unsafe_sub(D_plus, D_minus) + else: + D = unsafe_div(unsafe_sub(D_minus, D_plus), 2) + + if D > D_prev: + diff = unsafe_sub(D, D_prev) + else: + diff = unsafe_sub(D_prev, D) + + if diff * 10**14 < max(10**16, D): # Could reduce precision for gas efficiency here + + for _x in x: + frac: uint256 = _x * 10**18 / D + assert (frac > 10**16 / N_COINS - 1) and (frac < 10**20 / N_COINS + 1) # dev: unsafe values x[i] + return D + + raise "Did not converge" + + +@external +@view +def get_p( + _xp: uint256[N_COINS], _D: uint256, _A_gamma: uint256[N_COINS] +) -> uint256: + """ + @notice Calculates dx/dy. + @dev Output needs to be multiplied with price_scale to get the actual value. + @param _xp Balances of the pool. + @param _D Current value of D. + @param _A_gamma Amplification coefficient and gamma. + """ + + assert _D > 10**17 - 1 and _D < 10**15 * 10**18 + 1 # dev: unsafe D values + + # K0 = P * N**N / D**N. + # K0 is dimensionless and has 10**36 precision: + K0: uint256 = unsafe_div( + unsafe_div(4 * _xp[0] * _xp[1], _D) * 10**36, + _D + ) + + # GK0 is in 10**36 precision and is dimensionless. + # GK0 = ( + # 2 * _K0 * _K0 / 10**36 * _K0 / 10**36 + # + (gamma + 10**18)**2 + # - (_K0 * _K0 / 10**36 * (2 * gamma + 3 * 10**18) / 10**18) + # ) + # GK0 is always positive. So the following should never revert: + GK0: uint256 = ( + unsafe_div(unsafe_div(2 * K0 * K0, 10**36) * K0, 10**36) + + pow_mod256(unsafe_add(_A_gamma[1], 10**18), 2) + - unsafe_div( + unsafe_div(pow_mod256(K0, 2), 10**36) * unsafe_add(unsafe_mul(2, _A_gamma[1]), 3 * 10**18), + 10**18 + ) + ) + + # NNAG2 = N**N * A * gamma**2 + NNAG2: uint256 = unsafe_div(unsafe_mul(_A_gamma[0], pow_mod256(_A_gamma[1], 2)), A_MULTIPLIER) + + # denominator = (GK0 + NNAG2 * x / D * _K0 / 10**36) + denominator: uint256 = (GK0 + unsafe_div(unsafe_div(NNAG2 * _xp[0], _D) * K0, 10**36) ) + + # p_xy = x * (GK0 + NNAG2 * y / D * K0 / 10**36) / y * 10**18 / denominator + # p is in 10**18 precision. + return unsafe_div( + _xp[0] * ( GK0 + unsafe_div(unsafe_div(NNAG2 * _xp[1], _D) * K0, 10**36) ) / _xp[1] * 10**18, + denominator + ) + + +@external +@pure +def wad_exp(x: int256) -> int256: + """ + @dev Calculates the natural exponential function of a signed integer with + a precision of 1e18. + @notice Note that this function consumes about 810 gas units. The implementation + is inspired by Remco Bloemen's implementation under the MIT license here: + https://xn--2-umb.com/22/exp-ln. + @param x The 32-byte variable. + @return int256 The 32-byte calculation result. + """ + value: int256 = x + + # If the result is `< 0.5`, we return zero. This happens when we have the following: + # "x <= floor(log(0.5e18) * 1e18) ~ -42e18". + if (x <= -42_139_678_854_452_767_551): + return empty(int256) + + # When the result is "> (2 ** 255 - 1) / 1e18" we cannot represent it as a signed integer. + # This happens when "x >= floor(log((2 ** 255 - 1) / 1e18) * 1e18) ~ 135". + assert x < 135_305_999_368_893_231_589, "Math: wad_exp overflow" + + # `x` is now in the range "(-42, 136) * 1e18". Convert to "(-42, 136) * 2 ** 96" for higher + # intermediate precision and a binary base. This base conversion is a multiplication with + # "1e18 / 2 ** 96 = 5 ** 18 / 2 ** 78". + value = unsafe_div(x << 78, 5 ** 18) + + # Reduce the range of `x` to "(-½ ln 2, ½ ln 2) * 2 ** 96" by factoring out powers of two + # so that "exp(x) = exp(x') * 2 ** k", where `k` is a signer integer. Solving this gives + # "k = round(x / log(2))" and "x' = x - k * log(2)". Thus, `k` is in the range "[-61, 195]". + k: int256 = unsafe_add(unsafe_div(value << 96, 54_916_777_467_707_473_351_141_471_128), 2 ** 95) >> 96 + value = unsafe_sub(value, unsafe_mul(k, 54_916_777_467_707_473_351_141_471_128)) + + # Evaluate using a "(6, 7)"-term rational approximation. Since `p` is monic, + # we will multiply by a scaling factor later. + y: int256 = unsafe_add(unsafe_mul(unsafe_add(value, 1_346_386_616_545_796_478_920_950_773_328), value) >> 96, 57_155_421_227_552_351_082_224_309_758_442) + p: int256 = unsafe_add(unsafe_mul(unsafe_add(unsafe_mul(unsafe_sub(unsafe_add(y, value), 94_201_549_194_550_492_254_356_042_504_812), y) >> 96,\ + 28_719_021_644_029_726_153_956_944_680_412_240), value), 4_385_272_521_454_847_904_659_076_985_693_276 << 96) + + # We leave `p` in the "2 ** 192" base so that we do not have to scale it up + # again for the division. + q: int256 = unsafe_add(unsafe_mul(unsafe_sub(value, 2_855_989_394_907_223_263_936_484_059_900), value) >> 96, 50_020_603_652_535_783_019_961_831_881_945) + q = unsafe_sub(unsafe_mul(q, value) >> 96, 533_845_033_583_426_703_283_633_433_725_380) + q = unsafe_add(unsafe_mul(q, value) >> 96, 3_604_857_256_930_695_427_073_651_918_091_429) + q = unsafe_sub(unsafe_mul(q, value) >> 96, 14_423_608_567_350_463_180_887_372_962_807_573) + q = unsafe_add(unsafe_mul(q, value) >> 96, 26_449_188_498_355_588_339_934_803_723_976_023) + + # The polynomial `q` has no zeros in the range because all its roots are complex. + # No scaling is required, as `p` is already "2 ** 96" too large. Also, + # `r` is in the range "(0.09, 0.25) * 2**96" after the division. + r: int256 = unsafe_div(p, q) + + # To finalise the calculation, we have to multiply `r` by: + # - the scale factor "s = ~6.031367120", + # - the factor "2 ** k" from the range reduction, and + # - the factor "1e18 / 2 ** 96" for the base conversion. + # We do this all at once, with an intermediate result in "2**213" base, + # so that the final right shift always gives a positive value. + + # Note that to circumvent Vyper's safecast feature for the potentially + # negative parameter value `r`, we first convert `r` to `bytes32` and + # subsequently to `uint256`. Remember that the EVM default behaviour is + # to use two's complement representation to handle signed integers. + return convert(unsafe_mul(convert(convert(r, bytes32), uint256), 3_822_833_074_963_236_453_042_738_258_902_158_003_155_416_615_667) >>\ + convert(unsafe_sub(195, k), uint256), int256) diff --git a/contracts/experimental/initial_guess/CurveTwocryptoOptimized.vy b/contracts/experimental/initial_guess/CurveTwocryptoOptimized.vy new file mode 100644 index 00000000..28879303 --- /dev/null +++ b/contracts/experimental/initial_guess/CurveTwocryptoOptimized.vy @@ -0,0 +1,1928 @@ +# pragma version 0.3.10 +# pragma optimize gas +# pragma evm-version paris +""" +@title CurveTwocryptoOptimized +@author Curve.Fi +@license Copyright (c) Curve.Fi, 2023 - all rights reserved +@notice A Curve AMM pool for 2 unpegged assets (e.g. WETH, USD). +@dev All prices in the AMM are with respect to the first token in the pool. +""" + +from vyper.interfaces import ERC20 +implements: ERC20 # <--------------------- AMM contract is also the LP token. + +# --------------------------------- Interfaces ------------------------------- + +interface Math: + def wad_exp(_power: int256) -> uint256: view + def newton_D( + ANN: uint256, + gamma: uint256, + x_unsorted: uint256[N_COINS], + K0_prev: uint256 + ) -> uint256: view + def get_y( + ANN: uint256, + gamma: uint256, + x: uint256[N_COINS], + D: uint256, + i: uint256, + ) -> uint256[2]: view + def get_p( + _xp: uint256[N_COINS], + _D: uint256, + _A_gamma: uint256[2], + ) -> uint256: view + +interface Factory: + def admin() -> address: view + def fee_receiver() -> address: view + def views_implementation() -> address: view + +interface Views: + def calc_token_amount( + amounts: uint256[N_COINS], deposit: bool, swap: address + ) -> uint256: view + def get_dy( + i: uint256, j: uint256, dx: uint256, swap: address + ) -> uint256: view + def get_dx( + i: uint256, j: uint256, dy: uint256, swap: address + ) -> uint256: view + + +# ------------------------------- Events ------------------------------------- + +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: uint256 + tokens_sold: uint256 + bought_id: uint256 + tokens_bought: uint256 + fee: uint256 + packed_price_scale: uint256 + +event AddLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + fee: uint256 + token_supply: uint256 + packed_price_scale: uint256 + +event RemoveLiquidity: + provider: indexed(address) + token_amounts: uint256[N_COINS] + token_supply: uint256 + +event RemoveLiquidityOne: + provider: indexed(address) + token_amount: uint256 + coin_index: uint256 + coin_amount: uint256 + approx_fee: uint256 + packed_price_scale: uint256 + +event NewParameters: + mid_fee: uint256 + out_fee: uint256 + fee_gamma: uint256 + allowed_extra_profit: uint256 + adjustment_step: uint256 + ma_time: uint256 + +event RampAgamma: + initial_A: uint256 + future_A: uint256 + initial_gamma: uint256 + future_gamma: uint256 + initial_time: uint256 + future_time: uint256 + +event StopRampA: + current_A: uint256 + current_gamma: uint256 + time: uint256 + +event ClaimAdminFee: + admin: indexed(address) + tokens: uint256[N_COINS] + + +# ----------------------- Storage/State Variables ---------------------------- + +N_COINS: constant(uint256) = 2 +PRECISION: constant(uint256) = 10**18 # <------- The precision to convert to. +PRECISIONS: immutable(uint256[N_COINS]) + +MATH: public(immutable(Math)) +coins: public(immutable(address[N_COINS])) +factory: public(immutable(Factory)) + +cached_price_scale: uint256 # <------------------------ Internal price scale. +cached_price_oracle: uint256 # <------- Price target given by moving average. + +last_prices: public(uint256) +last_timestamp: public(uint256) # idx 0 is for prices, idx 1 is for xcp. + +initial_A_gamma: public(uint256) +initial_A_gamma_time: public(uint256) + +future_A_gamma: public(uint256) +future_A_gamma_time: public(uint256) # <------ Time when ramping is finished. +# This value is 0 (default) when pool is first deployed, and only gets +# populated by block.timestamp + future_time in `ramp_A_gamma` when the +# ramping process is initiated. After ramping is finished +# (i.e. self.future_A_gamma_time < block.timestamp), the variable is left +# and not set to 0. + +balances: public(uint256[N_COINS]) +D: public(uint256) +xcp_profit: public(uint256) +xcp_profit_a: public(uint256) # <--- Full profit at last claim of admin fees. + +virtual_price: public(uint256) # <------ Cached (fast to read) virtual price. +# The cached `virtual_price` is also used internally. + +# Params that affect how price_scale get adjusted : +packed_rebalancing_params: public(uint256) # <---------- Contains rebalancing +# parameters allowed_extra_profit, adjustment_step, and ma_time. + +# Fee params that determine dynamic fees: +packed_fee_params: public(uint256) # <---- Packs mid_fee, out_fee, fee_gamma. + +ADMIN_FEE: public(constant(uint256)) = 5 * 10**9 # <----- 50% of earned fees. +MIN_FEE: constant(uint256) = 5 * 10**5 # <-------------------------- 0.5 BPS. +MAX_FEE: constant(uint256) = 10 * 10**9 +NOISE_FEE: constant(uint256) = 10**5 # <---------------------------- 0.1 BPS. + +# ----------------------- Admin params --------------------------------------- + +last_admin_fee_claim_timestamp: uint256 +admin_lp_virtual_balance: uint256 + +MIN_RAMP_TIME: constant(uint256) = 86400 +MIN_ADMIN_FEE_CLAIM_INTERVAL: constant(uint256) = 86400 + +A_MULTIPLIER: constant(uint256) = 10000 +MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 10 +MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000 +MAX_A_CHANGE: constant(uint256) = 10 +MIN_GAMMA: constant(uint256) = 10**10 +MAX_GAMMA: constant(uint256) = 199 * 10**15 # 1.99 * 10**17 + +# ----------------------- ERC20 Specific vars -------------------------------- + +name: public(immutable(String[64])) +symbol: public(immutable(String[32])) +decimals: public(constant(uint8)) = 18 +version: public(constant(String[8])) = "v2.0.0" + +balanceOf: public(HashMap[address, uint256]) +allowance: public(HashMap[address, HashMap[address, uint256]]) +totalSupply: public(uint256) +nonces: public(HashMap[address, uint256]) + +EIP712_TYPEHASH: constant(bytes32) = keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)" +) +EIP2612_TYPEHASH: constant(bytes32) = keccak256( + "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" +) +VERSION_HASH: constant(bytes32) = keccak256(version) +NAME_HASH: immutable(bytes32) +CACHED_CHAIN_ID: immutable(uint256) +salt: public(immutable(bytes32)) +CACHED_DOMAIN_SEPARATOR: immutable(bytes32) + + +# ----------------------- Contract ------------------------------------------- + +@external +def __init__( + _name: String[64], + _symbol: String[32], + _coins: address[N_COINS], + _math: address, + _salt: bytes32, + packed_precisions: uint256, + packed_gamma_A: uint256, + packed_fee_params: uint256, + packed_rebalancing_params: uint256, + initial_price: uint256, +): + + MATH = Math(_math) + + factory = Factory(msg.sender) + name = _name + symbol = _symbol + coins = _coins + + PRECISIONS = self._unpack_2(packed_precisions) # <-- Precisions of coins. + + # --------------- Validate A and gamma parameters here and not in factory. + gamma_A: uint256[2] = self._unpack_2(packed_gamma_A) # gamma is at idx 0. + + assert gamma_A[0] > MIN_GAMMA-1 + assert gamma_A[0] < MAX_GAMMA+1 + + assert gamma_A[1] > MIN_A-1 + assert gamma_A[1] < MAX_A+1 + + self.initial_A_gamma = packed_gamma_A + self.future_A_gamma = packed_gamma_A + # ------------------------------------------------------------------------ + + self.packed_rebalancing_params = packed_rebalancing_params # <-- Contains + # rebalancing params: allowed_extra_profit, adjustment_step, + # and ma_exp_time. + + self.packed_fee_params = packed_fee_params # <-------------- Contains Fee + # params: mid_fee, out_fee and fee_gamma. + + self.cached_price_scale = initial_price + self.cached_price_oracle = initial_price + self.last_prices = initial_price + self.last_timestamp = block.timestamp + self.xcp_profit_a = 10**18 + + # Cache DOMAIN_SEPARATOR. If chain.id is not CACHED_CHAIN_ID, then + # DOMAIN_SEPARATOR will be re-calculated each time `permit` is called. + # Otherwise, it will always use CACHED_DOMAIN_SEPARATOR. + # see: `_domain_separator()` for its implementation. + NAME_HASH = keccak256(name) + salt = _salt + CACHED_CHAIN_ID = chain.id + CACHED_DOMAIN_SEPARATOR = keccak256( + _abi_encode( + EIP712_TYPEHASH, + NAME_HASH, + VERSION_HASH, + chain.id, + self, + salt, + ) + ) + + log Transfer(empty(address), self, 0) # <------- Fire empty transfer from + # 0x0 to self for indexers to catch. + + +# ------------------- Token transfers in and out of the AMM ------------------ + + +@internal +def _transfer_in( + _coin_idx: uint256, + _dx: uint256, + sender: address, + expect_optimistic_transfer: bool, +) -> uint256: + """ + @notice Transfers `_coin` from `sender` to `self` and calls `callback_sig` + if it is not empty. + @params _coin_idx uint256 Index of the coin to transfer in. + @params dx amount of `_coin` to transfer into the pool. + @params sender address to transfer `_coin` from. + @params expect_optimistic_transfer bool True if pool expects user to transfer. + This is only enabled for exchange_received. + @return The amount of tokens received. + """ + coin_balance: uint256 = ERC20(coins[_coin_idx]).balanceOf(self) + + if expect_optimistic_transfer: # Only enabled in exchange_received: + # it expects the caller of exchange_received to have sent tokens to + # the pool before calling this method. + + # If someone donates extra tokens to the contract: do not acknowledge. + # We only want to know if there are dx amount of tokens. Anything extra, + # we ignore. This is why we need to check if received_amounts (which + # accounts for coin balances of the contract) is atleast dx. + # If we checked for received_amounts == dx, an extra transfer without a + # call to exchange_received will break the method. + dx: uint256 = coin_balance - self.balances[_coin_idx] + assert dx >= _dx # dev: user didn't give us coins + + # Adjust balances + self.balances[_coin_idx] += dx + + return dx + + # ----------------------------------------------- ERC20 transferFrom flow. + + # EXTERNAL CALL + assert ERC20(coins[_coin_idx]).transferFrom( + sender, + self, + _dx, + default_return_value=True + ) + + dx: uint256 = ERC20(coins[_coin_idx]).balanceOf(self) - coin_balance + self.balances[_coin_idx] += dx + return dx + + +@internal +def _transfer_out(_coin_idx: uint256, _amount: uint256, receiver: address): + """ + @notice Transfer a single token from the pool to receiver. + @dev This function is called by `remove_liquidity` and + `remove_liquidity_one`, `_claim_admin_fees` and `_exchange` methods. + @params _coin_idx uint256 Index of the token to transfer out + @params _amount Amount of token to transfer out + @params receiver Address to send the tokens to + """ + + # Adjust balances before handling transfers: + self.balances[_coin_idx] -= _amount + + # EXTERNAL CALL + assert ERC20(coins[_coin_idx]).transfer( + receiver, + _amount, + default_return_value=True + ) + + +# -------------------------- AMM Main Functions ------------------------------ + + +@external +@nonreentrant("lock") +def exchange( + i: uint256, + j: uint256, + dx: uint256, + min_dy: uint256, + receiver: address = msg.sender +) -> uint256: + """ + @notice Exchange using wrapped native token by default + @param i Index value for the input coin + @param j Index value for the output coin + @param dx Amount of input coin being swapped in + @param min_dy Minimum amount of output coin to receive + @param receiver Address to send the output coin to. Default is msg.sender + @return uint256 Amount of tokens at index j received by the `receiver + """ + # _transfer_in updates self.balances here: + dx_received: uint256 = self._transfer_in( + i, + dx, + msg.sender, + False + ) + + # No ERC20 token transfers occur here: + out: uint256[3] = self._exchange( + i, + j, + dx_received, + min_dy, + ) + + # _transfer_out updates self.balances here. Update to state occurs before + # external calls: + self._transfer_out(j, out[0], receiver) + + # log: + log TokenExchange(msg.sender, i, dx_received, j, out[0], out[1], out[2]) + + return out[0] + + +@external +@nonreentrant('lock') +def exchange_received( + i: uint256, + j: uint256, + dx: uint256, + min_dy: uint256, + receiver: address = msg.sender, +) -> uint256: + """ + @notice Exchange: but user must transfer dx amount of coin[i] tokens to pool first. + Pool will not call transferFrom and will only check if a surplus of + coins[i] is greater than or equal to `dx`. + @dev Use-case is to reduce the number of redundant ERC20 token + transfers in zaps. Primarily for dex-aggregators/arbitrageurs/searchers. + Note for users: please transfer + exchange_received in 1 tx. + @param i Index value for the input coin + @param j Index value for the output coin + @param dx Amount of input coin being swapped in + @param min_dy Minimum amount of output coin to receive + @param receiver Address to send the output coin to + @return uint256 Amount of tokens at index j received by the `receiver` + """ + # _transfer_in updates self.balances here: + dx_received: uint256 = self._transfer_in( + i, + dx, + msg.sender, + True # <---- expect_optimistic_transfer is set to True here. + ) + + # No ERC20 token transfers occur here: + out: uint256[3] = self._exchange( + i, + j, + dx_received, + min_dy, + ) + + # _transfer_out updates self.balances here. Update to state occurs before + # external calls: + self._transfer_out(j, out[0], receiver) + + # log: + log TokenExchange(msg.sender, i, dx_received, j, out[0], out[1], out[2]) + + return out[0] + + +@external +@nonreentrant("lock") +def add_liquidity( + amounts: uint256[N_COINS], + min_mint_amount: uint256, + receiver: address = msg.sender +) -> uint256: + """ + @notice Adds liquidity into the pool. + @param amounts Amounts of each coin to add. + @param min_mint_amount Minimum amount of LP to mint. + @param receiver Address to send the LP tokens to. Default is msg.sender + @return uint256 Amount of LP tokens received by the `receiver + """ + + A_gamma: uint256[2] = self._A_gamma() + xp: uint256[N_COINS] = self.balances + amountsp: uint256[N_COINS] = empty(uint256[N_COINS]) + d_token: uint256 = 0 + d_token_fee: uint256 = 0 + old_D: uint256 = 0 + + assert amounts[0] + amounts[1] > 0 # dev: no coins to add + + # --------------------- Get prices, balances ----------------------------- + + price_scale: uint256 = self.cached_price_scale + + # -------------------------------------- Update balances and calculate xp. + xp_old: uint256[N_COINS] = xp + amounts_received: uint256[N_COINS] = empty(uint256[N_COINS]) + + ########################## TRANSFER IN <------- + + for i in range(N_COINS): + if amounts[i] > 0: + # Updates self.balances here: + amounts_received[i] = self._transfer_in( + i, + amounts[i], + msg.sender, + False, # <--------------------- Disable optimistic transfers. + ) + xp[i] = xp[i] + amounts_received[i] + + xp = [ + xp[0] * PRECISIONS[0], + unsafe_div(xp[1] * price_scale * PRECISIONS[1], PRECISION) + ] + xp_old = [ + xp_old[0] * PRECISIONS[0], + unsafe_div(xp_old[1] * price_scale * PRECISIONS[1], PRECISION) + ] + + for i in range(N_COINS): + if amounts_received[i] > 0: + amountsp[i] = xp[i] - xp_old[i] + + # -------------------- Calculate LP tokens to mint ----------------------- + + if self.future_A_gamma_time > block.timestamp: # <--- A_gamma is ramping. + + # ----- Recalculate the invariant if A or gamma are undergoing a ramp. + old_D = MATH.newton_D(A_gamma[0], A_gamma[1], xp_old, 0) + + else: + + old_D = self.D + + D: uint256 = MATH.newton_D(A_gamma[0], A_gamma[1], xp, 0) + + token_supply: uint256 = self.totalSupply + if old_D > 0: + d_token = token_supply * D / old_D - token_supply + else: + d_token = self.get_xcp(D, price_scale) # <----- Making initial virtual price equal to 1. + + assert d_token > 0 # dev: nothing minted + + if old_D > 0: + + d_token_fee = ( + self._calc_token_fee(amountsp, xp) * d_token / 10**10 + 1 + ) + + d_token -= d_token_fee + token_supply += d_token + self.mint(receiver, d_token) + self.admin_lp_virtual_balance += unsafe_div(ADMIN_FEE * d_token_fee, 10**10) + + price_scale = self.tweak_price(A_gamma, xp, D, 0) + + else: + + # (re)instatiating an empty pool: + + self.D = D + self.virtual_price = 10**18 + self.xcp_profit = 10**18 + self.xcp_profit_a = 10**18 + + self.mint(receiver, d_token) + + assert d_token >= min_mint_amount, "Slippage" + + # ---------------------------------------------- Log and claim admin fees. + + log AddLiquidity( + receiver, + amounts_received, + d_token_fee, + token_supply, + price_scale + ) + + return d_token + + +@external +@nonreentrant("lock") +def remove_liquidity( + _amount: uint256, + min_amounts: uint256[N_COINS], + receiver: address = msg.sender, +) -> uint256[N_COINS]: + """ + @notice This withdrawal method is very safe, does no complex math since + tokens are withdrawn in balanced proportions. No fees are charged. + @param _amount Amount of LP tokens to burn + @param min_amounts Minimum amounts of tokens to withdraw + @param receiver Address to send the withdrawn tokens to + @return uint256[3] Amount of pool tokens received by the `receiver` + """ + amount: uint256 = _amount + balances: uint256[N_COINS] = self.balances + withdraw_amounts: uint256[N_COINS] = empty(uint256[N_COINS]) + + # -------------------------------------------------------- Burn LP tokens. + + total_supply: uint256 = self.totalSupply # <------ Get totalSupply before + self.burnFrom(msg.sender, _amount) # ---- reducing it with self.burnFrom. + + # There are two cases for withdrawing tokens from the pool. + # Case 1. Withdrawal does not empty the pool. + # In this situation, D is adjusted proportional to the amount of + # LP tokens burnt. ERC20 tokens transferred is proportional + # to : (AMM balance * LP tokens in) / LP token total supply + # Case 2. Withdrawal empties the pool. + # In this situation, all tokens are withdrawn and the invariant + # is reset. + + if amount == total_supply: # <----------------------------------- Case 2. + + for i in range(N_COINS): + + withdraw_amounts[i] = balances[i] + + else: # <-------------------------------------------------------- Case 1. + + amount -= 1 # <---- To prevent rounding errors, favor LPs a tiny bit. + + for i in range(N_COINS): + + withdraw_amounts[i] = balances[i] * amount / total_supply + assert withdraw_amounts[i] >= min_amounts[i] + + D: uint256 = self.D + self.D = D - unsafe_div(D * amount, total_supply) # <----------- Reduce D + # proportional to the amount of tokens leaving. Since withdrawals are + # balanced, this is a simple subtraction. If amount == total_supply, + # D will be 0. + + # ---------------------------------- Transfers --------------------------- + + for i in range(N_COINS): + # _transfer_out updates self.balances here. Update to state occurs + # before external calls: + self._transfer_out(i, withdraw_amounts[i], receiver) + + log RemoveLiquidity(msg.sender, withdraw_amounts, total_supply - _amount) + + return withdraw_amounts + + +@external +@nonreentrant("lock") +def remove_liquidity_one_coin( + token_amount: uint256, + i: uint256, + min_amount: uint256, + receiver: address = msg.sender +) -> uint256: + """ + @notice Withdraw liquidity in a single token. + Involves fees (lower than swap fees). + @dev This operation also involves an admin fee claim. + @param token_amount Amount of LP tokens to burn + @param i Index of the token to withdraw + @param min_amount Minimum amount of token to withdraw. + @param receiver Address to send the withdrawn tokens to + @return Amount of tokens at index i received by the `receiver` + """ + + self._claim_admin_fees() # <--------- Auto-claim admin fees occasionally. + + A_gamma: uint256[2] = self._A_gamma() + + dy: uint256 = 0 + D: uint256 = 0 + p: uint256 = 0 + xp: uint256[N_COINS] = empty(uint256[N_COINS]) + approx_fee: uint256 = 0 + + # ------------------------------------------------------------------------ + + dy, D, xp, approx_fee = self._calc_withdraw_one_coin( + A_gamma, + token_amount, + i, + (self.future_A_gamma_time > block.timestamp), # <------- During ramps + ) # we need to update D. + + assert dy >= min_amount, "Slippage" + + # ---------------------------- State Updates ----------------------------- + + # Burn user's tokens: + self.burnFrom(msg.sender, token_amount) + + packed_price_scale: uint256 = self.tweak_price(A_gamma, xp, D, 0) + # Safe to use D from _calc_withdraw_one_coin here ---^ + + # ------------------------- Transfers ------------------------------------ + + # _transfer_out updates self.balances here. Update to state occurs before + # external calls: + self._transfer_out(i, dy, receiver) + + log RemoveLiquidityOne( + msg.sender, token_amount, i, dy, approx_fee, packed_price_scale + ) + + return dy + + +# -------------------------- Packing functions ------------------------------- + + +@internal +@pure +def _pack_3(x: uint256[3]) -> uint256: + """ + @notice Packs 3 integers with values <= 10**18 into a uint256 + @param x The uint256[3] to pack + @return uint256 Integer with packed values + """ + return (x[0] << 128) | (x[1] << 64) | x[2] + + +@internal +@pure +def _unpack_3(_packed: uint256) -> uint256[3]: + """ + @notice Unpacks a uint256 into 3 integers (values must be <= 10**18) + @param val The uint256 to unpack + @return uint256[3] A list of length 3 with unpacked integers + """ + return [ + (_packed >> 128) & 18446744073709551615, + (_packed >> 64) & 18446744073709551615, + _packed & 18446744073709551615, + ] + + +@pure +@internal +def _pack_2(p1: uint256, p2: uint256) -> uint256: + return p1 | (p2 << 128) + + +@pure +@internal +def _unpack_2(packed: uint256) -> uint256[2]: + return [packed & (2**128 - 1), packed >> 128] + + +# ---------------------- AMM Internal Functions ------------------------------- + + +@internal +def _exchange( + i: uint256, + j: uint256, + dx_received: uint256, + min_dy: uint256, +) -> uint256[3]: + + assert i != j # dev: coin index out of range + assert dx_received > 0 # dev: do not exchange 0 coins + + A_gamma: uint256[2] = self._A_gamma() + xp: uint256[N_COINS] = self.balances + dy: uint256 = 0 + + y: uint256 = xp[j] + x0: uint256 = xp[i] - dx_received # old xp[i] + + price_scale: uint256 = self.cached_price_scale + xp = [ + xp[0] * PRECISIONS[0], + unsafe_div(xp[1] * price_scale * PRECISIONS[1], PRECISION) + ] + + # ----------- Update invariant if A, gamma are undergoing ramps --------- + + t: uint256 = self.future_A_gamma_time + if t > block.timestamp: + + x0 *= PRECISIONS[i] + + if i > 0: + x0 = unsafe_div(x0 * price_scale, PRECISION) + + x1: uint256 = xp[i] # <------------------ Back up old value in xp ... + xp[i] = x0 # | + self.D = MATH.newton_D(A_gamma[0], A_gamma[1], xp, 0) # | + xp[i] = x1 # <-------------------------------------- ... and restore. + + # ----------------------- Calculate dy and fees -------------------------- + + D: uint256 = self.D + y_out: uint256[2] = MATH.get_y(A_gamma[0], A_gamma[1], xp, D, j) + dy = xp[j] - y_out[0] + xp[j] -= dy + dy -= 1 + + if j > 0: + dy = dy * PRECISION / price_scale + dy /= PRECISIONS[j] + + fee: uint256 = unsafe_div(self._fee(xp) * dy, 10**10) + dy -= fee # <--------------------- Subtract fee from the outgoing amount. + assert dy >= min_dy, "Slippage" + y -= dy + + y *= PRECISIONS[j] + if j > 0: + y = unsafe_div(y * price_scale, PRECISION) + xp[j] = y # <------------------------------------------------- Update xp. + + # ------ Tweak price_scale with good initial guess for newton_D ---------- + + # Get initial guess using: D = isqrt(x[0] * x[1] * 4 / K0_prev * 10**18) + initial_D: uint256 = isqrt(xp[0] * xp[1] * 4 / y_out[1] * 10**18) + price_scale = self.tweak_price(A_gamma, xp, 0, initial_D) + + return [dy, fee, price_scale] + + +@internal +def tweak_price( + A_gamma: uint256[2], + _xp: uint256[N_COINS], + new_D: uint256, + initial_D: uint256 = 0, +) -> uint256: + """ + @notice Updates price_oracle, last_price and conditionally adjusts + price_scale. This is called whenever there is an unbalanced + liquidity operation: _exchange, add_liquidity, or + remove_liquidity_one_coin. + @dev Contains main liquidity rebalancing logic, by tweaking `price_scale`. + @param A_gamma Array of A and gamma parameters. + @param _xp Array of current balances. + @param new_D New D value. + @param initial_D Initial guess for `newton_D`. + """ + + # ---------------------------- Read storage ------------------------------ + + price_oracle: uint256 = self.cached_price_oracle + last_prices: uint256 = self.last_prices + price_scale: uint256 = self.cached_price_scale + rebalancing_params: uint256[3] = self._unpack_3(self.packed_rebalancing_params) + # Contains: allowed_extra_profit, adjustment_step, ma_time. -----^ + + total_supply: uint256 = self.totalSupply + old_xcp_profit: uint256 = self.xcp_profit + old_virtual_price: uint256 = self.virtual_price + + # ------------------ Update Price Oracle if needed ----------------------- + + last_timestamp: uint256 = self.last_timestamp + alpha: uint256 = 0 + if last_timestamp < block.timestamp: # 0th index is for price_oracle. + + # The moving average price oracle is calculated using the last_price + # of the trade at the previous block, and the price oracle logged + # before that trade. This can happen only once per block. + + # ------------------ Calculate moving average params ----------------- + + alpha = MATH.wad_exp( + -convert( + unsafe_div( + unsafe_sub(block.timestamp, last_timestamp) * 10**18, + rebalancing_params[2] # <----------------------- ma_time. + ), + int256, + ) + ) + + # ---------------------------------------------- Update price oracles. + + # ----------------- We cap state price that goes into the EMA with + # 2 x price_scale. + price_oracle = unsafe_div( + min(last_prices, 2 * price_scale) * (10**18 - alpha) + + price_oracle * alpha, # ^-------- Cap spot price into EMA. + 10**18 + ) + + self.cached_price_oracle = price_oracle + self.last_timestamp = block.timestamp + + # `price_oracle` is used further on to calculate its vector distance from + # price_scale. This distance is used to calculate the amount of adjustment + # to be done to the price_scale. + # ------------------------------------------------------------------------ + + # ------------------ If new_D is set to 0, calculate it ------------------ + + D_unadjusted: uint256 = new_D + if new_D == 0: # <--------------------------- _exchange sets new_D to 0. + D_unadjusted = MATH.newton_D(A_gamma[0], A_gamma[1], _xp, initial_D) + + # ----------------------- Calculate last_prices -------------------------- + + self.last_prices = unsafe_div( + MATH.get_p(_xp, D_unadjusted, A_gamma) * price_scale, + 10**18 + ) + + # ---------- Update profit numbers without price adjustment first -------- + + xp: uint256[N_COINS] = [ + unsafe_div(D_unadjusted, N_COINS), + D_unadjusted * PRECISION / (N_COINS * price_scale) # <------ safediv. + ] # with price_scale. + + xcp_profit: uint256 = 10**18 + virtual_price: uint256 = 10**18 + + if old_virtual_price > 0: + + xcp: uint256 = isqrt(xp[0] * xp[1]) + virtual_price = 10**18 * xcp / total_supply + + xcp_profit = unsafe_div( + old_xcp_profit * virtual_price, + old_virtual_price + ) # <---------------- Safu to do unsafe_div as old_virtual_price > 0. + + # If A and gamma are not undergoing ramps (t < block.timestamp), + # ensure new virtual_price is not less than old virtual_price, + # else the pool suffers a loss. + if self.future_A_gamma_time < block.timestamp: + # this usually reverts when withdrawing a very small amount of LP tokens + assert virtual_price > old_virtual_price # dev: virtual price decreased + + self.xcp_profit = xcp_profit + + # ------------ Rebalance liquidity if there's enough profits to adjust it: + if virtual_price * 2 - 10**18 > xcp_profit + 2 * rebalancing_params[0]: + # allowed_extra_profit --------^ + + # ------------------- Get adjustment step ---------------------------- + + # Calculate the vector distance between price_scale and + # price_oracle. + norm: uint256 = unsafe_div( + unsafe_mul(price_oracle, 10**18), price_scale + ) + if norm > 10**18: + norm = unsafe_sub(norm, 10**18) + else: + norm = unsafe_sub(10**18, norm) + adjustment_step: uint256 = max( + rebalancing_params[1], unsafe_div(norm, 5) + ) # ^------------------------------------- adjustment_step. + + if norm > adjustment_step: # <---------- We only adjust prices if the + # vector distance between price_oracle and price_scale is + # large enough. This check ensures that no rebalancing + # occurs if the distance is low i.e. the pool prices are + # pegged to the oracle prices. + + # ------------------------------------- Calculate new price scale. + + p_new: uint256 = unsafe_div( + price_scale * unsafe_sub(norm, adjustment_step) + + adjustment_step * price_oracle, + norm + ) # <---- norm is non-zero and gt adjustment_step; unsafe = safe. + + # ---------------- Update stale xp (using price_scale) with p_new. + + xp = [ + _xp[0], + unsafe_div(_xp[1] * p_new, price_scale) + ] + + # ------------------------------------------ Update D with new xp. + D: uint256 = MATH.newton_D( + A_gamma[0], + A_gamma[1], + xp, + D_unadjusted + ) + + for k in range(N_COINS): + frac: uint256 = xp[k] * 10**18 / D # <----- Check validity of + assert (frac > 10**16 - 1) and (frac < 10**20 + 1) # p_new. + + # ------------------------------------- Convert xp to real prices. + xp = [ + unsafe_div(D, N_COINS), + D * PRECISION / (N_COINS * p_new) + ] + + # ---------- Calculate new virtual_price using new xp and D. Reuse + # `old_virtual_price` (but it has new virtual_price). + old_virtual_price = unsafe_div( + 10**18 * isqrt(xp[0] * xp[1]), total_supply + ) # <----- unsafe_div because we did safediv before (if vp>1e18) + + # ---------------------------- Proceed if we've got enough profit. + if ( + old_virtual_price > 10**18 and + 2 * old_virtual_price - 10**18 > xcp_profit + ): + + self.D = D + self.virtual_price = old_virtual_price + self.cached_price_scale = p_new + + return p_new + + # --------- price_scale was not adjusted. Update the profit counter and D. + self.D = D_unadjusted + self.virtual_price = virtual_price + + return price_scale + + +@internal +def _claim_admin_fees(): + """ + @notice Claims admin fees and sends it to fee_receiver set in the factory. + @dev Functionally similar to: + 1. Calculating admin's share of fees, + 2. minting LP tokens, + 3. admin claims underlying tokens via remove_liquidity. + """ + + # --------------------- Check if fees can be claimed --------------------- + + # Disable fee claiming if: + # 1. If time passed since last fee claim is less than + # MIN_ADMIN_FEE_CLAIM_INTERVAL. + # 2. Pool parameters are being ramped. + + last_claim_time: uint256 = self.last_admin_fee_claim_timestamp + if ( + unsafe_sub(block.timestamp, last_claim_time) < MIN_ADMIN_FEE_CLAIM_INTERVAL or + self.future_A_gamma_time > block.timestamp + ): + return + + xcp_profit: uint256 = self.xcp_profit # <---------- Current pool profits. + xcp_profit_a: uint256 = self.xcp_profit_a # <- Profits at previous claim. + current_lp_token_supply: uint256 = self.totalSupply + + # Do not claim admin fees if: + # 1. insufficient profits accrued since last claim, and + # 2. there are less than 10**18 (or 1 unit of) lp tokens, else it can lead + # to manipulated virtual prices. + + if xcp_profit <= xcp_profit_a or current_lp_token_supply < 10**18: + return + + # ---------- Conditions met to claim admin fees: compute state. ---------- + + A_gamma: uint256[2] = self._A_gamma() + D: uint256 = self.D + vprice: uint256 = self.virtual_price + price_scale: uint256 = self.cached_price_scale + fee_receiver: address = factory.fee_receiver() + balances: uint256[N_COINS] = self.balances + + # Admin fees are calculated as follows. + # 1. Calculate accrued profit since last claim. `xcp_profit` + # is the current profits. `xcp_profit_a` is the profits + # at the previous claim. + # 2. Take out admin's share, which is hardcoded at 5 * 10**9. + # (50% => half of 100% => 10**10 / 2 => 5 * 10**9). + # 3. Since half of the profits go to rebalancing the pool, we + # are left with half; so divide by 2. + + fees: uint256 = unsafe_div( + unsafe_sub(xcp_profit, xcp_profit_a) * ADMIN_FEE, 2 * 10**10 + ) + + # ------------------------------ Claim admin fees by minting admin's share + # of the pool in LP tokens. + + # This is the admin fee tokens claimed in self.add_liquidity. We add it to + # the LP token share that the admin needs to claim: + admin_share: uint256 = self.admin_lp_virtual_balance + frac: uint256 = 0 + if fee_receiver != empty(address) and fees > 0: + + # -------------------------------- Calculate admin share to be minted. + frac = vprice * 10**18 / (vprice - fees) - 10**18 + admin_share += current_lp_token_supply * frac / 10**18 + + # ------ Subtract fees from profits that will be used for rebalancing. + xcp_profit -= fees * 2 + + # ------------------- Recalculate virtual_price following admin fee claim. + total_supply_including_admin_share: uint256 = ( + current_lp_token_supply + admin_share + ) + vprice = ( + 10**18 * self.get_xcp(D, price_scale) / + total_supply_including_admin_share + ) + + # Do not claim fees if doing so causes virtual price to drop below 10**18. + if vprice < 10**18: + return + + # ---------------------------- Update State ------------------------------ + + # Set admin virtual LP balances to zero because we claimed: + self.admin_lp_virtual_balance = 0 + + self.xcp_profit = xcp_profit + self.last_admin_fee_claim_timestamp = block.timestamp + + # Since we reduce balances: virtual price goes down + self.virtual_price = vprice + + # Adjust D after admin seemingly removes liquidity + self.D = D - unsafe_div(D * admin_share, total_supply_including_admin_share) + + if xcp_profit > xcp_profit_a: + self.xcp_profit_a = xcp_profit # <-------- Cache last claimed profit. + + # --------------------------- Handle Transfers --------------------------- + + admin_tokens: uint256[N_COINS] = empty(uint256[N_COINS]) + if admin_share > 0: + + for i in range(N_COINS): + + admin_tokens[i] = ( + balances[i] * admin_share / + total_supply_including_admin_share + ) + + # _transfer_out tokens to admin and update self.balances. State + # update to self.balances occurs before external contract calls: + self._transfer_out(i, admin_tokens[i], fee_receiver) + + log ClaimAdminFee(fee_receiver, admin_tokens) + + +@internal +@pure +def xp( + balances: uint256[N_COINS], + price_scale: uint256, +) -> uint256[N_COINS]: + + return [ + balances[0] * PRECISIONS[0], + unsafe_div(balances[1] * PRECISIONS[1] * price_scale, PRECISION) + ] + + +@view +@internal +def _A_gamma() -> uint256[2]: + t1: uint256 = self.future_A_gamma_time + + A_gamma_1: uint256 = self.future_A_gamma + gamma1: uint256 = A_gamma_1 & 2**128 - 1 + A1: uint256 = A_gamma_1 >> 128 + + if block.timestamp < t1: + + # --------------- Handle ramping up and down of A -------------------- + + A_gamma_0: uint256 = self.initial_A_gamma + t0: uint256 = self.initial_A_gamma_time + + t1 -= t0 + t0 = block.timestamp - t0 + t2: uint256 = t1 - t0 + + A1 = ((A_gamma_0 >> 128) * t2 + A1 * t0) / t1 + gamma1 = ((A_gamma_0 & 2**128 - 1) * t2 + gamma1 * t0) / t1 + + return [A1, gamma1] + + +@internal +@view +def _fee(xp: uint256[N_COINS]) -> uint256: + + fee_params: uint256[3] = self._unpack_3(self.packed_fee_params) + f: uint256 = xp[0] + xp[1] + f = fee_params[2] * 10**18 / ( + fee_params[2] + 10**18 - + (10**18 * N_COINS**N_COINS) * xp[0] / f * xp[1] / f + ) + + return unsafe_div( + fee_params[0] * f + fee_params[1] * (10**18 - f), + 10**18 + ) + + +@internal +@pure +def get_xcp(D: uint256, price_scale: uint256) -> uint256: + + x: uint256[N_COINS] = [ + unsafe_div(D, N_COINS), + D * PRECISION / (price_scale * N_COINS) + ] + + return isqrt(x[0] * x[1]) # <------------------- Geometric Mean. + + +@view +@internal +def _calc_token_fee(amounts: uint256[N_COINS], xp: uint256[N_COINS]) -> uint256: + # fee = sum(amounts_i - avg(amounts)) * fee' / sum(amounts) + fee: uint256 = unsafe_div( + unsafe_mul(self._fee(xp), N_COINS), + unsafe_mul(4, unsafe_sub(N_COINS, 1)) + ) + + S: uint256 = 0 + for _x in amounts: + S += _x + + avg: uint256 = unsafe_div(S, N_COINS) + Sdiff: uint256 = 0 + + for _x in amounts: + if _x > avg: + Sdiff += unsafe_sub(_x, avg) + else: + Sdiff += unsafe_sub(avg, _x) + + return fee * Sdiff / S + NOISE_FEE + + +@internal +@view +def _calc_withdraw_one_coin( + A_gamma: uint256[2], + token_amount: uint256, + i: uint256, + update_D: bool, +) -> (uint256, uint256, uint256[N_COINS], uint256): + + token_supply: uint256 = self.totalSupply + assert token_amount <= token_supply # dev: token amount more than supply + assert i < N_COINS # dev: coin out of range + + xx: uint256[N_COINS] = self.balances + D0: uint256 = 0 + + # -------------------------- Calculate D0 and xp ------------------------- + + price_scale_i: uint256 = self.cached_price_scale * PRECISIONS[1] + xp: uint256[N_COINS] = [ + xx[0] * PRECISIONS[0], + unsafe_div(xx[1] * price_scale_i, PRECISION) + ] + if i == 0: + price_scale_i = PRECISION * PRECISIONS[0] + + if update_D: # <-------------- D is updated if pool is undergoing a ramp. + D0 = MATH.newton_D(A_gamma[0], A_gamma[1], xp, 0) + else: + D0 = self.D + + D: uint256 = D0 + + # -------------------------------- Fee Calc ------------------------------ + + # Charge fees on D. Roughly calculate xp[i] after withdrawal and use that + # to calculate fee. Precision is not paramount here: we just want a + # behavior where the higher the imbalance caused the more fee the AMM + # charges. + + # xp is adjusted assuming xp[0] ~= xp[1] ~= x[2], which is usually not the + # case. We charge self._fee(xp), where xp is an imprecise adjustment post + # withdrawal in one coin. If the withdraw is too large: charge max fee by + # default. This is because the fee calculation will otherwise underflow. + + xp_imprecise: uint256[N_COINS] = xp + xp_correction: uint256 = xp[i] * N_COINS * token_amount / token_supply + fee: uint256 = self._unpack_3(self.packed_fee_params)[1] # <- self.out_fee. + + if xp_correction < xp_imprecise[i]: + xp_imprecise[i] -= xp_correction + fee = self._fee(xp_imprecise) + + dD: uint256 = unsafe_div(token_amount * D, token_supply) + D_fee: uint256 = fee * dD / (2 * 10**10) + 1 # <------- Actual fee on D. + + # --------- Calculate `approx_fee` (assuming balanced state) in ith token. + # -------------------------------- We only need this for fee in the event. + approx_fee: uint256 = N_COINS * D_fee * xx[i] / D # <------------------<---------- TODO: Check math. + + # ------------------------------------------------------------------------ + D -= (dD - D_fee) # <----------------------------------- Charge fee on D. + # --------------------------------- Calculate `y_out`` with `(D - D_fee)`. + y: uint256 = MATH.get_y(A_gamma[0], A_gamma[1], xp, D, i)[0] + dy: uint256 = (xp[i] - y) * PRECISION / price_scale_i + xp[i] = y + + return dy, D, xp, approx_fee + + +# ------------------------ ERC20 functions ----------------------------------- + + +@internal +def _approve(_owner: address, _spender: address, _value: uint256): + self.allowance[_owner][_spender] = _value + + log Approval(_owner, _spender, _value) + + +@internal +def _transfer(_from: address, _to: address, _value: uint256): + assert _to not in [self, empty(address)] + + self.balanceOf[_from] -= _value + self.balanceOf[_to] += _value + + log Transfer(_from, _to, _value) + + +@view +@internal +def _domain_separator() -> bytes32: + if chain.id != CACHED_CHAIN_ID: + return keccak256( + _abi_encode( + EIP712_TYPEHASH, + NAME_HASH, + VERSION_HASH, + chain.id, + self, + salt, + ) + ) + return CACHED_DOMAIN_SEPARATOR + + +@external +def transferFrom(_from: address, _to: address, _value: uint256) -> bool: + """ + @dev Transfer tokens from one address to another. + @param _from address The address which you want to send tokens from + @param _to address The address which you want to transfer to + @param _value uint256 the amount of tokens to be transferred + @return bool True on successul transfer. Reverts otherwise. + """ + _allowance: uint256 = self.allowance[_from][msg.sender] + if _allowance != max_value(uint256): + self._approve(_from, msg.sender, _allowance - _value) + + self._transfer(_from, _to, _value) + return True + + +@external +def transfer(_to: address, _value: uint256) -> bool: + """ + @dev Transfer token for a specified address + @param _to The address to transfer to. + @param _value The amount to be transferred. + @return bool True on successful transfer. Reverts otherwise. + """ + self._transfer(msg.sender, _to, _value) + return True + + +@external +def approve(_spender: address, _value: uint256) -> bool: + """ + @notice Allow `_spender` to transfer up to `_value` amount + of tokens from the caller's account. + @param _spender The account permitted to spend up to `_value` amount of + caller's funds. + @param _value The amount of tokens `_spender` is allowed to spend. + @return bool Success + """ + self._approve(msg.sender, _spender, _value) + return True + + +@external +def permit( + _owner: address, + _spender: address, + _value: uint256, + _deadline: uint256, + _v: uint8, + _r: bytes32, + _s: bytes32, +) -> bool: + """ + @notice Permit `_spender` to spend up to `_value` amount of `_owner`'s + tokens via a signature. + @dev In the event of a chain fork, replay attacks are prevented as + domain separator is recalculated. However, this is only if the + resulting chains update their chainId. + @param _owner The account which generated the signature and is granting an + allowance. + @param _spender The account which will be granted an allowance. + @param _value The approval amount. + @param _deadline The deadline by which the signature must be submitted. + @param _v The last byte of the ECDSA signature. + @param _r The first 32 bytes of the ECDSA signature. + @param _s The second 32 bytes of the ECDSA signature. + @return bool Success. + """ + assert _owner != empty(address) # dev: invalid owner + assert block.timestamp <= _deadline # dev: permit expired + + nonce: uint256 = self.nonces[_owner] + digest: bytes32 = keccak256( + concat( + b"\x19\x01", + self._domain_separator(), + keccak256( + _abi_encode( + EIP2612_TYPEHASH, _owner, _spender, _value, nonce, _deadline + ) + ), + ) + ) + assert ecrecover(digest, _v, _r, _s) == _owner # dev: invalid signature + + self.nonces[_owner] = unsafe_add(nonce, 1) # <-- Unsafe add is safe here. + self._approve(_owner, _spender, _value) + return True + + +@internal +def mint(_to: address, _value: uint256) -> bool: + """ + @dev Mint an amount of the token and assigns it to an account. + This encapsulates the modification of balances such that the + proper events are emitted. + @param _to The account that will receive the created tokens. + @param _value The amount that will be created. + @return bool Success. + """ + self.totalSupply += _value + self.balanceOf[_to] += _value + + log Transfer(empty(address), _to, _value) + return True + + +@internal +def burnFrom(_to: address, _value: uint256) -> bool: + """ + @dev Burn an amount of the token from a given account. + @param _to The account whose tokens will be burned. + @param _value The amount that will be burned. + @return bool Success. + """ + self.totalSupply -= _value + self.balanceOf[_to] -= _value + + log Transfer(_to, empty(address), _value) + return True + + +# ------------------------- AMM View Functions ------------------------------- + + +@internal +@view +def internal_price_oracle() -> uint256: + """ + @notice Returns the oracle price of the coin at index `k` w.r.t the coin + at index 0. + @dev The oracle is an exponential moving average, with a periodicity + determined by `self.ma_time`. The aggregated prices are cached state + prices (dy/dx) calculated AFTER the latest trade. + @param k The index of the coin. + @return uint256 Price oracle value of kth coin. + """ + price_oracle: uint256 = self.cached_price_oracle + price_scale: uint256 = self.cached_price_scale + last_prices_timestamp: uint256 = self.last_timestamp + + if last_prices_timestamp < block.timestamp: # <------------ Update moving + # average if needed. + + last_prices: uint256 = self.last_prices + ma_time: uint256 = self._unpack_3(self.packed_rebalancing_params)[2] + alpha: uint256 = MATH.wad_exp( + -convert( + unsafe_sub(block.timestamp, last_prices_timestamp) * 10**18 / ma_time, + int256, + ) + ) + + # ---- We cap state price that goes into the EMA with 2 x price_scale. + return ( + min(last_prices, 2 * price_scale) * (10**18 - alpha) + + price_oracle * alpha + ) / 10**18 + + return price_oracle + + +@external +@view +def fee_receiver() -> address: + """ + @notice Returns the address of the admin fee receiver. + @return address Fee receiver. + """ + return factory.fee_receiver() + + +@external +@view +def admin() -> address: + """ + @notice Returns the address of the pool's admin. + @return address Admin. + """ + return factory.admin() + + +@external +@view +def calc_token_amount(amounts: uint256[N_COINS], deposit: bool) -> uint256: + """ + @notice Calculate LP tokens minted or to be burned for depositing or + removing `amounts` of coins + @dev Includes fee. + @param amounts Amounts of tokens being deposited or withdrawn + @param deposit True if it is a deposit action, False if withdrawn. + @return uint256 Amount of LP tokens deposited or withdrawn. + """ + view_contract: address = factory.views_implementation() + return Views(view_contract).calc_token_amount(amounts, deposit, self) + + +@external +@view +def get_dy(i: uint256, j: uint256, dx: uint256) -> uint256: + """ + @notice Get amount of coin[j] tokens received for swapping in dx amount of coin[i] + @dev Includes fee. + @param i index of input token. Check pool.coins(i) to get coin address at ith index + @param j index of output token + @param dx amount of input coin[i] tokens + @return uint256 Exact amount of output j tokens for dx amount of i input tokens. + """ + view_contract: address = factory.views_implementation() + return Views(view_contract).get_dy(i, j, dx, self) + + +@external +@view +def get_dx(i: uint256, j: uint256, dy: uint256) -> uint256: + """ + @notice Get amount of coin[i] tokens to input for swapping out dy amount + of coin[j] + @dev This is an approximate method, and returns estimates close to the input + amount. Expensive to call on-chain. + @param i index of input token. Check pool.coins(i) to get coin address at + ith index + @param j index of output token + @param dy amount of input coin[j] tokens received + @return uint256 Approximate amount of input i tokens to get dy amount of j tokens. + """ + view_contract: address = factory.views_implementation() + return Views(view_contract).get_dx(i, j, dy, self) + + +@external +@view +@nonreentrant("lock") +def lp_price() -> uint256: + """ + @notice Calculates the current price of the LP token w.r.t coin at the + 0th index + @return uint256 LP price. + """ + return 2 * self.virtual_price * isqrt(self.internal_price_oracle() * 10**18) / 10**18 + + +@external +@view +@nonreentrant("lock") +def get_virtual_price() -> uint256: + """ + @notice Calculates the current virtual price of the pool LP token. + @dev Not to be confused with `self.virtual_price` which is a cached + virtual price. + @return uint256 Virtual Price. + """ + return 10**18 * self.get_xcp(self.D, self.cached_price_scale) / self.totalSupply + + +@external +@view +@nonreentrant("lock") +def price_oracle() -> uint256: + """ + @notice Returns the oracle price of the coin at index `k` w.r.t the coin + at index 0. + @dev The oracle is an exponential moving average, with a periodicity + determined by `self.ma_time`. The aggregated prices are cached state + prices (dy/dx) calculated AFTER the latest trade. + @return uint256 Price oracle value of kth coin. + """ + return self.internal_price_oracle() + + +@external +@view +@nonreentrant("lock") +def price_scale() -> uint256: + """ + @notice Returns the price scale of the coin at index `k` w.r.t the coin + at index 0. + @dev Price scale determines the price band around which liquidity is + concentrated. + @return uint256 Price scale of coin. + """ + return self.cached_price_scale + + +@external +@view +def fee() -> uint256: + """ + @notice Returns the fee charged by the pool at current state. + @dev Not to be confused with the fee charged at liquidity action, since + there the fee is calculated on `xp` AFTER liquidity is added or + removed. + @return uint256 fee bps. + """ + return self._fee(self.xp(self.balances, self.cached_price_scale)) + + +@view +@external +def calc_withdraw_one_coin(token_amount: uint256, i: uint256) -> uint256: + """ + @notice Calculates output tokens with fee + @param token_amount LP Token amount to burn + @param i token in which liquidity is withdrawn + @return uint256 Amount of ith tokens received for burning token_amount LP tokens. + """ + + return self._calc_withdraw_one_coin( + self._A_gamma(), + token_amount, + i, + (self.future_A_gamma_time > block.timestamp) + )[0] + + +@external +@view +def calc_token_fee( + amounts: uint256[N_COINS], xp: uint256[N_COINS] +) -> uint256: + """ + @notice Returns the fee charged on the given amounts for add_liquidity. + @param amounts The amounts of coins being added to the pool. + @param xp The current balances of the pool multiplied by coin precisions. + @return uint256 Fee charged. + """ + return self._calc_token_fee(amounts, xp) + + +@view +@external +def A() -> uint256: + """ + @notice Returns the current pool amplification parameter. + @return uint256 A param. + """ + return self._A_gamma()[0] + + +@view +@external +def gamma() -> uint256: + """ + @notice Returns the current pool gamma parameter. + @return uint256 gamma param. + """ + return self._A_gamma()[1] + + +@view +@external +def mid_fee() -> uint256: + """ + @notice Returns the current mid fee + @return uint256 mid_fee value. + """ + return self._unpack_3(self.packed_fee_params)[0] + + +@view +@external +def out_fee() -> uint256: + """ + @notice Returns the current out fee + @return uint256 out_fee value. + """ + return self._unpack_3(self.packed_fee_params)[1] + + +@view +@external +def fee_gamma() -> uint256: + """ + @notice Returns the current fee gamma + @return uint256 fee_gamma value. + """ + return self._unpack_3(self.packed_fee_params)[2] + + +@view +@external +def allowed_extra_profit() -> uint256: + """ + @notice Returns the current allowed extra profit + @return uint256 allowed_extra_profit value. + """ + return self._unpack_3(self.packed_rebalancing_params)[0] + + +@view +@external +def adjustment_step() -> uint256: + """ + @notice Returns the current adjustment step + @return uint256 adjustment_step value. + """ + return self._unpack_3(self.packed_rebalancing_params)[1] + + +@view +@external +def ma_time() -> uint256: + """ + @notice Returns the current moving average time in seconds + @dev To get time in seconds, the parameter is multipled by ln(2) + One can expect off-by-one errors here. + @return uint256 ma_time value. + """ + return self._unpack_3(self.packed_rebalancing_params)[2] * 694 / 1000 + + +@view +@external +def precisions() -> uint256[N_COINS]: # <-------------- For by view contract. + """ + @notice Returns the precisions of each coin in the pool. + @return uint256[3] precisions of coins. + """ + return PRECISIONS + + +@external +@view +def fee_calc(xp: uint256[N_COINS]) -> uint256: # <----- For by view contract. + """ + @notice Returns the fee charged by the pool at current state. + @param xp The current balances of the pool multiplied by coin precisions. + @return uint256 Fee value. + """ + return self._fee(xp) + + +@view +@external +def DOMAIN_SEPARATOR() -> bytes32: + """ + @notice EIP712 domain separator. + @return bytes32 Domain Separator set for the current chain. + """ + return self._domain_separator() + + +# ------------------------- AMM Admin Functions ------------------------------ + + +@external +def ramp_A_gamma( + future_A: uint256, future_gamma: uint256, future_time: uint256 +): + """ + @notice Initialise Ramping A and gamma parameter values linearly. + @dev Only accessible by factory admin, and only + @param future_A The future A value. + @param future_gamma The future gamma value. + @param future_time The timestamp at which the ramping will end. + """ + assert msg.sender == factory.admin() # dev: only owner + assert block.timestamp > self.future_A_gamma_time # dev: ramp undergoing + assert future_time > block.timestamp + MIN_RAMP_TIME - 1 # dev: insufficient time + + A_gamma: uint256[2] = self._A_gamma() + initial_A_gamma: uint256 = A_gamma[0] << 128 + initial_A_gamma = initial_A_gamma | A_gamma[1] + + assert future_A > MIN_A - 1 + assert future_A < MAX_A + 1 + assert future_gamma > MIN_GAMMA - 1 + assert future_gamma < MAX_GAMMA + 1 + + ratio: uint256 = 10**18 * future_A / A_gamma[0] + assert ratio < 10**18 * MAX_A_CHANGE + 1 # dev: A change too high + assert ratio > 10**18 / MAX_A_CHANGE - 1 # dev: A change too low + + ratio = 10**18 * future_gamma / A_gamma[1] + assert ratio < 10**18 * MAX_A_CHANGE + 1 # dev: gamma change too high + assert ratio > 10**18 / MAX_A_CHANGE - 1 # dev: gamma change too low + + self.initial_A_gamma = initial_A_gamma + self.initial_A_gamma_time = block.timestamp + + future_A_gamma: uint256 = future_A << 128 + future_A_gamma = future_A_gamma | future_gamma + self.future_A_gamma_time = future_time + self.future_A_gamma = future_A_gamma + + log RampAgamma( + A_gamma[0], + future_A, + A_gamma[1], + future_gamma, + block.timestamp, + future_time, + ) + + +@external +def stop_ramp_A_gamma(): + """ + @notice Stop Ramping A and gamma parameters immediately. + @dev Only accessible by factory admin. + """ + assert msg.sender == factory.admin() # dev: only owner + + A_gamma: uint256[2] = self._A_gamma() + current_A_gamma: uint256 = A_gamma[0] << 128 + current_A_gamma = current_A_gamma | A_gamma[1] + self.initial_A_gamma = current_A_gamma + self.future_A_gamma = current_A_gamma + self.initial_A_gamma_time = block.timestamp + self.future_A_gamma_time = block.timestamp + + # ------ Now (block.timestamp < t1) is always False, so we return saved A. + + log StopRampA(A_gamma[0], A_gamma[1], block.timestamp) + + +@external +@nonreentrant('lock') +def apply_new_parameters( + _new_mid_fee: uint256, + _new_out_fee: uint256, + _new_fee_gamma: uint256, + _new_allowed_extra_profit: uint256, + _new_adjustment_step: uint256, + _new_ma_time: uint256, +): + """ + @notice Commit new parameters. + @dev Only accessible by factory admin. + @param _new_mid_fee The new mid fee. + @param _new_out_fee The new out fee. + @param _new_fee_gamma The new fee gamma. + @param _new_allowed_extra_profit The new allowed extra profit. + @param _new_adjustment_step The new adjustment step. + @param _new_ma_time The new ma time. ma_time is time_in_seconds/ln(2). + """ + assert msg.sender == factory.admin() # dev: only owner + + # ----------------------------- Set fee params --------------------------- + + new_mid_fee: uint256 = _new_mid_fee + new_out_fee: uint256 = _new_out_fee + new_fee_gamma: uint256 = _new_fee_gamma + + current_fee_params: uint256[3] = self._unpack_3(self.packed_fee_params) + + if new_out_fee < MAX_FEE + 1: + assert new_out_fee > MIN_FEE - 1 # dev: fee is out of range + else: + new_out_fee = current_fee_params[1] + + if new_mid_fee > MAX_FEE: + new_mid_fee = current_fee_params[0] + assert new_mid_fee <= new_out_fee # dev: mid-fee is too high + + if new_fee_gamma < 10**18: + assert new_fee_gamma > 0 # dev: fee_gamma out of range [1 .. 10**18] + else: + new_fee_gamma = current_fee_params[2] + + self.packed_fee_params = self._pack_3([new_mid_fee, new_out_fee, new_fee_gamma]) + + # ----------------- Set liquidity rebalancing parameters ----------------- + + new_allowed_extra_profit: uint256 = _new_allowed_extra_profit + new_adjustment_step: uint256 = _new_adjustment_step + new_ma_time: uint256 = _new_ma_time + + current_rebalancing_params: uint256[3] = self._unpack_3(self.packed_rebalancing_params) + + if new_allowed_extra_profit > 10**18: + new_allowed_extra_profit = current_rebalancing_params[0] + + if new_adjustment_step > 10**18: + new_adjustment_step = current_rebalancing_params[1] + + if new_ma_time < 872542: # <----- Calculated as: 7 * 24 * 60 * 60 / ln(2) + assert new_ma_time > 86 # dev: MA time should be longer than 60/ln(2) + else: + new_ma_time = current_rebalancing_params[2] + + self.packed_rebalancing_params = self._pack_3( + [new_allowed_extra_profit, new_adjustment_step, new_ma_time] + ) + + # ---------------------------------- LOG --------------------------------- + + log NewParameters( + new_mid_fee, + new_out_fee, + new_fee_gamma, + new_allowed_extra_profit, + new_adjustment_step, + new_ma_time, + ) diff --git a/contracts/experimental/initial_guess/readme.md b/contracts/experimental/initial_guess/readme.md new file mode 100644 index 00000000..d2c73324 --- /dev/null +++ b/contracts/experimental/initial_guess/readme.md @@ -0,0 +1,27 @@ +Looking at a [twocrypto-ng swap](https://ethtx.info/mainnet/0xce5ba49b9f916fce565b6eaba8cefd44f47bd968b6ee44bc2bf0c45eeaf77d3c/): + +![alt text](./tx_trace_CVGETH.jpg) + +We observed that while we do indeed have an initial guess for the first newton's method for calculating D in tweak_price (before we rebalance liquidity), we could potentially use that precisely calculated D if we are going to rebalance liquidity. This is implemented in [CurveTwocryptoOptimized.vy](./CurveTwocryptoOptimized.vy). To accommodate this further, +there's a refactor of logic in newton_D. Previously newton_D used an initial guess that was derived from K0_prev, and it basically post-processed K0_prev into the initial guess within the method as follows: + +```python +D: uint256 = 0 +if K0_prev == 0: + D = N_COINS * isqrt(unsafe_mul(x[0], x[1])) +else: + # D = isqrt(x[0] * x[1] * 4 / K0_prev * 10**18) + D = isqrt(unsafe_mul(unsafe_div(unsafe_mul(unsafe_mul(4, x[0]), x[1]), K0_prev), 10**18)) + if S < D: + D = S +``` + +What we now has is this K0_prev post processing logic encapsulated in \_exchange method of the AMM contract, such that we do all post-processing outside newton_D. This may not necessarily be desirable, and we may want to refactor this to make it cleaner, such that the AMM contract doesn't have any math logic that is dependent on get_y. So perhaps the math contract should encapsulate all newton's method pre-processing logic. + +Tasks: + +- [x] Refactor initial guess processing logic outside of newton_D +- [ ] Refactor newton's method pre-processing logic into math contract (does this mean we do an extra external call in \_exchange method?) +- [ ] Write tests +- [ ] Check for convergence (fuzz to ensure there is a tangible improvement) +- [ ] Create analysis charts to show to stakeholders diff --git a/contracts/experimental/initial_guess/tx_trace_CVGETH.jpg b/contracts/experimental/initial_guess/tx_trace_CVGETH.jpg new file mode 100644 index 00000000..44f44689 Binary files /dev/null and b/contracts/experimental/initial_guess/tx_trace_CVGETH.jpg differ diff --git a/contracts/main/CurveCryptoMathOptimized2.vy b/contracts/main/CurveCryptoMathOptimized2.vy index 456a9e14..4bc0a644 100644 --- a/contracts/main/CurveCryptoMathOptimized2.vy +++ b/contracts/main/CurveCryptoMathOptimized2.vy @@ -18,7 +18,8 @@ N_COINS: constant(uint256) = 2 A_MULTIPLIER: constant(uint256) = 10000 MIN_GAMMA: constant(uint256) = 10**10 -MAX_GAMMA: constant(uint256) = 2 * 10**15 +MAX_GAMMA_SMALL: constant(uint256) = 2 * 10**16 +MAX_GAMMA: constant(uint256) = 199 * 10**15 # 1.99 * 10**17 MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 10 MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000 @@ -139,7 +140,7 @@ def _cbrt(x: uint256) -> uint256: @internal @pure -def _newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: uint256) -> uint256: +def _newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: uint256, lim_mul: uint256) -> uint256: """ Calculating x[i] given other balances x[0..N_COINS-1] and invariant D ANN = A * N**N @@ -150,7 +151,7 @@ def _newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: y: uint256 = D**2 / (x_j * N_COINS**2) K0_i: uint256 = (10**18 * N_COINS) * x_j / D - assert (K0_i > 10**16*N_COINS - 1) and (K0_i < 10**20*N_COINS + 1) # dev: unsafe values x[i] + assert (K0_i >= unsafe_div(10**36, lim_mul)) and (K0_i <= lim_mul) # dev: unsafe values x[i] convergence_limit: uint256 = max(max(x_j / 10**14, D / 10**14), 100) @@ -212,10 +213,13 @@ def newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: u 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 + lim_mul: uint256 = 100 * 10**18 # 100.0 + if gamma > MAX_GAMMA_SMALL: + lim_mul = unsafe_div(unsafe_mul(lim_mul, MAX_GAMMA_SMALL), gamma) # smaller than 100.0 - y: uint256 = self._newton_y(ANN, gamma, x, D, i) + y: uint256 = self._newton_y(ANN, gamma, x, D, i, lim_mul) frac: uint256 = y * 10**18 / D - assert (frac >= 10**16 - 1) and (frac < 10**20 + 1) # dev: unsafe value for y + assert (frac >= unsafe_div(10**36 / N_COINS, lim_mul)) and (frac <= unsafe_div(lim_mul, N_COINS)) # dev: unsafe value for y return y @@ -234,6 +238,10 @@ def get_y( 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 + lim_mul: uint256 = 100 * 10**18 # 100.0 + if _gamma > MAX_GAMMA_SMALL: + lim_mul = unsafe_div(unsafe_mul(lim_mul, MAX_GAMMA_SMALL), _gamma) # smaller than 100.0 + lim_mul_signed: int256 = convert(lim_mul, int256) ANN: int256 = convert(_ANN, int256) gamma: int256 = convert(_gamma, int256) @@ -246,7 +254,7 @@ def get_y( # K0_i: int256 = (10**18 * N_COINS) * x_j / D K0_i: int256 = unsafe_div(10**18 * N_COINS * x_j, D) - assert (K0_i > 10**16 * N_COINS - 1) and (K0_i < 10**20 * N_COINS + 1) # dev: unsafe values x[i] + assert (K0_i >= unsafe_div(10**36, lim_mul_signed)) and (K0_i <= lim_mul_signed) # dev: unsafe values x[i] ann_gamma2: int256 = ANN * gamma2 @@ -327,7 +335,7 @@ def get_y( sqrt_val = convert(isqrt(convert(sqrt_arg, uint256)), int256) else: return [ - self._newton_y(_ANN, _gamma, _x, _D, i), + self._newton_y(_ANN, _gamma, _x, _D, i, lim_mul), 0 ] @@ -358,7 +366,7 @@ def get_y( y_out: uint256[2] = [convert(unsafe_div(unsafe_div(unsafe_mul(unsafe_div(D**2, x_j), root), 4), 10**18), uint256), convert(root, uint256)] frac: uint256 = unsafe_div(y_out[0] * 10**18, _D) - assert (frac >= 10**16 - 1) and (frac < 10**20 + 1) # dev: unsafe value for y + assert (frac >= unsafe_div(10**36 / N_COINS, lim_mul)) and (frac <= unsafe_div(lim_mul, N_COINS)) # dev: unsafe value for y return y_out @@ -446,7 +454,7 @@ def newton_D(ANN: uint256, gamma: uint256, x_unsorted: uint256[N_COINS], K0_prev 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] + assert (frac > 10**16 / N_COINS - 1) and (frac < 10**20 / N_COINS + 1) # dev: unsafe values x[i] return D raise "Did not converge" diff --git a/contracts/main/CurveTwocryptoOptimized.vy b/contracts/main/CurveTwocryptoOptimized.vy index 4c409b59..899c0159 100644 --- a/contracts/main/CurveTwocryptoOptimized.vy +++ b/contracts/main/CurveTwocryptoOptimized.vy @@ -100,7 +100,6 @@ event NewParameters: allowed_extra_profit: uint256 adjustment_step: uint256 ma_time: uint256 - xcp_ma_time: uint256 event RampAgamma: initial_A: uint256 @@ -132,12 +131,9 @@ factory: public(immutable(Factory)) cached_price_scale: uint256 # <------------------------ Internal price scale. cached_price_oracle: uint256 # <------- Price target given by moving average. -cached_xcp_oracle: uint256 # <----------- EMA of totalSupply * virtual_price. last_prices: public(uint256) last_timestamp: public(uint256) # idx 0 is for prices, idx 1 is for xcp. -last_xcp: public(uint256) -xcp_ma_time: public(uint256) initial_A_gamma: public(uint256) initial_A_gamma_time: public(uint256) @@ -183,7 +179,7 @@ MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 10 MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000 MAX_A_CHANGE: constant(uint256) = 10 MIN_GAMMA: constant(uint256) = 10**10 -MAX_GAMMA: constant(uint256) = 5 * 10**16 +MAX_GAMMA: constant(uint256) = 199 * 10**15 # 1.99 * 10**17 # ----------------------- ERC20 Specific vars -------------------------------- @@ -258,9 +254,8 @@ def __init__( self.cached_price_scale = initial_price self.cached_price_oracle = initial_price self.last_prices = initial_price - self.last_timestamp = self._pack_2(block.timestamp, block.timestamp) + self.last_timestamp = block.timestamp self.xcp_profit_a = 10**18 - self.xcp_ma_time = 62324 # <--------- 12 hours default on contract start. # Cache DOMAIN_SEPARATOR. If chain.id is not CACHED_CHAIN_ID, then # DOMAIN_SEPARATOR will be re-calculated each time `permit` is called. @@ -558,9 +553,6 @@ def add_liquidity( self.xcp_profit = 10**18 self.xcp_profit_a = 10**18 - # Initialise xcp oracle here: - self.cached_xcp_oracle = d_token # <--- virtual_price * totalSupply / 10**18 - self.mint(receiver, d_token) assert d_token >= min_mint_amount, "Slippage" @@ -641,38 +633,6 @@ def remove_liquidity( log RemoveLiquidity(msg.sender, withdraw_amounts, total_supply - _amount) - # --------------------------- Upkeep xcp oracle -------------------------- - - # Update xcp since liquidity was removed: - xp: uint256[N_COINS] = self.xp(self.balances, self.cached_price_scale) - last_xcp: uint256 = isqrt(xp[0] * xp[1]) # <----------- Cache it for now. - - last_timestamp: uint256[2] = self._unpack_2(self.last_timestamp) - if last_timestamp[1] < block.timestamp: - - cached_xcp_oracle: uint256 = self.cached_xcp_oracle - alpha: uint256 = MATH.wad_exp( - -convert( - unsafe_div( - unsafe_sub(block.timestamp, last_timestamp[1]) * 10**18, - self.xcp_ma_time # <---------- xcp ma time has is longer. - ), - int256, - ) - ) - - self.cached_xcp_oracle = unsafe_div( - last_xcp * (10**18 - alpha) + cached_xcp_oracle * alpha, - 10**18 - ) - last_timestamp[1] = block.timestamp - - # Pack and store timestamps: - self.last_timestamp = self._pack_2(last_timestamp[0], last_timestamp[1]) - - # Store last xcp - self.last_xcp = last_xcp - return withdraw_amounts @@ -880,11 +840,11 @@ def tweak_price( old_xcp_profit: uint256 = self.xcp_profit old_virtual_price: uint256 = self.virtual_price - # ----------------------- Update Oracles if needed ----------------------- + # ------------------ Update Price Oracle if needed ----------------------- - last_timestamp: uint256[2] = self._unpack_2(self.last_timestamp) + last_timestamp: uint256 = self.last_timestamp alpha: uint256 = 0 - if last_timestamp[0] < block.timestamp: # 0th index is for price_oracle. + if last_timestamp < block.timestamp: # 0th index is for price_oracle. # The moving average price oracle is calculated using the last_price # of the trade at the previous block, and the price oracle logged @@ -895,7 +855,7 @@ def tweak_price( alpha = MATH.wad_exp( -convert( unsafe_div( - unsafe_sub(block.timestamp, last_timestamp[0]) * 10**18, + unsafe_sub(block.timestamp, last_timestamp) * 10**18, rebalancing_params[2] # <----------------------- ma_time. ), int256, @@ -913,32 +873,7 @@ def tweak_price( ) self.cached_price_oracle = price_oracle - last_timestamp[0] = block.timestamp - - # ----------------------------------------------------- Update xcp oracle. - - if last_timestamp[1] < block.timestamp: - - cached_xcp_oracle: uint256 = self.cached_xcp_oracle - alpha = MATH.wad_exp( - -convert( - unsafe_div( - unsafe_sub(block.timestamp, last_timestamp[1]) * 10**18, - self.xcp_ma_time # <---------- xcp ma time has is longer. - ), - int256, - ) - ) - - self.cached_xcp_oracle = unsafe_div( - self.last_xcp * (10**18 - alpha) + cached_xcp_oracle * alpha, - 10**18 - ) - - # Pack and store timestamps: - last_timestamp[1] = block.timestamp - - self.last_timestamp = self._pack_2(last_timestamp[0], last_timestamp[1]) + self.last_timestamp = block.timestamp # `price_oracle` is used further on to calculate its vector distance from # price_scale. This distance is used to calculate the amount of adjustment @@ -982,11 +917,8 @@ def tweak_price( # ensure new virtual_price is not less than old virtual_price, # else the pool suffers a loss. if self.future_A_gamma_time < block.timestamp: - assert virtual_price > old_virtual_price, "Loss" - - # -------------------------- Cache last_xcp -------------------------- - - self.last_xcp = xcp # geometric_mean(D * price_scale) + # this usually reverts when withdrawing a very small amount of LP tokens + assert virtual_price > old_virtual_price # dev: virtual price decreased self.xcp_profit = xcp_profit @@ -1033,10 +965,6 @@ def tweak_price( # ------------------------------------------ Update D with new xp. D: uint256 = MATH.newton_D(A_gamma[0], A_gamma[1], xp, 0) - for k in range(N_COINS): - frac: uint256 = xp[k] * 10**18 / D # <----- Check validity of - assert (frac > 10**16 - 1) and (frac < 10**20 + 1) # p_new. - # ------------------------------------- Convert xp to real prices. xp = [ unsafe_div(D, N_COINS), @@ -1531,7 +1459,7 @@ def internal_price_oracle() -> uint256: """ price_oracle: uint256 = self.cached_price_oracle price_scale: uint256 = self.cached_price_scale - last_prices_timestamp: uint256 = self._unpack_2(self.last_timestamp)[0] + last_prices_timestamp: uint256 = self.last_timestamp if last_prices_timestamp < block.timestamp: # <------------ Update moving # average if needed. @@ -1662,41 +1590,6 @@ def price_oracle() -> uint256: return self.internal_price_oracle() -@external -@view -@nonreentrant("lock") -def xcp_oracle() -> uint256: - """ - @notice Returns the oracle value for xcp. - @dev The oracle is an exponential moving average, with a periodicity - determined by `self.xcp_ma_time`. - `TVL` is xcp, calculated as either: - 1. virtual_price * total_supply, OR - 2. self.get_xcp(...), OR - 3. MATH.geometric_mean(xp) - @return uint256 Oracle value of xcp. - """ - - last_prices_timestamp: uint256 = self._unpack_2(self.last_timestamp)[1] - cached_xcp_oracle: uint256 = self.cached_xcp_oracle - - if last_prices_timestamp < block.timestamp: - - alpha: uint256 = MATH.wad_exp( - -convert( - unsafe_div( - unsafe_sub(block.timestamp, last_prices_timestamp) * 10**18, - self.xcp_ma_time - ), - int256, - ) - ) - - return (self.last_xcp * (10**18 - alpha) + cached_xcp_oracle * alpha) / 10**18 - - return cached_xcp_oracle - - @external @view @nonreentrant("lock") @@ -1884,7 +1777,7 @@ def ramp_A_gamma( @param future_time The timestamp at which the ramping will end. """ assert msg.sender == factory.admin() # dev: only owner - assert block.timestamp > self.initial_A_gamma_time + (MIN_RAMP_TIME - 1) # dev: ramp undergoing + assert block.timestamp > self.future_A_gamma_time # dev: ramp undergoing assert future_time > block.timestamp + MIN_RAMP_TIME - 1 # dev: insufficient time A_gamma: uint256[2] = self._A_gamma() @@ -1897,12 +1790,12 @@ def ramp_A_gamma( assert future_gamma < MAX_GAMMA + 1 ratio: uint256 = 10**18 * future_A / A_gamma[0] - assert ratio < 10**18 * MAX_A_CHANGE + 1 - assert ratio > 10**18 / MAX_A_CHANGE - 1 + assert ratio < 10**18 * MAX_A_CHANGE + 1 # dev: A change too high + assert ratio > 10**18 / MAX_A_CHANGE - 1 # dev: A change too low ratio = 10**18 * future_gamma / A_gamma[1] - assert ratio < 10**18 * MAX_A_CHANGE + 1 - assert ratio > 10**18 / MAX_A_CHANGE - 1 + assert ratio < 10**18 * MAX_A_CHANGE + 1 # dev: gamma change too high + assert ratio > 10**18 / MAX_A_CHANGE - 1 # dev: gamma change too low self.initial_A_gamma = initial_A_gamma self.initial_A_gamma_time = block.timestamp @@ -1952,7 +1845,6 @@ def apply_new_parameters( _new_allowed_extra_profit: uint256, _new_adjustment_step: uint256, _new_ma_time: uint256, - _new_xcp_ma_time: uint256, ): """ @notice Commit new parameters. @@ -1963,7 +1855,6 @@ def apply_new_parameters( @param _new_allowed_extra_profit The new allowed extra profit. @param _new_adjustment_step The new adjustment step. @param _new_ma_time The new ma time. ma_time is time_in_seconds/ln(2). - @param _new_xcp_ma_time The new ma time for xcp oracle. """ assert msg.sender == factory.admin() # dev: only owner @@ -2014,14 +1905,6 @@ def apply_new_parameters( [new_allowed_extra_profit, new_adjustment_step, new_ma_time] ) - # Set xcp oracle moving average window time: - new_xcp_ma_time: uint256 = _new_xcp_ma_time - if new_xcp_ma_time < 872542: - assert new_xcp_ma_time > 86 # dev: xcp MA time should be longer than 60/ln(2) - else: - new_xcp_ma_time = self.xcp_ma_time - self.xcp_ma_time = new_xcp_ma_time - # ---------------------------------- LOG --------------------------------- log NewParameters( @@ -2031,5 +1914,4 @@ def apply_new_parameters( new_allowed_extra_profit, new_adjustment_step, new_ma_time, - _new_xcp_ma_time, ) diff --git a/contracts/mocks/ERC20Mock.vy b/contracts/mocks/ERC20Mock.vy index c3e50b7a..fc1156e5 100644 --- a/contracts/mocks/ERC20Mock.vy +++ b/contracts/mocks/ERC20Mock.vy @@ -66,6 +66,6 @@ def approve(_spender: address, _value: uint256) -> bool: def _mint_for_testing(_target: address, _value: uint256) -> bool: self.totalSupply += _value self.balanceOf[_target] += _value - log Transfer(ZERO_ADDRESS, _target, _value) + log Transfer(empty(address), _target, _value) return True diff --git a/contracts/old/CurveCryptoSwap2Math.vy b/contracts/old/CurveCryptoSwap2Math.vy index 724c8d40..246cedf3 100644 --- a/contracts/old/CurveCryptoSwap2Math.vy +++ b/contracts/old/CurveCryptoSwap2Math.vy @@ -5,7 +5,8 @@ N_COINS: constant(uint256) = 2 A_MULTIPLIER: constant(uint256) = 10000 MIN_GAMMA: constant(uint256) = 10**10 -MAX_GAMMA: constant(uint256) = 2 * 10**16 +MAX_GAMMA_SMALL: constant(uint256) = 2 * 10**16 +MAX_GAMMA: constant(uint256) = 3 * 10**17 MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 10 MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000 @@ -50,6 +51,9 @@ def newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: u 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 + lim_mul: uint256 = 100 * 10**18 # 100.0 + if gamma > MAX_GAMMA_SMALL: + lim_mul = unsafe_div(unsafe_mul(lim_mul, MAX_GAMMA_SMALL), gamma) # smaller than 100.0 x_j: uint256 = x[1 - i] y: uint256 = D**2 / (x_j * N_COINS**2) @@ -57,7 +61,7 @@ def newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: u # S_i = x_j # frac = x_j * 1e18 / D => frac = K0_i / N_COINS - assert (K0_i > 10**16*N_COINS - 1) and (K0_i < 10**20*N_COINS + 1) # dev: unsafe values x[i] + assert (K0_i >= 10**36 / lim_mul) and (K0_i <= lim_mul), "unsafe values x[i]" # x_sorted: uint256[N_COINS] = x # x_sorted[i] = 0 @@ -111,7 +115,7 @@ def newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: u 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 + assert (frac >= 10**36 / N_COINS / lim_mul) and (frac <= lim_mul / N_COINS), "unsafe value for y" return y raise "Did not converge" diff --git a/requirements.txt b/requirements.txt index 931c4383..14e98ef9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,18 +10,20 @@ pre-commit eip712 eth_account ipython -hypothesis +hypothesis==6.74.0 pytest pytest-xdist pytest-forked pytest-repeat pdbpp -hypothesis>=6.68.1 # analytics pandas matplotlib -# vyper and dev framework: -git+https://github.com/vyperlang/titanoboa@8c2f673c10439d13b976d1f1667462810379f010 +# other deps (needed for pypy) +cytoolz + +# vyper and dev framework (boa interpreter): +git+https://github.com/vyperlang/titanoboa@1bf16f916b91aab299e293b4148be455c1674706 vyper>=0.3.10 diff --git a/scripts/experiments/compare_versions.ipynb b/scripts/experiments/compare_versions.ipynb index 648b79f1..58006c9c 100644 --- a/scripts/experiments/compare_versions.ipynb +++ b/scripts/experiments/compare_versions.ipynb @@ -868,7 +868,7 @@ " out_ng = ng_swap.exchange(i, 1-i, trade_amount, 0, sender=trader)\n", " \n", " # store data:\n", - " data[\"timestamp\"].append(boa.env.vm.state.timestamp)\n", + " data[\"timestamp\"].append(boa.env.evm.patch.timestamp)\n", " \n", " data[\"old_swap_out_amt\"].append(out_old / coin_precisions[1-i])\n", " data[\"ng_swap_out_amt\"].append(out_ng / coin_precisions[1-1])\n", diff --git a/scripts/experiments/max_newton_D.ipynb b/scripts/experiments/max_newton_D.ipynb new file mode 100644 index 00000000..48a8d667 --- /dev/null +++ b/scripts/experiments/max_newton_D.ipynb @@ -0,0 +1,233 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import boa\n", + "%load_ext boa.ipython" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "%%vyper MAXNewton_D\n", + "\n", + "# pragma version 0.3.10\n", + "# pragma optimize gas\n", + "# pragma evm-version paris\n", + "\n", + "\n", + "N_COINS: constant(uint256) = 2\n", + "A_MULTIPLIER: constant(uint256) = 10000\n", + "\n", + "MIN_GAMMA: constant(uint256) = 10**10\n", + "MAX_GAMMA_SMALL: constant(uint256) = 2 * 10**16\n", + "MAX_GAMMA: constant(uint256) = 199 * 10**15 # 1.99 * 10**17\n", + "\n", + "MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 10\n", + "MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000\n", + "\n", + "MAX_ITER: constant(uint256) = 255\n", + "\n", + "\n", + "@external\n", + "@view\n", + "def newton_D() -> (\n", + " uint256[MAX_ITER],\n", + " uint256[MAX_ITER],\n", + " uint256[MAX_ITER],\n", + " uint256[MAX_ITER],\n", + " uint256[MAX_ITER],\n", + " uint256[MAX_ITER],\n", + " uint256[MAX_ITER],\n", + " uint256[MAX_ITER]\n", + "):\n", + " \"\"\"\n", + " Finding the invariant using Newton method.\n", + " ANN is higher by the factor A_MULTIPLIER\n", + " ANN is already A * N**N\n", + " \"\"\"\n", + "\n", + " # # Safety checks\n", + " # assert ANN > MIN_A - 1 and ANN < MAX_A + 1 # dev: unsafe values A\n", + " # assert gamma > MIN_GAMMA - 1 and gamma < MAX_GAMMA + 1 # dev: unsafe values gamma\n", + "\n", + " # # Initial value of invariant D is that for constant-product invariant\n", + " # x: uint256[N_COINS] = x_unsorted\n", + " # if x[0] < x[1]:\n", + " # x = [x_unsorted[1], x_unsorted[0]]\n", + "\n", + " # assert x[0] > 10**9 - 1 and x[0] < 10**15 * 10**18 + 1 # dev: unsafe values x[0]\n", + " # assert unsafe_div(x[1] * 10**18, x[0]) > 10**14 - 1 # dev: unsafe values x[i] (input)\n", + "\n", + " ANN: uint256 = MAX_A\n", + " gamma: uint256 = MAX_GAMMA\n", + "\n", + " x: uint256[N_COINS] = [10**15 * 10**18, 10**15 * 10**18]\n", + " S: uint256 = unsafe_add(x[0], x[1]) # can unsafe add here because we checked x[0] bounds\n", + " D: uint256 = N_COINS * isqrt(unsafe_mul(x[0], x[1]))\n", + "\n", + " __g1k0: uint256 = gamma + 10**18\n", + " diff: uint256 = 0\n", + "\n", + " K0_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + " _g1k0_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + " mul1_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + " mul2_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + " neg_fprime_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + " D_plus_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + " D_minus_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + " D_iter: uint256[MAX_ITER] = empty(uint256[MAX_ITER])\n", + "\n", + " for i in range(MAX_ITER):\n", + " D_prev: uint256 = D\n", + " assert D > 0\n", + " # Unsafe division by D and D_prev is now safe\n", + "\n", + " # K0: uint256 = 10**18\n", + " # for _x in x:\n", + " # K0 = K0 * _x * N_COINS / D\n", + " # collapsed for 2 coins\n", + " K0: uint256 = unsafe_div(unsafe_div((10**18 * N_COINS**2) * x[0], D) * x[1], D)\n", + "\n", + " _g1k0: uint256 = __g1k0\n", + " if _g1k0 > K0:\n", + " _g1k0 = unsafe_add(unsafe_sub(_g1k0, K0), 1) # > 0\n", + " else:\n", + " _g1k0 = unsafe_add(unsafe_sub(K0, _g1k0), 1) # > 0\n", + " # K0 is greater than 0\n", + " # _g1k0 is greater than 0\n", + "\n", + " # D / (A * N**N) * _g1k0**2 / gamma**2\n", + " mul1: uint256 = unsafe_div(unsafe_div(unsafe_div(10**18 * D, gamma) * _g1k0, gamma) * _g1k0 * A_MULTIPLIER, ANN)\n", + "\n", + " # 2*N*K0 / _g1k0\n", + " mul2: uint256 = unsafe_div(((2 * 10**18) * N_COINS) * K0, _g1k0)\n", + "\n", + " # calculate neg_fprime. here K0 > 0 is being validated (safediv).\n", + " neg_fprime: uint256 = (\n", + " S +\n", + " unsafe_div(S * mul2, 10**18) +\n", + " mul1 * N_COINS / K0 -\n", + " unsafe_div(mul2 * D, 10**18)\n", + " )\n", + "\n", + " # D -= f / fprime; neg_fprime safediv being validated\n", + " D_plus: uint256 = D * (neg_fprime + S) / neg_fprime\n", + " D_minus: uint256 = unsafe_div(D * D, neg_fprime)\n", + " if 10**18 > K0:\n", + " D_minus += unsafe_div(unsafe_div(D * unsafe_div(mul1, neg_fprime), 10**18) * unsafe_sub(10**18, K0), K0)\n", + " else:\n", + " D_minus -= unsafe_div(unsafe_div(D * unsafe_div(mul1, neg_fprime), 10**18) * unsafe_sub(K0, 10**18), K0)\n", + "\n", + " if D_plus > D_minus:\n", + " D = unsafe_sub(D_plus, D_minus)\n", + " else:\n", + " D = unsafe_div(unsafe_sub(D_minus, D_plus), 2)\n", + "\n", + " K0_iter[i] = K0\n", + " _g1k0_iter[i] = _g1k0\n", + " mul1_iter[i] = mul1\n", + " mul2_iter[i] = mul2\n", + " neg_fprime_iter[i] = neg_fprime\n", + " D_plus_iter[i] = D_plus\n", + " D_minus_iter[i] = D_minus\n", + " D_iter[i] = D\n", + "\n", + " return K0_iter, _g1k0_iter, mul1_iter, mul2_iter, neg_fprime_iter, D_plus_iter, D_minus_iter, D_iter" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "math_contract = MAXNewton_D.deploy()\n", + "output = math_contract.newton_D()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "K0_iter = output[0]\n", + "_g1k0_iter = output[1]\n", + "mul1_iter = output[2]\n", + "mul2_iter = output[3]\n", + "neg_fprime_iter = output[4]\n", + "D_plus_iter = output[5]\n", + "D_minus_iter = output[6]\n", + "D_iter = output[7]" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "255" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "D_iter = output[-1]\n", + "len(D_iter)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/fixtures/pool.py b/tests/fixtures/pool.py index de4c2f66..89f9a07b 100644 --- a/tests/fixtures/pool.py +++ b/tests/fixtures/pool.py @@ -59,7 +59,6 @@ def params(): "fee_gamma": 230000000000000, "adjustment_step": 146000000000000, "ma_time": 866, # # 600 seconds//math.log(2) - "xcp_ma_time": 62324, # 12 hours//math.log(2) "initial_prices": INITIAL_PRICES, } diff --git a/tests/integration/test_create2_deployment.py b/tests/integration/test_create2_deployment.py index 3a45b31f..f6f7e64a 100644 --- a/tests/integration/test_create2_deployment.py +++ b/tests/integration/test_create2_deployment.py @@ -11,7 +11,10 @@ def forked_chain(): assert ( rpc_url is not None ), "Provider url is not set, add RPC_ETHEREUM param to env" - boa.env.fork(url=rpc_url) + env = boa.Env() + env.fork(rpc_url) + with boa.swap_env(env): + yield @pytest.fixture(scope="module") diff --git a/tests/profiling/conftest.py b/tests/profiling/conftest.py new file mode 100644 index 00000000..63a69cad --- /dev/null +++ b/tests/profiling/conftest.py @@ -0,0 +1,175 @@ +import boa +import pytest +from hypothesis import assume + +from contracts.experimental.initial_guess import ( + CurveCryptoMathOptimized2 as math_deployer_initial_guess, +) +from contracts.experimental.initial_guess import ( + CurveTwocryptoOptimized as amm_deployer_initial_guess, +) + +# compiling contracts +from contracts.main import CurveCryptoMathOptimized2 as math_deployer +from contracts.main import CurveCryptoViews2Optimized as view_deployer +from contracts.main import CurveTwocryptoFactory as factory_deployer +from contracts.main import CurveTwocryptoOptimized as amm_deployer +from tests.utils.tokens import mint_for_testing + +# ---------------- addresses ---------------- +address = boa.test.strategy("address") +deployer = address +fee_receiver = address +owner = address +params = { + "A": 400000, + "gamma": 145000000000000, + "mid_fee": 26000000, + "out_fee": 45000000, + "allowed_extra_profit": 2000000000000, + "fee_gamma": 230000000000000, + "adjustment_step": 146000000000000, + "ma_exp_time": 866, # # 600 seconds//math.log(2) + "price": 1 * 10**18, +} + + +def _deposit_initial_liquidity(pool, tokens): + + # deposit: + user = boa.env.generate_address() + quantities = [ + 10**6 * 10**36 // p for p in [10**18, params["price"]] + ] # $2M worth + + for coin, quantity in zip(tokens, quantities): + # mint coins for user: + mint_for_testing(coin, user, quantity) + assert coin.balanceOf(user) == quantity + + # approve crypto_swap to trade coin for user: + with boa.env.prank(user): + coin.approve(pool, 2**256 - 1) + + # Very first deposit + with boa.env.prank(user): + pool.add_liquidity(quantities, 0) + + return pool + + +@pytest.fixture(scope="module") +def tokens(): + return [ + boa.load("contracts/mocks/ERC20Mock.vy", "tkn_a", "tkn_a", 18), + boa.load("contracts/mocks/ERC20Mock.vy", "tkn_b", "tkn_b", 18), + ] + + +@pytest.fixture(scope="module") +def factory_no_initial_guess(): + + _deployer = boa.env.generate_address() + _fee_receiver = boa.env.generate_address() + _owner = boa.env.generate_address() + + with boa.env.prank(_deployer): + + amm_implementation = amm_deployer.deploy_as_blueprint() + math_contract = math_deployer.deploy() + view_contract = view_deployer.deploy() + + _factory = factory_deployer.deploy() + _factory.initialise_ownership(_fee_receiver, _owner) + + with boa.env.prank(_owner): + _factory.set_views_implementation(view_contract) + _factory.set_math_implementation(math_contract) + + # set pool implementations: + _factory.set_pool_implementation(amm_implementation, 0) + + return _factory + + +@pytest.fixture(scope="module") +def factory_initial_guess(): + + _deployer = boa.env.generate_address() + _fee_receiver = boa.env.generate_address() + _owner = boa.env.generate_address() + + assume(_fee_receiver != _owner != _deployer) + + with boa.env.prank(_deployer): + amm_implementation = amm_deployer_initial_guess.deploy_as_blueprint() + math_contract = math_deployer_initial_guess.deploy() + view_contract = view_deployer.deploy() + + _factory = factory_deployer.deploy() + _factory.initialise_ownership(_fee_receiver, _owner) + + with boa.env.prank(_owner): + _factory.set_views_implementation(view_contract) + _factory.set_math_implementation(math_contract) + + # set pool implementations: + _factory.set_pool_implementation(amm_implementation, 0) + + return _factory + + +@pytest.fixture(scope="module") +def pool(factory, tokens): + + with boa.env.prank(boa.env.generate_address()): + _pool = factory.deploy_pool( + "test_A", + "test_A", + tokens, + 0, + params["A"], + params["gamma"], + params["mid_fee"], + params["out_fee"], + params["fee_gamma"], + params["allowed_extra_profit"], + params["adjustment_step"], + params["ma_exp_time"], + params["price"], + ) + + _pool = amm_deployer.at(_pool) + return _deposit_initial_liquidity(_pool, tokens) + + +@pytest.fixture(scope="module") +def pool_initial_guess(factory_initial_guess, tokens): + + with boa.env.prank(boa.env.generate_address()): + _pool = factory_initial_guess.deploy_pool( + "test_B", + "test_B", + tokens, + 0, + params["A"], + params["gamma"], + params["mid_fee"], + params["out_fee"], + params["fee_gamma"], + params["allowed_extra_profit"], + params["adjustment_step"], + params["ma_exp_time"], + params["price"], + ) + + _pool = amm_deployer_initial_guess.at(_pool) + return _deposit_initial_liquidity(_pool, tokens) + + +@pytest.fixture(scope="module") +def pools(pool, pool_initial_guess): + return [ + pool, + # pool_initial_guess, + ] diff --git a/tests/profiling/test_boa_profile.py b/tests/profiling/test_boa_profile.py new file mode 100644 index 00000000..cb49ca54 --- /dev/null +++ b/tests/profiling/test_boa_profile.py @@ -0,0 +1,70 @@ +import random + +import boa +import pytest + +from tests.utils.tokens import mint_for_testing + +NUM_RUNS = 100 +N_COINS = 2 + + +def _choose_indices(): + i = random.randint(0, N_COINS - 1) + j = 0 if i == 1 else 1 + return i, j + + +@pytest.mark.gas_profile +def test_profile_amms(pools, tokens): + + user = boa.env.generate_address() + + for pool in pools: + + for coin in tokens: + mint_for_testing(coin, user, 10**50) + coin.approve(pool, 2**256 - 1, sender=user) + + with boa.env.prank(user): + + for k in range(NUM_RUNS): + + # proportional deposit: + balances = [pool.balances(i) for i in range(N_COINS)] + amount_first_coin = random.uniform(0, 0.05) * 10 ** ( + 18 + random.randint(1, 3) + ) + amounts = [ + int(amount_first_coin), + int(amount_first_coin * 1e18 // pool.price_scale()), + ] + pool.add_liquidity(amounts, 0) + boa.env.time_travel(random.randint(12, 600)) + + # deposit single token: + balances = [pool.balances(i) for i in range(N_COINS)] + c = random.uniform(0, 0.05) + i = random.randint(0, N_COINS - 1) + amounts = [0] * N_COINS + for j in range(N_COINS): + if i == j: + amounts[i] = int(balances[i] * c) + pool.add_liquidity(amounts, 0) + boa.env.time_travel(random.randint(12, 600)) + + # swap: + i, j = _choose_indices() + amount = int(pool.balances(i) * 0.01) + pool.exchange(i, j, amount, 0) + boa.env.time_travel(random.randint(12, 600)) + + # withdraw proportionally: + amount = int(pool.balanceOf(user) * random.uniform(0, 0.01)) + pool.remove_liquidity(amount, [0] * N_COINS) + boa.env.time_travel(random.randint(12, 600)) + + # withdraw in one coin: + i = random.randint(0, N_COINS - 1) + amount = int(pool.balanceOf(user) * 0.01) + pool.remove_liquidity_one_coin(amount, i, 0) diff --git a/tests/unitary/math/README.md b/tests/unitary/math/README.md new file mode 100644 index 00000000..a802eb43 --- /dev/null +++ b/tests/unitary/math/README.md @@ -0,0 +1,17 @@ +# Math contract tests + +### Fuzzing parallelization +Due to the nature of the math involved in curve pools (i.e. analytical solutions for equations not always availble), we often require approximation methods to solve these equations numerically. Testing this requires extensive fuzzing which can be very time consuming sometimes. Hypothesis does not support test parallelisation and this is why in the code we use test parametrisation as a hacky way to obtain parallel fuzzing with `xdist`: + +```python +@pytest.mark.parametrize( + "_tmp", range(N_CASES) +) # Parallelisation hack (more details in folder's README) +``` + +### Useful info +- We have proven (mathemtaically) that in `(0, x + y)` newton_D either converges or reverts. Converging to a wrong value is not possible since there's only one root in `(0, x + y)`. (should add link to proof once available, ask george if this still isn't available). + +### Checklist when modifying functions using on Newton's method +- Make sure that the function still converges in all instances where it used to before. +- The number of iterations required to converge should not increase significantly. diff --git a/tests/unitary/math/contracts/newton_y_exposed.vy b/tests/unitary/math/contracts/newton_y_exposed.vy new file mode 100644 index 00000000..1dcf36d7 --- /dev/null +++ b/tests/unitary/math/contracts/newton_y_exposed.vy @@ -0,0 +1,78 @@ +# Minimized version of the math contracts that expose some inner details of newton_y for testing purposes: +# - Additionally to the final value it also returns the number of iterations it took to find the value. +# From commit: 6dec22f6956cc04fb865d93c1e521f146e066cab + +N_COINS: constant(uint256) = 2 +A_MULTIPLIER: constant(uint256) = 10000 + +MIN_GAMMA: constant(uint256) = 10**10 +MAX_GAMMA: constant(uint256) = 3 * 10**17 + +MIN_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER / 10 +MAX_A: constant(uint256) = N_COINS**N_COINS * A_MULTIPLIER * 1000 + +@internal +@pure +def _newton_y(ANN: uint256, gamma: uint256, x: uint256[N_COINS], D: uint256, i: uint256, lim_mul: uint256) -> (uint256, uint256): + """ + Calculating x[i] given other balances x[0..N_COINS-1] and invariant D + ANN = A * N**N + This is computationally expensive. + """ + + x_j: uint256 = x[1 - i] + y: uint256 = D**2 / (x_j * N_COINS**2) + K0_i: uint256 = (10**18 * N_COINS) * x_j / D + + assert (K0_i >= unsafe_div(10**36, lim_mul)) and (K0_i <= lim_mul) # dev: unsafe values x[i] + + convergence_limit: uint256 = max(max(x_j / 10**14, D / 10**14), 100) + + for j in range(255): + y_prev: uint256 = y + + K0: uint256 = K0_i * y * N_COINS / D + S: uint256 = x_j + 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): + return y, j + + raise "Did not converge" diff --git a/tests/unitary/math/fuzz_multicoin_curve.py b/tests/unitary/math/fuzz_multicoin_curve.py deleted file mode 100644 index 84cd3e2f..00000000 --- a/tests/unitary/math/fuzz_multicoin_curve.py +++ /dev/null @@ -1,163 +0,0 @@ -# flake8: noqa -import unittest -from itertools import permutations - -import hypothesis.strategies as st -from hypothesis import given, settings - -from tests.utils.simulation_int_many import ( - Curve, - geometric_mean, - reduction_coefficient, - solve_D, - solve_x, -) - -MAX_EXAMPLES_MEAN = 20000 -MAX_EXAMPLES_RED = 20000 -MAX_EXAMPLES_D = 10000 -MAX_EXAMPLES_Y = 5000 -MAX_EXAMPLES_YD = 100000 -MAX_EXAMPLES_NOLOSS = 100000 -MIN_FEE = 5e-5 - -MIN_XD = 10**16 -MAX_XD = 10**20 - -N_COINS = 2 -A_MUL = 10000 -MIN_A = int(N_COINS**N_COINS * A_MUL / 10) -MAX_A = int(N_COINS**N_COINS * A_MUL * 1000) - -MIN_GAMMA = 10**10 -MAX_GAMMA = 2 * 10**15 - - -# Test with 2 coins -class TestCurve(unittest.TestCase): - @given( - x=st.integers(10**9, 10**15 * 10**18), - y=st.integers(10**9, 10**15 * 10**18), - ) - @settings(max_examples=MAX_EXAMPLES_MEAN) - def test_geometric_mean(self, x, y): - val = geometric_mean([x, y]) - assert val > 0 - diff = abs((x * y) ** (1 / 2) - val) - assert diff / val <= max(1e-10, 1 / min([x, y])) - - @given( - x=st.integers(10**9, 10**15 * 10**18), - y=st.integers(10**9, 10**15 * 10**18), - gamma=st.integers(10**10, 10**18), - ) - @settings(max_examples=MAX_EXAMPLES_RED) - def test_reduction_coefficient(self, x, y, gamma): - coeff = reduction_coefficient([x, y], gamma) - assert coeff <= 10**18 - - K = 2**2 * x * y / (x + y) ** 2 - if gamma > 0: - K = (gamma / 1e18) / ((gamma / 1e18) + 1 - K) - assert abs(coeff / 1e18 - K) <= 1e-7 - - @given( - A=st.integers(MIN_A, MAX_A), - x=st.integers(10**18, 10**15 * 10**18), # 1 USD to 1e15 USD - yx=st.integers( - 10**14, 10**18 - ), # <- ratio 1e18 * y/x, typically 1e18 * 1 - perm=st.integers(0, 1), # <- permutation mapping to values - gamma=st.integers(MIN_GAMMA, MAX_GAMMA), - ) - @settings(max_examples=MAX_EXAMPLES_D) - def test_D_convergence(self, A, x, yx, perm, gamma): - # Price not needed for convergence testing - pmap = list(permutations(range(2))) - - y = x * yx // 10**18 - curve = Curve(A, gamma, 10**18, 2) - curve.x = [0] * 2 - i, j = pmap[perm] - curve.x[i] = x - curve.x[j] = y - assert curve.D() > 0 - - @given( - A=st.integers(MIN_A, MAX_A), - x=st.integers(10**17, 10**15 * 10**18), # $0.1 .. $1e15 - yx=st.integers(10**15, 10**21), - gamma=st.integers(MIN_GAMMA, MAX_GAMMA), - i=st.integers(0, 1), - inx=st.integers(10**15, 10**21), - ) - @settings(max_examples=MAX_EXAMPLES_Y) - def test_y_convergence(self, A, x, yx, gamma, i, inx): - j = 1 - i - in_amount = x * inx // 10**18 - y = x * yx // 10**18 - curve = Curve(A, gamma, 10**18, 2) - curve.x = [x, y] - out_amount = curve.y(in_amount, i, j) - assert out_amount > 0 - - @given( - A=st.integers(MIN_A, MAX_A), - x=st.integers(10**17, 10**15 * 10**18), # 0.1 USD to 1e15 USD - yx=st.integers(5 * 10**14, 20 * 10**20), - gamma=st.integers(MIN_GAMMA, MAX_GAMMA), - i=st.integers(0, 1), - inx=st.integers(3 * 10**15, 3 * 10**20), - ) - @settings(max_examples=MAX_EXAMPLES_NOLOSS) - def test_y_noloss(self, A, x, yx, gamma, i, inx): - j = 1 - i - y = x * yx // 10**18 - curve = Curve(A, gamma, 10**18, 2) - curve.x = [x, y] - in_amount = x * inx // 10**18 - try: - out_amount = curve.y(in_amount, i, j) - D1 = curve.D() - except ValueError: - return # Convergence checked separately - we deliberately try unsafe numbers - is_safe = all( - f >= MIN_XD and f <= MAX_XD - for f in [xx * 10**18 // D1 for xx in curve.x] - ) - curve.x[i] = in_amount - curve.x[j] = out_amount - try: - D2 = curve.D() - except ValueError: - return # Convergence checked separately - we deliberately try unsafe numbers - is_safe &= all( - f >= MIN_XD and f <= MAX_XD - for f in [xx * 10**18 // D2 for xx in curve.x] - ) - if is_safe: - assert ( - 2 * (D1 - D2) / (D1 + D2) < MIN_FEE - ) # Only loss is prevented - gain is ok - - @given( - A=st.integers(MIN_A, MAX_A), - D=st.integers(10**18, 10**15 * 10**18), # 1 USD to 1e15 USD - xD=st.integers(MIN_XD, MAX_XD), - yD=st.integers(MIN_XD, MAX_XD), - gamma=st.integers(MIN_GAMMA, MAX_GAMMA), - j=st.integers(0, 1), - ) - @settings(max_examples=MAX_EXAMPLES_YD) - def test_y_from_D(self, A, D, xD, yD, gamma, j): - xp = [D * xD // 10**18, D * yD // 10**18] - y = solve_x(A, gamma, xp, D, j) - xp[j] = y - D2 = solve_D(A, gamma, xp) - assert ( - 2 * (D - D2) / (D2 + D) < MIN_FEE - ) # Only loss is prevented - gain is ok - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unitary/math/misc.py b/tests/unitary/math/misc.py deleted file mode 100644 index 2a36a01c..00000000 --- a/tests/unitary/math/misc.py +++ /dev/null @@ -1,39 +0,0 @@ -from decimal import Decimal - - -def get_y_n2_dec(ANN, gamma, x, D, i): - - if i == 0: - m = 1 - elif i == 1: - m = 0 - - A = Decimal(ANN) / 10**4 / 4 - gamma = Decimal(gamma) / 10**18 - x = [Decimal(_x) / 10**18 for _x in x] - D = Decimal(D) / 10**18 - - a = Decimal(16) * x[m] ** 3 / D**3 - b = 4 * A * gamma**2 * x[m] - (4 * (3 + 2 * gamma) * x[m] ** 2) / D - c = ( - D * (3 + 4 * gamma + (1 - 4 * A) * gamma**2) * x[m] - + 4 * A * gamma**2 * x[m] ** 2 - ) - d = -(Decimal(1) / 4) * D**3 * (1 + gamma) ** 2 - - delta0 = b**2 - 3 * a * c - delta1 = 2 * b**3 - 9 * a * b * c + 27 * a**2 * d - sqrt_arg = delta1**2 - 4 * delta0**3 - - if sqrt_arg < 0: - return [0, {}] - - sqrt = sqrt_arg ** (Decimal(1) / 2) - cbrt_arg = (delta1 + sqrt) / 2 - if cbrt_arg > 0: - C1 = cbrt_arg ** (Decimal(1) / 3) - else: - C1 = -((-cbrt_arg) ** (Decimal(1) / 3)) - root = -(b + C1 + delta0 / C1) / (3 * a) - - return [root, (a, b, c, d)] diff --git a/tests/unitary/math/test_get_y.py b/tests/unitary/math/test_get_y.py index a082062b..b13624c5 100644 --- a/tests/unitary/math/test_get_y.py +++ b/tests/unitary/math/test_get_y.py @@ -1,32 +1,21 @@ -# flake8: noqa -import time from decimal import Decimal import boa import pytest -from hypothesis import given, note, settings -from hypothesis import strategies as st +from hypothesis import event, given, note, settings +from hypothesis.strategies import integers -N_COINS = 2 -# MAX_SAMPLES = 1000000 # Increase for fuzzing -MAX_SAMPLES = 300 -N_CASES = 32 - -A_MUL = 10000 -MIN_A = int(N_COINS**N_COINS * A_MUL / 10) -MAX_A = int(N_COINS**N_COINS * A_MUL * 1000) - -MIN_GAMMA = 10**10 -MAX_GAMMA = 2 * 10**15 +from tests.utils.strategies import A, gamma -pytest.current_case_id = 0 -pytest.negative_sqrt_arg = 0 -pytest.gas_original = 0 -pytest.gas_new = 0 -pytest.t_start = time.time() +# you might want to increase this when fuzzing locally +MAX_SAMPLES = 10000 +N_CASES = 32 def inv_target_decimal_n2(A, gamma, x, D): + """Computes the inavriant (F) as described + in the whitepaper. + """ N = len(x) x_prod = Decimal(1) @@ -56,33 +45,33 @@ def test_get_y_revert(math_contract): D = 224824250915890636214130540882688 i = 0 - with boa.reverts(): + with boa.reverts(dev="unsafe values A"): math_contract.newton_y(a, gamma, x, D, i) - with boa.reverts(): + with boa.reverts(dev="unsafe values A"): math_contract.get_y(a, gamma, x, D, i) @pytest.mark.parametrize( "_tmp", range(N_CASES) -) # Create N_CASES independent test instances. +) # Parallelisation hack (more details in folder's README) @given( - A=st.integers(min_value=MIN_A, max_value=MAX_A), - D=st.integers( + A=A, + gamma=gamma, + D=integers( min_value=10**18, max_value=10**14 * 10**18 ), # 1 USD to 100T USD - xD=st.integers( - min_value=5 * 10**16, max_value=10**19 - ), # <- ratio 1e18 * x/D, typically 1e18 * 1 - yD=st.integers( - min_value=5 * 10**16, max_value=10**19 - ), # <- ratio 1e18 * y/D, typically 1e18 * 1 - gamma=st.integers(min_value=MIN_GAMMA, max_value=MAX_GAMMA), - j=st.integers(min_value=0, max_value=1), + xD=integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # ratio 1e18 * x/D, typically 1e18 * 1 + yD=integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # ratio 1e18 * y/D, typically 1e18 * 1 + j=integers(min_value=0, max_value=1), ) @settings(max_examples=MAX_SAMPLES, deadline=None) def test_get_y(math_unoptimized, math_optimized, A, D, xD, yD, gamma, j, _tmp): - pytest.current_case_id += 1 + # pytest.current_case_id += 1 X = [D * xD // 10**18, D * yD // 10**18] A_dec = Decimal(A) / 10000 / 4 @@ -92,11 +81,45 @@ def calculate_F_by_y0(y0): new_X[j] = y0 return inv_target_decimal_n2(A_dec, gamma, new_X, D) - result_original = math_unoptimized.newton_y(A, gamma, X, D, j) - pytest.gas_original += math_unoptimized._computation.get_gas_used() + try: + result_original = math_unoptimized.newton_y(A, gamma, X, D, j) + except Exception as e: + event("hit unsafe for unoptimizied") + if "unsafe value" in str(e): + assert "gamma" not in str(e) + assert gamma > 2 * 10**16 + return + else: # Did not converge? + raise + unoptimized_gas = math_unoptimized._computation.net_gas_used + event( + "unoptimizied implementation used {:.0e} gas".format(unoptimized_gas) + ) - result_get_y, K0 = math_optimized.get_y(A, gamma, X, D, j) - pytest.gas_new += math_optimized._computation.get_gas_used() + try: + result_get_y, K0 = math_optimized.get_y(A, gamma, X, D, j) + except Exception as e: + event("hit unsafe for optimizied") + if "unsafe value" in str(e): + # The only possibility for old one to not revert and + # new one to revert is to have very small difference + # near the unsafe y value boundary. + # So, here we check if it was indeed small + lim_mul = 100 * 10**18 + if gamma > 2 * 10**16: + lim_mul = lim_mul * 2 * 10**16 // gamma + frac = result_original * 10**18 // D + if ( + abs(frac - 10**36 // 2 // lim_mul) < 100 + or abs(frac - lim_mul // 2) < 100 + ): + return + else: + raise + else: + raise + optimized_gas = math_optimized._computation.net_gas_used + event("optimizied implementation used {:.0e} gas".format(optimized_gas)) note( "{" @@ -105,15 +128,9 @@ def calculate_F_by_y0(y0): ) if K0 == 0: - pytest.negative_sqrt_arg += 1 + event("fallback to newton_y") return - if pytest.current_case_id % 1000 == 0: - print( - f"--- {pytest.current_case_id}\nPositive dy frac: {100*pytest.negative_sqrt_arg/pytest.current_case_id:.1f}%\t{time.time() - pytest.t_start:.1f} seconds.\n" - f"Gas advantage per call: {pytest.gas_original//pytest.current_case_id} {pytest.gas_new//pytest.current_case_id}\n" - ) - assert abs(result_original - result_get_y) <= max( 10**4, result_original / 1e8 ) or abs(calculate_F_by_y0(result_get_y)) <= abs( diff --git a/tests/unitary/math/test_newton_D.py b/tests/unitary/math/test_newton_D.py index d1cfe5b7..f49985fe 100644 --- a/tests/unitary/math/test_newton_D.py +++ b/tests/unitary/math/test_newton_D.py @@ -1,140 +1,52 @@ -# flake8: noqa -import sys -import time -from decimal import Decimal - import pytest -from boa.vyper.contract import BoaError -from hypothesis import given, settings +from hypothesis import event, given, note, settings from hypothesis import strategies as st -import tests.utils.simulation_int_many as sim - -# Uncomment to be able to print when parallelized -# sys.stdout = sys.stderr - - -def inv_target_decimal_n2(A, gamma, x, D): - N = len(x) - - x_prod = Decimal(1) - for x_i in x: - x_prod *= x_i - K0 = x_prod / (Decimal(D) / N) ** N - K0 *= 10**18 - - if gamma > 0: - # K = gamma**2 * K0 / (gamma + 10**18*(Decimal(1) - K0))**2 - K = gamma**2 * K0 / (gamma + 10**18 - K0) ** 2 / 10**18 - K *= A - - f = ( - K * D ** (N - 1) * sum(x) - + x_prod - - (K * D**N + (Decimal(D) / N) ** N) - ) - - return f - - -N_COINS = 2 -# MAX_SAMPLES = 1000000 # Increase for fuzzing -MAX_SAMPLES = 300 # Increase for fuzzing -N_CASES = 1 - -A_MUL = 10000 -MIN_A = int(N_COINS**N_COINS * A_MUL / 10) -MAX_A = int(N_COINS**N_COINS * A_MUL * 1000) +import tests.utils.simulator as sim +from tests.utils.strategies import A, fee_gamma, fees, gamma -# gamma from 1e-8 up to 0.05 -MIN_GAMMA = 10**10 -MAX_GAMMA = 5 * 10**16 +# you might want to increase this when fuzzing locally +MAX_SAMPLES = 10000 +N_CASES = 32 MIN_XD = 10**17 MAX_XD = 10**19 -pytest.progress = 0 -pytest.actually_tested = 0 -pytest.t_start = time.time() - @pytest.mark.parametrize( "_tmp", range(N_CASES) -) # Create N_CASES independent test instances. +) # Parallelisation hack (more details in folder's README) @given( - A=st.integers(min_value=MIN_A, max_value=MAX_A), D=st.integers( min_value=10**18, max_value=10**14 * 10**18 ), # 1 USD to 100T USD xD=st.integers( min_value=MIN_XD, max_value=MAX_XD - ), # <- ratio 1e18 * x/D, typically 1e18 * 1 + ), # ratio 1e18 * x/D, typically 1e18 * 1 yD=st.integers( min_value=MIN_XD, max_value=MAX_XD - ), # <- ratio 1e18 * y/D, typically 1e18 * 1 - gamma=st.integers(min_value=MIN_GAMMA, max_value=MAX_GAMMA), + ), # ratio 1e18 * y/D, typically 1e18 * 1 j=st.integers(min_value=0, max_value=1), btcScalePrice=st.integers(min_value=10**2, max_value=10**7), ethScalePrice=st.integers(min_value=10, max_value=10**5), - mid_fee=st.sampled_from( - [ - int(0.7e-3 * 10**10), - int(1e-3 * 10**10), - int(1.2e-3 * 10**10), - int(4e-3 * 10**10), - ] - ), - out_fee=st.sampled_from([int(4.0e-3 * 10**10), int(10.0e-3 * 10**10)]), - fee_gamma=st.sampled_from([int(1e-2 * 1e18), int(2e-6 * 1e18)]), + A=A, + gamma=gamma, + mid_out_fee=fees(), + fee_gamma=fee_gamma, ) @settings(max_examples=MAX_SAMPLES, deadline=None) def test_newton_D( math_optimized, math_unoptimized, - A, D, xD, yD, - gamma, - j, - btcScalePrice, - ethScalePrice, - mid_fee, - out_fee, - fee_gamma, - _tmp, -): - _test_newton_D( - math_optimized, - math_unoptimized, - A, - D, - xD, - yD, - gamma, - j, - btcScalePrice, - ethScalePrice, - mid_fee, - out_fee, - fee_gamma, - _tmp, - ) - - -def _test_newton_D( - math_optimized, - math_unoptimized, A, - D, - xD, - yD, gamma, j, btcScalePrice, ethScalePrice, - mid_fee, - out_fee, + mid_out_fee, fee_gamma, _tmp, ): @@ -144,25 +56,20 @@ def _test_newton_D( for f in [xx * 10**18 // D for xx in [xD, yD]] ) - pytest.progress += 1 - if pytest.progress % 1000 == 0 and pytest.actually_tested != 0: - print( - f"{pytest.progress} | {pytest.actually_tested} cases processed in {time.time()-pytest.t_start:.1f} seconds." - ) X = [D * xD // 10**18, D * yD // 10**18] result_get_y = 0 get_y_failed = False try: (result_get_y, K0) = math_optimized.get_y(A, gamma, X, D, j) - except: + except Exception: get_y_failed = True if get_y_failed: newton_y_failed = False try: math_optimized.newton_y(A, gamma, X, D, j) - except: + except Exception: newton_y_failed = True if get_y_failed and newton_y_failed: @@ -172,7 +79,11 @@ def _test_newton_D( raise # this is a problem # dy should be positive - if result_get_y < X[j]: + if ( + result_get_y < X[j] + and result_get_y / D > MIN_XD / 1e18 + and result_get_y / D < MAX_XD / 1e18 + ): price_scale = (btcScalePrice, ethScalePrice) y = X[j] @@ -182,40 +93,33 @@ def _test_newton_D( if j > 0: dy = dy * 10**18 // price_scale[j - 1] - fee = sim.get_fee(X, fee_gamma, mid_fee, out_fee) + fee = sim.get_fee(X, fee_gamma, mid_out_fee[0], mid_out_fee[1]) dy -= fee * dy // 10**10 y -= dy if dy / X[j] <= 0.95: - pytest.actually_tested += 1 + # if we stop before this block we are not testing newton_D + event("test actually went through") X[j] = y - case = ( - "{" - f"'ANN': {A}, 'D': {D}, 'xD': {xD}, 'yD': {yD}, 'GAMMA': {gamma}, 'j': {j}, 'btcScalePrice': {btcScalePrice}, 'ethScalePrice': {ethScalePrice}, 'mid_fee': {mid_fee}, 'out_fee': {out_fee}, 'fee_gamma': {fee_gamma}" - "},\n" + note( + ", A: {:.2e}".format(A) + + ", D: {:.2e}".format(D) + + ", xD: {:.2e}".format(xD) + + ", yD: {:.2e}".format(yD) + + ", GAMMA: {:.2e}".format(gamma) + + ", j: {:.2e}".format(j) + + ", btcScalePrice: {:.2e}".format(btcScalePrice) + + ", ethScalePrice: {:.2e}".format(ethScalePrice) + + ", mid_fee: {:.2e}".format(mid_out_fee[0]) + + ", out_fee: {:.2e}".format(mid_out_fee[1]) + + ", fee_gamma: {:.2e}".format(fee_gamma) ) result_sim = math_unoptimized.newton_D(A, gamma, X) - try: - result_contract = math_optimized.newton_D(A, gamma, X, K0) - except Exception as e: - # with open("log/newton_D_fail.txt", "a") as f: - # f.write(case) - # with open("log/newton_D_fail_trace.txt", "a") as f: - # f.write(str(e)) - return - - A_dec = Decimal(A) / 10000 / 4 - - def calculate_D_polynome(d): - d = Decimal(d) - return abs(inv_target_decimal_n2(A_dec, gamma, X, d)) + result_contract = math_optimized.newton_D(A, gamma, X, K0) assert abs(result_sim - result_contract) <= max( 10000, result_sim / 1e12 ) - - # with open("log/newton_D_pass.txt", "a") as f: - # f.write(case) diff --git a/tests/unitary/math/test_newton_D_ref.py b/tests/unitary/math/test_newton_D_ref.py deleted file mode 100644 index 6852c39d..00000000 --- a/tests/unitary/math/test_newton_D_ref.py +++ /dev/null @@ -1,192 +0,0 @@ -# flake8: noqa -import sys -from decimal import Decimal - -import pytest -from boa.vyper.contract import BoaError -from hypothesis import given, settings -from hypothesis import strategies as st - -import tests.utils.simulation_int_many as sim - -# sys.stdout = sys.stderr - - -def inv_target_decimal_n2(A, gamma, x, D): - N = len(x) - - x_prod = Decimal(1) - for x_i in x: - x_prod *= x_i - K0 = x_prod / (Decimal(D) / N) ** N - K0 *= 10**18 - - if gamma > 0: - # K = gamma**2 * K0 / (gamma + 10**18*(Decimal(1) - K0))**2 - K = gamma**2 * K0 / (gamma + 10**18 - K0) ** 2 / 10**18 - K *= A - - f = ( - K * D ** (N - 1) * sum(x) - + x_prod - - (K * D**N + (Decimal(D) / N) ** N) - ) - - return f - - -N_COINS = 2 -# MAX_SAMPLES = 300000 # Increase for fuzzing -MAX_SAMPLES = 300 # Increase for fuzzing -N_CASES = 1 - -A_MUL = 10000 -MIN_A = int(N_COINS**N_COINS * A_MUL / 10) -MAX_A = int(N_COINS**N_COINS * A_MUL * 1000) - -# gamma from 1e-8 up to 0.05 -MIN_GAMMA = 10**10 -MAX_GAMMA = 2 * 10**15 - -MIN_XD = 10**17 -MAX_XD = 10**19 - -pytest.cases = 0 - - -@pytest.mark.parametrize( - "_tmp", range(N_CASES) -) # Create N_CASES independent test instances. -@given( - A=st.integers(min_value=MIN_A, max_value=MAX_A), - D=st.integers( - min_value=10**18, max_value=10**14 * 10**18 - ), # 1 USD to 100T USD - xD=st.integers( - min_value=MIN_XD, max_value=MAX_XD - ), # <- ratio 1e18 * x/D, typically 1e18 * 1 - yD=st.integers( - min_value=MIN_XD, max_value=MAX_XD - ), # <- ratio 1e18 * y/D, typically 1e18 * 1 - gamma=st.integers(min_value=MIN_GAMMA, max_value=MAX_GAMMA), - j=st.integers(min_value=0, max_value=1), - asset_x_scale_price=st.integers(min_value=10**2, max_value=10**7), - asset_y_scale_price=st.integers(min_value=10, max_value=10**5), - mid_fee=st.sampled_from( - [ - int(0.7e-3 * 10**10), - int(1e-3 * 10**10), - int(1.2e-3 * 10**10), - int(4e-3 * 10**10), - ] - ), - out_fee=st.sampled_from([int(4.0e-3 * 10**10), int(10.0e-3 * 10**10)]), - fee_gamma=st.sampled_from([int(1e-2 * 1e18), int(2e-6 * 1e18)]), -) -@settings(max_examples=MAX_SAMPLES, deadline=None) -def test_newton_D( - math_optimized, - math_unoptimized, - A, - D, - xD, - yD, - gamma, - j, - asset_x_scale_price, - asset_y_scale_price, - mid_fee, - out_fee, - fee_gamma, - _tmp, -): - _test_newton_D( - math_optimized, - math_unoptimized, - A, - D, - xD, - yD, - gamma, - j, - asset_x_scale_price, - asset_y_scale_price, - mid_fee, - out_fee, - fee_gamma, - _tmp, - ) - - -def _test_newton_D( - math_optimized, - math_unoptimized, - A, - D, - xD, - yD, - gamma, - j, - asset_x_scale_price, - asset_y_scale_price, - mid_fee, - out_fee, - fee_gamma, - _tmp, -): - - pytest.cases += 1 - X = [D * xD // 10**18, D * yD // 10**18] - is_safe = all( - f >= MIN_XD and f <= MAX_XD - for f in [xx * 10**18 // D for xx in [xD, yD]] - ) - try: - newton_y_output = math_unoptimized.newton_y(A, gamma, X, D, j) - except BoaError as e: - if is_safe: - raise - else: - return - - (result_get_y, K0) = math_optimized.get_y(A, gamma, X, D, j) - - # dy should be positive - if result_get_y < X[j]: - - price_scale = (asset_x_scale_price, asset_y_scale_price) - y = X[j] - dy = X[j] - result_get_y - dy -= 1 - - if j > 0: - dy = dy * 10**18 // price_scale[j - 1] - - fee = sim.get_fee(X, fee_gamma, mid_fee, out_fee) - dy -= fee * dy // 10**10 - y -= dy - - if dy / X[j] <= 0.95: - - X[j] = y - - # if pytest.cases % 1000 == 0: - # print(f'> {pytest.cases}') - try: - result_sim = math_unoptimized.newton_D(A, gamma, X) - except: - # breakpoint() - raise # this is a problem - - try: - result_contract = math_optimized.newton_D(A, gamma, X, K0) - # except BoaError: - except: - raise - - try: - assert abs(result_sim - result_contract) <= max( - 10000, result_sim / 1e12 - ) - except AssertionError: - raise diff --git a/tests/unitary/math/test_newton_y.py b/tests/unitary/math/test_newton_y.py new file mode 100644 index 00000000..dd8c5ad7 --- /dev/null +++ b/tests/unitary/math/test_newton_y.py @@ -0,0 +1,201 @@ +""" +This test suite was added as part of PR #12 to verify +the correctness of the newton_y function in the math +contract. + +Since the introduction of `get_y` this function is just used +as a fallback when the analytical method fails to find a solution +for y (roughly 3% of the time). + +Since bounds for gamma have been not only restored to the original +tricrypto levels but even pushed forward this suite aims to +test the convergence of the newton_y function in the new bounds. + +Since calls to newton_y are not that frequent anymore this test suite +tries to test it in isolation. + +While the tests are quite similar, they have been separated to obtain +more fine-grained information about the convergence of the newton_y +through hypothesis events. + +We don't test the correctness of y because newton_y should always +converge to the correct value (or not converge at all otherwise). +""" + +import pytest +from hypothesis import event, given, settings +from hypothesis import strategies as st + +from tests.utils.constants import MAX_GAMMA, MAX_GAMMA_SMALL, MIN_GAMMA + +N_COINS = 2 +# MAX_SAMPLES = 1000000 # Increase for fuzzing +MAX_SAMPLES = 10000 +N_CASES = 32 +# for tests that are trivial +N_CASES_TRIVIAL = 6 + +A_MUL = 10000 +MIN_A = int(N_COINS**N_COINS * A_MUL / 10) +MAX_A = int(N_COINS**N_COINS * A_MUL * 1000) + +# Old bounds for gamma +MAX_GAMMA_OLD = 2 * 10**15 + + +@pytest.fixture(scope="module") +def math_exposed(): + # compile + from contracts import newton_y_exposed + + # deploy + return newton_y_exposed() + + +@pytest.mark.parametrize( + "_tmp", range(N_CASES_TRIVIAL) +) # Parallelisation hack (more details in folder's README) +@given( + A=st.integers(min_value=MIN_A, max_value=MAX_A), + D=st.integers( + min_value=10**18, max_value=10**14 * 10**18 + ), # 1 USD to 100T USD + xD=st.integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # <- ratio 1e18 * x/D, typically 1e18 * 1 + yD=st.integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # <- ratio 1e18 * y/D, typically 1e18 * 1 + gamma=st.integers(min_value=MIN_GAMMA, max_value=MAX_GAMMA_OLD), + j=st.integers(min_value=0, max_value=1), +) +@settings(max_examples=MAX_SAMPLES, deadline=None) +def test_equivalence( + math_exposed, math_optimized, A, D, xD, yD, gamma, j, _tmp +): + """ + Tests whether the newton_y function works the same way + for both the exposed and production versions on ranges that are + already in production. + + [MIN_GAMMA, MAX_GAMMA_OLD] = [1e10, 2e15]. + """ + # using the same prices as in get_y fuzzing + # TODO is this the correct way? + X = [D * xD // 10**18, D * yD // 10**18] + + # this value remains as before the increase of the bounds + # since we're testing the old bounds + lim_mul = int(100e18) # 100.0 + + # we can use the old version to know the number of iterations + # and the expected value + y_exposed, iterations = math_exposed.internal._newton_y( + A, gamma, X, D, j, lim_mul + ) + + # this should not revert (didn't converge or hit bounds) + y = math_optimized.newton_y(A, gamma, X, D, j) + + event(f"converges in {iterations} iterations") + + assert y_exposed == y + + +@pytest.mark.parametrize( + "_tmp", range(N_CASES_TRIVIAL) +) # Parallelisation hack (more details in folder's README) +@given( + A=st.integers(min_value=MIN_A, max_value=MAX_A), + D=st.integers( + min_value=10**18, max_value=10**14 * 10**18 + ), # 1 USD to 100T USD + xD=st.integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # <- ratio 1e18 * x/D, typically 1e18 * 1 + yD=st.integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # <- ratio 1e18 * y/D, typically 1e18 * 1 + gamma=st.integers(min_value=MAX_GAMMA_OLD, max_value=MAX_GAMMA_SMALL), + j=st.integers(min_value=0, max_value=1), +) +@settings(max_examples=MAX_SAMPLES, deadline=None) +def test_restored(math_optimized, math_exposed, A, D, xD, yD, gamma, j, _tmp): + """ + Tests whether the bounds that have been restored to the original + tricrypto ones work as expected. + + [MAX_GAMMA_OLD, MAX_GAMMA_SMALL] = [2e15, 2e16] + """ + # using the same prices as in get_y fuzzing + # TODO is this the correct way? + X = [D * xD // 10**18, D * yD // 10**18] + + # according to vyper math contracts (get_y) since we never have + # values bigger than MAX_GAMMA_SMALL, lim_mul is always 100 + lim_mul = int(100e18) # 100.0 + + # we can use the exposed version to know the number of iterations + # and the expected value + y_exposed, iterations = math_exposed.internal._newton_y( + A, gamma, X, D, j, lim_mul + ) + + # this should not revert (didn't converge or hit bounds) + y = math_optimized.internal._newton_y(A, gamma, X, D, j, lim_mul) + + # we can use the exposed version to know the number of iterations + # since we didn't change how the computation is done + event(f"converges in {iterations} iterations") + + assert y_exposed == y + + +@pytest.mark.parametrize( + "_tmp", range(N_CASES) +) # Parallelisation hack (more details in folder's README) +@given( + A=st.integers(min_value=MIN_A, max_value=MAX_A), + D=st.integers( + min_value=10**18, max_value=10**14 * 10**18 + ), # 1 USD to 100T USD + xD=st.integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # <- ratio 1e18 * x/D, typically 1e18 * 1 + yD=st.integers( + min_value=10**17 // 2, max_value=10**19 // 2 + ), # <- ratio 1e18 * y/D, typically 1e18 * 1 + gamma=st.integers(min_value=MAX_GAMMA_SMALL + 1, max_value=MAX_GAMMA), + j=st.integers(min_value=0, max_value=1), +) +@settings(max_examples=MAX_SAMPLES, deadline=None) +def test_new_bounds( + math_optimized, math_exposed, A, D, xD, yD, gamma, j, _tmp +): + """ + Tests whether the new bouds that no pool has ever reached + work as expected. + + [MAX_GAMMA_SMALL, MAX_GAMMA] = [2e16, 3e17] + """ + # using the same prices as in get_y fuzzing + # TODO is this the correct way? + X = [D * xD // 10**18, D * yD // 10**18] + + # this comes from `get_y`` which is the only place from which _newton_y + # is called when gamma is bigger than MAX_GAMMA_SMALL lim_mul has to + # be adjusted accordingly + lim_mul = 100e18 * MAX_GAMMA_SMALL // gamma # smaller than 100.0 + + y_exposed, iterations = math_exposed.internal._newton_y( + A, gamma, X, D, j, int(lim_mul) + ) + + # this should not revert (didn't converge or hit bounds) + y = math_optimized.internal._newton_y(A, gamma, X, D, j, int(lim_mul)) + + # we can use the exposed version to know the number of iterations + # since we didn't change how the computation is done + event(f"converges in {iterations} iterations") + + assert y == y_exposed diff --git a/tests/unitary/pool/admin/test_commit_params.py b/tests/unitary/pool/admin/test_commit_params.py index 215ec7e6..710f96ec 100644 --- a/tests/unitary/pool/admin/test_commit_params.py +++ b/tests/unitary/pool/admin/test_commit_params.py @@ -11,7 +11,6 @@ def _apply_new_params(swap, params): params["allowed_extra_profit"], params["adjustment_step"], params["ma_time"], - params["xcp_ma_time"], ) @@ -105,16 +104,6 @@ def test_commit_accept_ma_time(swap, factory_admin, params): assert ma_time == p["ma_time"] -def test_commit_accept_xcp_ma_time(swap, factory_admin, params): - - p = copy.deepcopy(params) - p["xcp_ma_time"] = 872541 - with boa.env.prank(factory_admin): - _apply_new_params(swap, p) - - assert swap.xcp_ma_time() == p["xcp_ma_time"] - - def test_commit_accept_rebalancing_params(swap, factory_admin, params): p = copy.deepcopy(params) diff --git a/tests/unitary/pool/admin/test_ramp_A_gamma.py b/tests/unitary/pool/admin/test_ramp_A_gamma.py index f06126ca..ceb744cd 100644 --- a/tests/unitary/pool/admin/test_ramp_A_gamma.py +++ b/tests/unitary/pool/admin/test_ramp_A_gamma.py @@ -2,13 +2,15 @@ import boa +from tests.utils.constants import UNIX_DAY + def test_ramp_A_gamma_up(swap, factory_admin, params): p = copy.deepcopy(params) future_A = p["A"] + 10000 future_gamma = p["gamma"] + 10000 - future_time = boa.env.vm.state.timestamp + 86400 + future_time = boa.env.evm.patch.timestamp + UNIX_DAY initial_A_gamma = [swap.A(), swap.gamma()] swap.ramp_A_gamma( @@ -31,7 +33,7 @@ def test_ramp_A_gamma_down(swap, factory_admin, params): p = copy.deepcopy(params) future_A = p["A"] - 10000 future_gamma = p["gamma"] - 10000 - future_time = boa.env.vm.state.timestamp + 86400 + future_time = boa.env.evm.patch.timestamp + UNIX_DAY initial_A_gamma = [swap.A(), swap.gamma()] swap.ramp_A_gamma( diff --git a/tests/unitary/pool/admin/test_revert_commit_params.py b/tests/unitary/pool/admin/test_revert_commit_params.py index 48228166..7163e3ea 100644 --- a/tests/unitary/pool/admin/test_revert_commit_params.py +++ b/tests/unitary/pool/admin/test_revert_commit_params.py @@ -11,7 +11,6 @@ def _apply_new_params(swap, params): params["allowed_extra_profit"], params["adjustment_step"], params["ma_time"], - params["xcp_ma_time"], ) diff --git a/tests/unitary/pool/admin/test_revert_ramp.py b/tests/unitary/pool/admin/test_revert_ramp.py index e485c628..dc455436 100644 --- a/tests/unitary/pool/admin/test_revert_ramp.py +++ b/tests/unitary/pool/admin/test_revert_ramp.py @@ -1,5 +1,7 @@ import boa +from tests.utils.constants import UNIX_DAY + def test_revert_unauthorised_ramp(swap, user): @@ -9,10 +11,11 @@ def test_revert_unauthorised_ramp(swap, user): def test_revert_ramp_while_ramping(swap, factory_admin): + # sanity check: ramping is not active assert swap.initial_A_gamma_time() == 0 A_gamma = [swap.A(), swap.gamma()] - future_time = boa.env.vm.state.timestamp + 86400 + 1 + future_time = boa.env.evm.patch.timestamp + UNIX_DAY + 1 with boa.env.prank(factory_admin): swap.ramp_A_gamma(A_gamma[0] + 1, A_gamma[1] + 1, future_time) @@ -23,19 +26,46 @@ def test_revert_ramp_while_ramping(swap, factory_admin): def test_revert_fast_ramps(swap, factory_admin): A_gamma = [swap.A(), swap.gamma()] - future_time = boa.env.vm.state.timestamp + 10 + future_time = boa.env.evm.patch.timestamp + 10 with boa.env.prank(factory_admin), boa.reverts(dev="insufficient time"): swap.ramp_A_gamma(A_gamma[0] + 1, A_gamma[1] + 1, future_time) def test_revert_unauthorised_stop_ramp(swap, factory_admin, user): + # sanity check: ramping is not active assert swap.initial_A_gamma_time() == 0 A_gamma = [swap.A(), swap.gamma()] - future_time = boa.env.vm.state.timestamp + 86400 + 1 + future_time = boa.env.evm.patch.timestamp + UNIX_DAY + 1 with boa.env.prank(factory_admin): swap.ramp_A_gamma(A_gamma[0] + 1, A_gamma[1] + 1, future_time) with boa.env.prank(user), boa.reverts(dev="only owner"): swap.stop_ramp_A_gamma() + + +def test_revert_ramp_too_far(swap, factory_admin): + + # sanity check: ramping is not active + assert swap.initial_A_gamma_time() == 0 + + A = swap.A() + gamma = swap.gamma() + future_time = boa.env.evm.patch.timestamp + UNIX_DAY + 1 + + with boa.env.prank(factory_admin), boa.reverts(dev="A change too high"): + future_A = A * 11 # can at most increase by 10x + swap.ramp_A_gamma(future_A, gamma, future_time) + with boa.env.prank(factory_admin), boa.reverts(dev="A change too low"): + future_A = A // 11 # can at most decrease by 10x + swap.ramp_A_gamma(future_A, gamma, future_time) + + with boa.env.prank(factory_admin), boa.reverts( + dev="gamma change too high" + ): + future_gamma = gamma * 11 # can at most increase by 10x + swap.ramp_A_gamma(A, future_gamma, future_time) + with boa.env.prank(factory_admin), boa.reverts(dev="gamma change too low"): + future_gamma = gamma // 11 # can at most decrease by 10x + swap.ramp_A_gamma(A, future_gamma, future_time) diff --git a/tests/unitary/pool/stateful/README.md b/tests/unitary/pool/stateful/README.md new file mode 100644 index 00000000..110cde0c --- /dev/null +++ b/tests/unitary/pool/stateful/README.md @@ -0,0 +1,42 @@ +# Stateful testing guidelines + +Welcome to the most important and fragile section of the tests. Stateful testing mixes and matches a set of allowed actions (rules) to generate every possible scenario and make sure that Curve pools always work under a set of conditions (invariants). To do so we leveraged the [hyptohesis testing framework](https://hypothesis.readthedocs.io/en/latest/index.html#) which provides stateful testing out of the box. + +### Tests structure +All stateful tests are based off `StatefulBase` which contains a wrapped version of every function you might want to call from a Curve pool. Most of the fixtures that are used in the other tests have been converted into `SearchStrategies` in the `strategies.py` file. Keep in mind that titanoboa offers some EVM specific `SearchStrategies` out of the box. + +### Get the most out of stateful testing + +#### How to debug a stateful tests +Stateful tests can run for amounts of time (sometime even more than 30 minutes). Since we can't wait half an hour for a test to pass these tests are filled with "notes" that can help figure out what is going on. To see these notes you need to run pytest with this command: +```bash +python -m pytest --hypothesis-show-statistics --hypothesis-verbosity=verbose -s path/to/test.py +``` + +`--hypothesis-show-statistics` while not being necessary for showing notes, can be helpful to have some statistics of what happens in the test. + +--- +If you see a test reverting but not stopping it's because it is in the shrinking phase! Sometime shrinking can take a lot of time without leading to significant results. If you want to skip the shrinking phase and get straight to the error you can do so by enabling the `no-shrink` profile defined in `strategies.py`. It sufficies to add this line to your test: +```python +settings.load_profile("no-shrink") +``` + +--- +If you're struggling with some math related errors and you can't get a sense of what is wrong you can replace the strategy that generates tokens with one that only generate 18 decimals tokens, this can definitely help understand if the amount being passed a very large/small. + + +#### Before writing/updating stateful tests +Read the docs multiple times through your stateful testing journey. Not only the stateful testing section but the whole hypothesis docs. Stateful testing might look like an isolated part of hypothesis, but the reality is that it is built on top of `SearchStrategies` and requires a very deep understanding of how hypothesis works. If you are wondering why one hypothesis functionality is useful, you're probably not capable of writing good tests (yet). + +#### What you should do with stateful testing +- Build these tests with composability in mind, by inheriting a test class you can test for more specific situations in which the pool might be (invariant stateful tests are a good example of this). +- It's better to build "actions" as methods (like the ones in `StatefulBase`), and then turn them into rules later by inheriting in a subclass. This helps for maintainability and code reusability. + +#### What you should **not** do with stateful testing +- Do not use stateful testing to reliably test for edge cases in considerations, if a function is known to revert under a certain input you should make sure your `SearchStrategy` never generates that input in the first place. For this very reason you should try to avoid using `try/excpet` and returning when a function hits an edge case. The test doesn't pass? Restrict your `SearchStrategy` and don't create early termination conditions. +- Do not try to use `pytest` fixtures in stateful tests. While we have done this in the past, for this to work we need to use `run_state_machine_as_test` which breaks a lot of the features that make stateful testing so powerful (i.e. test statistics). To achieve the same result you should convert the fixture into an hypothesis native `SearchStrategy`. Keep in mind that a lot of strategies have been already built +- Do not replicate good actors behavior in rules + +#### Practical suggestions +- `Bundles` are a nice feature but they are often too limited in practice, you can easily build a more advanced bundle-like infrastructure with lists. (The way depositors are tracked in the tests is a good example of how you can build something more flexible than a bundle). +- Everything you can do with `.flatmap` can be achieved with the `@composite` decorator. While it's fun to do some functional programming (no pun intended), strategies built with `@composite` are a LOT more readable. diff --git a/tests/unitary/pool/stateful/conftest.py b/tests/unitary/pool/stateful/conftest.py new file mode 100644 index 00000000..b437a763 --- /dev/null +++ b/tests/unitary/pool/stateful/conftest.py @@ -0,0 +1,3 @@ +from hypothesis import Phase, settings + +settings.register_profile("no-shrink", settings(phases=list(Phase)[:4])) diff --git a/tests/unitary/pool/stateful/legacy/stateful_base.py b/tests/unitary/pool/stateful/legacy/stateful_base.py new file mode 100644 index 00000000..c11e55ab --- /dev/null +++ b/tests/unitary/pool/stateful/legacy/stateful_base.py @@ -0,0 +1,274 @@ +import contextlib +from math import log + +import boa +from boa.test import strategy as boa_st +from hypothesis import strategies as hyp_st +from hypothesis.stateful import RuleBasedStateMachine, invariant, rule + +from tests.fixtures.pool import INITIAL_PRICES +from tests.utils.tokens import mint_for_testing + +MAX_D = 10**12 * 10**18 # $1T is hopefully a reasonable cap for tests + + +class StatefulBase(RuleBasedStateMachine): + # strategy to pick two random amount for the two tokens + # in the pool. Useful for depositing, withdrawing, etc. + two_token_amounts = boa_st( + "uint256[2]", min_value=0, max_value=10**9 * 10**18 + ) + + # strategy to pick a random amount for an action like exchange amounts, + # remove_liquidity (to determine the LP share), + # remove_liquidity_one_coin, etc. + token_amount = boa_st("uint256", max_value=10**12 * 10**18) + + # exchange_amount_in = strategy("uint256", max_value=10**9 * 10**18) + + # strategy to pick which token should be exchanged for the other + exchange_i = boa_st("uint8", max_value=1) + + # strategy to decide by how much we should move forward in time + # for ramping, oracle updates, etc. + sleep_time = boa_st("uint256", max_value=86400 * 7) + + # strategy to pick which address should perform the action + user = boa_st("address") + + percentage = hyp_st.integers(min_value=1, max_value=100).map( + lambda x: x / 100 + ) + + def __init__(self): + + super().__init__() + + self.decimals = [int(c.decimals()) for c in self.coins] + self.user_balances = {u: [0] * 2 for u in self.users} + self.initial_prices = INITIAL_PRICES + self.initial_deposit = [ + 10**4 * 10 ** (18 + d) // p + for p, d in zip(self.initial_prices, self.decimals) + ] # $10k * 2 + + self.xcp_profit = 10**18 + self.xcp_profit_a = 10**18 + + self.total_supply = 0 + self.previous_pool_profit = 0 + + self.swap_admin = self.swap.admin() + self.fee_receiver = self.swap.fee_receiver() + + for user in self.users: + for coin in self.coins: + coin.approve(self.swap, 2**256 - 1, sender=user) + + self.setup() + + def setup(self, user_id=0): + + user = self.users[user_id] + for coin, q in zip(self.coins, self.initial_deposit): + mint_for_testing(coin, user, q) + + # Very first deposit + self.swap.add_liquidity(self.initial_deposit, 0, sender=user) + + self.balances = self.initial_deposit[:] + self.total_supply = self.swap.balanceOf(user) + self.xcp_profit = 10**18 + + def check_limits(self, amounts, D=True, y=True): + """ + Should be good if within limits, but if outside - can be either + """ + _D = self.swap.D() + prices = [10**18] + [self.swap.price_scale()] + xp_0 = [self.swap.balances(i) for i in range(2)] + xp = xp_0 + xp_0 = [ + x * p // 10**d for x, p, d in zip(xp_0, prices, self.decimals) + ] + xp = [ + (x + a) * p // 10**d + for a, x, p, d in zip(amounts, xp, prices, self.decimals) + ] + + if D: + for _xp in [xp_0, xp]: + if ( + (min(_xp) * 10**18 // max(_xp) < 10**14) + or (max(_xp) < 10**9 * 10**18) + or (max(_xp) > 10**15 * 10**18) + ): + return False + + if y: + for _xp in [xp_0, xp]: + if ( + (_D < 10**17) + or (_D > 10**15 * 10**18) + or (min(_xp) * 10**18 // _D < 10**16) + or (max(_xp) * 10**18 // _D > 10**20) + ): + return False + + return True + + @rule( + exchange_amount_in=token_amount, + exchange_i=exchange_i, + user=user, + ) + def exchange(self, exchange_amount_in, exchange_i, user): + exchange_j = 1 - exchange_i + try: + calc_amount = self.swap.get_dy( + exchange_i, exchange_j, exchange_amount_in + ) + except Exception: + _amounts = [0] * 2 + _amounts[exchange_i] = exchange_amount_in + if self.check_limits(_amounts) and exchange_amount_in > 10000: + raise + return None + + _amounts = [0] * 2 + _amounts[exchange_i] = exchange_amount_in + _amounts[exchange_j] = -calc_amount + limits_check = self.check_limits(_amounts) # If get_D fails + mint_for_testing(self.coins[exchange_i], user, exchange_amount_in) + + d_balance_i = self.coins[exchange_i].balanceOf(user) + d_balance_j = self.coins[exchange_j].balanceOf(user) + try: + self.coins[exchange_i].approve( + self.swap, 2**256 - 1, sender=user + ) + out = self.swap.exchange( + exchange_i, exchange_j, exchange_amount_in, 0, sender=user + ) + except Exception: + # Small amounts may fail with rounding errors + if ( + calc_amount > 100 + and exchange_amount_in > 100 + and calc_amount / self.swap.balances(exchange_j) > 1e-13 + and exchange_amount_in / self.swap.balances(exchange_i) > 1e-13 + and limits_check + ): + raise + return None + + # This is to check that we didn't end up in a borked state after + # an exchange succeeded + self.swap.get_dy( + exchange_j, + exchange_i, + 10**16 + * 10 ** self.decimals[exchange_j] + // INITIAL_PRICES[exchange_j], + ) + + d_balance_i -= self.coins[exchange_i].balanceOf(user) + d_balance_j -= self.coins[exchange_j].balanceOf(user) + + assert d_balance_i == exchange_amount_in + assert -d_balance_j == calc_amount, f"{-d_balance_j} vs {calc_amount}" + + self.balances[exchange_i] += d_balance_i + self.balances[exchange_j] += d_balance_j + + return out + + @rule(sleep_time=sleep_time) + def sleep(self, sleep_time): + boa.env.time_travel(sleep_time) + + @invariant() + def balances(self): + balances = [self.swap.balances(i) for i in range(2)] + balances_of = [c.balanceOf(self.swap) for c in self.coins] + for i in range(2): + assert self.balances[i] == balances[i] + assert self.balances[i] == balances_of[i] + + @invariant() + def total_supply(self): + assert self.total_supply == self.swap.totalSupply() + + @invariant() + def virtual_price(self): + virtual_price = self.swap.virtual_price() + xcp_profit = self.swap.xcp_profit() + get_virtual_price = self.swap.get_virtual_price() + + assert xcp_profit >= 10**18 - 10 + assert virtual_price >= 10**18 - 10 + assert get_virtual_price >= 10**18 - 10 + + assert ( + xcp_profit - self.xcp_profit > -3 + ), f"{xcp_profit} vs {self.xcp_profit}" + assert (virtual_price - 10**18) * 2 - ( + xcp_profit - 10**18 + ) >= -5, f"vprice={virtual_price}, xcp_profit={xcp_profit}" + assert abs(log(virtual_price / get_virtual_price)) < 1e-10 + + self.xcp_profit = xcp_profit + + @invariant() + def up_only_profit(self): + + current_profit = xcp_profit = self.swap.xcp_profit() + xcp_profit_a = self.swap.xcp_profit_a() + current_profit = (xcp_profit + xcp_profit_a + 1) // 2 + + assert current_profit >= self.previous_pool_profit + self.previous_pool_profit = current_profit + + @contextlib.contextmanager + def upkeep_on_claim(self): + + admin_balances_pre = [ + c.balanceOf(self.fee_receiver) for c in self.coins + ] + pool_is_ramping = ( + self.swap.future_A_gamma_time() > boa.env.evm.state.patch.timestamp + ) + + try: + + yield + + finally: + + new_xcp_profit_a = self.swap.xcp_profit_a() + old_xcp_profit_a = self.xcp_profit_a + + claimed = False + if new_xcp_profit_a > old_xcp_profit_a: + claimed = True + self.xcp_profit_a = new_xcp_profit_a + + admin_balances_post = [ + c.balanceOf(self.fee_receiver) for c in self.coins + ] + + if claimed: + + for i in range(2): + claimed_amount = ( + admin_balances_post[i] - admin_balances_pre[i] + ) + assert ( + claimed_amount > 0 + ) # check if non zero amounts of claim + assert not pool_is_ramping # cannot claim while ramping + + # update self.balances + self.balances[i] -= claimed_amount + + self.xcp_profit = self.swap.xcp_profit() diff --git a/tests/unitary/pool/stateful/test_multiprecision.py b/tests/unitary/pool/stateful/legacy/test_multiprecision.py similarity index 84% rename from tests/unitary/pool/stateful/test_multiprecision.py rename to tests/unitary/pool/stateful/legacy/test_multiprecision.py index dcecf832..002852eb 100644 --- a/tests/unitary/pool/stateful/test_multiprecision.py +++ b/tests/unitary/pool/stateful/legacy/test_multiprecision.py @@ -1,8 +1,9 @@ import pytest from boa.test import strategy +from hypothesis import HealthCheck, settings from hypothesis.stateful import rule, run_state_machine_as_test -from tests.unitary.pool.stateful.test_stateful import NumbaGoUp +from tests.unitary.pool.stateful.legacy.test_stateful import NumbaGoUp MAX_SAMPLES = 100 STEP_COUNT = 100 @@ -38,17 +39,15 @@ def exchange(self, exchange_amount_in, exchange_i, user): def test_multiprecision(users, coins, swap): - from hypothesis import settings - from hypothesis._settings import HealthCheck - Multiprecision.TestCase.settings = settings( max_examples=MAX_SAMPLES, stateful_step_count=STEP_COUNT, - suppress_health_check=HealthCheck.all(), + suppress_health_check=list(HealthCheck), deadline=None, ) for k, v in locals().items(): setattr(Multiprecision, k, v) + # because of this hypothesis.event does not work run_state_machine_as_test(Multiprecision) diff --git a/tests/unitary/pool/stateful/legacy/test_ramp.py b/tests/unitary/pool/stateful/legacy/test_ramp.py new file mode 100644 index 00000000..7133aa53 --- /dev/null +++ b/tests/unitary/pool/stateful/legacy/test_ramp.py @@ -0,0 +1,133 @@ +import boa +from hypothesis import HealthCheck, settings +from hypothesis import strategies as st +from hypothesis.stateful import ( + initialize, + invariant, + precondition, + rule, + run_state_machine_as_test, +) + +from tests.unitary.pool.stateful.legacy.test_stateful import NumbaGoUp +from tests.utils.constants import ( + MAX_A, + MAX_GAMMA, + MIN_A, + MIN_GAMMA, + MIN_RAMP_TIME, + UNIX_DAY, +) + +MAX_SAMPLES = 20 +STEP_COUNT = 100 + +# [0.2, 0.3 ... 0.9, 1, 2, 3 ... 10], used as sample values for the ramp step +change_steps = [x / 10 if x < 10 else x for x in range(2, 11)] + list( + range(2, 11) +) + + +class RampTest(NumbaGoUp): + """ + This class tests statefully tests wheter ramping A and + gamma does not break the pool. At the start it always start + with a ramp, then it can ramp again. + """ + + # we can only ramp A and gamma at most 10x + # lower/higher than their starting value + change_step_strategy = st.sampled_from(change_steps) + + # we fuzz the ramp duration up to a year + days = st.integers(min_value=1, max_value=365) + + def is_not_ramping(self): + """ + Checks if the pool is not already ramping. + """ + return ( + boa.env.evm.patch.timestamp + > self.swap.initial_A_gamma_time() + (MIN_RAMP_TIME - 1) + ) + + @initialize( + A_change=change_step_strategy, + gamma_change=change_step_strategy, + days=days, + ) + def initial_ramp(self, A_change, gamma_change, days): + """ + At the start of the stateful test, we always ramp. + """ + self.__ramp(A_change, gamma_change, days) + + @precondition(is_not_ramping) + @rule( + A_change=change_step_strategy, + gamma_change=change_step_strategy, + days=days, + ) + def ramp(self, A_change, gamma_change, days): + """ + Additional ramping after the initial ramp. + Pools might ramp multiple times during their lifetime. + """ + self.__ramp(A_change, gamma_change, days) + + def __ramp(self, A_change, gamma_change, days): + """ + Computes the new A and gamma values by multiplying the current ones + by the change factors. Then clamps the new values to stay in the + [MIN_A, MAX_A] and [MIN_GAMMA, MAX_GAMMA] ranges. + + Then proceeds to ramp the pool with the new values (with admin rights). + """ + new_A = self.swap.A() * A_change + new_A = int( + max(MIN_A, min(MAX_A, new_A)) + ) # clamp new_A to stay in [MIN_A, MAX_A] + + new_gamma = self.swap.gamma() * gamma_change + new_gamma = int( + max(MIN_GAMMA, min(MAX_GAMMA, new_gamma)) + ) # clamp new_gamma to stay in [MIN_GAMMA, MAX_GAMMA] + + # current timestamp + fuzzed days + ramp_duration = boa.env.evm.patch.timestamp + days * UNIX_DAY + + self.swap.ramp_A_gamma( + new_A, + new_gamma, + ramp_duration, + sender=self.swap_admin, + ) + + @invariant() + def up_only_profit(self): + """ + We allow the profit to go down only because of the ramp. + """ + pass + + @invariant() + def virtual_price(self): + """ + We allow the profit to go down only because of the ramp. + """ + pass + + +def test_ramp(users, coins, swap): + RampTest.TestCase.settings = settings( + max_examples=MAX_SAMPLES, + stateful_step_count=STEP_COUNT, + suppress_health_check=list(HealthCheck), + deadline=None, + ) + + for k, v in locals().items(): + setattr(RampTest, k, v) + + # because of this hypothesis.event does not work + run_state_machine_as_test(RampTest) diff --git a/tests/unitary/pool/stateful/test_simulate.py b/tests/unitary/pool/stateful/legacy/test_simulate.py similarity index 88% rename from tests/unitary/pool/stateful/test_simulate.py rename to tests/unitary/pool/stateful/legacy/test_simulate.py index 3b86f37b..3c395863 100644 --- a/tests/unitary/pool/stateful/test_simulate.py +++ b/tests/unitary/pool/stateful/legacy/test_simulate.py @@ -1,10 +1,11 @@ import boa from boa.test import strategy +from hypothesis import HealthCheck, settings from hypothesis.stateful import invariant, rule, run_state_machine_as_test -from tests.unitary.pool.stateful.stateful_base import StatefulBase +from tests.unitary.pool.stateful.legacy.stateful_base import StatefulBase from tests.utils import approx -from tests.utils import simulation_int_many as sim +from tests.utils import simulator as sim from tests.utils.tokens import mint_for_testing MAX_SAMPLES = 20 @@ -36,11 +37,9 @@ def setup(self): self.swap.A(), self.swap.gamma(), self.swap.D(), - 2, [10**18, self.swap.price_scale()], self.swap.mid_fee() / 1e10, self.swap.out_fee() / 1e10, - self.swap.allowed_extra_profit(), self.swap.fee_gamma(), self.swap.adjustment_step() / 1e18, int( @@ -53,7 +52,7 @@ def setup(self): # Adjust virtual prices self.trader.xcp_profit = self.swap.xcp_profit() self.trader.xcp_profit_real = self.swap.virtual_price() - self.trader.t = boa.env.vm.state.timestamp + self.trader.t = boa.env.evm.patch.timestamp self.swap_no = 0 @rule( @@ -75,12 +74,12 @@ def exchange(self, exchange_amount_in, exchange_i, user): return # if swap breaks, dont check. dy_trader = self.trader.buy(dx, exchange_i, 1 - exchange_i) - self.trader.tweak_price(boa.env.vm.state.timestamp) + self.trader.tweak_price(boa.env.evm.patch.timestamp) # exchange checks: assert approx(self.swap_out, dy_trader, 1e-3) assert approx( - self.swap.price_oracle(), self.trader.price_oracle[1], 1e-3 + self.swap.price_oracle(), self.trader.price_oracle[1], 1.5e-3 ) boa.env.time_travel(12) @@ -104,17 +103,15 @@ def simulator(self): def test_sim(users, coins, swap): - from hypothesis import settings - from hypothesis._settings import HealthCheck - StatefulSimulation.TestCase.settings = settings( max_examples=MAX_SAMPLES, stateful_step_count=STEP_COUNT, - suppress_health_check=HealthCheck.all(), + suppress_health_check=list(HealthCheck), deadline=None, ) for k, v in locals().items(): setattr(StatefulSimulation, k, v) + # because of this hypothesis.event does not work run_state_machine_as_test(StatefulSimulation) diff --git a/tests/unitary/pool/stateful/legacy/test_stateful.py b/tests/unitary/pool/stateful/legacy/test_stateful.py new file mode 100644 index 00000000..d34bd782 --- /dev/null +++ b/tests/unitary/pool/stateful/legacy/test_stateful.py @@ -0,0 +1,183 @@ +import boa +from hypothesis import HealthCheck, settings +from hypothesis.stateful import ( + Bundle, + precondition, + rule, + run_state_machine_as_test, +) + +from tests.fixtures.pool import INITIAL_PRICES +from tests.unitary.pool.stateful.legacy.stateful_base import StatefulBase +from tests.utils.tokens import mint_for_testing + +MAX_SAMPLES = 20 +STEP_COUNT = 100 +MAX_D = 10**12 * 10**18 # $1T is hopefully a reasonable cap for tests + + +class NumbaGoUp(StatefulBase): + """ + Test that profit goes up + """ + + depositor = Bundle("depositor") + + def supply_not_too_big(self): + # this is not stableswap so hard + # to say what is a good limit + return self.swap.D() < MAX_D + + def pool_not_empty(self): + return self.total_supply != 0 + + @precondition(supply_not_too_big) + @rule( + target=depositor, + deposit_amounts=StatefulBase.two_token_amounts, + user=StatefulBase.user, + ) + def add_liquidity(self, amounts, user): + if sum(amounts) == 0: + return str(user) + + new_balances = [x + y for x, y in zip(self.balances, amounts)] + + for coin, q in zip(self.coins, amounts): + mint_for_testing(coin, user, q) + + try: + + tokens = self.swap.balanceOf(user) + self.swap.add_liquidity(amounts, 0, sender=user) + tokens = self.swap.balanceOf(user) - tokens + self.total_supply += tokens + self.balances = new_balances + + except Exception: + + if self.check_limits(amounts): + raise + return str(user) + + # This is to check that we didn't end up in a borked state after + # an exchange succeeded + try: + self.swap.get_dy(0, 1, 10 ** (self.decimals[0] - 2)) + except Exception: + self.swap.get_dy( + 1, + 0, + 10**16 * 10 ** self.decimals[1] // self.swap.price_scale(), + ) + return str(user) + + @precondition(pool_not_empty) + @rule(token_amount=StatefulBase.token_amount, user=depositor) + def remove_liquidity(self, token_amount, user): + if self.swap.balanceOf(user) < token_amount or token_amount == 0: + print("Skipping") + # no need to have this case in stateful + with boa.reverts(): + self.swap.remove_liquidity(token_amount, [0] * 2, sender=user) + else: + print("Removing") + amounts = [c.balanceOf(user) for c in self.coins] + tokens = self.swap.balanceOf(user) + with self.upkeep_on_claim(): + self.swap.remove_liquidity(token_amount, [0] * 2, sender=user) + tokens -= self.swap.balanceOf(user) + self.total_supply -= tokens + amounts = [ + (c.balanceOf(user) - a) for c, a in zip(self.coins, amounts) + ] + self.balances = [b - a for a, b in zip(amounts, self.balances)] + + # Virtual price resets if everything is withdrawn + if self.total_supply == 0: + self.virtual_price = 10**18 + + @precondition(pool_not_empty) + @rule( + token_amount=StatefulBase.token_amount, + exchange_i=StatefulBase.exchange_i, + user=depositor, + ) + def remove_liquidity_one_coin(self, token_amount, exchange_i, user): + try: + calc_out_amount = self.swap.calc_withdraw_one_coin( + token_amount, exchange_i + ) + except Exception: + if ( + self.check_limits([0] * 2) + and not (token_amount > self.total_supply) + and token_amount > 10000 + ): + self.swap.calc_withdraw_one_coin( + token_amount, exchange_i, sender=user + ) + return + + d_token = self.swap.balanceOf(user) + if d_token < token_amount: + with boa.reverts(): + self.swap.remove_liquidity_one_coin( + token_amount, exchange_i, 0, sender=user + ) + return + + d_balance = self.coins[exchange_i].balanceOf(user) + try: + with self.upkeep_on_claim(): + self.swap.remove_liquidity_one_coin( + token_amount, exchange_i, 0, sender=user + ) + except Exception: + # Small amounts may fail with rounding errors + if ( + calc_out_amount > 100 + and token_amount / self.total_supply > 1e-10 + and calc_out_amount / self.swap.balances(exchange_i) > 1e-10 + ): + raise + return + + # This is to check that we didn't end up in a borked state after + # an exchange succeeded + _deposit = [0] * 2 + _deposit[exchange_i] = ( + 10**16 + * 10 ** self.decimals[exchange_i] + // ([10**18] + INITIAL_PRICES)[exchange_i] + ) + assert self.swap.calc_token_amount(_deposit, True) + + d_balance = self.coins[exchange_i].balanceOf(user) - d_balance + d_token = d_token - self.swap.balanceOf(user) + + assert ( + calc_out_amount == d_balance + ), f"{calc_out_amount} vs {d_balance} for {token_amount}" + + self.balances[exchange_i] -= d_balance + self.total_supply -= d_token + + # Virtual price resets if everything is withdrawn + if self.total_supply == 0: + self.virtual_price = 10**18 + + +def test_numba_go_up(users, coins, swap): + NumbaGoUp.TestCase.settings = settings( + max_examples=MAX_SAMPLES, + stateful_step_count=STEP_COUNT, + suppress_health_check=list(HealthCheck), + deadline=None, + ) + + for k, v in locals().items(): + setattr(NumbaGoUp, k, v) + + # because of this hypothesis.event does not work + run_state_machine_as_test(NumbaGoUp) diff --git a/tests/unitary/pool/stateful/stateful_base.py b/tests/unitary/pool/stateful/stateful_base.py index 31900774..29bbb031 100644 --- a/tests/unitary/pool/stateful/stateful_base.py +++ b/tests/unitary/pool/stateful/stateful_base.py @@ -1,270 +1,618 @@ -import contextlib -from math import log +from math import log, log10 +from typing import List import boa -from boa.test import strategy -from hypothesis.stateful import RuleBasedStateMachine, invariant, rule - -from tests.fixtures.pool import INITIAL_PRICES +from hypothesis import assume, event, note +from hypothesis.stateful import ( + RuleBasedStateMachine, + initialize, + invariant, + precondition, + rule, +) +from hypothesis.strategies import integers + +from contracts.main import CurveTwocryptoFactory as factory +from contracts.mocks import ERC20Mock as ERC20 +from tests.utils.constants import UNIX_DAY +from tests.utils.strategies import address, pool_from_preset from tests.utils.tokens import mint_for_testing -MAX_SAMPLES = 20 -MAX_D = 10**12 * 10**18 # $1T is hopefully a reasonable cap for tests - class StatefulBase(RuleBasedStateMachine): - exchange_amount_in = strategy("uint256", max_value=10**9 * 10**18) - exchange_i = strategy("uint8", max_value=1) - sleep_time = strategy("uint256", max_value=86400 * 7) - user = strategy("address") + pool = None + total_supply = 0 + coins = None + balances = None + decimals = None + xcp_profit = 0 + xcp_profit_a = 0 + xcpx = 0 + depositors = None + equilibrium = 0 + swapped_once = False + fee_receiver = None + admin = None + + @initialize( + pool=pool_from_preset(), + amount=integers(min_value=int(1e20), max_value=int(1e30)), + user=address, + ) + def initialize_pool(self, pool, amount, user): + """Initialize the state machine with a pool and some + initial liquidity. + + Prefer to use this method instead of the `__init__` method + when initializing the state machine. + """ - def __init__(self): + # cahing the pool generated by the strategy + self.pool = pool - super().__init__() + # total supply of lp tokens (updated from reported balances) + self.total_supply = 0 - self.decimals = [int(c.decimals()) for c in self.coins] - self.user_balances = {u: [0] * 2 for u in self.users} - self.initial_prices = INITIAL_PRICES - self.initial_deposit = [ - 10**4 * 10 ** (18 + d) // p - for p, d in zip(self.initial_prices, self.decimals) - ] # $10k * 2 + # caching coins here for easier access + self.coins = [ERC20.at(pool.coins(i)) for i in range(2)] - self.xcp_profit = 10**18 - self.xcp_profit_a = 10**18 + # these balances should follow the pool balances + self.balances = [0, 0] - self.total_supply = 0 - self.previous_pool_profit = 0 + # cache the decimals of the coins + self.decimals = [c.decimals() for c in self.coins] - self.swap_admin = self.swap.admin() - self.fee_receiver = self.swap.fee_receiver() + # initial profit is 1e18 + self.xcp_profit = 1e18 + self.xcp_profit_a = 1e18 + self.xcpx = 1e18 - for user in self.users: - for coin in self.coins: - coin.approve(self.swap, 2**256 - 1, sender=user) + self.depositors = set() - self.setup() + self.equilibrium = 5e17 - def setup(self, user_id=0): + self.swapped_once = False - user = self.users[user_id] - for coin, q in zip(self.coins, self.initial_deposit): - mint_for_testing(coin, user, q) + self.fee_receiver = factory.at(pool.factory()).fee_receiver() + self.admin = factory.at(pool.factory()).admin() - # Very first deposit - self.swap.add_liquidity(self.initial_deposit, 0, sender=user) + # figure out the amount of the second token for a balanced deposit + balanced_amounts = self.get_balanced_deposit_amounts(amount) - self.balances = self.initial_deposit[:] - self.total_supply = self.swap.balanceOf(user) - self.xcp_profit = 10**18 + # correct amounts to the right number of decimals + balanced_amounts = self.correct_all_decimals(balanced_amounts) - def convert_amounts(self, amounts): - prices = [10**18] + [self.swap.price_scale()] - return [ - p * a // 10 ** (36 - d) - for p, a, d in zip(prices, amounts, self.decimals) - ] + note( + "seeding pool with balanced amounts: {:.2e} {:.2e}".format( + *balanced_amounts + ) + ) + self.add_liquidity(balanced_amounts, user) + note("[SUCCESS]") + + # --------------- utility methods --------------- + + def is_ramping(self) -> bool: + """Check if the pool is currently ramping.""" + + return self.pool.future_A_gamma_time() > boa.env.evm.patch.timestamp + + def correct_decimals(self, amount: int, coin_idx: int) -> int: + """Takes an amount that uses 18 decimals and reduces its precision""" + + corrected_amount = int( + amount // (10 ** (18 - self.decimals[coin_idx])) + ) + # sometimes a non-zero amount generated + # by the strategy is <= 0 when corrected + if amount > 0: + assume(corrected_amount > 0) + return corrected_amount + + def correct_all_decimals(self, amounts: List[int]) -> list[int]: + """Takes a list of amounts that use 18 decimals and reduces their + precision to the number of decimals of the respective coins.""" + + return [self.correct_decimals(a, i) for i, a in enumerate(amounts)] + + def get_balanced_deposit_amounts(self, amount: int): + """Get the amounts of tokens that should be deposited + to the pool to have balanced amounts of the two tokens. - def check_limits(self, amounts, D=True, y=True): + Args: + amount (int): the amount of the first token + + Returns: + List[int]: the amounts of the two tokens """ - Should be good if within limits, but if outside - can be either + return [int(amount), int(amount * 1e18 // self.pool.price_scale())] + + def report_equilibrium(self): + """Helper function to report the current equilibrium of the pool. + This is useful to see how the pool is doing in terms of + imbalances. + + This is useful to see if a revert because of "unsafe values" in + the math contract could be justified by the pool being too imbalanced. + + We compute the equilibrium as the ratio between the two tokens + scaled prices. That is xp / yp where xp is the amount of the first + token in the pool multiplied by its price scale (1) and yp is the + amount of the second token in the pool multiplied by its price + scale (price_scale). """ - _D = self.swap.D() - prices = [10**18] + [self.swap.price_scale()] - xp_0 = [self.swap.balances(i) for i in range(2)] - xp = xp_0 - xp_0 = [ - x * p // 10**d for x, p, d in zip(xp_0, prices, self.decimals) - ] - xp = [ - (x + a) * p // 10**d - for a, x, p, d in zip(amounts, xp, prices, self.decimals) - ] + # we calculate the equilibrium of the pool + old_equilibrium = self.equilibrium - if D: - for _xp in [xp_0, xp]: - if ( - (min(_xp) * 10**18 // max(_xp) < 10**14) - or (max(_xp) < 10**9 * 10**18) - or (max(_xp) > 10**15 * 10**18) - ): - return False - - if y: - for _xp in [xp_0, xp]: - if ( - (_D < 10**17) - or (_D > 10**15 * 10**18) - or (min(_xp) * 10**18 // _D < 10**16) - or (max(_xp) * 10**18 // _D > 10**20) - ): - return False + # price of the first coin is always 1 + xp = self.coins[0].balanceOf(self.pool) * ( + 10 ** (18 - self.decimals[0]) # normalize to 18 decimals + ) - return True + yp = ( + self.coins[1].balanceOf(self.pool) + * self.pool.price_scale() # price of the second coin + * (10 ** (18 - self.decimals[1])) # normalize to 18 decimals + ) - @rule( - exchange_amount_in=exchange_amount_in, - exchange_i=exchange_i, - user=user, - ) - def exchange(self, exchange_amount_in, exchange_i, user): - out = self._exchange(exchange_amount_in, exchange_i, user) - if out: - self.swap_out = out + self.equilibrium = xp * 1e18 / yp + + # we compute the percentage change from the old equilibrium + # to have a sense of how much an operation changed the pool + percentage_change = ( + self.equilibrium - old_equilibrium + ) / old_equilibrium + + # we report equilibrium as log to make it easier to read + note( + "pool equilibrium {:.2f} (center is at 0) ".format( + log10(self.equilibrium) + ) + + "| change from old equilibrium: {:.4%}".format(percentage_change) + ) + + # --------------- pool methods --------------- + # methods that wrap the pool methods that should be used in + # the rules of the state machine. These methods make sure that + # both the state of the pool and of the state machine are + # updated together. Calling pool methods directly will probably + # lead to incorrect simulation and errors. + + def add_liquidity(self, amounts: List[int], user: str): + """Wrapper around the `add_liquidity` method of the pool. + Always prefer this instead of calling the pool method directly + when constructing rules. + + Args: + amounts (List[int]): amounts of tokens to be deposited + user (str): the sender of the transaction + + Returns: + str: the address of the depositor + """ + # check to prevent revert on empty deposits + if sum(amounts) == 0: + event("empty deposit") return - self.swap_out = None - def _exchange( - self, exchange_amount_in, exchange_i, user, check_out_amount=True - ): - exchange_j = 1 - exchange_i - try: - calc_amount = self.swap.get_dy( - exchange_i, exchange_j, exchange_amount_in + for coin, amount in zip(self.coins, amounts): + # infinite approval + coin.approve(self.pool, 2**256 - 1, sender=user) + # mint the amount of tokens for the depositor + mint_for_testing(coin, user, amount) + + # store the amount of lp tokens before the deposit + lp_tokens = self.pool.balanceOf(user) + + self.pool.add_liquidity(amounts, 0, sender=user) + + # find the increase in lp tokens + lp_tokens = self.pool.balanceOf(user) - lp_tokens + # increase the total supply by the amount of lp tokens + self.total_supply += lp_tokens + + # pool balances should increase by the amounts + self.balances = [x + y for x, y in zip(self.balances, amounts)] + + # update the profit since it increases through `tweak_price` + # which is called by `add_liquidity` + self.xcp_profit = self.pool.xcp_profit() + self.xcp_profit_a = self.pool.xcp_profit_a() + + self.depositors.add(user) + + def exchange(self, dx: int, i: int, user: str) -> bool: + """Wrapper around the `exchange` method of the pool. + Always prefer this instead of calling the pool method directly + when constructing rules. + + Args: + dx (int): amount in + i (int): the token the user sends to swap + user (str): the sender of the transaction + + Returns: + bool: True if the swap was successful, False otherwise + """ + # j is the index of the coin that comes out of the pool + j = 1 - i + + # mint coins for the user + mint_for_testing(self.coins[i], user, dx) + self.coins[i].approve(self.pool, dx, sender=user) + + note( + "trying to swap {:.2e} of token {} ".format( + dx, + i, ) - except Exception: - _amounts = [0] * 2 - _amounts[exchange_i] = exchange_amount_in - if self.check_limits(_amounts) and exchange_amount_in > 10000: - raise - return None - mint_for_testing(self.coins[exchange_i], user, exchange_amount_in) - - d_balance_i = self.coins[exchange_i].balanceOf(user) - d_balance_j = self.coins[exchange_j].balanceOf(user) + ) + + # store the balances of the user before the swap + delta_balance_i = self.coins[i].balanceOf(user) + delta_balance_j = self.coins[j].balanceOf(user) + + # depending on the pool state the swap might revert + # because get_y hits some math try: - self.coins[exchange_i].approve( - self.swap, 2**256 - 1, sender=user - ) - out = self.swap.exchange( - exchange_i, exchange_j, exchange_amount_in, 0, sender=user - ) - except Exception: - # Small amounts may fail with rounding errors - if ( - calc_amount > 100 - and exchange_amount_in > 100 - and calc_amount / self.swap.balances(exchange_j) > 1e-13 - and exchange_amount_in / self.swap.balances(exchange_i) > 1e-13 + expected_dy = self.pool.get_dy(i, j, dx) + except boa.BoaError as e: + # our top priority when something goes wrong is to + # make sure that the lp can always withdraw their funds + self.can_always_withdraw(imbalanced_operations_allowed=True) + + # we make sure that the revert was caused by the pool + # being too imbalanced + if e.stack_trace.last_frame.dev_reason.reason_str not in ( + "unsafe value for y", + "unsafe values x[i]", ): - raise - return None - - # This is to check that we didn't end up in a borked state after - # an exchange succeeded - self.swap.get_dy( - exchange_j, - exchange_i, - 10**16 - * 10 ** self.decimals[exchange_j] - // INITIAL_PRICES[exchange_j], - ) + raise ValueError(f"Reverted for the wrong reason: {e}") + + # we use the log10 of the equilibrium to obtain an easy interval + # to work with. If the pool is balanced the equilibrium is 1 and + # the log10 is 0. + log_equilibrium = log10(self.equilibrium) + # we store the old equilibrium to restore it after we make sure + # that the pool can be healed + event( + "newton_y broke with log10 of x/y = {:.1f}".format( + log_equilibrium + ) + ) - d_balance_i -= self.coins[exchange_i].balanceOf(user) - d_balance_j -= self.coins[exchange_j].balanceOf(user) + # we make sure that the pool is reasonably imbalanced + assert ( + abs(log_equilibrium) >= 0.1 + ), "pool ({:.2e}) is not imbalanced".format(log_equilibrium) - assert d_balance_i == exchange_amount_in - if check_out_amount: - if check_out_amount is True: - assert ( - -d_balance_j == calc_amount - ), f"{-d_balance_j} vs {calc_amount}" - else: - assert abs(d_balance_j + calc_amount) < max( - check_out_amount * calc_amount, 3 - ), f"{-d_balance_j} vs {calc_amount}" + # we return False because the swap failed + # (safe failure, but still a failure) + return False - self.balances[exchange_i] += d_balance_i - self.balances[exchange_j] += d_balance_j + # if get_y didn't fail we can safely swap + actual_dy = self.pool.exchange(i, j, dx, expected_dy, sender=user) - return out + # compute the change in balances + delta_balance_i = self.coins[i].balanceOf(user) - delta_balance_i + delta_balance_j = self.coins[j].balanceOf(user) - delta_balance_j - @rule(sleep_time=sleep_time) - def sleep(self, sleep_time): - boa.env.time_travel(sleep_time) + assert -delta_balance_i == dx, "didn't swap right amount of token x" + assert ( + delta_balance_j == expected_dy == actual_dy + ), "didn't receive the right amount of token y" - @invariant() - def balances(self): - balances = [self.swap.balances(i) for i in range(2)] - balances_of = [c.balanceOf(self.swap) for c in self.coins] - for i in range(2): - assert self.balances[i] == balances[i] - assert self.balances[i] == balances_of[i] + # update the internal balances of the test for the invariants + self.balances[i] -= delta_balance_i + self.balances[j] -= delta_balance_j - @invariant() - def total_supply(self): - assert self.total_supply == self.swap.totalSupply() + # update the profit made by the pool + self.xcp_profit = self.pool.xcp_profit() - @invariant() - def virtual_price(self): - virtual_price = self.swap.virtual_price() - xcp_profit = self.swap.xcp_profit() - get_virtual_price = self.swap.get_virtual_price() + self.swapped_once = True - assert xcp_profit >= 10**18 - 10 - assert virtual_price >= 10**18 - 10 - assert get_virtual_price >= 10**18 - 10 + # we return True because the swap was successful + return True - assert ( - xcp_profit - self.xcp_profit > -3 - ), f"{xcp_profit} vs {self.xcp_profit}" - assert (virtual_price - 10**18) * 2 - ( - xcp_profit - 10**18 - ) >= -5, f"vprice={virtual_price}, xcp_profit={xcp_profit}" - assert abs(log(virtual_price / get_virtual_price)) < 1e-10 + def remove_liquidity(self, amount: int, user: str): + """Wrapper around the `remove_liquidity` method of the pool. + Always prefer this instead of calling the pool method directly + when constructing rules. - self.xcp_profit = xcp_profit + Args: + amount (int): the amount of lp tokens to withdraw + user (str): the address of the withdrawer + """ + # store the balances of the user before the withdrawal + amounts = [c.balanceOf(user) for c in self.coins] - @invariant() - def up_only_profit(self): + # withdraw the liquidity + self.pool.remove_liquidity(amount, [0] * 2, sender=user) + + # compute the change in balances + amounts = [ + (c.balanceOf(user) - a) for c, a in zip(self.coins, amounts) + ] + + # total apply should have decreased by the amount of liquidity + # withdrawn + self.total_supply -= amount + # update the internal balances of the test for the invariants + self.balances = [b - a for a, b in zip(amounts, self.balances)] - current_profit = xcp_profit = self.swap.xcp_profit() - xcp_profit_a = self.swap.xcp_profit_a() - current_profit = (xcp_profit + xcp_profit_a + 1) // 2 + # we don't want to keep track of users with low liquidity because + # it would approximate to 0 tokens and break the invariants. + if self.pool.balanceOf(user) <= 1e0: + self.depositors.remove(user) - assert current_profit >= self.previous_pool_profit - self.previous_pool_profit = current_profit + # virtual price resets if everything is withdrawn + if self.total_supply == 0: + event("full liquidity removal") + self.virtual_price = 1e18 - @contextlib.contextmanager - def upkeep_on_claim(self): + def remove_liquidity_one_coin( + self, percentage: float, coin_idx: int, user: str + ): + """Wrapper around the `remove_liquidity_one_coin` method of the pool. + Always prefer this instead of calling the pool method directly + when constructing rules. + + Args: + percentage (float): percentage of liquidity to withdraw + from the user balance + coin_idx (int): index of the coin to withdraw + user (str): address of the withdrawer + """ + # when the fee receiver is the lp owner we can't compute the + # balances in the invariants correctly. (This should never + # be the case in production anyway). + assume(user != self.fee_receiver) + # store balances of the fee receiver before the removal admin_balances_pre = [ c.balanceOf(self.fee_receiver) for c in self.coins ] - pool_is_ramping = ( - self.swap.future_A_gamma_time() > boa.env.vm.state.timestamp + # store the balance of the user before the removal + user_balances_pre = self.coins[coin_idx].balanceOf(user) + + # lp tokens before the removal + lp_tokens_balance_pre = self.pool.balanceOf(user) + + if percentage >= 0.99: + # this corrects floating point errors that can lead to + # withdrawing more than the user has + lp_tokens_to_withdraw = lp_tokens_balance_pre + else: + lp_tokens_to_withdraw = int(lp_tokens_balance_pre * percentage) + + # this is a bit convoluted because we want this function + # to continue in two scenarios: + # 1. the function didn't revert (except block) + # 2. the function reverted because the virtual price + # decreased (try block + boa.reverts) + try: + with boa.reverts(dev="virtual price decreased"): + self.pool.remove_liquidity_one_coin( + lp_tokens_to_withdraw, + coin_idx, + 0, # no slippage checks + sender=user, + ) + # if we end up here something went wrong, so we need to check + # if the pool was in a state that justifies a revert + + # we only allow small amounts to make the balance decrease + # because of rounding errors + assert ( + lp_tokens_to_withdraw < 1e16 + ), "virtual price decreased but but the amount was too high" + event( + "unsuccessful removal of liquidity because of " + "loss (this should not happen too often)" + ) + return + except ValueError as e: + assert str(e) == "Did not revert" + # if the function didn't revert we can continue + if lp_tokens_to_withdraw < 1e15: + # useful to compare how often this happens compared to failures + event("successful removal of liquidity with low amounts") + + # compute the change in balances + user_balances_post = abs( + user_balances_pre - self.coins[coin_idx].balanceOf(user) ) + # update internal balances + self.balances[coin_idx] -= user_balances_post + # total supply should decrease by the amount of tokens withdrawn + self.total_supply -= lp_tokens_to_withdraw + + # we don't want to keep track of users with low liquidity because + # it would approximate to 0 tokens and break the test. + if self.pool.balanceOf(user) <= 1e0: + self.depositors.remove(user) + + # invarinant upkeeping logic: + # imbalanced removals can trigger a claim of admin fees + + # store the balances of the fee receiver after the removal + new_xcp_profit_a = self.pool.xcp_profit_a() + # store the balances of the fee receiver before the removal + old_xcp_profit_a = self.xcp_profit_a + + # check if the admin fees were claimed (not always the case) + if new_xcp_profit_a > old_xcp_profit_a: + event("admin fees claim was detected") + note("claiming admin fees during removal") + # if the admin fees were claimed we have to update xcp + self.xcp_profit_a = new_xcp_profit_a + + # store the balances of the fee receiver after the removal + # (should be higher than before the removal) + admin_balances_post = [ + c.balanceOf(self.fee_receiver) for c in self.coins + ] + + for i in range(2): + claimed_amount = admin_balances_post[i] - admin_balances_pre[i] + note( + "admin received {:.2e} of token {}".format( + claimed_amount, i + ) + ) + assert ( + claimed_amount > 0 + # decimals: with such a low precision admin fees might be 0 + or self.decimals[i] <= 6 + ), f"the admin fees collected should be positive for coin {i}" + assert not self.is_ramping(), "claim admin fees while ramping" + + # deduce the claimed amount from the pool balances + self.balances[i] -= claimed_amount + + # update test-tracked xcp profit + self.xcp_profit = self.pool.xcp_profit() + + @rule(time_increase=integers(min_value=1, max_value=UNIX_DAY * 7)) + def time_forward(self, time_increase): + """Make the time moves forward by `sleep_time` seconds. + Useful for ramping, oracle updates, etc. + Up to 1 week. + """ + boa.env.time_travel(time_increase) + + # --------------- pool invariants ---------------------- + + @invariant() + def newton_y_converges(self): + """We use get_dy with a small amount to check if the newton_y + still manages to find the correct value. If this is not the case + the pool is broken and it can't execute swaps anymore. + """ + # TODO should this be even smaller? Or depend on the pool size? + ARBITRARY_SMALL_AMOUNT = int(1e15) try: + self.pool.get_dy(0, 1, ARBITRARY_SMALL_AMOUNT) + try: + self.pool.get_dy(1, 0, ARBITRARY_SMALL_AMOUNT) + except Exception: + raise AssertionError("newton_y is broken") + except Exception: + pass - yield + @invariant() + def can_always_withdraw(self, imbalanced_operations_allowed=False): + """Make sure that newton_D always works when withdrawing liquidity. + No matter how imbalanced the pool is, it should always be possible + to withdraw liquidity in a proportional way. + """ - finally: + # anchor the environment to make sure that the balances are + # restored after the invariant is checked + with boa.env.anchor(): + # remove all liquidity from all depositors + for d in self.depositors: + # store the current balances of the pool + prev_balances = [c.balanceOf(self.pool) for c in self.coins] + # withdraw all liquidity from the depositor + tokens = self.pool.balanceOf(d) + self.pool.remove_liquidity(tokens, [0] * 2, sender=d) + # assert current balances are less as the previous ones + for c, b in zip(self.coins, prev_balances): + # check that the balance of the pool is less than before + if c.balanceOf(self.pool) == b: + assert self.pool.balanceOf(d) < 10, ( + "balance of the depositor is not small enough to" + "justify a withdrawal that does not affect the" + "pool token balance" + ) + else: + assert c.balanceOf(self.pool) < b, ( + "one withdrawal didn't reduce the liquidity" + "of the pool" + ) + for c in self.coins: + # there should not be any liquidity left in the pool + assert ( + # when imbalanced withdrawal occurs the pool protects + # itself by retaining some liquidity in the pool. + # In such a scenario a pool can have some liquidity left + # even after all withdrawals. + imbalanced_operations_allowed + or + # 1e7 is an arbitrary number that should be small enough + # not to worry about the pool actually not being empty. + c.balanceOf(self.pool) <= 1e7 + ), "pool still has signficant liquidity after all withdrawals" - new_xcp_profit_a = self.swap.xcp_profit_a() - old_xcp_profit_a = self.xcp_profit_a + @invariant() + def balances(self): + balances = [self.pool.balances(i) for i in range(2)] + balance_of = [c.balanceOf(self.pool) for c in self.coins] + for i in range(2): + assert ( + self.balances[i] == balances[i] + ), "test-tracked balances don't match pool-tracked balances" + assert ( + self.balances[i] == balance_of[i] + ), "test-tracked balances don't match token-tracked balances" - claimed = False - if new_xcp_profit_a > old_xcp_profit_a: - claimed = True - self.xcp_profit_a = new_xcp_profit_a + @invariant() + def sanity_check(self): + """Make sure the stateful simulations matches the contract state.""" + assert self.xcp_profit == self.pool.xcp_profit() + assert self.total_supply == self.pool.totalSupply() - admin_balances_post = [ - c.balanceOf(self.fee_receiver) for c in self.coins - ] + # profit, cached vp and current vp should be at least 1e18 + assert self.xcp_profit >= 1e18, "profit should be at least 1e18" + assert ( + self.pool.virtual_price() >= 1e18 + ), "cached virtual price should be at least 1e18" + assert ( + self.pool.get_virtual_price() >= 1e18 + ), "virtual price should be at least 1e18" - if claimed: + for d in self.depositors: + assert ( + self.pool.balanceOf(d) > 0 + ), "tracked depositors should not have 0 lp tokens" - for i in range(2): - claimed_amount = ( - admin_balances_post[i] - admin_balances_pre[i] - ) - assert ( - claimed_amount > 0 - ) # check if non zero amounts of claim - assert not pool_is_ramping # cannot claim while ramping + @precondition(lambda self: self.swapped_once) + @invariant() + def virtual_price(self): + assert (self.pool.virtual_price() - 1e18) * 2 >= ( + self.pool.xcp_profit() - 1e18 + ), "virtual price should be at least twice the profit" + assert ( + abs(log(self.pool.virtual_price() / self.pool.get_virtual_price())) + < 1e-10 + ), "cached virtual price shouldn't lag behind current virtual price" + + @invariant() + def up_only_profit(self): + """This method checks if the pool is profitable, since it should + never lose money. + + To do so we use the so called `xcpx`. This is an empirical measure + of profit that is even stronger than `xcp`. We have to use this + because `xcp` goes down when claiming admin fees. + + You can imagine `xcpx` as a value that that is always between the + interval [xcp_profit, xcp_profit_a]. When `xcp` goes down + when claiming fees, `xcp_a` goes up. Averaging them creates this + measure of profit that only goes down when something went wrong. + """ + xcp_profit = self.pool.xcp_profit() + xcp_profit_a = self.pool.xcp_profit_a() + xcpx = (xcp_profit + xcp_profit_a + 1e18) // 2 + + # make sure that the previous profit is smaller than the current + assert xcpx >= self.xcpx, "xcpx has decreased" + # updates the previous profit + self.xcpx = xcpx + self.xcp_profit = xcp_profit + self.xcp_profit_a = xcp_profit_a - # update self.balances - self.balances[i] -= claimed_amount - self.xcp_profit = self.swap.xcp_profit() +TestBase = StatefulBase.TestCase diff --git a/tests/unitary/pool/stateful/test_ramp.py b/tests/unitary/pool/stateful/test_ramp.py deleted file mode 100644 index ab62ac9a..00000000 --- a/tests/unitary/pool/stateful/test_ramp.py +++ /dev/null @@ -1,107 +0,0 @@ -import boa -from boa.test import strategy -from hypothesis.stateful import invariant, rule, run_state_machine_as_test - -from tests.unitary.pool.stateful.test_stateful import NumbaGoUp - -MAX_SAMPLES = 20 -STEP_COUNT = 100 -MAX_D = 10**12 * 10**18 # $1T is hopefully a reasonable cap for tests -ALLOWED_DIFFERENCE = 0.001 - - -class RampTest(NumbaGoUp): - check_out_amount = strategy("bool") - exchange_amount_in = strategy( - "uint256", min_value=10**18, max_value=50000 * 10**18 - ) - token_amount = strategy( - "uint256", min_value=10**18, max_value=10**12 * 10**18 - ) - deposit_amounts = strategy( - "uint256[3]", min_value=10**18, max_value=10**9 * 10**18 - ) - user = strategy("address") - exchange_i = strategy("uint8", max_value=1) - - def setup(self, user_id=0): - super().setup(user_id) - new_A = self.swap.A() * 2 - new_gamma = self.swap.gamma() * 2 - self.swap.ramp_A_gamma( - new_A, - new_gamma, - boa.env.vm.state.timestamp + 14 * 86400, - sender=self.swap_admin, - ) - - @rule(user=user, deposit_amounts=deposit_amounts) - def deposit(self, deposit_amounts, user): - deposit_amounts[1:] = [ - deposit_amounts[0], - deposit_amounts[1] * 10**18 // self.swap.price_oracle(), - ] - super().deposit(deposit_amounts, user) - - @rule( - user=user, - exchange_i=exchange_i, - exchange_amount_in=exchange_amount_in, - check_out_amount=check_out_amount, - ) - def exchange(self, exchange_amount_in, exchange_i, user, check_out_amount): - - if exchange_i > 0: - exchange_amount_in = ( - exchange_amount_in * 10**18 // self.swap.price_oracle() - ) - if exchange_amount_in < 1000: - return - - super()._exchange( - exchange_amount_in, - exchange_i, - user, - ALLOWED_DIFFERENCE if check_out_amount else False, - ) - - @rule( - user=user, - token_amount=token_amount, - exchange_i=exchange_i, - check_out_amount=check_out_amount, - ) - def remove_liquidity_one_coin( - self, token_amount, exchange_i, user, check_out_amount - ): - - if check_out_amount: - super().remove_liquidity_one_coin( - token_amount, exchange_i, user, ALLOWED_DIFFERENCE - ) - else: - super().remove_liquidity_one_coin( - token_amount, exchange_i, user, False - ) - - @invariant() - def virtual_price(self): - # Invariant is not conserved here - pass - - -def test_ramp(users, coins, swap): - from hypothesis import settings - from hypothesis._settings import HealthCheck - - RampTest.TestCase.settings = settings( - max_examples=MAX_SAMPLES, - stateful_step_count=STEP_COUNT, - suppress_health_check=HealthCheck.all(), - deadline=None, - ) - - for k, v in locals().items(): - setattr(RampTest, k, v) - - run_state_machine_as_test(RampTest) diff --git a/tests/unitary/pool/stateful/test_ramp_nocheck.py b/tests/unitary/pool/stateful/test_ramp_nocheck.py deleted file mode 100644 index 2519fbc4..00000000 --- a/tests/unitary/pool/stateful/test_ramp_nocheck.py +++ /dev/null @@ -1,81 +0,0 @@ -import boa -from boa.test import strategy -from hypothesis.stateful import invariant, rule, run_state_machine_as_test - -from tests.unitary.pool.stateful.test_stateful import NumbaGoUp - -MAX_SAMPLES = 20 -STEP_COUNT = 100 -MAX_D = 10**12 * 10**18 # $1T is hopefully a reasonable cap for tests -ALLOWED_DIFFERENCE = 0.02 - - -class RampTest(NumbaGoUp): - future_gamma = strategy( - "uint256", - min_value=int(2.8e-4 * 1e18 / 9), - max_value=int(2.8e-4 * 1e18 * 9), - ) - future_A = strategy( - "uint256", - min_value=90 * 2**2 * 10000 // 9, - max_value=90 * 2**2 * 10000 * 9, - ) - check_out_amount = strategy("bool") - exchange_amount_in = strategy( - "uint256", min_value=10**18, max_value=50000 * 10**18 - ) - token_amount = strategy( - "uint256", min_value=10**18, max_value=10**12 * 10**18 - ) - user = strategy("address") - exchange_i = strategy("uint8", max_value=1) - - def initialize(self, future_A, future_gamma): - self.swap.ramp_A_gamma( - future_A, - future_gamma, - boa.env.vm.state.timestamp + 14 * 86400, - sender=self.swap_admin, - ) - - @rule( - exchange_amount_in=exchange_amount_in, - exchange_i=exchange_i, - user=user, - ) - def exchange(self, exchange_amount_in, exchange_i, user): - try: - super()._exchange(exchange_amount_in, exchange_i, user, False) - except Exception: - if exchange_amount_in > 10**9: - # Small swaps can fail at ramps - raise - - @rule(token_amount=token_amount, exchange_i=exchange_i, user=user) - def remove_liquidity_one_coin(self, token_amount, exchange_i, user): - super().remove_liquidity_one_coin( - token_amount, exchange_i, user, False - ) - - @invariant() - def virtual_price(self): - # Invariant is not conserved here - pass - - -def test_ramp(users, coins, swap): - from hypothesis import settings - from hypothesis._settings import HealthCheck - - RampTest.TestCase.settings = settings( - max_examples=MAX_SAMPLES, - stateful_step_count=STEP_COUNT, - suppress_health_check=HealthCheck.all(), - deadline=None, - ) - - for k, v in locals().items(): - setattr(RampTest, k, v) - - run_state_machine_as_test(RampTest) diff --git a/tests/unitary/pool/stateful/test_stateful.py b/tests/unitary/pool/stateful/test_stateful.py index 8f9653ce..48b244ec 100644 --- a/tests/unitary/pool/stateful/test_stateful.py +++ b/tests/unitary/pool/stateful/test_stateful.py @@ -1,182 +1,346 @@ import boa -from boa.test import strategy -from hypothesis.stateful import rule, run_state_machine_as_test +from hypothesis import event, note +from hypothesis.stateful import precondition, rule +from hypothesis.strategies import data, floats, integers, sampled_from +from stateful_base import StatefulBase -from tests.fixtures.pool import INITIAL_PRICES -from tests.unitary.pool.stateful.stateful_base import StatefulBase -from tests.utils.tokens import mint_for_testing +from tests.utils.constants import MAX_A, MAX_GAMMA, MIN_A, MIN_GAMMA, UNIX_DAY +from tests.utils.strategies import address -MAX_SAMPLES = 20 -STEP_COUNT = 100 -MAX_D = 10**12 * 10**18 # $1T is hopefully a reasonable cap for tests + +class OnlySwapStateful(StatefulBase): + """This test suits always starts with a seeded pool + with balanced amounts and execute only swaps depending + on the liquidity in the pool. + """ + + @rule( + data=data(), + i=integers(min_value=0, max_value=1), + user=address, + ) + def exchange_rule(self, data, i: int, user: str): + note("[EXCHANGE]") + liquidity = self.coins[i].balanceOf(self.pool) + # we use a data strategy since the amount we want to swap + # depends on the pool liquidity which is only known at runtime + dx = data.draw( + integers( + # swap can be between 0.01% and 50% of the pool liquidity + min_value=int(liquidity * 0.0001), + max_value=int(liquidity * 0.50), + ), + label="dx", + ) + # decimals: sometime very small amount get rounded to 0 + if dx == 0: + note("corrected dx draw to 1") + event("corrected dx to 1") + dx = 1 + + note("trying to swap: {:.2%} of pool liquidity".format(dx / liquidity)) + + exchange_successful = self.exchange(dx, i, user) + + if exchange_successful: + # if the exchange was successful it alters the pool + # composition so we report the new equilibrium + self.report_equilibrium() + note("[SUCCESS]") + else: + # if the exchange was not successful we add an + # event to make sure that failure was reasonable + event( + "swap failed (balance = {:.2e}) {:.2%} of liquidity with A: " + "{:.2e} and gamma: {:.2e}".format( + self.equilibrium, + dx / liquidity, + self.pool.A(), + self.pool.gamma(), + ) + ) + note("[ALLOWED FAILURE]") -class NumbaGoUp(StatefulBase): +class UpOnlyLiquidityStateful(OnlySwapStateful): + """This test suite does everything as the `OnlySwapStateful` + but also adds liquidity to the pool. It does not remove liquidity.""" + + # too high liquidity can lead to overflows + @precondition(lambda self: self.pool.D() < 1e28) + @rule( + # we can only add liquidity up to 1e25, this was reduced + # from the initial deposit that can be up to 1e30 to avoid + # breaking newton_D + amount=integers(min_value=int(1e20), max_value=int(1e25)), + user=address, + ) + def add_liquidity_balanced(self, amount: int, user: str): + note("[BALANCED DEPOSIT]") + # figure out the amount of the second token for a balanced deposit + balanced_amounts = self.get_balanced_deposit_amounts(amount) + + # correct amounts to the right number of decimals + balanced_amounts = self.correct_all_decimals(balanced_amounts) + + note( + "increasing pool liquidity with balanced amounts: " + + "{:.2e} {:.2e}".format(*balanced_amounts) + ) + self.add_liquidity(balanced_amounts, user) + note("[SUCCESS]") + + +class OnlyBalancedLiquidityStateful(UpOnlyLiquidityStateful): + """This test suite does everything as the `UpOnlyLiquidityStateful` + but also removes liquidity from the pool. Both deposits and withdrawals + are balanced. """ - Test that profit goes up + + @precondition( + # we need to have enough liquidity before removing + # leaving the pool with shallow liquidity can break the amm + lambda self: self.pool.totalSupply() > 10e20 + # we should not empty the pool + # (we still check that we can in the invariants) + and len(self.depositors) > 1 + ) + @rule( + data=data(), + ) + def remove_liquidity_balanced(self, data): + note("[BALANCED WITHDRAW]") + # we use a data strategy since the amount we want to remove + # depends on the pool liquidity and the depositor balance + # which are only known at runtime + depositor = data.draw( + sampled_from(list(self.depositors)), + label="depositor for balanced withdraw", + ) + depositor_balance = self.pool.balanceOf(depositor) + # we can remove between 10% and 100% of the depositor balance + amount = data.draw( + integers( + min_value=int(depositor_balance * 0.10), + max_value=depositor_balance, + ), + label="amount to withdraw", + ) + note( + "Removing {:.2e} from the pool ".format(amount) + + "that is {:.1%} of address balance".format( + amount / depositor_balance + ) + + " and {:.1%} of pool liquidity".format( + amount / self.pool.totalSupply() + ) + ) + + self.remove_liquidity(amount, depositor) + note("[SUCCESS]") + + +class ImbalancedLiquidityStateful(OnlyBalancedLiquidityStateful): + """This test suite does everything as the `OnlyBalancedLiquidityStateful` + Deposits and withdrawals can be imbalanced. + + This is the most complex test suite and should be used when making sure + that some specific gamma and A can be used without unexpected behavior. """ - user = strategy("address") - exchange_i = strategy("uint8", max_value=1) - deposit_amounts = strategy( - "uint256[2]", min_value=0, max_value=10**9 * 10**18 + # too high imbalanced liquidity can break newton_D + @precondition(lambda self: self.pool.D() < 1e28) + @rule( + data=data(), + imbalance_ratio=floats(min_value=0, max_value=1), + user=address, ) - token_amount = strategy("uint256", max_value=10**12 * 10**18) - check_out_amount = strategy("bool") + def add_liquidity_imbalanced(self, data, imbalance_ratio, user: str): + note("[IMBALANCED DEPOSIT]") - @rule(deposit_amounts=deposit_amounts, user=user) - def deposit(self, deposit_amounts, user): + jump_limit = 2 - if self.swap.D() > MAX_D: - return + amount = data.draw( + integers( + min_value=int(1e18), + max_value=max( + self.coins[0].balanceOf(user) * jump_limit, int(1e18) + ), + ), + label="amount", + ) - amounts = self.convert_amounts(deposit_amounts) - if sum(amounts) == 0: - return + balanced_amounts = self.get_balanced_deposit_amounts(amount) + imbalanced_amounts = [ + int(balanced_amounts[0] * imbalance_ratio) + if imbalance_ratio != 1 + else balanced_amounts[0], + int(balanced_amounts[1] * (1 - imbalance_ratio)) + if imbalance_ratio != 0 + else balanced_amounts[1], + ] - new_balances = [x + y for x, y in zip(self.balances, amounts)] + # we correct the decimals of the imbalanced amounts + imbalanced_amounts = self.correct_all_decimals(imbalanced_amounts) - for coin, q in zip(self.coins, amounts): - mint_for_testing(coin, user, q) + note("depositing {:.2e} and {:.2e}".format(*imbalanced_amounts)) + # we add the liquidity + self.add_liquidity(imbalanced_amounts, user) - try: + # since this is an imbalanced deposit we report the new equilibrium + self.report_equilibrium() + note("[SUCCESS]") - tokens = self.swap.balanceOf(user) - self.swap.add_liquidity(amounts, 0, sender=user) - tokens = self.swap.balanceOf(user) - tokens - self.total_supply += tokens - self.balances = new_balances + @precondition( + # we need to have enough liquidity before removing + # leaving the pool with shallow liquidity can break the amm + lambda self: self.pool.totalSupply() > 10e20 + # we should not empty the pool + # (we still check that we can in the invariants) + and len(self.depositors) > 1 + ) + @rule( + data=data(), + coin_idx=integers(min_value=0, max_value=1), + ) + def remove_liquidity_one_coin_rule(self, data, coin_idx: int): + note("[WITHDRAW ONE COIN]") + + # we only allow depositors with enough balance to withdraw + # this avoids edge cases where the virtual price decreases + # because of a small withdrawal + depositors_allowed_to_withdraw = [ + d for d in self.depositors if self.pool.balanceOf(d) > 1e11 + ] + + # this should never happen thanks to the preconditions + if len(depositors_allowed_to_withdraw) == 0: + raise ValueError("No depositors with enough balance to withdraw") + + # we use a data strategy since the amount we want to remove + # depends on the pool liquidity and the depositor balance + depositor = data.draw( + sampled_from(depositors_allowed_to_withdraw), + label="depositor for imbalanced withdraw", + ) + + # depositor amount of lp tokens + depositor_balance = self.pool.balanceOf(depositor) + + # total amount of lp tokens in circulation + lp_supply = self.pool.totalSupply() - except Exception: + # ratio of the pool that the depositor will remove + depositor_ratio = depositor_balance / lp_supply - if self.check_limits(amounts): - raise - return + # TODO check these two conditions + max_withdraw = 0.3 if depositor_ratio > 0.25 else 1 - # This is to check that we didn't end up in a borked state after - # an exchange succeeded - try: - self.swap.get_dy(0, 1, 10 ** (self.decimals[0] - 2)) - except Exception: - self.swap.get_dy( - 1, - 0, - 10**16 * 10 ** self.decimals[1] // self.swap.price_scale(), + min_withdraw = 0.1 if depositor_balance >= 1e13 else 0.01 + + # we draw a percentage of the depositor balance to withdraw + percentage = data.draw( + floats(min_value=min_withdraw, max_value=max_withdraw) + ) + + note( + "removing {:.2e} lp tokens ".format( + amount_withdrawn := percentage * depositor_balance + ) + + "which is {:.4%} of pool liquidity ".format( + amount_withdrawn / lp_supply ) + + "(only coin {}) ".format(coin_idx) + + "and {:.1%} of address balance".format(percentage) + ) + self.remove_liquidity_one_coin(percentage, coin_idx, depositor) + self.report_equilibrium() + note("[SUCCESS]") + + def can_always_withdraw(self, imbalanced_operations_allowed=True): + # we allow imbalanced operations by default + super().can_always_withdraw(imbalanced_operations_allowed=True) + + def virtual_price(self): + # we disable this invariant because claiming admin fees can break it. + # claiming admin_fees can lead to a decrease in the virtual price + # however the pool is still profitable as long as xcpx is increasing. + pass - @rule(token_amount=token_amount, user=user) - def remove_liquidity(self, token_amount, user): - if self.swap.balanceOf(user) < token_amount or token_amount == 0: - with boa.reverts(): - self.swap.remove_liquidity(token_amount, [0] * 2, sender=user) - else: - amounts = [c.balanceOf(user) for c in self.coins] - tokens = self.swap.balanceOf(user) - with self.upkeep_on_claim(): - self.swap.remove_liquidity(token_amount, [0] * 2, sender=user) - tokens -= self.swap.balanceOf(user) - self.total_supply -= tokens - amounts = [ - (c.balanceOf(user) - a) for c, a in zip(self.coins, amounts) - ] - self.balances = [b - a for a, b in zip(amounts, self.balances)] - - # Virtual price resets if everything is withdrawn - if self.total_supply == 0: - self.virtual_price = 10**18 +class RampingStateful(ImbalancedLiquidityStateful): + """This test suite does everything as the `ImbalancedLiquidityStateful` + but also ramps the pool. Because of this some of the invariant checks + are disabled (loss is expected). + + This class tests statefully tests wheter ramping A and + gamma does not break the pool. At the start it always start + with a ramp, then it can ramp again. + """ + + # create the steps for the ramp + # [0.2, 0.3 ... 0.9, 1, 2, 3 ... 10] + change_steps = [x / 10 if x < 10 else x for x in range(2, 11)] + list( + range(2, 11) + ) + + # we can only ramp A and gamma at most 10x + # lower/higher than their starting value + change_step_strategy = sampled_from(change_steps) + + # we fuzz the ramp duration up to a year + days = integers(min_value=1, max_value=365) + + @precondition(lambda self: not self.is_ramping()) @rule( - token_amount=token_amount, - exchange_i=exchange_i, - user=user, - check_out_amount=check_out_amount, + A_change=change_step_strategy, + gamma_change=change_step_strategy, + days=days, ) - def remove_liquidity_one_coin( - self, token_amount, exchange_i, user, check_out_amount - ): + def ramp(self, A_change, gamma_change, days): + """ + Computes the new A and gamma values by multiplying the current ones + by the change factors. Then clamps the new values to stay in the + [MIN_A, MAX_A] and [MIN_GAMMA, MAX_GAMMA] ranges. - try: - calc_out_amount = self.swap.calc_withdraw_one_coin( - token_amount, exchange_i - ) - except Exception: - if ( - self.check_limits([0] * 2) - and not (token_amount > self.total_supply) - and token_amount > 10000 - ): - self.swap.calc_withdraw_one_coin( - token_amount, exchange_i, sender=user - ) - return + Then proceeds to ramp the pool with the new values (with admin rights). + """ + note("[RAMPING]") + new_A = self.pool.A() * A_change + new_A = int( + max(MIN_A, min(MAX_A, new_A)) + ) # clamp new_A to stay in [MIN_A, MAX_A] - d_token = self.swap.balanceOf(user) - if d_token < token_amount: - with boa.reverts(): - self.swap.remove_liquidity_one_coin( - token_amount, exchange_i, 0, sender=user - ) - return + new_gamma = self.pool.gamma() * gamma_change + new_gamma = int( + max(MIN_GAMMA, min(MAX_GAMMA, new_gamma)) + ) # clamp new_gamma to stay in [MIN_GAMMA, MAX_GAMMA] - d_balance = self.coins[exchange_i].balanceOf(user) - try: - with self.upkeep_on_claim(): - self.swap.remove_liquidity_one_coin( - token_amount, exchange_i, 0, sender=user - ) - except Exception: - # Small amounts may fail with rounding errors - if ( - calc_out_amount > 100 - and token_amount / self.total_supply > 1e-10 - and calc_out_amount / self.swap.balances(exchange_i) > 1e-10 - ): - raise - return - - # This is to check that we didn't end up in a borked state after - # an exchange succeeded - _deposit = [0] * 2 - _deposit[exchange_i] = ( - 10**16 - * 10 ** self.decimals[exchange_i] - // ([10**18] + INITIAL_PRICES)[exchange_i] + # current timestamp + fuzzed days + ramp_duration = boa.env.evm.patch.timestamp + days * UNIX_DAY + + self.pool.ramp_A_gamma( + new_A, + new_gamma, + ramp_duration, + sender=self.admin, ) - assert self.swap.calc_token_amount(_deposit, True) - - d_balance = self.coins[exchange_i].balanceOf(user) - d_balance - d_token = d_token - self.swap.balanceOf(user) - - if check_out_amount: - if check_out_amount is True: - assert ( - calc_out_amount == d_balance - ), f"{calc_out_amount} vs {d_balance} for {token_amount}" - else: - assert abs(calc_out_amount - d_balance) <= max( - check_out_amount * calc_out_amount, 5 - ), f"{calc_out_amount} vs {d_balance} for {token_amount}" - - self.balances[exchange_i] -= d_balance - self.total_supply -= d_token - - # Virtual price resets if everything is withdrawn - if self.total_supply == 0: - self.virtual_price = 10**18 - - -def test_numba_go_up(users, coins, swap): - from hypothesis import settings - from hypothesis._settings import HealthCheck - - NumbaGoUp.TestCase.settings = settings( - max_examples=MAX_SAMPLES, - stateful_step_count=STEP_COUNT, - suppress_health_check=HealthCheck.all(), - deadline=None, - ) - for k, v in locals().items(): - setattr(NumbaGoUp, k, v) + note( + "ramping A and gamma to {:.2e} and {:.2e}".format(new_A, new_gamma) + ) + + def up_only_profit(self): + # we disable this invariant because ramping can lead to losses + pass + + def sanity_check(self): + # we disable this invariant because ramping can lead to losses + pass + - run_state_machine_as_test(NumbaGoUp) +TestOnlySwap = OnlySwapStateful.TestCase +TestUpOnlyLiquidity = UpOnlyLiquidityStateful.TestCase +TestOnlyBalancedLiquidity = OnlyBalancedLiquidityStateful.TestCase +TestImbalancedLiquidity = ImbalancedLiquidityStateful.TestCase +# TestRampingStateful = RampingStateful.TestCase diff --git a/tests/unitary/pool/test_a_gamma.py b/tests/unitary/pool/test_a_gamma.py index 33167a5a..982be726 100644 --- a/tests/unitary/pool/test_a_gamma.py +++ b/tests/unitary/pool/test_a_gamma.py @@ -1,5 +1,7 @@ import boa +from tests.utils.constants import UNIX_DAY + def test_A_gamma(swap, params): A = swap.A() @@ -15,8 +17,8 @@ def test_revert_ramp_A_gamma(swap, factory_admin): gamma = swap.gamma() future_A = A * 10 # 10 is too large of a jump future_gamma = gamma // 100 - t0 = boa.env.vm.state.timestamp - t1 = t0 + 7 * 86400 + t0 = boa.env.evm.patch.timestamp + t1 = t0 + 7 * UNIX_DAY with boa.env.prank(factory_admin), boa.reverts(): swap.ramp_A_gamma(future_A, future_gamma, t1) @@ -31,14 +33,14 @@ def test_ramp_A_gamma(swap, factory_admin): future_A = A * 9 future_gamma = gamma // 10 - t0 = boa.env.vm.state.timestamp - t1 = t0 + 7 * 86400 + t0 = boa.env.evm.patch.timestamp + t1 = t0 + 7 * UNIX_DAY with boa.env.prank(factory_admin): swap.ramp_A_gamma(future_A, future_gamma, t1) for i in range(1, 8): - boa.env.time_travel(86400) + boa.env.time_travel(UNIX_DAY) A_gamma = [swap.A(), swap.gamma()] assert ( abs( diff --git a/tests/unitary/pool/test_deposit_withdraw.py b/tests/unitary/pool/test_deposit_withdraw.py index b70bffee..3f503a6f 100644 --- a/tests/unitary/pool/test_deposit_withdraw.py +++ b/tests/unitary/pool/test_deposit_withdraw.py @@ -5,7 +5,7 @@ from tests.fixtures.pool import INITIAL_PRICES from tests.utils import approx -from tests.utils import simulation_int_many as sim +from tests.utils import simulator as sim from tests.utils.tokens import mint_for_testing SETTINGS = {"max_examples": 100, "deadline": None} @@ -149,18 +149,12 @@ def test_second_deposit( calculated = swap_with_deposit.calc_token_amount(amounts, True) measured = swap_with_deposit.balanceOf(user) d_balances = [swap_with_deposit.balances(i) for i in range(len(coins))] - claimed_fees = [0] * len(coins) with boa.env.prank(user): swap_with_deposit.add_liquidity(amounts, int(calculated * 0.999)) - logs = swap_with_deposit.get_logs() - for log in logs: - if log.event_type.name == "ClaimAdminFee": - claimed_fees = log.args[0] - d_balances = [ - swap_with_deposit.balances(i) - d_balances[i] + claimed_fees[i] + swap_with_deposit.balances(i) - d_balances[i] for i in range(len(coins)) ] measured = swap_with_deposit.balanceOf(user) - measured @@ -205,18 +199,12 @@ def test_second_deposit_one( ) measured = swap_with_deposit.balanceOf(user) d_balances = [swap_with_deposit.balances(i) for i in range(len(coins))] - claimed_fees = [0] * len(coins) with boa.env.prank(user): swap_with_deposit.add_liquidity(amounts, int(calculated * 0.999)) - logs = swap_with_deposit.get_logs() - for log in logs: - if log.event_type.name == "ClaimAdminFee": - claimed_fees = log.args[0] - d_balances = [ - swap_with_deposit.balances(i) - d_balances[i] + claimed_fees[i] + swap_with_deposit.balances(i) - d_balances[i] for i in range(len(coins)) ] measured = swap_with_deposit.balanceOf(user) - measured diff --git a/tests/unitary/pool/test_oracles.py b/tests/unitary/pool/test_oracles.py index 80666a0c..53485552 100644 --- a/tests/unitary/pool/test_oracles.py +++ b/tests/unitary/pool/test_oracles.py @@ -7,6 +7,7 @@ from tests.fixtures.pool import INITIAL_PRICES from tests.utils import approx +from tests.utils.constants import UNIX_DAY from tests.utils.tokens import mint_for_testing SETTINGS = {"max_examples": 1000, "deadline": None} @@ -50,7 +51,7 @@ def test_last_price_remove_liq(swap_with_deposit, user, token_frac, i): "uint256", min_value=10**10, max_value=2 * 10**6 * 10**18 ), # Can be more than we have i=strategy("uint8", min_value=0, max_value=1), - t=strategy("uint256", min_value=10, max_value=10 * 86400), + t=strategy("uint256", min_value=10, max_value=10 * UNIX_DAY), ) @settings(**SETTINGS) def test_ma(swap_with_deposit, coins, user, amount, i, t): @@ -84,65 +85,13 @@ def test_ma(swap_with_deposit, coins, user, amount, i, t): assert abs(log2(theory / prices3)) < 0.001 -@given( - amount=strategy( - "uint256", min_value=10**10, max_value=2 * 10**6 * 10**18 - ), # Can be more than we have - i=strategy("uint8", min_value=0, max_value=1), - t=strategy("uint256", min_value=10, max_value=10 * 86400), -) -@settings(**SETTINGS) -def test_xcp_ma(swap_with_deposit, coins, user, amount, i, t): - - price_scale = swap_with_deposit.price_scale() - D0 = swap_with_deposit.D() - xp = [0, 0] - xp[0] = D0 // 2 # N_COINS = 2 - xp[1] = D0 * 10**18 // (2 * price_scale) - - xcp0 = boa.eval(f"isqrt({xp[0]*xp[1]})") - - # after first deposit anf before any swaps: - # xcp oracle is equal to totalSupply - assert xcp0 == swap_with_deposit.totalSupply() - - amount = amount * 10**18 // INITIAL_PRICES[i] - mint_for_testing(coins[i], user, amount) - - ma_time = swap_with_deposit.xcp_ma_time() - - # swap to populate - with boa.env.prank(user): - swap_with_deposit.exchange(i, 1 - i, amount, 0) - - xcp1 = swap_with_deposit.last_xcp() - tvl = ( - swap_with_deposit.virtual_price() - * swap_with_deposit.totalSupply() - // 10**18 - ) - assert approx(xcp1, tvl, 1e-10) - - boa.env.time_travel(t) - - with boa.env.prank(user): - swap_with_deposit.remove_liquidity_one_coin(10**15, 0, 0) - - xcp2 = swap_with_deposit.xcp_oracle() - - alpha = exp(-1 * t / ma_time) - theory = xcp0 * alpha + xcp1 * (1 - alpha) - - assert approx(theory, xcp2, 1e-10) - - # Sanity check for price scale @given( amount=strategy( "uint256", min_value=10**10, max_value=2 * 10**6 * 10**18 ), # Can be more than we have i=strategy("uint8", min_value=0, max_value=1), - t=strategy("uint256", max_value=10 * 86400), + t=strategy("uint256", max_value=10 * UNIX_DAY), ) @settings(**SETTINGS) def test_price_scale_range(swap_with_deposit, coins, user, amount, i, t): @@ -172,7 +121,7 @@ def test_price_scale_range(swap_with_deposit, coins, user, amount, i, t): def test_price_scale_change(swap_with_deposit, i, coins, users): j = 1 - i amount = 10**6 * 10**18 - t = 86400 + t = UNIX_DAY user = users[1] prices1 = INITIAL_PRICES amount = amount * 10**18 // prices1[i] diff --git a/tests/unitary/pool/token/conftest.py b/tests/unitary/pool/token/conftest.py index f4149cb2..bf8aea83 100644 --- a/tests/unitary/pool/token/conftest.py +++ b/tests/unitary/pool/token/conftest.py @@ -39,7 +39,7 @@ def _sign_permit(swap, owner, spender, value, deadline): struct["domain"] = dict( name=swap.name(), version=swap.version(), - chainId=boa.env.vm.chain_context.chain_id, + chainId=boa.env.evm.patch.chain_id, verifyingContract=swap.address, salt=HexBytes(swap.salt()), ) diff --git a/tests/unitary/pool/token/test_permit.py b/tests/unitary/pool/token/test_permit.py index 83493b21..65c001ff 100644 --- a/tests/unitary/pool/token/test_permit.py +++ b/tests/unitary/pool/token/test_permit.py @@ -12,7 +12,7 @@ def test_permit_success(eth_acc, bob, swap, sign_permit): value = 2**256 - 1 - deadline = boa.env.vm.state.timestamp + 600 + deadline = boa.env.evm.patch.timestamp + 600 sig = sign_permit( swap=swap, @@ -51,7 +51,7 @@ def test_permit_reverts_owner_is_invalid(bob, swap): ZERO_ADDRESS, bob, 2**256 - 1, - boa.env.vm.state.timestamp + 600, + boa.env.evm.patch.timestamp + 600, 27, b"\x00" * 32, b"\x00" * 32, @@ -64,7 +64,7 @@ def test_permit_reverts_deadline_is_invalid(bob, swap): bob, bob, 2**256 - 1, - boa.env.vm.state.timestamp - 600, + boa.env.evm.patch.timestamp - 600, 27, b"\x00" * 32, b"\x00" * 32, @@ -77,7 +77,7 @@ def test_permit_reverts_signature_is_invalid(bob, swap): bob, bob, 2**256 - 1, - boa.env.vm.state.timestamp + 600, + boa.env.evm.patch.timestamp + 600, 27, b"\x00" * 32, b"\x00" * 32, @@ -88,5 +88,5 @@ def test_domain_separator_updates_when_chain_id_updates(swap): domain_separator = swap.DOMAIN_SEPARATOR() with boa.env.anchor(): - boa.env.vm.patch.chain_id = 42 + boa.env.evm.patch.chain_id = 42 assert domain_separator != swap.DOMAIN_SEPARATOR() diff --git a/tests/utils/constants.py b/tests/utils/constants.py new file mode 100644 index 00000000..250299fd --- /dev/null +++ b/tests/utils/constants.py @@ -0,0 +1,23 @@ +""" +Constants often used for testing. + +These cannot be used as fixtures because they are often +used as bounds for fuzzing (outside of the test functions). +""" +# TODO use values from actual contracts once this: +# https://github.com/vyperlang/titanoboa/issues/196 +# is implmented. +N_COINS = 2 + +MIN_GAMMA = 10**10 +MAX_GAMMA_SMALL = 2 * 10**16 +MAX_GAMMA = 199 * 10**15 # 1.99 * 10**17 + +A_MULTIPLIER = 10000 +MIN_A = N_COINS**N_COINS * A_MULTIPLIER / 10 +MAX_A = N_COINS**N_COINS * A_MULTIPLIER * 1000 + +UNIX_DAY = 86400 + +MIN_FEE = 5 * 10**5 +MAX_FEE = 10 * 10**9 diff --git a/tests/utils/fuzz_curve.py b/tests/utils/fuzz_curve.py new file mode 100644 index 00000000..a30f0e13 --- /dev/null +++ b/tests/utils/fuzz_curve.py @@ -0,0 +1,170 @@ +# flake8: noqa + +""" +This file was originally used to find the initial bounds for A and gamma in the Curve contract. +It is now used to test contract with Hypothesis stateful testing. Some unused parts are broken +and kept for reference. + +Original file: https://github.com/curvefi/curve-crypto-contract/blob/d7d04cd9ae038970e40be850df99de8c1ff7241b/tests/simulation_int_many.py +""" +from itertools import permutations + +import hypothesis.strategies as st +from hypothesis import given, settings + +from tests.utils.simulator import ( + Curve, + geometric_mean, + reduction_coefficient, + solve_D, + solve_x, +) + +MAX_EXAMPLES_MEAN = 20000 +MAX_EXAMPLES_RED = 20000 +MAX_EXAMPLES_D = 10000 +MAX_EXAMPLES_Y = 5000 +MAX_EXAMPLES_YD = 100000 +MAX_EXAMPLES_NOLOSS = 100000 +MIN_FEE = 5e-5 + +MIN_XD = 10**16 +MAX_XD = 10**20 + +N_COINS = 2 +A_MUL = 10000 +MIN_A = int(N_COINS**N_COINS * A_MUL / 10) +MAX_A = int(N_COINS**N_COINS * A_MUL * 1000) + +MIN_GAMMA = 10**10 +MAX_GAMMA = 2 * 10**15 + + +# Test with 2 coins +@given( + x=st.integers(10**9, 10**15 * 10**18), + y=st.integers(10**9, 10**15 * 10**18), +) +@settings(max_examples=MAX_EXAMPLES_MEAN) +def test_geometric_mean(x, y): + val = geometric_mean([x, y]) + assert val > 0 + diff = abs((x * y) ** (1 / 2) - val) + assert diff / val <= max(1e-10, 1 / min([x, y])) + + +@given( + x=st.integers(10**9, 10**15 * 10**18), + y=st.integers(10**9, 10**15 * 10**18), + gamma=st.integers(10**10, 10**18), +) +@settings(max_examples=MAX_EXAMPLES_RED) +def test_reduction_coefficient(x, y, gamma): + coeff = reduction_coefficient([x, y], gamma) + assert coeff <= 10**18 + + K = 2**2 * x * y / (x + y) ** 2 + if gamma > 0: + K = (gamma / 1e18) / ((gamma / 1e18) + 1 - K) + assert abs(coeff / 1e18 - K) <= 1e-7 + + +@given( + A=st.integers(MIN_A, MAX_A), + x=st.integers(10**18, 10**15 * 10**18), # 1 USD to 1e15 USD + yx=st.integers( + 10**14, 10**18 + ), # <- ratio 1e18 * y/x, typically 1e18 * 1 + perm=st.integers(0, 1), # <- permutation mapping to values + gamma=st.integers(MIN_GAMMA, MAX_GAMMA), +) +@settings(max_examples=MAX_EXAMPLES_D) +def test_D_convergence(A, x, yx, perm, gamma): + # Price not needed for convergence testing + pmap = list(permutations(range(2))) + + y = x * yx // 10**18 + curve = Curve(A, gamma, 10**18, p) + curve.x = [0] * 2 + i, j = pmap[perm] + curve.x[i] = x + curve.x[j] = y + assert curve.D() > 0 + + +@given( + A=st.integers(MIN_A, MAX_A), + x=st.integers(10**17, 10**15 * 10**18), # $0.1 .. $1e15 + yx=st.integers(10**15, 10**21), + gamma=st.integers(MIN_GAMMA, MAX_GAMMA), + i=st.integers(0, 1), + inx=st.integers(10**15, 10**21), +) +@settings(max_examples=MAX_EXAMPLES_Y) +def test_y_convergence(A, x, yx, gamma, i, inx): + j = 1 - i + in_amount = x * inx // 10**18 + y = x * yx // 10**18 + curve = Curve(A, gamma, 10**18, p) + curve.x = [x, y] + out_amount = curve.y(in_amount, i, j) + assert out_amount > 0 + + +@given( + A=st.integers(MIN_A, MAX_A), + x=st.integers(10**17, 10**15 * 10**18), # 0.1 USD to 1e15 USD + yx=st.integers(5 * 10**14, 20 * 10**20), + gamma=st.integers(MIN_GAMMA, MAX_GAMMA), + i=st.integers(0, 1), + inx=st.integers(3 * 10**15, 3 * 10**20), +) +@settings(max_examples=MAX_EXAMPLES_NOLOSS) +def test_y_noloss(A, x, yx, gamma, i, inx): + j = 1 - i + y = x * yx // 10**18 + curve = Curve(A, gamma, 10**18, p) + curve.x = [x, y] + in_amount = x * inx // 10**18 + try: + out_amount = curve.y(in_amount, i, j) + D1 = curve.D() + except ValueError: + return # Convergence checked separately - we deliberately try unsafe numbers + is_safe = all( + f >= MIN_XD and f <= MAX_XD + for f in [xx * 10**18 // D1 for xx in curve.x] + ) + curve.x[i] = in_amount + curve.x[j] = out_amount + try: + D2 = curve.D() + except ValueError: + return # Convergence checked separately - we deliberately try unsafe numbers + is_safe &= all( + f >= MIN_XD and f <= MAX_XD + for f in [xx * 10**18 // D2 for xx in curve.x] + ) + if is_safe: + assert ( + 2 * (D1 - D2) / (D1 + D2) < MIN_FEE + ) # Only loss is prevented - gain is ok + + +@given( + A=st.integers(MIN_A, MAX_A), + D=st.integers(10**18, 10**15 * 10**18), # 1 USD to 1e15 USD + xD=st.integers(MIN_XD, MAX_XD), + yD=st.integers(MIN_XD, MAX_XD), + gamma=st.integers(MIN_GAMMA, MAX_GAMMA), + j=st.integers(0, 1), +) +@settings(max_examples=MAX_EXAMPLES_YD) +def test_y_from_D(A, D, xD, yD, gamma, j): + xp = [D * xD // 10**18, D * yD // 10**18] + y = solve_x(A, gamma, xp, D, j) + xp[j] = y + D2 = solve_D(A, gamma, xp) + assert ( + 2 * (D - D2) / (D2 + D) < MIN_FEE + ) # Only loss is prevented - gain is ok diff --git a/tests/utils/pool_presets.csv b/tests/utils/pool_presets.csv new file mode 100644 index 00000000..af58caf5 --- /dev/null +++ b/tests/utils/pool_presets.csv @@ -0,0 +1,5 @@ +A,gamma,mid_fee,out_fee,fee_gamma,allowed_extra_profit,adjustment_step,ma_exp_time,name,description +400000,145000000000000,26000000,45000000,230000000000000,2000000000000,146000000000000,866,crypto,frontend preset for volatile assets +20000000,1000000000000000,5000000,45000000,5000000000000000,10000000000,5500000000000,866,forex,frontend preset for forex pools +40000000,2000000000000000,3000000,45000000,300000000000000000,10000000000,5500000000000,866,LSD,frontend preset for Liquid Staking Derivatives +4000000,199000000000000000,5000000,45000000,5000000000000000,10000000000,5500000000000,866,large_gamma,"preset similar to the one that will be used by the eurs pools" diff --git a/tests/utils/pool_presets.py b/tests/utils/pool_presets.py new file mode 100644 index 00000000..01fa92f7 --- /dev/null +++ b/tests/utils/pool_presets.py @@ -0,0 +1,22 @@ +# read from csv file with the same name +import pandas as pd + +df = pd.read_csv("tests/utils/pool_presets.csv") + +all_presets = df.iloc[1:].to_dict(orient="records") + +numeric_columns = [ + "A", + "gamma", + "mid_fee", + "out_fee", + "fee_gamma", + "allowed_extra_profit", + "adjustment_step", + "ma_exp_time", +] + +all_presets = [ + {k: int(v) if k in numeric_columns else v for k, v in d.items()} + for d in all_presets +] diff --git a/tests/utils/simulation_int_many.py b/tests/utils/simulator.py similarity index 82% rename from tests/utils/simulation_int_many.py rename to tests/utils/simulator.py index d7d4d9e8..98cd8cd5 100644 --- a/tests/utils/simulation_int_many.py +++ b/tests/utils/simulator.py @@ -1,12 +1,56 @@ #!/usr/bin/env python3 # flake8: noqa +from decimal import Decimal from math import exp -from tests.unitary.math.misc import get_y_n2_dec - A_MULTIPLIER = 10000 +def get_y_n2_dec(ANN, gamma, x, D, i): + """ + Analytical solution to obtain the value of y + Equivalent to get_y in the math smart contract, + except that it doesn't fallback to newton_y. + This function is a draft and should not be used + as expected value for y in testing. + """ + + if i == 0: + m = 1 + elif i == 1: + m = 0 + + A = Decimal(ANN) / 10**4 / 4 + gamma = Decimal(gamma) / 10**18 + x = [Decimal(_x) / 10**18 for _x in x] + D = Decimal(D) / 10**18 + + a = Decimal(16) * x[m] ** 3 / D**3 + b = 4 * A * gamma**2 * x[m] - (4 * (3 + 2 * gamma) * x[m] ** 2) / D + c = ( + D * (3 + 4 * gamma + (1 - 4 * A) * gamma**2) * x[m] + + 4 * A * gamma**2 * x[m] ** 2 + ) + d = -(Decimal(1) / 4) * D**3 * (1 + gamma) ** 2 + + delta0 = b**2 - 3 * a * c + delta1 = 2 * b**3 - 9 * a * b * c + 27 * a**2 * d + sqrt_arg = delta1**2 - 4 * delta0**3 + + if sqrt_arg < 0: + return [0, {}] + + sqrt = sqrt_arg ** (Decimal(1) / 2) + cbrt_arg = (delta1 + sqrt) / 2 + if cbrt_arg > 0: + C1 = cbrt_arg ** (Decimal(1) / 3) + else: + C1 = -((-cbrt_arg) ** (Decimal(1) / 3)) + root = -(b + C1 + delta0 / C1) / (3 * a) + + return [root, (a, b, c, d)] + + def geometric_mean(x): N = len(x) x = sorted(x, reverse=True) # Presort - good for convergence @@ -43,7 +87,6 @@ def get_fee(x, fee_gamma, mid_fee, out_fee): def newton_D(A, gamma, x, D0): D = D0 - i = 0 S = sum(x) x = sorted(x, reverse=True) @@ -138,8 +181,14 @@ def newton_y(A, gamma, x, D, i): def solve_x(A, gamma, x, D, i): - return int(get_y_n2_dec(A, gamma, x, D, i)[0] * 10**18) - # return newton_y(A, gamma, x, D, i) + """ + Solving for x or y in the AMM equation. + + Even though we have an analytical solution we consider + the newton method to be a ground truth. The analytical + solution does not always work. + """ + return newton_y(A, gamma, x, D, i) def solve_D(A, gamma, x): @@ -147,13 +196,15 @@ def solve_D(A, gamma, x): return newton_D(A, gamma, x, D0) +N_COINS = 2 + + class Curve: - def __init__(self, A, gamma, D, n, p): + def __init__(self, A, gamma, D, p): self.A = A self.gamma = gamma - self.n = n self.p = p - self.x = [D // n * 10**18 // self.p[i] for i in range(n)] + self.x = [D // N_COINS * 10**18 // self.p[i] for i in range(N_COINS)] def xp(self): return [x * p // 10**18 for x, p in zip(self.x, self.p)] @@ -171,7 +222,6 @@ def y(self, x, i, j): return yp * 10**18 // self.p[j] def get_p(self): - A = self.A gamma = self.gamma xp = self.xp() @@ -198,38 +248,26 @@ def __init__( A, gamma, D, - n, p0, mid_fee=1e-3, out_fee=3e-3, - allowed_extra_profit=2 * 10**13, fee_gamma=None, adjustment_step=0.003, ma_time=866, - log=True, ): - # allowed_extra_profit is actually not used - self.p0 = p0[:] - self.price_oracle = self.p0[:] - self.last_price = self.p0[:] - self.curve = Curve(A, gamma, D, n, p=p0[:]) - self.dx = int(D * 1e-8) + self.price_oracle = p0[:] + self.last_price = p0[:] + self.curve = Curve(A, gamma, D, p=p0[:]) self.mid_fee = int(mid_fee * 1e10) self.out_fee = int(out_fee * 1e10) - self.D0 = self.curve.D() - self.xcp_0 = self.get_xcp() self.xcp_profit = 10**18 self.xcp_profit_real = 10**18 - self.xcp = self.xcp_0 - self.allowed_extra_profit = allowed_extra_profit + self.xcp = self.get_xcp() self.adjustment_step = int(10**18 * adjustment_step) - self.log = log - self.fee_gamma = fee_gamma or gamma - self.total_vol = 0.0 + self.fee_gamma = ( + fee_gamma or gamma + ) # why can gamma be used as fee_gamma? self.ma_time = ma_time - self.ext_fee = 0 # 0.03e-2 - self.slippage = 0 - self.slippage_count = 0 def fee(self): f = reduction_coefficient(self.curve.xp(), self.fee_gamma) diff --git a/tests/utils/strategies.py b/tests/utils/strategies.py new file mode 100644 index 00000000..9a556983 --- /dev/null +++ b/tests/utils/strategies.py @@ -0,0 +1,184 @@ +""" +Collection of useful strategies for stateful testing, +somewhat redundant due to the fact that we cannot use +fixtures in stateful testing (without compromises). +""" + +import boa +from boa.test import strategy +from hypothesis import assume, note +from hypothesis.strategies import composite, integers, just, sampled_from + +# compiling contracts +from contracts.main import CurveCryptoMathOptimized2 as math_deployer +from contracts.main import CurveCryptoViews2Optimized as view_deployer +from contracts.main import CurveTwocryptoFactory as factory_deployer +from contracts.main import CurveTwocryptoOptimized as amm_deployer +from contracts.main import LiquidityGauge as gauge_deployer +from tests.utils.constants import ( + MAX_A, + MAX_FEE, + MAX_GAMMA, + MIN_A, + MIN_FEE, + MIN_GAMMA, +) +from tests.utils.pool_presets import all_presets + +# ---------------- hypothesis test profiles ---------------- + +# just a more hyptohesis-like way to get an address +# from boa's search strategy +address = strategy("address") + +# ---------------- addresses ---------------- +deployer = address +fee_receiver = address +owner = address + + +# ---------------- factory ---------------- +@composite +def factory( + draw, +): + _deployer = draw(deployer) + _fee_receiver = draw(fee_receiver) + _owner = draw(owner) + + assume(_fee_receiver != _owner != _deployer) + + with boa.env.prank(_deployer): + amm_implementation = amm_deployer.deploy_as_blueprint() + gauge_implementation = gauge_deployer.deploy_as_blueprint() + + view_contract = view_deployer.deploy() + math_contract = math_deployer.deploy() + + _factory = factory_deployer.deploy() + _factory.initialise_ownership(_fee_receiver, _owner) + + with boa.env.prank(_owner): + _factory.set_pool_implementation(amm_implementation, 0) + _factory.set_gauge_implementation(gauge_implementation) + _factory.set_views_implementation(view_contract) + _factory.set_math_implementation(math_contract) + + return _factory + + +# ---------------- pool deployment params ---------------- +A = integers(min_value=MIN_A, max_value=MAX_A) +gamma = integers(min_value=MIN_GAMMA, max_value=MAX_GAMMA) + +fee_gamma = integers(min_value=1, max_value=1e18) + + +@composite +def fees(draw): + """ + These two needs to be computed together as the value of `out_fee` + depends on `mid_fee`. + """ + mid_fee = draw(integers(min_value=MIN_FEE, max_value=MAX_FEE - 2)) + out_fee = draw(integers(min_value=mid_fee, max_value=MAX_FEE - 2)) + + return mid_fee, out_fee + + +allowed_extra_profit = integers(min_value=0, max_value=1e18) +adjustment_step = integers(min_value=1, max_value=1e18) +ma_exp_time = integers(min_value=87, max_value=872541) + +# 1e26 is less than the maximum amount allowed by the factory +# however testing with a smaller number is more realistic +# and less cumbersome +price = integers(min_value=int(1e10), max_value=int(1e26)) + +# -------------------- tokens -------------------- + +# we put bigger values first to shrink +# towards 18 in case of failure (instead of 2) +token = sampled_from([18, 6, 2]).map( + # token = just(18).map( + lambda x: boa.load("contracts/mocks/ERC20Mock.vy", "USD", "USD", x) +) +weth = just(boa.load("contracts/mocks/WETH.vy")) + + +# ---------------- pool ---------------- +@composite +def pool( + draw, + A=A, + gamma=gamma, + fees=fees(), + fee_gamma=fee_gamma, + allowed_extra_profit=allowed_extra_profit, + adjustment_step=adjustment_step, + ma_exp_time=ma_exp_time, + price=price, +): + """Creates a factory based pool with the following fuzzed parameters: + Custom strategies can be passed as argument to override the default + """ + + # Creates a factory based pool with the following fuzzed parameters: + _factory = draw(factory()) + mid_fee, out_fee = draw(fees) + + # TODO should test weird tokens as well (non-standard/non-compliant) + tokens = [draw(token), draw(token)] + + with boa.env.prank(draw(deployer)): + _pool = _factory.deploy_pool( + "stateful simulation", + "SIMULATION", + tokens, + 0, + draw(A), + draw(gamma), + mid_fee, + out_fee, + draw(fee_gamma), + draw(allowed_extra_profit), + draw(adjustment_step), + draw(ma_exp_time), + draw(price), + ) + + _pool = amm_deployer.at(_pool) + + note( + "deployed pool with " + + "A: {:.2e}".format(_pool.A()) + + ", gamma: {:.2e}".format(_pool.gamma()) + + ", price: {:.2e}".format(_pool.price_oracle()) + + ", fee_gamma: {:.2e}".format(_pool.fee_gamma()) + + ", allowed_extra_profit: {:.2e}".format(_pool.allowed_extra_profit()) + + ", adjustment_step: {:.2e}".format(_pool.adjustment_step()) + + "\n coin 0 has {} decimals".format(tokens[0].decimals()) + + "\n coin 1 has {} decimals".format(tokens[1].decimals()) + ) + return _pool + + +@composite +def pool_from_preset(draw, preset=sampled_from(all_presets)): + params = draw(preset) + + note( + "[POOL PRESET: {}] \n {}".format(params["name"], params["description"]) + ) + + return draw( + pool( + A=just(params["A"]), + gamma=just(params["gamma"]), + fees=just((params["mid_fee"], params["out_fee"])), + fee_gamma=just(params["fee_gamma"]), + allowed_extra_profit=just(params["allowed_extra_profit"]), + adjustment_step=just(params["adjustment_step"]), + ma_exp_time=just(params["ma_exp_time"]), + ) + )