Decode Ethena

Decode Ethena

Tags
Web3
Defi
StableCoin
Published
April 29, 2024
Author
Senn

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

notion image
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 the benefactor
  • sanity check on beneficiary, collateral_amount, usde_amount and expiry
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 and ratios 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

notion image
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 than mintedPerBlock 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:
  1. Staked asset consensus and execution layer rewards
  1. 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, and 10 ** _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.
notion image
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.

Reference