Code4Arena Contest - Renzo Oracle
This article presents the oracle implementation from Renzo. This analyze has been done for the Code4Arena contest.
The results from code4Arena are available here: Renzo Mitigation Review
Since I have a limited time, I found that it could be interesting to focus only in one part in the oracle implementation instead of the whole code.
Description
- Renzo is a Liquid Restaking Token (LRT) and Strategy Manager for EigenLayer allowing user to restake their eth or LST.
For every LST or ETH deposited on Renzo, it mints an equivalent amount of $ezETH.
- ezETH is the liquid restaking token representing a user’s restaked position at Renzo.
Reference: docs.renzoprotocol.com/docs/renzo/ezeth
Links
-
Previous Audits - Halborn.
- Documentation
- oracle implementation
- Analyser report
[TOC]
Past accident
On April 24th, there was a momentary depeg involving Renzo’s ezETH/WETH pair on multiple DEXs set off a chain reaction of liquidations across different protocols.
The most likely cause suggests that several individuals, with substantial positions, that were leverage farming opted to close their positions following the Renzo announcement of token allocation.
That provoqued a cascading series of liquidations on both Morpho and Gearbox protocols.
Reference: Daedalus - Renzo
Chainlink Integration
Renzo uses the oracle from chainlink, version AggregatorV3Interface
More information here
Reminder
The function latestRoundData()from Chainlink oracle allows to get the data from the latest round.
function latestRoundData() external view
returns (
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
)
The returned values requiresto be validated by the smart contract, notably
-
price >= 0
-
updatedAt!= 0 and is not too old -
answeredInRound is now deprecated and I don’t think it needs to be checked
Reference: Tigran Piliposyan
Schema
Smart contracts UML

