Overview
This audit covers the Royco Protocol, a perpetual risk-tranching protocol. It divides yield opportunities into senior and junior tranches. The senior tranche is protected, at a minimum, from a market-defined drawdown percentage in the underlying investment, with the junior tranche serving as first-loss capital. In exchange, the junior tranche receives a portion of the senior yield in addition to its own, effectively providing leveraged exposure. Our review was conducted over 10 days and included a comprehensive analysis of all Solidity smart contracts. During the assessment, we identified three medium-severity vulnerabilities, one of which could allow an attacker to reduce the senior tranche's impermanent loss. Additionally, we reported two low-severity issues and eight informational findings. All identified issues were either remediated or formally acknowledged by the development team and subsequently verified by our auditors. Following the remediation phase, we conclude that the protocol's overall security posture and code quality have been significantly improved as a result of this audit.
Scope
The analyzed resources are located on:
https://github.com/roycoprotocol/royco-dawn/tree/ea3702466967a5dae1c2fd67b925dd6b25f79217
During the audit, the following PRs/commits were added to the scope and also reviewed:
PR 31: https://github.com/roycoprotocol/royco-dawn/commit/6e95bc7fa70795357e672cd22baaeef2ab79fb84
PR 33: https://github.com/roycoprotocol/royco-dawn/commit/0c8ce561f1a6bd999ebfa15576f8c9dc52494e48
PR 34: https://github.com/roycoprotocol/royco-dawn/commit/ab64a6556d0f2ec766c3d8ae3865a0456383c1b1
PR 36: https://github.com/roycoprotocol/royco-dawn/commit/1a4cf83ee8da16eef780de3360e5ca661b16cd94
PR 39: https://github.com/roycoprotocol/royco-dawn/commit/1b4e1d9da1cfaec655fa5af17ee49b2491f74c1a
Summary
Weaknesses
This section contains the list of discovered weaknesses.
ROYCO2-9 | ATTACKER CAN LOWER THE STIMPERMANENTLOSS BY REPEATEDLY DEPOSITING INTO AND REDEEMING FROM ST
Severity:
Status:
Fixed
Path:
src/accountant/RoycoAccountant.sol:postOpSyncTrancheAccounting()
Description:
The storage variable $.lastSTImpermanentLoss indicates the impermanent loss that ST has suffered after exhausting JT's loss-absorption buffer. This variable has the highest priority loss, such that when either JT or ST receives yield, it will be used to cover this loss first.
This loss from ST is a burden for the JT suppliers because JT gains will now go to ST instead. However, there is a way for the JT attacker to lower this value to a negligible amount.
In the function RoycoAccountant.postOpSyncTrancheAccounting(), there is an if block to handle the case when a user redeems shares from the Senior Tranche:
if (_op == Operation.ST_REDEEM) {
NAV_UNIT preWithdrawalSTEffectiveNAV = stEffectiveNAV;
// The actual amount withdrawn from ST effective NAV could be from both tranches (its own share of its
// NAV, coverage applied, IL repayments, etc.)
stEffectiveNAV = preWithdrawalSTEffectiveNAV - (_stRedeemPreOpNAV + _jtRedeemPreOpNAV);
// The withdrawing senior LP has realized its proportional share of past uncovered losses and associated
// recovery optionality, rounding in favor of senior
if (stImpermanentLoss != ZERO_NAV_UNITS) {
stImpermanentLoss = stImpermanentLoss.mulDiv(stEffectiveNAV, preWithdrawalSTEffectiveNAV, Math.Rounding.Ceil);
}
}
Within this if block, stImpermanentLoss is reduced proportionally with the withdrawn effective ST NAV.
The mechanism above is a flaw, since the attacker can deposit into ST and then redeem immediately. By doing so, the value of stImpermanentLoss will be reduced proportionally.
Remediation:
Consider disabling the deposit of the senior tranche in a period of time when the stImpermanentLoss > 0.
ROYCO2-1 | JT MAY BE UNABLE TO REDEEM THEIR SHARES DUE TO DIVISION BY ZERO
Severity:
Status:
Fixed
Path:
src/accountant/RoycoAccountant.sol#L197-L200
Description:
In the function RoycoAccountant.postOpSyncTrancheAccounting(), if the designated operation is ST_DECREASE_NAV or JT_DECREASE_NAV, the following calculation is performed:
jtImpermanentLoss = $.lastJTSelfImpermanentLoss;
if (jtImpermanentLoss != ZERO_NAV_UNITS) {
$.lastJTSelfImpermanentLoss = jtImpermanentLoss.mulDiv(
_jtRawNAV,
$.lastJTRawNAV,
Math.Rounding.Floor
);
}
The calculation above is used to scale down the junior tranche impermanent loss proportionally to the withdrawn JT raw NAV. However, this calculation does not check whether $.lastJTRawNAV is 0. As a consequence, if $.lastJTRawNAV == 0, the division causes the redemption to revert, and the tranche redemption cannot be completed.
Redeeming from Senior tranche:
// If some coverage was realized by this ST LP
if (deltaJT != 0) {
// JT raw NAV that is leaving the market realizes its proportional share of past JT losses
// from its own depreciation, rounding in favor of senior
NAV_UNIT jtSelfImpermanentLoss = $.lastJTSelfImpermanentLoss;
if (jtSelfImpermanentLoss != ZERO_NAV_UNITS) {
$.lastJTSelfImpermanentLoss = jtSelfImpermanentLoss.mulDiv(
_jtRawNAV,
$.lastJTRawNAV,
Math.Rounding.Floor
);
}
}
In this scenario, the function requires deltaJT != 0 in order to modify lastJTSelfImpermanentLoss. This condition cannot be satisfied when $.lastJTRawNAV == 0, since there is no junior tranche liquidity available to provide coverage. As a result, the division by zero path is not reachable when redeeming from the senior tranche.
Redeeming from Junior tranche:
jtImpermanentLoss = $.lastJTSelfImpermanentLoss;
if (jtImpermanentLoss != ZERO_NAV_UNITS) {
$.lastJTSelfImpermanentLoss = jtImpermanentLoss.mulDiv(
_jtRawNAV,
$.lastJTRawNAV,
Math.Rounding.Floor
);
}
This scenario differs because it does not require deltaJT != 0 to perform the impermanent loss adjustment. Therefore, if $.lastJTRawNAV == 0 while lastJTSelfImpermanentLoss is non-zero, the division by zero occurs and the junior tranche redemption reverts. The maximum loss that can be realized in this case is capped by the yield previously earned by the junior tranche from the senior tranche when the senior tranche generated yield.
Remediation:
Consider calculating the lastJTSelfImpermanentLoss only when the $.lastJTRawNAV is different from 0.
ROYCO2-2 | ACCRUED JT PROTOCOL FEES BECOME UNMINTABLE WHEN EFFECTIVE NAV REACHES ZERO, BLOCKING FUTURE SYNCS
Severity:
Path:
src/tranches/base/RoycoVaultTranche.sol#L662
Description:
Protocol fees on JT/ST gains are minted during the pre sync phase. It is possible that in the previous sync the jtEffectiveNAV was reduced to 0 because of a ST coverage, while having jtProtocolFeeAccrued accrued from a JT gain.
In the next sync triggered by deposit/withdraw/sync call, it will try to mint these protocol fees with a jtEffectiveNAV of 0, causing an underflow error.
function previewMintProtocolFeeShares(NAV_UNIT _protocolFeeAssets, NAV_UNIT _trancheTotalAssets) {
// Compute the shares to be minted to the protocol fee recipient to satisfy the ratio of total assets that the fee represents
// Subtract fee assets from total tranche assets because fees are included in total tranche assets
// Round in favor of the tranche
uint256 totalShares = totalSupply();
protocolFeeSharesMinted = _convertToShares(_protocolFeeAssets, totalShares, (_trancheTotalAssets - _protocolFeeAssets), Math.Rounding.Floor);
}
Remediation:
Consider not minting protocol fee shares if _trancheTotalAssets - _protocolFeeAssets <= 0.
ROYCO2-16 | THE AAVE JT KERNEL CAN BE INFLATED THROUGH A DIRECT TRANSFER OF ATOKENS, POTENTIALLY CAUSE THE SIGNIFICANT LOSS OF PROTOCOL FEES
Severity:
Status:
Fixed
Path:
src/kernels/base/junior/AaveV3_JT_Kernel.sol
Description:
In AaveV3_JT_Kernel, the total NAV value of its tranche is determined by the AToken balance held in the kernel:
function _getJuniorTrancheRawNAV() internal view override(RoycoKernel) returns (NAV_UNIT) {
// The tranche's balance of the AToken is the total assets it is owed from the Aave pool
/// @dev This does not treat illiquidity in the Aave pool as a loss: we assume that total lent and interest will
/// be withdrawable at some point
return jtConvertTrancheUnitsToNAVUnits(toTrancheUnits(IERC20(JT_ASSET_ATOKEN).balanceOf(address(this))));
}
Consequently, the asset-to-share exchange rate in the kernel can be inflated by directly transferring ATokens into it.
Once inflated, protocol fees minted via the previewMintProtocolFeeShares() function may result in zero shares because the share calculation uses rounding down. Over time, this can cause the protocol to lose a significant amount of revenue, as multiple fee minting events may result in zero shares.
function previewMintProtocolFeeShares(
NAV_UNIT _protocolFeeAssets,
NAV_UNIT _trancheTotalAssets
)
public
view
virtual
override(IRoycoVaultTranche)
returns (uint256 protocolFeeSharesMinted, uint256 totalTrancheShares)
{
// Compute the shares to be minted to the protocol fee recipient to satisfy the ratio of total assets that the fee represents
// Subtract fee assets from total tranche assets because fees are included in total tranche assets
// Round in favor of the tranche
uint256 totalShares = totalSupply();
protocolFeeSharesMinted = _convertToShares(_protocolFeeAssets, totalShares, (_trancheTotalAssets - _protocolFeeAssets), Math.Rounding.Floor);
// The total tranche shares include the protocol fee shares and virtual shares
totalTrancheShares = _withVirtualShares(totalShares + protocolFeeSharesMinted);
}
Example scenario:
_decimalsOffsetin the kernel is 0, both the initial virtual shares and virtual assets are 1.- An attacker first deposits 1 wei of assets into the kernel and receives 1 wei of shares in return.
- The attacker directly transfers 2e18 ATokens wei to the kernel, inflating the exchange rate to:
(2e18 + 2) / 2 = 1e18 + 1 - As a result, any deposit smaller than 1e18 ATokens will receive 0 shares.
- Once profit is realized, protocol fee shares are minted based on the fee value. If the fee value is less than 1e18, no shares will be minted.
- Because the fee value cannot be accumulated or controlled, a significant amount of fees may be lost after repeatedly minting zero shares for any fee value lower than 1e18.
In this way, an attacker spends just 2e18 ATokens to inflate the share rate, but causes the protocol to lose a much larger amount of fees.
Remediation:
Consider accumulating fees and minting only when the fee amount is sufficient to receive at least 1 share, or preventing share rate inflation by setting a minimum deposit amount.
ROYCO2-5 | INCONSISTENT REDEMPTION AVAILABILITY WHEN SENIOR TRANCHE UNDERLYING BECOMES ILLIQUID
Severity:
Status:
Fixed
Path:
src/kernels/base/RoycoKernel.sol, src/kernels/base/senior/ERC4626_ST_Kernel.sol
Description:
When the senior tranche's underlying ERC4626 vault becomes illiquid (i.e., maxWithdraw returns zero), the protocol exhibits inconsistent behavior between existing redemption requests and new ones.
The _stWithdrawAssets function in ERC4626_ST_Kernel.sol is designed to handle illiquidity gracefully by transferring vault shares instead of underlying assets when the vault cannot fulfill a withdrawal. This allows existing JT redemption requests to proceed regardless of ST liquidity status.
However, jtMaxWithdrawable in RoycoKernel.sol depends on stVault.maxWithdraw(), which returns zero when illiquid. This propagates to maxRedeem in RoycoVaultTranche.sol, causing requestRedeem to revert with MUST_REQUEST_WITHIN_MAX_REDEEM_AMOUNT for any new request.
The result is that users who created redemption requests before the illiquidity event can execute their redemptions and receive vault shares, while users attempting to create new requests are blocked entirely. This creates an availability gap where JT holders cannot initiate redemptions during illiquidity periods, even though the protocol supports vault share redemptions in such scenarios.
If the underlying vault remains illiquid for an extended period, this could prevent JT holders from accessing the redemption flow until liquidity returns.
function jtMaxWithdrawable(address _owner)
public
view
virtual
override(IRoycoKernel)
returns (NAV_UNIT claimOnStNAV, NAV_UNIT claimOnJtNAV, NAV_UNIT stMaxWithdrawableNAV, NAV_UNIT jtMaxWithdrawableNAV)
{
// Get the total claims the junior tranche has on each tranche's assets
(SyncedAccountingState memory state, AssetClaims memory jtNotionalClaims,) = previewSyncTrancheAccounting(TrancheType.JUNIOR);
// Get the max withdrawable ST and JT assets in NAV units from the accountant consider coverage requirement
(, NAV_UNIT stClaimableGivenCoverage, NAV_UNIT jtClaimableGivenCoverage) = _accountant()
.maxJTWithdrawalGivenCoverage(
state.stRawNAV,
state.jtRawNAV,
stConvertTrancheUnitsToNAVUnits(jtNotionalClaims.stAssets),
jtConvertTrancheUnitsToNAVUnits(jtNotionalClaims.jtAssets)
);
claimOnStNAV = stConvertTrancheUnitsToNAVUnits(jtNotionalClaims.stAssets);
claimOnJtNAV = jtConvertTrancheUnitsToNAVUnits(jtNotionalClaims.jtAssets);
// Bound the claims by the max withdrawable assets globally for each tranche and compute the cumulative NAV
stMaxWithdrawableNAV = UnitsMathLib.min(stConvertTrancheUnitsToNAVUnits(_stMaxWithdrawableGlobally(_owner)), stClaimableGivenCoverage);
jtMaxWithdrawableNAV = UnitsMathLib.min(jtConvertTrancheUnitsToNAVUnits(_jtMaxWithdrawableGlobally(_owner)), jtClaimableGivenCoverage);
}
function _stWithdrawAssets(TRANCHE_UNIT _stAssets, address _receiver) internal override(RoycoKernel) {
ERC4626KernelState storage $ = ERC4626KernelStorageLib._getERC4626KernelStorage();
// Get the currently withdrawable liquidity from the vault
TRANCHE_UNIT maxWithdrawableAssets = toTrancheUnits(IERC4626(ST_VAULT).maxWithdraw(address(this)));
// If the vault has sufficient liquidity to withdraw the specified assets, do so
if (maxWithdrawableAssets >= _stAssets) {
$.stOwnedShares -= IERC4626(ST_VAULT).withdraw(toUint256(_stAssets), _receiver, address(this));
} else {
// If the vault has insufficient liquidity to withdraw the specified assets, transfer the equivalent number of shares to the receiver
uint256 sharesEquivalentToWithdraw = IERC4626(ST_VAULT).convertToShares(toUint256(_stAssets));
$.stOwnedShares -= sharesEquivalentToWithdraw;
// Transfer the assets equivalent of shares to the receiver
IERC20(address(ST_VAULT)).safeTransfer(_receiver, sharesEquivalentToWithdraw);
}
}
Remediation:
If the intended design is to allow redemptions via vault shares during illiquidity, update the maxRedeem calculation to account for this fallback mechanism rather than returning zero when the underlying is illiquid.
If the intended design is to block new requests during illiquidity while allowing existing ones to proceed, the current implementation is correct but should be explicitly documented as expected behavior.
ROYCO2-8 | ERC777 REENTRANCY ATTACK IN THE ACCOUNTANT CONTRACT
Severity:
Status:
Fixed
Path:
src/accountant/RoycoAccountant.sol:stRedeem()
Description:
The function stRedeem() is implemented as follows:
/// @inheritdoc IRoycoKernel
/// @dev ST redemptions are allowed if the market is in a PERPETUAL state
function stRedeem(
uint256 _shares,
address,
address _receiver,
uint256
)
external
virtual
override(IRoycoKernel)
whenNotPaused
onlySeniorTranche
withQuoterCache
returns (AssetClaims memory userAssetClaims, bytes memory)
{
// Execute a pre-op sync on accounting
uint256 totalTrancheShares;
{
SyncedAccountingState memory state;
(state, userAssetClaims, totalTrancheShares) = _preOpSyncTrancheAccounting(TrancheType.SENIOR);
MarketState marketState = state.marketState;
// Ensure that the market is in a state where ST redemptions are allowed: PERPETUAL
require(marketState == MarketState.PERPETUAL, ST_REDEEM_DISABLED_IN_FIXED_TERM_STATE());
}
// Scale total tranche asset claims by the ratio of shares this user owns of the tranche vault
// Protocol fee shares were minted in the pre-op sync, so the total tranche shares are up to date
userAssetClaims = UtilsLib.scaleAssetClaims(userAssetClaims, _shares, totalTrancheShares);
// Withdraw the asset claims from each tranche and transfer them to the receiver
(NAV_UNIT stRedeemPreOpNAV, NAV_UNIT jtRedeemPreOpNAV) = _withdrawAssets(userAssetClaims, _receiver);
// Execute a post-op sync on accounting
_postOpSyncTrancheAccounting(Operation.ST_REDEEM, ZERO_NAV_UNITS, ZERO_NAV_UNITS, stRedeemPreOpNAV, jtRedeemPreOpNAV);
}
The token transfer occurs inside _withdrawAssets(), while _preOpSyncTrancheAccounting() and _postOpSyncTrancheAccounting() are responsible for updating the contract's accounting state. This design violates the Checks-Effects-Interactions (CEI) pattern, as state variables are still modified after the external token transfer.
As a result, the contract is vulnerable to reentrancy when the underlying asset is native ETH or an ERC777 token.
Scenario:
Assume the ST underlying asset is an ERC777 token, and the RoycoAccountant state is:
stEffectiveNAV = stRawNAV = 100jtEffectiveNAV = jtRawNAV = 100
- An attacker redeems 50 tokens from the ST.
_withdrawAssets()transfers 50 tokens to the attacker:stRawNAV = 100 - 50 = 50- During the ERC777 transfer callback, the attacker reenters the protocol and deposits tokens into the JT.
_preOpSyncTrancheAccounting()is executed again, and the accountant treats the 50-token withdrawal from ST as a loss:stRawNAV = 50jtRawNAV = 100
- JT absorbs the loss:
jtEffectiveNAV = 100 - 50 = 50stEffectiveNAV = 100This demonstrates that an attacker can force a loss onto the JT tranche by exploiting reentrancy during an ERC777 token transfer initiated bystRedeem().
Remediation:
Consider adding nonReentrant modifier to protect the reentrancy attack.
ROYCO2-15 | TRANCHE FEE RECIPIENT'S SHARES COMPOUND INTO HIGHER FEE RATES
Severity:
Path:
src/tranches/base/RoycoVaultTranche.sol:mintProtocolFeeShares#L674-L692
Description:
The Royco kernel will call into the ST and JT to mint protocol fee shares whenever there are gains. The protocol fee amount is calculated in the accountant using a percentage of the realised gains, which are then converted into shares by the respective tranche in mintProtocolFeeShares.
The calculation that is used will mint shares such that the minted shares have exactly the value of the protocol fee asset amount. Nonetheless, the _protocolFeeRecipient will accumulate shares in the tranche and so these shares will also accumulate a percentage of the realised gains. In other words, the protocol fee shares compound.
In the beginning this could be negligible, but after a while the protocol fee recipient's shares grow and with that the effective protocol fee rate.
Consider the case with a protocol fee rate of 1% and the protocol fee recipient has already accumulated 1% of the total supply. In that case, 99% of gains would be divided among shareholders (including the fee recipient) and as a result the effective fee rate is not 1%, but 1.99%.
function mintProtocolFeeShares(
NAV_UNIT _protocolFeeAssets,
NAV_UNIT _trancheTotalAssets,
address _protocolFeeRecipient
)
external
virtual
override(IRoycoVaultTranche)
returns (uint256 protocolFeeSharesMinted, uint256 totalTrancheShares)
{
// Only the kernel can mint protocol fee shares based on sync
require(msg.sender == kernel(), ONLY_KERNEL());
// Mint any protocol fee shares accrued to the specified recipient
(protocolFeeSharesMinted, totalTrancheShares) = previewMintProtocolFeeShares(_protocolFeeAssets, _trancheTotalAssets);
if (protocolFeeSharesMinted != 0) _mint(_protocolFeeRecipient, protocolFeeSharesMinted);
emit ProtocolFeeSharesMinted(_protocolFeeRecipient, protocolFeeSharesMinted, totalTrancheShares);
}
Remediation:
Three options are considered:
- Keep the protocol fee reserved in absolute asset amounts instead of minting shares: This will not dilute the share rate nor compound the fee amount. The amount could still be deployed and generate yield.
- Limit the protocol fee based on the yield portion of the share balance of the fee recipient account, the share balance could be saved in storage instead of taking a live balance.
- Keep this as-is.
ROYCO2-12 | ROYCOACCOUNTANT PARAMETER CHANGES SHOULD ENFORCE ACTUAL COVERAGE/LLTV
Severity:
Path:
src/accountant/RoycoAccountant.sol:setCoverage, setLLTV#L749-L755, L767-L773
Description:
The RoycoAccountant contract has 2 parameters coverageWAD and lltvWAD which can be set with their respective functions.
Both functions use the internal _validateCoverageConfig function to validate the new values with the already set parameters. However, it does not check it against the actual coverage/LLTV calculated from the ST and JT effective NAVs.
So a new value for coverage or LLTV might cause the accountant to go into a state where these requirements are not satisfied.
function setCoverage(uint64 _coverageWAD) external override(IRoycoAccountant) restricted withSyncedAccounting {
RoycoAccountantState storage $ = _getRoycoAccountantStorage();
// Validate the new coverage configuration
_validateCoverageConfig(_coverageWAD, $.betaWAD, $.lltvWAD);
$.coverageWAD = _coverageWAD;
emit CoverageUpdated(_coverageWAD);
}
function setLLTV(uint64 _lltvWAD) external override(IRoycoAccountant) restricted withSyncedAccounting {
RoycoAccountantState storage $ = _getRoycoAccountantStorage();
// Validate the new coverage configuration
_validateCoverageConfig($.coverageWAD, $.betaWAD, _lltvWAD);
$.lltvWAD = _lltvWAD;
emit LLTVUpdated(_lltvWAD);
}
Remediation:
It is recommended to also enforce the coverage and LLTV requirement against the actual effective NAV values and the new configuration parameters.
ROYCO2-10 | INTERMEDIATE-STATE VALIDATION BLOCKS PARAMETER RECONFIGURATION
Severity:
Status:
Fixed
Path:
src/accountant/RoycoAccountant.sol:setCoverage(), src/accountant/RoycoAccountant.sol:setBeta(), src/accountant/RoycoAccountant.sol:setLLTV()
Description:
In the RoycoAccountant contract, the functions setCoverage(), setBeta(), and setLLTV() are used to update the corresponding storage variables coverageWAD, betaWAD, and lltvWAD. While these functions make the variables appear configurable, updating multiple parameters at the same time is not straightforward.
Each setter function calls _validateCoverageConfig(), which validates the newly set variable against the other two current variables. As a result, when the owner attempts to update more than one parameter, an intermediate state may be invalid even though the final desired configuration is valid. This makes it impossible to apply certain valid configurations through the existing setters.
Example:
- Current state:
coverageWAD = 20%betaWAD = 0%lltvWAD = 97%
- Target state:
coverageWAD = 30%betaWAD = 0%lltvWAD = 80%If the owner first callssetLLTV()to updatelltvWADto 80%,_validateCoverageConfig()will revert because:
80% < maxLTV = 1 / (1 + 20%) ≈ 83.33%
Even though the target configuration is valid once coverageWAD is updated to 30%, the change cannot be applied due to validation against the old parameters.
/// @inheritdoc IRoycoAccountant
function setCoverage(uint64 _coverageWAD) external override(IRoycoAccountant) restricted withSyncedAccounting {
RoycoAccountantState storage $ = _getRoycoAccountantStorage();
// Validate the new coverage configuration
_validateCoverageConfig(_coverageWAD, $.betaWAD, $.lltvWAD);
$.coverageWAD = _coverageWAD;
emit CoverageUpdated(_coverageWAD);
}
/// @inheritdoc IRoycoAccountant
function setBeta(uint96 _betaWAD) external override(IRoycoAccountant) restricted withSyncedAccounting {
RoycoAccountantState storage $ = _getRoycoAccountantStorage();
// Validate the new coverage configuration
_validateCoverageConfig($.coverageWAD, _betaWAD, $.lltvWAD);
$.betaWAD = _betaWAD;
emit BetaUpdated(_betaWAD);
}
/// @inheritdoc IRoycoAccountant
function setLLTV(uint64 _lltvWAD) external override(IRoycoAccountant) restricted withSyncedAccounting {
RoycoAccountantState storage $ = _getRoycoAccountantStorage();
// Validate the new coverage configuration
_validateCoverageConfig($.coverageWAD, $.betaWAD, _lltvWAD);
$.lltvWAD = _lltvWAD;
emit LLTVUpdated(_lltvWAD);
}
Remediation:
To address this issue, consider implementing a function that allows coverageWAD, betaWAD, and lltvWAD to be updated simultaneously and validated as a single configuration.
ROYCO2-11 | REDUNDANT ACCESS CONTROL CHECK IN ROYCOACCOUNTANT
Severity:
Status:
Fixed
Path:
rc/accountant/RoycoAccountant.sol:postOpSyncTrancheAccountingAndEnforceCoverage#L269-L287
Description:
The function postOpSyncTrancheAccountingAndEnforceCoverage has the modifier onlyRoycoKernel which checks that the caller is the linked kernel contract.
However, this function proceeds to call the public function postOpSyncTrancheAccounting which also has the same modifier. This means that the access control check will be executed twice.
function postOpSyncTrancheAccountingAndEnforceCoverage(
Operation _op,
NAV_UNIT _stPostOpRawNAV,
NAV_UNIT _jtPostOpRawNAV,
NAV_UNIT _stDepositPreOpNAV,
NAV_UNIT _jtDepositPreOpNAV,
NAV_UNIT _stRedeemPreOpNAV,
NAV_UNIT _jtRedeemPreOpNAV
)
external
override(IRoycoAccountant)
onlyRoycoKernel
returns (SyncedAccountingState memory state)
{
// Execute a post-op NAV synchronization
state = postOpSyncTrancheAccounting(_op, _stPostOpRawNAV, _jtPostOpRawNAV, _stDepositPreOpNAV, _jtDepositPreOpNAV, _stRedeemPreOpNAV, _jtRedeemPreOpNAV);
// Enforce the market's coverage requirement
require(_isCoverageRequirementSatisfied(state.utilizationWAD), COVERAGE_REQUIREMENT_UNSATISFIED());
}
Remediation:
It is recommended to remove the onlyRoycoKernel modifier from the postOpSyncTrancheAccountingAndEnforceCoverage function, as the access control is already enforced when calling postOpSyncTrancheAccounting, in favour of gas optimisation and code clarity.
ROYCO2-3 | UNNECESSARY ORACLE DEPENDENCY IN REDEMPTION CANCELLATION FUNCTIONS
Severity:
Status:
Fixed
Path:
src/kernels/base/RoycoKernel.sol
Description:
The jtCancelRedeemRequest and jtClaimCancelRedeemRequest functions in RoycoKernel.sol include withQuoterCache. In kernel implementations that use oracle-based quoters (e.g., IdenticalAssetsChainlinkOracleQuoter), this modifier may trigger an external oracle read even though these functions do not use price data. jtCancelRedeemRequest only sets request.isCanceled = true, and jtClaimCancelRedeemRequest returns the locked shares and clears the request state, neither requires NAV calculations. If the oracle feed is stale or invalid, _initializeQuoterCache() can revert, making cancellation temporarily unavailable until the oracle is available again.
function jtClaimCancelRedeemRequest(
uint256 _requestId,
address _controller
)
external
virtual
override(IRoycoKernel)
withQuoterCache
whenNotPaused
onlyJuniorTranche
checkJTRedemptionRequestId(_controller, _requestId)
returns (uint256 shares)
{
...
}
function jtCancelRedeemRequest(
uint256 _requestId,
address _controller
)
external
virtual
override(IRoycoKernel)
whenNotPaused
withQuoterCache
onlyJuniorTranche
checkJTRedemptionRequestId(_controller, _requestId)
{
...
}
Remediation:
Avoid initializing the quoter cache in cancel/claim paths and limit it to functions that require NAV conversions. Apply the same approach to any future ST cancellation functions if asynchronous ST redemption is introduced.
ROYCO2-13 | SETTING THE FIXED TERM DURATION TO ZERO SHOULD ONLY BE POSSIBLE IN A PERPETUAL MARKET STATE
Severity:
Status:
Acknowledged
Path:
src/accountant/RoycoAccountant.sol:setFixedTermDuration#L776-L785
Description:
The function setFixedTermDuration allows an authorised caller to change the fixed term duration of the market.
If the market was already in a fixed term, it does not change the leftover time, as the market state only stores the end timestamp of the fixed state. The new duration would only be used in the next fixed term state.
However, if the caller specifies 0 as the new duration, it does instantly transition the market to a perpetual state and reset the coverage impermanent loss. This could be seen as contradictive.
function setFixedTermDuration(uint24 _fixedTermDurationSeconds) external override(IRoycoAccountant) restricted withSyncedAccounting {
RoycoAccountantState storage $ = _getRoycoAccountantStorage();
$.fixedTermDurationSeconds = _fixedTermDurationSeconds;
if (_fixedTermDurationSeconds == 0) {
$.lastJTCoverageImpermanentLoss = ZERO_NAV_UNITS;
$.lastMarketState = MarketState.PERPETUAL;
}
emit FixedTermDurationUpdated(_fixedTermDurationSeconds);
}
Remediation:
It is recommended to consider not allowing the fixed term duration to be set to 0 if the market is currently in a fixed term state, as that would make the function overly centralised.
ROYCO2-14 | ARBITRARY CALLS FROM ROYCOACCOUNTANT
Severity:
Status:
Acknowledged
Path:
src/accountant/RoycoAccountant.sol:_initializeYDM#L849-L857
Description:
In the RoycoAccount contract, the function _initializeYDM to initialise a new YDM makes an arbitrary call. The target is the new address and the call data are arbitrary bytes.
This function is restricted to a caller with the permission to set a new YDM, but besides that it would also allow the caller to make the RoycoAccount perform arbitrary calls.
For example, if _ydm is set to an ERC20 and the _ydmInitializationData would contain transfer(X, Y) call data.
At the moment, there is little impact, but it is recommended to restrict the privileges of the caller with such a role to only being able to initialise a YDM.
function _initializeYDM(address _ydm, bytes calldata _ydmInitializationData) internal {
// Ensure that the YDM is not null
require(_ydm != address(0), NULL_YDM_ADDRESS());
// Initialize the YDM if required
if (_ydmInitializationData.length != 0) {
(bool success, bytes memory data) = _ydm.call(_ydmInitializationData);
require(success, FAILED_TO_INITIALIZE_YDM(data));
}
}
Remediation:
Consider using an interface with a set function to initialise a YDM.
If the YDM needs dynamic parameters that are not known beforehand, then the only parameter should be bytes and the bytes should be ABI-decoded into the corresponding struct inside of the YDM itself.