diff --git a/contracts/L2/ETHxPoolV5.sol b/contracts/L2/ETHxPoolV5.sol new file mode 100644 index 0000000..323b2f8 --- /dev/null +++ b/contracts/L2/ETHxPoolV5.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.22; + +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { SafeERC20, IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +/** + * @title ETHxPool Contract + * @author Stader Labs + * @notice This contract is responsible for the swap of ETHx; the user must deposit ETH in order to receive ETHx in + * return. + */ +interface AggregatorV3Interface { + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + +contract ETHxPoolV5 is AccessControlUpgradeable, ReentrancyGuardUpgradeable { + using SafeERC20 for IERC20; + + /// @notice Role hash of BRIDGER + bytes32 public constant BRIDGER_ROLE = keccak256("BRIDGER_ROLE"); + /// @notice Base rate of ETHx/ETH + uint256 public constant ETHX_BASE_RATE = 1e18; + /// @notice Address of ETHx token + IERC20 public ETHx; + /// @notice Basis points for fees + uint256 public feeBps; + /// @notice Total fees earned in swap + uint256 public feeEarnedInETH; + /// @notice Address of the ETHx/ETH oracle + address public ethxOracle; + + /// @notice Emitted when swap occured successfully + event SwapOccurred(address indexed user, uint256 ETHxAmount, uint256 fee, string referralId); + /// @notice Emitted when accumulated fees is withdrawn + event FeesWithdrawn(uint256 feeEarnedInETH); + /// @notice Emitted when deposited ETH is withdrawn + event AssetsMovedForBridging(uint256 ethBalanceMinusFees); + /// @notice Emitted when basis fee is updated + event FeeBpsSet(uint256 feeBps); + /// @notice Emitted when oracle address is updated + event OracleSet(address indexed oracle); + + /// @dev Thrown when input is zero address + error ZeroAddress(); + /// @dev Thrown when input is invalid amount + error InvalidAmount(); + /// @dev Thrown when input is invalid basis fee + error InvalidBps(); + /// @dev Thrown when transfer is failed + error TransferFailed(); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @dev Initialize the contract + /// @param _admin The admin address + /// @param _bridger The bridger address + /// @param _ethx The ETHx token address + /// @param _feeBps The fee basis points + /// @param _ethxOracle The ethxOracle address + function initialize( + address _admin, + address _bridger, + address _ethx, + uint256 _feeBps, + address _ethxOracle + ) + public + initializer + { + _checkNonZeroAddress(_ethx); + _checkNonZeroAddress(_ethxOracle); + + __AccessControl_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _setupRole(BRIDGER_ROLE, _bridger); + + ETHx = IERC20(_ethx); + feeBps = _feeBps; + ethxOracle = _ethxOracle; + } + + /// @dev Swaps ETH for ETHx + /// @param _referralId The referral id + function deposit(string memory _referralId) external payable nonReentrant { + uint256 amount = msg.value; + + if (amount == 0) revert InvalidAmount(); + + (uint256 ethxAmount, uint256 fee) = viewSwapETHxAmountAndFee(amount); + + feeEarnedInETH += fee; + + ETHx.safeTransfer(msg.sender, ethxAmount); + + emit SwapOccurred(msg.sender, ethxAmount, fee, _referralId); + } + + /// @dev view function to get the ETHx amount for a given amount of ETH + /// @param _amount The amount of ETH + /// @return ethxAmount The amount of ETHx that will be received + /// @return fee The fee that will be charged + function viewSwapETHxAmountAndFee(uint256 _amount) public view returns (uint256 ethxAmount, uint256 fee) { + fee = _amount * feeBps / 10_000; + uint256 amountAfterFee = _amount - fee; + + (, int256 ethxToEthRate,,,) = AggregatorV3Interface(ethxOracle).latestRoundData(); + + // Calculate the final ETHx amount + ethxAmount = amountAfterFee * ETHX_BASE_RATE / uint256(ethxToEthRate); + } + + /*////////////////////////////////////////////////////////////// + ACCESS RESTRICTED FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @dev Withdraws fees earned by the pool + function withdrawFees(address _receiver) external onlyRole(BRIDGER_ROLE) { + // withdraw fees in ETH + uint256 amountToSendInETH = feeEarnedInETH; + feeEarnedInETH = 0; + (bool success,) = payable(_receiver).call{ value: amountToSendInETH }(""); + if (!success) revert TransferFailed(); + + emit FeesWithdrawn(amountToSendInETH); + } + + /// @dev Withdraws assets from the contract for bridging + function moveAssetsForBridging() external onlyRole(BRIDGER_ROLE) { + // withdraw ETH - fees + uint256 ethBalanceMinusFees = address(this).balance - feeEarnedInETH; + + (bool success,) = msg.sender.call{ value: ethBalanceMinusFees }(""); + if (!success) revert TransferFailed(); + + emit AssetsMovedForBridging(ethBalanceMinusFees); + } + + /// @dev Sets the fee basis points + /// @param _feeBps The fee basis points + function setFeeBps(uint256 _feeBps) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (_feeBps > 10_000) revert InvalidBps(); + + feeBps = _feeBps; + + emit FeeBpsSet(_feeBps); + } + + /// @dev Sets the ethxOracle address + /// @param _ethxOracle The ethxOracle address + function setETHXOracle(address _ethxOracle) external onlyRole(DEFAULT_ADMIN_ROLE) { + _checkNonZeroAddress(_ethxOracle); + ethxOracle = _ethxOracle; + emit OracleSet(_ethxOracle); + } + + function _checkNonZeroAddress(address _addr) private pure { + if (_addr == address(0)) { + revert ZeroAddress(); + } + } +} diff --git a/test/L2/ETHxPoolV5.t.sol b/test/L2/ETHxPoolV5.t.sol new file mode 100644 index 0000000..c15fbb9 --- /dev/null +++ b/test/L2/ETHxPoolV5.t.sol @@ -0,0 +1,205 @@ +// SPDX_License_Identifier: UNLICENSED +pragma solidity 0.8.22; + +import { Test } from "forge-std/Test.sol"; +import { ETHxPoolV5 } from "../../contracts/L2/ETHxPoolV5.sol"; +import { AggregatorV3Interface } from "../../contracts/L2/ETHxPoolV5.sol"; +import { ERC20Mock } from "../mocks/ERC20Mock.sol"; + +contract ETHxPoolV5Test is Test { + uint256 private constant ETHX_RATE = 1 ether; + uint256 private constant FEE_BPS = 1000; + address private admin; + address private bridger; + + address private oracle; + address private ETHx; + + ETHxPoolV5 private eTHxPoolV5; + + function setUp() public { + vm.clearMockedCalls(); + admin = vm.addr(0x100); + bridger = vm.addr(0x101); + address pool = vm.addr(0x1000); + + ETHx = vm.addr(0x1001); + oracle = vm.addr(0x1004); + mockRateOracle(oracle); + mockErc20(ETHx, "ETHx"); + + eTHxPoolV5 = mockETHxPoolV5(pool, admin, bridger, ETHx, FEE_BPS, oracle); + } + + function testInitialization() public { + assertEq(address(eTHxPoolV5.ETHx()), ETHx); + assertTrue(eTHxPoolV5.hasRole(keccak256("BRIDGER_ROLE"), bridger)); + assertTrue(eTHxPoolV5.hasRole(0x0, admin)); + } + + function testInitializeDisabled() public { + eTHxPoolV5 = new ETHxPoolV5(); + vm.expectRevert("Initializable: contract is already initialized"); + eTHxPoolV5.initialize(vm.addr(0x100), vm.addr(0x101), vm.addr(0x102), 9000, vm.addr(0x1001)); + } + + function testETHxNonZeroAddressRequired() public { + address pool = vm.addr(0x1003); + mockProxyDeploy(pool); + eTHxPoolV5 = ETHxPoolV5(pool); + vm.expectRevert(ETHxPoolV5.ZeroAddress.selector); + eTHxPoolV5.initialize(vm.addr(0x100), vm.addr(0x101), vm.addr(0x102), 9000, address(0)); + } + + function testSetFeeBps() public { + vm.prank(admin); + eTHxPoolV5.setFeeBps(FEE_BPS); + assertEq(eTHxPoolV5.feeBps(), FEE_BPS); + } + + function testSetFeeInvalidBps(uint256 feeBps) public { + vm.assume(feeBps > 10_000); + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(ETHxPoolV5.InvalidBps.selector)); + eTHxPoolV5.setFeeBps(feeBps); + } + + function testSetFeeAdminRequired() public { + address nonAdmin = vm.addr(0x102); + vm.prank(nonAdmin); + vm.expectRevert( + "AccessControl: account 0x85e4e16bd367e4259537269633da9a6aa4cf95a3 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" + ); + eTHxPoolV5.setFeeBps(10_000); + } + + function testOracleAdminRequired() public { + address nonAdmin = vm.addr(0x102); + address oracle_ = vm.addr(0x103); + vm.prank(nonAdmin); + vm.expectRevert( + "AccessControl: account 0x85e4e16bd367e4259537269633da9a6aa4cf95a3 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" + ); + eTHxPoolV5.setETHXOracle(oracle_); + } + + function testOracleZeroAddressNotAllowed() public { + address oracle_ = address(0); + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(ETHxPoolV5.ZeroAddress.selector)); + eTHxPoolV5.setETHXOracle(oracle_); + } + + function testOracleSetAddress() public { + vm.prank(admin); + eTHxPoolV5.setETHXOracle(oracle); + assertEq(eTHxPoolV5.ethxOracle(), oracle); + } + + function testDepositRequiresNonZeroAmount() public { + vm.prank(admin); + vm.expectRevert(ETHxPoolV5.InvalidAmount.selector); + eTHxPoolV5.deposit{ value: 0 }("referral"); + } + + function testSwapETHForETHx(uint256 ethAmount) public { + vm.assume(ethAmount > 0.1 ether && ethAmount < 100 ether); + address user = vm.addr(0x110); + vm.deal(user, ethAmount); + vm.prank(admin); + ERC20Mock(ETHx).mint(address(eTHxPoolV5), ethAmount); + vm.prank(user); + eTHxPoolV5.deposit{ value: ethAmount }("referral"); + uint256 expectedBalance = ethAmount - (ethAmount * FEE_BPS / 10_000); + assertEq(ERC20Mock(ETHx).balanceOf(user), expectedBalance); + (uint256 _amtLessFee,) = eTHxPoolV5.viewSwapETHxAmountAndFee(ethAmount); + assertEq(ERC20Mock(ETHx).balanceOf(user), _amtLessFee); + } + + function testViewSwapETHxAmountAndFee(uint256 ethAmount) public { + vm.assume(ethAmount > 0.1 ether && ethAmount < 100 ether); + vm.prank(admin); + (uint256 _amt, uint256 _fee) = eTHxPoolV5.viewSwapETHxAmountAndFee(ethAmount); + uint256 expectFee = ethAmount * FEE_BPS / 1e4; + uint256 expectAmt = (ethAmount - expectFee) * 1e18 / ETHX_RATE; + assertEq(_fee, expectFee); + assertEq(_amt, expectAmt); + } + + function testWithdrawFeesForToken(uint256 ethAmount) public { + vm.assume(ethAmount > 0.1 ether && ethAmount < 100 ether); + address user = vm.addr(0x110); + vm.prank(admin); + ERC20Mock(ETHx).mint(address(eTHxPoolV5), ethAmount); + vm.deal(user, ethAmount); + vm.prank(user); + eTHxPoolV5.deposit{ value: ethAmount }("referral"); + uint256 expectedBalance = ethAmount - (ethAmount * FEE_BPS / 10_000); + assertEq(ERC20Mock(ETHx).balanceOf(user), expectedBalance); + (, uint256 feeAmnt) = eTHxPoolV5.viewSwapETHxAmountAndFee(ethAmount); + address _owner = vm.addr(0x111); + vm.prank(bridger); + eTHxPoolV5.withdrawFees(_owner); + assertEq(_owner.balance, feeAmnt); + } + + function testWithdrawFeesRequiresBridgerRole() public { + vm.expectRevert( + "AccessControl: account 0x7fa9385be102ac3eac297483dd6233d62b3e1496 is missing role 0xc809a7fd521f10cdc3c068621a1c61d5fd9bb3f1502a773e53811bc248d919a8" + ); + eTHxPoolV5.withdrawFees(vm.addr(0x111)); + } + + function testMoveAssetForBridging(uint256 ethAmount) public { + vm.assume(ethAmount > 0.1 ether && ethAmount < 100 ether); + vm.prank(admin); + address user = vm.addr(0x110); + vm.deal(user, ethAmount); + ERC20Mock(ETHx).mint(address(eTHxPoolV5), ethAmount); + vm.prank(user); + eTHxPoolV5.deposit{ value: ethAmount }("referral"); + uint256 feeEarned = eTHxPoolV5.feeEarnedInETH(); + vm.prank(bridger); + eTHxPoolV5.moveAssetsForBridging(); + assertEq(bridger.balance, ethAmount - feeEarned); + } + + function mockProxyDeploy(address ethxPool) private { + ETHxPoolV5 implementation = new ETHxPoolV5(); + bytes memory code = address(implementation).code; + vm.etch(ethxPool, code); + } + + function mockRateOracle(address _oracle) private returns (AggregatorV3Interface mock_) { + AggregatorV3Interface mock = AggregatorV3Interface(_oracle); + vm.mockCall( + _oracle, + abi.encodeWithSelector(AggregatorV3Interface.latestRoundData.selector), + abi.encode(0, ETHX_RATE, 0, 0, 0) + ); + return mock; + } + + function mockErc20(address ethxMock, string memory name) private { + ERC20Mock implementation = new ERC20Mock(name, name); + bytes memory code = address(implementation).code; + vm.etch(ethxMock, code); + } + + function mockETHxPoolV5( + address ethxPool, + address _admin, + address _bridger, + address _ETHx, + uint256 _feeBps, + address _ethxOracle + ) + private + returns (ETHxPoolV5 mock_) + { + mockProxyDeploy(ethxPool); + ETHxPoolV5 mock = ETHxPoolV5(ethxPool); + mock.initialize(_admin, _bridger, _ETHx, _feeBps, _ethxOracle); + return mock; + } +}