diff --git a/.openzeppelin/mainnet.json b/.openzeppelin/mainnet.json index 7e50e1dc38..d89b536e24 100644 --- a/.openzeppelin/mainnet.json +++ b/.openzeppelin/mainnet.json @@ -1,6 +1,12 @@ { "manifestVersion": "3.2", - "proxies": [], + "proxies": [ + { + "address": "0x6702c91ffC24fA862B8D9C053d8C3e395c379E1e", + "txHash": "0x4af4d98737396c1c4e31768387d7026851b135de3199615f942cbfe72ae154d4", + "kind": "uups" + } + ], "impls": { "5772f9a12946a693b87839e6ea71a2c52293982e3a7989190fa8d1e93305a58c": { "address": "0x143C35bFe04720394eBd18AbECa83eA9D8BEdE2F", @@ -10166,6 +10172,106 @@ } } } + }, + "ae1029b17f53d69f796c3f889c9bb28ba66d7ba84e0b1d7344697cf40207aac2": { + "address": "0x0Da349103B71821c688308Ea4477E6A3359d69B1", + "txHash": "0x9f2c2745f70a513b5f671dc7d32823a14b7e273d40f4ad878ea2847873cee3d1", + "layout": { + "solcVersion": "0.8.19", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:63", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:68" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ERC1967UpgradeUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/ERC1967/ERC1967UpgradeUpgradeable.sol:169" + }, + { + "label": "__gap", + "offset": 0, + "slot": "51", + "type": "t_array(t_uint256)50_storage", + "contract": "UUPSUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol:111" + }, + { + "label": "__gap", + "offset": 0, + "slot": "101", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:40" + }, + { + "label": "_owner", + "offset": 0, + "slot": "151", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "152", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "__gap", + "offset": 0, + "slot": "201", + "type": "t_array(t_uint256)49_storage", + "contract": "EthPlusIntoEth", + "src": "contracts/facade/redeem/EthPlusIntoEth.sol:377" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } } } } diff --git a/contracts/redeem/EthPlusIntoEth.sol b/contracts/redeem/EthPlusIntoEth.sol new file mode 100644 index 0000000000..88266f3b7c --- /dev/null +++ b/contracts/redeem/EthPlusIntoEth.sol @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; +import { IBasketHandler } from "../interfaces/IBasketHandler.sol"; +import { IRToken } from "../interfaces/IRToken.sol"; +import { RoundingMode, FixLib } from "../libraries/Fixed.sol"; + +interface IWETH is IERC20 { + function withdraw(uint256 wad) external; +} + +interface IRETHRouter { + function swapTo( + uint256 _uniswapPortion, + uint256 _balancerPortion, + uint256 _minTokensOut, + uint256 _idealTokensOut + ) external payable; + + function swapFrom( + uint256 _uniswapPortion, + uint256 _balancerPortion, + uint256 _minTokensOut, + uint256 _idealTokensOut, + uint256 _tokensIn + ) external; + + function optimiseSwapTo(uint256 _amount, uint256 _steps) + external + returns (uint256[2] memory portions, uint256 amountOut); + + function optimiseSwapFrom(uint256 _amount, uint256 _steps) + external + returns (uint256[2] memory portions, uint256 amountOut); +} + +interface IAsset { + // solhint-disable-previous-line no-empty-blocks +} + +interface IVault { + enum SwapKind { + GIVEN_IN, + GIVEN_OUT + } + struct FundManagement { + address sender; + bool fromInternalBalance; + address payable recipient; + bool toInternalBalance; + } + struct SingleSwap { + bytes32 poolId; + SwapKind kind; + IAsset assetIn; + IAsset assetOut; + uint256 amount; + bytes userData; + } + struct BatchSwapStep { + bytes32 poolId; + uint256 assetInIndex; + uint256 assetOutIndex; + uint256 amount; + bytes userData; + } + + function swap( + SingleSwap memory singleSwap, + FundManagement memory funds, + uint256 limit, + uint256 deadline + ) external payable returns (uint256); + + function queryBatchSwap( + SwapKind kind, + BatchSwapStep[] memory swaps, + IAsset[] memory assets, + FundManagement memory funds + ) external returns (int256[] memory assetDeltas); +} + +interface IWSTETH is IERC20 { + function unwrap(uint256 _wstETHAmount) external returns (uint256); + + function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256); +} + +interface ICurveETHstETHStableSwap { + function exchange( + int128 i, + int128 j, + uint256 dx, + uint256 minDy + ) external payable returns (uint256); + + function get_dy( + int128 i, + int128 j, + uint256 dx + ) external view returns (uint256); +} + +interface IRETH is IERC20 { + function burn(uint256 rethAmt) external; + + function getTotalCollateral() external view returns (uint256); + + function getEthValue(uint256 rethAmt) external view returns (uint256); +} + +interface ICurveStableSwap { + function exchange( + int128 i, + int128 j, + uint256 dx, + uint256 minDy, + address receiver + ) external returns (uint256); + + function get_dy( + int128 i, + int128 j, + uint256 dx + ) external view returns (uint256); +} + +interface IUniswapV2Like { + function swapExactTokensForETH( + uint256 amountIn, + uint256 amountOutMin, + // is ignored, can be empty + address[] calldata path, + address to, + uint256 deadline + ) external returns (uint256[] memory amounts); + + function getAmountsOut( + uint256 amountIn, + // is ignored, can be empty + address[] calldata path + ) external returns (uint256[] memory amounts); +} + +/** Small utility contract to swap ETH+ for ETH by redeeming ETH+ and swapping. + */ +contract EthPlusIntoEth is IUniswapV2Like, UUPSUpgradeable, OwnableUpgradeable { + using SafeERC20 for IERC20; + + IVault private constant BALANCER_VAULT = IVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8); + bytes32 private constant BALANCER_POOL_ID = + 0x1e19cf2d73a72ef1332c882f20534b6519be0276000200000000000000000112; + + IRToken private constant ETH_PLUS = IRToken(0xE72B141DF173b999AE7c1aDcbF60Cc9833Ce56a8); + + IRETH private constant RETH = IRETH(0xae78736Cd615f374D3085123A210448E74Fc6393); + + IWETH private constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + + IRETHRouter private constant RETH_ROUTER = + IRETHRouter(0x16D5A408e807db8eF7c578279BEeEe6b228f1c1C); + + IWSTETH private constant WSTETH = IWSTETH(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0); + IERC20 private constant STETH = IERC20(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); + + IERC4626 private constant SFRXETH = IERC4626(0xac3E018457B222d93114458476f3E3416Abbe38F); + IERC20 private constant FRXETH = IERC20(0x5E8422345238F34275888049021821E8E08CAa1f); + ICurveETHstETHStableSwap private constant CURVE_ETHSTETH_STABLE_SWAP = + ICurveETHstETHStableSwap(0xDC24316b9AE028F1497c275EB9192a3Ea0f67022); + ICurveStableSwap private constant CURVE_FRXETH_WETH = + ICurveStableSwap(0x9c3B46C0Ceb5B9e304FCd6D88Fc50f7DD24B31Bc); + + function initialize() public initializer { + __Ownable_init(); + __UUPSUpgradeable_init(); + } + + function makeBalancerFunds() internal view returns (IVault.FundManagement memory) { + IVault.FundManagement memory fundManagement; + fundManagement.sender = address(this); + fundManagement.recipient = payable(address(this)); + fundManagement.fromInternalBalance = false; + fundManagement.toInternalBalance = false; + return fundManagement; + } + + function balancerSwap(uint256 _amount) private { + IVault.SingleSwap memory swap; + swap.poolId = BALANCER_POOL_ID; + swap.kind = IVault.SwapKind.GIVEN_IN; + swap.assetIn = IAsset(address(RETH)); + swap.assetOut = IAsset(address(WETH)); + swap.amount = _amount; + IVault.FundManagement memory funds = makeBalancerFunds(); + IERC20(RETH).safeApprove(address(BALANCER_VAULT), _amount); + BALANCER_VAULT.swap(swap, funds, 0, block.timestamp); + } + + function getETHPlusRedemptionQuantities(uint256 amt) external view returns (uint256[] memory) { + IBasketHandler handler = ETH_PLUS.main().basketHandler(); + uint256 supply = ETH_PLUS.totalSupply(); + (, uint256[] memory quantities) = handler.quote( + FixLib.muluDivu(ETH_PLUS.basketsNeeded(), amt, supply, RoundingMode.CEIL), + RoundingMode.FLOOR + ); + return quantities; + } + + function calculateRETHPortions(uint256 amountIn) + internal + view + returns (uint256 toBurn, uint256 toTrade) + { + uint256 collateralAvailable = RETH.getTotalCollateral(); + + if (amountIn > collateralAvailable) { + toBurn = collateralAvailable; + toTrade = amountIn - collateralAvailable; + } else { + toBurn = amountIn; + toTrade = 0; + } + return (toBurn, toTrade); + } + + function getBalancerQuote(uint256 _amount) internal returns (uint256) { + IAsset[] memory assets = new IAsset[](2); + assets[0] = IAsset(address(RETH)); + assets[1] = IAsset(address(WETH)); + + IVault.BatchSwapStep[] memory balancerSwapStep = new IVault.BatchSwapStep[](1); + balancerSwapStep[0].poolId = BALANCER_POOL_ID; + balancerSwapStep[0].amount = _amount; + balancerSwapStep[0].assetInIndex = 0; + balancerSwapStep[0].assetOutIndex = 1; + balancerSwapStep[0].userData = new bytes(0); + + IVault.FundManagement memory funds; + + int256[] memory out = BALANCER_VAULT.queryBatchSwap( + IVault.SwapKind.GIVEN_IN, + balancerSwapStep, + assets, + funds + ); + + return uint256(-out[1]); + } + + /// @dev Returns the amounts of ETH a given amount of RETH can be redeemed into + /// The function is meant be called from off-chain for getting the current quote, or to + /// calculate the minAmount to use for the swapExactTokensForETH function + function getAmountsOut(uint256 amountIn, address[] calldata) + external + override + returns (uint256[] memory amounts) + { + require(amountIn != 0, "INVALID_AMOUNT_IN"); + amounts = new uint256[](2); + amounts[0] = amountIn; + + (, bytes memory data) = address(this).staticcall( + abi.encodeWithSignature("getETHPlusRedemptionQuantities(uint256)", amountIn) + ); + uint256[] memory quantities = abi.decode(data, (uint256[])); + + { + (uint256 toBurn, uint256 toTrade) = calculateRETHPortions(quantities[2]); + + if (toBurn > 0) { + uint256 burnQuote = RETH.getEthValue(toBurn); + amounts[1] += burnQuote; + } + + if (toTrade > 0) { + uint256 balQuote = getBalancerQuote(toTrade); + amounts[1] += balQuote; + } + } + + { + uint256 stEthAmt = WSTETH.getStETHByWstETH(quantities[1]); + + amounts[1] += CURVE_ETHSTETH_STABLE_SWAP.get_dy(1, 0, stEthAmt); + } + + { + uint256 frxEthAmt = SFRXETH.convertToAssets(quantities[0]); + amounts[1] += CURVE_FRXETH_WETH.get_dy(1, 0, frxEthAmt); + } + + return amounts; + } + + function safeTransferETH(address to, uint256 amount) internal { + /// @solidity memory-safe-assembly + assembly { + if iszero(call(gas(), to, amount, codesize(), 0x00, codesize(), 0x00)) { + mstore(0x00, 0xb12d13eb) // `ETHTransferFailed()`. + revert(0x1c, 0x04) + } + } + } + + /// @dev Swaps RETH for ETH, RETH must be approved before calling this function + function swapExactTokensForETH( + uint256 amountIn, + uint256 amountOutMin, + // is ignored so can be both empty, or token path, or anything + // solhint-disable-next-line unused-ignore + address[] calldata, + address to, + uint256 deadline + ) external override returns (uint256[] memory amounts) { + // solhint-disable-next-line custom-errors + require(deadline >= block.timestamp, "DEADLINE"); + require(to != address(0), "INVALID_TO"); + require(amountIn != 0, "INVALID_AMOUNT_IN"); + ETH_PLUS.transferFrom(msg.sender, address(this), amountIn); + ETH_PLUS.redeem(ETH_PLUS.balanceOf(address(this))); + + // reth -> eth + { + (uint256 toBurn, uint256 toTrade) = calculateRETHPortions( + RETH.balanceOf(address(this)) + ); + + if (toBurn > 0) { + RETH.burn(toBurn); + } + if (toTrade > 0) { + balancerSwap(toTrade); + } + } + + // wsteth -> eth + { + WSTETH.unwrap(WSTETH.balanceOf(address(this))); + uint256 stethBalance = STETH.balanceOf(address(this)); + STETH.approve(address(CURVE_ETHSTETH_STABLE_SWAP), stethBalance); + CURVE_ETHSTETH_STABLE_SWAP.exchange(1, 0, stethBalance, 0); + } + + // sfrxeth -> eth + { + uint256 sfrxethBalance = SFRXETH.balanceOf(address(this)); + SFRXETH.redeem(sfrxethBalance, address(this), address(this)); + uint256 frxethBalance = FRXETH.balanceOf(address(this)); + FRXETH.approve(address(CURVE_FRXETH_WETH), frxethBalance); + + // frxeth -> weth + CURVE_FRXETH_WETH.exchange(1, 0, frxethBalance, 0, address(this)); + } + + // weth -> eth + WETH.withdraw(WETH.balanceOf(address(this))); + amounts = new uint256[](2); + amounts[0] = amountIn; + amounts[1] = address(this).balance; + + require(address(this).balance >= amountOutMin, "INSUFFICIENT_OUTPUT_AMOUNT"); + + safeTransferETH(to, amounts[1]); + } + + receive() external payable {} + + // === Upgradeability === + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[49] private __gap; +} diff --git a/hardhat.config.ts b/hardhat.config.ts index 0e9f812b2e..40d26cdecd 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -28,11 +28,15 @@ const BASE_RPC_URL = useEnv('BASE_RPC_URL') const ARBITRUM_SEPOLIA_RPC_URL = useEnv('ARBITRUM_SEPOLIA_RPC_URL') const ARBITRUM_RPC_URL = useEnv('ARBITRUM_RPC_URL') const MNEMONIC = useEnv('MNEMONIC') || 'test test test test test test test test test test test junk' +const PRIVATE_KEY = useEnv('PRIVATE_KEY') const TIMEOUT = useEnv('SLOW') ? 6_000_000 : 600_000 const src_dir = `./contracts/${useEnv('PROTO')}` const settings = useEnv('NO_OPT') ? {} : { optimizer: { enabled: true, runs: 200 } } +const accounts = PRIVATE_KEY != null ? [PRIVATE_KEY] : { + mnemonic: MNEMONIC, +} const config: HardhatUserConfig = { defaultNetwork: 'hardhat', networks: { @@ -40,9 +44,9 @@ const config: HardhatUserConfig = { // network for tests/in-process stuff forking: useEnv('FORK') ? { - url: forkRpcs[useEnv('FORK_NETWORK', 'mainnet') as Network], - blockNumber: Number(useEnv(`FORK_BLOCK`, forkBlockNumber['default'].toString())), - } + url: forkRpcs[useEnv('FORK_NETWORK', 'mainnet') as Network], + blockNumber: Number(useEnv(`FORK_BLOCK`, forkBlockNumber['default'].toString())), + } : undefined, gas: 0x1ffffffff, blockGasLimit: 0x1fffffffffffff, @@ -60,54 +64,40 @@ const config: HardhatUserConfig = { goerli: { chainId: 5, url: GOERLI_RPC_URL, - accounts: { - mnemonic: MNEMONIC, - }, + accounts, }, 'base-goerli': { chainId: 84531, url: BASE_GOERLI_RPC_URL, - accounts: { - mnemonic: MNEMONIC, - }, + accounts, }, base: { chainId: 8453, url: BASE_RPC_URL, - accounts: { - mnemonic: MNEMONIC, - }, + accounts, }, mainnet: { chainId: 1, url: MAINNET_RPC_URL, - accounts: { - mnemonic: MNEMONIC, - }, + accounts, // gasPrice: 30_000_000_000, gasMultiplier: 2, // 100% buffer; seen failures on RToken deployment and asset refreshes otherwise }, arbitrum: { chainId: 42161, url: ARBITRUM_RPC_URL, - accounts: { - mnemonic: MNEMONIC, - }, + accounts, gasMultiplier: 2, // 100% buffer; seen failures on RToken deployment and asset refreshes otherwise }, 'arbitrum-sepolia': { chainId: 421614, url: ARBITRUM_SEPOLIA_RPC_URL, - accounts: { - mnemonic: MNEMONIC, - }, + accounts, }, tenderly: { chainId: 3, url: TENDERLY_RPC_URL, - accounts: { - mnemonic: MNEMONIC, - }, + accounts, // gasPrice: 10_000_000_000, gasMultiplier: 2, // 100% buffer; seen failures on RToken deployment and asset refreshes otherwise }, diff --git a/tasks/deployment/redeem/deploy-ethplus-to-eth.ts b/tasks/deployment/redeem/deploy-ethplus-to-eth.ts new file mode 100644 index 0000000000..1243bf95da --- /dev/null +++ b/tasks/deployment/redeem/deploy-ethplus-to-eth.ts @@ -0,0 +1,34 @@ +import { task } from 'hardhat/config' + +task( + 'deploy-redeem-ethplus', + 'Deploys the EthPlusIntoEth contract. It offers a UniswapV2-like interface to exit ETH+ that is fully decentralised' +).setAction(async (_, hre) => { + const [deployer] = await hre.ethers.getSigners() + + console.log( + `Deploying EthPlusIntoEth contract to network ${hre.network.name} with deployer account ${deployer.address}...` + ) + + // Deploy the EthPlusIntoEth contract + const EthPlusIntoEthFactory = await hre.ethers.getContractFactory('EthPlusIntoEth') + const ethPlusIntoEth = await hre.upgrades.deployProxy(EthPlusIntoEthFactory, [], { + kind: 'uups', + redeployImplementation: 'onchange', + }) + await ethPlusIntoEth.deployed() + + console.log(`Deployed EthPlusIntoEth to ${hre.network.name}: + EthPlusIntoEth: ${ethPlusIntoEth.address}`) + + /** ******************** Verify EthPlusIntoEth ****************************************/ + console.time('Verifying EthPlusIntoEth Implementation') + await hre.run('verify:verify', { + address: ethPlusIntoEth.address, + constructorArguments: [], + contract: 'contracts/redeem/EthPlusIntoEth.sol:EthPlusIntoEth', + }) + console.timeEnd('Verifying EthPlusIntoEth Implementation') + + console.log('verified') +}) diff --git a/tasks/index.ts b/tasks/index.ts index 6fba90bae8..41480aac03 100644 --- a/tasks/index.ts +++ b/tasks/index.ts @@ -21,6 +21,7 @@ import './deployment/empty-wallet' import './deployment/cancel-tx' import './deployment/deploy-spell' import './deployment/sign-msg' +import './deployment/redeem/deploy-ethplus-to-eth' import './deployment/get-addresses' import './deployment/deploy-governor-anastasius' import './deployment/deploy-timelock' diff --git a/test/rtoken-redemptions/EthPlusIntoEth.test.ts b/test/rtoken-redemptions/EthPlusIntoEth.test.ts new file mode 100644 index 0000000000..7fef53edb7 --- /dev/null +++ b/test/rtoken-redemptions/EthPlusIntoEth.test.ts @@ -0,0 +1,140 @@ +import { loadFixture, setBalance, setCode } from '@nomicfoundation/hardhat-network-helpers' +import { EthPlusIntoEth } from '@typechain/EthPlusIntoEth' +import { IERC20 } from '@typechain/IERC20' +import { formatEther, parseEther } from 'ethers/lib/utils' +import hardhat, { ethers } from 'hardhat' +import { forkRpcs, Network } from '#/utils/fork' +import { useEnv } from '#/utils/env' +import { expect } from 'chai' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +const ETH_PLUS_WHALE = '0xc5C75cAF067Ae899a7EC10b86b5aB38C13879388' + +const loader = async () => { + await hardhat.network.provider.request({ + method: 'hardhat_reset', + params: [ + { + forking: { + jsonRpcUrl: forkRpcs[useEnv('FORK_NETWORK', 'mainnet') as Network], + blockNumber: 20334000, + }, + }, + ], + }) + + await hardhat.network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [ETH_PLUS_WHALE], + }) + const signer = await ethers.getSigner(ETH_PLUS_WHALE) + await setBalance(ETH_PLUS_WHALE, parseEther('10000.0')) + const ethPlusToETHFactory = await ethers.getContractFactory('EthPlusIntoEth') + const ethPlusToETH = ( + await hardhat.upgrades.deployProxy(ethPlusToETHFactory, [], { + kind: 'uups', + }) + ).connect(signer) + + const reth = await ethers.getContractAt( + 'IERC20Metadata', + '0xE72B141DF173b999AE7c1aDcbF60Cc9833Ce56a8', + signer + ) + await (await reth.approve(ethPlusToETH.address, ethers.utils.parseEther('10000'))).wait(0) + + return { ethPlusToETH, reth, signer } +} +const runTestScenario = async ( + body: (state: { + signer: SignerWithAddress + ethPlusToETH: EthPlusIntoEth + reth: IERC20 + }) => Promise +) => { + const { ethPlusToETH, reth, signer } = await loadFixture(loader) + + await body({ + ethPlusToETH: ethPlusToETH as EthPlusIntoEth, + signer, + reth, + }) +} + +describe('EthPlusIntoEth', () => { + it('swapExactTokensForETH and getAmountsOut are consistent (enough)', async () => { + await runTestScenario(async ({ ethPlusToETH }) => { + const simuOutput = await ethPlusToETH.callStatic.getAmountsOut( + ethers.utils.parseEther('1'), + [], + { + gasLimit: 10_000_000n, + } + ) + expect(parseFloat(formatEther(simuOutput[1]))).to.be.gt(1.017) + + const ethBalBefore = await ethers.provider.getBalance(ETH_PLUS_WHALE) + + await ethPlusToETH.swapExactTokensForETH( + ethers.utils.parseEther('1'), + 0, + [], + ETH_PLUS_WHALE, + 0xffffffffffffffffn + ) + + const ethBalAfter = await ethers.provider.getBalance(ETH_PLUS_WHALE) + expect(ethBalAfter).to.be.gt(ethBalBefore) + }) + }) + + it('Handles 1000 RETH', async () => { + await runTestScenario(async ({ ethPlusToETH }) => { + const ethBalBefore = await ethers.provider.getBalance(ETH_PLUS_WHALE) + + await ethPlusToETH.swapExactTokensForETH( + ethers.utils.parseEther('1000'), + 0, + [], + ETH_PLUS_WHALE, + 0xffffffffffffffffn + ) + + const ethBalAfter = await ethers.provider.getBalance(ETH_PLUS_WHALE) + expect(parseFloat(formatEther(ethBalAfter.sub(ethBalBefore)))).to.be.gt(1017.67) + }) + }) + + it('Handles 2000 RETH', async () => { + await runTestScenario(async ({ ethPlusToETH }) => { + const ethBalBefore = await ethers.provider.getBalance(ETH_PLUS_WHALE) + await ethPlusToETH.swapExactTokensForETH( + ethers.utils.parseEther('2000'), + 0, + [], + ETH_PLUS_WHALE, + 0xffffffffffffffffn + ) + + const ethBalAfter = await ethers.provider.getBalance(ETH_PLUS_WHALE) + + expect(parseFloat(formatEther(ethBalAfter.sub(ethBalBefore)))).to.be.gt(2035.25) + }) + }) + + it('Handles 3000 RETH', async () => { + await runTestScenario(async ({ ethPlusToETH }) => { + const ethBalBefore = await ethers.provider.getBalance(ETH_PLUS_WHALE) + await ethPlusToETH.swapExactTokensForETH( + ethers.utils.parseEther('3000'), + 0, + [], + ETH_PLUS_WHALE, + 0xffffffffffffffffn + ) + + const ethBalAfter = await ethers.provider.getBalance(ETH_PLUS_WHALE) + + expect(parseFloat(formatEther(ethBalAfter.sub(ethBalBefore)))).to.be.gt(3052) + }) + }) +}) diff --git a/utils/env.ts b/utils/env.ts index 8af0009843..7ff4f7450f 100644 --- a/utils/env.ts +++ b/utils/env.ts @@ -31,6 +31,7 @@ type IEnvVars = | 'FORK_NETWORK' | 'FORK_BLOCK' | 'FORCE_WHALE_REFRESH' + | 'PRIVATE_KEY' export function useEnv(key: IEnvVars | IEnvVars[], _default = ''): string { if (typeof key === 'string') {