Skip to content

Commit

Permalink
feat: add ETHx pool for arbitrum native staking
Browse files Browse the repository at this point in the history
  • Loading branch information
blockgroot committed Jul 9, 2024
1 parent 6c255c4 commit 04c0697
Show file tree
Hide file tree
Showing 2 changed files with 376 additions and 0 deletions.
171 changes: 171 additions & 0 deletions contracts/L2/ETHxPoolV5.sol
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();
}
}
}
205 changes: 205 additions & 0 deletions test/L2/ETHxPoolV5.t.sol
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;
}
}

0 comments on commit 04c0697

Please sign in to comment.