-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add ETHx pool for arbitrum native staking
- Loading branch information
1 parent
6c255c4
commit 04c0697
Showing
2 changed files
with
376 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} |