Download audit report
This document presents the results of the security review conducted by Nethermind Security for Mellow Finance. The Mellow protocol provides a sophisticated and flexible infrastructure for on-chain asset management, enabling curators to design and deploy structured financial products with a high degree of security and control.
The system is built around a central Vault contract that orchestrates user deposits, withdrawals, and the delegation of assets to external yield-generating strategies via Subvaults. The protocol's architecture is designed to mitigate common DeFi risks such as front-running and price manipulation through time-buffered queues, while providing granular, function-level access control for all strategy-related operations via a novel Verifier mechanism.
This audit focused on the end-to-end review of the smart contracts that implement the protocol's core logic, including: Vault, Subvault, DepositQueue, RedeemQueue, Oracle, Verifier, ShareManager, FeeManager, and RiskManager.
The audit comprises 4217 lines of Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.
| Audit assessment | |
| Audit Type | Security Review |
| Final Report | September 03, 2025 |
| Repository | mellow-finance/flexible-vaults |
| Initial Commit | 69413d545f |
| Final Commit | 72f689f965 |
| Documentation | Mellow Core Vaults & Flexible Vaults Architecture |
| Documentation Assessment | High |
| Test Suite Assessment | High |
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 64 | src/oracles/OracleHelper.sol | 122 | 19 | 15.6% | 8 | 149 |
| 65 | src/oracles/Oracle.sol | 195 | 23 | 11.8% | 30 | 248 |
| 66 | src/vaults/Subvault.sol | 16 | 1 | 6.2% | 3 | 20 |
| 67 | src/vaults/VaultConfigurator.sol | 65 | 2 | 3.1% | 7 | 74 |
| 68 | src/vaults/Vault.sol | 49 | 1 | 2.0% | 5 | 55 |
| 69 | src/modules/VerifierModule.sol | 25 | 4 | 16.0% | 10 | 39 |
| 70 | src/modules/SubvaultModule.sol | 31 | 6 | 19.4% | 12 | 49 |
| 71 | src/modules/ACLModule.sol | 14 | 2 | 14.3% | 6 | 22 |
| 72 | src/modules/CallModule.sol | 13 | 3 | 23.1% | 4 | 20 |
| 73 | src/modules/ShareModule.sol | 247 | 39 | 15.8% | 41 | 327 |
| 74 | src/modules/VaultModule.sol | 124 | 22 | 17.7% | 26 | 172 |
| 75 | src/modules/BaseModule.sol | 17 | 6 | 35.3% | 9 | 32 |
| Total | 4217 | 1354 | 32.1% | 1139 | 6710 |
| # | Finding | Severity | Update |
| 1 | Inconsistent Price Conversion Logic Leads to Incorrect Oracle Prices | High | Fixed |
| 2 | Off-chain price calculation can result in an infinite loop | High | Fixed |
| 3 | Incorrect Natspec for CUSTOM_VERIFIER verification data | Info | Fixed |
| 4 | Incorrect natspec for ClaimableRequestExists error | Info | Fixed |
| 5 | Use of abi.encodePacked with dynamic types could lead to storage slot collisions | Info | Acknowledged |
The Mellow protocol introduces a modular and upgradeable vault architecture designed for curators and asset managers to create, deploy, and manage structured on-chain financial products. The system aims to abstract the complexities of DeFi, providing a standardized framework for trustless execution of institutional-grade strategies while maintaining transparency and composability.
The architecture is centered around a main Vault contract that serves as the primary user entry point and orchestrates interactions between several specialized modules and peripheral contracts.
The system's functionality is segregated into a collection of distinct, interconnected smart contracts:
Vault: The central contract that aggregates core logic and serves as the facade for the entire system. It coordinates asset management, share issuance, and interactions with subvaults and queues. It is composed of four main modules: ACLModule, ShareModule, VaultModule, and BaseModule.Subvault: An isolated contract responsible for holding assets and executing delegated yield-generating strategies. Curators interact with external protocols through subvaults, with all actions subject to validation by an associated Verifier.DepositQueue & RedeemQueue: These contracts manage user deposits and withdrawals through a time-buffered queuing system. This mechanism introduces a mandatory delay (depositInterval and redeemInterval) between a user's request and its execution, which is designed to protect against front-running and price manipulation attacks.SignatureDepositQueue & SignatureRedeemQueue: Stateless, alternative queues that allow for instant, gas-efficient deposits and redemptions. They bypass the standard time-delay by processing EIP-712 compliant orders signed by a trusted off-chain consensus group.Oracle: A permissioned price oracle responsible for providing secure and validated price reports for vault assets. It is a critical component for converting assets to shares and vice versa. It includes mechanisms to reject or flag reports based on configurable deviation thresholds.Verifier: A crucial access control component paired with each Subvault. It enforces granular, call-level permissions, ensuring that curators can only execute pre-approved interactions with external contracts.Share Manager: An upgradeable contract that handles all logic related to the vault's ERC20 receipt tokens (shares), including issuance, transfers, lockups, and whitelisting.Fee Manager: A centralized module that calculates and directs four types of fees: deposit, redeem, performance, and protocol fees. Fees are typically collected in vault shares.Risk Manager: Enforces configurable deposit limits at both the global Vault level and for each individual Subvault. It tracks asset balances (denominated in shares) to prevent excessive exposure.MellowACL: A system-wide, role-based access control layer built upon OpenZeppelin's AccessControl. It manages permissions for administrative and operational functions.4.2.1 User Deposit Workflow
deposit() on the DepositQueue, which registers a request containing the asset amount and a timestamp. Each user is limited to one pending deposit request at a time.Oracle.depositInterval. This delay mitigates front-running risks.claim() to mint the allocated shares to their account.4.2.2 User Withdrawal Workflow
redeem() on the RedeemQueue, which burns their vault shares and creates a redemption request. To prevent yield-griefing, these requests are non-cancellable.Vault to fulfill pending redemptions, potentially by pulling assets from various Subvault strategies.Oracle report, submitted after the redeemInterval, prices the redemption requests.handleBatches() to process the priced requests, which allocates the underlying assets to the queue for withdrawal.claim() to receive their underlying assets.4.2.3 Delegated Strategy Execution
CALLER_ROLE, executes an external call (e.g., to an external DeFi protocol) through a Subvault.Subvault forwards the call data to its designated Verifier contract for authorization.Verifier validates the call against a set of predefined rules based on its verification type:ONCHAIN_COMPACT: Checks if a hash of the call's components (who, where, selector) exists in an on-chain allowlist.MERKLE_COMPACT / MERKLE_EXTENDED: Validates the call against a Merkle root, allowing for a large set of approved calls with minimal on-chain footprint.CUSTOM_VERIFIER: Delegates the validation logic to an external contract, enabling complex, state-dependent rules.Verifier approves the call, the Subvault executes it. Otherwise, the transaction reverts.The security of the Mellow Vaults relies on a defense-in-depth approach, combining robust access control, strict price validation, and architectural safeguards.
MellowACL, while low-level curator actions are strictly controlled by the Verifier contract, ensuring curators can only perform whitelisted operations.Oracle is permissioned, accepting reports only from trusted actors. All submitted prices are validated against configurable absolute and relative deviation thresholds. Reports that are valid but exceed a `suspicious` threshold are queued for explicit approval by an admin, preventing the system from acting on potentially manipulative price data.DepositQueue and RedeemQueue ensures that user actions are priced by future, unknown oracle reports, neutralizing the advantage of front-runners who might otherwise exploit stale prices.Risk Manager contract enforces system-wide and per-subvault deposit limits, preventing unbounded asset exposure and providing a safeguard against unforeseen protocol risks or economic exploits.The 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.
| File | Fix commit | Type | Status |
src/oracles/OracleHelper.sol | — | High | Fixed |
The OracleHelper contract is responsible for calculating the prices of assets held within a vault, which are then reported to an oracle. The price of each asset is defined relative to a "base asset" through the priceD18 parameter, as explained in the comments of the AssetPrice struct. According to these comments, to convert an amount from a specific asset to the base asset, one should multiply by priceD18.
The getPricesD18(...) function performs two key calculations using priceD18 that are logically inconsistent with each other.
First, when calculating the totalRedeemDemand (the total value of withdrawal demands in terms of the base asset), the code divides the demand_ by assetPrice.priceD18. This contradicts the logic described in the comments.
// ...
// @audit This comment explains that priceD18 is a multiplier.
// @audit - If priceD18 = 2e18, it means that 1 asset = 2 base assets
/**
* Price of the asset expressed via the base asset.
// ...
*/
// ...
$.unprocessedShares += shares_;
if (assetPrice.priceD18 == 0) {
$.totalRedeemDemand += demand_;
} else {
// @audit-issue The demand is divided by the price, not multiplied.
// @audit-issue This should be Math.mulDiv(demand_, assetPrice.priceD18, 1 ether).
$.totalRedeemDemand += Math.mulDiv(demand_, 1 ether, assetPrice.priceD18);
}
// ...Later in the same function, when calculating the final prices of the other assets, the logic correctly multiplies the base asset's price by assetPrices[i].priceD18. This second calculation is consistent with the comments.
// ...
for (uint256 i = 0; i < assetPrices.length; i++) {
if (i != $.baseAssetIndex) {
// @audit This calculation correctly uses priceD18 as a multiplier.
pricesD18[i] = Math.mulDiv(
pricesD18[$.baseAssetIndex], assetPrices[i].priceD18, 1 ether
);
}
// ...The incorrect calculation of totalRedeemDemand will lead to a miscalculation of the base asset's price. Since all other asset prices are derived from the base asset price, the entire set of prices returned by the function will be incorrect. This can severely compromise the protocol's economic security, leading to incorrect vault valuations, unfair distribution of funds upon withdrawal, and potential financial exploits.
Recommendation
Review the intended formula for converting asset amounts to their base asset equivalents. Assuming the comments and the final price calculation logic are correct, the calculation for totalRedeemDemand should be changed from division to multiplication.
| File | Fix commit | Type | Status |
src/oracles/OracleHelper.solsrc/managers/FeeManager.sol | — | High | Fixed |
The OracleHelper contract includes the getPricesD18(...) function, which is designed for off-chain execution by keepers to calculate a vault's share price. This calculation is iterative, using a while(true) loop to find a stable price that correctly accounts for performance and protocol fees. The loop is expected to terminate once the calculated price converges.
The performance fee calculation, handled by the FeeManager contract, is conditional. It is applied only when the share price surpasses its previous high water mark, which corresponds to when priceD18 is less than minPriceD18. This conditional logic can create a scenario where the calculated price oscillates between two values, preventing convergence and causing the loop to run indefinitely.
This issue arises when an iteration calculates a price ( P1) low enough to trigger the performance fee. In the next iteration, the inclusion of this fee results in a higher calculated price ( P2). If P2 is now above the high-water mark, the performance fee is no longer applied in the subsequent iteration, causing the calculated price to revert to P1. This cycle of price oscillation between P1 and P2 means the loop's exit condition is never met.
The core of the issue lies in the while loop in the OracleHelper contract.
// src/oracles/OracleHelper.sol
// ...
uint256 baseAssetPriceD18 = vault.oracle().getReport($.baseAsset).priceD18;
// @audit The loop lacks a circuit breaker and can run indefinitely under certain conditions.
while (true) {
uint256 feeShares = totalShares > recipientShares
? feeManager.calculateFee(
address(vault), $.baseAsset, baseAssetPriceD18, totalShares - recipientShares
)
: 0;
uint256 newBaseAssetPriceD18 = Math.mulDiv(
totalShares + $.unprocessedShares + feeShares,
1 ether,
totalAssets - $.totalRedeemDemand
);
// @audit-issue This equality check may never be true if the price oscillates.
if (newBaseAssetPriceD18 == baseAssetPriceD18) {
break;
}
baseAssetPriceD18 = newBaseAssetPriceD18;
}
// ...The conditional logic in the FeeManager contract.
// src/managers/FeeManager.sol
function calculateFee(address vault, address asset, uint256 priceD18, uint256 totalShares)
public
view
returns (uint256 shares)
{
// ...
if (asset == $.baseAsset[vault]) {
uint256 minPriceD18_ = $.minPriceD18[vault];
// @audit Performance fees are only added if the price is below the high-water mark.
if (priceD18 < minPriceD18_ && minPriceD18_ != 0) {
shares = Math.mulDiv(
minPriceD18_ - priceD18,
$.performanceFeeD6 * totalShares,
priceD18 * 1e6
);
}
// ...
}
}An infinite loop in the off-chain price calculation script will cause it to fail, preventing keepers from submitting updated price reports. This leads to stale prices on-chain, which can halt critical vault operations or be exploited by attackers, resulting in a Denial of Service for the price update mechanism.
Recommendation
Consider re-architecting the iterative price calculation to guarantee convergence.
CUSTOM_VERIFIER verification data| File | Fix commit | Type | Status |
src/permissions/Verifier.sol | — | Info | Fixed |
The getVerificationResult(...) function in the Verifier contract handles the authorization of delegated function calls based on different verification types. One such type is CUSTOM_VERIFIER, which allows for verification logic to be delegated to an external contract.
The natspec documentation for the VerificationPayload struct provides instructions on how to encode the verificationData for each verification type. For CUSTOM_VERIFIER, it incorrectly suggests using abi.encodePacked(address customVerifier, customVerifierSpecificData).
/// @notice Struct containing all inputs required to verify a delegated function call.
struct VerificationPayload {
/// @dev The method used to verify the delegated call.
VerificationType verificationType;
/// @dev Encoded payload to be verified, varies by verification type:
/// - MERKLE_COMPACT: abi.encode(who, where, selector)
/// - MERKLE_EXTENDED: abi.encode(who, where, value, callData)
// @audit-issue Natspec suggests abi.encodePacked, which is incompatible with the implementation.
/// - CUSTOM_VERIFIER: abi.encodePacked(address customVerifier, customVerifierSpecificData)
bytes verificationData;
/// @dev Merkle proof used to validate the `verificationType` and `verificationData` for MERKLE_COMPACT,
/// MERKLE_EXTENDED, and CUSTOM_VERIFIER types.
bytes32[] proof;
}The issue arises in the getVerificationResult(...) function, where the verifier address is extracted from verificationData using an assembly block with the calldataload instruction. This instruction reads a full 32-byte word.
function getVerificationResult(...) public view returns (bool) {
// ...
} else if (payload.verificationType == VerificationType.CUSTOM_VERIFIER) {
address verifier;
assembly {
// @audit This instruction reads a full 32-byte word from calldata.
verifier := calldataload(verificationData.offset)
}
return ICustomVerifier(verifier).verifyCall(who, where, value, data, verificationData[0x20:]);
// ...
}If a developer follows the natspec and uses abi.encodePacked, the 20-byte verifier address will be tightly packed with customVerifierSpecificData. The calldataload instruction would then read the address plus the first 12 bytes of the custom data, resulting in a corrupted address and causing the verification to always fail. The correct encoding should pad the address to 32 bytes, for example, by using abi.encode(...).
This discrepancy between the documentation and the implementation can mislead developers, causing them to create invalid payloads and breaking the CUSTOM_VERIFIER functionality for anyone who relies on the natspec.
Recommendation
Consider updating the natspec comment for verificationData in the VerificationPayload struct. Instead of abi.encodePacked(...), it should specify abi.encode(...) or another encoding method that ensures the customVerifier address is padded to a full 32-byte word to align with the implementation.
ClaimableRequestExists error| File | Fix commit | Type | Status |
src/queues/DepositQueue.solsrc/interfaces/queues/IDepositQueue.sol | — | Info | Fixed |
The cancelDepositRequest(...) function in the DepositQueue contract allows a user to cancel their pending deposit request. The function checks if the request has become claimable by comparing the request's timestamp with the latest price checkpoint timestamp. If the request is claimable, the function reverts with the ClaimableRequestExists error.
// src/queues/DepositQueue.sol
function cancelDepositRequest() external nonReentrant {
// ...
(bool exists, uint32 timestamp,) = $.prices.latestCheckpoint();
// @audit The function reverts if a user tries to cancel a request that is already claimable.
if (exists && timestamp >= request._key) {
revert ClaimableRequestExists();
}
// ...
}However, the natspec documentation for the ClaimableRequestExists error in the IDepositQueue interface is incorrect. It states that the error is thrown when a user tries to deposit again, which is not the case.
// src/interfaces/queues/IDepositQueue.sol
// @audit-issue The natspec incorrectly states the error is for depositing again.
/// @notice Thrown when a user tries to deposit again while a claimable request exists.
error ClaimableRequestExists();This discrepancy can mislead developers, auditors, and integrators about the actual behavior of the cancelDepositRequest(...) function, potentially causing confusion when interacting with the contract.
Recommendation
Consider updating the natspec for the ClaimableRequestExists error to accurately reflect its usage. The comment should clarify that the error is thrown when a user attempts to cancel a deposit request that has already become claimable.
abi.encodePacked with dynamic types could lead to storage slot collisions| File | Fix commit | Type | Status |
src/libraries/SlotLibrary.sol | — | Info | Acknowledged |
The SlotLibrary contract provides a helper function, getSlot(...), to compute deterministic storage slots for various modules based on a contractName, an instance name, and a version. This mechanism is used by contracts such as VerifierModule and Verifier to derive their unique storage locations.
The implementation of getSlot(...) uses abi.encodePacked(...) to concatenate multiple dynamic string arguments. This function is known to be unsafe when used with multiple dynamic types, as it can lead to hash collisions. For example, the following two different sets of inputs would produce the same output:
abi.encodePacked("VerifierModule", "example", 1);abi.encodePacked("Verifier", "Moduleexample", 1);This is because abi.encodePacked directly concatenates the byte representations of the strings without any padding or delimiters.
function getSlot(string memory contractName, string memory name, uint256 version)
internal
pure
returns (bytes32)
{
// @audit-issue Use of abi.encodePacked with multiple dynamic types can lead to collisions.
return keccak256(
abi.encode(
uint256(
keccak256(
abi.encodePacked(
"mellow.flexible-vaults.storage.",
contractName,
name,
version
)
)
) - 1
)
) & ~bytes32(uint256(0xff));
}This vulnerability could allow two different modules, for instance, a VerifierModule with name set to "example" and a Verifier with name set to "Moduleexample", to be assigned the same storage slot if they share the same version. This would cause the storage of one module to overwrite the other, leading to state corruption and unpredictable behavior.
Recommendation
Consider revisiting the slot generation logic in light of the potential for hash collisions with abi.encodePacked. While the risk may be low, it is important to be aware of this behavior. Adding explanatory comments to the function could also serve to inform future developers of this subtlety.
Resolution
Acknowledged, not applicable. This issue is mitigated by the current assumptions: contractName always corresponds to the actual contract name, and name is static, so storage collisions are impossible.
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 Mellow Protocol team provided an overview of the main system components during the kick-off call with a detailed explanation of the intended functionalities. They also supplied Notion documentation detailing the contracts' inner workings and flow: Mellow Core Vaults & Flexible Vaults Architecture. Moreover, the team addressed all questions and concerns from the Nethermind Security team, offering valuable insights into the project's technical aspects.
| 430Tests 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