Introduction
Ethena is a project designed to address issues within the crypto and financial ecosystem by providing crypto-native form of money.
Contract
Name | Address |
EthenaMinting | |
StakedUSDeV2 | |
USDe | |
sUSDe | |
GnosisSafe(Yield distribution) | |
StakingRewardsDistributor | |
ENA |
Code analysis
Mint
Whitelisted account can call
EthenaMinting.mint
to provide collateral and exchange for USDe (Ethena stable coin).The basic process here is:
- User who wants USDe sends their intention to Ethena (off chain).
- Ethena check the request, and calculate corresponding USDe amount, then it will construct order
- User check the order, approves their collateral to
EthenaMinting
(on chain) and sign the order. Then user submits the order back to Ethena.
- Ethena send order to
EthenaMinting.mint
to transfer the collateral of user and mint USDe to user.
Inside
EthenaMinting.mint
:- check the caller has role
MINTER_ROLE
- check the to-mint USDe amount won’t incur excess of the max mint limitation of one block. The limitation can’t be modified, which can help prevent attack.
- check order type is
MINT
.
- verify the order is signed by
benefactor
who provides collateral.
- verify
route
’s correctness, each collateral receiver should be whitelisted and precentage sums to 100%.
- check whether the nonce has been used before, if not, then mark it used.
- transfer collateral to receivers defined in route.
- mint USDe to beneficiary.
- emit
Mint
event.
// EthenaMinting.sol enum OrderType { MINT, REDEEM } struct Order { OrderType order_type; uint256 expiry; // expiration of the order uint256 nonce; // address benefactor; address beneficiary; address collateral_asset; uint256 collateral_amount; uint256 usde_amount; } struct Route { address[] addresses; uint256[] ratios; } /// @notice max minted USDe allowed per block uint256 public maxMintPerBlock; /// @notice ensure that the already minted USDe in the actual block plus the amount to be minted is below the maxMintPerBlock var /// @param mintAmount The USDe amount to be minted modifier belowMaxMintPerBlock(uint256 mintAmount) { if (mintedPerBlock[block.number] + mintAmount > maxMintPerBlock) revert MaxMintPerBlockExceeded(); _; } /** * @notice Mint stablecoins from assets * @param order struct containing order details and confirmation from server * @param signature signature of the taker */ function mint( Order calldata order, Route calldata route, Signature calldata signature ) external override nonReentrant onlyRole(MINTER_ROLE) belowMaxMintPerBlock(order.usde_amount) { if (order.order_type != OrderType.MINT) revert InvalidOrder(); verifyOrder(order, signature); if (!verifyRoute(route)) revert InvalidRoute(); _deduplicateOrder(order.benefactor, order.nonce); // Add to the minted amount in this block mintedPerBlock[block.number] += order.usde_amount; _transferCollateral( order.collateral_amount, order.collateral_asset, order.benefactor, route.addresses, route.ratios ); usde.mint(order.beneficiary, order.usde_amount); emit Mint( msg.sender, order.benefactor, order.beneficiary, order.collateral_asset, order.collateral_amount, order.usde_amount ); }
Verify order
Inside
verifyOrder
:- useing ERC712 standard to hash order and get hash
- recover the signer using signature
- check the signer is
benefactor
or delegated by thebenefactor
- sanity check on
beneficiary
,collateral_amount
,usde_amount
andexpiry
Note the
nonce
is included in hash generation which make sure each order is bound with specific nonce
to make sure each order can only be used once.// EthenaMinting.sol /// @notice assert validity of signed order function verifyOrder( Order calldata order, Signature calldata signature ) public view override returns (bytes32 taker_order_hash) { taker_order_hash = hashOrder(order); address signer = ECDSA.recover( taker_order_hash, signature.signature_bytes ); if ( !(signer == order.benefactor || delegatedSigner[signer][order.benefactor] == DelegatedSignerStatus.ACCEPTED) ) { revert InvalidSignature(); } if (order.beneficiary == address(0)) revert InvalidAddress(); if (order.collateral_amount == 0) revert InvalidAmount(); if (order.usde_amount == 0) revert InvalidAmount(); if (block.timestamp > order.expiry) revert SignatureExpired(); } /// @notice hash an Order struct function hashOrder( Order calldata order ) public view override returns (bytes32) { return ECDSA.toTypedDataHash( getDomainSeparator(), keccak256(encodeOrder(order)) ); } function encodeOrder( Order calldata order ) public pure returns (bytes memory) { return abi.encode( ORDER_TYPE, order.order_type, order.expiry, order.nonce, order.benefactor, order.beneficiary, order.collateral_asset, order.collateral_amount, order.usde_amount ); }
Verify route
Inside
verifyRoute
:- sanity check of
addresses
andratios
length, ensure the length is not zero
- check all addresses are whitelisted. No zero address and ratio.
- ratios sum to 100%.
// EthenaMinting.sol /// @notice required ratio for route uint256 private constant ROUTE_REQUIRED_RATIO = 10_000; /// @notice assert validity of route object per type function verifyRoute( Route calldata route ) public view override returns (bool) { uint256 totalRatio = 0; if (route.addresses.length != route.ratios.length) { return false; } if (route.addresses.length == 0) { return false; } for (uint256 i = 0; i < route.addresses.length; ) { if ( !_custodianAddresses.contains(route.addresses[i]) || route.addresses[i] == address(0) || route.ratios[i] == 0 ) { return false; } totalRatio += route.ratios[i]; unchecked { ++i; } } return (totalRatio == ROUTE_REQUIRED_RATIO); }
Verify nonce
To prevent order replay attack, each order is attached with a unique nonce. In a mint operation, contract will check whether this nonce has been used before. Using a used nonce will revert the tx.
Ethena uses bitmap to save gas. Each addess (benefactor) has nonce space in mapping which are divided into slots. Each slot can record 256 nonces.
nonce
is in fact of type uint64. The leading 56 bits are used to locate slot. The last 8 bits are used to locate nonce bit in single slot.If the nonce hasn’t been used before, then mark the corresponding bit to be 1 so that this nonce can’t be used any more.
// EthenaMinting.sol /// @notice user deduplication mapping(address => mapping(uint256 => uint256)) private _orderBitmaps; /// @notice deduplication of taker order function _deduplicateOrder(address sender, uint256 nonce) private { ( uint256 invalidatorSlot, uint256 invalidator, uint256 invalidatorBit ) = verifyNonce(sender, nonce); _orderBitmaps[sender][invalidatorSlot] = invalidator | invalidatorBit; } /// @notice verify validity of nonce by checking its presence function verifyNonce( address sender, uint256 nonce ) public view override returns (uint256, uint256, uint256) { if (nonce == 0) revert InvalidNonce(); uint256 invalidatorSlot = uint64(nonce) >> 8; uint256 invalidatorBit = 1 << uint8(nonce); uint256 invalidator = _orderBitmaps[sender][invalidatorSlot]; if (invalidator & invalidatorBit != 0) revert InvalidNonce(); return (invalidatorSlot, invalidator, invalidatorBit); }
Check minted USDe limitation per block
// EthenaMinting.sol /** * @notice Mint stablecoins from assets * @param order struct containing order details and confirmation from server * @param signature signature of the taker */ function mint( Order calldata order, Route calldata route, Signature calldata signature ) external override nonReentrant onlyRole(MINTER_ROLE) belowMaxMintPerBlock(order.usde_amount) { /// ... // Add to the minted amount in this block mintedPerBlock[block.number] += order.usde_amount; /// ... }
Transfer collateral to receivers defined in route
Inside
_transferCollateral
:- check asset used as collateral is whitelisted
- calcualte asset amount and transfer to each receiver
- if there are remaining balance due to calculation precision, then transfer back to benefactor.
// EthenaMinting.sol /// @notice transfer supported asset to array of custody addresses per defined ratio function _transferCollateral( uint256 amount, address asset, address benefactor, address[] calldata addresses, uint256[] calldata ratios ) internal { // cannot mint using unsupported asset or native ETH even if it is supported for redemptions if (!_supportedAssets.contains(asset) || asset == NATIVE_TOKEN) revert UnsupportedAsset(); IERC20 token = IERC20(asset); uint256 totalTransferred = 0; for (uint256 i = 0; i < addresses.length; ) { uint256 amountToTransfer = (amount * ratios[i]) / ROUTE_REQUIRED_RATIO; token.safeTransferFrom(benefactor, addresses[i], amountToTransfer); totalTransferred += amountToTransfer; unchecked { ++i; } } uint256 remainingBalance = amount - totalTransferred; if (remainingBalance > 0) { token.safeTransferFrom( benefactor, addresses[addresses.length - 1], remainingBalance ); } }
Mint USDe to benefactor
// EthenaMinting.sol /** * @notice Mint stablecoins from assets * @param order struct containing order details and confirmation from server * @param signature signature of the taker */ function mint( Order calldata order, Route calldata route, Signature calldata signature ) external override nonReentrant onlyRole(MINTER_ROLE) belowMaxMintPerBlock(order.usde_amount) { /// ... // mint USDe to benefactor usde.mint(order.beneficiary, order.usde_amount); /// ... }
// USDe.sol function mint(address to, uint256 amount) external { if (msg.sender != minter) revert OnlyMinter(); _mint(to, amount); }
Redeem
Whitelisted account can call
EthenaMinting.redeem
to burn USDe and get collateral back.The process is similar to mint except sevel differences:
- check
redeemPerBlock
limitation rather thanmintedPerBlock
limitation
- call
USDe
to burn USDe of benefactor
- transfer collateral to
beneficiary
Note collateral is transferred from
EthenaMinting
to beneficiary. So there should be off chain process where collateral are transfered to EthenaMinting
.// EthenaMinting.sol /** * @notice Redeem stablecoins for assets * @param order struct containing order details and confirmation from server * @param signature signature of the taker */ function redeem( Order calldata order, Signature calldata signature ) external override nonReentrant onlyRole(REDEEMER_ROLE) belowMaxRedeemPerBlock(order.usde_amount) { if (order.order_type != OrderType.REDEEM) revert InvalidOrder(); verifyOrder(order, signature); _deduplicateOrder(order.benefactor, order.nonce); // Add to the redeemed amount in this block redeemedPerBlock[block.number] += order.usde_amount; usde.burnFrom(order.benefactor, order.usde_amount); _transferToBeneficiary( order.beneficiary, order.collateral_asset, order.collateral_amount ); emit Redeem( msg.sender, order.benefactor, order.beneficiary, order.collateral_asset, order.collateral_amount, order.usde_amount ); }
Stake
User calls
StakedUSDeV2.deposit
to stake USDe and gain yield. Yield originates from:
- Staked asset consensus and execution layer rewards
- Funding and basis spread earned from the delta hedging derivatives positions
StakedUSDeV2
inhertis StakedUSDe
which inherits ERC4626
to handle deposit logic.Inside the
deposit
:- user passes asset amount (
assets
) wishes to deposit.
- check deposit amount doesn’t exceed limitation.
- calculate corresponding shares. ERC4626 is built on the assumpution that asset may increase over time. So it convert asset to
shares
in the conract whose amount is static. When user deposits, it uses ratio at that time to calculate claimbale shares. When user wants to withdraw, it can calculate corrsponding asset amount based on the at that time. Note in the calcualtion of shares, it adds 1 to asset balance, and10 ** _decimalsOffset()
(1) to shares supply. This is to hanlde zero divisor problram in first deposit.
- transfer asset to self and mint corresponding shares to the caller.
// ERC4626.sol /** @dev See {IERC4626-deposit}. */ function deposit(uint256 assets, address receiver) public virtual override returns (uint256) { require(assets <= maxDeposit(receiver), "ERC4626: deposit more than max"); uint256 shares = previewDeposit(assets); _deposit(_msgSender(), receiver, assets, shares); return shares; } /** @dev See {IERC4626-maxDeposit}. */ function maxDeposit(address) public view virtual override returns (uint256) { return type(uint256).max; } /** @dev See {IERC4626-previewDeposit}. */ function previewDeposit(uint256 assets) public view virtual override returns (uint256) { return _convertToShares(assets, Math.Rounding.Down); } /** * @dev Internal conversion function (from shares to assets) with support for rounding direction. */ function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) { return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); } function _decimalsOffset() internal view virtual returns (uint8) { return 0; } /** @dev See {IERC4626-totalAssets}. */ function totalAssets() public view virtual override returns (uint256) { return _asset.balanceOf(address(this)); } // supply of shares uint256 private _totalSupply; /** * @dev See {IERC20-totalSupply}. */ function totalSupply() public view virtual override returns (uint256) { return _totalSupply; }
/** * @dev Deposit/mint common workflow. */ function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal virtual { // If _asset is ERC777, `transferFrom` can trigger a reentrancy BEFORE the transfer happens through the // `tokensToSend` hook. On the other hand, the `tokenReceived` hook, that is triggered after the transfer, // calls the vault, which is assumed not malicious. // // Conclusion: we need to do the transfer before we mint so that any reentrancy would happen before the // assets are transferred and before the shares are minted, which is a valid state. // slither-disable-next-line reentrancy-no-eth SafeERC20.safeTransferFrom(_asset, caller, address(this), assets); _mint(receiver, shares); emit Deposit(caller, receiver, assets, shares); }
Unstake
User calls
StakedUSDeV2.cooldownShares
or StakedUSDeV2.cooldownAssets
to unstake USDe. Both functions has same logic, the only different is whether use shares or assets amount to specify the asset amount.Ethena set a cooldown period, user should wait a perioud before they can actually withdraw their assets. This can help prevent MEV, if there is no cooldown, attacker can deposit right before yield distribution tx is recorded in block. And withdraw immediately to get risk free revenue repeatedly.
Inside the
cooldownShares
:- check user has enough
shares
to redeem
- calcualte corresponding assets amount
- record cooldown related information.
- withdraw assets to silo. Note that assets should be transferred out, if still left in
StakedUSDeV2
, it will effect later withdraw’s assets calculation.
// StakedUSDeV2.sol /// @notice redeem shares into assets and starts a cooldown to claim the converted underlying asset /// @param shares shares to redeem function cooldownShares( uint256 shares ) external ensureCooldownOn returns (uint256 assets) { if (shares > maxRedeem(msg.sender)) revert ExcessiveRedeemAmount(); assets = previewRedeem(shares); cooldowns[msg.sender].cooldownEnd = uint104(block.timestamp) + cooldownDuration; cooldowns[msg.sender].underlyingAmount += uint152(assets); _withdraw(msg.sender, address(silo), msg.sender, assets, shares); }
// ERC4626.sol /** @dev See {IERC4626-maxRedeem}. */ function maxRedeem(address owner) public view virtual override returns (uint256) { return balanceOf(owner); } /** @dev See {IERC4626-previewRedeem}. */ function previewRedeem(uint256 shares) public view virtual override returns (uint256) { return _convertToAssets(shares, Math.Rounding.Down); } /** * @dev Internal conversion function (from shares to assets) with support for rounding direction. */ function _convertToAssets(uint256 shares, Math.Rounding rounding) internal view virtual returns (uint256) { return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); } /** * @dev Withdraw/redeem common workflow. */ function _withdraw( address caller, address receiver, address owner, uint256 assets, uint256 shares ) internal virtual { if (caller != owner) { _spendAllowance(owner, caller, shares); } // If _asset is ERC777, `transfer` can trigger a reentrancy AFTER the transfer happens through the // `tokensReceived` hook. On the other hand, the `tokensToSend` hook, that is triggered before the transfer, // calls the vault, which is assumed not malicious. // // Conclusion: we need to do the transfer after the burn so that any reentrancy would happen after the // shares are burned and after the assets are transferred, which is a valid state. _burn(owner, shares); SafeERC20.safeTransfer(_asset, receiver, assets); emit Withdraw(caller, receiver, owner, assets, shares); }
Claim
User call
StakedUSDeV2.unstake
to claim USDe back.Inside:
- check cooldown period has been satisifed.
- call soli to withdraw assets to receiver.
// StakedUSDeV2.sol /// @notice Claim the staking amount after the cooldown has finished. The address can only retire the full amount of assets. /// @dev unstake can be called after cooldown have been set to 0, to let accounts to be able to claim remaining assets locked at Silo /// @param receiver Address to send the assets by the staker function unstake(address receiver) external { UserCooldown storage userCooldown = cooldowns[msg.sender]; uint256 assets = userCooldown.underlyingAmount; if ( block.timestamp >= userCooldown.cooldownEnd || cooldownDuration == 0 ) { userCooldown.cooldownEnd = 0; userCooldown.underlyingAmount = 0; silo.withdraw(receiver, assets); } else { revert InvalidCooldown(); } }
// USDeSilo.sol modifier onlyStakingVault() { if (msg.sender != _STAKING_VAULT) revert OnlyStakingVault(); _; } function withdraw(address to, uint256 amount) external onlyStakingVault { _USDE.transfer(to, amount); }
Yield Distribution
Ethena uses a safe wallet to send USDe to
StakedUSDeV2
which will be shared by all stakers.USDe
USDe
is standard ERC20.ENA
ENA
is standard ERC20.Conclusion
Ethena proposes a new way to provide stable coin. But from the on chain contracts, current design is quite centralized depends on centralized exchange and ethena itself.