Download audit report
This document presents the results of a security review conducted by Nethermind Security for the OpenCover's CoveredMetavault contracts.
OpenCover is a specialist DeFi risk transfer provider, enabling individuals, businesses and institutions to protect themselves against technical and economic risks onchain.
The CoveredMetavault contract is a new primitive that implements the concept of an insured yield vault, enabling composable on-demand coverage natively onchain. It is a UUPS-upgradeable vault designed to wrap an underlying ERC-4626 compatible asset. It follows and extends the ERC-7540 standard to handle asynchronous deposits and redemptions through a request-settle-claim lifecycle managed by epochs. The protocol incorporates a configurable premium-streaming mechanism that directs a portion of settled assets to a designated collector to pay for coverage progressively from collected yield rather than upfront
The audit comprises 916 lines of the Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.
| Audit assessment | |
| Audit Type | Security Review |
| Initial Report | December 05, 2025 |
| Final Report | December 17, 2025 |
| Initial Commit | 5685593c2b |
| Final Commit | 84823a3b09 |
| Documentation Assessment | High |
| Test Suite Assessment | High |
Findings by severity
| # | Contract | LoC | Comments | Ratio | Blank | Total |
| 1 | src/CoveredMetavault.sol | 767 | 259 | 33.8% | 237 | 1263 |
| 2 | src/Constants.sol | 5 | 5 | 100.0% | 4 | 14 |
| 3 | src/libraries/PercentageLib.sol | 16 | 8 | 50.0% | 6 | 30 |
| 4 | src/interfaces/IERC7540.sol | 33 | 83 | 251.5% | 16 | 132 |
| 5 | src/interfaces/IERC7540Cancel.sol | 19 | 37 | 194.7% | 6 | 62 |
| 6 | src/interfaces/ICoveredMetavault.sol | 71 | 104 | 146.5% | 40 | 215 |
| 7 | src/interfaces/IERC7575.sol | 5 | 7 | 140.0% | 2 | 14 |
| Total | 916 | 503 | 54.9% | 311 | 1730 |
The CoveredMetavault is a UUPS upgradeable ERC-7540 vault, which implements an asynchronous process for deposits and redemptions. The vault's primary role is to stream premium for the insurance cover.
The users can make deposit and redemption requests that wait in a queue to be settled by entitled roles only. Post settlement, users can claim the processed requests.
The critical operations and management of the vault are governed by a role-based access control system with segregated roles for settlement, configuration, and pausability.
The deposits and redemptions in CoveredMetavault are implemented as a three-phase process.
settle function, which processes pending requests for deposit and settlement in a batch. Every settlement also advances the vault's epoch while computing the proportional assets or shares for the pending requests.
Below are the actors and their entry points.
CoveredMetavault.requestDeposit: Submits assets to the vault to be queued for a future deposit.requestRedeem: Submits vault shares to be queued for a future redemption.deposit/mint: Claims vault shares after a deposit request has been settled by a Keeper.redeem/withdraw: Claims underlying assets after a redemption request has been settled by a Keeper.cancelDepositRequest: Cancels a pending deposit request and withdraws the submitted assets.cancelRedeemRequest: Cancels a pending redemption request and withdraws the submitted shares.settle: Processes all pending deposit and redemption requests, making them claimable by users.cancelDepositRequestForController: Cancels a pending deposit on behalf of a specific user.pushDepositShares: Forcefully mints and sends shares to a user from their settled deposit balance.pushRedeemAssets: Forcefully redeems and sends assets to a user from their settled redemption balance.settleMaturedRedemption: Settles a specific redemption request if it has remained pending beyond a predefined maturity period, providing an exit path for users.CoveredMetavault.pause: To pause the functionality of the vault in case of unexpected scenarios.unpause: To release the vault from a paused state.CoveredMetavault.setPremiumRateBps: Configures the premium rate.setPremiumCollector: Configures the address that receives the streamed premium.setMinimumRequestAssets: Configures the minimum amount of assets that can be deposited into the vault.The segregation of assets in the CoveredMetavault functions as a tiered system that separates user intent from protocol accounting. The lifecycle begins with Pending Assets, which act as a temporary loading dock. The funds physically exist in the contract's balance but are mathematically ignored by the vault’s valuation logic. This isolation ensures that unverified deposits cannot manipulate the share price, while simultaneously allowing users to cancel and receive a full refund without affecting the broader system.
Once the Keeper executes a settlement, these funds move into the Settled Assets category.This qualifies the assets to be charged the management fee, yet they remain excluded from the exchange rate calculation.
The final stage of the life cycle occurs when the user manually claims their deposit, converting Claimable Assets into Active Assets. Only at this point are shares minted to the users at the current rate.
The CoveredMetavault uses two distinct Time Clocks to manage the state of user deposits.While they both track time, they serve completely different purposes.
pending state associated with the current epoch. Once the Keeper calls settle(), that epoch closes, a snapshot of the exchange rate between units and pending assets is taken, and these pending assets are effectively converted into claimable units.hard reset for the claimable deposit pool in the rare event that all assets are consumed. Because the pending pool is subject to continuous premium fees, it is mathematically possible for the asset balance to decay to zero. if this happens, the contract increments the Era counter, effectively declaring bankruptcy on the old pool and wiping out any unclaimed units associated with it. This ensures that when new liquidity enters the vault, it starts fresh in a new Era without being diluted by the debt of users who spent their funds in the previous Era.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/CoveredMetavault.sol | b34107e | Low | Fixed |
The CoveredMetavault utilizes a concept of "claimable eras" to manage the lifecycle of the deposit pool. When the totalClaimableAssets of the vault are fully drained (e.g., due to premium streaming), the claimableEra increments, effectively marking the previous pool as dead.
The _syncEpoch(...) function is responsible for updating a controller's state to match the current vault state. It handles two main transitions:
claimableEra is behind the global era, it resets the controller's claimableUnits to zero and updates the era;lastSyncedEpoch is behind the current epoch, it processes any pendingAssets by calculating the corresponding unit assignment and adding them to claimableUnits;The issue is that pendingAssets represents deposits that were requested in a previous epoch and settled into the global pool at that time. If the global claimableEra has incremented since the user's last sync, it implies that the pool—to which those pendingAssets were contributed—has been fully exhausted.
However, the logic in _syncEpoch clears claimableUnits upon an era change but does not clear pendingAssets. The execution flows immediately into the epoch sync block, where it converts these stale pendingAssets (using the historical epochAllocations) into units and adds them to the controller's claimableUnits.
Since the controller's era was just updated to the *new* era, these units are now valid claims against the *new* era's pool. This effectively allows a user to carry over a claim from a drained/dead pool into a fresh pool, potentially diluting new depositors or stealing assets they did not contribute to.
Recommendation
Consider resetting pendingAssets to zero and updating the lastSyncedEpoch when a controller is detected to be in an outdated era. This ensures that claims tied to a fully drained pool are correctly discarded.
Resolution
Fixed in commit b34107. Resolved by pre-minting metavault shares during settlement, simplifying vault accounting and eliminating the need for eras.
| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | b5a250c | Low | Fixed |
The CoveredMetavault enforces a minimum asset amount ( minimumRequestAssets) for asynchronous redemption requests initiated via requestRedeem(...).
This check ensures that the resulting underlying asset value from the requested shares meets the minimum threshold.
The constraint is enforced within requestRedeem(...) before the redemption request is recorded:
function requestRedeem(uint256 shares, address controller, address owner)
external
override
// ...
returns (uint256 requestId)
{
require(shares != 0, ZeroShares());
require(controller == owner, InvalidController(controller));
require(owner == msg.sender, InvalidOwner(owner));
uint256 redeemAssetAmount = _convertToAssets(shares, Math.Rounding.Floor);
_requireMinimumRequestAssets(redeemAssetAmount); // The minimum request assets check is enforced here.
// ...
}If a user's total remaining share balance corresponds to an underlying asset value that is less than minimumRequestAssets, they will be blocked from initiating a withdrawal using requestRedeem(...) because the check in _requireMinimumRequestAssets(...) will revert.
While the assets are not permanently locked, this mechanism hinders a user's ability to fully exit the position if their final balance is low.
Recommendation
Consider modifying the minimum request asset requirement to allow requests that are either greater than or equal to the configured minimum, OR requests that represent the user's entire remaining balance. This maintains the protection against "dust" operations while guaranteeing that users can always fully exit their position.
Resolution
Fixed in commit b5a250. Removed this restriction for redemptions, allowing users to always redeem their full remaining balance. Minimums are now enforced by the offchain settlement engine that operates the keeper, while users can still settle and claim redemptions permissionlessly via settleMaturedRedemption.
| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | b34107e | Info | Fixed |
The _streamPremium() function calculates and transfers a premium fee based on the total settled assets of the vault. Since the vault's assets are logically partitioned into "backed assets" and "claimable assets", the premium payment must be proportionally deducted from both pools.
When updating the totalClaimableAssets to reflect the deduction, the contract calculates the new balance using the ratio of assets remaining after the premium payment:
// src/CoveredMetavault.sol
// ...
if (claimableAssetsTotal != 0) {
uint256 newClaimableAssetTotal =
claimableAssetsTotal.mulDiv(assetsAfter, assetsBefore, Math.Rounding.Floor); // @audit-issue Rounding down the remaining assets
// ...
_vaultStorage.totalClaimableAssets = newClaimableAssetTotal;
// ...By using Math.Rounding.Floor to calculate newClaimableAssetTotal, the contract rounds down the remaining assets in the claimable pool. This effectively means the reduction applied to the claimable pool (the premium paid by it) is rounded *up*.
Consequently, the claimable pool bears the burden of the rounding error, paying slightly more premium than the backed assets pool. Over time, this slight overpayment creates a disadvantage for users whose assets are sitting in the claimable pool waiting to be claimed, as their claimable units will correspond to a slightly smaller total asset amount than mathematically precise.
Recommendation
The current implementation places the burden of precision loss on the claimable pool. While switching to Math.Rounding.Ceil would shift this burden to the backed pool, the current design systematically favors the backed assets.
Consider evaluating this trade-off. If the intent is to protect the backed pool, no change is needed, but consider documenting this behavior to incentivize users to finalize claims.
Resolution
Fixed in commit b34107. Resolved by pre-minting shares at settlement so that premiums are applied via the share price only, eliminating the need to scale the claimable pool.
| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | 3fc8d33 | Info | Fixed |
The CoveredMetavault uses the lastPremiumTimestamp state variable to track when the premium was last streamed. A value of 0 is intended to serve as a sentinel indicating that the vault has not yet undergone its first settlement, ensuring that no premium is accrued or charged for the period preceding the first epoch. This logic is enforced in the _streamPremium function, which checks that lastPremiumTimestamp != 0 before calculating the premium.
However, the lastPremiumTimestamp variable can be set to a non-zero value before the first settlement in two scenarios:
initialize(...) function explicitly sets lastPremiumTimestamp to block.timestamp if the initial premiumRateBps is non-zero;setPremiumRateBps(...) function calls _streamPremium() to settle accrued premiums before updating the rate. The _streamPremium() function unconditionally updates lastPremiumTimestamp to the current timestamp at the end of execution, even if the calculation was skipped because the previous timestamp was 0;In both cases, lastPremiumTimestamp becomes non-zero before the first settle() call. As a result, when the first settlement eventually occurs, the vault will calculate and charge premiums for the duration elapsed since initialization or the rate update. Note that is not expected for the vault to hold funds at this time.
Recommendation
Consider modifying the initialize(...) function to ensure lastPremiumTimestamp remains 0, even if a premium rate is set. Additionally, update the setPremiumRateBps(...) function to check if lastPremiumTimestamp is 0; if so, it can skip the _streamPremium() call and simply update the rate, preserving the sentinel value until the first settlement.
Resolution
Fixed in commit 3fc8d3. Resolved by removing the lastPremiumTimestamp assignment from the initialiser and guarding premium streaming in setPremiumRateBps to preserve the sentinel value until the first settlement.
requestRedeem function uses an external call to transfer vault's shares| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | 915324e | Info | Fixed |
The CoveredMetavault contract functions has the requestRedeem function to return shares to the vault in exchange for assets. The function explicitly validates that the caller is the owner of the shares or has the appropriate permissions.
However, the contract uses an external call to transfer the shares from the user to the contract itself.
function requestRedeem(uint256 shares, address controller, address owner) external {
// ...
if (msg.sender != owner) {
revert InvalidOwner(owner);
}
// ...
// @audit-issue This requires prior approval and unnecessarily increases gas costs.
IERC20(address(this)).safeTransferFrom(owner, address(this), shares);
// ...
}By using safeTransferFrom on address(this), the contract triggers the standard ERC20 allowance check. This creates a circular dependency where the user must approve the vault contract to spend the vault's own tokens, even though the user is the one initiating the transaction.
This leads to a degraded User Experience as the user must sign and broadcast two transactions (approve and redeem) instead of one. Additionally, it increases the gas cost due to the external CALL opcode and the storage operations required for the allowance check.
Recommendation
Consider replacing the external safeTransferFrom call with the internal _transfer function inherited from the ERC20Upgradeable contract. This will bypass the redundant allowance check and allow users to redeem their shares in a single transaction.
Resolution
Fixed in commit 915324. Resolved by replacing the external safeTransferFrom with an internal _transfer call when locking vault shares in requestRedeem.
_computeUnitAssignment favors early claimers| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | b34107e | Info | Fixed |
The _computeUnitAssignment function determines the share of claimable units a controller receives based on their pending assets. The algorithm calculates maxAssignableUnits by determining the minimum number of units that must be reserved for the remaining unassignedAssets.
However, the function implements an "optimistic" rounding mechanism. If the standard pro-rata calculation ( unitsToAssign) is less than maxAssignableUnits and the division results in a remainder, the function explicitly increments unitsToAssign by 1.
// ...
// Start from floor and clamp for extra safety.
unitsToAssign = pendingAssets.mulDiv(epochTotalUnits, epochTotalAssets, Math.Rounding.Floor);
if (unitsToAssign > maxAssignableUnits) unitsToAssign = maxAssignableUnits;
// @audit-issue This logic rounds up the assignment, consuming units meant for later users.
// Grant at most +1 unit if it won't breach the remaining units limit.
if (unitsToAssign < maxAssignableUnits) {
uint256 remainderNumerator = mulmod(pendingAssets, epochTotalUnits, epochTotalAssets);
if (remainderNumerator != 0) {
unitsToAssign += 1;
if (unitsToAssign > maxAssignableUnits) unitsToAssign = maxAssignableUnits;
}
}
}This logic systematically favors users who sync their state (claim) earlier in the epoch. By rounding up their unit allocation, early claimers extract more value from the epochTotalUnits pool than their exact pro-rata share. Since the total number of units is fixed, this over-allocation reduces the unassignedUnits available for subsequent users. Consequently, users who claim later will inevitably receive fewer units than their fair share to balance the epoch, resulting in an unfair distribution based on claim order.
Recommendation
Consider removing the conditional block that increments unitsToAssign by 1. Relying on standard floor rounding ( Math.Rounding.Floor) for all unit assignments ensures that every user receives a consistent and fair share of the units relative to their assets, regardless of the order in which they sync.
Resolution
Fixed in commit b34107. Replaced _computeUnitAssignment with a simpler, per-epoch, floor-based share allocation that removes order-dependent bias.
claimableRedeemRequest returns non-zero for already claimed request| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | — | Info | Acknowledged |
claimableRedeemRequest is a view function that returns the number of shares that can be claimed, if the redeem request is already settled. But, this number is valid only until the actual claim is processed. After the shares are already claimed via redeem, the request will not have claimable shares as they were already processed.
function claimableRedeemRequest(uint256 requestId, address controller)
external
view
override
returns (uint256 claimableShares)
{
RedeemRequestStorage storage redeemRequestStorage = _vaultStorage.redeemRequests[requestId];
if (redeemRequestStorage.controller != controller || redeemRequestStorage.timestamp == 0) return 0;
// Audit - this is valid only under the shares are not claimed by the user. Post redeem call, this function should return 0 shares considering the shares were already claimed.
==> return redeemRequestStorage.settled ? redeemRequestStorage.shares : 0;
}Recommendation
Consider documenting this behavior for integrators and users that can use this feature.
Resolution
Documented the current behaviour in both NatSpec comments and the integrator guides.
| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | 5960af6 | Best Practices | Fixed |
The transferOwnership() function from the Vault contract is used to transfer control of the contract to a new address. The function validates that the newOwner is not the zero address, revokes the owner role from the caller, and grants the owner role to the newOwner address.
However, the function performs this transfer in a single step. It does not verify that the newOwner account is accessible or correct beyond the zero address check.
This means that if the current owner accidentally passes an incorrect address, the ownership of the vault will be permanently lost. Since the current owner revokes their own role immediately during the call, there is no way to recover access.
function transferOwnership(address newOwner) external {
// ...
require(newOwner != address(0), "Invalid address");
_revokeRole(OWNER_ROLE, msg.sender);
// @audit If newOwner is incorrect, ownership is lost forever.
_grantRole(OWNER_ROLE, newOwner);
}Recommendation
Consider implementing a two-step ownership transfer process. This pattern allows the current owner to propose a newOwner address, but the transfer is not finalized until the newOwner explicitly accepts it. This approach ensures that the newOwner address is valid and under control before the previous owner relinquishes their rights.
Resolution
Fixed in commit 5960af. The contract now implements a two-step ownership transfer process.
| File | Fix commit | Type | Status |
src/CoveredMetavault.sol | 08e0746 | Best Practices | Fixed |
CoveredMetavault is an upgradeable contract. In order to minimise the storage layout conflicts, it is recommended to use Namespaced storage instead of using the storage slot of this contract.
Recommendation
Use namespace storage for storage variables.
Resolution
Fixed in commit 08e074. Resolved by using ERC-7201 compliant namespaced storage for vault state.
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 development team provided a detailed overview for the CoveredMetaVault contract and its purpose. The code also contains inline comments to describe the functionality.The development team was collaborative during the audit, providing answers to all questions raised by the Nethermind Security team during calls and through asynchronous communications.
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.
| 224Tests 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