Table of Contents
- Table of Contents
- Summary
- Scope
- System Overview
- Security Model & Trust Assumptions
- Critical Severity
- High Severity
- Medium Severity
- Incorrect Calculation of Tokens to Burn on withdraw
- Incorrect Earnings Accounting When ChefIncentivesController Reserve Is Empty
- Incorrect Usage of Uniswap V2 Oracle
- Incorrect Leveraged Borrow Calculation
- Leveraged Positions May Receive Reduced Reward Emissions
- Slippage Tolerances May Be Too Large
- Missing whenNotPaused Modifier
- Loss of RDNT Rewards When Compounding
- Missing PriceProvider Oracle Updates
- Use of Deprecated Chainlink Function
- Incomplete Validation of Chainlink Price Data
- Incorrect Reward Updates for Scheduled Emissions
- Possible Denial-of-Service for Compounding
- Lack of Event Emission
- Uniswap Pool Initialization Fails if Pool Exists
- Low Severity
- Lack of gap Variable in Base Contract
- Duplicated Code
- Missing Error Message in require Statement
- Missing or Incorrect Docstrings
- Incorrect Use of Assert
- Missing Slippage Protection for Direct Zapping
- Invalid Reward Tokens Can Brick Reward Claiming
- Unchecked ERC-20 Return Value
- Assets With More Than 18 Decimals Not Supported for Zapping
- Users Can Burn Their Tokens Without Incentives
- Shadowed initialized Variable
- Duplicate Emission Schedules Are Allowed
- Locked ETH in Contracts
- Unnecessary Truncation of Balancer LP Total Supply
- Unnecessary access control and incorrect mutability
- Commented-Out Code
- Unsafe ABI Encoding
- Tokens May Get Stuck in Compounder Contract
- Inconsistent Use of Named Return Values
- Incorrect Input Validation
- Inconsistent Amount Calculation When Providing Liquidity
- RDNT Bridge Fee Is Being Overcharged
- Use of Low Liquidity Source When Compounding on Arbitrum
- Use of Hard-Coded Values
- Uniswap V2 Oracle Does Not Scale Price
- Misleading Naming for Variables and Functions
- LockZap Does Not Correctly Enforce Maximum Borrowing Power
- Incorrect Value Used for Slippage Calculations
- Incorrect Fee Returned in Case Compounding Is Not Being Executed
- Flawed lastEligibleTime Function
- Incorrect Bounty Returned When BountyManager Reserve Is Low
- Logic Contracts Initialization Is Allowed
- Lack of Input Validation
- Incorrect Event Emission
- Incorrect or Inconsistent Error Types
- Configuration Changes May Negatively Impact Users
- Rewards Can Be Emitted to the MiddleFeeDistribution And MultiFeeDistribution Contracts
- Missing Zero Address Checks
- Lack of Access Control on Pool Helper swapToWeth Functions
- Lack of Access Control for LiquidityZap Contract Initialization
- Usage of Insecure Approval Functions
- Notes & Additional Information
- Inconsistent SPDX License Identifiers
- Using uint Instead of uint256
- TODO Comments in the Code
- Variables Could Be Marked immutable
- Functions With public Visibility Could Be external
- Unused Named Return Variables
- Use Custom Errors
- Use of Non-Explicit Imports
- Redundant Use of SafeMath Library
- Implicitly Abstract Contract
- Follow Solidity Style Guide
- Non-library Contract in libraries Directory
- Unused Imports
- Inconsistent Gas Optimizations in for Loops
- Gas Optimizations
- Floating Pragma
- Redundant Code
- Inconsistent Use of IERC20 Interfaces
- Use of Magic Numbers
- Typographical Errors
- Conclusion
- Appendix
Summary
- Type
- DeFi
- Timeline
- From 2023-04-24
- To 2023-06-09
- Languages
- Solidity
- Total Issues
- 82 (75 resolved)
- Critical Severity Issues
- 1 (1 resolved)
- High Severity Issues
- 5 (5 resolved)
- Medium Severity Issues
- 15 (13 resolved)
- Low Severity Issues
- 41 (36 resolved)
- Notes & Additional Information
- 20 (20 resolved)
Scope
We audited the radiant-capital/v2-core
repository at the e8f21f103e17736907b62809d1ad902febc53714
commit.
In scope were the following contracts:
./contracts/radiant
├── accessories
│ ├── Compounder.sol
│ ├── Leverager.sol
│ └── StargateBorrow.sol
├── eligibility
│ ├── BountyManager.sol
│ └── EligibilityDataProvider.sol
├── libraries
│ ├── AddressPagination.sol
│ └── LockerList.sol
├── oracles
│ ├── BaseOracle.sol
│ ├── ChainlinkV3Adapter.sol
│ ├── PriceProvider.sol
│ ├── UniV2TwapOracle.sol
│ ├── UniV3TwapOracle.sol
│ └── wstethOracle.sol
├── staking
│ ├── ChefIncentivesController.sol
│ ├── MiddleFeeDistribution.sol
│ └── MultiFeeDistribution.sol
├── token
│ ├── Migration.sol
│ └── RadiantOFT.sol
└── zap
├── LockZap.sol
└── helpers
├── BalancerPoolHelper.sol
├── DustRefunder.sol
├── LiquidityZap.sol
└── UniswapPoolHelper.sol
System Overview
Radiant V2 is a decentralized lending platform where users can provide a variety of assets as collateral and borrow against it while keeping a healthy collateralization ratio. It features cross-chain borrowing by leveraging LayerZero's novel interoperability protocol between different blockchains.
Protocol revenue is shared with depositors and additionally, RDNT token rewards are distributed to eligible stakers. Stakers can become eligible by providing liquidity in the form of RDNT and the wrapped native token of each specific blockchain, and then locking it for a given period of time. The longer the lock time, the larger the reward multiplier. This incentivizes protocol participants to provide deep liquidity for the governance token.
The audited codebase is comprised of a few different packages that manage staking, incentives, eligibility, price oracles, a bounty system, looping (leveraging positions in a single transaction), zapping assets into locked LP tokens, cross-chain borrowing and the new Radiant OFT V2 token.
The system is currently deployed on Arbitrum and Binance Smart Chain (BSC). Locked liquidity is provided in a Balancer weighted pool (80% RDNT and 20% WETH) in Arbitrum, while a PancakeSwap V2 pool is used on BSC with 50% RDNT and 50% WBNB.
Staking, Reward Emissions and Eligibility
Staking on Radiant V2 means providing liquidity and locking the LP tokens for a certain period of time. In order to become eligible for reward emissions, users must always maintain a certain amount of locked LP tokens, respective to their total deposited collateral value in USD terms. These tokens can be locked for different periods of time, ranging from 1 month up to 12 months at the moment of writing. The longer the period, the larger the multiplier applied to accrued rewards.
By default, auto-relocking is enabled, which means that after a certain lock expires, it will get locked again automatically for the preferred lock period in order to not lose eligibility for rewards. Alternatively, the LP tokens can be withdrawn on lock expiration.
RDNT reward emissions can be claimed into vesting, which imposes a 90-day period before users can withdraw their full earnings. If a user decides to withdraw their rewards before vesting is complete, they will incur a linear penalty over that time ranging from 90% down to 25%. The penalty fee is distributed as follows: 90% goes to the Radiant DAO reserve, and the remaining 10% is sent to the Radiant Starfleet Treasury.
Eligible depositors and borrowers are entitled to receive RDNT emissions within the money market. Emissions can follow either a linear pattern with a fixed number of tokens distributed per second, or be linked to a predefined schedule that continuously updates the distribution at specific times.
The aforementioned vesting period can be bypassed and rewards instantly claimed for their total amount under the condition that they are zapped into locked LP tokens by pairing all claimed RDNT with the wrapped native token of the specific chain.
Compounding
Eligible depositors will generate rewards in rTokens
such as rUSDC or rWBTC, which are the deposited, yield-generating equivalent of their underlying asset. These rewards can be claimed directly as rTokens
into the user's wallet or, alternatively, they can be compounded into locked LP tokens in order to generate even more rewards.
This can be achieved via the Compounder
contract, in a single transaction. Users can self-compound their rewards at any time or, they can opt-in for auto-compounding and rely on bounty hunters (see below) to do so on their behalf once a day in exchange for a small fee taken out of their claimed rewards.
Bounty System
In order to keep the protocol both decentralized and working as expected, a system of bounties has been set up so that third parties (called hunters) are incentivized to perform specific actions on behalf of the protocol. There are three different actions that hunters can perform in order to receive a bounty for their efforts:
- Withdrawing expired locks for users: Expired locks should not keep generating rewards, so hunters are paid a fixed amount (the base bounty) in order to timely withdraw any user's expired locks. Depending on user settings, this lock will be withdrawn as LP tokens directly into their wallet or relocked into the protocol for their preferred amount of time.
- Stopping emissions for ineligible users: Due to natural market fluctuations, user locks might change in USD value and go below the necessary threshold to maintain eligibility. Hunters can stop emissions for users when this happens in exchange for the base bounty as well.
- Auto-compounding user rewards: Users can opt-in for auto-compounding their rewards into locked LP tokens. These rewards correspond to their share in protocol revenue and are paid in the different
rTokens
supported by the platform. Hunters can claim these rewards every 24 hours for opted-in users to get them all swapped into LP tokens, which are automatically locked. For this last bounty type, the total amount paid is not fixed but instead, it represents a percentage of the total amount of rewards claimed on behalf of the user. A percentage of this bounty is paid to the hunter and the remaining is kept by the bounty reserve in order to have enough tokens to pay the other two types of bounties.
All bounty types are paid in RDNT tokens, which are subject to the same 90-day vesting discussed before. Additionally, hunters need to be eligible as well in order to be able to claim bounties.
Radiant V2 OFT
OFT stands for Omnichain Fungible Token. This new version of the Radiant governance token (RDNT) implements the OFT interface provided by LayerZero in order to make it natively transferrable cross-chain, and it is not upgradeable. Old v1 token holders can migrate their holdings into the new version by leveraging the Migration
contract.
The token can be transferred to and from any of the chains where the protocol is deployed (BSC and Arbitrum at the time of writing). In order to do so, users need to pay two fees: the LayerZero fee and the Radiant bridging fee. Both are paid in the chain's native currency when executing the transfer transaction.
Cross-Chain Borrowing
Users have the option of borrowing a certain subset of assets and getting them sent directly into another chain. For instance, users can deposit USDC on Arbitrum and borrow USDT on Binance Smart Chain. This can be done via the StargateBorrow
contract, which communicates with Stargate's bridge routers in order to perform the swaps.
There are also two fees involved, one to cover LayerZero's fee and another one charged by the Radiant protocol and sent to their treasury.
Looping
Users who want to set up a leveraged position can do so via the looping functionality provided by the Leverager
contract.
It features a variety of functions to achieve that goal, depending on the asset being used and whether funds should be pulled from the user's wallet or borrowed against their already existing collateral. A manually leveraged position requires multiple transactions to deposit collateral, borrow against it and then redepositing the borrowed amount. This can all be performed in a single looping transaction as many times as necessary to reach the desired leverage.
Zapping
Providing liquidity in a pool is a complex process which involves swapping source assets into the two necessary tokens in the right proportion. The ZapLock
contract, along with all the pool helpers, provides the necessary functions to lock LP tokens directly from any source asset users may hold, in a single transaction. It even allows for tokens to be borrowed instead of supplied when necessary.
Price Oracles
Price oracles are used for two different purposes within the protocol:
1) To track RDNT prices in order to manage eligibility and properly provide liquidity in the form of wrapped native token and RDNT in the right proportion.
2) To manage the different money markets supported by the platform.
The audited codebase contains a variety of price oracles focused on the first task, so they are all specifically designed to retrieve RDNT prices. This gives the team enough flexibility to choose the best candidate depending on the blockchain the protocol is deployed, Chainlink price feed availability, market conditions and the automated market maker of choice where users will provide liquidity. The different price oracles available are:
-
Uniswap V2 TWAP Oracle: This is the oracle currently configured on the BSC deployment. It uses a 60-second period between updates and returns the average price registered by the associated PancakeSwap V2 pool on the last period. It relies on the protocol being poked frequently enough by user actions in order to perform these updates in a timely manner. Note that this period is particularly low, which carries some risk of making the price manipulatable. However, the Radiant team made the conscious decision to set it this way in order to more effectively track short-term fluctuations in prices and due to not being used to manage an active lending market. The ROI of potential eligibility attack vectors is not high enough to justify the cost of the price manipulation. For the same reason, the team has decided to allow stale prices to be reported. There is a feature implemented to avoid using the TWAP price and instead, simply calculate the price based on the current pool reserves. Since this alternative is highly manipulatable, it is encouraged that it is only used during protocol deployment when the pool is being created. The team stated that they will switch to using Chainlink as soon as there is an available price feed for RDNT on BSC.
-
Uniswap V3 TWAP Oracle: This oracle is not actively being used, but it contains the necessary features to correctly track the TWAP price registered by the associated Uniswap V3 pool.
-
Chainlink V3 Adapter: This is the oracle of choice on Arbitrum, where there is an available price feed for the RDNT token.
-
Base oracle: This is not a full oracle but instead it is intended to be inherited by the TWAP oracles in order to enforce a consistent API with Chainlink, which conforms to its associated interface. It provides the basic functionality and configuration needed by their children.
Additionally, there is an independent oracle that tracks wrapped staked ETH (wstETH) prices by querying Chainlink's price feeds. This specific oracle is not used within the audited codebase.
Security Model & Trust Assumptions
The owner
of the Radiant contracts is the multisig wallet managed by the Radiant team. The protocol owner
has the power to:
- Pause/unpause certain contracts to restrict new fund inflows
- Upgrade implementation contracts
- Change external contract addresses
- Change key protocol configuration settings such as fees, incentives, and slippage ratios
- Change oracle settings such as allowing/disallowing stale prices and allowing the use of fallback oracles
Users of the protocol place their trust in the Radiant team to act in their best interests when making administrative changes to the system.
The Radiant protocol also requires ongoing monitoring and routine administrative actions by the Radiant team in order to ensure that the system remains sufficiently capitalized to pay out rewards emissions and bounties, and to ensure that bounty hunters remain incentivized to help the owners maintain an up-to-date protocol state.
The community can participate in governance by submitting proposals to the Radiant DAO. All users that hold locked RDNT tokens in the MultiFeeDistribution
contract are eligible to vote. Voting weight is proportional to the number of tokens held. The foundation that administers the DAO is trusted to abide by governance decisions.
Update: After our assessment of the implemented fixes, it is worth noting that the Radiant team now has the ability to remove existing rewards from MultiFeeDistribution
and then recover any existing balance. As previously stated, the team is expected to always act in the best interest of its user base.
Critical Severity
Eligibility Manipulation Leads to Stolen Rewards
Users can leverage their position by calling the loop
function within the Leverager
contract. This function will loop through multiple cycles of depositing and borrowing of the same asset in order to achieve the desired leveraged position.
During this process, ChefIncentivesController
's setEligibilityExempt
function is called to temporarily activate an exemption for the user. This skips the check on their eligibility after every iteration in order to save gas. Once the position is fully leveraged, this exemption is set back to false
on the last loop iteration.
However, a loopCount
of 0 iterations is allowed, so an attacker may pass 0
as a value for loopCount
which will skip the loop execution and thus, also skip setting the eligibility exemption back to false
. Similar logic has been implemented in loopETH
and loopETHFromBorrow
, but in those functions, the eligibility exemption is toggled back to false
outside of the for
loop, so they are not vulnerable.
This exemption is used in the handleActionAfter
hook to prevent eligibility updates for ChefIncentivesController
pools. Since this functionality is crucial for balance change updates used by the IncentivizedERC20
(transfers, mint, burn) and StableDebtToken
(mint, burn) contracts it opens up different attack vectors such as:
- Reusing the same deposit across multiple accounts in order to emit additional rewards by transferring the rTokens between multiple accounts and requalifying every account for rewards via
requalifyFor
. - Inflating the user's pool balance by depositing a flash-loaned asset, requalifying the position for rewards eligibility via
requalify
and finally withdrawing the deposit to pay back the flash loan amount. At this point, the total deposited collateral is zero but the user is still emitting rewards for the entire amount previously deposited.
The only requirement in both scenarios is to have the minimum required LP locked from the attack setup until the closest maturity date. After that, since deposited collateral will be zero, the required amount of LP tokens locked to maintain eligibility will also be zero.
Consider refactoring the loop
function to ensure that the eligibility exempt is always set back to false
before returning and enforcing loopCount
to be strictly larger than 0.
Update: Resolved in pull request #170 at commit 5f64342. A new InvalidLoopCount
custom error has been introduced. It is used to revert the loop
, loopETH
, and loopETHFromBorrow
functions when they are called with a loopCount
value equal to zero.
High Severity
Vested RDNT Tokens Can Be Withdrawn Early With No Penalties
The individualEarlyExit
function within the MultiFeeDistribution
contract allows withdrawing a specific user earnings entry, which is identified by passing its unlockTime
to ieeWithdrawableBalances
. Once a record is matched, its index is assigned and returned along with the penalty and burn amounts. The individualEarlyExit
function contains a check that presumes that an out-of-bounds index
returned by ieeWithdrawableBalances
indicates the target unlockTime
was not found.
However, if the specified unlockTime
is not found, ieeWithdrawableBalances
will return all variables set to zero, including the index
. Instead of halting execution as intended, it will continue and individualEarlyExit
will remove the first userEarnings
item from the list, while not correctly decreasing the user's earned
balance accordingly. This transaction will not revert and will end up transferring 0 RDNT tokens to the user and the DAO treasury.
A malicious user can call individualEarlyExit
as many times as necessary using an invalid unlockTime
until their userEarnings
array is empty, and then call the exit
function to determine the withdrawable balance by looping through the userEarnings
array. Since there are no entries (they were all removed), the penalty and burn amounts returned by withdrawableBalance
will be zero, resulting in a withdrawable balance equal to the full balances[user].earned
value.
The end result is that any user can withdraw their full amount of RDNT rewards at any time, bypassing any penalties for exiting early.
To ensure users cannot withdraw earnings that are not fully vested without penalties, consider reverting when the specified unlockTime
is not found.
Update: Resolved in pull request #242 at commit 0752677. If the specified unlockTime
is not found, the transaction will revert with the custom error UnlockTimeNotFound
.
RDNT Token Bridge Fee Can Be Bypassed
The RDNT token is implemented as a LayerZero Omnichain Fungible Token (OFT), which allows it to be sent to any blockchain that has a LayerZero endpoint. The tokens can be transferred cross-chain by using the OFT sendFrom
function, which internally calls the overridden _send
function in the RadiantOFT
contract. The purpose of this override is to allow the Radiant team to collect an extra fee called the bridge fee, which is expressed in the chain's native currency and dependent on the amount of RDNT being transferred.
This fee can potentially be bypassed by instead using the OFT sendAndCall
function, which is similar to sendFrom
but allows executing a specific payload after executing the token transfer. If crafted correctly, this will execute the inherited _sendAndCall
function, which has not been overridden to also collect the bridge fee.
Consider overriding the internal _sendAndCall
function in order to also collect the extra fee and avoid the potential loss of protocol revenue.
Update: Resolved in pull request #176 at commits 01b2bde and eddbf1c.
Initial Liquidity Can Be Lost by Front-Running Pool Initialization
Both the UniswapPoolHelper
and BalancerPoolHelper
contracts implement an initializePool
function that is responsible for creating a new liquidity pool for the RDNT token paired with a base token. In the currently deployed contracts, RDNT is paired with WETH on Arbitrum and WBNB on Binance Smart Chain. In the case of Uniswap, this initialization will revert if a pair has already been created.
The logic in both pool initialization functions expects the input RDNT/WETH or RDNT/WBNB tokens to be sent to the respective helper contract in advance, before calling either initializePool
function. This is outlined in the deployment script for UniswapPoolHelper and BalancerPoolHelper as a manual set of transactions, instead of being executed atomically. After depositing liquidity into a pool, the respective initializePool
function will send the received liquidity (LP) tokens to the caller (see here and here).
However, access to these initializePool
functions is unrestricted, which allows any user to perform the pool initialization step. This leads to the front-running scenario where an attacker monitors the mempool and waits for the protocol owners to send RDNT and WETH tokens to the contract, and then front-runs the initializePool
transaction, or alternatively (on Layer 2 chains that do not feature public mempools), the attacker can back-run the second token transfer to accomplish the same goal. The existing RDNT and WETH or WBNB contract balances will then be used to provide liquidity and send the minted LP tokens to the attacker.
Consider adding the onlyOwner
modifier to the initializePool
functions of UniswapPoolHelper
and BalancerPoolHelper
to ensure they can only be executed by the protocol owners, and/or implementing an atomic flow of contract deployment, transfer of funds, and pool initialization.
Update: Resolved in pull request #177 at commit 7ce4838. The onlyOwner
modifier was added to both initializePool
functions.
Bounty Hunters Can Incur a Denial-of-Service
Successful bounty hunters are expected to claim a large number of bounties per day. The larger the protocol grows, the more situations will arise for hunters to get a reward for ensuring the protocol works as expected.
These bounties are paid out in RDNT tokens, but not directly to the hunter's wallet. Instead, these tokens go into a 90-day vesting period. In order to track penalties for each individual amount in vesting, every bounty needs to be separately added to the hunter's earnings array.
A successful bounty hunter can potentially claim a very large amount of bounties within the same 90-day period (before vesting ends for his first bounty), causing the userEarnings
array to be so large that any interaction with it on a transaction will fail due to running out of gas because it has reached the block gas limit. When this happens, hunters will not be able to withdraw their earnings or perform static calls to the earnedBalances
function, essentially bricking protocol interactions for that specific user.
The same effect can be achieved by a malicious actor performing a grieving attack on any account by claiming numerous times on behalf of a victim's address since there is no minimum amount claimable and there is no access control enforced when claiming rewards.
In order to avoid a denial of service on regular users, consider increasing the minimum amount claimable to a reasonable threshold. Additionally, consider enforcing access control on the claiming functionality if necessary.
Regarding bounty hunters, in order to prevent them from incurring a denial-of-service, consider implementing an alternate solution to how bounties are added to the userEarnings
array. For example, the same array item could be used to combine multiple bounty amounts claimed within the same day.
Update: Resolved in pull request #201 at commits 42c6772, bec761e, 5b43456, 82d6503, d4c1489 and 814adc6. Claims of 0 RDNT are disallowed and a 1-day epoch system has been implemented so that both RDNT claims and LP locks of the same type happening on the same day are merged within 1 entry. LP locks aggregation only takes place if the current lock will unlock on the exact same day as the last lock from the user list. This strategy makes the protocol more gas efficient and protects both users and bounty hunters from denial-of-service attacks by limiting the number of entries in both arrays.
Incorrect Accounting of earned
and unlocked
Tokens on exit
May Brick the Protocol
The exit
function within the MultiFeeDistribution
contract is responsible for withdrawing RDNT tokens (earnings) that are already unlocked and still in vesting. It is using withdrawableBalance
to calculate the balance that can be withdrawn, and then it releases the tokens to the user via _withdrawTokens
.
While the function is correctly deleting userEarnings
, it is not updating the unlocked
and earned
balances to 0
, which is where the withdrawableBalance
function gets the data from.
A regular call to exit
will break that user's internal accounting for earnings and lockings, leading to reverts for crucial functionalities of the system and thus, causing unexpected results and potentially bricking most of the MultiFeeDistribution
contract alongside other parts of the protocol.
The following features will be impacted by the incorrect value of earned or unlocked tokens:
- The
exit
function will be reverting when updating the value oftotal
. - The
withdrawableBalance
function will be returning an incorrect value. - The
withdraw
function will be reverting when updating value oftotal
. - The
earnedBalances
function will be returning anincorrect value.
In order to maintain a correct internal accounting of user balances, it is recommended to reset both the unlocked
and earned
balances to 0
in the exit
function.
Update: Resolved in pull request #66 at commit 1830760. Both unlocked
and earned
values are now reset to 0
within the exit
function.
Medium Severity
Incorrect Calculation of Tokens to Burn on withdraw
The withdraw
function of the MultiFeeDistribution
contract implements the logic for withdrawing earned and unlocked tokens from the protocol. It loops through all earnings to sum the amount of earned tokens, penalties for the early withdrawal, and the burn amounts.
The problem is that burnAmount
is incorrectly calculated by using the total penaltyAmount
instead of the individual penalty for the current loop iteration, which leads to an inflated value of burnAmount
.
It is recommended to correct the calculation of burnAmount
by replacing penaltyAmount
with the individual penalty associated with the current loop iteration.
Update: Resolved in pull request #208 at commit 65220b8.
Incorrect Earnings Accounting When ChefIncentivesController
Reserve Is Empty
The ChefIncentivesController
contract allows claiming rewards through its claim
function. The rewards are then sent to the MultiFeeDistribution
contract by calling _mint
, which uses the _sendRadiant
function to perform the actual transfer. Following the transfer, the claimed rewards are vested by calling MultiFeeDistribution
's mint
function.
If a user calls claim
when the ChefIncentivesController
contract's reserve of RDNT tokens is empty, the contract is paused but the _amount
that the user would have received is still returned and used for vesting. This behavior is incorrect since MultiFeeDistribution
will record RDNT tokens that it never actually received as vested, leading to the insolvency of MultiFeeDistribution
because the accounting will record user reward amounts in excess of the contract's balance.
To ensure adequate funds are present in the reserve, consider reverting when there are insufficient funds for any rewards claim. In addition, consider using OpenZeppelin Defender to detect this event and subsequently pause the contract or fill the reserve.
Update: Resolved in pull request #174 at commits 4c7d287 and 482c14f. The ChefIncentivesController
contract will revert with an OutOfRewards
custom error if there are not enough reserve funds to pay out a claim. Additionally, the contract is no longer paused when this situation is encountered.
Incorrect Usage of Uniswap V2 Oracle
Uniswap provides example code for a Uniswap V2 oracle and UniV2TwapOracle
follows the example implementation to retrieve oracle price data. However, Uniswap's implementation actually relies on the unchecked arithmetic in Solidity 0.6.6
in order to work correctly. In contrast, the Solidity version 0.8.12
used by UniV2TwapOracle
contains default checks to prevent overflow from occurring. Since the Uniswap implementation expects overflows as part of normal operation, the use of modern Solidity >=0.8.0
may eventually cause the code to revert unexpectedly.
Consider adding unchecked
to the parts of the UniV2TwapOracle
code that are expected to overflow.
Update: Resolved in pull request #204 at commit 6e67d1c. All parts that are expected to overflow have been wrapped in unchecked
blocks.
Incorrect Leveraged Borrow Calculation
The Leverager
contract provides the ability to leverage a user's position by "looping" the deposit and borrow actions, i.e. funds that are borrowed are then deposited, increasing the collateral to borrow against in the next loop iteration. This functionality has been implemented by functions loop
, loopETH
, and loopETHFromBorrow
, which cover various use cases.
Users are charged a percentage fee on every deposit during the leveraging process but the fee is not correctly deducted from the amount
used for the next deposit. This can be observed in the loop where the amount to deposit is reduced by the fee
but amount
is not updated for the next iteration of the loop, leading to an increased amount of borrowed tokens beyond what the user expected. The issue is present in loop
, loopETH
, loopETHFromBorrow
, and the view function wethToZapEstimation
.
Consider updating the value of the amount
by deducting fee
from it before using it for the deposit.
Update: Resolved in pull request #206 at commits 1483fb5, c95a251 and bb54b79.
Leveraged Positions May Receive Reduced Reward Emissions
The loopETH
and loopETHFromBorrow
functions set a user as exempt from eligibility for the duration of the loop, which disables any updates to ChefIncentivesController
regarding changes in rToken
balances. After the loop is finished, the exemption is revoked and zapWETHWithBorrow
is called to lock more LP tokens when necessary, in order to ensure that the user maintains reward emission eligibility. When the _zap
function locks more liquidity by calling MultiFeeDistribution
's stake
function, updated rToken
balances will correctly be reflected within ChefIncentivesController
.
However, when users are still eligible even after reaching the desired leverage, the function wethToZap
will return 0
and thus, zapWETHWithBorrow
will not lock any more liquidity. This will cause users to miss rewards generated by the newly deposited amount since ChefIncentivesController
has not been notified about the new balance. Reward emission will continue the next time liquidity is locked on behalf of this specific user.
Consider redesigning both loopETH
and loopETHFromBorrow
functions in order to ensure that ChefIncentivesController
is always notified about final rToken
balances after the position is leveraged.
Update: Resolved in pull request #219 at commit 5a7a905. Both loopETH
and loopETHFromBorrow
have been refactored to behave like the loop
function, where the exemption from eligibility is lifted right before performing the last loop iteration. This change ensures that ChefIncentivesController
will receive accurate balance notifications after the position is fully leveraged.
Slippage Tolerances May Be Too Large
When performing a selfCompound
operation or when a hunter claims an auto-compound bounty, a series of swap operations take place so that an account's rewards can be converted into a combination of the base token (WETH or WBNB) and RDNT.
The first set of swap operations takes place when converting all aToken
rewards into the base token. Each of those swaps has a maximum slippage ratio between 0% and 20%.
Once all rewards have been converted into the base token, the necessary amount of RDNT tokens charged as a fee when an auto-compound bounty is being claimed is swapped out from the base token balance. This swap is also subject to a 0%-20% maximum slippage.
Finally, the necessary percentage of the base token balance is swapped again into RDNT in order to be able to provide liquidity in the right ratio. This last swap operation is subject to the ACCEPTABLE_RATIO
parameter, which can range from 0% to 100%.
All these slippage amounts can be compounded and an attacker may be able to extract up to the maximum slippage tolerance at all times.
Since Radiant users may have different amounts of capital invested in the protocol, different slippage settings should be enforced for each situation. Apart from enforcing sensible general parameters for these tolerances, consider giving users the ability to choose their own slippage settings so that smaller positions can get away with much tighter ratios.
Update: Resolved in pull request #225 at commits 59d7f1d, 6cb87f9, 937a6b9, 843555b, af0b342 and f466ecc. Users are now able to specify their own custom slippage value for swaps. When auto-compounding is enabled, the MultiFeeDistribution
contract stores the user's desired slippage tolerance for compounding. Users are prevented from using a slippage tolerance greater than 5%. If a user has not set a slippage limit for compounding, the maximum slippage tolerance configured for the system will be used, which is currently set at 5%.
Missing whenNotPaused
Modifier
The LockZap
contract implements pause functionality that prevents users from locking additional funds in case of emergency. While the functions zap
, zapOnBehalf
and zapFromVesting
have the whenNotPaused
modifier correctly applied, the function zapAlternateAsset
is missing that modifier. As a result, even when the contract is paused, it will still be possible to use the zapAlternateAsset
function to stake additional liquidity.
Consider adding the whenNotPaused
modifier to the zapAlternateAsset
function so that it cannot be executed while the contract is paused.
Update: Resolved in pull request #234 at commit 6e5faf7.
Loss of RDNT Rewards When Compounding
Function claimCompound
allows compounding accrued rewards into locked liquidity. The full list of available rewards is configured within MultiFeeDistribution
and it contains all supported rTokens
and the RDNT token itself. When rewards are claimed from the MultiFeeDistribution
contract via claimFromConverter
, this list is looped through and any available reward is transferred into the Compounder
contract. At the time of writing, the RDNT token is added to that list but has no active emissions, so it will never get transferred.
However, the Radiant team confirmed that the RDNT token is added to the list of reward tokens within MultiFeeDistribution
because in the future they might want to distribute them as rewards. If that happens, these tokens will get stuck on the Compounder
contract because only rTokens
are being handled, skipping the iteration when the token is RDNT.
Consider adding specific logic to handle RDNT rewards within the claimCompound
function in order to avoid the potential loss of user rewards in the future.
Update: Resolved in pull request #213 at commits 8bad016, e43df4e, 2e579cc, 77ca628, 02db1ea, 776bda9 and 26bea56. RDNT rewards are no longer forwarded to the Compounder
contract. Additionally, a safety check has been added so that no reward token can be added if it implements the UNDERLYING_ASSET_ADDRESS
function but is not part of the rToken
suite.
Missing PriceProvider
Oracle Updates
The PriceProvider
contract is responsible for providing pricing data to multiple contracts of the protocol. It uses either pool data or one of the following oracles: ChainlinkV3Adapter
, UniV3TwapOracle
, UniV2TwapOracle
.
In the case where UniV2TwapOracle
is used, the update
function must be frequently triggered to correctly track TWAP data, otherwise the returned price will be stale. This is done in the LockZap
contract and in multiple functions of the MultiFeeDistribution
contract.
The following contracts are using PriceProvider
but are missing oracle updates:
It is recommended to always trigger the update
function before using PriceProvider
to query price data.
Update: Resolved in pull request #235 at commits 6d1516f, c654e43 and 4467c8d.
Use of Deprecated Chainlink Function
The contracts BaseOracle
and PriceProvider
are using Chainlink’s deprecated latestAnswer
function to retrieve the price of ETH. Although latestAnswer
returns the price of the asset, it is not possible to check if the data is fresh.
The following instances of using latestAnswer
were identified:
latestAnswer
inBaseOracle.sol
getTokenPriceUsd
andgetLpTokenPriceUsd
inPriceProvider.sol
Consider replacing calls to latestAnswer
with Chainlink's latestRoundData
function, and adding checks on the returned data to ensure the price value is positive, is not stale, and that the round is complete.
Update: Resolved in pull request #220 at commit 1612d75. Calls to the deprecated latestAnswer
function have been replaced with calls to latestRoundData
. Additionally, transactions will be reverted if reported prices are older than a day, non-positive, or if the round is not complete.
Incomplete Validation of Chainlink Price Data
The ChainlinkV3Adapter
and WSTETHOracle
contracts are using Chainlink’s latestRoundData
function, but are not fully validating the returned data. The returned price value (answer
) is checked to ensure it is not zero, but the other return values roundId
, updatedAt
, and answeredInRound
are not checked, which might result in stale and incorrect prices being used by the protocol.
The following instances of missing security checks were identified:
latestAnswer
ofChainlinkV3Adapter
contract.latestAnswerInEth
ofChainlinkV3Adapter
contract.latestAnswer
ofWSTETHOracle
contract.
In each case, consider adding security checks on the returned data with proper revert messages if the price is stale or if the round is incomplete.
Update: Resolved in pull request #221 at commits 0f5c958, 7ebf11a, a6c51b7, 0dc3ddf and cff685f. Prices retrieved from ChainlinkV3Adapter
will revert if they are non-positive, older than a day, or from an incomplete round.
Incorrect Reward Updates for Scheduled Emissions
The ChefIncentivesController
contract supports two methods of distributing rewards. The protocol can either set a fixed value for rewardsPerSecond
or change the rewardsPerSecond
value according to an emission schedule. Usage of emission schedules requires a frequent check on the current rewards per second value against the schedule to determine when emissions should be adjusted. This logic has been implemented in the setScheduledRewardsPerSecond
function that is triggered by _updateEmissions
.
However, the _updateEmissions
function is only used by addPool
and claim
, but not in _handleActionAfterForToken
or afterLockUpdate
. The latter two functions both call the _updatePool
function in order to update pool rewards using the rewardsPerSecond
value, but do not support emission schedule updates, which may lead to incorrect reward emissions until there is a new pool added or a claim is triggered.
Consider calling _updateEmissions
whenever rewards need to be recalculated, to ensure that the rewardsPerSecond
value tracks the current scheduled emission rate as closely as possible.
Update: Acknowledged, not resolved. The Radiant team stated:
Calling
_updateEmissions
on everyrToken
/dToken
transfer would be very gas intensive and would render the product unusable on certain chains. In our opinion, the resulting reward inaccuracy is worth the gas savings on this heavily used function.
Possible Denial-of-Service for Compounding
The claimCompound
function allows compounding accrued rewards from the MultiFeeDistribution
contract by claiming them, converting them to the base token, and finally zapping them into locked LP tokens via LockZap
. The logic for swapping rewards to the base token has been implemented in _claimAndSwapToBase
by approving uniRouter
to spend an amount
of tokens and then executing swapExactTokensForTokens
on Uniswap's router.
Since the swap is inside a try-catch
block, it is possible that the swap reverts but the transaction succeeds, so long as the total received base token meets the slippage requirement. This leads to a scenario where the token has been correctly approved to be spent via safeApprove
but then the actual spending did not happen. The implementation of safeApprove
requires either the new allowance to be reset to 0
or the current allowance to be equal to 0
. This may not be true for uniRouter
if any of the swaps revert since there will be an unspent allowance. This will lead to a denial-of-service situation for the Compounder
.
Consider using the SafeERC20
forceApprove
function instead of safeApprove
.
Update: Resolved in pull request #184 at commit d72012f.
Lack of Event Emission
The following functions do not emit relevant events after executing sensitive actions:
setMinStakeAmount
,setBounties
andaddAddressToWL
inBountyManager.sol
setBountyManager
,addRewardConverter
,setLockTypeInfo
,setAddresses
,setLPToken
andaddReward
inMultiFeeDistribution.sol
setOracle
,setPoolHelper
,setAggregator
andsetUsePool
inPriceProvider.sol
setFallback
andenableFallback
inBaseOracle.sol
setPeriod
,setConsultLeniency
andsetAllowStaleConsults
inUniV2TwapOracle.sol
increaseObservationCardinality
,setTWAPLookbackSec
andtoggleTokenForPricing
inUniV3TwapOracle.sol
setAdmin
andaddReward
inMiddleFeeDistribution.sol
setBountyManager
,setCompoundFee
andsetSlippageLimit
inCompounder.sol
setOnwardIncentives
,setBountyManager
,setEligibilityEnabled
,batchUpdateAllocPoint
,setLeverager
,setEndingTimeUpdateCadance
andregisterRewardDeposit
inChefIncentivesController.sol
setPriceProvider
,setMfd
andsetPoolHelper
inLockZap.sol
setLiquidityZap
andsetLockZap
inUniswapPoolHelper.sol
Consider emitting events after sensitive changes take place to facilitate tracking and to notify off-chain clients following the contracts' activity.
Update: Resolved in pull request #227 at commits 53348f6 and ed9cabf.
Uniswap Pool Initialization Fails if Pool Exists
The UniswapPoolHelper
and BalancerPoolHelper
contracts both implement an initializePool
function that is responsible for creating a trading pair via IUniswapV2PairFactory
or IWeightedPoolFactory
, respectively. However, in the former case, Uniswap's createPair
function will revert if a pair has already been created for the given tokens.
During the deployment process on a new chain, an attacker may create a Uniswap trading pair that matches the same pair Radiant intends to create before this initializePool
function is called. This will cause the function to always revert from that point onward. This would require Radiant to upgrade the UniswapPoolHelper
contract to resolve this issue.
Consider always checking if the desired Uniswap pool already exists before attempting to create it.
Update: Acknowledged, not resolved. The Radiant team stated:
As this issue only affects deployments on new chains, we will tackle this minor upgrade once (and if) it does actually end up occurring.
Low Severity
Lack of gap
Variable in Base Contract
The BaseOracle
contract is inherited by several other contracts, but does not declare its own __gap
variable. Gap variables provide a standard approach to reserve storage slots in base contracts, so that new variables can be safely added while avoiding collisions with the storage used by child contracts.
Consider adding storage gaps to inherited contracts by using gap variables to avoid future storage clashes.
Update: Resolved in pull request #246 at commit 45833b7.
Duplicated Code
There are instances of duplicated code within the codebase, which can lead to issues later on in the development lifecycle. Errors can inadvertently be introduced when functionality changes are not replicated across all instances of code that should be identical. Some examples are:
- Functions allowing the owner to recover stray ERC-20 tokens (
recoverERC20
) can be found with different implementations within theMiddleFeeDistribution
,MultiFeeDistribution
andChefIncentivesController
, andBountyManager
contracts. All implementations are similar but only the first two emit aRecovered
event. - Internal functions to safely transfer ETH into a certain address (
_safeTransferETH
) are replicated on both theLeverager
andStargateBorrow
contracts. - The
loop
andloopETH
functions in theLeverager
contract implement identical logic for updating a token's approved allowance for thelendingPool
andtreasury
. - When withdrawing unvested RDNT tokens, return values from
_penaltyInfo
are ignored and then amounts after the penalty are recalculated. Consider using theamount
,penaltyAmount
andburnAmount
return values directly to determine if the earned amount from this lock is enough to cover the withdrawal instead of recalculating the values. Duplicating code like this may become error-prone as seen on issue M-01 - The
wethToZap
andwethToZapEstimation
functions in theLeverager
contract use an identical code block for computingwethAmount
. - In the
ChefIncentivesController
contract, there are multiple instances where accrued rewards since the last update are being calculated. Consider extracting these calculations into a separate function so it can be reused across the contract instead of replicating the same logic. - In the
Compounder
contract, theselfEligibleCompound
function implements a special case of theuserEligibleForCompound
function where_user = msg.sender
. Consider unifying the logic in an internal function that can be called by both public functions instead. - The
slippageLimit
validation within theCompounder
contract is duplicated in theconstructor
and insetSlippageLimit
. - When adding liquidity within
LiquidityZap
, it performs the optimal token amount calculation directly instead of leveraging the existing publicquote
function. - Consider leveraging the
DustRefunder
contract withinLiquidityZap
to avoid duplicating logic to refund token leftovers after providing liquidity. - When calculating the LP token price via
getLpPrice
inUniswapPoolHelper
, consider leveraging the existinggetReserves
function in order to retrieve the pool reserves and total supply instead of duplicating its logic. - When calling
ltv
, the asset configuration is queried directly from the lending pool. Consider making the existinggetConfiguration
function public so that it can be reused for this query.
Instead of duplicating code, consider extracting it into a shared function or a helper library when applicable.
Update: Resolved in pull request #230 at commits 6c94f65, f946b0c, 57c2ee1 and 22cd008.
Missing Error Message in require
Statement
The require
statement on line 1172 of MultiFeeDistribution.sol
lacks an error message.
Consider including specific, informative error messages in require
statements to improve overall code clarity and facilitate troubleshooting whenever a requirement is not satisfied.
Update: Resolved in pull request #224 at commits d14d322 and e869cd9.
Missing or Incorrect Docstrings
There are several instances in the codebase where NatSpec comments are either absent or incorrect:
In EligibilityDataProvider.sol
:
- The internal
_lockedUsdValue
function retrieves the USD value for the LP token but the docstring states that the reported value is in ETH.
In wstethOracle.sol
:
- The function
latestAnswer
returns the price forwstETH/USD
instead ofwstETH/ETH
as the comments suggest.
In MultiFeeDistribution.sol
:
- Line 143 is a stray comment that is not associated with any code.
- The
initialize
function only has@param
tags for 4 out of 10 input parameters and has a@dev
comment that refers to it as being a constructor instead of an initializer. - The docstring for
setLockTypeInfo
says the function adds a new reward token, but the function actually sets the lock periods and corresponding multipliers. The@notice
documentation forsetLockTypeInfo
is a copy from theaddReward
function's docstring. - There is a section divider named "View functions", but non-view setter functions are mixed with getter functions in this section.
- Documentation associated with the
totalBalance
function states that it returns the total balance of an account, including earned tokens. This is not the case when the staking token is the LP token instead of the RDNT token. - The
earnedBalances
@dev
comment states that earned balances can be immediately withdrawn for a 50% penalty. This is not the case since the penalty factor is implemented as a function of time, starting at 90% and finishing at 25% right before vesting is complete. - The
_notifyUnseenReward
function's documentation states that it is meant for rewards other than the staking token, which in this case is the LP token. However, the function only checks if thetoken
argument is the RDNT token. - All references to
burn
andburnAmount
are not valid. Rather than being burnt, the "burnt" share of early-exit penalty tokens is sent to thestarfleetTreasury
contract. - The docstring for
ieeWithdrawableBalances
states that the function "returns withdrawable balances at exact unlock time", but only a single balance will ever be returned from this function because it stops iterating once it finds a matchingunlockTime
. Consider renaming the function toieeWithdrawableBalance
and updating the corresponding documentation.
In MiddleFeeDistribution.sol
:
- Missing
@param _operationExpenses
and@param _operationExpenseRatio
for thesetOperationExpenses
function - Missing
@param _configurator
for thesetAdmin
function - Missing
@param _rewardsToken
for theaddReward
function - Missing
@param _rewardTokens
for theforwardReward
function - Missing
@return
for thegetRdntTokenAddress
function - Missing
@return
for thegetMultiFeeDistributionAddress
function - Missing
@param asset
and@param lpReward
for theemitNewTransferAdded
function - Missing
@param tokenAddress
and@param tokenAmount
for therecoverERC20
function - Incorrect
@notice
comment for therecoverERC20
function - The
forwardReward
function docstring is a copy of therecoverERC20
function docstring, which is misleading because it does forward tokens to theMultiFeeDistribution
contract, but not LP rewards such as BAL as indicated.
In ChefIncentivesController.sol
:
- The
@title
docstring is a copy of the@title
from theUniV3TwapOracle
contract. - The docstring for the internal
_mint
function states that it "can be called by owner or leverager contract". This appears to be copied from thesetEligibilityExempt
function docstring. - The
emissionSchedule
array documentation states that block number will be used to determine which schedule is the current one, but in reality it usesblock.timestamp
instead, so offsets refer to seconds not blocks.
In RadiantOFT.sol
:
- The
@notice
comment for the_send function
incorrectly describes the return value.
In Leverager.sol
:
- Missing
@param _cic
for theconstructor
function - Missing
@return
for thewethToZapEstimation
function - Missing
@return
for thewethToZap
function - Missing
@return
for therequiredLocked
function
In StargateBorrow.sol
:
- Incorrect docstring for the
borrow
function. - Missing
@return
for thegetXChainBorrowFeeAmount
function. - Missing all
@param
documentation and@return
for thequoteLayerZeroSwapFee
function
In LockerList.sol
:
- Missing
@return
for thelockersCount
function - Missing all
@param
documentation@return
for thegetUsers
function - Missing
@param user
for theaddToList
function - Missing
@param user
for theremoveFromList
function
In BountyManager.sol
:
- Missing
@param _eligibilityDataProvider
and@param _compounder
for theinitialize
function. - Incomplete
@return
documentation for thegetMfdBounty
function. It describestotalBounty
but notissueBaseBounty
. - Incomplete
@return
documentation for thegetChefBounty
function. It describestotalBounty
but notissueBaseBounty
. - The docstring for
getAutoCompoundBounty
states thatMFDPlus.claimCompound
is called and thatMFDPlus
is the incentivizer; in both casesMFDPlus
should beCompounder
. - Missing
@return issueBaseBounty
documentation for thegetAutoCompoundBounty
function. - There is no reference to the auto-compounder bounty within the
getBestBounty
documentation.
In Compounder.sol
:
- Missing
@return fee
documentation for theclaimCompound
function.
In LockZap.sol
:
- The docstring for the
LockZap
contract is a copy of theStargateborrow
contract's docstring - Missing
@return
for thezap
function - Missing
@return
for thezapOnBehalf
function - Missing
@return
for thezapFromVesting
function - Missing
@return
for the_zap
function
In BaseOracle.sol
:
- Incorrect
@notice
comment forlatestAnswerInEth
. It returns the token price in ETH, not USD.
The following instances lack docstrings entirely:
- The
ChainlinkV3Adapter
contract - The
Compounder
contract, with the exception of theclaimCompound
function - Most of the functions within the
wstethOracle
contract - The
onUpgrade
function inMultiFeeDistribution
- The
getPrice
andgetReserves
functions withinBalancerPoolHelper
- The entire
UniswapPoolHelper
contract, with the exception of theswapToWeth
function
Consider thoroughly documenting all functions (and their parameters) that are part of any contract's public API. Functions implementing sensitive functionality, even if not public, should be clearly documented as well. When writing docstrings, consider following the Ethereum Natural Specification Format (NatSpec).
Update: Resolved in pull request #190 at commits 49d2c01, 8f4573e, and 4afdeb2. All indicated instances of incorrect documentation have been corrected and any missing docstrings have been added.
Incorrect Use of Assert
Some instances were found across the codebase where an assert
statement is used:
A failed assertion will revert with a Panic exception in the EVM. According to the Solidity documentation, the assert
statement should only be used to test for internal errors where an invariant is violated (i.e., it should be reserved to test for "this should never happen" states). Well-designed Solidity contracts should never generate a Panic, even when an input parameter is invalid.
Consider replacing all assert
statements with an equivalent require
or revert
statement.
Update: Resolved in pull request #223 at commits 41ae45a and 7154ae9.
Missing Slippage Protection for Direct Zapping
The LiquidityZap
contract implements the zapETH
and addLiquidityETHOnly
functions which allow adding liquidity by directly providing ETH. The received ETH is wrapped into WETH tokens and then half of it is swapped for RDNT.
However, there is no slippage protection when swapping WETH for RDNT, making users of these functions vulnerable to sandwich attacks. An attacker may monitor the mempool and front-run zapETH
and addLiquidityETHOnly
transactions by buying a significant amount of RDNT tokens prior to a user's transaction and then selling them immediately afterward, profiting at the cost of the user.
Note that this contract is only used in the BSC deployment, so all references to ETH and WETH are technically BNB and WBNB. ETH terms have been used for convenience since both functions contain references to it in their names.
Consider adding slippage protection to zapETH
and addLiquidityETHOnly
functions to protect against sandwich attacks. The addLiquidityWETHOnly
function is safe since it can only be executed by poolHelper
.
Update: Resolved in pull request #264 at commit 523d4ab.
Invalid Reward Tokens Can Brick Reward Claiming
In order to get a new token added to the list of reward emitting tokens, addresses with the minter role can do so via the MultiFeeDistribution
contract's addReward
function, or alternatively, the owner
or the admin
of MiddleFeeDistribution
can do so as well by calling its addReward
function.
These tokens are always assumed to be AToken
instances or the RDNT token itself. However, if by mistake a different token was added, there is no validation to prevent it as long as it is not the zero address.
Adding an erroneous token will cause multiple parts of the protocol to revert, since this token will not conform to the IAToken
interface and there will not be a proper response from the AaveOracle
when trying to figure out its price. Furthermore, there is no way to remove a reward token from the list, forcing the team to issue an emergency upgrade in order to address the problem if it occurs.
Consider implementing logic to remove reward tokens from the list when they are no longer supported or when they are added by mistake.
Update: Resolved in pull request #248 at commits 6573a30, 16503ae, fe562e4 and 855cfa6. The Radiant team added a function that allows the removal of existing reward tokens if necessary.
Unchecked ERC-20 Return Value
According to the ERC-20 token standard, many function calls return a bool
to signal the success or failure of the call. Such functions include transfer
, transferFrom
and approve
. While many tokens throw an exception when these functions are unsuccessful, some tokens instead return false
.
In the LockZap
contract, the function zapAlternateAsset
does not check the return value of the transferFrom
call.
Consider adding a check on the return value of transferFrom
in this instance. Alternatively, consider using the safeTransferFrom
function from the OpenZeppelin library SafeERC20
, which is already being imported by the LockZap
contract. This library safely handles ERC-20 tokens that behave in different ways, and ensures that all calls revert on failure, regardless of whether the underlying token does.
Update: Resolved in pull request #244 at commit d30d1ab. The unchecked transferFrom
function call was replaced with a call to safeTransferFrom
.
Assets With More Than 18 Decimals Not Supported for Zapping
The LockZap
contract allows zapping any ERC-20 asset into staking via the zapAlternateAsset
function by converting it to the base token (WETH or WBNB depending on the chain), adding liquidity, and then staking those LP tokens within the MultiFeeDistribution
contract. In order to handle slippage, the expected amount of base token is calculated by assuming that the asset's decimals will be equal to or smaller than 18, which may not always be correct. Attempting to zap assets with more than 18 decimals will result in an underflow that will make the transaction revert.
In order to avoid unexpected reverts and be compatible with more asset types, consider refactoring the expectedEthAmount
calculation in zapAlternateAsset
to also support assets with more than 18 decimals.
Update: Resolved in pull request #191 at commit 95400d3.
Users Can Burn Their Tokens Without Incentives
The RDNT token features an unrestricted burn
function which allows anyone to voluntarily burn any token holdings without incentive.
While burning tokens is a necessary action for specific protocol flows, consider restricting who can call this function, in order to avoid accidental loss of user funds in the event that the function is called by mistake.
Update: Acknowledged, not resolved. The Radiant team stated:
We prefer to maintain a degree of flexibility here that will allow for potential future utility that is yet unforeseen.
Shadowed initialized
Variable
The LiquidityZap
contract implements the upgrade proxy pattern and uses the initialize
function to set up storage variables. Additional initialization steps are done in the initLiquidityZap
function which is responsible for setting up pool configuration. The contract uses a boolean variable named initialized
in order to ensure that initLiquidityZap
can be executed only once.
However, LiquidityZap
's variable declaration of initialized
shadows the initialized
variable from the parent contract Initializable
.
Although the code works as expected, this practice is error-prone and should be avoided. Consider renaming the LiquidityZap
storage variable initialized
to something different such asinitializedLiquidity
, to ensure it does not shadow the other initialized
variable from the Initializable
contract.
Update: Resolved in pull request #172 at commits b0cd872 and b416eff.
Duplicate Emission Schedules Are Allowed
When appending a new set of emission schedules, their starting offsets should be in ascending order to make sure they are activated correctly as time goes by.
However, when enforcing this ascending order, it is possible to schedule different emission rates with the same starting time offset. This will cause only the latest one in the list to be enforced when its starting timestamp is reached, ignoring all the other schedule items.
In order to avoid potential misconfiguration and unexpected results, consider reverting when attempting to set an emission schedule that contains duplicated start time offsets.
Update: Resolved in pull request #171 at commit 95fe10c. Before a new emission schedule can be added, it is required that its starting offset is unique and does not match any starting offset in the existing list of emission schedules.
Locked ETH in Contracts
There are several instances in the codebase where ETH can be locked:
- The
receive
function ofLockZap.sol
- The
receive
function ofUniswapPoolHelper.sol
- The
receive
function ofStargateBorrow.sol
In order to avoid accidentally locking ETH on this contract, consider removing the first two instances, and restricting the third one in Stargateborrow
so that only the WETH contract can send ETH.
Update: Resolved in pull request #214 at commit 750dcbf. ETH withdrawal functions were added to LockZap
and StargateBorrow
, protected by the onlyOwner
modifier. In UniswapPoolHelper
the receive
function was removed since the Radiant team deemed it unnecessary.
Unnecessary Truncation of Balancer LP Total Supply
The getReserves
function in the BalancerPoolHelper
contract returns the amounts of RDNT and WETH in the Balancer pool and the corresponding amount of LP tokens.
However, the lpTokenSupply
value is divided by 1e18
before being returned, effectively removing all decimals from the value. This calculation is not done in the getReserves
function of the UniswapPoolHelper
contract.
Even though this lpTokenSupply
value is never used within the codebase, consider removing the division by 1e18
in order to return the total supply of Balancer LP tokens with no precision loss.
Update: Resolved in pull request #215 at commit 23fbf42. The unused lpTokenSupply
return value was removed from the getReserves
function.
Unnecessary access control and incorrect mutability
The BalancerPoolHelper
contract limits access to its getSwapFeePercentage
function with the onlyOwner
modifier. However, there is no reason to limit access to value, since it can be read directly from the pool contract. Additionally, this function does not change the contract's state, but is missing view
function state mutability.
Consider removing the onlyOwner
modifier from the getSwapFeePercentage
function, and also adding view
mutability to correctly reflect its behavior.
Update: Resolved in pull request #198 at commit d790662.
Commented-Out Code
Several contracts within the codebase include commented-out lines of code without giving developers enough context on why those lines have been discarded, thus providing them with little to no value at all. For instance:
As the purpose of these lines is unclear and may confuse future developers and external contributors, consider removing them from the codebase. If they are to provide alternate implementation options, consider extracting them to a separate document where a deeper and more thorough explanation could be included.
Update: Resolved in pull request #245 at commit 100d94e. All instances of commented-out code were removed except one, where the Radiant team added an inline comment that explains the rationale for leaving that instance in place.
Unsafe ABI Encoding
It is common practice to use abi.encodeWithSignature
or abi.encodeWithSelector
to generate calldata for a low-level call. However, the first option is not typo-safe, and the second option is not type-safe. The result is that both of these methods are error-prone and should be considered unsafe.
Line 140 of BountyManager
uses the unsafe encodeWithSignature
ABI encoding.
Consider replacing all the occurrences of unsafe ABI encodings with abi.encodeCall
, which checks whether the supplied values actually match the types expected by the called function, and also avoids errors caused by typos.
Update: Resolved in pull request #216 at commit ce21edd.
Tokens May Get Stuck in Compounder
Contract
The claimCompound
function allows compounding accrued rewards from the MultiFeeDistribution
contract by claiming them, converting them to the base token, and finally zapping them into locked LP tokens via LockZap
. The logic for swapping rewards to the base token has been implemented in _claimAndSwapToBase
by approving uniRouter
to spend amount
of tokens and then executing swapExactTokensForTokens
on Uniswap's router.
Because the swap is inside a try-catch
block, it is possible that the swap fails but the transaction succeeds (as long as the slippage requirement is satisfied), which leads to a scenario where tokens will get stuck in the Compounder
contract without any withdrawal mechanism.
In order to avoid this scenario, consider allowing the swap transaction to revert by removing the try-catch
block.
Update: Resolved in pull request #183 at commit 150782f. Rather than removing the try-catch
block, the team decided to revert on the catch
block with a custom SwapFailed
error that reports the underlying asset address and the intended swap amount.
Inconsistent Use of Named Return Values
Throughout the codebase, most functions that return values use named return variables. However, there are several instances where functions use unnamed return values, such as:
- The
getBridgeFee function
within theRadiantOFT
contract - The first return value from the
lockedBalances
function within theMultiFeeDistribution
contract - The
lockersCount
andgetUsers
functions within theLockerList
library
For consistency, consider always using named return values.
Update: Resolved in pull request #196 at commit e6a9cde.
Incorrect Input Validation
The EligibilityDataProvider
and Compounder
contracts incorrectly validate input parameters used to set the values of storage variables priceToleranceRatio
and slippageLimit
:
-
EligibilityDataProvider
'ssetPriceToleranceRatio
function incorrectly implements validation of the_priceToleranceRatio
parameter, which makes it possible to pass any value as a price tolerance ratio without triggering a revert. -
Compounder
'sinitialize
andsetSlippageLimit
functions incorrectly implement validation of the_slippageLimit
parameter, which makes it possible to pass any value as a slippage limit without triggering a revert.
In both cases, the intended behavior is to accept values between 8000
(80%) and 10000
(100%).
Consider updating the input validation logic to ensure that only values within the intended range are permitted.
Update: Resolved in pull request #195 at commit 82e419d and pull request #230 at commit 22cd008.
Inconsistent Amount Calculation When Providing Liquidity
When users zap their RDNT tokens from vesting, the optimal amount of base token necessary to provide liquidity is calculated via the pool helper quoteFromToken
function, and then an extra 3% safety margin is added on top.
This is inconsistent with the _zap
function's call to the same quoteFromToken
function, where no extra safety margin is added.
Consider applying this extra safety margin consistently when calculating the amount of base tokens required to supply liquidity.
Update: Resolved in pull request #247 at commit 503383f. The Radiant team decided not to use an extra safety margin by using the already existing pool helper's quoteFromToken
function directly.
RDNT Bridge Fee Is Being Overcharged
The new Radiant OFT v2 token allows cross-chain transfers via LayerZero. Such a transfer involves two types of fees: the native LayerZero fee and a bridging fee charged by the Radiant protocol.
In order to enforce cross-chain compatibility (even with non-EVM chains), prior to a transfer LayerZero needs amounts to be truncated to a certain number of decimals configured as token decimals minus a shared decimals value. In this specific case, shared decimals are configured to be 8
and RDNT features 18
decimals, so amounts will be truncated to the closest 1e10
multiple. This means that for example when users want to transfer 1.99e10
tokens, only 1e10
tokens will be transferred.
However, the getBridgeFee
calculation does not truncate input amounts so in the extreme scenario where the minimum value of 1e10
is transferred, the final fee charged will be almost double what it would have been if the amount was truncated first. This effect becomes much less noticeable for larger transfer amounts.
Consider truncating the transfer _amount
before calculating the bridge fee in order to not overcharge users.
Update: Resolved in pull request #217 at commit d59c6a1.
Use of Low Liquidity Source When Compounding on Arbitrum
User rewards can be compounded into locked LP tokens via the Compounder
contract. They can do so themselves, or a hunter can claim a bounty for doing so on their behalf. When claiming an auto-compounding bounty, several swaps are made:
- Each reward token is withdrawn for the underlying token and then swapped for the base token (WETH on Arbitrum and WBNB on BSC).
- A percentage of the total base token is swapped for RDNT as a fee that will be split between the hunter and the bounty reserve.
- Half of the remaining base token will be swapped for RDNT so that liquidity can be provided in the right proportion via
zapOnBehalf
.
In the case of Arbitrum, liquidity is provided on a Balancer pool. This means that the highest liquidity available for WETH and RDNT will be found on that Balancer pool. However, the second swap is performed in a Sushiswap pair, which has far lower liquidity since there is no incentive for users to provide liquidity there.
Consider enforcing consistency to always get the best liquidity possible by performing all swaps between the base token and RDNT on the pool where users lock their LP tokens.
Update: Acknowledged, not resolved. The Radiant team stated:
Seeing as we will be deploying on multiple chains, with varying configurations and combinations of locks, zaps and DEXes, we will not be making a change to the codebase at this time.
Use of Hard-Coded Values
- The
swapToWeth
function in theBalancerPoolHelper
contract contains multiple hard-coded addresses and pool ids that are too tightly coupled with the specific Arbitrum deployment. - Functions
borrow
andborrowETH
are using fixed slippage set to1%
. Consider adding functionality to adjust slippage based on liquidity and market conditions. - The
wethToZap
function is using a fixed value of6%
as a margin for wrapping WETH.
Consider creating constants to store these values or assigning them dynamically upon deployment in order to avoid deployment issues on new chains.
Update: Resolved in pull request #231 at commits 74f00dc and c91894b.
Uniswap V2 Oracle Does Not Scale Price
The UniV2TwapOracle
contract implements the consult
function, which determines the current RDNT price by calculating how many base tokens would be received when swapping one unit of RDNT. When consult
calls the internal _consult
function to get the price, it sets _amountIn
equal to 10 ** decimals
, which represents 1 token.
However, the function latestAnswerInEth
of the parent contract BaseOracle
expects the price returned by consult
to always be scaled to 18 decimals. Based on this expectation, latestAnswerInEth
divides the price received from consult
by 10 ** 10
, which should result in the output having 8 decimals. If the quote token does not use 18 decimals, this will result in an incorrect price value, because the return value of consult
will violate latestAnswerInEth
's precondition. Correct scaling was implemented in UniV3TwapOracle
where the amount of received tokens is scaled to 18 decimals.
Consider scaling the price returned by the consult
function to 18 decimals.
Update: Acknowledged, not resolved. The Radiant team stated:
We don't ever intend to utilize any token outside of RDNT in this context (and we don't intend to utilize any version of the RDNT token that doesn't have 18 decimal places).
Misleading Naming for Variables and Functions
Across the codebase, multiple instances of misleading variable and function names were identified. For example:
- In the
MultiFeeDistribution
contract, themint
function does not mint RDNT tokens. Themint
function assumes that the rewards being added have already been minted and are available to be distributed by the MFD, which may not be true. - The internal
_withdrawExpiredLocksFor
function features a booleanisRelockAction
parameter which is described as an indicator to determine whether the current staking operation is a relock. However, it will not relock those tokens if it is set totrue
. Additionally, the alternativewithdrawExpiredLocksWithOptions
function describes the same parameter as_ignoreRelock
, which is the opposite meaning for the same value. - The
setFee
function within theRadiantOFT
contract accepts an input parameter called_fee
which will be used to update the state variablefeeRatio
. If this_fee
value is larger than a certain threshold, it reverts with an error message specifying "Invalid ratio" and if it does not, it successfully emits theFeeUpdated
event. Consider renamingsetFee
tosetFeeRatio
,_fee
to_feeRatio
, andFeeUpdated
toFeeRatioUpdated
. - The
getBestBounty
function in theBountyManager
contract is responsible for finding and calculating the bounty that can be received for the specified user. It can be executed either by passing an_actionTypeIndex
value that corresponds to one of the bounty types (MFD
,CIC
orCompounder
) or by passing the value of0
which should iterate over all bounty types and find the best available bounty for the user. The logic of the function does not implement that behavior. It iterates over theMFD
,CIC
andCompounder
bounties, until finding one that offers any bounty, not until finding the best one. Consider changing the function name togetAvailableBounty
. - In the
MultiFeeDistribution
contract, theearnedBalances
function returnstotal
andunlocked
earnings balances. Thetotal
variable name is confusing since it seems to account for both vested and already unlocked amounts. However,total
in reality means the total amount in vesting (before applying penalties), excluding any already unlocked amounts. - The
claimFromConverter
function within theMultiFeeDistribution
contract is called by theCompounder
, but the docstrings indicate that rewards are claimed by and transferred to the "converter", not the "compounder". Consider renaming this function toclaimByCompounder
and updating the associated documentation.
Consider providing explicit and consistent naming for both variables and functions all across the project in order to avoid confusion and improve readability.
Update: Resolved in pull request #232 at commits 666d900, dcaa6cd and dc0934b.
LockZap
Does Not Correctly Enforce Maximum Borrowing Power
When zapping assets into locked LP tokens, users need to provide both RDNT and the base token (WBNB or WETH) in equal value in order to provide liquidity. Among other options, users can provide the base token themselves by approving the LockZap
contract to spend them, or they can borrow it from the lending pool against their deposited collateral.
When users decide to borrow against their collateral, there is a check in place to prevent users from borrowing amounts larger than their maximum borrowing power, in order to ensure they maintain an overcollateralized position. This check asks the lending pool how much ETH this user can borrow and compares it with the requested amount, scaled down to 8 decimals.
The original getUserAccountData
function from Aave V2 documentation states that the returned value availableBorrowsETH
specifies an ETH amount with 18 decimals. However, the modified Radiant version of the lending pool returns all values as USD-denominated amounts with 8 decimals. This comparison is flawed since it is comparing USD amounts with ETH amounts. The end result is that at current prices, this condition is always satisfied since ETH amounts are usually lower than the maximum borrowing power expressed in USD amounts, even when trying to borrow beyond your limit.
Consider converting the amount in ETH to USD by querying the ethOracle
in order to correctly enforce that borrow amounts are lower than the actual borrowing power. Even if the lending pool is expected to revert when a user tries to borrow beyond their limit, external code should not be relied on to prevent actions that may lead to insolvency.
Update: Resolved in pull request #192 at commit db69559. The ethOracle
Chainlink ETH/USD price feed is now queried to get the requested borrow amount
in USD. However, the deprecated latestAnswer
function is used to perform the query.
Incorrect Value Used for Slippage Calculations
The LockZap
contract implements functionality to zap assets into liquidity and then stake the received LP tokens in the MultiFeeDistribution
contract. In order to handle slippage and prevent sandwich attacks, LockZap
calculates the equivalent value of the provided tokens in the base token (WETH or WBNB) and then does the same for the received LP tokens in _calcSlippage
. After that it makes sure the required ACCEPTABLE_RATIO
is satisfied.
However, it assumes that the entire token amounts passed to zapTokens
have been used to add liquidity, which is not always true. This may lead to unexpected reverts due to acceptable slippage not being met when the value of the passed asset is higher than the actual value used when providing liquidity.
Consider calculating the exact amount that was used for adding liquidity by retrieving the token balances before and after adding liquidity through the zapTokens
function.
Update: Resolved in pull request #178 at commits 39ff88b, 7d447a4 and af19454. In both the _zap
and zapAlternateAsset
functions, the difference in the WETH or WBNB amount held by the LockZap
contract before and after zapping is now used as the input to the _calcSlippage
function.
Incorrect Fee Returned in Case Compounding Is Not Being Executed
The function claimCompound
of the Compounder
contract returns the fee that is charged for compounding. In case it is a self-compounding operation being executed, there is no fee charged and the function returns 0
. It is also possible to pass the boolean parameter _execute
set to false
as a simulation mode, skipping actual execution. The selfCompound
function always sets _execute
to true
.
However, when claimCompound
is called directly by a user on behalf of themselves with _execute
set to false
, it always returns the auto-compound fee instead of returning 0
for this self-compound operation.
It is recommended to always return a fee value of 0
for self-compounding in claimCompound
. In addition, consider removing the fee
returned by the selfCompound
function to make it clear that there is no fee charged when performing it.
Update: Resolved in pull request #218 at commit e409b80.
Flawed lastEligibleTime
Function
The lastEligibleTime
function should return a value pointing to a future date, since it represents the point in time at which a user's eligibility status might change, always assuming users are currently eligible.
However, if a given user is not currently eligible, this function will still loop through their locks and incorrectly return the nearest unlock time, even when the amount locked does not cover the minimum requirement for eligibility.
Consider handling the scenario where a given user is not eligible differently or, alternatively, provide some more documentation on why this behavior is expected.
Update: Resolved in pull request #226 at commit 80324eb.
Incorrect Bounty Returned When BountyManager
Reserve Is Low
The executeBounty
function of the BountyManager
contract returns the amount of RDNT token that will be paid as a bounty
to the hunter that claims it. At the end of its execution, executeBounty
uses the _sendBounty
function to transfer the bounty
amount to the caller.
However, executeBounty
assumes that the total bounty
was successfully paid, which is not correct when the reserve does not hold enough funds. In that case, the logic of _sendBounty
does a partial payout by sending the remaining reserve balance of RDNT and returns that value from the function. Regardless of the actual amount paid to the hunter, executeBounty
will always return the full bounty amount.
Consider modifying executeBounty
to return the exact amount paid to the hunter, even when the reserve is not large enough. To ensure an adequate balance at all times in the BountyManager
reserve, consider implementing logic that reverts the executeBounty
transaction when there are insufficient funds to pay a bounty. Additionally, consider using OpenZeppelin Defender to detect this event and subsequently pause the contract or fill the reserve.
Update: Resolved in pull request #222 at commit 1a05a30. The executeBounty
function no longer assumes that the reserve balance was sufficient to pay the entire bounty, and now returns the actual bounty amount transferred.
Logic Contracts Initialization Is Allowed
The Radiant protocol implements multiple upgradeable contracts, each having an initializer function meant to be called by their respective proxy. However, nothing prevents users from directly calling the initialize
function on any of the implementation contracts. In this case, the state of the given implementation would be initialized to some meaningful value.
Leaving an implementation contract uninitialized is generally an insecure pattern to follow.
For each initializable contract, consider adding a constructor that calls the _disableInitializers
function to ensure that the implementation contracts cannot be initialized by a third party.
Update: Resolved in pull request #233 at commit b00066b.
Lack of Input Validation
Throughout the codebase, there are several functions that lack input validation. In particular:
- The
owner
of theMultiFeeDistribution
contract can arbitrarily update the value for therewardsLookback
variable, while it is enforced to be non-zero during initialization. - The
owner
of theLeverager
contract can callsetFeePercent
to update the value offeePercent
to 10000 (100%). Consider adding a check that will enforce setting thefeePercent
to a reasonable value. - Consider enforcing that
borrowRatio
is larger than 0 apart from being lower than 100% in order to avoid dividing by zero. Note that the functionsloopETH
andloopETHFromBorrow
should also follow the same recommendation. - The
setXChainBorrowFeePercent
function in theStargateBorrow
contract allowsowner
to update the value ofxChainBorrowFeePercent
to 10000 (100%). Consider adding a check that will enforce setting thexChainBorrowFeePercent
to a reasonable value. - The value of
ACCEPTABLE_RATIO
in theLockZap
contract that is responsible for handling slippage can be set to any value between 0 and 10000 viasetAcceptableRatio
. Consider adding a check that will enforce setting theACCEPTABLE_RATIO
to a reasonable minimum value as it happens in other parts of the codebase. Additionally, theinitialize
function does not enforce any checks on this value. - The value of
_ethLPRatio
accepted by theLockZap
contract initializer function can be 10000 (100%). Consider modifying the check so that a value of 10000 is rejected as invalid. - All functions that handle adding liquidity into the pool within the
LiquidityZap
contract enforce that LP tokens cannot be accidentally burnt by checking that the recipientto
is not the zero address, except thestandardAdd
function. Additionally, this function is the only one that does not check if either of the input amounts is zero. Consider enforcing consistency on input validations including thestandardAdd
function to prevent accidental user loss of funds. - The
setFee
function in theRadiantOFT
contract allows setting the bridge fee equal to 10000 (100%). Consider adding a reasonable limit on the maximum bridge fee. - The
setTWAPLookbackSec
andinitialize
functions in theUniV3TwapOracle
both set the value oflookbackSecs
that is used as a TWAP lookback period. Consider adding a check to these functions that will enforce a minimal value of lookback seconds. - Consider adding a check that
_actionTypeIndex
is less than or equal tobountyCount
in order to avoid an out of bounds error when calling thegetBestBounty
function.
Consider implementing the suggested validations in order to prevent unexpected behaviour that may lead to potential attacks on the protocol.
Update: Resolved in pull request #207 at commits a2de2ee, 400b1b1, bb4b163, ca7ce42, 3eac7d6, 20c279c, 4e23cff and 2ce50e6.
Incorrect Event Emission
Within the LockerList
contract, if an attempt is made to add a locker
address that already exists to the userlist
, it will not be re-added since there is a check that the address is not already inserted. However, the LockerAdded
event is being emitted regardless of whether the user is already on the list.
To avoid hindering the task of off-chain services by emitting misleading information, consider only emitting the LockerAdded
event when adding an address that is not already inserted in the user list.
Update: Resolved in pull request #202 at commit 2d7307a.
Incorrect or Inconsistent Error Types
Throughout the codebase, some instances were found where the custom error used to reflect the reason behind a revert was incorrect or inconsistent with the rest of the codebase. Some examples include:
- The
addReward
function in theMultiFeeDistribution
contract is used to add reward tokens other than RDNT to the system. It contains a check that ensures the_rewardToken
input address is not zero, but instead of reverting with theAddressZero
error it uses theInvalidBurn
error. - The
_stake
function in theMultiFeeDistribution
contract reverts withInvalidAmount
if the providedtypeIndex
is beyond the size of the lock types array. Consider using the more appropriateInvalidType
error. - The
setLPToken
function in theMultiFeeDistribution
contract correctly reverts withAddressZero
when trying to set the staking token value to the zero address. However, if this value was already set, it is also reverting withAddressZero
. Consider leveraging theAlreadyAdded
error or creating a more specific one such asAlreadySet
in order to be more informative. - The
withdraw
function in theMultiFeeDistribution
contract reverts withInvalidAmount
when trying to withdraw a zero amount. Consider using the more explicitAmountZero
error type instead. - When initializing the
BountyManager
contract, if_hunterShare
is larger than 10000 it will revert with anInvalidNumber
custom error. However, when using thesetHunterShare
function to update thehunterShare
variable, it reverts withOverride
under the same conditions.
Consider updating the aforementioned instances in order to make reported errors more accurate and explicit.
Update: Resolved in pull request #209 at commit b61bfe1.
Configuration Changes May Negatively Impact Users
Within the codebase there are several instances of configuration update actions that may have a negative impact on a user's transaction if the configuration change happens just before the user's transaction:
- The
setLockTypeInfo
function in theMultiFeeDistribution
contract can be used by the owner to set or update the lock periods and their multipliers. Any user who wants tostake
or set their default relock type is required to specify an index for the lock period and its multiplier. In the case where the owner's transaction updating lock periods and multipliers is included before the user's call tostake
orsetDefaultRelockTypeIndex
is confirmed, this user will end up potentially committing to an unknown locking period and reward multiplier, different from their expected values. - The
Leverager
contract charges a fee to users that want to leverage their positions. ThefeePercent
can be set at any time by theowner
using thesetFeePercent
function. That leads to a scenario where the owner's transaction to update this fee may be confirmed before the user's looping transaction, resulting in the user being charged a different fee than expected. - The
Compounder
contract charges a fee to users that want to compound their rewards into locked liquidity. ThecompoundFee
can be set at any time by theowner
using thesetCompoundFee
function. If the owner's update transaction gets confirmed before the user's compound transaction, they will be charged a different fee than expected. - The
setXChainBorrowFeePercent
function of theStargateBorrow
contract allows updating the cross-chain fee that will be charged when users want to receive funds on another chain. If the owner's transaction to update this fee is included before the user's borrow transaction, they will end up being charged a different fee than expected.
In these cases, consider allowing users to specify the expected values of the relevant configuration state as additional function parameters so that their transaction will revert when the configuration does not match the expected state.
Update: Acknowledged, not resolved. The Radiant team stated:
During the period of a protocol variable change, the front end can be frozen until the new values are active and displayed. In our opinion, the proposed changes are not worth the likely UX decline.
Rewards Can Be Emitted to the MiddleFeeDistribution
And MultiFeeDistribution
Contracts
The ChefIncentivesController
contract implements the handleActionAfter
function that is used by IncentivizedERC20
(_transfer
, mint
and burn
functions) and StableDebtToken
(mint
and burn
functions) in order to track any changes in user balances after performing those actions. It does not track the balances of rewardMinter
(the MiddleFeeDistribution
contract) nor the MultiFeeDistribution
contract, in order to skip emitting rewards to them.
However, this can be bypassed if the requalifyFor
, stake
, withdrawExpiredLocksFor
, or withdrawExpiredLocksForWithOptions
functions are called with the address of either the MiddleFeeDistribution
or MultiFeeDistribution
contracts as a parameter.
Consider enforcing consistency and preventing any action intended only for protocol users from being performed on behalf of the MiddleFeeDistribution
and MultiFeeDistribution
contracts.
Update: Acknowledged, not resolved. The Radiant team stated:
It is intended that these functions can be executed on behalf of any valid EVM addresses. We have reviewed the exclusion of the MFD and
MiddleFeeDistribution
contracts from the rewards and believe that functionality is sound.
Missing Zero Address Checks
Multiple contracts are missing zero address checks for setting storage variables. Accidentally setting an address variable to address zero might result in an incorrect configuration of the protocol. For instance, the following parameters are not checked:
_multiFeeDistribution
in theinitialize
function of theMiddleFeeDistribution
contract._lpToken
in thesetLPToken
function of theEligibilityDataProvider
contract._rdntAddr
,_wethAddr
,_routerAddr
and_liquidityZap
in theinitialize
function of theUniswapPoolHelper
contract._poolFactory
in theinitialize
function of theBalancerPoolHelper
contract._stETHUSDOracle
and_stEthPerWstETHOracle
in theinitialize
function of theWSTETHOracle
contract.
Consider adding zero address checks to the listed parameters in order to avoid accidental misconfigurations.
Update: Resolved in pull request #203 at commit 37ea95a.
Lack of Access Control on Pool Helper swapToWeth
Functions
Both pool helper contracts UniswapPoolHelper
and BalancerPoolHelper
implement a swapToWeth
function that is responsible for swapping from a variety of source assets into the base token (WETH or WBNB depending on the specific chain) and then transferring them to the message sender. This functionality is used only by the LockZap
contract and unlike the related pool helper functions zapWETH
and zapToken
, it can be called by anyone.
An attacker may steal any token balance held by the UniswapPoolHelper
or BalancerPoolHelper
contracts by calling swapToWeth
, which will swap the specified _inToken
to the base token.
Consider adding a check to the swapToWeth
function of UniswapPoolHelper
and BalancerPoolHelper
to make sure they can only be called from the LockZap
contract.
Update: Resolved in pull request #173 at commits 4ba1fa9 and 6f5fd15.
Lack of Access Control for LiquidityZap
Contract Initialization
The LiquidityZap
contract allows any user to initialize its configuration through the initLiquidityZap
function. During protocol deployment, an attacker can execute initLiquidityZap
before it is called by UniswapPoolHelper
's initializePool
function. As a result, this would require the Radiant team to deploy a new instance of LiquidityZap
.
Consider limiting access to initLiquidityZap
by ensuring only the pool helper can call it, or by adding the onlyOwner
modifier and calling it externally rather than from the pool helper's initializePool
function.
Update: Resolved in pull request #200 at commit 6ea6553. Access to the initLiquidityZap
function is now restricted to the contract's owner, and the function is no longer invoked from initializePool
.
Usage of Insecure Approval Functions
Throughout the codebase there are multiple instances of insecure approvals that may lead to security issues:
- Function
swapToWeth
is used to convert a variety of source assets into the base token (WBNB in this case) and then provide both RDNT and the base token as liquidity to the pool. This functionality is expected to process tokens such as USDC, DAI, USDT or WBTC. It usesSafeERC20
’ssafeIncreaseAllowance
function which cannot handle non-standard ERC-20 token implementations such as USDT on Ethereum Mainnet, because USDT requires the allowance to be reset to zero before setting it to any positive value. If the allowance is not already zero when attempting to change the existing approval to a non-zero value, the USDTapprove
function will revert. Compounder.sol
in lines 154, 172 and 274 uses the deprecatedsafeApprove
function.Leverager.sol
in lines 191, 194, 233, 236, 282, 285 and 386 uses the deprecatedsafeApprove
function.StargateBorrow.sol
in lines 204-205 uses the deprecatedsafeApprove
function.LockZap.sol
in lines 244, 320 and 333 uses the deprecatedsafeApprove
function.BalancerPoolHelper.sol
in lines 113-114 uses the deprecatedsafeApprove
function.UniswapPoolHelper.sol
in lines 60-61 uses the deprecatedsafeApprove
function.
Consider using the SafeERC20
contract's new forceApprove
function instead in order to avoid running into issues when dealing with non-standard ERC-20 implementations.
Update: Resolved in pull request #228 at commits 299d6d5, 25a9377 and 06f49a7.
Notes & Additional Information
Inconsistent SPDX License Identifiers
Throughout the codebase, the MIT SPDX license identifier is used, except for these instances:
wstethOracle.sol
does not contain an SPDX license identifier.PriceProvider.sol
uses the agpl-3.0 license instead of MIT.LiquidityZap.sol
uses the MIT license, but the file contains documentation that indicates the original code was licensed under GPL.
Consider working with a legal team with knowledge about software licensing to resolve these inconsistencies to avoid potential legal issues regarding copyright.
Update: Resolved in pull request #261 at commit df9c768.
Using uint
Instead of uint256
Within MultiFeeDistribution.sol
, uint
is used instead of uint256
.
In favor of explicitness, consider replacing all instances of uint
with uint256
.
Update: Resolved in pull request #239 at commit 9488446.
TODO Comments in the Code
The following instances of TODO comments were found in the codebase:
- The
TODO
comment on line 847 inChefIncentivesController.sol
- The
TODO
comment on line 1070 inMultiFeeDistribution.sol
During development, having well-described TODO comments will make the process of tracking and resolving them easier. Without this information, these comments might age and important information for the security of the system might be forgotten by the time it is released to production.
Consider removing all instances of TODO comments and instead tracking them in the issues backlog. Alternatively, consider linking each inline TODO to a corresponding backlog issue.
Update: Resolved in pull request #263 at commit a5e28ad.
Variables Could Be Marked immutable
Variables that are only assigned a value from within the constructor
of a contract can be declared as immutable
.
Within the codebase, there are several variables that could be marked immutable
. For instance:
- The
lendingPool
,eligibilityDataProvider
,lockZap
,cic
,weth
andaaveOracle
variables in theLeverager
contract - The
tokenV1
andtokenV2
variables in theMigration
contract
To better convey the intended use of variables and to potentially save gas, consider adding the immutable
keyword to variables that are only set in the constructor.
Update: Resolved in pull request #236 at commit ee519f4.
Functions With public
Visibility Could Be external
Throughout the codebase, instances of externally-called functions with public
visibility were found.
Some examples include:
initialize
,pause
,unpause
,autocompoundThreshold
isEligibleForCompound
,userEligibleForCompound
andselfEligibleCompound
functions inCompounder.sol
getVDebtToken
andltv
functions inLeverager.sol
initialize
function inStargateBorrow.sol
isMarketDisqualified
function inEligibilityDataProvider.sol
setFallback
andenableFallback
functions inBaseOracle.sol
setLookback
function inMultiFeeDistribution.sol
getSwapFeePercentage
andsetSwapFeePercentage
functions inBalancerPoolHelper.sol
batchUpdateAllocPoint
function inChefIncentivesController.sol
getPoolHelper
andgetVDebtToken
functions inLockZap.sol
To better convey the intended use of functions and to potentially realize some additional gas savings, consider changing a function's visibility from public
to external
if it is never used internally.
Update: Resolved in pull request #237 at commit 026ec8a.
Unused Named Return Variables
Named return variables are a way to declare variables that are meant to be used within a function body for the purpose of being returned as the function's output. They are an alternative to explicit inline return
statements.
Throughout the codebase, there are multiple instances of unused named return variables. Some examples are:
In ChainlinkAdapter.sol
:
- The
price
return variable in theconsult
function - The
roundId
,answer
,startedAt
,updatedAt
andansweredInRound
return variables in thegetRoundData
function - The
roundId
,answer
,startedAt
,updatedAt
andansweredInRound
return variables in thelatestRoundData
function
In ChefIncentivesController.sol
:
- The
issueBaseBounty
return variable in thecheckAndProcessEligibility
function - The
amount
return variable in theavailableRewards
function
In UniswapPoolHelper.sol
:
- The
optimalWETHAmount
return variable in thequoteFromToken
function
Consider using the existing named return variables or alternatively removing them.
Update: Resolved in pull request #251 at commit e9984e9.
Use Custom Errors
Since Solidity version 0.8.4, custom errors provide a cleaner and more cost-efficient way to explain to users why an operation failed versus using require
and revert
statements with custom error strings.
There are instances of require
statements found in these files:
Leverager.sol
StargateBorrow.sol
BountyManager.sol
BaseOracle.sol
ChainlinkV3Adapter.sol
PriceProvider.sol
UniV2TwapOracle.sol
UniV3TwapOracle.sol
RadiantOFT.sol
There are instances of revert
statements found in this file:
To improve conciseness, consistency, and gas savings, consider replacing hard-coded require
and revert
messages with custom errors.
Update: Resolved in pull request #252 at commits 6e081bc, 6451a5d, and 362c4a4.
Use of Non-Explicit Imports
The use of non-explicit imports in the codebase can decrease the clarity of the code and may create naming conflicts between locally defined and imported variables. This is particularly relevant when multiple contracts exist within the same Solidity files or when inheritance chains are long.
Several instances where global imports are being used in the codebase were identified:
- Lines 4-8 and 10-20 of
Compounder.sol
- Line 4 and Lines 6-10 of
ChainlinkV3Adapter.sol
- Lines 2-5 of
wstethOracle.sol
- Line 10 of
RadiantOFT.sol
- Line 8 of
DustRefunder.sol
- Lines 5-13 and Lines 15-21 of
UniswapPoolHelper.sol
Following the principle that clearer code is better code, consider using named import syntax (import {A, B, C} from "X"
) to explicitly declare which contracts are being imported.
Update: Resolved in pull request #253 at commit c05064a.
Redundant Use of SafeMath
Library
The OpenZeppelin SafeMath library provides arithmetic functions with overflow/underflow protection, but Solidity 0.8.0
has added built-in overflow and underflow checking, supplanting the functionality provided by the library.
Throughout the codebase, the SafeMath
library is being used in contracts with a Solidity version greater than 0.8.0
, resulting in the addition of redundant overflow/underflow checks.
The following contracts import the SafeMath
library:
Leverager.sol
StargateBorrow.sol
BountyManager.sol
EligibilityDataProvider.sol
BaseOracle.sol
ChainlinkV3Adapter.sol
PriceProvider.sol
UniV3TwapOracle.sol
ChefIncentivesController.sol
MiddleFeeDistribution.sol
MultiFeeDistribution.sol
Migration.sol
RadiantOFT.sol
LockZap.sol
BalancerPoolHelper.sol
LiquidityZap.sol
UniswapPoolHelper.sol
Consider removing the SafeMath
import and its associated function calls from the codebase.
Update: Resolved in pull request #254 at commits 18a98c1 and 3c3dd90.
Implicitly Abstract Contract
The consult
virtual function of BaseOracle.sol
is declared but not implemented. As a result, BaseOracle
can never be instantiated directly.
To better signal this intention, consider explicitly marking the BaseOracle
contract as abstract
.
Update: Resolved in pull request #255 at commit 1e74c37.
Follow Solidity Style Guide
There are several occurrences in the codebase where the Solidity style guide is not followed which makes code more difficult to read and prone to errors.
In DustRefunder.sol
:
- Function
refundDust
is internal and its name should start with the underscore_
.
In LiquidityZap.sol
:
- The function
getPairReserves
is internal and its name should start with an underscore (_
). - Public storage variables
_token
and_tokenWETHPair
should start with a lowercase letter, not with an underscore (_
). - The
else
block within_addLiquidity
does not have curly braces. The subsequentif block
where RDNT token proceeds are refunded also does not have curly braces.
In MultiFeeDistribution.sol
:
- The style guide's recommended layout of functions is not followed. Consider grouping view functions together.
- The
getBalances
function uses an underscore on its input parameteraddress _user
. This is inconsistent with the other nearby functions that also accept anaddress user
input parameter, such aswithdrawableBalance
. - All initializer arguments have a leading underscore, except for
priceProvider
. At the same time, all state variables do not have leading underscores, even private mappings, except for_priceProvider
.
In MiddleFeeDistribution.sol
:
- The
emitNewTransferAdded
function is internal and its name should start with an underscore (_
). - The
aaveOracle
parameter of the initializer is the only one without a leading underscore (_
).
In LockerList.sol
:
- The
userlist
state variable should be renameduserList
to follow the same naming convention as the rest of the codebase.
In Leverager.sol
:
- The internal function name
requiredLocked
should start with an underscore (_
).
In StargateBorrow.sol
:
- The function
borrowETH
is internal and its name should start with an underscore (_
).
In BountyManager.sol
:
- Functions
getBestBounty
,getMfdBounty
,getChefBounty
andgetAutoCompoundBounty
are internal and their names should start with an underscore (_
).
In LockZap.sol
:
- The
ACCEPTABLE_RATIO
storage variable name consists of all capital letters which suggests it is a constant variable, but it can be changed via thesetAcceptableRatio
function.
In UniswapPoolHelper.sol
:
- Consider making the value
100000000
more readable by changing it to100_000_000
.
In BalancerPoolHelper.sol
:
- The
computeFairReserves
function is internal and its name should start with an underscore (_
). - The
joinPool
function is internal and its name should start with an underscore (_
). - The
sortTokens
function is internal and its name should start with an underscore (_
).
To improve the readability of the codebase, consider following the Solidity style guide.
Update: Resolved in pull request #256 at commits 66b6ecd, 8a8abf6 and 8a8abf6.
Non-library Contract in libraries
Directory
The LockerList.sol
contract is located in the libraries
directory, but it is not defined using the library
keyword.
To avoid confusion, consider relocating this contract to the staking
directory.
Update: Resolved in pull request #257 at commits 859dacd and a5fbb2f.
Unused Imports
Throughout the codebase there are imports that are unused and could be removed.
For instance:
- Imports
IAToken
,IMultiFeeDistribution
,ILendingPoolAddressesProvider
,ILendingPool
, andILockZap
ofBountyManager.sol
- Imports
SafeMath
,Initializable
, andIChainlinkAggregator
ofChainlinkV3Adapter.sol
- Imports
IChainlinkAggregator
andIBaseOracle
ofwstethOracle.sol
- Import
IERC20Metadata
ofChefIncentivesController.sol
- Import
LockedBalance
ofMiddleFeeDistribution.sol
- Imports
IMultiFeeDistribution
,ILendingPool
,IPoolHelper
, andIERC20DetailedBytes
ofUniswapPoolHelper.sol
Consider removing unused imports to improve the overall clarity and readability of the codebase.
Update: Resolved in pull request #258 at commit f6dfecb.
Inconsistent Gas Optimizations in for
Loops
Throughout the codebase, the unchecked
keyword is used frequently in for
loops to increment the loop index variable. However, the use of unchecked
is inconsistent, with only some loops including it. For instance, in the ChefIncentivesController
contract, there are 11 for loops, but only 2 of them use unchecked
to increment the loops' index.
Within ChefIncentivesController
there are also 3 instances where the counter is pre-incremented:
Consider applying the unchecked
pattern consistently in for
loops throughout the codebase and enforcing consistency on pre-increments vs post-increments on the counter variable, specifying the reason behind each deviation with a comment.
Update: Resolved in pull request #241 at commit cc773b9.
Gas Optimizations
Throughout the codebase, there are several opportunities for gas optimizations:
- In the
MultiFeeDistribution
contract, consider moving thepenaltyAmount
andburnAmount
calculation into the previousif
block in order to skip the calculation whenpenaltyFactor
is not computed due to tokens already being fully vested. - Consider checking if the bridge
fee
is positive before sending it to the treasury. - Consider checking if the looping
fee
is positive before sending it to the treasury on every looping function within theLeverager
in order to save gas when this fee is deactivated. - When calling
loopETH
within theLeverager
contract, the amount borrowed in WETH is fully unwrapped into native ETH in order to send thefee
to the treasury, and then gets wrapped back into WETH before being deposited into the lending pool. Consider just unwrapping thefee
amount in order to skip the unnecessary wrapping operation. - Consider overriding the
OFTCoreV2
internal_sendAndCall
function and adding thewhenNotPaused
modifier. Additionally, consider adding the modifier to_send
directly as well. That way the overridden_debitFrom
can be removed entirely and gas will be saved when the contract is paused. - In the
RadiantOFT
overridden_send
function, consider requiring a positive amount before trying to debit that amount from the caller in order to save gas, and skip the overwriting of theamount
variable after the fact, since it returns the same value as the input parameter. - Consider delaying the call to
getBaseBounty
until the value ofissueBaseBounty
is known. There is no need to retrieve it when paying auto-compound bounties. - In the
ChefIncentivesController
contract, the_mint
function calls the_getMfd
function twice. Consider storing the result from the first call in a local variable that can be used in place of the second call. - Consider removing both the
whenNotPaused
andisWhiteListed
modifiers from theclaim
function in order to avoid executing them twice, since they will be enforced directly on theexecuteBounty
function. - Consider whether it is necessary to emit the
NewTransferAdded
event withinMiddleFeeDistribution
since this is part of the auto-compounder bounty claiming flow of execution. It is important to keep this bounty gas-efficient in order to make sure hunters are incentivized to claim it. - Throughout the codebase,
for
loops that iterate over an array have been optimized by caching the array length on the stack, which avoids repeatedly reading the unchanged length from memory or storage on every iteration. However, several instances were found where this optimization was not already being used:
To improve gas consumption, readability, and code quality, consider refactoring the code in these examples and carefully reviewing the entire codebase.
Update: Resolved in pull request #260 at commits 9b770e2 and c2e0af6. The whenNotPaused
modifier was also removed from the quote
function in the BountyManager
contract. The NewTransferAdded
event emission was left unchanged.
Floating Pragma
Contract wstethOracle
uses the floating pragma ^0.8.0
. It is recommended to set the pragma to 0.8.12
to align with the protocol's practice of locking the pragma for all contracts. This precautionary measure helps prevent the accidental deployment of contracts with outdated compiler versions that could potentially introduce vulnerabilities.
Update: Resolved in pull request #249 at commit f4e029e.
Redundant Code
Several instances of redundant or unnecessary code were found throughout the codebase:
- The
override
keyword is often used in cases where there is no base function body being overridden. Consider eliminating the unnecessary use ofoverride
to avoid confusion. - The
pragma abicoder v2
statement is redundant on the current Solidity version used to compile the project, 0.8.12. Consider whether it should stay for explicitness or alternatively, remove it from all occurrences since it is now enabled by default.
In MiddleFeeDistribution.sol
:
- On line 145 the
forwardReward
function unnecessarily re-computes the value oftotal
. Consider removing this line.
In MultiFeeDistribution.sol
:
- When all user locks are withdrawable, the function
_cleanWithdrawableLocks
overrides the values oflockAmount
andlockAmountWithMultiplier
, and deletesuserLocks[user]
, all of which is unnecessary since it was already computed. - The
individualEarlyExit
function contains a line of unreachable code. TheieeWithdrawableBalances
function returns anindex
value that will be between0
anduserEarnings[onBehalfOf].length - 1
, so theif
statement on line 903 can never be true. Additionally, even if this could happen, the correct behavior in this case would be to revert rather than return.
In ChefIncentivesController.sol
:
- The
initialize
function performs an unnecessaryaddress()
cast on_poolConfigurator
, which is already an address. - The
_emitReserveLow
internal function is only called byclaim
. Consider eliminating this function by moving its code into the location where it is called. - The
endRewardTime
function's calculation forunclaimedRewards
duplicates the existingavailableRewards
function. - The
_isEligible
boolean parameter passed tostopEmissionsFor
is unnecessary since that execution point cannot be reached if_isEligible
istrue
.
In LockerList.sol
:
- The empty constructor is unnecessary since it will be called (and the parent
Ownable
constructor) implicitly.
In Compounder.sol
:
- The contract uses a
<= 0
check for input validation of_compoundFee
, both in theinitializer
and thesetCompoundFee
function. This is an unsigned value, so the value can never be less than zero. - The
_claimAndSwapToBase
function triggers a call toswapExactTokensForTokens
within Uniswap's router, using a deadline value ofblock.timestamp + 600
. This additional 600-second value is not needed; passingblock.timestamp
is sufficient. - The
_wethToRdnt
function triggers a call toswapExactTokensForTokens
within Uniswap's router, using the deadline value ofblock.timestamp + 600
. This additional 600-second value is not needed; passingblock.timestamp
is sufficient.
In StargateBorrow.sol
:
- The constant
POOL_ID_ETH
is not used and should be removed.
In EligibilityDataProvider.sol
:
- The storage variables
eligibleDeposits
anduserDeposits
are declared but never used. - The
isMarketDisqualified
function should be removed since it is not used anywhere and it is not coherent with the current protocol's behavior.
In BountyManager.sol
:
- The storage variable
bountyBooster
does not appear to be used anywhere after being set and can be removed along with the associatedsetBountyBooster
function andBountyBoosterUpdated
event. - The storage variable
slippageLimit
does not appear to be used anywhere after being set and can be removed along with the associatedsetSlippageLimit
function andSlippageLimitUpdated
event. - The check for positive
_amount
in_sendBounty
is redundant since it has to be positive at this point due to this previous condition.
In LockZap.sol
:
- The
setPriceProvider
,setMfd
, andsetPoolHelper
functions perform unnecessaryaddress()
casts on their input variables, which are already addresses.
In UniswapPoolHelper.sol
:
- The
zapTokens
function reassigns the value ofliquidity
. - The
zapWETH
reassigns the value ofliquidity
.
In BalancerPoolHelper.sol
:
- The internal function
swap
is not used and should be removed. - The
swap
function call toIVault(vaultAddr).swap
uses a deadline value ofblock.timestamp + 3 minutes
. The addition of 3 minutes is not needed; passingblock.timestamp
is sufficient.
In ChainlinkV3Adapter.sol
:
- The
update
function logic which updatesethLatestTimestamp
andtokenLatestTimestamp
is not used sincecanUpdate
returnsfalse
. Consider removing theupdate
function and the associated storage variablesethLatestTimestamp
andtokenLatestTimestamp
.
In PriceProvider.sol
:
- The storage variable
eligibilityProvider
is not used and should be removed.
Update: Resolved in pull request #259 at commits 7fe0064, 74f889a, 855c142, e86d67c, ea37c2b and 724e290. The isMarketDisqualified
function was removed in pull request #226 at commit 7f4a0bc.
Inconsistent Use of IERC20
Interfaces
Throughout the protocol, the IERC20
and IERC20Metadata
interfaces are frequently used to interact with ERC-20 tokens, with the latter interface only being used to access the decimals
function of tokens.
However, within the Compounder
contract, the IERC20DetailedBytes
interface is used instead of IERC20Metadata
. The end result is the same, but this approach may be error-prone for future development, since the return value types for the name
and symbol
functions in IERC20DetailedBytes
are different than the ones used in IERC20Metadata
, and there is no apparent reason to favor IERC20DetailedBytes
in the Compounder
contract.
Consider enforcing consistency by replacing the IERC20DetailedBytes
interface with IERC20Metadata
within the Compounder
contract.
Update: Resolved in pull request #262 at commit 659430e.
Use of Magic Numbers
Throughout the codebase, there are several literal values with unexplained meanings.
In Leverager.sol
:
- Consider leveraging the existing
RATIO_DIVISOR
constant on both theconstructor
andsetFeePercent
function when validating_feePercent
instead of the hard-coded1e4
value. - Consider assigning the value
2 ** 16
within theltv
function to a constant variable or alternatively, leveraging the existinggetLtv
calculation which is more self-explanatory and gas efficient. - Consider adding a constant value for calculating margin in
wethToZapEstimation
. - Consider using a constant when adding a safety margin to
wethToZap
. - Consider using a constant when choosing the desired interest rate mode instead of the hard-coded value
2
inzapWETHWithBorrow
.
In Compounder.sol
:
- Consider adding a
MAX_COMPOUND_FEE = 2000
constant to handle validation in theinitialize
andsetCompoundFee
functions. - Consider adding a
MIN_SLIPPAGE_LIMIT = 8000
constant to handle validation in theinitialize
andsetSlippageLimit
. - Consider dividing by
PERCENT_DIVISOR
and not by10000
in_wethToRdnt
. - Consider adding a
MIN_DELAY = 1 days
constant to handle the check inisEligibleForAutoCompound
.
In StargateBorrow.sol
:
- Consider using the
FEE_PERCENT_DIVISOR
constant to validate_xChainBorrowFeePercent
parameter in theinitialize
andsetXChainBorrowFeePercent
functions. - Consider using a constant variable to hold the referral code used (
0
) when interacting with the lending pool in theborrow
andborrowETH
functions.
In EligibilityDataProvider.sol
:
- Consider adding a
INITIAL_REQUIRED_DEPOSIT_RATIO = 500
constant to setrequiredDepositRatio
during initialization. - Consider adding a
INITIAL_PRICE_TOLERANCE_RATIO = 9000
constant to setpriceToleranceRatio
during initialization. - Consider adding a
MIN_PRICE_TOLERANCE_RATIO = 8000
constant to validate the value of_priceToleranceRatio
insetPriceToleranceRatio
.
In BountyManager.sol
:
- Consider adding a
RATIO_DIVISOR = 10000
constant and use it for validating_hunterShare
in theinitialize
,setHunterShare
, andexecuteBounty
functions.
In UniV2TwapOracle.sol
:
- Consider adding a
PERIOD_MIN = 10
constant for validating_period
in theinitializer
andsetPeriod
functions.
In RadiantOFT.sol
:
- Consider using a
SHARED_DECIMALS = 8
constant forOFTV2
constructor initialization. - Consider using
FEE_DIVISOR
for validating the_fee
parameter in thesetFee
function.
In BalancerPoolHelper.sol
:
- Consider adding
RDNT_WEIGHT = 800000000000000000
andWETH_WEIGHT = 200000000000000000
constants for setting pool weights. - Consider adding
INITIAL_SWAP_FEE_PERCENTAGE = 1000000000000000
constant for settingswapFeePercentage
. - Consider dividing by the pool weights ratio instead of using the hard-coded value of
4
withinquoteFromToken
.
In LockZap.sol
:
- Consider using
RATIO_DIVISOR
to validate_ethLPRatio
. - Consider adding constant variables for the values
100
and97
in thequoteFromToken
calculation. - Consider assigning the interest rate value
2
to a constant when borrowing from the lending pool to explicitly represent it as a variable interest rate.
To improve the code’s readability and facilitate refactoring, consider defining a constant for every magic number, giving it a clear and self-explanatory name. For complex values, consider adding an inline comment explaining how they were calculated or why they were chosen.
Update: Resolved in pull request #240 at commits 46a113f, cf2bc80, and f98d6b2.
Typographical Errors
Consider addressing the following typographical errors:
In Leverager.sol
:
- Line 394: "underlyig" should be "underlying".
In BountyManager.sol
:
In EligibilityDataProvider.sol
:
- Line 55: "Elgible" should be "Eligible".
In PriceProvider.sol
:
- Line 123: "heler" should be "helper".
In UniV3TwapOracle.sol
:
- Line 33: "loopback" should be "lookback".
- Line 77: "loopback" should be "lookback".
- Line 78: "Loopback" should be "Lookback".
In ChefIncentivesController.sol
:
In MultiFeeDistribution.sol
:
- Lines 120, 301, and 1100: "startfleet" should be "starfleet".
- Line 259: "convert" should be "converter".
- Line 473: "Earnings which is locked yet" is an incomplete sentence.
- Line 628: "transfered" should be "transferred".
- Line 689: "users" should be "user's".
- Line 1046: "loopback" should be "lookback".
In BalancerPoolHelper.sol
:
- Line 290: "ad" should be "and".
In DustRefunder.sol
:
- Line 11: "remained" should be "remaining".
In LiquidityZap.sol
:
In LockZap.sol
:
Update: Resolved in pull request #238 at commit d042343.
Conclusion
1 critical, 5 high, and 15 medium-severity issues were found, apart from multiple lower-severity findings. Communication with the Radiant team was smooth and the team's thorough answers to our questions were helpful throughout the audit. The Radiant team started working on the higher-severity findings as soon as they were disclosed. The codebase is quite complex and will benefit from incorporating our informational notes and lower-severity recommendations in order to improve its readability and robustness. In terms of documentation, there is high-quality material available to end users, but it is still recommended to generate more technical documentation.
Appendix
Monitoring Recommendations
While audits help in identifying potential security risks, the Radiant team is encouraged to also incorporate automated monitoring of on-chain contract activity into their operations. Ongoing monitoring of deployed contracts helps in identifying potential threats and issues affecting the production environment.
- To ensure the
ChefIncentiveController
contract remains funded, consider monitoring theChefReserveLow
andChefReserveEmpty
events. - To ensure the
BountyManager
contract remains funded, consider monitoring theBountyReserveEmpty
event, and also periodically checking if thebalanceOf
RDNT token held by theBountyManager
drops below some predefined low level. - Monitor positions that meet the criteria for a bounty to make sure incentives are large enough to incentivize hunters to claim them. Otherwise, protocol health might be affected.
- Monitor transaction history for the routine occurrence of bounties, which are a necessary service required by the protocol to ensure a fair distribution of rewards. If users are not performing bounty transactions on a regular basis, this may indicate that the
maxBaseBounty
amount needs to be increased, or the bounty system is not working as intended for some other reason.