diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 71ff2704..b1c94013 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -40,4 +40,7 @@ jobs: run: pip install -r requirements.txt - name: Run Tests - run: brownie test tests/fork --network=mainnet-fork \ No newline at end of file + run: brownie test tests/fork --network=mainnet-fork + + - name: Run Avalanche Tests + run: brownie test tests/avalanche-fork --network=avax-main-fork diff --git a/contracts/burners/avalanche/TraderJoeBurner.vy b/contracts/burners/avalanche/TraderJoeBurner.vy new file mode 100644 index 00000000..e67621cc --- /dev/null +++ b/contracts/burners/avalanche/TraderJoeBurner.vy @@ -0,0 +1,328 @@ +# @version 0.2.15 +""" +@title TraderJoe Burner +@notice Swap coins to USDC using TraderJoe, and send to receiver +""" + +from vyper.interfaces import ERC20 + + +interface UniswapV2Pair: + def token0() -> address: + view + + def token1() -> address: + view + + def factory() -> address: + view + + +interface UniswapV2Router02: + def removeLiquidity( + tokenA: address, + tokenB: address, + liquidity: uint256, + amountAMin: uint256, + amountBMin: uint256, + to: address, + deadline: uint256, + ) -> uint256[2]: + nonpayable + + def factory() -> address: + view + + +interface UniswapV2Factory: + def getPair(tokenA: address, tokenB: address) -> address: + view + + +is_approved: HashMap[address, HashMap[address, bool]] +receiver: public(address) +recovery: public(address) +is_killed: public(bool) +owner: public(address) +emergency_owner: public(address) +future_owner: public(address) +future_emergency_owner: public(address) + + +WAVAX: constant(address) = 0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7 + +# USDC.e +USDC: constant(address) = 0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664 +ROUTERS: constant(address[1]) = [ + # traderjoe + 0x60aE616a2155Ee3d9A68541Ba4544862310933d4, +] + + +@internal +def _swap_for_usdc(_coin: address, amount: uint256, router: address): + # vyper doesnt support dynamic array. build calldata manually + if _coin == WAVAX: + raw_call( + router, + concat( + method_id("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"), + convert(amount, bytes32), # swap amount + EMPTY_BYTES32, # min expected + convert(160, bytes32), # offset pointer to path array + convert(self.receiver, bytes32), # receiver of the swap + convert(block.timestamp, bytes32), # swap deadline + convert(2, bytes32), # path length + convert(_coin, bytes32), # input token + convert(USDC, bytes32), # usdc (final output) + ), + ) + else: + raw_call( + router, + concat( + method_id("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)"), + convert(amount, bytes32), # swap amount + EMPTY_BYTES32, # min expected + convert(160, bytes32), # offset pointer to path array + convert(self.receiver, bytes32), # receiver of the swap + convert(block.timestamp, bytes32), # swap deadline + convert(3, bytes32), # path length + convert(_coin, bytes32), # input token + convert(WAVAX, bytes32), # wavax (intermediate swap) + convert(USDC, bytes32), # usdc (final output) + ), + ) + + +@internal +def _get_amounts_out(_coin: address, amount: uint256, router: address) -> uint256: + # vyper doesnt support dynamic array. build calldata manually + call_data: Bytes[256] = 0x00 + if _coin == WAVAX: + call_data = concat( + method_id("getAmountsOut(uint256,address[])"), + convert(amount, bytes32), + convert(64, bytes32), + convert(2, bytes32), + convert(_coin, bytes32), + convert(USDC, bytes32), + ) + else: + call_data = concat( + method_id("getAmountsOut(uint256,address[])"), + convert(amount, bytes32), + convert(64, bytes32), + convert(3, bytes32), + convert(_coin, bytes32), + convert(WAVAX, bytes32), + convert(USDC, bytes32), + ) + response: Bytes[128] = raw_call(router, call_data, max_outsize=128) + response_bytes_start_index: uint256 = 0 + if _coin == WAVAX: + response_bytes_start_index = 64 + else: + response_bytes_start_index = 96 + return convert(slice(response, response_bytes_start_index, 32), uint256) + + +@external +def __init__(_receiver: address, _recovery: address, _owner: address, _emergency_owner: address): + """ + @notice Contract constructor + @param _receiver Address that converted tokens are transferred to. + Should be set to an USDCBurner. + @param _recovery Address that tokens are transferred to during an + emergency token recovery. + @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. + """ + self.receiver = _receiver + self.recovery = _recovery + self.owner = _owner + self.emergency_owner = _emergency_owner + + +@external +@nonreentrant("lock") +def burn(_coin: address) -> bool: + """ + @notice Receive `_coin` and swap it for USDC using TraderJoe + @param _coin Address of the coin being converted + @return bool success + """ + assert not self.is_killed # dev: is killed + + # transfer coins from caller + amount: uint256 = ERC20(_coin).balanceOf(msg.sender) + + if amount != 0: + response: Bytes[32] = raw_call( + _coin, + concat( + method_id("transferFrom(address,address,uint256)"), + convert(msg.sender, bytes32), + convert(self, bytes32), + convert(amount, bytes32), + ), + max_outsize=32, + ) + if len(response) != 0: + assert convert(response, bool) + + # get actual balance in case of transfer fee or pre-existing balance + amount = ERC20(_coin).balanceOf(self) + + best_expected: uint256 = 0 + router: address = ZERO_ADDRESS + + # check the rates on traderjoe to see which is the better option + for addr in ROUTERS: + if _coin != WAVAX: + factory: address = UniswapV2Router02(addr).factory() + coin_wavax_pair: address = UniswapV2Factory(factory).getPair(_coin, WAVAX) + if coin_wavax_pair == ZERO_ADDRESS: + continue + expected: uint256 = self._get_amounts_out(_coin, amount, addr) + if expected > best_expected: + best_expected = expected + router = addr + + assert router != ZERO_ADDRESS, "TraderJoe has liquidity pool for this token" + # make sure the router is approved to transfer the coin + if not self.is_approved[router][_coin]: + response: Bytes[32] = raw_call( + _coin, + concat( + method_id("approve(address,uint256)"), + convert(router, bytes32), + convert(MAX_UINT256, bytes32), + ), + max_outsize=32, + ) + if len(response) != 0: + assert convert(response, bool) + self.is_approved[router][_coin] = True + # swap for USDC on the best dex protocol + self._swap_for_usdc(_coin, amount, router) + return True + + +@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, + concat( + method_id("transfer(address,uint256)"), + convert(self.recovery, bytes32), + convert(amount, bytes32), + ), + 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 set_receiver(_receiver: address) -> bool: + """ + @notice Set receiver + @param _receiver Receiver address + @return bool success + """ + assert msg.sender in [self.owner, self.emergency_owner] # dev: only owner + self.receiver = _receiver + return True diff --git a/requirements.txt b/requirements.txt index 85916186..484faeca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -black==20.8b1 -eth-brownie>=1.14.6,<2.0.0 +black==21.11b1 +eth-brownie==1.17.2 flake8==3.8.4 isort==5.7.0 -brownie-token-tester>=0.2.2 +brownie-token-tester==0.3.2 diff --git a/tests/avalanche-fork/Burners/test_traderjoe_burner.py b/tests/avalanche-fork/Burners/test_traderjoe_burner.py new file mode 100644 index 00000000..571a676d --- /dev/null +++ b/tests/avalanche-fork/Burners/test_traderjoe_burner.py @@ -0,0 +1,63 @@ +# import brownie +import pytest + +from abi.ERC20 import ERC20 + + +@pytest.fixture(scope="module") +def burner(TraderJoeBurner, alice, receiver): + yield TraderJoeBurner.deploy(receiver, receiver, alice, alice, {"from": alice}) + + +WAVAX = "0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7" +USDT = "0xc7198437980c041c805A1EDcbA50c1Ce5db95118" +WETH = "0x49D5c2BdFfac6CE2BFdB6640F4F80f226bc10bAB" +DAI = "0xd586E7F844cEa2F87f50152665BCbc2C279D8d70" +WBTC = "0x50b7545627a5162F82A992c33b87aDc75187B218" +USDC_OLD = "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e" +LINK = "0x5947BB275c521040051D82396192181b413227A3" + +TOKENS = [ + (WAVAX, "0xdfe521292ece2a4f44242efbcd66bc594ca9714b"), + (USDT, "0x532e6537fea298397212f09a61e03311686f548e"), + (WETH, "0x53f7c5869a859f0aec3d334ee8b4cf01e3492f21"), + (DAI, "0x47afa96cdc9fab46904a55a6ad4bf6660b53c38a"), + (WBTC, "0x686bef2417b6dc32c50a3cbfbcc3bb60e1e9a15d"), + (USDC_OLD, "0xbf14db80d9275fb721383a77c00ae180fc40ae98"), + (LINK, "0x2842a5d74a0374a4749784477c686d27f82a9e03"), +] + + +@pytest.mark.parametrize("token,whale", TOKENS) +def test_burn(MintableTestToken, USDC, alice, receiver, burner, token, whale): + token = MintableTestToken.from_abi("token", token, abi=ERC20) + amount = 1000 * (10 ** token.decimals()) + token.transfer(alice, amount, {"from": whale}) + + token.approve(burner, 2 ** 256 - 1, {"from": alice}) + + burner.burn(token, {"from": alice}) + + assert token.balanceOf(alice) == 0 + assert token.balanceOf(burner) == 0 + assert token.balanceOf(receiver) == 0 + + assert USDC.balanceOf(alice) == 0 + assert USDC.balanceOf(burner) == 0 + assert USDC.balanceOf(receiver) > 0 + + +""" +def test_burn_unburnable(MintableTestToken, USDC, alice, burner): + # CMC Rank 500 coin, not available in either sushi or uniswap + turtle = MintableTestToken.from_abi( + "turtle", "0xf3afdc2525568ffe743801c8c54bdea1704c9adb", abi=ERC20 + ) + + amount = 10 ** turtle.decimals() + + turtle._mint_for_testing(alice, amount, {"from": alice}) + turtle.approve(burner, 2 ** 256 - 1, {"from": alice}) + with brownie.reverts("neither Uniswap nor Sushiswap has liquidity pool for this token"): + burner.burn(turtle, {"from": alice}) +""" diff --git a/tests/avalanche-fork/conftest.py b/tests/avalanche-fork/conftest.py new file mode 100644 index 00000000..070c5899 --- /dev/null +++ b/tests/avalanche-fork/conftest.py @@ -0,0 +1,21 @@ +import pytest +from brownie_tokens import MintableForkToken + +from abi.ERC20 import ERC20 + + +class _MintableTestToken(MintableForkToken): + def __init__(self, address): + super().__init__(address) + + +@pytest.fixture(scope="session") +def MintableTestToken(): + yield _MintableTestToken + + +@pytest.fixture(scope="module") +def USDC(): + yield _MintableTestToken.from_abi( + "USDC", "0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664", abi=ERC20 + )