ezETH (Renzo) technical analysis
Summary
This is a technical analysis of all the smart contracts of the ezETH token and its main dependencies.
Disclosure: This is not an exhaustive security review of the asset like the ones done by the Renzo team, but an analysis from an Aave technical service provider on different aspects we consider critical to review before a new type of listing. Consequently, like with any security review, this is not an absolute statement that the asset is flawless, only that, in our opinion, we don’t see significant problems with its integration with Aave, apart from different trust points.
Analysis
ezETH is an LRT reward-bearing token, exchange-rate based, where users can deposit native ETH and LSTs (currently stETH) in exchange for ezETH.
ezETH’s surrounding system handles operations that enable users to deposit their ETH and LSTs. These assets are then staked into the EigenLayer ecosystem, and used to earn rewards from staking and restaking.
In return, users receive ezETH tokens representing their share of staked assets and rewards, that can later on be redeemed by requesting a withdrawal, waiting for the unstake period, and doing a claim.
For the context of this analysis, our focus has been on the following aspects, critical for the correct and secure integration with Aave:
- Mechanism to update the exchange rate of the asset for the underlying ETH.
- Access control (ownerships, admin roles) and nature of the entities involved.
- Any miscellaneous aspect of the code we can consider of importance.
General points
- The upgradeability admin of all systems is an OZ Timelock, which has a 3-day time lock and is controlled by a 3-of-5 Gnosis Safe.
- The system’s default admin role is held by the Timelock.
- For proxies, it uses an OZ transparent proxy pattern.
- The system has a proper hierarchical organization of permissions, with the most critical being controlled by the default admin role (given to the Timelock and the 3-of-5 Safe) and others with less power and different protections surrounding them.
- The current validators are Renzo’s partners Figment and P2P.org, which runs institutional-grade nodes on Ethereum and EigenLayer.
Contracts
The following is a non-exhaustive overview of the main smart contracts involved with ezETH.
Role Manager
The system’s main access controller, keeping track of all the roles and permissions: each role gives specific permissions to perform crucial actions within the system.
This contract has functions to check if a given address has a specific role, and the system’s contracts use them to ensure an address is allowed to perform certain operations. The Role Manager it’s an OZ Transparent Proxy with the OZ Access Control pattern, and upgradeability by the general Timelock.
- The
DEFAULT_ADMIN_ROLE
role, which has permissions to grant and remove all the roles, is held by the Timelock. - Currently, the contract contains 13 different roles, most of which are held by the 3-of-5 Gnosis Safe. Each role will have a description in the corresponding contract of the system where it is utilized.
- In the worst scenario, if the
DEFAULT_ADMIN_ROLE
is compromised, the system could have serious problems, allowing the controller to change the behavior of the flow of underlying funds.
ezETH
The ezETH token represents participation within the system and allows authorized addresses to mint and burn tokens. It is an ERC20 OZ Transparent Proxy contract with upgradeability by the general Timelock.
- The
mint()
andburn()
are permissioned functions that can only be called by the owners of theRX_ETH_MINTER_BURNER
role. At the present of this analysis, the Restake Manager contract and the Withdraw Queue contract hold this role. The Restake Manager acts as the minter, while the Withdraw Queue acts as the burner. - In a scenario where the
RX_ETH_MINTER_BURNER
role gets compromised, an unlimited number of ezETH tokens can be minted and sent to any address.
Restake Manager
The Restake Manager contract is the main entry point for depositing native ETH and LSTs in exchange for ezETH. This contract serves as the core interaction between users and the staking within the EigenLayer by managing how funds should be allocated across different Operator Delegators. It also manages the basket of LSTs and their limits that can be deposited into the system. The Restake Manager is a Transparent Proxy contract with upgradeability by the general Timelock.
Access control
- Restake Manager Admin: Safe 3-5 (0xD1e6…B68A), TimeLock
- Deposit Withdraw Pauser Admin: Safe 3-5 (0xD1e6…B68A)
- Deposit Queue: Smart Contract.
Deposits and exchange rate:
- Users can deposit native ETH through the
depositETH()
function, while LSTs are deposited via thedeposit(token, amount)
function. The ETH amount is sent to the Deposit Queue Contract to be staked later. For the LST, if there is any withdrawal deficit (the amount to cover withdrawal requests), the deficit is subtracted from the amount and is transferred to the Deposit Queue contract via thefillERC20withdrawBuffer(token, amount)
function. - For LSTs, it first checks if it is needed to fill any withdrawal deficit buffer and sends it to the Deposit Queue contract via the
fillERC20withdrawBuffer(token, amount)
function, which is sent to the Withdraw Queue contract afterward. Any remaining amount in the Restake Manager is sent to an Operator Delegator Contract chosen by calling thechooseOperatorDelegatorForDeposit()
function and deposit via theoperator.deposit(token, amount)
function. - The exchange rate can be obtained by calling the
calculateMintAmount(TVL, amount, totalSupply)
function in the Renzo Oracle contract and passing the protocol’s total value deposited, the amount value to be minted, and the total supply of ezETH. The formula utilized ismintAmount = (totalSupply * amount) / TVL
. - The total value deposited for the protocol (across all Operator Delegators contracts, Deposit Queue, and Withdrawal Queue contracts) is calculated by calling the
calculateTVLs()
function, which internally calls the Renzo Oracle to obtain the prices of each asset. The Operator Delegators use internal accounting for LSTs to safeguard against potential donations. However, the total supply of ezETH is fetched directly viaezETH.totalSupply()
, and the Withdraw Queue fetches its balance by callinglst.balanceOf(withdrawQueue)
, which may lead to donation of LSTs into the system, manipulating the exchange rate.
Tokens list and Capacity
- Currently, one LSTs (stETH) can be deposited in the system.
- The Restake Manager Admin can add new tokens by calling the
addCollateralToken(token)
function. This function internally checks whether the token is already listed and whether it has 18 decimals before adding it. - The Restake Manager Admin can also remove a token from the list by calling the
removeCollateralToken()
function. Before removing it, ensure that no TVL is left across the contracts in the system. - For Native ETH, no cap limits are defined. For LSTs, caps are checked internally in the
deposit(token, amount)
function. - The caps for LSTs are set via the
setTokenTvlLimit(token, limit)
function, which is called only by the Restake Manager Admin.
Validators and flow of funds
- When the minimum 32 ETH is reached, the ETH is staked in the Beacon Chain via the
stakeEthInOperatorDelegator(delegator)
function called by the Deposit Queue contract. The ETH is sent to the chosen Operator Delegator contract, which will stake via the Eigen Pod Manager (Eigen Layer Contract), allowing for both stake and Eigen Layer AVS rewards to be earned. - LSTs are deposited via the
depositTokenRewardsFromProtocol()
function only called by the Deposit Queue contract. Internally, the Restake Manager contract will pick one Operator Delegator contract via thechooseOperatorDelegatorForDeposit()
and then deposit it by calling thedeposit(token, amount)
function. - The Restake Manager Admin can add new Operator Delegator contracts via the
addOperatorDelegator(operator)
function. Before pushing it to the Operators list, it first checks if the operator’s address is not in the system if it has a valid percentage, and if the delegation address is set. - The Restake Manager Admin can remove Operator Delegator contracts via the
removeOperatorDelegator(operator)
function. Before removing it from the Operators list, it checks if the Operator still has the value locked within. - The Restake Manager Admin can set the allocation amount for each Operator Delegator by calling the
setOperatorDelegatorAllocation()
function. Before updating the allocation, it’s checked whether the new allocation percentage is valid and doesn’t exceed the maximum percentage value.
Emergency Pause
- The Deposit Withdraw Pauser Admin can pause/unpause all deposits into the system by calling the
setPaused()
function.
Renzo Oracle
The Renzo Oracle contract is responsible for fetching listed asset prices via ChainLink feeds using ETH as the reference. It works by retrieving reliable prices of LSTs used by the Restake Manager to calculate the TVL across all Operator Delegators and the Withdraw Queue contract. It also aids the Restake Manager in calculating the mint amount, and helps the Withdraw Queue in determining the amount to be redeemed.
The Renzo Oracle is a Transparent Proxy with upgradeability by the general Timelock.
- The Oracle Admin (Safe 3-of-5 0xD1e6…B68A) can add new feeds by calling the
setOracleAddress(token, oracle)
function. This function only ensures that the token is not an invalid address and that the Oracle address has 18 decimals. - Before returning prices, the system checks if the price is greater than zero and the price’s time update window is within 24 hours + 60 seconds (via the
MAX_TIME_WINDOW
storage variable). - The Restake Manager uses the
lookupTokenValue(token, balance)
function to calculate the total value of each token in the system. This function returns the value in 18 decimals, as Restake Manager only allows tokens with 18 decimals to be included in the assets list. - The Restake Manager fetches the mint amount from the
calculateMintAmount(TVL, amount, totalSupply)
helper function. This function uses the formulamintAmount = (totalSupply * amount) / TVL
and reverts ifmintAmount = 0
. - The Withdraw Queue fetches the ETH redeemed amount value from the
calculateRedeemAmount(amount, totalSupply, TVL)
helper function. This function uses the formularedeemAmountValue = (TVL * amount) / totalSupply
and reverts ifredeemAmountValue = 0
. If the redeemed token is an LST, the Withdraw Queue will call thelookupTokenAmountFromValue(token, redeemAmountValue)
function, passing the redeem amount value to obtain the asset amount.
Deposit Queue
The Deposit Queue is the contract designed to manage the flow of assets in the basket, in and out of the system. It acts as the middleman that handles funds deposited via the Restake Manager, manages the withdrawal buffer, and coordinates with the Restake Manager and Operator Delegators for the staking into the Beacon Chain and the Eigen Layer.
The Deposit Queue contract is a Transparent Proxy that can be upgraded by the general Timelock.
- The Restake Manager Admin can change the Restake Manager and Withdraw Queue contracts by respectfully calling
setRestakeManager(address)
andsetWithdrawQueue(address)
. - The Restake Manager Admin can change the fees earned by the system by calling the
setFeeConfig(receiver, fee)
. It’s defined in basis points and currently is set to 10% in thefeeBasisPoints
storage variable. - The Restake Manager contract sent ETH deposits by calling the
depositETHFromProtocol()
function. This function checks whether the Withdraw Queue has a deficit, which is subtracted from the amount sent to the Withdraw Queue contract. The remaining amount is kept in the Deposit Queue contract and will be staked later. In the case of an LST, it sends by calling thefillERC20withdrawBuffer(token, amount)
function, which redirects the amount to the Withdraw Queue contract. - The Operator Delegators contracts sent ETH unstaked by calling the
forwardFullWithdrawalETH()
function. This function behaves like thedepositETHFromProtocol()
function explained above. In the case of an LST, the Operator Delegator sends via thefillERC20withdrawBuffer(token, amount)
function. - The Native ETH Restake Admin (EOA 0x3f77…d4e8, EOA 0x64E9…baE6) initiates the ETH stake in the Beacon Chain by calling the
stakeEthFromQueue(operator)
function. This function calls thestakeEthInOperatorDelegator(operator)
function in the Restake Manager Contract, sending 32 ETH through the call. It can also stake in multiple validators by calling thestakeEthFromQueueMulti(operators)
function. - This contract also receives rewards from the Execution Layer and MEV from native staking via the
receive()
function. It internally takes the protocol fee from the amount sent and sends any deficit existing in the Withdraw Queue Contract. Any remaining amount is kept in the Contract and will be staked later. A reentrant guard modifier protects thereceive()
function. - The ERC20 Rewards Admin (currently not set) can deposit any accumulated amount of LSTs in the Deposit Queue Contract to the Restake Manager by calling the
sweepERC20(token)
function, which will internally calldeposit(token, amount)
in an Operator Delegator contract to be sent to EigenLayer.
Withdraw Queue
The Withdraw Queue contract serves as the exit point for users to redeem their underlying tokens, operating on a first-come, first-served basis. This contract manages the withdrawal process by queueing users’ requests, ensuring that enough liquidity is available until each user’s turn to claim it after the cooldown period elapses. During the claim process, after all conditions are met, the ezETH is burned and the underlying asset is sent to the user.
The Withdraw Queue contract is a Transparent Proxy contract with the upgradeability by the general Timelock.
General admin permissions
- The Withdraw Queue Admin (Timelock, Safe 3-of-5 0xD1e6…B68A) can set the withdraw buffer amount of assets by calling the
updateWithdrawBufferTarget()
function. Before updating the amounts, it checks internally whether the asset address is valid and if the buffer amount is not zero. - The Withdraw Queue Admin can update the cooldown period of the withdraw requests using the
updateCoolDownPeriod()
function. The current value in thecooldownPeriod
storage variable is three days. - The Deposit Withdraw Pauser Admin (Safe 3-of-5 0xD1e6…B68A) can pause/unpause all withdrawals from the system by calling the
pause()
andunpause()
functions, respectively.
Withdrawals and exchange rate
- Users can withdraw ETH or LST by requesting via the
withdraw(amount, asset)
function. This function internally calls thecalculateRedeemAmount(amount, totalSupply, TVL)
in the Renzo Oracle contract, which returns the amount in ETH value. If the asset requested is an LST, it calls thelookupTokenAmountFromValue(token, redeemAmountValue)
function to get the amount of the corresponding asset. The ezETH is locked in the contract and will be burned later. - The exchange rate is calculated in the
calculateRedeemAmount(amount, totalSupply, TVL)
function with the formularedeemAmountValue = (TVL * amount) / totalSupply
. This returns the value of the redeemed amount in ETH. ThelookupTokenAmountFromValue(token, redeemAmountValue)
uses the formulatokenAmount = redeemAmountValue / tokenPrice
to obtain the amount in the unit of the token. - The window for completing the withdrawal can vary from 3 to 10 days. This timeframe depends on whether the requested amount is already available in the WithdrawQueue or needs to be unstake from the Beacon Chain and Eigen Layer. If the amount is available, the request can completed in 3 days. Otherwise, the amount is queued to be unstake, which may take up to 10 days. Unstake from the Ethereum Beacon Chain can vary from a day to 9 days (Exit Queue + Sweep Delay), and the Eigen Layer requires seven days before withdrawal.
- Users complete the withdrawal by calling the
claim()
function. Internally, it recalculates the redeemed amount by callingcalculateRedeemAmount(amount, totalSupply, TVL)
and updates the withdrawal to the lower value of the two amounts: the initially requested amount in thewithdraw()
function or the recalculated amount in theclaim()
step. Finally, the ezETH amount is burned, and the underlying tokens are sent to the user’s address.
Flow of funds
- The unstaked tokens are sent to an Operator Delegator contract, which sends them to the Deposit Queue Contract to fill the Withdraw Queue contract by calling
fillEthWithdrawBuffer()
orfillERC20WithdrawBuffer(token)
for LSTs.
Operator Delegator
The Operator Delegator contract manages the stake of ETH in the Beacon Chain and the deposit of LSTs in Eigen Layer strategies. This contract is responsible for staking and delegating the system’s assets to operators within EigenLayer, managing shares, and requesting and claiming withdrawals when needed.
The Operator Delegator is a Transparent Proxy with upgradeability by the general Timelock.
General admin permissions
- The Operator Delegator Admin (Safe 3-5 0xD1e6…B68A, TimeLock) can set the strategy manager from EigenLayer for a given token by calling the
setTokenStrategy(token, strategy)
. - The Operator Delegator Admin can set the Eigen Layer Operator of the tokens by calling the
setDelegateAddress()
function. Internally, it calls the Delegation Manager and delegates to an Operator, and it can be set only once. - The Operator Delegator Admin can set the Rewards Coordinator (EigenLayer contract to claim rewards) and the Rewards Destination address (from where to send the rewards) by calling the
setRewardsCoordinator(address)
and thesetRewardsDestination(address)
, respectively.
Flow of funds
- Once the Deposit Queue reaches 32 ETH, the Native ETH Restake Admin can call the
stakeEthFromQueue(delegator)
function in the Deposit Queue contract. This will send the funds to the Restake Manager contract using thestakeEthInOperatorDelegator(delegator)
function, which in turn calls thestakeEth()
function in the chosen Operator Delegator contract. This internally triggers thestake()
function in the Eigen Pod Manager, and the ETH is staked. - LSTs are deposited into EigenLayer when users deposit them in the Restake Manager. Internally, it first fills any withdraw buffer in the Withdraw Queue contract and then calls the
deposit(token, amount)
. The deposits can also be triggered via the Rewards Admin by calling thesweepERC20(token)
in the Deposit Queue contract. This will send the Deposit Queue token balance minus protocol fees to the Restake Manager via thedepositTokenRewardsFromProtocol(token)
function, which calls thedeposit(token, amount)
in the chosen Operator Delegator contract. The Operator Delegator contract will deposit his token balance via the EigenLayer strategy Manager. - When the users’ ETH is staked in the Beacon Chain, the withdrawal credentials are delegated to the EigenPod in EigenLayer. With that, users earn staking rewards and rewards from their contribution to securing AVSs in EigenLayer.
- To unstake ETH and withdraw LSTs from EigenLayer, the Native ETH Restake Admin should call the
queueWithdrawals(tokens)
function. This will trigger a withdrawal request from the Beacon Chain/Strategy Manager by calling thequeueWithdrawals(tokens)
in the Delegation Manager contract. Internally, the Operator Delegator is set as the receiver of the unstaked tokens, and shares of the withdrawn amount are incremented to aqueuedShares[token]
in the storage. - The Native ETH Restake Admin finalizes the withdrawal request by calling the
completeQueuedWithdrawal(tokens)
function, which calls the same function name in the Delegation Manager contract. The shares are discounted from thequeuedShares[token]
, and the Operator Delegator fills the withdraw buffer if necessary. Any remaining token balance is restaked via the internal_deposit(token, amount)
function. - The
receive()
function handles ETH unstaked from the Beacon Chain and protocol rewards. If the sender is the Eigen Pod contract, this function understands the value as ETH unstaked and sends it to the Deposit Queue contract by calling theforwardFullWithdrawalETH()
function. For any other address that sends ETH, the function will consider the value as a protocol reward and transfer it to the Deposit Queue contract via acall()
function, not limiting the reward amount for ETH. - The
receive()
function handles ETH unstaked from the Beacon Chain and protocol rewards. If the sender is the Eigen Pod contract, the function sends the ETH to the Deposit Queue contract via theforwardFullWithdrawalETH()
function. For any other address that sends ETH, the function considers it a protocol reward and transfers it to the Deposit Queue contract via acall()
function. - The Native Restake Admin can withdraw and send to any address any ETH in the Eigen Pod that is not staked in the Beacon Chain by calling the
withdrawNonBeaconChainETHBalanceWei(address, amount)
function. It can also rescue and send any ERC20 token accidentally sent to the Eigen Pod to any address by calling therecoverTokens(tokens)
.
Asset Pricing
After reviewing the system from a technical perspective (e.g. how the exchange rate is calculated), and the planned configurations, our suggestion for pricing is using a CAPO ezETH/ETH (exchange rate, not secondary market) + ETH/USD feed.
During the analysis, we identified that the exchange rate of ezETH can be inflated due to the accounting of stETH being queried directly via the balanceOf
function. However, the potential for profitable manipulation through donations is effectively minimized and useless, given the following points:
- As the listing of ezETH will happen after Aave v3.2, the main use case of the asset will be as collateral on an ezETH/wstETH eMode, or as collateral to borrow other assets is other eModes or non-eMode. Not being borrowable stops any potential attack.
- Donating stETH to artificially increase the price of ezETH and borrow more wstETH is not economically viable on the base layer (Renzo). This is because donating stETH represents a real cost and a permanent loss. The attacker wouldn’t receive any ezETH, and the potential benefit gained from being able to borrow more wstETH due to an increase in the collateral’s value is unlikely to offset the cost of donated stETH. And still, the non-borrowable configuration on Aave supersedes this consideration.
- Even if the price of ezETH reaches the maximum growth ratio (
maxYearlyRatioGrowthPercent
) in the CAPO oracle, creating arbitrage opportunities on the borrowing side, this behavior is short-lived and self-correcting, as withdrawals will quickly deplete the ezETH supply to sell it on the market at the inflated price.
Miscellaneous aspects
- The system has four technical audit reviews by Halborn (1, 2), SigmaPrime, and Code4rena.
- The minimum proposal delay of 3 days for upgradability is acceptable, but we recommend evaluating an extension, allowing more time to verify the safety of the new implementations.
- Even if we don’t see any immediate threat on it, the system’s architecture pattern may lead to complexities within the system, and if not correctly managed during upgrades, unexpected behaviors can occur in the storage layout.
- Regarding the Oracle contract, we notice a need for sanity verifications when adding a new oracle address, like compatibility with the ChainLink interface and whether the price is valid by calling
Oracle.latestRoundData()
, which is used by the system afterward. Still, from our perspective it is a pre-requirement of listing to Aave having a compromise to not list more assets without important time in advance, and we recommend to move to the Timelock the admin role of the Restake Manager.
Conclusion
We think ezETH doesn’t have any problem in terms of integration with Aave, and there is no major blocker for listing.
That being said, given the anticipated use cases of the asset, we highly encourage to wait until the Aave v3.2 release for listing.
Additionally, we recommend the Renzo team to consider the aforementioned minor considerations
Special thanks for the Renzo team to always support on all our inquiries about their system.