diff --git a/.changeset/eleven-dots-deliver.md b/.changeset/eleven-dots-deliver.md new file mode 100644 index 000000000..a701c402a --- /dev/null +++ b/.changeset/eleven-dots-deliver.md @@ -0,0 +1,5 @@ +--- +"@exactly/protocol": patch +--- + +✨ swapper: swap `ETH` for `EXA` on velodrome diff --git a/.gas-snapshot b/.gas-snapshot index 39c64c6d8..bfa403017 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -411,4 +411,9 @@ RewardsControllerTest:testUpdateWithTotalDebtZeroShouldUpdateLastUndistributed() RewardsControllerTest:testUtilizationEqualZero() (gas: 622308) RewardsControllerTest:testWithTwelveFixedPools() (gas: 3989456) RewardsControllerTest:testWithdrawAllRewardBalance() (gas: 47146) -RewardsControllerTest:testWithdrawOnlyAdminRole() (gas: 84719) \ No newline at end of file +RewardsControllerTest:testWithdrawOnlyAdminRole() (gas: 84719) +SwapperTest:testSwapBasic() (gas: 187376) +SwapperTest:testSwapWithInaccurateSlippageSendsETHToAccount() (gas: 207789) +SwapperTest:testSwapWithKeepAmount() (gas: 194032) +SwapperTest:testSwapWithKeepEqualToValue() (gas: 79362) +SwapperTest:testSwapWithKeepHigherThanValue() (gas: 34589) \ No newline at end of file diff --git a/contracts/periphery/Swapper.sol b/contracts/periphery/Swapper.sol new file mode 100644 index 000000000..6b5b5bc8c --- /dev/null +++ b/contracts/periphery/Swapper.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import { WETH, ERC20 } from "solmate/src/tokens/WETH.sol"; +import { SafeTransferLib } from "solmate/src/utils/SafeTransferLib.sol"; + +contract Swapper { + using SafeTransferLib for address payable; + using SafeTransferLib for WETH; + + /// @notice The EXA asset. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + ERC20 public immutable exa; + /// @notice The WETH asset. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + WETH public immutable weth; + /// @notice The liquidity pool. + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + IPool public immutable pool; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor(ERC20 exa_, WETH weth_, IPool pool_) { + exa = exa_; + weth = weth_; + pool = pool_; + } + + /// @notice Swaps `msg.value` ETH for EXA and sends it to `account`. + /// @param account The account to send the EXA to. + /// @param minEXA The minimum amount of EXA to receive. + /// @param keepETH The amount of ETH to send to `account` (ex: for gas). + function swap(address payable account, uint256 minEXA, uint256 keepETH) external payable { + if (keepETH > msg.value) return account.safeTransferETH(msg.value); + + uint256 inETH = msg.value - keepETH; + uint256 outEXA = pool.getAmountOut(inETH, weth); + if (outEXA < minEXA) return account.safeTransferETH(msg.value); + + weth.deposit{ value: inETH }(); + weth.safeTransfer(address(pool), inETH); + + (uint256 amount0Out, uint256 amount1Out) = address(exa) < address(weth) + ? (outEXA, uint256(0)) + : (uint256(0), outEXA); + try pool.swap(amount0Out, amount1Out, account, "") { + account.safeTransferETH(keepETH); + } catch { + account.safeTransferETH(msg.value); + } + } +} + +interface IPool { + function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external; + + function getAmountOut(uint256 amountIn, WETH tokenIn) external view returns (uint256); +} diff --git a/test/solidity/Swapper.t.sol b/test/solidity/Swapper.t.sol new file mode 100644 index 000000000..6d22a85af --- /dev/null +++ b/test/solidity/Swapper.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import { ForkTest } from "./Fork.t.sol"; +import { FixedPointMathLib } from "solmate/src/utils/FixedPointMathLib.sol"; +import { Swapper, IPool, ERC20, WETH } from "../../contracts/periphery/Swapper.sol"; + +contract SwapperTest is ForkTest { + using FixedPointMathLib for uint256; + + ERC20 internal exa; + WETH internal weth; + IPool internal pool; + Swapper internal swapper; + + function setUp() external { + vm.createSelectFork(vm.envString("OPTIMISM_NODE"), 107_385_046); + + exa = ERC20(deployment("EXA")); + weth = WETH(payable(deployment("WETH"))); + pool = IPool(deployment("EXAPool")); + swapper = new Swapper(exa, weth, pool); + + deal(address(weth), address(this), 500 ether); + } + + function testSwapBasic() external _checkBalance { + uint256 balanceETH = address(this).balance; + uint256 amountEXA = pool.getAmountOut(1 ether, weth); + swapper.swap{ value: 1 ether }(payable(address(this)), 0, 0); + + assertEq(address(this).balance, balanceETH - 1 ether, "eth spent"); + assertEq(exa.balanceOf(address(this)), amountEXA, "exa received"); + } + + function testSwapWithKeepAmount() external _checkBalance { + uint256 balanceETH = address(this).balance; + uint256 amountEXA = pool.getAmountOut(0.9 ether, weth); + swapper.swap{ value: 1 ether }(payable(address(this)), 0, 0.1 ether); + + assertEq(address(this).balance, balanceETH - 0.9 ether, "eth spent"); + assertEq(exa.balanceOf(address(this)), amountEXA, "exa received"); + } + + function testSwapWithKeepEqualToValue() external _checkBalance { + uint256 balanceETH = address(this).balance; + swapper.swap{ value: 1 ether }(payable(address(this)), 0, 1 ether); + + assertEq(address(this).balance, balanceETH, "eth spent"); + assertEq(exa.balanceOf(address(this)), 0, "exa received"); + } + + function testSwapWithKeepHigherThanValue() external _checkBalance { + uint256 balanceETH = address(this).balance; + swapper.swap{ value: 1 ether }(payable(address(this)), 0, 2 ether); + + assertEq(address(this).balance, balanceETH, "eth spent"); + assertEq(exa.balanceOf(address(this)), 0, "exa received"); + } + + function testSwapWithInaccurateSlippageSendsETHToAccount() external _checkBalance { + uint256 balanceETH = address(this).balance; + uint256 amountEXA = pool.getAmountOut(1 ether, weth); + + swapper.swap{ value: 1 ether }(payable(address(this)), amountEXA * 5, 0); + assertEq(address(this).balance, balanceETH, "eth spent"); + assertEq(exa.balanceOf(address(this)), 0, "exa received"); + + swapper.swap{ value: 1 ether }(payable(address(this)), amountEXA - 10e18, 0); + assertEq(address(this).balance, balanceETH - 1 ether, "eth spent"); + assertEq(exa.balanceOf(address(this)), amountEXA, "exa received"); + } + + modifier _checkBalance() { + _; + assertEq(address(swapper).balance, 0); + } + + // solhint-disable-next-line no-empty-blocks + receive() external payable {} +}