Overview
This report covers the security review for LayerCover protocol, a yield protocol that allows for risk tranching. Our security assessment was a full review of the code, spanning a total of 1 week. During our review, we identified one critical severity vulnerability that could have resulted in major disruption and potential asset loss. We also identified several minor severity vulnerabilities. All reported issues were fixed by the development team and subsequently verified by us. We can confidently say that the overall security and code quality have increased af ter completion of our audit.
Scope
The analyzed resources are located on:
https://github.com/LayerCover/protocol/tree/bcbedc04cd65bea13ceb181d6a69b0747dc5f332
The issues described in this report were fixed in the following commit:
https://github.com/LayerCover/protocol/commit/504a37bb8f95e2b620a7cfd0208a1ae6c6c5f3de
Summary
Weaknesses
This section contains the list of discovered weaknesses.
LCOV-1 | REDEEM FLOW WILL ALWAYS REVERT DUE TO MAXREDEEM CHECK ON MORPHO VAULT
Severity:
Status:
Fixed
Path:
protocol/contracts/tranches/TranchedMetaVault.sol:_ensureRedeemable#L617-L621
Description:
The TranchedMetaVault allows senior and junior tranche holders to request withdrawals and consequently execute the withdrawal using withdraw(Senior|Junior).
The withdrawal execution flow goes into _redeem(Senior|Junior)Locked. Both versions use the function _ensureRedeemable to check the tranche's current redeemable shares of the Morpho VaultV2.
This function _ensureRedeemable simply calls vault.maxRedeem(address(this)) and reverts if the available shares are less than the requested vault shares.
However, Morpho's VaultV2 does not really implement any of the ERC4626 max functions and instead always returns 0:
/// @dev Gross underestimation because being revert-free cannot be guaranteed when calling the gate.
function maxRedeem(address) external pure returns (uint256) {
return 0;
}
vault-v2/src/VaultV2.sol at main · morpho-org/vault-v2
This is to prevent reverts on these max-style functions, but now it causes an incorrect liquidity check on LayerCover's side.
Due to this behavior the withdrawal flow for both junior and senior tranches will always revert, causing the assets to be effectively locked in the protocol permanently.
This case was not caught in any tests because of the use of MockERC4626 instead of actual Morpho VaultV2 contracts.
function _ensureRedeemable(uint256 vaultShares) internal view {
uint256 available = vault.maxRedeem(address(this));
if (vaultShares <= available) return;
revert ErrorsLib.InsufficientVaultLiquidity(vaultShares, available);
}
Remediation:
The withdrawal flow should not rely on any of the max-style functions of the vault for liquidity checks. Instead it could do use a balance and preview-style function to obtain a rough liquidity estimation.
LCOV-2 | EXECUTION-TIME WITHDRAWAL PRICING ENABLES FREE DOWNSIDE PROTECTION OPTION FOR TRANCHE HOLDERS
Severity:
Status:
Fixed
Path:
contracts/tranches/TranchedMetaVault.sol#L170-L191
Description:
The functions requestSeniorWithdrawal() and requestJuniorWithdrawal() immediately lock user shares and create a withdrawal notice, but the redemption value is determined at execution time rather than at the time the notice is filed (TranchedMetaVault.sol:502, TranchedMetaVault.sol:532).
This enables the following low-cost strategy:
- Deposit into the senior or junior tranche.
- Immediately file a withdrawal notice, securing a future redemption right.
- Wait until the notice becomes executable (5 days for senior, 10 days for junior).
- During each
NOTICE_EXECUTION_WINDOW(3 days):- If NAV has decreased, execute the withdrawal immediately to exit before further losses.
- If NAV has increased, allow the notice window to expire without execution, then either:
- cancel the stale notice and submit a new one, or
- simply submit a fresh notice to reset executableAt, and repeat the process indefinitely. Because redemption uses execution-time pricing, users continue to benefit from NAV appreciation while retaining a low-cost exit mechanism during adverse market conditions.
The NOTICE_EXECUTION_WINDOW mechanism was intended to mitigate this behavior (as noted in ConstantsLib), but in practice it only imposes an execution deadline. It does not prevent users from selectively executing only during unfavorable market conditions. A rational user can continuously recycle withdrawal notices every ~8 days (senior) or ~13 days (junior), obtaining recurring downside protection at effectively no cost.
function requestSeniorWithdrawal(uint256 shares) external nonReentrant {
if (shares == 0) revert ErrorsLib.InvalidAmount();
_syncAccounting();
seniorToken.transferFrom(msg.sender, address(this), shares);
uint64 executableAt = uint64(block.timestamp + SENIOR_WITHDRAWAL_NOTICE);
WithdrawalNoticeLib.Notice storage notice = seniorWithdrawalNotices[msg.sender];
notice.file(shares, executableAt);
totalSeniorWithdrawalNoticeShares += shares;
emit EventsLib.SeniorWithdrawalNoticeRequested(msg.sender, notice.shares, executableAt);
}
function requestJuniorWithdrawal(uint256 shares) external nonReentrant {
if (shares == 0) revert ErrorsLib.InvalidAmount();
_syncAccounting();
juniorToken.transferFrom(msg.sender, address(this), shares);
uint64 executableAt = uint64(block.timestamp + JUNIOR_WITHDRAWAL_NOTICE);
WithdrawalNoticeLib.Notice storage notice = juniorWithdrawalNotices[msg.sender];
notice.file(shares, executableAt);
emit EventsLib.JuniorWithdrawalNoticeRequested(msg.sender, notice.shares, executableAt);
}
Remediation:
Snapshot the NAV-per-share at the time the withdrawal notice is filed, and cap redemption payouts using that snapshot value.
For example, extend the Notice struct with:
uint256 snapshotAssetsPerShare;
or alternatively:
uint256 snapshotAssets;
Then store the snapshot during file() and modify the redemption logic as follows:
// in _redeemSeniorLocked / _redeemJuniorLocked
uint256 currentAssets = _convertToAssets(shares, seniorNav, supply);
uint256 snapshotAssets = notice.snapshotAssets;
assets = currentAssets < snapshotAssets
? currentAssets
: snapshotAssets;
LCOV-7 | SENIOR WITHDRAWAL NOTICES CAN PERSISTENTLY REDUCE JUNIOR EXIT LIQUIDITY
Severity:
Status:
Fixed
Path:
contracts/tranches/TranchedMetaVault.sol#L643
Description:
requestSeniorWithdrawal() transfers senior shares into the metavault and increases totalSeniorWithdrawalNoticeShares. The notice is recorded under the caller and its executable timestamp is set through notice.file().
function requestSeniorWithdrawal(uint256 shares) external nonReentrant {
if (shares == 0) revert ErrorsLib.InvalidAmount();
_syncAccounting();
seniorToken.transferFrom(msg.sender, address(this), shares);
uint64 executableAt = uint64(block.timestamp + SENIOR_WITHDRAWAL_NOTICE);
WithdrawalNoticeLib.Notice storage notice = seniorWithdrawalNotices[msg.sender];
notice.file(shares, executableAt);
totalSeniorWithdrawalNoticeShares += shares;
emit EventsLib.SeniorWithdrawalNoticeRequested(msg.sender, notice.shares, executableAt);
}
Outstanding senior notices are then reserved against redeemable underlying liquidity before junior withdrawal capacity is calculated.
function _availableJuniorRedeemAssets() internal view returns (uint256) {
if (totalVaultShares == 0) return 0;
uint256 availableShares = vault.maxRedeem(address(this));
if (availableShares == 0) return 0;
if (availableShares > totalVaultShares) availableShares = totalVaultShares;
uint256 availableAssets = vault.previewRedeem(availableShares);
uint256 reservedAssets = _seniorNoticeAssets();
return availableAssets > reservedAssets ? availableAssets - reservedAssets : 0;
}
function _seniorNoticeAssets() internal view returns (uint256) {
uint256 shares = totalSeniorWithdrawalNoticeShares;
uint256 nav = seniorNav;
if (shares == 0 || nav == 0) return 0;
return shares.mulDiv(nav, seniorToken.totalSupply(), Math.Rounding.Ceil);
}
This reserved amount is used as the liquidity side of the junior withdrawal cap.
function _maxJuniorWithdrawAssets() internal view returns (uint256) {
uint256 required = _requiredJuniorCollateralAssets(seniorNav);
if (juniorNav <= required) return 0;
uint256 navAvailable = juniorNav - required;
uint256 liquidityAvailable = _availableJuniorRedeemAssets();
return navAvailable < liquidityAvailable ? navAvailable : liquidityAvailable;
}
As a result, senior withdrawal notices reduce the liquidity available to junior exits.
This can persist through ordinary senior behavior. A senior holder has an incentive to keep a withdrawal notice outstanding because it preserves priority over redeemable liquidity in the junior cap calculation while reducing the amount available to junior withdrawals. Since additional senior withdrawal requests refresh the aggregate notice timestamp, the same senior holder can extend this reservation over repeated notice cycles.
During periods where the underlying vault has limited redeemable liquidity, this can prevent junior holders from withdrawing assets that would otherwise be available above the senior protection floor.
Remediation:
- Do not refresh the executable timestamp for already-noticed senior shares when additional senior shares are filed.
- Track senior withdrawal notices in separate batches, so newly filed shares do not extend the reservation period of existing noticed shares.
- Limit how long senior-noticed shares can reduce junior redeemable liquidity.
- Consider applying senior notice reservations only when notices are executable or within a defined pre-execution window.
- Cap the amount of redeemable liquidity that senior notices can reserve against junior withdrawals.
LCOV-10 | FEE SPLITTER USES BASIS POINTS AGAINST PER-SECOND WAD MANAGEMENT FEE
Severity:
Status:
Fixed
Path:
contracts/factory/LayerCoverFeeSplitter.sol#L115-L130
Description:
LayerCoverFeeSplitter treats vault.managementFee() as a basis-point value, but Morpho V2 stores the management fee as a per-second WAD rate. This causes the LayerCover share of accrued management-fee shares to be calculated with mismatched units.
uint256 vaultFee = IVaultV2(vault).managementFee();
if (vaultFee < LC_RATE_BPS) revert VaultFeeBelowFloor(vaultFee, LC_RATE_BPS);
...
lcShares = (balance * LC_RATE_BPS) / vaultFee;
curatorShares = balance - lcShares;
The Standard Fund configuration stores the LayerCover floor as a per-second WAD value:
// 0.25% / yr expressed as per-second WAD
const QUARTER_PCT_PER_SECOND_WAD =
(25n * 10n ** 14n) / (365n * 24n * 60n * 60n);
For a compliant Standard Fund, 0.25%/yr is stored as 79,274,479. With balance = 1,000,000, the splitter calculates:
lcShares = 1,000,000 * 25 / 79,274,479 = 0
curatorShares = 1,000,000
As a result, a vault can pass the off-chain Standard Fund compliance check while the splitter sends almost all accrued management-fee shares to the curator instead of allocating the intended LayerCover floor.
Remediation:
- Use the same fee unit in the splitter as Morpho V2 uses for
managementFee, namely per-second WAD. - Replace the basis-point floor used by the splitter with the Standard Fund per-second WAD floor.
- Compare
vaultFeeagainst the per-second WAD floor in the floor check. - Calculate the LayerCover share as the per-second WAD floor divided by the current vault management fee.
LCOV-11 | LAYERCOVERGATE CANNOT HANDLE VAULTV2 FEES
Severity:
Status:
Fixed
Path:
contracts/gates/LayerCoverGate.sol:canReceiveShares
Description:
LayerCoverGate.canReceiveShares only allows the metavault address. Morpho VaultV2 gates fee share minting behind this same check. As a result, management and performance fee shares are never minted to any recipient, including the LayerCoverFeeSplitter. The splitter holds zero shares permanently and every call to claim() reverts with NothingToClaim.
// contracts/gates/LayerCoverGate.sol
function canReceiveShares(address account) external view returns (bool) {
return account == metaVault;
}
Morpho VaultV2's accrueInterestView gates fee computation behind this check:
// ../vault-v2/src/VaultV2.sol
uint256 performanceFeeAssets = interest > 0 && performanceFee > 0
&& canReceiveShares(performanceFeeRecipient)
? interest.mulDivDown(performanceFee, WAD)
: 0;
uint256 managementFeeAssets = elapsed > 0 && managementFee > 0
&& canReceiveShares(managementFeeRecipient)
? (newTotalAssets * elapsed).mulDivDown(managementFee, WAD)
: 0;
Since neither performanceFeeRecipient nor managementFeeRecipient is ever the metavault, both checks always return false. Fee assets are permanently zeroed, no shares are minted regardless of what the curator configured.
Remediation:
Whitelist the fee recipient addresses in the LayerCoverGate, for example:
function canReceiveShares(address account) external view returns (bool) {
return account == metaVault || account == feeSplitter;
}
LCOV-3 | KEEPER FEE WITHDRAWAL CAN USE A STALE UNDERLYING VALUE BASELINE
Severity:
Status:
Fixed
Path:
contracts/tranches/TranchedMetaVault.sol#L557
Description:
In the non-owner keeper path, the withdrawal is split into an owner payout and a keeper fee payout. The second _withdrawAssets call derives beforeAssets arithmetically instead of reading the actual remaining vault value after the first withdrawal.
function _payoutWithKeeperFee(
uint256 assets,
uint256 vaultShares,
address receiver,
address owner,
uint256 currentAssets
) internal {
if (msg.sender == owner) {
_withdrawAssets(assets, vaultShares, receiver, currentAssets);
return;
}
uint256 fee = (assets * KEEPER_FEE_BPS) / BPS;
if (fee == 0) {
_withdrawAssets(assets, vaultShares, receiver, currentAssets);
return;
}
uint256 ownerAssets = assets - fee;
uint256 ownerVaultShares = _requiredVaultShares(ownerAssets);
uint256 feeVaultShares = _requiredVaultShares(fee);
_ensureRedeemable(ownerVaultShares + feeVaultShares);
_withdrawAssets(ownerAssets, ownerVaultShares, receiver, currentAssets);
_withdrawAssets(fee, feeVaultShares, msg.sender, currentAssets - ownerAssets);
emit EventsLib.KeeperRewarded(owner, msg.sender, fee);
}
The first _withdrawAssets call burns vault shares and updates totalVaultShares. Because vault withdraw may round burned shares up and previewRedeem may round remaining assets down, the actual remaining value can be lower than currentAssets - ownerAssets by one atom.
The second withdrawal then starts from an overstated beforeAssets value. Its own rounding can add another atom of difference, causing _enforceMinUnderlyingValue to revert with UnderlyingValueLoss even though the same call would pass if the post-first-withdrawal value were measured directly.
Remediation:
- Use the actual post-owner-withdrawal ERC-4626 value as the
beforeAssetsvalue for the keeper fee withdrawal. - Prefer reusing the post-withdrawal value from
_withdrawAssets, or explicitly re-readvault.previewRedeem(totalVaultShares)after the owner payout.
LCOV-6 | LAYERCOVERLENS UNDERSTATES PREVIEWED JUNIOR NAV WHEN PERFORMANCE FEE SHARES ARE MINTED
Severity:
Status:
Fixed
Path:
contracts/lens/LayerCoverLens.sol#L143
Description:
previewSyncedNavs() is documented as returning the senior and junior NAVs as they would appear after the next syncAccounting() call. In the performance fee mint-success branch, however, the Lens does not add feeAssets to previewJuniorNav.
if (feeAssets > 0) {
uint256 feeShares = feeAssets.mulDiv(jSupply, previewJuniorNav);
if (feeShares == 0) {
previewJuniorNav += feeAssets;
}
// else: dilutive mint — junior unit price unchanged.
}
The on-chain accounting path adds feeAssets to juniorNav regardless of whether fee shares are minted.
function _mintPerformanceFeeShares(uint256 gainAssets, uint256 feeAssets) internal {
uint256 supply = juniorToken.totalSupply();
uint256 feeShares = feeAssets.mulDiv(supply, juniorNav);
if (feeShares == 0) {
juniorNav += feeAssets;
return;
}
address recipient = _feeConfig.recipient;
juniorToken.mint(recipient, feeShares);
juniorNav += feeAssets;
}
As a result, before syncAccounting() is called, Lens values that are meant to preview post-sync NAV can be lower than the actual post-sync storage values.
This affects previewSyncedNavs(), juniorTotalAssets(), snapshot.totalAssets, and NAV-derived fields such as maxJuniorWithdrawAssets and juniorCushionBps.
For example, with 1,000 senior NAV, 250 junior NAV, a 10% performance fee, and a 125 asset gain, the actual post-sync junior NAV is 285, while the Lens previews 272.5. The current Lens behavior preserves the existing junior unit-price view, but it does not match the documented post-sync NAV view.
Remediation:
- Align
previewSyncedNavs()with the on-chain post-sync NAV accounting. - When simulating a successful performance fee mint, account for both the added junior NAV and the added junior token supply.
- Keep NAV previews and per-share value previews internally consistent, or split them into separate helpers if both views are needed.
LCOV-9 | COVERAGE ENFORCEMENT TRAPS JUNIOR TRANCHE DURING PREMIUM RATE CHANGES
Severity:
Status:
Fixed
Path:
contracts/lens/LayerCoverLens.sol#L143
Description:
The 10-day timelock on setProtectionPremiumRate gives the impression that Junior Tranche (JT) users have enough time to withdraw before a lower premium rate takes effect.
However a JT withdrawal must preserve Senior Tranche (ST) coverage requirements. If a premium rate update becomes significantly worse for JT but better for ST, JT users may become unable to withdraw (partially or fully).
For example: ( 4x leverage )
- Current premium rate: 10%
- JT liquidity: 25
- ST liquidity: 100
setProtectionPremiumRateupdate changes the premium rate to 5%, causing ST to pay 1/2 the original amount to JT. Although the change is delayed by 10 days, JT holders are still be unable to exit. Since JT withdrawals must maintain required ST protection coverage, as a result, jt's are not given the opportunity to exit before the unfavorable rate becomes active, while seniors can opt out freely and are less likely too if the premium was favored their way.
Remediation:
Consider only allowing a new premium rate if it is higher than the previous one. If a lower premium rate is needed, deploy new tranches instead.
LCOV-12 | ANY GAIN AFTER FULL-WIPE IS ALWAYS ROUTED TO JUNIOR
Severity:
Status:
Fixed
Path:
protocol/contracts/tranches/TranchedMetaVault.sol:_applyGain#L371-L381
Description:
The function _applyGain is called from _syncAccounting whenever the delta is positive and some gain has been made. It has a special case to handle a 0 NAV:
if (priorAccounted == 0) {
juniorNav += gain;
return;
}
And otherwise it uses pro-rata distribution based on the corresponding NAV values:
uint256 seniorGain = gain.mulDiv(seniorNav, priorAccounted);
seniorNav += seniorGain;
juniorNav += gain - seniorGain;
This does not correctly account for the case where a NAV could be fully wiped to 0 and then makes some recovery.
If we have an existing tranche vault with a 100/25 distribution of Senior and Junior deposits and the value goes to 0, for example due to bad debt on Morpho, then any subsequent recovery would be router to the junior only:
- The first special case handles
priorAccounted == 0and would put all gain intojuniorNav. - Any subsequent gains would be distributed pro-rata based on
juniorNavandseniorNav, but only the former is positive. So pro-rata distribution means thatjuniorNavwould still get all the gain. A tranche vault that goes to 0 due to bad debt would still retain its supply shares in the Morpho market, but bad debt socialization can bring the supply assets, and thus the value, to 0.
This would already be a catastrophic event, but any recovery gains should be correctly distributed among the original holders, not just to junior tranche holders.
function _applyGain(uint256 gain, uint256 priorAccounted) internal {
if (gain == 0) return;
if (priorAccounted == 0) {
juniorNav += gain;
return;
}
uint256 seniorGain = gain.mulDiv(seniorNav, priorAccounted);
seniorNav += seniorGain;
juniorNav += gain - seniorGain;
}
LCOV-4 | USERS CAN STILL STRAND SHARES IN CONTROLLER VIA DEPOSITSENIOR AND DEPOSITJUNIOR RECEIVER PARAMETER
Severity:
Status:
Fixed
Path:
contracts/tranches/PooledTrancheToken.sol#L48-L56
Description:
The function PooledTrancheToken._update() is overridden to reject transfers into the controller unless the controller itself initiated the transfer. According to the inline comments, this protection is intended to prevent users from accidentally or maliciously stranding shares inside the controller contract.
/// @dev Reject transfers landing on the controller unless the
/// controller itself initiated them — otherwise a holder could
/// `transfer(controller, X)` and strand shares (no notice
/// covers them, no burn path reaches them). The metavault's
/// notice flow passes because it calls `transferFrom` itself.
function _update(address from, address to, uint256 value) internal virtual override {
if (to == controller && msg.sender != controller) revert OnlyController();
super._update(from, to, value);
}
However, users can still strand shares in the controller through another execution path that does not rely on transfer().
Specifically, the functions depositSenior() and depositJunior() in TranchedMetaVault allow users to specify an arbitrary receiver. If the user supplies the controller address as the receiver, newly minted shares are sent directly to the controller during minting.
In this case:
to == controllermsg.sender == controllercausing the_update()validation to pass successfully.
As a result, users can still strand shares inside the controller despite the intended protection.
Remediation:
Consider validating that the receiver parameter in depositSenior() and depositJunior() is not equal to address(this) (the controller address).
function depositSenior(uint256 assets, address receiver) external nonReentrant returns (uint256 shares) {
++ require(receiver != address(this), "invalid receiver");
_pullAssets(msg.sender, assets);
shares = _depositSeniorReceived(assets, receiver, /*account*/ msg.sender);
}
function depositJunior(uint256 assets, address receiver) external nonReentrant returns (uint256 shares) {
++ require(receiver != address(this), "invalid receiver");
_pullAssets(msg.sender, assets);
shares = _depositJuniorReceived(assets, receiver, /*account*/ msg.sender);
}
LCOV-5 | REDUNDANT ZERO-LOSS CHECK IN _APPLYLOSS
Severity:
Status:
Fixed
Path:
contracts/tranches/TranchedMetaVault.sol#L387
Description:
The function _applyLoss() contains an early return when loss == 0:
function _applyLoss(uint256 loss) internal {
if (loss == 0) return;
However, _applyLoss() is only called from _syncAccounting() when currentUnderlying < accounted:
uint256 loss = accounted - currentUnderlying;
_applyLoss(loss);
Since loss is guaranteed to be greater than zero in this branch, the zero-loss check is redundant.
Remediation:
Consider removing the unnecessary check.