Functions
The interface IRenzoOracle defines four functions
- lookupTokenValue
- lookupTokenAmountFromValue
- lookupTokenValues
- calculateMintAmount
- calculateRedeemAmount
setOracleAddress
Description
Sets addresses for oracle lookup. Permission gated to the oracle admin only.
The sender can put the address 0 for AggregatorV3Interfaceto disable lookups for the token.
Code
function setOracleAddress(
IERC20 _token,
AggregatorV3Interface _oracleAddress
) external nonReentrant onlyOracleAdmin {
if (address(_token) == address(0x0)) revert InvalidZeroInput();
// Verify that the pricing of the oracle is 18 decimals - pricing calculations will be off otherwise
if (_oracleAddress.decimals() != 18)
revert InvalidTokenDecimals(18, _oracleAddress.decimals());
tokenOracleLookup[_token] = _oracleAddress;
emit OracleAddressUpdated(_token, _oracleAddress);
}
Analyze
| Function | |
|---|---|
| Access control | Yes with the modifier onlyOracleAdmin |
| Token can not be a zero address | yes, revert with InvalidZeroInput |
| Oracle address can not be a zero address | No, reason indicated in the comment |
| Check token decimals | yes, decimals have to be 18 |
INFO
- Why using the
nonReentrantmodifier ? The function is protected by access control and can not be reentrant. - Use a constant instead a magic value (18) as indicated by the analyzer report
Check oracle value
Description
The functions lookupTokenValue and lookupTokenAmountFromValuecalls both the function latestRoundData from chainlink, AggregatorV3Interface.
- lookupTokenAmountFromValue
Given a single token and value, return amount of tokens needed to represent that value
- lookupTokenValues:
Given list of tokens and balances, return total value (assumes all lookups are denomintated in same underlying currency)
Code
/// @dev The maxmimum staleness allowed for a price feed from chainlink
uint256 constant MAX_TIME_WINDOW = 86400 + 60; // 24 hours + 60 seconds
(, int256 price, , uint256 timestamp, ) = oracle.latestRoundData();
if (timestamp < block.timestamp - MAX_TIME_WINDOW) revert OraclePriceExpired();
if (price <= 0) revert InvalidOraclePrice();
INFO
An unique function checkOracle(uint256 price, uint256 timestamp) could be implemented instead of having the same check implemented twice.
lookupTokenValues
Description
Batch version of lookupTokenValue
Given list of tokens and balances, return total value (assumes all lookups are denomintated in same underlying currency).
The value returned will be denominated in the decimal precision of the lookup oracle (e.g. a value of 100 would return as 100 * 10^18)
Code
function lookupTokenValues(
IERC20[] memory _tokens,
uint256[] memory _balances
) external view returns (uint256) {
if (_tokens.length != _balances.length) revert MismatchedArrayLengths();
uint256 totalValue = 0;
uint256 tokenLength = _tokens.length;
for (uint256 i = 0; i < tokenLength; ) {
totalValue += lookupTokenValue(_tokens[i], _balances[i]);
unchecked {
++i;
}
}
return totalValue;
}
Analyze
| Description | Result |
|---|---|
| Check input size (_tokens) _balance equals | yes |
| Check input size different of 0 | No, but return 0 in this case |
| For loop optimized | yes(local variable for tokenLenght, unchecked,) |
calculateMintAmount
Given amount of current protocol value, new value being added, and supply of ezETH, determine amount to mint.
Values should be denominated in the same underlying currency with the same decimal precision.
uint256 constant SCALE_FACTOR = 10 ** 18;
function calculateMintAmount(
uint256 _currentValueInProtocol,
uint256 _newValueAdded,
uint256 _existingEzETHSupply
) external pure returns (uint256) {
// For first mint, just return the new value added.
// Checking both current value and existing supply to guard against gaming the initial mint
if (_currentValueInProtocol == 0 || _existingEzETHSupply == 0) {
return _newValueAdded; // value is priced in base units, so divide by scale factor
}
// Calculate the percentage of value after the deposit
uint256 inflationPercentaage = (SCALE_FACTOR * _newValueAdded) /
(_currentValueInProtocol + _newValueAdded);
// Calculate the new supply
uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) /
(SCALE_FACTOR - inflationPercentaage);
// Subtract the old supply from the new supply to get the amount to mint
uint256 mintAmount = newEzETHSupply - _existingEzETHSupply;
// Sanity check
if (mintAmount == 0) revert InvalidTokenAmount();
return mintAmount;
}
Analyze
Calculate the percentage of value after the deposit
uint256 inflationPercentaage = (SCALE_FACTOR * _newValueAdded) /
(_currentValueInProtocol + _newValueAdded);
Calculate the new supply
uint256 newEzETHSupply = (_existingEzETHSupply * SCALE_FACTOR) /
(SCALE_FACTOR - inflationPercentaage);
Analyze
It seems OK
calculateRedeemAmount
Description
Given the amount of ezETH to burn, the supply of ezETH, and the total value in the protocol, determine amount of value to return to user
Code
function calculateRedeemAmount(
uint256 _ezETHBeingBurned,
uint256 _existingEzETHSupply,
uint256 _currentValueInProtocol
) external pure returns (uint256) {
// This is just returning the percentage of TVL that matches the percentage of ezETH being burned
uint256 redeemAmount = (_currentValueInProtocol * _ezETHBeingBurned) / _existingEzETHSupply;
// Sanity check
if (redeemAmount == 0) revert InvalidTokenAmount();
return redeemAmount;
}
Analyze
It seems OK
Others
Access control
A modifier is defined to restrict the configuration of the contract to only the oracle admin.
/// @dev Allows only a whitelisted address to configure the contract
modifier onlyOracleAdmin() {
if (!roleManager.isOracleAdmin(msg.sender)) revert NotOracleAdmin();
_;
}
Proxy implementation
- The implementation is correctly locked with
_disableInitializersinside the constructor
/// @dev Prevents implementation contract from being initialized.
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
- The public function
initializecontains the modifierinitializerto allow the function to be called only once.
function initialize(IRoleManager _roleManager) public initializer
- Conclusion
The proxy seems correctly implemented.
Analyzer report
The automatic analyzer report by Code4Arena has mainly made “informal” remark to improve the code quality.
Medium
[M-7] Missing checks for whether the L2 Sequencer is active
Chainlink recommends that users using price oracles, check whether the Arbitrum Sequencer is active. If the sequencer goes down, the Chainlink oracles will have stale prices from before the downtime, until a new L2 OCR transaction goes through.
Users who submit their transactions via the L1 Dealyed Inbox will be able to take advantage of these stale prices.
=> Use a Chainlink oracle to determine whether the sequencer is offline or not, and don’t allow operations to take place while the sequencer is offline.
File: contracts/Bridge/L2/Oracle/RenzoOracleL2.sol
51: (, int256 price, , uint256 timestamp, ) = oracle.latestRoundData();
if (timestamp < block.timestamp - MAX_TIME_WINDOW) revert OraclePriceExpired();
Informal
Use scientific notation (e.g. 1e18) rather than exponentiation (e.g. 10**18)
While this won’t save gas in the recent solidity versions, this is shorter and more readable (this is especially true in calculations).
- [NC-27] Contract does not follow the Solidity style guide’s suggested layout ordering
The style guide says that, within a contract, the ordering should be:
The right order is:
- Type declarations
- State variables
- Events
- Modifiers
- Functions
File: contracts/Oracle/RenzoOracle.sol
Use Underscores for Number Literals (add an underscore every 3 digits)
uint256 public constant MAX_TIME_WINDOW = 86400 + 60;
Can be updated for
uint256 public constant MAX_TIME_WINDOW = 86_400 + 60;
File: contracts/Bridge/L2/Oracle/RenzoOracleL2.sol
Event is missing indexed fields
event OracleAddressUpdated(address newOracle, address oldOracle);
File: contracts/Oracle/RenzoOracle.sol
event OracleAddressUpdated(IERC20 token, AggregatorV3Interface oracleAddress);
- [M-6] Chainlink’s
latestRoundDatamight return stale or incorrect results
Report:latestRoundData() is used to fetch the asset price from a Chainlink aggregator, but it’s missing additional validations to ensure that the round is complete.
My comment: This is not necessary any more
Final result
In the final report, majority of vulnerabilities reported by wardens concerned Withdrawals functionalities and not directly the oracles analyzed in this article.