Download audit report
This document presents the security review performed by Nethermind Security for Boring Vault Chef smart contracts. The Royco team has forked the BoringVault contract and implemented changes to facilitate the distribution of rewards in multiple tokens and claims. The BoringChef contract, inherited by Boring Vault implements the rewards distribution logic, along with management of epochs and user balances.
This security review focuses exclusively on the smart contracts listed in Section 2 (Audited Files).
The audit was performed using (a) manual analysis of the codebase and (b) creation of test cases.
| Audit assessment | |
| Audit Type | Security Review |
| Initial Report | Feb 19, 2025 |
| Response from Client | Regular responses during audit engagement |
| Final Report | Feb 20, 2025 |
| Repository | boring-vault-chef |
| Commit (Audit) | 9591cffef3 |
| Commit (Final) | fe71b4267f |
| Documentation Assessment | High |
| Test Suite Assessment | Medium |
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | src/base/BoringVault.sol | 72 | 46 | 63.9% | 27 | 145 |
| 2 | src/boring-chef/BoringChef.sol | 380 | 235 | 61.8% | 100 | 715 |
| 3 | src/boring-chef/BoringSafe.sol | 10 | 9 | 90.0% | 3 | 22 |
| Total | 462 | 290 | 62.8% | 130 | 882 |
| # | Finding | Severity | Update |
| 1 | Calling claimRewards with an empty array allows reclaiming rewards | Critical | Fixed |
| 2 | Missing cache initialization leads to double claiming of rewards | Critical | Fixed |
| 3 | Rewards including epochs with no eligible shares cannot be claimed | Medium | Fixed |
| 4 | Incorrect totalSharesBalance update leads to an underflow error | Low | Fixed |
| 5 | Epoch eligible shares can be incorrectly increased | Info | Fixed |
| 6 | Incorrect epoch in the UserWithdrawnFromEpoch event | Info | Fixed |
| 7 | claimRewards allows claiming invalid reward Ids | Info | Fixed |
| 8 | Unused and redundant code | Best Practices | Fixed |
The Royco team developed a fork of the Boring Vault, which uses the Boring Chef to facilitate retroactive reward distribution and claims. The rewards can be in any token and distributed over any historical duration. Depositors who are eligible for these rewards, based on their participation during the specified duration, can claim them from the BoringChef contract.
The system is comprised of three primary smart contracts:
BoringChef contract, exposing the main external vault functions, including manager operations and the enter and exit functions, it outsources complex functionality to external contracts.BoringChef contract.
An epoch represents a defined time period in which user balances are recorded. Each epoch has a start timestamp, an end timestamp, and a total amount of eligible shares, which excludes any funds that do not accrue rewards. The total eligible shares for an epoch differ from the total supply, as the latter includes non-eligible shares.
The manager can transition to the next epoch using the rollOverEpoch function:
function rollOverEpoch() external requiresAuthThis function is invoked through the manage function defined in the BoringVault contract. It transfers eligible shares from the current epoch to the next one.
The enter function allows authorized addresses to deposit ERC20 tokens into the vault. In exchange, the function mints a specified amount of vault shares and assigns them to the receiver address (to).
function enter(address from, ERC20 asset, uint256 assetAmount, address to, uint256 shareAmount)
external
requiresAuthWhen tokens are deposited, the user's participation in the next epoch is increased based on the minted shares. The next epoch’s eligible shares are also incremented by the same amount. If the user has disabled reward accrual, stored share amount will not be updated.
The exit function allows authorized addresses to burn vault shares of a specified user and withdraw a corresponding amount of ERC20 tokens.
function exit(address to, ERC20 asset, uint256 assetAmount, address from, uint256 shareAmount)
external
requiresAuthWhen shares are burned, the user’s participation in the next epoch is reduced. This logic is implemented through the _decreaseCurrentAndNextEpochParticipation function, which handles multiple cases: it first attempts to withdraw from the next epoch and, if insufficient, withdraws from the current epoch. The total eligible shares for each epoch are adjusted accordingly. If the user reward accrual is disabled, no updates occur.
Rewards are distributed by authorized actors within the vault. Each reward is defined by a start and end epoch, a reward token, and a reward rate (the amount of tokens distributed per second). The distributeRewards function enables the distribution of a set of rewards:
function distributeRewards(
address[] calldata tokens,
uint256[] calldata amounts,
uint48[] calldata startEpochs,
uint48[] calldata endEpochs
) external requiresAuth Rewards are assigned unique incremental IDs and can only be distributed for past epochs. The reward amounts are transferred to the BoringSafe contract.
Users can claim their eligible rewards using the claimRewards function by providing an array of reward IDs to claim.
function claimRewards(uint256[] calldata rewardIds) externalFor each reward ID, the user's owed amount is calculated based on the ratio of shares they held during a specific epoch compared to the total eligible shares. The owed amounts for the epochs covered by the reward are summed up and transferred from the BoringSafe to the user.
Rewards are grouped into buckets, each containing up to 256 rewards. The userToRewardBucketToClaimedRewards mapping stores a 256-bit field for each bucket, where the bit at the i-th position indicates whether the user has claimed the reward ID at that position in the bucket.
mapping(address user => mapping(uint256 rewardBucket => uint256 claimedRewards)) public
userToRewardBucketToClaimedRewards;The vault allows authorized addresses to enable or disable reward accrual for a user. Enabling rewards increases both the user's balance update and the eligible shares for the next epoch by their current shares balance.
function enableRewardAccrual(address user) external requiresAuthDisabling reward accrual reduces the user’s balance update and total eligible shares for the next ( or current) epoch by their current balance.
function disableRewardAccrual(address user) external requiresAuthThe risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.
Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:
When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.
Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:
When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.
| Impact / Likelihood | Low | Medium | High |
| High | Medium | High | Critical |
| Medium | Low | Medium | High |
| Low | Info / Best Practices | Low | Medium |
| Undetermined | Undetermined | Undetermined | Undetermined |
To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.
claimRewards with an empty array allows reclaiming rewards| File | Fix commit | Type | Status |
BoringChef.sol | 700addd | Critical | Fixed |
The claimRewards function accepts an array of rewardIds to claim. It calls the internal function _getEpochRangeForRewards to determine the epoch range for each reward and fetch the corresponding Reward structs. Additionally, this function updates the userToRewardBucketToClaimedRewards mapping to mark rewards as claimed by modifying the appropriate bit in the reward bucket.
function _getEpochRangeForRewards(uint256[] calldata rewardIds)
internal
returns (uint48 minEpoch, uint48 maxEpoch, Reward[] memory rewardsToClaim)
{
// ...
// Variables to cache reward claim data as a gas optimization.
uint256 cachedRewardBucket;
uint256 cachedClaimedRewards;
for (uint256 i = 0; i < rewardsLength; ++i) {
// ...
}
// @audit If rewardIds.length is 0, then it would unset all bits for 0th reward bucket.
// Write back the final cache to persistent storage
userToRewardBucketToClaimedRewards[msg.sender][cachedRewardBucket] = cachedClaimedRewards;
}However, when the function is called with an empty rewardIds array, the loop is skipped, and the final line is executed. This results in all bits in the userToRewardBucketToClaimedRewards mapping for the 0th reward bucket being unset, effectively marking them as unclaimed. Consequently, users can reclaim rewards in the 0th reward bucket multiple times.
Recommendation
Consider updating the _getEpochRangeForRewards function to revert when the rewardIds array is empty.
| File | Fix commit | Type | Status |
BoringChef.sol | 1faf594 | Critical | Fixed |
The claimRewards function takes a rewardIds array as an argument and calls _getEpochRangeForRewards to get the epoch range for all rewards to claim and the corresponding Reward structs. A nested mapping userToRewardBucketToClaimedRewards is used to efficiently keep track of claimed rewards per user. Each reward bucket contains batches of 256 contiguous rewardIds. Each bit corresponds to whether the reward is claimed or not. To optimize gas usage, the _getEpochRangeForRewards function caches reward bucket data, minimizing the number of SLOAD and SSTORE operations when claiming rewards
function _getEpochRangeForRewards(uint256[] calldata rewardIds)
internal
returns (uint48 minEpoch, uint48 maxEpoch, Reward[] memory rewardsToClaim)
{
// ...
// Variables to cache reward claim data as a gas optimization.
uint256 cachedRewardBucket;
uint256 cachedClaimedRewards;
for (uint256 i = 0; i < rewardsLength; ++i) {
// Cache management (reading and writing)
{
uint256 rewardBucket = rewardIds[i] / 256;
if (i == 0) {
// @audit cachedRewardBucket is not initialized
cachedClaimedRewards = userToRewardBucketToClaimedRewards[msg.sender][rewardBucket];
} else if (cachedRewardBucket != rewardBucket) {
// @audit invalid claim data written into storage
userToRewardBucketToClaimedRewards[msg.sender][cachedRewardBucket] = cachedClaimedRewards;
// Updated cache with the new reward bucket and rewards bit field
cachedRewardBucket = rewardBucket;
cachedClaimedRewards = userToRewardBucketToClaimedRewards[msg.sender][rewardBucket];
}
// ...
}
}However, the caching mechanism contains an issue. Specifically, during the first iteration of the loop (i = 0), only cachedClaimedRewards is set, but cachedRewardBucket is not. As a result, cachedClaimedRewards may contain claim statuses from any reward bucket, while cachedRewardBucket defaults to 0.
This causes an inconsistency: if the rewardId in the first iteration belongs to a reward bucket other than 0 (i.e., rewardId > 255), the subsequent iteration will incorrectly store the updated claim data in bucket 0, enabling users to claim the same reward multiple times for any rewardId greater than 255, by only passing one rewardId in the argument.
Recommendation
Consider assigning cachedRewardBucket in the first iteration of the loop.
| File | Fix commit | Type | Status |
BoringChef.sol | a4f768c | Medium | Fixed |
The claimRewards function takes an array of rewardIds and calls _getEpochRangeForRewards to fetch the epoch range for all rewards to claim, along with the corresponding Reward structs. This epoch range is then passed to the _computeUserShareRatiosAndDurations function, which calculates the user's share ratios and epoch durations for each epoch within the specified range from minEpoch to maxEpoch.
function _computeUserShareRatiosAndDurations(
uint48 minEpoch,
uint48 maxEpoch,
BalanceUpdate[] storage userBalanceUpdates
) internal view returns (uint256[] memory userShareRatios, uint256[] memory epochDurations) {
uint256 epochCount = maxEpoch - minEpoch + 1;
userShareRatios = new uint256[](epochCount);
epochDurations = new uint256[](epochCount);
// ...
// Loop over each epoch from minEpoch to maxEpoch.
for (uint256 epoch = minEpoch; epoch <= maxEpoch; ++epoch) {
// ...
// Retrieve the epoch data.
Epoch storage epochData = epochs[uint48(epoch)];
uint256 epochIndex = epoch - minEpoch;
// Calculate the user's fraction of shares for this epoch.
// @audit It will revert for epochData.eligibleShares equals to 0
userShareRatios[epochIndex] = epochSharesBalance.divWadDown(epochData.eligibleShares);
// Calculate the epoch duration.
epochDurations[epochIndex] = epochData.endTimestamp - epochData.startTimestamp;
}
}However, an issue arises when calculating the user's share ratios. The function divides the user's shares by the total eligible shares for each epoch, causing the transaction to revert if eligibleShares is zero. Since rewards are distributed across multiple epochs, if any epoch in the range has zero eligible shares, users cannot claim rewards for that rewardId.
Recommendation
Implement a check to handle the case where eligibleShares for an epoch is 0 in _computeUserShareRatiosAndDurations.
Resolution
Fix reversions: a4f768c398c269298c1a51b0e9e8875ddebfd2f6
totalSharesBalance update leads to an underflow error| File | Fix commit | Type | Status |
BoringChef.sol | 2ba2c32PR #8 | Low | Fixed |
The _decreaseCurrentAndNextEpochParticipation function implements the logic to decrease the user's participation for the current or next epoch. This function is invoked in several cases: when a user disables reward accrual, when a share transfer occurs, or when a user exits the vault and burns a specified number of shares. It is designed to adjust the user’s participation for the current or next epoch accordingly.
However, instead of decreasing the balance by the specified amount, the function overrides the stored shares balance with the user's current ERC20 balance. This logic can lead to inaccuracies, particularly when the function is triggered by the disableRewardAccrual flow. In such cases, the contract should set the user balance update to zero (i.e., reducing the balance update by the full balance), while the user's ERC20 balance remains unchanged. The current code mistakenly overrides the balance with the user’s ERC20 balance, leading to potential underflow errors.
function _decreaseCurrentAndNextEpochParticipation(address user, uint128 amount) internal {// ...int128 userBalance = uint128(balanceOf[user]);
// ...
// CASE 1: No deposit for the next epoch.
if (lastBalanceUpdate.epoch <= currEpoch) {
if (lastBalanceUpdate.epoch == currEpoch) {
// @audit totalSharesBalance set to current user ERC20 balance
lastBalanceUpdate.totalSharesBalance = userBalance;
} else {
// @audit totalSharesBalance set to current user ERC20 balance
userBalanceUpdates.push(BalanceUpdate({epoch: currEpoch, totalSharesBalance: userBalance}));
}
// ...
}
// CASE 2: Only deposit made is for the next epoch.
if (balanceUpdatesLength == 1) {
// @audit totalSharesBalance set to current user ERC20 balance
lastBalanceUpdate.totalSharesBalance = userBalance;
// ...
}
// @audit underflow error occures due to incorrect balanceUpdate value
uint128 amountDepositedIntoNextEpoch =
lastBalanceUpdate.totalSharesBalance - secondLastBalanceUpdate.totalSharesBalance;
// CASE 3: Deposit Made for the next epoch and the full withdrawal amount cannot be removed solely from the last update.
if (amount > amountDepositedIntoNextEpoch) {
uint128 amountToWithdrawFromCurrentEpoch = (amount - amountDepositedIntoNextEpoch);
if (secondLastBalanceUpdate.epoch == currEpoch) {... } else {
// ...
// @audit totalSharesBalance set to current user ERC20 balance
// ...
}
// ...
} else {...}
}Recommendation
Consider updating the _decreaseCurrentAndNextEpochParticipation function logic to decrease the stored balance update by the provided amount instead of relying on the user's current ERC20 balance.
Update from Nethermind Security
The following line incorrectly reduces the lastBalanceUpdate.totalSharesBalance by amountToWithdrawFromCurrentEpoch, it should be reduced by the full amount instead. This is because the lastBalanceUpdate contains the shares amount of the next epoch.
| File | Fix commit | Type | Status |
BoringChef.sol | 05c7db3 | Info | Fixed |
The enableRewardAccrual function allows rewards to be enabled for a specified user. It increases the user's next epoch participation based on their current balance, effectively enabling reward accrual starting from the next epoch. However, the function lacks a check to determine whether the user's rewards accrual is already enabled, allowing the function to be called multiple times.
function enableRewardAccrual(address user) external requiresAuth {
//@audit Absence of check if rewards are enabled already
addressToIsDisabled[user] = false;
_increaseNextEpochParticipation(user, uint128(balanceOf[user]));
}The internal _increaseNextEpochParticipation function updates the user's totalSharesBalance using their current ERC20 balance and increases the next epoch's eligible shares by the provided amount. The vulnerability allows repeatedly calling the function and artificially increasing the next epoch's eligible shares, reducing other users' share ratios and preventing them from claiming their eligible rewards.
function _increaseNextEpochParticipation(address user, uint128 amount) internal {
// ...
// Get the epoch data for the current epoch and next epoch (epoch to deposit for)
Epoch storage epochData = epochs[nextEpoch];
// @audit the epoch `eligibleShares` can be incorrectly increased
epochData.eligibleShares += amount;
// Emit event for this deposit
emit UserDepositedIntoEpoch(user, nextEpoch, amount);
}Recommendation
Implement a check in the enableRewardAccrual function to prevent it from being called if rewards accrual is already enabled for the user. It is also recommended to implement a similar check in the disableRewardAccrual function.
UserWithdrawnFromEpoch event| File | Fix commit | Type | Status |
BoringChef.sol | 4d2ceca | Info | Fixed |
The _decreaseCurrentAndNextEpochParticipation function emits the UserWithdrawnFromEpoch event, indicating the withdrawn amount from each epoch for the specified user. However, in the 4th case of the function, where the full withdrawal amount is taken from the next epoch, the event erroneously refers to the current epoch ( currEpoch) instead of the next epoch ( nextEpoch).
function _decreaseCurrentAndNextEpochParticipation(address user, uint128 amount) internal {
// ...
// CASE 4: The full amount can be withdrawn from the next epoch. Modify the next epoch (last) update.
lastBalanceUpdate.totalSharesBalance -= amount;
epochs[uint48(nextEpoch)].eligibleShares -= amount;
// Emit event for this withdrawal.
emit UserWithdrawnFromEpoch(user, currEpoch, amount); //@audit must be `nextEpoch` instead of `currEpoch`
return;
}
}Recommendation
Update the event argument to use the correct epoch: nextEpoch instead of currEpoch.
claimRewards allows claiming invalid reward Ids| File | Fix commit | Type | Status |
BoringChef.sol | d4df93f | Info | Fixed |
The claimRewards function enables users to claim rewards by providing an array of reward IDs. Each claimed reward's bit is marked as 1 in the claimedRewards bit field variable, preventing double claims. However, the function does not validate the provided reward IDs. If a user inputs a reward ID that exceeds the maxRewardId, the corresponding bit is set to true, effectively blocking future claims for that reward.
Recommendation
Implement a validation check within the claimRewards function to ensure that each provided reward ID is valid.
| File | Fix commit | Type | Status |
BoringChef.sol | 5e03120accc265 | Best Practices | Fixed |
We provide a non-exhaustive list of unused code below:
RewardClaimInfo struct is defined in the BoringChef contract but has never been used in the code;/// @dev A record of a user's balance changing at a specific epoch
struct RewardClaimInfo {
/// @dev The epoch in which the deposit was made
uint48 epoch;
/// @dev The total number of shares the user has at this epoch
uint128 totalSharesBalance;
}claimRewards function processes multiple reward IDs by grouping rewards into unique tokens and amounts, minimizing the number of transfer calls to save gas. It initializes arrays for uniqueTokens and tokenAmounts, using uniqueCount to track their lengths. A check ensures that reward campaigns with zero rewards are skipped. The function then loops over the tokens to transfer the accumulated rewards. However, the check if (tokenAmounts[i] > 0) before transferring is redundant, as rewards with zero owed have already been excluded;function claimRewards(uint256[] calldata rewardIds) external {
// ...
for (uint256 i = 0; i < rewardIds.length; ++i) {
// Calculate the total rewards owed for this reward campaign.
uint256 rewardsOwed = _calculateRewardsOwed(rewardsToClaim[i], minEpoch, userShareRatios, epochDurations);
if (rewardsOwed > 0) {
// ...
if (!found) {
uniqueTokens[uniqueCount] = rewardsToClaim[i].token;
tokenAmounts[uniqueCount] = rewardsOwed;
uniqueCount++;
}
// Emit the reward-claim event per reward campaign.
emit UserRewardsClaimed(msg.sender, rewardIds[i], rewardsOwed);
}
}
// Finally, do one transfer per unique reward token.
for (uint256 i = 0; i < uniqueCount; ++i) {
// @audit Redundant check
if (tokenAmounts[i] > 0) {
boringSafe.transfer(uniqueTokens[i], msg.sender, tokenAmounts[i]);
}
}
}Recommendation
Remove the unused and redundant code.
Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:
These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.
The documentation for the Royco Vaults smart contracts is contained in the README file in the project's GitHub repository. This file provides an overview of the Boring Vault Chef architecture and outlines the implemented changes to facilitate reward distribution and claims.
The functions are thoroughly documented. Each function includes detailed comments that explain the logic behind its implementation and the significance of each parameter.
Additionally, the Royco team has provided a comprehensive walkthrough of the project and has addressed all questions and concerns raised by the Nethermind Security team during their regular calls.
The test suite for Royco Vaults smart contracts covers the BoringChef public functionalities. It includes main flows such as entering and exiting the vault, reward distribution and claims, and share transfers. Foundry's fuzzing feature is utilized throughout the test suite to verify that functions handle a wide variety of inputs correctly. However, the test suite could be further enhanced by testing cases with large reward IDs and multiple buckets. In addition, more fuzzing tests could be added to explore different scenarios.
All the relevant issues raised by Slither have been incorporated into the issues described in this report.
All the relevant issues raised by the AuditAgent have been incorporated into this report. The AuditAgent is an AI-powered smart contract auditing tool that analyses code, detects vulnerabilities, and provides actionable fixes. It accelerates the security analysis process, complementing human expertise with advanced AI models to deliver efficient and comprehensive smart contract audits. Available at https://app.auditagent.nethermind.io.
| 34Tests passed | 0Failed |
The complete test output is in the full report.
As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.
This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.
The Leading Engineers of Blockchain Infrastructure