From fa9731af120f92ec55a059cc6b09f0e26174b81d Mon Sep 17 00:00:00 2001 From: bout3fiddy <11488427+bout3fiddy@users.noreply.github.com> Date: Thu, 12 Oct 2023 14:03:08 +0200 Subject: [PATCH] use correct asset precisions for erc4626 --- contracts/main/CurveStableSwapMetaNG.vy | 22 +- contracts/main/CurveStableSwapNG.vy | 45 ++- contracts/mocks/ERC4626.vy | 270 ++++++++++++++++++ tests/pools/test_erc4626_swaps.py | 169 +++++++++++ ...est_specific_liquidity_operations copy.py} | 0 5 files changed, 489 insertions(+), 17 deletions(-) create mode 100644 contracts/mocks/ERC4626.vy create mode 100644 tests/pools/test_erc4626_swaps.py rename tests/pools/{test_specific_liquidity_operations.py => test_specific_liquidity_operations copy.py} (100%) diff --git a/contracts/main/CurveStableSwapMetaNG.vy b/contracts/main/CurveStableSwapMetaNG.vy index 66115917..d13bceee 100644 --- a/contracts/main/CurveStableSwapMetaNG.vy +++ b/contracts/main/CurveStableSwapMetaNG.vy @@ -219,6 +219,10 @@ rate_multipliers: immutable(DynArray[uint256, MAX_COINS]) # [bytes4 method_id][bytes8 ][bytes20 oracle] oracles: DynArray[uint256, MAX_COINS] +# For ERC4626 tokens, we need: +call_amount: immutable(uint256) +scale_factor: immutable(uint256) + last_prices_packed: DynArray[uint256, MAX_COINS] # packing: last_price, ma_price last_D_packed: uint256 # packing: last_D, ma_D ma_exp_time: public(uint256) @@ -320,7 +324,14 @@ def __init__( # _exchange_underlying: ERC20(_base_coins[i]).approve(BASE_POOL, max_value(uint256)) - self.last_prices_packed.append(self.pack_2(10**18, 10**18)) + # For ERC4626 tokens: + if asset_types[0] == 3: + # In Vyper 0.3.10, if immutables are not set, because of an if-statement, + # it is by default set to 0; this is fine in the case of these two + # immutables, since they are only used if asset_types[0] == 3. + call_amount = 10**convert(ERC20Detailed(_coins[0]).decimals(), uint256) + _underlying_asset: address = ERC4626(_coins[0]).asset() + scale_factor = 10**(18 - convert(ERC20Detailed(_underlying_asset).decimals(), uint256)) # ----------------- Parameters independent of pool type ------------------ @@ -337,6 +348,9 @@ def __init__( self.D_ma_time = 62324 # <--------- 12 hours default on contract start. self.ma_last_time = self.pack_2(block.timestamp, block.timestamp) + # ------------------- initialize storage for DynArrays ------------------ + + self.last_prices_packed.append(self.pack_2(10**18, 10**18)) for i in range(N_COINS_128): self.oracles.append(convert(_method_ids[i], uint256) * 2**224 | convert(_oracles[i], uint256)) @@ -500,13 +514,11 @@ def _stored_rates() -> DynArray[uint256, MAX_COINS]: elif asset_types[0] == 3: # ERC4626 - coin_decimals: uint256 = convert(ERC20Detailed(coins[0]).decimals(), uint256) - # rates[0] * fetched_rate / PRECISION rates[0] = unsafe_div( - rates[0] * ERC4626(coins[0]).convertToAssets(10**coin_decimals) * 10**(18 - coin_decimals), + rates[0] * ERC4626(coins[0]).convertToAssets(call_amount) * scale_factor, PRECISION - ) + ) # 1e18 precision return rates diff --git a/contracts/main/CurveStableSwapNG.vy b/contracts/main/CurveStableSwapNG.vy index 5304a11c..2baa8e6b 100644 --- a/contracts/main/CurveStableSwapNG.vy +++ b/contracts/main/CurveStableSwapNG.vy @@ -175,6 +175,10 @@ rate_multipliers: immutable(DynArray[uint256, MAX_COINS]) # [bytes4 method_id][bytes8 ][bytes20 oracle] oracles: DynArray[uint256, MAX_COINS] +# For ERC4626 tokens, we need: +call_amount: immutable(DynArray[uint256, MAX_COINS]) +scale_factor: immutable(DynArray[uint256, MAX_COINS]) + last_prices_packed: DynArray[uint256, MAX_COINS] # packing: last_price, ma_price last_D_packed: uint256 # packing: last_D, ma_D ma_exp_time: public(uint256) @@ -259,11 +263,6 @@ def __init__( N_COINS = __n_coins N_COINS_128 = convert(__n_coins, int128) - for i in range(MAX_COINS): - if i == __n_coins - 1: - break - self.last_prices_packed.append(self.pack_2(10**18, 10**18)) - rate_multipliers = _rate_multipliers POOL_IS_REBASING_IMPLEMENTATION = 2 in _asset_types @@ -280,18 +279,37 @@ def __init__( self.D_ma_time = 62324 # <--------- 12 hours default on contract start. self.ma_last_time = self.pack_2(block.timestamp, block.timestamp) + # ------------------- initialize storage for DynArrays ------------------ + + _call_amount: DynArray[uint256, MAX_COINS] = empty(DynArray[uint256, MAX_COINS]) + _scale_factor: DynArray[uint256, MAX_COINS] = empty(DynArray[uint256, MAX_COINS]) for i in range(MAX_COINS_128): if i == N_COINS_128: break - self.oracles.append(convert(_method_ids[i], uint256) * 2**224 | convert(_oracles[i], uint256)) + if i < N_COINS_128 - 1: + self.last_prices_packed.append(self.pack_2(10**18, 10**18)) - # --------------------------- initialize storage --------------------------- + self.oracles.append(convert(_method_ids[i], uint256) * 2**224 | convert(_oracles[i], uint256)) self.stored_balances.append(0) self.admin_balances.append(0) - # --------------------------- ERC20 stuff ---------------------------- + if _asset_types[i] == 3: + + _call_amount.append(10**convert(ERC20Detailed(_coins[i]).decimals(), uint256)) + _underlying_asset: address = ERC4626(_coins[i]).asset() + _scale_factor.append(10**(18 - convert(ERC20Detailed(_underlying_asset).decimals(), uint256))) + + else: + + _call_amount.append(0) + _scale_factor.append(0) + + call_amount = _call_amount + scale_factor = _scale_factor + + # ----------------------------- ERC20 stuff ------------------------------ name = _name symbol = _symbol @@ -421,10 +439,13 @@ def _stored_rates() -> DynArray[uint256, MAX_COINS]: elif asset_types[i] == 3: # ERC4626 - coin_decimals: uint256 = convert(ERC20Detailed(coins[i]).decimals(), uint256) - fetched_rate: uint256 = ERC4626(coins[i]).convertToAssets(10**coin_decimals) * 10**(18 - coin_decimals) - - rates[i] = unsafe_div(rates[i] * fetched_rate, PRECISION) + # fetched_rate: uint256 = ERC4626(coins[i]).convertToAssets(call_amount[i]) * scale_factor[i] + # here: call_amount has ERC4626 precision, but the returned value is scaled up to 18 + # using scale_factor which is (18 - n) if underlying asset has n decimals. + rates[i] = unsafe_div( + rates[i] * ERC4626(coins[i]).convertToAssets(call_amount[i]) * scale_factor[i], + PRECISION + ) # 1e18 precision return rates diff --git a/contracts/mocks/ERC4626.vy b/contracts/mocks/ERC4626.vy new file mode 100644 index 00000000..5f7fd379 --- /dev/null +++ b/contracts/mocks/ERC4626.vy @@ -0,0 +1,270 @@ +# @version 0.3.10 +# From: https://github.com/fubuloubu/ERC4626/blob/main/contracts/VyperVault.vy +from vyper.interfaces import ERC20 + +import ERC4626 as ERC4626 + +implements: ERC20 +implements: ERC4626 + +##### ERC20 ##### + +totalSupply: public(uint256) +balanceOf: public(HashMap[address, uint256]) +allowance: public(HashMap[address, HashMap[address, uint256]]) + +NAME: immutable(String[10]) +SYMBOL: immutable(String[5]) +DECIMALS: immutable(uint8) + +event Transfer: + sender: indexed(address) + receiver: indexed(address) + amount: uint256 + +event Approval: + owner: indexed(address) + spender: indexed(address) + allowance: uint256 + +##### ERC4626 ##### + +asset: public(immutable(ERC20)) + +event Deposit: + depositor: indexed(address) + receiver: indexed(address) + assets: uint256 + shares: uint256 + +event Withdraw: + withdrawer: indexed(address) + receiver: indexed(address) + owner: indexed(address) + assets: uint256 + shares: uint256 + + +@external +def __init__( + _name: String[10], + _symbol: String[5], + _decimals: uint8, + _asset: ERC20 +): + NAME = _name + SYMBOL = _symbol + DECIMALS = _decimals + asset = _asset + + +@view +@external +def name() -> String[10]: + return NAME + + +@view +@external +def symbol() -> String[5]: + return SYMBOL + + +@view +@external +def decimals() -> uint8: + return DECIMALS + + +@external +def transfer(receiver: address, amount: uint256) -> bool: + self.balanceOf[msg.sender] -= amount + self.balanceOf[receiver] += amount + log Transfer(msg.sender, receiver, amount) + return True + + +@external +def approve(spender: address, amount: uint256) -> bool: + self.allowance[msg.sender][spender] = amount + log Approval(msg.sender, spender, amount) + return True + + +@external +def transferFrom(sender: address, receiver: address, amount: uint256) -> bool: + self.allowance[sender][msg.sender] -= amount + self.balanceOf[sender] -= amount + self.balanceOf[receiver] += amount + log Transfer(sender, receiver, amount) + return True + + +@view +@external +def totalAssets() -> uint256: + return asset.balanceOf(self) + + +@view +@internal +def _convertToAssets(shareAmount: uint256) -> uint256: + totalSupply: uint256 = self.totalSupply + if totalSupply == 0: + return 0 + + # NOTE: `shareAmount = 0` is extremely rare case, not optimizing for it + return shareAmount * asset.balanceOf(self) / totalSupply + + +@view +@external +def convertToAssets(shareAmount: uint256) -> uint256: + return self._convertToAssets(shareAmount) + + +@view +@internal +def _convertToShares(assetAmount: uint256) -> uint256: + totalSupply: uint256 = self.totalSupply + totalAssets: uint256 = asset.balanceOf(self) + if totalAssets == 0 or totalSupply == 0: + return assetAmount # 1:1 price + + # NOTE: `assetAmount = 0` is extremely rare case, not optimizing for it + return assetAmount * totalSupply / totalAssets + + +@view +@external +def convertToShares(assetAmount: uint256) -> uint256: + return self._convertToShares(assetAmount) + + +@view +@external +def maxDeposit(owner: address) -> uint256: + return max_value(uint256) + + +@view +@external +def previewDeposit(assets: uint256) -> uint256: + return self._convertToShares(assets) + + +@external +def deposit(assets: uint256, receiver: address=msg.sender) -> uint256: + shares: uint256 = self._convertToShares(assets) + asset.transferFrom(msg.sender, self, assets) + + self.totalSupply += shares + self.balanceOf[receiver] += shares + log Transfer(empty(address), receiver, shares) + log Deposit(msg.sender, receiver, assets, shares) + return shares + + +@view +@external +def maxMint(owner: address) -> uint256: + return max_value(uint256) + + +@view +@external +def previewMint(shares: uint256) -> uint256: + assets: uint256 = self._convertToAssets(shares) + + # NOTE: Vyper does lazy eval on if, so this avoids SLOADs most of the time + if assets == 0 and asset.balanceOf(self) == 0: + return shares # NOTE: Assume 1:1 price if nothing deposited yet + + return assets + + +@external +def mint(shares: uint256, receiver: address=msg.sender) -> uint256: + assets: uint256 = self._convertToAssets(shares) + + if assets == 0 and asset.balanceOf(self) == 0: + assets = shares # NOTE: Assume 1:1 price if nothing deposited yet + + asset.transferFrom(msg.sender, self, assets) + + self.totalSupply += shares + self.balanceOf[receiver] += shares + log Transfer(empty(address), receiver, shares) + log Deposit(msg.sender, receiver, assets, shares) + return assets + + +@view +@external +def maxWithdraw(owner: address) -> uint256: + return max_value(uint256) # real max is `asset.balanceOf(self)` + + +@view +@external +def previewWithdraw(assets: uint256) -> uint256: + shares: uint256 = self._convertToShares(assets) + + # NOTE: Vyper does lazy eval on if, so this avoids SLOADs most of the time + if shares == assets and self.totalSupply == 0: + return 0 # NOTE: Nothing to redeem + + return shares + + +@external +def withdraw(assets: uint256, receiver: address=msg.sender, owner: address=msg.sender) -> uint256: + shares: uint256 = self._convertToShares(assets) + + # NOTE: Vyper does lazy eval on if, so this avoids SLOADs most of the time + if shares == assets and self.totalSupply == 0: + raise # Nothing to redeem + + if owner != msg.sender: + self.allowance[owner][msg.sender] -= shares + + self.totalSupply -= shares + self.balanceOf[owner] -= shares + + asset.transfer(receiver, assets) + log Transfer(owner, empty(address), shares) + log Withdraw(msg.sender, receiver, owner, assets, shares) + return shares + + +@view +@external +def maxRedeem(owner: address) -> uint256: + return max_value(uint256) # real max is `self.totalSupply` + + +@view +@external +def previewRedeem(shares: uint256) -> uint256: + return self._convertToAssets(shares) + + +@external +def redeem(shares: uint256, receiver: address=msg.sender, owner: address=msg.sender) -> uint256: + if owner != msg.sender: + self.allowance[owner][msg.sender] -= shares + + assets: uint256 = self._convertToAssets(shares) + self.totalSupply -= shares + self.balanceOf[owner] -= shares + + asset.transfer(receiver, assets) + log Transfer(owner, empty(address), shares) + log Withdraw(msg.sender, receiver, owner, assets, shares) + return assets + + +@external +def DEBUG_steal_tokens(amount: uint256): + # NOTE: This is the primary method of mocking share price changes + asset.transfer(msg.sender, amount) diff --git a/tests/pools/test_erc4626_swaps.py b/tests/pools/test_erc4626_swaps.py new file mode 100644 index 00000000..8e2bf6c3 --- /dev/null +++ b/tests/pools/test_erc4626_swaps.py @@ -0,0 +1,169 @@ +import boa +import pytest +from eth_utils import function_signature_to_4byte_selector + +from tests.utils.tokens import mint_for_testing + + +@pytest.fixture(scope="module") +def asset(deployer): + with boa.env.prank(deployer): + return boa.load( + "contracts/mocks/ERC20.vy", + "Asset", + "AST", + 8, # 8 decimals + ) + + +@pytest.fixture(scope="module") +def token_a(deployer, asset): + with boa.env.prank(deployer): + return boa.load( + "contracts/mocks/ERC4626.vy", + "Vault", + "VLT", + 8, # 8 decimals + asset.address, + ) + + +@pytest.fixture(scope="module") +def token_b(deployer): + with boa.env.prank(deployer): + return boa.load( + "contracts/mocks/ERC20Oracle.vy", + "Oracle", + "ORC", + 18, + 1006470359024000000, + ) + + +@pytest.fixture(scope="module") +def token_c(deployer): + with boa.env.prank(deployer): + return boa.load( + "contracts/mocks/ERC20Rebasing.vy", + "Rebasing", + "RBSN", + 6, + True, + ) + + +@pytest.fixture(scope="module") +def pool_tokens(token_a, token_b, token_c): + return [token_a, token_b, token_c] + + +@pytest.fixture(scope="module") +def pool_erc20_tokens(asset, token_b, token_c): + return [asset, token_b, token_c] + + +@pytest.fixture(scope="module") +def asset_types(pool_tokens): + _asset_types = [] + for token in pool_tokens: + if "ERC20Oracle" in token.filename: + _asset_types.append(1) + elif "ERC20Rebasing" in token.filename: + _asset_types.append(2) + elif "ERC4626" in token.filename: + _asset_types.append(3) + else: + _asset_types.append(0) + return _asset_types + + +@pytest.fixture(scope="module") +def empty_swap( + deployer, + factory, + pool_tokens, + zero_address, + amm_interface, + asset_types, + set_pool_implementations, +): + pool_size = len(pool_tokens) + oracle_method_id = function_signature_to_4byte_selector("exchangeRate()") + offpeg_fee_multiplier = 20000000000 + method_ids = [bytes(b"")] * pool_size + oracles = [zero_address] * pool_size + A = 1000 + fee = 3000000 + + for i in range(pool_size): + + if asset_types[i] == 1: + method_ids[i] = oracle_method_id + oracles[i] = pool_tokens[i].address + + with boa.env.prank(deployer): + pool = factory.deploy_plain_pool( + "test", + "test", + [t.address for t in pool_tokens], + A, + fee, + offpeg_fee_multiplier, + 866, + 0, + asset_types, + method_ids, + oracles, + ) + + return amm_interface.at(pool) + + +@pytest.fixture(scope="module") +def deposit_amounts(pool_erc20_tokens, token_a, bob): + _deposit_amounts = [] + for i, token in enumerate(pool_tokens): + _deposit_amount = 10**6 * 10 ** token.decimals() + if token.balanceOf(bob) < _deposit_amount: + mint_for_testing(bob, _deposit_amount, token, False) + + if i == 0: # erc4626 token + token.approve(token_a, 2**256 - 1, sender=bob) + token_a.deposit(_deposit_amount, bob, sender=bob) + + _deposit_amounts.append(_deposit_amount) + return _deposit_amounts + + +@pytest.fixture(scope="module") +def swap(empty_swap, bob, deposit_amounts, pool_tokens): + + for token in pool_tokens: + token.approve(empty_swap, 2**256 - 1, sender=bob) + + empty_swap.add_liquidity(deposit_amounts, 0, bob, sender=bob) + return empty_swap + + +def test_swap(swap, charlie, pool_tokens): + + amount_in = 10**18 + i = 0 + j = 1 + if amount_in > pool_tokens[i].balanceOf(charlie): + mint_for_testing(charlie, 10**18, pool_tokens[i], False) + + pool_tokens[i].approve(swap, 2**256 - 1, sender=charlie) + swap.exchange(i, j, amount_in, 0, sender=charlie) + + +def test_rebase(swap, charlie, bob, pool_tokens): + + amount_rewards = 10**4 * 10**18 + i = 1 + if amount_rewards > pool_tokens[i].balanceOf(charlie): + mint_for_testing(charlie, amount_rewards, pool_tokens[i], False) + + pool_tokens[i].transfer(swap, amount_rewards, sender=charlie) # <---- donate. + bob_lp_tokens = swap.balanceOf(bob) + swap.remove_liquidity(bob_lp_tokens, [0, 0, 0], sender=bob) # <--- should not revert diff --git a/tests/pools/test_specific_liquidity_operations.py b/tests/pools/test_specific_liquidity_operations copy.py similarity index 100% rename from tests/pools/test_specific_liquidity_operations.py rename to tests/pools/test_specific_liquidity_operations copy.py