From 75f7dd601ab4a27764664318851f7fe5e42f88a9 Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Mon, 12 Jun 2023 17:53:51 +0300 Subject: [PATCH 1/2] feat: add crvUSD burner with tests --- contracts/burners/eth/crvUSDBurner.vy | 335 ++++++++++++++++++++++++++ tests/fork/Burners/test_crvburner.py | 138 +++++++++++ 2 files changed, 473 insertions(+) create mode 100644 contracts/burners/eth/crvUSDBurner.vy create mode 100644 tests/fork/Burners/test_crvburner.py diff --git a/contracts/burners/eth/crvUSDBurner.vy b/contracts/burners/eth/crvUSDBurner.vy new file mode 100644 index 00000000..00a543ff --- /dev/null +++ b/contracts/burners/eth/crvUSDBurner.vy @@ -0,0 +1,335 @@ +# @version 0.3.7 +""" +@title crvUSD Burner +@notice Withdraws LP tokens from crvUSD pools and exchanges crvUSD +""" + + +interface ERC20: + def approve(_to: address, _value: uint256): nonpayable + def transferFrom(_from: address, _to: address, _value: uint256) -> bool: nonpayable + def balanceOf(_owner: address) -> uint256: view + def decimals() -> uint256: view + + +interface StableSwap: + def coins(_i: uint256) -> address: view + def price_oracle() -> uint256: view + def get_virtual_price() -> uint256: view + def get_dy(i: int128, j: int128, dx: uint256) -> uint256: view + def remove_liquidity_one_coin(token_amount: uint256, i: int128, min_amount: uint256, + receiver: address = msg.sender) -> uint256: nonpayable + def exchange(i: int128, j: int128, _dx: uint256, _min_dy: uint256, _receiver: address = msg.sender) -> uint256: nonpayable + + +interface PoolProxy: + def burners(_coin: address) -> address: view + + +struct RemoveIn: + adjustment: uint64 + i: uint8 # crvUSD index + not_crvUSD: bool # Remove crvUSD if not prioritized pool(from self.pools) + + +N_COINS: constant(uint8) = 2 +MAX_NUM: constant(uint256) = 8 # max number of coins more prioritized than crvUSD +BPS: constant(uint256) = 10000 # precision +SLIPPAGE: constant(uint256) = 2 * 100 # in BPS, 2% +crvUSD: immutable(address) + +pools: public(DynArray[address, MAX_NUM]) +remove_in: public(HashMap[address, RemoveIn]) + +slippage_of: public(HashMap[address, uint256]) + +pool_proxy: public(address) +recovery: public(address) +is_killed: public(bool) + +owner: public(address) +emergency_owner: public(address) +manager: public(address) +future_owner: public(address) +future_emergency_owner: public(address) +future_manager: public(address) + + +@external +def __init__(_crvUSD: address, _pool_proxy: address, _owner: address, _emergency_owner: address): + """ + @notice Contract constructor + @dev Unlike other burners, this contract may transfer tokens to + multiple addresses after the swap. Receiver addresses are + set by calling `set_swap_data` instead of setting it + within the constructor. + @param _crvUSD Address of crvUSD + @param _pool_proxy Address of pool owner proxy + @param _owner Owner address. Can kill the contract, recover tokens + and modify the recovery address. + @param _emergency_owner Emergency owner address. Can kill the contract + and recover tokens. + """ + crvUSD = _crvUSD + self.pool_proxy = _pool_proxy + self.recovery = _pool_proxy + self.owner = _owner + self.emergency_owner = _emergency_owner + self.manager = msg.sender + + +@internal +def _burn(_coin: address, _amount: uint256) -> bool: + if _coin != crvUSD: + # LP token of crvUSD pool + info: RemoveIn = self.remove_in[_coin] + if info.adjustment == 0: + # New coin, remove in crvUSD + if StableSwap(_coin).coins(1) == crvUSD: + info.i = 1 + info.adjustment = 1 # crvUSD is 18 decimals + self.remove_in[_coin] = info + + i: int128 = convert(info.i, int128) + receiver: address = self + if info.not_crvUSD: + i = 1 - i + receiver = self.pool_proxy + + price: uint256 = StableSwap(_coin).price_oracle() + min_amount: uint256 = _amount * StableSwap(_coin).get_virtual_price() / 10 ** 18 + if info.i == 0: + min_amount = min_amount * 10 ** 18 / price + else: + min_amount = min_amount * price / 10 ** 18 + + # Decimals difference + min_amount /= convert(info.adjustment, uint256) + + # Account slippage + slippage: uint256 = self.slippage_of[_coin] + if slippage == 0: + slippage = SLIPPAGE + min_amount -= min_amount * slippage / BPS + + StableSwap(_coin).remove_liquidity_one_coin(_amount, i, min_amount, receiver) + else: + # Find the best available pool by dy + best_dy: uint256 = 0 + best_pool: address = empty(address) + i: int128 = 0 + for pool in self.pools: + info: RemoveIn = self.remove_in[pool] + dy: uint256 = StableSwap(pool).get_dy( + convert(info.i, int128), 1 - convert(info.i, int128), _amount + ) * convert(info.adjustment, uint256) + + if dy > best_dy: + best_dy = dy + best_pool = pool + i = convert(info.i, int128) + + StableSwap(best_pool).exchange(i, 1 - i, _amount, 0, self.pool_proxy) # best_dy is already counted + + return True + + +@payable +@external +def burn(_coin: ERC20) -> bool: + """ + @notice Swap `_coin` for 3CRV and transfer to the fee distributor + @param _coin Address of the coin being swapped + @return bool success + """ + assert not self.is_killed # dev: is killed + + # transfer coins from caller + amount: uint256 = _coin.balanceOf(msg.sender) + if amount != 0: + _coin.transferFrom(msg.sender, self, amount) + + # get actual balance in case of transfer fee or pre-existing balance + amount = _coin.balanceOf(self) + + return self._burn(_coin.address, amount) + + +@external +def burn_amount(_coin: ERC20, _amount_to_burn: uint256): + """ + @notice Burn a specific quantity of `_coin` + @dev Useful when the total amount to burn is so large that it fails from slippage + @param _coin Address of the coin being converted + @param _amount_to_burn Amount of the coin to burn + """ + assert not self.is_killed # dev: is killed + + pool_proxy: address = self.pool_proxy + amount: uint256 = _coin.balanceOf(pool_proxy) + if PoolProxy(pool_proxy).burners(_coin.address) == self and amount != 0: + _coin.transferFrom(pool_proxy, self, amount) + + amount = _coin.balanceOf(self) + assert amount >= _amount_to_burn, "Insufficient balance" + + self._burn(_coin.address, _amount_to_burn) + + +@external +def set_pools(_pools: DynArray[address, MAX_NUM]): + """ + @notice Set new prioritized pools + @param _pools Addresses of prioritized pools + """ + assert msg.sender in [self.owner, self.manager] + assert len(_pools) > 0 + + for pool in self.pools: + self.remove_in[pool] = empty(RemoveIn) + ERC20(crvUSD).approve(pool, 0) + + self.pools = _pools + + for pool in _pools: + info: RemoveIn = empty(RemoveIn) + info.i = N_COINS + for i in range(N_COINS): + coin: address = StableSwap(pool).coins(convert(i, uint256)) + if coin == crvUSD: + info.i = i + else: + info.adjustment = convert(10 ** (18 - ERC20(coin).decimals()), uint64) + assert info.i < N_COINS # dev: pool does not contain crvUSD + info.not_crvUSD = True + + self.remove_in[pool] = info + ERC20(crvUSD).approve(pool, max_value(uint256)) + + +@external +def recover_balance(_coin: address) -> bool: + """ + @notice Recover ERC20 tokens from this contract + @dev Tokens are sent to the recovery address + @param _coin Token address + @return bool success + """ + assert msg.sender in [self.owner, self.emergency_owner] # dev: only owner + + amount: uint256 = ERC20(_coin).balanceOf(self) + response: Bytes[32] = raw_call( + _coin, + _abi_encode(self.recovery, amount, method_id=method_id("transfer(address,uint256)")), + max_outsize=32, + ) + if len(response) != 0: + assert convert(response, bool) + + return True + + +@external +def set_recovery(_recovery: address) -> bool: + """ + @notice Set the token recovery address + @param _recovery Token recovery address + @return bool success + """ + assert msg.sender == self.owner # dev: only owner + self.recovery = _recovery + + return True + + +@external +def set_killed(_is_killed: bool) -> bool: + """ + @notice Set killed status for this contract + @dev When killed, the `burn` function cannot be called + @param _is_killed Killed status + @return bool success + """ + assert msg.sender in [self.owner, self.emergency_owner] # dev: only owner + self.is_killed = _is_killed + + return True + + + +@external +def commit_transfer_ownership(_future_owner: address) -> bool: + """ + @notice Commit a transfer of ownership + @dev Must be accepted by the new owner via `accept_transfer_ownership` + @param _future_owner New owner address + @return bool success + """ + assert msg.sender == self.owner # dev: only owner + self.future_owner = _future_owner + + return True + + +@external +def accept_transfer_ownership() -> bool: + """ + @notice Accept a transfer of ownership + @return bool success + """ + assert msg.sender == self.future_owner # dev: only owner + self.owner = msg.sender + + return True + + +@external +def commit_transfer_emergency_ownership(_future_owner: address) -> bool: + """ + @notice Commit a transfer of ownership + @dev Must be accepted by the new owner via `accept_transfer_ownership` + @param _future_owner New owner address + @return bool success + """ + assert msg.sender == self.emergency_owner # dev: only owner + self.future_emergency_owner = _future_owner + + return True + + +@external +def accept_transfer_emergency_ownership() -> bool: + """ + @notice Accept a transfer of ownership + @return bool success + """ + assert msg.sender == self.future_emergency_owner # dev: only owner + self.emergency_owner = msg.sender + + return True + + +@external +def commit_new_manager(_future_manager: address) -> bool: + """ + @notice Commit a transfer of manager's role + @dev Must be accepted by the new manager via `accept_new_manager` + @param _future_manager New manager address + @return bool success + """ + assert msg.sender in [self.owner, self.emergency_owner, self.manager] # dev: only owner + self.future_manager = _future_manager + + return True + + +@external +def accept_new_manager() -> bool: + """ + @notice Accept a transfer of manager's role + @return bool success + """ + assert msg.sender == self.future_manager # dev: only owner + self.manager = msg.sender + + return True diff --git a/tests/fork/Burners/test_crvburner.py b/tests/fork/Burners/test_crvburner.py new file mode 100644 index 00000000..6ae54ddb --- /dev/null +++ b/tests/fork/Burners/test_crvburner.py @@ -0,0 +1,138 @@ +import pytest +from brownie import ZERO_ADDRESS, Contract +from brownie_tokens import MintableForkToken + +LP_TOKENS = [ + "0x4DEcE678ceceb27446b35C672dC7d61F30bAD69E", # USDC/crvUSD + "0x34d655069f4cac1547e4c8ca284ffff5ad4a8db0", # TUSD/crvUSD + "0x390f3595bca2df7d23783dfd126427cceb997bf4", # USDT/crvUSD + "0xca978a0528116dda3cba9acd3e68bc6191ca53d0", # USDP/crvUSD +] + + +@pytest.fixture(scope="module") +def pool_proxy(): + yield Contract("0xeCb456EA5365865EbAb8a2661B0c503410e9B347") + + +@pytest.fixture(scope="module") +def crvusd(): + return MintableForkToken("0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E") + + +@pytest.fixture(scope="module") +def lp_tokens(): + return [Contract(token) for token in LP_TOKENS] + + +@pytest.fixture(scope="module") +def burner(crvUSDBurner, crvusd, pool_proxy, alice, receiver, lp_tokens): + burner = crvUSDBurner.deploy( + crvusd, # crvUSD + pool_proxy, # pool_proxy + alice, # owner + alice, # emergency_owner + {"from": alice}, + ) + coins = lp_tokens + [crvusd] + pool_proxy.set_many_burners( + coins + [ZERO_ADDRESS] * (20 - len(coins)), + [burner] * len(coins) + [ZERO_ADDRESS] * (20 - len(coins)), + {"from": pool_proxy.ownership_admin()} + ) + return burner + + +@pytest.fixture(scope="module") +def prioritized(lp_tokens): + return lp_tokens[: len(lp_tokens) // 2] + + +@pytest.fixture(scope="module") +def not_prioritized(prioritized, lp_tokens): + return [token for token in lp_tokens if token not in prioritized] + + +@pytest.fixture(scope="module", autouse=True) +def setup(burner, prioritized, alice): + burner.set_pools( + prioritized, + {"from": alice}, + ) + + +@pytest.fixture(scope="module") +def get_coin(crvusd): + def inner(lp): + coin = lp.coins(0) + if coin == crvusd.address: + coin = lp.coins(1) + return Contract(coin) + return inner + + +def test_lp(prioritized, not_prioritized, get_coin, burner, crvusd, pool_proxy, alice): + for lp in prioritized: + amount = lp.balanceOf(pool_proxy) + amount_crvUSD = [crvusd.balanceOf(pool_proxy), crvusd.balanceOf(burner)] + + coin = get_coin(lp) + adjustment = 10 ** (18 - coin.decimals()) + amount_coin = [coin.balanceOf(pool_proxy) * adjustment, coin.balanceOf(burner) * adjustment] + + burner.burn(lp, {"from": pool_proxy}) + + assert lp.balanceOf(pool_proxy) == 0 + assert lp.balanceOf(burner) == 0 + + assert crvusd.balanceOf(pool_proxy) == amount_crvUSD[0] + assert crvusd.balanceOf(burner) == amount_crvUSD[1] + + assert coin.balanceOf(pool_proxy) * adjustment == pytest.approx( + amount_coin[0] + amount, abs=amount * .01 + ) + assert coin.balanceOf(burner) * adjustment == amount_coin[1] + + for lp in not_prioritized: + amount = lp.balanceOf(pool_proxy) + amount_crvUSD = [crvusd.balanceOf(pool_proxy), crvusd.balanceOf(burner)] + + coin = get_coin(lp) + amount_coin = [coin.balanceOf(pool_proxy), coin.balanceOf(burner)] + + burner.burn(lp, {"from": pool_proxy}) + + assert lp.balanceOf(pool_proxy) == 0 + assert lp.balanceOf(burner) == 0 + + assert crvusd.balanceOf(pool_proxy) == amount_crvUSD[0] + assert int(crvusd.balanceOf(burner)) == pytest.approx( + amount_crvUSD[1] + amount, abs=amount * .05 + ) + + assert coin.balanceOf(pool_proxy) == amount_coin[0] + assert coin.balanceOf(burner) == amount_coin[1] + + +def test_crvusd(prioritized, burner, crvusd, get_coin, pool_proxy, alice, chain): + chain_initial = chain.height + for pool in prioritized: + # Move price + coin = MintableForkToken(get_coin(pool).address) + amount_coin = coin.balanceOf(pool) // 2 + coin._mint_for_testing(alice, amount_coin, {"from": alice}) + coin.approve(pool, amount_coin, {"from": alice}) + pool.exchange(0, 1, amount_coin, 0, {"from": alice}) + + adjustment = 10 ** (18 - coin.decimals()) + initial_amounts = [coin.balanceOf(pool_proxy) * adjustment, crvusd.balanceOf(pool_proxy)] + + burner.burn(crvusd, {"from": pool_proxy}) + + assert crvusd.balanceOf(pool_proxy) == 0 + assert crvusd.balanceOf(burner) == 0 + + assert coin.balanceOf(pool_proxy) * adjustment > sum(initial_amounts) + assert coin.balanceOf(burner) == 0 + + chain.undo(chain.height - chain_initial) From e462fb9488f78aa285c2c9b1d4ead485d61094fb Mon Sep 17 00:00:00 2001 From: Roman Agureev Date: Mon, 12 Jun 2023 18:10:21 +0300 Subject: [PATCH 2/2] feat: add price_oracle check for crvUSD exchange --- contracts/burners/eth/crvUSDBurner.vy | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/contracts/burners/eth/crvUSDBurner.vy b/contracts/burners/eth/crvUSDBurner.vy index 00a543ff..3510ebec 100644 --- a/contracts/burners/eth/crvUSDBurner.vy +++ b/contracts/burners/eth/crvUSDBurner.vy @@ -129,6 +129,12 @@ def _burn(_coin: address, _amount: uint256) -> bool: best_pool = pool i = convert(info.i, int128) + price_oracle: uint256 = StableSwap(best_pool).price_oracle() + slippage: uint256 = self.slippage_of[_coin] + if slippage == 0: + slippage = SLIPPAGE + assert price_oracle - price_oracle * slippage / BPS <= best_dy * 10 ** 18 / _amount + StableSwap(best_pool).exchange(i, 1 - i, _amount, 0, self.pool_proxy) # best_dy is already counted return True