ROWA logo

ROWA Protocol Security Review Report

September 2026

Overview

This report covers the security review for ROWA protocol, a highly configurable vault protocol that allows for efficient management and tokenization of investments. Our security assessment was a full review of the protocol code, spanning a total of 6 weeks. During our review, we identified multiple high severity vulnerability, which could have resulted in loss of assets. We also identified some minor severity vulnerabilities and code optimizations. 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:

Phase 1:

Issue Code: ROWA-

Phase 2:

Issue Code: ROWA2-

Phase 3:

Issue Code: ROWA3-

The issues described in this report were fixed in the following commits:

Phase 1: Issue Code: ROWA-

Phase 2:

Issue Code: ROWA2-

Phase 3:

Issue Code: ROWA3-

Summary

Total number of findings
60

Weaknesses

This section contains the list of discovered weaknesses.

ROWA-14 | ALLOCATED ASSETS ARE VALUED AT GROSS NAV INSTEAD OF REALIZABLE VALUE

Severity:

High

Status:

Fixed

Path:

contracts/rebalance/PortfolioValuatorUpgradeable.sol:_derivePositionValues#L199-L276

Description:

The function _derivePositionValues calculates a portfolio's total net asset value (NAV). The portfolio values allocated positions using their full reported NAV:

values_[i] = Math.mulDiv(totalBalance_, price_, 10 ** decimals_);

However, adapters such as Midas charge instant redemption fees (+50 bps), Curve/Uniswap charge a swap fee, etc. when converting vault shares back into underlying assets. These costs are not reflected in portfolio valuation, causing the vault to overstate its total assets and share price. As a result, users can deposit and withdraw against an inflated NAV even when no liquidation occurs during their transaction.

Impact

Users redeeming from available idle liquidity do not bear their proportional share of the redemption costs embedded in allocated positions. Instead, those costs are realized only when allocated assets are eventually withdrawn, shifting losses to remaining shareholders.

Example

Assume:

50% of vault assets are idle.

50% are allocated to Midas.

Midas charges a 60 bps instant redemption fee.

The portfolio values assets as:

Total Assets = Idle + Allocated NAV
 
      = 500 + 500
      = 1000 = 1000

However, the allocated position can only realize:

500 × (1 - 0.006) = 497

making the actual realizable value:

500 + 497 = 997

Despite this, shares continue to be minted and redeemed against a reported NAV of 1000, causing the embedded redemption cost to be borne by users who remain in the vault when the allocated position is eventually redeemed.

Remediation:

Consider valuing allocated assets based on their net realizable value rather than their gross NAV.

ROWA-12 | STRATEGYLOGIC TRIGGERREBALANCE ALWAYS REVERTS

Severity:

High

Status:

Fixed

Path:

src/strategy/StrategyLogicUpgradeable.sol:triggerRebalance

Description:

The StrategyLogic contract is deployed per vault and handles the functionality for the strategist to manage the vault. It exposes a function triggerRebalance for the keeper or strategist to directly trigger a rebalance. The function checks whether the caller is the keeper or the strategist and then forwards the call to PortfolioCoordinator.executeRebalance. However, this function performs more access control checks using the onlyAuthorizedRebalancer modifier:

modifier onlyAuthorizedRebalancer(address strategy) {
_checkRebalanceAuthorization(strategy);
_;
}
function _checkRebalanceAuthorization(address strategy) internal view {
if (strategy == address(0)) revert Error.ZeroAddress();
if (roleRegistry.hasProtocolRole(Roles.KEEPER_ROLE, msg.sender)) {
return;
}
if (
roleRegistry.hasProtocolRole(Roles.OPS_ROLE, msg.sender) ||
roleRegistry.hasProtocolRole(Roles.PROTOCOL_ADMIN_ROLE, msg.sender) ||
roleRegistry.hasProtocolRole(Roles.SUPER_ADMIN_ROLE, msg.sender)
) {
return;
}
revert Error.NotAllowed("Coordinator: caller not authorized");
}

This means that the caller requires the one of the KEEPER_ROLE, OPS_ROLE, PROTOCOL_ADMIN_ROLE or SUPER_ADMIN_ROLE or the function will revert. The caller of this function is the StrategyLogic contract, but that was deployed by the factory and never gets one of these roles assigned. As a result, the entire triggerRebalance flow will always revert and only the keeper or admin roles can trigger rebalances by calling executeRebalance directly. This revert was never caught in the tests, because it uses a MockPortfolioCoordinator that does not check access control for executeRebalance (e.g. in StrategyLogicUpgradeable.e2e.t.sol and others).

function triggerRebalance(
bytes calldata hint
) external onlyWhenActive whenNotPaused requireRouter {
bool isKeeper = roleRegistry.hasProtocolRole(Roles.KEEPER_ROLE, msg.sender);
bool isStrategist = (msg.sender == IVaultStrategyDeployer(vault).strategyDeployer());
if (!isKeeper && !isStrategist)
revert Error.NotAllowed("Strategy: requires KEEPER or strategist");
portfolioCoordinator.executeRebalance(address(this), keccak256("IMMEDIATE"), hint);
emit RebalanceExecuted(vault, 0, 0);
}

Remediation:

Allow a registered StrategyLogic to call executeRebalance for a specified vault, including a check that this StrategyLogic belongs to that vault.

ROWA-6 | WITHDRAWAL LIQUIDATION COSTS ARE BORNE BY REMAINING SHAREHOLDERS

Severity:

High

Status:

Fixed

Path:

contracts/vault/ROWAVaultUpgradeable.sol#L635

Description:

withdraw() and redeem() calculate the exiting user's shares or asset payout before any liquidation occurs. If the vault lacks enough idle base asset, _withdraw() liquidates deployed positions afterward, but the exit amount is not recalculated and no realized liquidation impact is charged to the exiting shares.

function withdraw(...)
{
...
uint256 shares_ = _convertToShares(grossAssets_, Math.Rounding.Ceil);
_trackWithdrawal(owner, assets, shares_);
_withdraw(caller_, receiver, owner, assets, shares_);
...
}
function redeem(...)
{
...
uint256 grossAssets_ = _convertToAssets(shares, Math.Rounding.Floor);
uint256 fee_ = (withdrawFeeBps_ == 0 ||
caller_ == protocolTreasury ||
owner == protocolTreasury)
? 0
: (grossAssets_ * withdrawFeeBps_) / (10_000 + withdrawFeeBps_);
uint256 netAssets_ = grossAssets_ - fee_;
_trackWithdrawal(owner, netAssets_, shares);
_withdraw(caller_, receiver, owner, netAssets_, shares);
...
}

Inside _withdraw(), the vault liquidates only when the idle balance is insufficient. After liquidation, it only verifies that enough base asset exists for the already-fixed payout, then proceeds with the withdrawal.

if (vaultBalance < assets_) {
uint256 shortfall = assets_ - vaultBalance;
uint256 raised = IStrategyLogic(strategyLogic).liquidateForWithdrawal(
shortfall,
""
);
uint256 vaultBalanceAfter = IERC20(baseAsset).balanceOf(address(this));
if (vaultBalanceAfter < assets_) {
revert Error.InsufficientLiquidityForWithdrawal(assets_, vaultBalanceAfter);
}
}
super._withdraw(caller_, receiver_, owner_, assets_, shares_);

The liquidation path uses bounded swaps, but any realized spread, swap fee, or price impact is not assigned to the withdrawing account. The exiting user receives the pre-liquidation NAV amount, while the realized liquidation cost remains in the vault and reduces NAV for the remaining shareholders.

Remediation:

Recalculate the exit amount after withdrawal-induced liquidation, or otherwise adjust the withdrawal settlement to account for realized liquidation impact.

Charge realized liquidation impact to the exiting shares through an exit-impact fee or equivalent swing-pricing mechanism.

Ensure the withdrawal flow does not finalize assets and shares from pre-liquidation NAV when liquidation is required to satisfy the withdrawal.

ROWA2-24 | MID-FLIGHT ASYNC EXECUTIONS CAN TRIGGER FALSE CRITICAL ACTIONS

Severity:

High

Status:

Fixed

Path:

contracts/rebalance/PortfolioCoordinatorUpgradeable.sol#L1227-L1600

Description:

The PortfolioCoordinatorUpgradeable.executeWithTriggerAction() function is used to execute a rebalance using the trigger-action pattern. A trigger entry with CRITICAL priority is allowed to bypass both the strategy-wide cooldown and the in-flight overlap gate by preempting any in- flight asynchronous execution.

function executeWithTriggerAction(
address strategy,
bytes calldata hint
)
external
override
whenNotPaused
nonReentrant
onlyAuthorizedRebalancer(strategy)
returns (bytes32 executionId)
{
for (uint256 i; i < entries.length; ) {
...
// Evaluate trigger
(bool triggered, /*reason*/, bytes memory triggerData) = ITriggerEvaluator(evaluator).evaluate(
strategy
);
...
}
...
if (winner.priority == ActionTypes.TriggerPriority.CRITICAL) {
uint64 lastCrit = _lastEntryExecution[strategy][_entryKey(winner)];
if (lastCrit != 0 && block.timestamp < lastCrit + CRITICAL_MIN_INTERVAL) {
revert Error.CooldownActive(
strategy,
uint64(lastCrit + CRITICAL_MIN_INTERVAL - block.timestamp)
);
}
if (_inFlightExecution[strategy] != bytes32(0)) {
_preemptInFlight(strategy);
}
}
...
if (!ranAsync) {
...
executionId = IActionHandler(handler).execute(
strategy,
winner.actionParams,
winnerTriggerData,
hint,
entryExecutionType
);
...
}

The issue arises when there is an in-flight asynchronous execution and part of the strategy's funds has already been transferred to an auxiliary source (auxSource) as part of the swap process. In this case, some trigger evaluators may derive incorrect position values, leading to an incorrect trigger decision. For example, DriftTriggerEvaluator fires when an individual asset's driftBps exceeds thresholdBps. During evaluate(), the asset values are obtained by calling positionValuation.derivePositionValues(), which only considers assets held by the vault and staking contracts, but does not account for assets temporarily held by the auxiliary source. Assume that an asset's calculated drift exceeds the configured threshold because part of its balance is currently held in the auxiliary source. executeWithTriggerAction() will consider the trigger valid and subsequently call _preemptInFlight(), which pulls the funds back from the auxiliary source to the vault. However, after the funds are returned, the asset's actual drift may no longer exceed the threshold and the trigger should not have fired. Despite this, the corresponding action is still executed because evaluate() is performed before _preemptInFlight() is called.

As a result, the protocol may execute an incorrect rebalance based on stale position values whenever an asynchronous execution is in flight. In the worst case, if the selected action handler is PauseActionHandler, the strategy may be unnecessarily paused and its funds temporarily frozen. Similarly, the in-flight asynchronous execution can have impact on the trigger evaluator that depends on the timestamp such as TimeTriggerEvaluator, DepegLadderTriggerEvaluator, MovingAverageTriggerEvaluator. Or for the evaluator such as DrawdownTriggerEvaluator, the high water mark should be updated before the async execution is started.

Remediation:

Consider taking the aux source's balance into account when triggering in the trigger entry's evaluate() function. For the timestamp, high water mark related, consider introducing a function onPreExecution() which allows the evaluator to update the timestamp related state, high water mark state before the execution is triggered.

ROWA2-22 | DOUBLE BIT SHIFT IN _LOPCONSUMED() CAUSES INCORRECT LOP INVALIDATOR SLOT LOOKUPS

Severity:

High

Status:

Fixed

Path:

contracts/execution/cow/CoWSwapModule.sol#L729

Description:

The CoWSwapModule._lopConsumed() function is used to determine whether a LOP order has already been invalidated on 1inch.

function _lopConsumed(uint256 makerTraits) internal view returns (bool) {
uint256 nonce = LopOrderPolicyLib.nonceOf(makerTraits);
uint256 word = LOP_ROUTER.bitInvalidatorForOrder(address(this), nonce >> 8);
return (word >> (nonce & 0xff)) & 1 == 1;
}

The function calls bitInvalidatorForOrder() with slot = nonce >> 8. However, inspecting the implementation of bitInvalidatorForOrder() in the 1inch LOP router (0x111111125421cA6dc452d289314280a0f8842A65) shows that the provided value is shifted by 8 bits once again.

function bitInvalidatorForOrder(address maker, uint256 slot) external view returns(uint256 /* result */) {
return _bitInvalidator[maker].checkSlot(slot);
}
function checkSlot(Data storage self, uint256 nonce) internal view returns(uint256) {
uint256 invalidatorSlot = nonce >> 8;
return self._raw[invalidatorSlot];
}

As shown above, checkSlot() computes the actual invalidator slot by performing nonce >> 8. Therefore, passing nonce >> 8 to bitInvalidatorForOrder() causes the value to be shifted twice, resulting in an effective nonce >> 16 lookup instead of the expected nonce >> 8. As a consequence, _lopConsumed() queries an incorrect invalidator slot and may incorrectly report that an order has not been invalidated. Since _lopConsumed() is used by reconcileLop(),

this can prevent cancelled or filled orders from being properly reconciled. As a result, the funds accounted for in lopOutstanding may become unsweepable until manual intervention (cancel).

Remediation:

Consider passing the unshifted nonce value to bitInvalidatorForOrder() and allowing the 1inch implementation to derive the correct invalidator slot internally.

- uint256 word = LOP_ROUTER.bitInvalidatorForOrder(address(this), nonce >> 8);
+ uint256 word = LOP_ROUTER.bitInvalidatorForOrder(address(this), nonce);

This ensures the invalidator slot is shifted exactly once (nonce >> 8), matching the 1inch implementation.

ROWA3-1 | NAV DOUBLE-COUNTS A CURVE POOL WHEN TWO STAKING OPTIONS SHARE THE SAME LP TOKEN

Severity:

High

Status:

Fixed

Path:

contracts/adapters/lending/LidoStakingAdapter.sol, contracts/adapters/lending/CurveCryptoLPAdapter.sol, contracts/adapters/lending/CurveStableLPAdapter.sol, contracts/adapters/lending/MetaMorphoSupplyAdapter.so

Description:

PortfolioValuator._getStakedBalance() is called once for each allocation asset and sums getPosition().currentValue across all active staking options registered for that asset. Non- custodial LP adapters derive this value from the vault's raw LP token balance:

// CurveStableLPAdapter.getPosition
uint256 lpBalance = IERC20(LP_TOKEN).balanceOf(vault);
uint256 vp = POOL.get_virtual_price();
uint256 value = Math.mulDiv(Math.mulDiv(lpBalance, vp, WAD), UNDERLYING_UNIT, WAD);

Consider a vault that allocates both USDC and USDT, with two CurveStableLPAdapter deployments using the same USDC/USDT pool:

  • Adapter A has UNDERLYING = USDC and is registered under USDC.

  • Adapter B has UNDERLYING = USDT and is registered under USDT.

  • Both adapters use the same LP_TOKEN.

As a result:

  • _getStakedBalance(strategy, USDC) ™ Adapter A ™ LP_TOKEN.balanceOf(vault) × vp, denominated in USDC.

  • _getStakedBalance(strategy, USDT) ™ Adapter B ™ the same LP token balance, denominated in USDT.

Therefore, the same LP position is included in NAV twice, effectively inflating the value of the position. This causes totalAssets() to be overstated, allowing redeemers to withdraw more than their fair share and potentially drain value from the remaining holders.

Several existing checks do not prevent this: AssetRegistry._assertStakingOptionUnique() only checks _assets[asset].stakingOptions. Since the two adapters are registered under different assets, they reside in separate arrays and are not considered duplicates.

  • _getStakedBalance() iterates over the registry's staking options rather than the strategy's

  • entry.stakingOptions, so both adapters are valued regardless of the strategy's configured options.

  • ConfigValidator does not check for duplicate receipt tokens across allocation entries.

Note that the same root cause can occur in another form when a vault-held receipt token is also an allocation asset. For example, a vault may allocate WETH with a Lido staking option whose receipt token is wstETH, while wstETH is separately allocated. In both cases, the same underlying balance can be counted twice in NAV. Similarly to CurveCryptoLPAdapter, MetaMorphoSupplyAdapter.

Remediation:

Consider de-duplicating staking positions by receipt token in the valuator. Before adding a position's value, track the getReceiptToken() of each active staking option and skip any option whose receipt token has already been counted. This approach covers both cases, including staking options registered after deployment, without requiring an additional registry-wide index.

ROWA3-10 | READ-ONLY REENTRANCY CAN MANIPULATE THE LP TOKEN PRICE DURING CURVE WITHDRAWALS

Severity:

High

Status:

Fixed

Path:

contracts/adapters/lending/CurveGaugeStakingAdapter.sol#L386, contracts/adapters/lending/CurveStableLPAdapter.sol#L350

Description:

The CurveStableLPAdapter.getPosition() function uses POOL.get_virtual_price() to determine the value of the Curve LP tokens held by the vault:

function getPosition(
address strategy,
address asset
) external view override returns (StakingPosition memory position) {
position.asset = UNDERLYING;
position.canWithdraw = true;
if (asset != UNDERLYING) return position;
address vault = IStrategyLogic(strategy).vault();
uint256 lpBalance = IERC20(LP_TOKEN).balanceOf(vault);
if (lpBalance == 0) return position;
uint256 vp = POOL.get_virtual_price();
// lpBalance ™ USD (1e18) ™ underlying token units.
uint256 value = Math.mulDiv(Math.mulDiv(lpBalance, vp, WAD), UNDERLYING_UNIT, WAD);
position.stakedAmount = value;
position.currentValue = value;
}

However, this exposes the valuation to read-only reentrancy, a well-known issue affecting Curve pools that support native ETH withdrawals. During remove_liquidity(), Curve burns the LP tokens before transferring the underlying assets to the recipient. This temporarily reduces the LP token total supply while the pool balances have not yet been updated, causing get_virtual_price() to temporarily return an inflated value:

CurveToken(lp_token).burnFrom(msg.sender, _amount)
for i in range(N_COINS):
...
if i == 0:
raw_call(msg.sender, b"", value=value)

If the withdrawn asset is native ETH, the external call made during the withdrawal allows the recipient to execute arbitrary logic before the Curve withdrawal completes. An attacker can use this callback to interact with another protocol that relies on get_virtual_price() for valuation. For example, assume a RowaVault uses CurveStableLPAdapter to stake stETH through the Curve stETH/ETH pool at 0xDC24316b9AE028F1497c275EB9192a3Ea0f67022.

An attacker could:

  1. Deposit a significant amount of assets into the RowaVault and receive vault shares.

  2. Provide a large amount of liquidity to the Curve pool.

  3. Initiate a large liquidity withdrawal from Curve.

  4. During Curve's native ETH transfer callback, reenter the RowaVault.

  5. At this point, the Curve LP tokens have already been burned, temporarily inflating get_virtual_price().

  6. The RowaVault therefore observes an artificially inflated value for its Curve LP position.

  7. The attacker withdraws or redeems their vault shares at the inflated valuation, receiving more assets than they should and potentially extracting a profit.

The core issue is that the vault relies on a Curve price oracle that can be temporarily manipulated during a reentrant callback. Any vault operation that can be reentered during this window and relies on the LP token valuation may therefore be vulnerable to an inflated NAV calculation.

Reference: https://www.chainsecurity.com/blog/curve-lp-oracle-manipulation-post-mortem

Remediation:

Consider calling the pool's withdraw_admin_fees function to trigger the reentrancy lock. Most protocols followed that protection pattern.

ROWA3-18 | A STALE CRITICAL TRIGGER BLOCKS EVERY OTHER CRITICAL TRIGGER FROM EXECUTING

Severity:

High

Status:

Fixed

Path:

contracts/rebalance/PortfolioCoordinatorUpgradeable.sol#L1509-L1511

Description:

The executeWithTriggerAction() function evaluates all enabled trigger entries, selects a winner based on priority, and dispatches the winner's action. The winner is selected using the following loop:

while (true) {
int256 bestPriority = -1;
bool found;
for (uint256 i; i < entries.length; ) {
if (eligible[i] && !skipped[i]) {
int256 p = int256(uint256(entries[i].priority));
if (p > bestPriority) {
bestPriority = p;
winnerIdx = i;
found = true;
}
}
unchecked { ++i; }
}
if (!found) break;
...
skipped[winnerIdx] = true;
anySkipped = true;
}

The comparison uses p > bestPriority, so when two entries have the same priority, the entry with the lower index wins.

CRITICAL is the highest priority in the enum, so a triggered critical entry always becomes the winner. When a critical entry wins while an asynchronous program is in flight, the coordinator cancels that program and then re-evaluates the winner:

if (_inFlightExecution[strategy] != bytes32(0)) {
_preemptInFlight(strategy);
(bool stillTriggered, bytes memory freshTriggerData) = _evaluateEntry(
strategy,
rebalanceRegistry.getEvaluator(winner.triggerType),
winnerKey
);
if (!stillTriggered) {
revert Error.TriggerStaleAfterPreemption(strategy, winnerIdx);
}
winnerTriggerData = freshTriggerData;
}

This re-check exists because position-derived evaluators rely on derivePositionValues(), which excludes balances held by the vault's CoW module. While a program is in flight, an asset may therefore appear under-allocated, causing a drift or exposure trigger to fire even though the corresponding condition does not actually exist.

Cancelling the program returns those balances to the vault, so the trigger is evaluated again and rejected if it no longer fires.

However, rejecting the stale trigger causes the entire transaction to revert. Unlike the selection loop above, there is no fallback to the next eligible entry when the selected winner becomes invalid after preemption.

As a result, consider a vault with two critical entries where the lower-indexed entry fires only because of the in-flight accounting discrepancy. That entry wins the contest every time, causes the program to be cancelled, fails the re-check, and reverts the transaction. The second critical entry is therefore never reached.

Consequently, a genuine emergency condition guarded by the second critical trigger, such as an emergency exit or pause, cannot be executed for as long as the program remains in flight.

Lower-priority entries are not affected because a non-critical winner reverts with ExecutionInFlight while a program is in flight, regardless of whether the trigger remains valid after preemption.

if (!stillTriggered) {
revert Error.TriggerStaleAfterPreemption(strategy, winnerIdx);
}

Remediation:

The root cause is that triggers are evaluated before the in-flight program is cancelled, meaning the evaluation can be based on an incorrect view of the portfolio. The current ordering exists because the overlap decision "depends on the winner's PRIORITY", which is unknown until the triggers have been evaluated.

Consider splitting executeWithTriggerAction() into two entry points so that the caller specifies the priority class up front, removing this ordering dependency:

  • executeCriticalTrigger() should cancel any in-flight program before evaluating triggers. Since the cancellation has already returned the CoW balances to the vault, all evaluators will operate on the correct portfolio state, preventing a phantom trigger from becoming eligible. Only critical entries should be considered. The strategy-wide cooldown and per-entry cooldown should continue to be bypassed, as they are today. If no critical entry fires, the function should revert. The revert will roll back the cancellation, preserving a legitimate in- flight program just as the current re-check does.

  • executeRoutineTrigger() should consider only non-critical entries and revert with ExecutionInFlight when a program is in flight, matching the current behaviour for these entries.

This removes the stale re-check entirely and eliminates the resulting starvation because the winner is selected only after the relevant entries have been evaluated against the correct portfolio state.

Keepers would then need to choose which entry point to call. The existing simulate() view can be used to determine which path is appropriate, or the keeper can attempt the critical path first and fall back to the routine path if no critical trigger is active.

ROWA3-19 | A REVERTING ACTION HANDLER PERMANENTLY BLOCKS EVERY LOWER-PRIORITY TRIGGER

Severity:

High

Status:

Fixed

Path:

contracts/rebalance/PortfolioCoordinatorUpgradeable.sol#L1607-L1613

Description:

After executeWithTriggerAction() selects a winning trigger entry, it dispatches the corresponding action handler:

if (!ranAsync) {
// Synchronous path — behavior unchanged from before EX-3.4d.
// Dispatch to acti
 they compute their own deltas)
executionId = IActionHandler(handler).execute(
strategy,
winner.actionParams,
winnerTriggerData,
hint,
entryExecutionType
);

The call is not wrapped in try/catch. Therefore, if the handler reverts, the entire transaction reverts. The winner is selected deterministically based on the current state, with ties broken by selecting the entry with the lowest index. As a result, if a handler reverts for a condition that does not resolve on its own, the same entry will win on every subsequent call and revert again. Any entry with a lower priority, or with the same priority but a higher index, is never reached. AdjustLeverageActionHandler is one handler that can revert in this way. It repays debt using the vault's balance of the debt asset:

address vault = IStrategyLogic(strategy).vault();
uint256 cash = IERC20(ILendingAdapter(lendingAdapter).debtAsset()).balanceOf(vault);
uint256 amount = repayNeeded > cash ? cash : repayNeeded;
if (amount == 0) {
revert Error.NotAllowed("AdjustLeverage: no cash to deleverage");
}

If investors withdraw the vault's debt-asset balance, cash becomes zero and the handler reverts. Notably, the same handler treats its three other early-exit conditions as soft no-ops through _skip(), which emits an event and returns normally:

if (hfNow == type(uint256).max) {
return _skip(strategy, executionId, bytes32("NO_DEBT"), hfNow);
}
if (hfNow >= targetHealthFactor) {
return _skip(strategy, executionId, bytes32("HF_AT_TARGET"), hfNow);
}
if (hfNow == 0) {
// Repaying vault cash into a position the venue will liquidate anyway only
// donates the cash to the liquidator —
 refuse, loudly observable, and let the
// strategy's OTHER protections (CRITICAL de-risk/exit entries) keep running.
return _skip(strategy, executionId, bytes32("INSOLVENT"), hfNow);
}

The comment on the INSOLVENT branch explicitly states that a soft skip is used to allow the strategy's other protections to continue running. The same reasoning applies to the zero-cash condition, but that condition currently causes a hard revert instead. The combination of a hard revert and deterministic winner selection therefore prevents other protections from executing.

Consider a strategy with two trigger entries:

  • Entry 0: HEALTH_FACTOR_TRIGGER with the ADJUST_LEVERAGE action at HIGH priority. The priority enum describes HIGH as "Stop loss, collateral management".

  • Entry 1: CASH_FLOW_TRIGGER with the INCREASE_CASH action. IncreaseCashActionHandler sells proportionally across the vault's positions to raise base currency, which is also the debt asset.

The sequence is:

  1. Investors withdraw, draining the vault's debt-asset balance.

  2. The health factor falls below the configured threshold, causing Entry 0 to trigger.

  3. The outflow also crosses the cash-flow threshold, causing Entry 1 to trigger.

  4. Entry 0 wins the priority contest.

  5. AdjustLeverageActionHandler finds that cash is zero and reverts.

  6. The entire transaction reverts, so INCREASE_CASH never executes and no positions are sold to raise the required base currency.

  7. The next keeper call observes the same state and repeats the failure.

As a result, the vault cannot raise the cash required by ADJUST_LEVERAGE because the only trigger capable of raising that cash is permanently starved by the higher-priority reverting trigger. The health factor can continue deteriorating until the lending position is liquidated, which is precisely the outcome the health-factor trigger is intended to prevent. Recovery requires manual intervention, such as the strategist disabling or reordering the trigger entries, a new deposit providing the required base currency, or a keeper calling executeRebalance() directly to bypass the trigger path. None of these recovery mechanisms occur automatically.

Remediation:

Consider dispatching the action handler inside a try/catch. If the handler reverts, mark the corresponding entry as temporarily ineligible by setting a skipUntil timestamp. Subsequent trigger evaluations should treat the entry as skipped until this timestamp expires, allowing other eligible triggers to execute. Consider clearing the skipUntil value for all entries whenever a trigger or rebalance succeeds, since the portfolio state may have changed and the condition that caused the previous failure may no longer apply.

ROWA-8 | STRATEGIST-CONTROLLED ALLOCATION UPDATES CAN MISSTATE VAULT NAV

Severity:

Medium

Status:

Fixed

Path:

contracts/strategy/StrategyLogicUpgradeable.solL#434

Description:

updateAllocations() can be called by the strategist or an admin and replaces the strategy's allocation list. The function validates each allocation entry independently, but it does not ensure that allocation assets are unique and does not check whether removed assets still have a live vault balance.

function updateAllocations(
StrategyConfig.AllocationEntry[] calldata newAllocations
) external onlyWhenActive requireRouter {
_checkStrategistOrAdmin();
if (newAllocations.length == 0) revert Error.NotAllowed("Strategy: no allocations");
...
}

After validation, the existing allocations are deleted and replaced with the strategist-supplied list.

delete _allocations;
for (uint256 i; i < length; ) {
_allocations.push(newAllocations[i]);
unchecked {
++i;
}
}

The portfolio valuator later derives the NAV asset list directly from _allocations.

function getAssets() external view returns (address[] memory) {
uint256 length = _allocations.length;
address[] memory assets_ = new address[](length);
for (uint256 i = 0; i < length; i++) {
assets_[i] = _allocations[i].asset;
}
return assets_;
}

Each returned asset entry is then valued independently and added to the total NAV.

for (uint256 i; i < length_; ) {
address asset_ = assets_[i];
uint256 walletBalance_ = IERC20(asset_).balanceOf(vault_);
uint256 stakedBalance_ = _getStakedBalance(asset_, registry_);
uint256 totalBalance_ = walletBalance_ + stakedBalance_;
...
values_[i] = Math.mulDiv(totalBalance_, price_, 10 ** decimals_);
total_ += values_[i];
}

A strategist can therefore submit an allocation update that changes NAV without first reconciling the allocation list against actual vault holdings. Removing an asset that the vault still holds excludes that asset from NAV. Adding the same asset more than once can cause the same balance to be counted multiple times. This can distort ERC4626 share pricing around deposits or withdrawals by accounts controlled by, or coordinated with, the strategist.

This gives the strategist a direct primitive to steal value from a vault:

  1. Decrease the NAV by removing an asset to make the NAV 50%.

  2. Deposit into the vault and obtain shares.

  3. Increase the NAV by adding the asset back to make the NAV 100% again.

  4. Withdraw all shares and get double the initial deposit's value back.

Remediation:

Require allocation assets to be unique in both initial configuration and updateAllocations().

Reject allocation updates that remove an asset while the vault still holds a non-zero balance for that asset.

Ensure the NAV calculation cannot double-count the same asset even if duplicate entries are present.

ROWA-16 | DECIMAL OFFSET SHOULD BE DYNAMIC BASED ON THE BASE ASSET

Severity:

Medium

Status:

Fixed

Path:

contracts/vault/ROWAVaultUpgradeable.sol:_decimalsOffset#L314-L316

Description:

In the ROWA vault, the _decimalsOffset function is currently hardcoded to always return 12. This has the adverse effect that the vault's share decimals will always be 12 greater than the base asset, as decimals() calculates this dynamically from the base asset:

function decimals() public view virtual override(IERC20Metadata, ERC20Upgradeable) returns (uint8) {
ERC4626Storage storage $ = _getERC4626Storage();
return $._underlyingDecimals + _decimalsOffset();
}

So for an 18-decimal token like DAI, WETH, etc. The shares will be minted at a decimal of 30. The view function decimals() will also return 30. This is much too large to safely integrate with most on-chain DeFi protocols and off-chain applications. A lot of DeFi protocols rely on tokens having <= 18 decimals and wonˇt work with anything larger. Normally _decimalsOffset is hardcoded for ERC4626 vaults where the base asset is known at deployment time, so it can be set correctly (usually 12 for USDC, 0 for WETH/DAI, etc.). For ROWA it would have to be dynamic based on the configured base asset. Furthermore, the inflation vector is already mitigated with the ERC4626 virtual assets/shares in _convertToShares/Assets and it can be further mitigated by seeding the vault with dead shares if desired.

function _decimalsOffset() internal pure override returns (uint8) {
return 12;
}

Remediation:

The function _decimalsOffset can be used to make the vault' shares always 18 decimals an d it should be dynamically calculated from the base asset's decimals:

base.decimals() < 18 ? 18 - base.decimals() : 0;

ROWA-21 | RESOLVEDEXADAPTER COULD SELECT THE WRONG DEX ADAPTER BY IGNORING ASSET PAIR COMPATIBILITY

Severity:

Medium

Status:

Fixed

Description:

resolveDexAdapterselects the first active adapter w hose type isDEXwith without verifying that the adapter supports the asset pair being traded.

// ActionLib.sol:108-118
for (uint256 i; i < allocation.adapters.length; ) {
IAssetRegistry.AdapterInfo memory info = assetReg.getAdapterInfo(allocation.adapters[i]);
if (info.adapterType == IAssetRegistry.AdapterType.DEX && info.active) {
return abi.encode(allocation.adapters[i]);
}
unchecked { ++i; }
}

As a result, when multiple DEX adapters are configured, the function may choose an incompatible adapter even though a later adapter in the list supports the trade. This causes rebalances to revert. The function iterates through allocation.adapters and immediately returns the first active DEX adapter it encounters:

for (uint256 i; i < allocation.adapters.length; ) {
  IAssetRegistry.AdapterInfo memory info =
    assetReg.getAdapterInfo(allocation.adapters[i]);
  if (info.adapterType == IAssetRegistry.AdapterType.DEX && info.active) {
   
return abi.encode(allocation.adapters[i]);
  }
  uncheck unchecked { ++i; }
}

As a result, adapter selection depends solely on ordering, not actual pair support.

Scenario

Assume the allocation is configured with:

adapters = [curveAdapter, uniswapAdapter]

and the supported routes are:

  • Curve: BASEtoken1

  • Curve: BASEtoken2

  • Uniswap: BASEtoken3

When the protocol needs to swap between BASE and token3, resolveDexAdapter selects curveAdapter because it is the first active DEX in the list. token3 pair, causing the swap to revert. Although uniswapAdapter supports the pair, it is never checked because the function returns immediately after finding the first DEX adapter.

Remediation:

If multiple DEX's are supported, consider adding the following check inside the loop.

if (info.adapterType == IAssetRegistry.AdapterType.DEX && info.active) {
++    if (ISpotTradeAdapter(allocation.adapters[i]).isPairSupported(allocation.asset, baseCurrency)) {
return abi.encode(allocation.adapters[i]);
++    }
}

ROWA-22 | DATASTREAMSPRICEVERIFIER IGNORES CHAINLINK REPORT EXPIRY AND CAN CACHE EXPIRED PRICES

Severity:

Medium

Status:

Fixed

Path:

contracts/oracles/DataStreamsPriceVerifier.sol

Description:

verifyAndCachedecodesreport.expiresAtfrom the Chainlink Data Streams report but never validates or uses it. Instead, cached prices always receive a new expiry based on the protocol'sdefaultCacheTTL.

As a result:

  • Expired reports can be accepted.Reports withreport.expiresAt < block.timestampare still verified, cached, and treated as fresh data.

  • Cache lifetime can exceed Chainlink's validity window.A report that is about to expire can remain usable for up to the full protocol TTL (ex. 300 seconds), even after Chainlink considers it stale.

This allows oracle data to remain valid in the protocol beyond the oracle network's intended expiry period. Impact All pricing cons umers rely onge getPrice(), including NAV calculations, withdrawals, rebalancing, and fee accounting. If stale or expired reports are cached, these operations may use outdated prices. Additionally, because there is no check that a new report is newer than the currently cached one, a keeper can overwrite a fresh price with an older report, effectively rolling back the cached value.

Remediation:

// reject if already expired by Chainlink's own clock
++ if (report.expiresAt < block.timestamp) revert Error.InvalidDataStreamsReport();
// cache only as long as BOTH constraints allow
verifiedPrices[asset][baseCurrency] = VerifiedPrice({
...
--    expiresAt: currentTimestamp + cachedTTL
++    expiresAt: uint64(Math.min(currentTimestamp + cachedTTL, report.expiresAt))
});

ROWA2-1 | VOLATILITYTRIGGEREVALUATOR UNDERREPORTS VOLATILITY DUE TO FIXED-POINT SCALING ERROR

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/triggers/VolatilityTriggerEvaluator.sol#L447-L461

Description:

VolatilityTriggerEvaluator reports annualized volatility ~1e9× too small, so for any realistic price evaluation the volatilityBps rounds to 0 and the trigger never fires. The issue is in _calculateStdDev. Returns are stored in 1e18 scale, but each squared difference is divided by PRECISION before taking the square root:

squaredDiff = (squaredDiff * squaredDiff) / PRECISION;
uint256 variance = sumSquaredDiff / (n - 1);
return _sqrt(variance);

This leavesvariancescaled by1e18. When_sqrt()is applied, the result is only1e9-scaledinstead of1e18-scaled. The rest of the calculation still treatsstddevas if it were1e18-scaled, making the computed volatility approximately1e9× too small.

uint256 annualizedVol = (stddev * SQRT_365_SCALED) / SQRT_SCALE;
uint256 volatilityBps = annualizedVol / 1e14;

Remediation:

KeepsquaredDiffat1e36 scaleby removing the division byPRECISION. This allows the square root to naturally return a1e18-scaledstandard deviation, matching the scale expected by the rest of the calculation.

- squaredDiff = (squaredDiff * squaredDiff) / PRECISION; // 1e18 scale ™ sqrt returns 1e9 (incorrect)
+
 squaredDiff = squaredDiff * squaredDiff;               // 1e36 scale ™ s sqrt returns 1e18 (correct)

Proof-of-Concept: Consider taking the aux source's balance into account when triggering in the trigger entry's evaluate() function. For the timestamp, high water mark related, consider introducing a function onPreExecution() which allows the evaluator to update the timestamp related state, high water mark state before the execution is triggered.

function test_PoC_EvenAbsurdSwingsAreUnderReported() public {
uint256[] memory prices = new uint256[](WINDOW_DAYS + 1);
for (uint256 i = 0; i < prices.length; i++) {
prices[i] = (i % 2 == 0) ? 1e23 : 1e16; // 10,000,000x swing every day
}
priceOracle.setHistoricalPrices(asset, baseCurrency, prices);
(bool triggered, bytes32 reason, bytes memory data) = evaluator.evaluate(
address(mockStrategy)
);
(uint256 reportedVolBps, ) = abi.decode(data, (uint256, uint16));
console2.log("Reported vol for 10,000,000x daily swing (bps):", reportedVolBps);
console2.log("Threshold (bps):", uint256(THRESHOLD_BPS));
assertEq(reason, evaluator.REASON_WITHIN_VOLATILITY());
assertGt(reportedVolBps, 0); // ~971: only absurd inputs produce anything non-zero
assertLt(reportedVolBps, THRESHOLD_BPS, "even a 10,000,000x swing stays under 20%");
assertFalse(triggered);
}

ROWA2-2 | STATEFUL TRIGGER EVALUATORS CAN BECOME OUT OF SYNC

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/PortfolioCoordinatorUpgradeable.sol#L742-L744, contracts/rebalance/PortfolioCoordinatorUpgradeable.sol#L890-L892

Description:

Some trigger evaluators maintain internal state that is updated through onPostExecution(). However, the coordinator only calls onPostExecution() for evaluators whose evaluate() function returned true. As a result, evaluators that return false are never notified that a rebalance has completed. Whether this is correct depends on the type of state the evaluator maintains.

This leads to two different issues:

  • TimeTriggerEvaluator stores the timestamp of its last trigger. Since this state is only updated when the Time trigger itself fires, it measures the time since the last Time-triggered rebalance instead of the last portfolio rebalance. This can cause redundant rebalances.

  • MovingAverageTriggerEvaluator stores crossover detection state. If the Moving Average trigger fires but loses the priority contest to another trigger, onPostExecution() still updates its baseline even though the MA action never executed. This consumes the crossover, causing it to be missed on subsequent evaluations. Additionally, single-direction configurations can leave the baseline stale when an unconfigured crossover occurs.

Remediation:

Each evaluator could maintain only the state it actually requires. TimeTriggerEvaluator should derive its timing from the coordinator's lastRebalanceTimestamp() instead of maintaining its own timestamp.

MovingAverageTriggerEvaluator should remove its dependency on a persisted baseline and derive crossover events directly from historical price data, avoiding state that can become consumed or stale.

ROWA2-4 | COMPOSITE-CHILDREN TRIGGERS NEVER RECEIVE ONPOSTEXECUTION

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/triggers/CompositeTriggerEvaluator.sol

Description:

When a strategy uses a COMPOSITE trigger that includes one or more stateful child triggers (e.g., TIME_TRIGGER, MOVING_AVERAGE_TRIGGER, or DRAWDOWN_TRIGGER), the child trigger's onPostExecution() hook is never invoked after a successful rebalance. As a result, the child trigger's internal state is not reset, causing TIME_TRIGGER to fire repeatedly and preventing MOVING_AVERAGE_TRIGGER and DRAWDOWN_TRIGGER from updating their crossover or high-water-mark state correctly. The issue stems from CompositeTriggerEvaluator not implementing ITriggerPostExecution and therefore not forwarding the onPostExecution() callback to the child triggers that participated in the evaluation.

Remediation:

CompositeTriggerEvaluator contract should implement ITriggerPostExecution and forward the hook to each referenced child that implements it:

function onPostExecution(address strategy) external override {
if (msg.sender != COORDINATOR) revert Error.CallerNotCoordinatorOrHandler(msg.sender);
bytes32[] storage types = _configs[strategy].triggerTypes;
for (uint i; i < types.length; ++i) {
address ev = triggerRegistry.getEvaluator(types[i]);
if (ev == address(0)) continue;
try ITriggerPostExecution(ev).onPostExecution(strategy) {} catch {}
}
}

ROWA2-8 | TRIGGERS CALCULATE INCORRECT PORTFOLIO ALLOCATIONS BY EXCLUDING IDLE CASH AND UNTRACKED ASSETS FROM NAV

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/triggers/NAVExposureTriggerEvaluator.sol, contracts/rebalance/triggers/DriftTriggerEvaluator.sol

Description:

Both triggers calculate an asset' allocation asassetValue / totalNAV, wheretotalNAVcomes fromderivePositionValues(). However,derivePositionValues()only sums the assets passed into it and does not include the idle base-currency buffer. This makes the denominator smaller than the actual portfolio NAV, causing reported allocations to be inflated. Drift trigger: targetBpsis based on the full portfolio NAV (assets + cash). The rebalance handler usesderivePortfolioValues, which includes the idle base-currency buffer when calculating current weights. However,DriftTrigger EvaluatorcalculatescurrentBpswithou without the buffer, causing the trigger and executor to use different NAV calculations. Because of this mismatch, the trigger can hide real drift because idle cash makes an asset appear closer to its target allocation than it actually is.

Example:

Target allocation:

  • ETH = 40%

  • BTC = 40%

  • USDC = 20%

Current portfolio:

  • ETH = $40

  • BTC = $40

  • USDC = $20

The true portfolio NAV is $100, so the actual allocation is:

  • ETH = 40 / 100 = 40%

  • BTC = 40 / 100 = 40%

  • USDC = 20 / 100 = 20%

The portfolio is exactly at target.

However,DriftTriggerEvaluatorexcludes the USDC buffer and calculates against only the positions:

  • ETH = 40 / 80 = 50%

  • BTC = 40 / 80 = 50%

The trigger sees ETH and BTC as over-allocated, even though the portfolio is correctly balanced when the USDC buffer is included.

NAV Exposure trigger: there are two ways the denominator shrinks:

  1. Unwatched assets are excluded.

Portfolio:

A = $25, B = $25, C = $25, D = $25

The true portfolio NAV is $100. If the trigger only watches[A [A, B], the denominator becomes $50, so A is reported as:

25 / 50 = 50%

instead of its true allocation:

25 / 100 = 25%

A single watched asset will always appear as 100% allocation because it becomes the entire denominator.

  1. Idle cash is excluded.

A = $33, B = $33, Cash = $34

The true portfolio NAV is $100, but the trigger only sees $66 of assets. This causes A to be reported as:

33 / 66 = 50%

instead of its true allocation:

33 / 100 = 33%

Remediation:

Update both triggers to use the full portfolio NAV (including all assets and the idle cash buffer) as the denominator, using PortfolioValuator.getPortfolioValue(strategy)or a or an equivalent buffer- inclusive NAV calculation, while keeping the watched asset list only for selecting which assets to evaluate.

ROWA2-27 | TARGETED DE-RISK INFLATES BASE-CURRENCY EXPOSURE OVER ITS CAP WHEN BASE IS A WATCHED ALLOCATION

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/actions/DeRiskActionHandler.sol#L388-L485

Description:

In the targeted mode of DeRiskActionHandler, only the assets flagged in the payload are sold down to cap * (10000 - reductionBps) / 10000 of the position-only NAV. For each flagged asset, the sell amount s_i satisfies:

(v_i - s_i) / (D - S) = rc / 10000

This formula assumes that all flagged assets share the same denominator and that the sale proceeds are removed from the position-value basis, causing the denominator to become D - S. However, this assumption does not hold when the base currency is itself an allocation asset. In that case, selling a non-base asset increases the strategy's exposure to the base currency rather than removing value from the position set. Consequently, if the evaluator also monitors the base currency, it may become flagged after the rebalance, causing the trigger to fire again.

Consider a example universe {ETH, BTC, USDC (base)} with a cap of 30% and reductionBps = 1000, resulting in rc = 27%.

Initial portfolio:

  • ETH: $500 (50%)

  • BTC: $250 (25%)

  • USDC: $250 (25%)

  • Total position value D = $1,000

Only ETH is flagged for de-risking.

Using the current formula, the handler computes that approximately $315 of ETH should be sold, assuming the total position value shrinks from $1,000 to $685, leaving ETH at 27% of the remaining NAV. However, because the sale proceeds are denominated in the base currency and the base currency is itself an allocation asset, the proceeds remain within the portfolio instead of leaving the position-value basis.

AssetBeforeAfter
ETH50%18.5%
BTC25%25%
USDC (base)25%56.5%

As a result, ETH is over-sold while the base currency's allocation increases significantly and exceeds the 30% cap. In the next evaluation cycle, USDC is flagged for de-risking (56.5% > 30%), causing the trigger to fire again.

Remediation:

Consider excluding the base currency from the risk-capping path.

ROWA2-11 | DRAWDOWNTRIGGEREVALUATOR MEASURES DRAWDOWN USING TOTAL NAV INSTEAD OF NAV-PER-SHARE

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/triggers/DrawdownTriggerEvaluator.sol

Description:

The drawdown trigger is intended to detectinvestment losses, but it measures drawdown u singtotal portfolio NAV (AUM)instead ofNAV-pe NAV-per-share.

function _getCurrentNAV(address strategy) internal view returns (uint256) {
...
(, uint256 totalValue) = positionValuation.derivePositionValues(strategy, assets);
return totalValue;
}

The high-water mark is stored using this total value, and drawdown is calculated as:

drawdownBps = ((highWaterMark - currentNAV) * 10_000) / highWaterMark;

Total NAV changes whenever assets enter or leave the vault, so it doesnotrepresent investment performance.

For example:

  • Withdrawalsreduce total NAV even if portfolio performance is unchanged, causing the trigger to report a false drawdown.

  • Depositsincrease total NAV and raise the high-water mark. A subsequent portfolio loss may not exceed this inflated HWM, delaying or preventing the trigger from firing.

  • Fee collections(redeeming accrued fee shares) also reduce total NAV and can incorrectly appear as investment losses.

As a result, the trigger measurescapital flowsrather than actual portfolio performance.

Impact

The drawdown trigger can:

  • False positive:Trigger an unnecessary rebalance after withdrawals or fee collections despite no investment loss.
  • False negative:Mi Miss or delay a genuine drawdown after deposits because the HWM was increased by new capital rather than portfolio appreciation.

Remediation:

Track the high-water mark using NAV-per-share(pri (price per share) instead of total NAV.

ROWA2-13 | LIQUIDITY-BASED POOL SELECTION CAN ROUTE SWAPS THROUGH WORSE-EXECUTION POOLS

Severity:

Medium

Status:

Fixed

Path:

contracts/adapters/dex /UniswapUniversalAdapter.sol#L214-L215, contracts/adapters/dex /UniswapUniversalAdapter.sol#L585-L591, contracts/adapters/dex /PancakeV3Adapter.sol#L410-L416

Description:

Both PancakeV3Adapter and UniswapUniversalAdapter select the swap pool based solely on the highest raw liquidity() value. However, raw liquidity is not a reliable measure of execution quality across fee tiers (or between Uniswap V3 and V4), since actual output depends on both fees and price impact. As a result, the adapters can route swaps through a pool that returns less output than another available pool. The trade still succeeds because the only protection is the protocol's oracle slippage limit, allowing the value loss to occur silently. Impact: This causes a recurring loss of value on affected swaps and rebalances. Although each swap is bounded by the configured slippage limit, the losses accumulate over time and directly reduce vault NAV. ((Example: (On the PancakeSwap USDC/WETH market, the adapter selects the 0.01% fee tier because it has the highest raw liquidity (1.854e16 vs 1.735e16 for the 0.05% tier). However, on a $250,000 swap, the rejected 0.05% pool returns approximately 77 79 bps more WETH despite having lower reported liquidity.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {console} from "forge-std/console.sol";
import {ForkTestBase} from "./ForkTestBase.sol";
import {StrategyConfig} from "../../vault/StrategyConfig.sol";
import {Quote} from "../../types/ExecutionTypes.sol";
import {Roles} from "../../access/Roles.sol";
import {IUniversalRouter} from "../../interfaces/external/uniswap-v4/IUniversalRouter.sol";
import {IPermit2} from "../../interfaces/external/uniswap-v4/IPermit2.sol";
import {IUniswapV3Factory} from "../../interfaces/external/uniswap-v3/IUniswapV3Factory.sol";
import {IPancakeV3Pool} from "../../interfaces/external/pancake-v3/IPancakeV3Pool.sol";
contract ForkPancakeAdapterBestExecBugTest is ForkTestBase {
uint24[4] internal FEES = [uint24(100), uint24(500), uint24(2_500), uint24(10_000)];
function setUp() public override {
vm.createSelectFork(vm.envOr("MAINNET_RPC_URL", string("https://ethereum-rpc.publicnode.com")));
super.setUp();
// Impersonate an authorized ExecutionRouter so the adapter's onlyRouter passes and it
// pulls/pushes against this contract (BaseAdapter P0-14 token flow).
vm.prank(superAdmin);
roleRegistry.grantRole(Roles.FACTORY_ROLE, address(this));
protocolRegistry.authorizeRouter(address(this));
}
function _b2_5_deployAndRegisterAdapters() internal override {
super._b2_5_deployAndRegisterAdapters();
_deployPancakeAdapter();
}
function _buildStrategyConfig() internal view override returns (StrategyConfig.Strategy memory) {
return _buildPancakeStrategyConfig();
}
function _b5_seedVault() internal override {}
// PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP
    // The PoC
    // PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP
 
    function test_poc_bug_priceBlindPoolSelection_leaksValue() public {
        uint256[2] memory sizes = [uint256(250_000e6), uint256(500_000e6)];
 
        bool demonstrated;
        for (uint256 s = 0; s < sizes.length && !demonstrated; s++) {
            uint256 tradeUsdc = sizes[s];
 
            // (1) The metric the adapter actually ranks on: highest raw in-range liquidity.
            (uint24 maxLiqFee,) = _maxLiquidityTier();
// (2) Confirm the adapter auto-selects exactly that max-raw-L tier.
Quote memory q = pancakeAdapter.getQuote(USDC, WETH, tradeUsdc);
assertEq(
uint256(_pathFee(q.routeData)), uint256(maxLiqFee), "adapter must select the max raw-liquidity tier"
);
// (3) Realized output through the adapter (routes the max-L tier).
uint256 adapterOut;
{
uint256 snap = vm.snapshotState();
deal(USDC, address(this), tradeUsdc);
IERC20(USDC).approve(address(pancakeAdapter), tradeUsdc);
adapterOut = pancakeAdapter.swap(USDC, WETH, tradeUsdc, 0, "");
vm.revertToState(snap);
}
// (4) Realized output through the best tier, measured against the REAL router.
(uint256 bestOut, uint24 bestFee) = _bestRealizedTier(tradeUsdc);
console.log("--- trade size (USDC) ---", tradeUsdc / 1e6);
console.log("adapter picked fee tier :", maxLiqFee);
console.log("adapter realized WETH   :", adapterOut);
console.log("best tier fee           :", bestFee);
console.log("best realized WETH      :", bestOut);
// The bug only surfaces once the trade is large enough that fee-tier + liquidity
// distribution diverge from raw L. Skip to the next size if it hasn't yet.
if (bestFee == maxLiqFee || bestOut <= adapterOut) continue;
// BUG CONFIRMED: a tier the adapter REJECTED (lower raw L) delivers strictly more.
uint256 lossBps = ((bestOut - adapterOut) * 10_000) / bestOut;
console.log(">> SILENT BEST-EXEC LOSS (bps):", lossBps);
// The loss sits INSIDE the vault's 5% oracle floor, so nothing reverts — it is a
            // silent leak on every rebalance, not a self-correcting failure.
            assertLt(lossBps, 500, "loss is within the oracle floor -> no revert, silent leak");
            assertGt(lossBps, 0, "adapter under-filled vs a rejected tier");
            demonstrated = true;
        }
 
        assertTrue(demonstrated, "adapter matched best tier at all tested sizes (bump trade size / re-pin block)");
    }
 
    // PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP
    // Helpers
    // PPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP
/// @dev The fee tier with the highest raw in-range liquidity —
 what the adapter will pick.
function _maxLiquidityTier() internal view returns (uint24 maxLiqFee, uint128 maxLiq) {
for (uint256 i = 0; i < 4; i++) {
address pool = IUniswapV3Factory(PCS_V3_FACTORY).getPool(USDC, WETH, FEES[i]);
if (pool == address(0)) continue;
if (IPancakeV3Pool(pool).slot0() == 0) continue;
uint128 liq = IPancakeV3Pool(pool).liquidity();
if (liq > maxLiq) {
maxLiq = liq;
maxLiqFee = FEES[i];
}
}
}
/// @dev The tier that ACTUALLY delivers the most WETH for `amtIn`, by real swaps on each tier.
function _bestRealizedTier(uint256 amtIn) internal returns (uint256 bestOut, uint24 bestFee) {
for (uint256 i = 0; i < 4; i++) {
address pool = IUniswapV3Factory(PCS_V3_FACTORY).getPool(USDC, WETH, FEES[i]);
if (pool == address(0)) continue;
uint256 snap = vm.snapshotState();
uint256 out = _rawSwap(FEES[i], amtIn);
vm.revertToState(snap);
if (out > bestOut) {
bestOut = out;
bestFee = FEES[i];
}
}
}
/// @dev A raw Pancake V3 exact-in swap through a SPECIFIC fee tier, driving the REAL
///      UniversalRouter with the classic 5-field layout (recipient = this contract).
function _rawSwap(uint24 fee, uint256 amtIn) internal returns (uint256 out) {
deal(USDC, address(this), amtIn);
IERC20(USDC).approve(PCS_PERMIT2, amtIn);
IPermit2(PCS_PERMIT2).approve(USDC, PCS_UNIVERSAL_ROUTER, uint160(amtIn), 
uint48(block.timestamp + 1));
bytes memory path = abi.encodePacked(USDC, fee, WETH);
bytes memory commands = abi.encodePacked(uint8(0x00)); // V3_SWAP_EXACT_IN
bytes[] memory inputs = new bytes[](1);
inputs[0] = abi.encode(address(this), amtIn, uint256(0), path, true);
uint256 before = IERC20(WETH).balanceOf(address(this));
IUniversalRouter(PCS_UNIVERSAL_ROUTER).execute(commands, inputs, block.timestamp + 1);
out = IERC20(WETH).balanceOf(address(this)) - before;
}
/// @dev Extract the fee (bytes[20:23]) from a single-hop packed path token|fee|token (43 bytes).
function _pathFee(bytes memory path) internal pure returns (uint24 fee) {
require(path.length == 43, "unexpected path length");
// solhint-disable-next-line no-inline-assembly
assembly {
fee := shr(232, mload(add(path, 52)))
}
}
}

Remediation:

Select pools based on expected output rather than raw liquidity. The approach could be to query the DEX quoter for each candidate pool and choose the route with the highest quoted output. The same fix should be applied to both adapters, including the Uniswap adapter's V3/V4 routing logic.

ROWA2-18 | UNABLE TO REBALANCE WHEN BASECURRENCY IS NOT INCLUDED IN THE STRATEGY'S ASSET

Severity:

Medium

Status:

Fixed

Path:

contracts/execution/cow/CoWSwapModule.sol#L548-L554

Description:

The CoWSwapModule.registerLopOrder() function is used to register a 1inch Limit Order Protocol (LOP) maker order so that the module can ERC-1271-sign it.

function registerLopOrder(
bytes32 executionId,
uint256 deltaIndex,
IOrderMixin.Order calldata order,
uint256 minTakingAmount
) external onlyExecutionStrategy returns (bytes32 orderHash) {
...
if (!(ASSET_REGISTRY.isAssetEnabled(makerAsset) && _inUniverse(makerAsset)))
revert Error.NotAllowed("sell token");
if (!(ASSET_REGISTRY.isAssetEnabled(takerAsset) && _inUniverse(takerAsset)))
revert Error.NotAllowed("buy token");
...
}

The function requires both makerAsset and takerAsset to satisfy _inUniverse(), meaning they must be included in the strategy's allocation assets.

function _inUniverse(address asset) internal view returns (bool) {
address[] memory assets = IStrategyLogic(strategy).getAssets();
for (uint256 i; i < assets.length; ++i) {
if (assets[i] == asset) return true;
}
return false;
}

However, checking only the strategy's allocation assets is insufficient. The function should also allow the strategy's baseCurrency, as it may legitimately appear in rebalance orders. During the rebalance flow, CoWRebalancePlanLib.planOrders() generates order intents from the rebalance deltas. In both PASS 2 and PASS 3, the generated orders may use baseCurrency as either the sellToken or the buyToken.

function planOrders(
RebalanceDelta[] memory deltas,
PricingCtx memory ctx
) internal pure returns (OrderIntent[] memory intents) {
...
// PASS 2 — residual net-sells settle asset™base.
for (uint256 s; s < nSell; ++s) {
if (sells[s].value == 0) continue;
OrderIntent memory oi = _intent(
sells[s].asset,
ctx.baseCurrency,
sells[s].value, 
            ctx
        );
        if (oi.sellAmount > 0 && oi.floor > 0) buf[count++] = oi;
    }
 
    // PASS 3 — residual net-deploys settle base™as asset (funded from vault-held base).
for (uint256 b; b < nBuy; ++b) {
if (buys[b].value == 0) continue;
OrderIntent memory oi = _intent(
ctx.baseCurrency,
buys[b].asset,
buys[b].value,
ctx
);
if (oi.sellAmount > 0 && oi.floor > 0) buf[count++] = oi;
}
...
}

As a result, if baseCurrency is not included in the strategy's asset universe, otherwise valid rebalance orders will fail the _inUniverse() check and cannot be registered, preventing the rebalance from being completed. The same issue exists in OrderPolicyLib.validate().

Remediation:

Consider treating the strategy's baseCurrency as a valid asset in addition to the strategy's allocation assets when validating makerAsset and takerAsset. The same change should be applied to OrderPolicyLib.validate() to ensure rebalance orders involving baseCurrency are handled correctly.

ROWA2-19 | LOP(1INCH) ORDERS OVERSTATE NAV, ALLOWING REDEEMERS TO WITHDRAW MORE THAN THEIR FAIR SHARE DURING ACTIVE ORDERS

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/PortfolioValuatorUpgradeable.sol#L526-L532

Description:

While a 1inch LOP rebalance order is waiting to be filled, the vault overstates its NAV, allowing users to redeem more assets than their shares are actually worth. The resulting loss is borne by the remaining vault holders once the order settles. This happens because the vault continues valuing the assets committed to the LOP order at the current market price, even though they can only be sold at the order's minimum execution price. The valuation logic is intended to discount assets that have already been committed to an order:

try IAuxBalanceSource(auxSource_).outstandingBudget(asset_) returns (uint256 c_) {
committed_ = c_;
} catch {
committed_ = balance_;
}
if (committed_ > 0) {
value_ -= discount;
}

This works for CoW orders because their committed amount is stored in outstandingBudget. However, LOP orders track committed assets in a separate lopOutstanding mapping, which is never read during valuation.

As a result, outstandingBudget(asset_) returns 0 for LOP orders, committed_ remains zero, the discount is skipped, and the committed assets continue to be valued at the current market price. This temporarily inflates NAV until the order is filled.

As the consequence, A user can redeem while an LOP order is in flight and receive an inflated payout. Once the order settles at its lower execution price, the difference is paid by the remaining shareholders.

Remediation:

Consider introducing a new function commitedBudget() which includes both the outstandingBudget and lopOutstanding and use that function when compute the committed balance on the auxSource_.

ROWA2-20 | AUX SOURCE VALUATION DISCOUNT CAN BE EXPLOITED THROUGH DEPOSITS AND WITHDRAWALS

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/PortfolioValuatorUpgradeable.sol#L519-L547

Description:

Currently, ROWAVaultUpgradable.totalAssets() uses getPortfolioValue() from PortfolioValuatorUpgradeable to determine the value of the assets held by the strategy in the base currency.

function getPortfolioValue(
address strategy
) external view override returns (uint256 totalValue) {
...
if (auxSource_ != address(0)) {
totalValue += _auxSourceValue(
auxSource_,
assets,
baseCurrency_,
_auxSlippageCap(strategy) 1000)
);
}
...
}
function _auxSourceValue(...) internal view returns (uint256 total_) {
...
if (committed_ > 0) {
uint256 committedValue_ = Math.mulDiv(committed_, price_, 10 ** decimals_);
value_ -= Math.mulDiv(
committedValue_,
slippageCapBps_,
BasisPointsLib.MAX_BPS
);
}
...
}

Notably, _auxSourceValue() applies a discount to all assets held in the auxSource, except for the base currency. From the perspective of totalAssets() alone, this behavior is reasonable because totalAssets() represents the amount of base currency that could be obtained if all assets were liquidated immediately.

However, totalAssets() is also used to determine the exchange rate between shares and assets during deposits and withdrawals. As a result, the discounted valuation of assets in the auxSource can be exploited by users to extract value from the vault.

For example, while an asynchronous execution is in progress, a user can deposit assets while the auxSource assets are being valued at a discount. This causes the user to receive more shares than they would have received based on the assets' actual value. Once the asynchronous execution completes, either through a successful fill or cancellation, the value of totalAssets() increases as the discount is removed. The user can then withdraw the shares obtained from the earlier deposit at the higher share price, capturing the resulting increase in value.

Remediation:

Consider using different valuation mechanisms for deposits and withdrawals: Deposits: value all assets held in the auxSource at their full value without applying the slippage discount.

Withdrawals: apply the slippage discount to all assets held in the auxSource, including the base currency. This prevents users from acquiring excess shares based on a temporarily discounted auxSource valuation while still ensuring that withdrawals conservatively account for the potential liquidation cost.

ROWA2-23 | UNABLE TO EXECUTE SWAPS THROUGH ONEINCHLOPADAPTER

Severity:

Medium

Status:

Fixed

Path:

contracts/adapters/orderbook/OneInchLOPAdapter.sol#L160-L161

Description:

The OneInchLOPAdapter.swap() function is used to fill a 1inch Limit Order Protocol maker order encoded in the routeData parameter. The function can only be called by the router (ExecutionRouterUpgradeable).

function swap(
address sellAsset,
address buyAsset,
uint256 sellAmount,
uint256 minBuyAmount,
bytes calldata routeData
) external override onlyRouter whenNotPaused nonReentrant returns (uint256 buyAmount) {
IOrderMixin.Order memory order = _decodeOrder(routeData);
...
}

As shown above, the order is decoded from routeData. Tracing the call flow, there is only one location in ExecutionRouterUpgradeable that invokes swap(), namely _executeOrderInternal().

function _executeOrderInternal(
bytes32 orderId,
address adapter,
uint16 maxSlippageBps
) internal returns (ExecutionResult memory result) {
...
if (isSwap) {
ISpotTradeAdapter(effectiveAdapter).swap(
order.sellAsset,
order.buyAsset,
remainingAmount,
effectiveMinBuyAmount,
order.executionData
);
}
...
}

There are two code paths that reach _executeOrderInternal(): executeOrder() and swapImmediate().

For executeOrder(), the specified order must already exist, which only happens after it has been registered through submitOrder(). However, submitOrder() can only be called by a strategy, and we could not identify any code path in StrategyLogic that invokes this function. Consequently, executeOrder() cannot be used to reach OneInchLOPAdapter.swap().

For swapImmediate(), there are two call sites:

  • LiquidationLib.liquidate()

  • BaseExecutionStrategy._executeDeltas() ™ _ _executeSell() / _executeBuy()

In both cases, hints.adapterSpecificData, which is ultimately forwarded as routeData to OneInchLOPAdapter.swap(), is always initialized as an empty byte array.

hints = ExecutionHints({
maxSlippageBps: maxSlippageBps,
urgency: Urgency.IMMEDIATE,
preferredAdapters: preferredAdapters,
excludedAdapters: new address[](0),
allowPartialFill: false,
minFillPercent: 10_000,
adapterSpecificData: ""
});

As a result, even if swapImmediate() reaches OneInchLOPAdapter.swap(), the function attempts to decode an order from an empty routeData value and cannot execute successfully. In conclusion, we could not identify any valid code path that supplies the required order data to OneInchLOPAdapter.swap(). As a result, 1inch LOP orders are unable to be executed through the adapter.

Remediation:

Consider triggering the submitOrder when registering new orders using 1inch.

ROWA3-2 | REWARD TOKENS CAN BE LOST IF THEY ARE NOT INCLUDED IN THE VAULT'S ALLOCATIONS

Severity:

Medium

Status:

Fixed

Path:

contracts/execution/curve/CurveGaugeModule.sol#L207-L228

Description:

The CurveGaugeModule.harvest() function mints all accrued CRV rewards and transfers them directly to the vault:

function harvest() external nonReentrant returns (uint256 minted_) {
uint256 before = crv.balanceOf(address(this));
try minter.mint(address(gauge)) {} catch {}
minted_ = crv.balanceOf(address(this)) - before;
if (minted_ > 0) {
crv.safeTransfer(vault, minted_);
}
try gauge.claim_rewards(address(this), vault) {} catch {}
emit Harvested(minted_, vault);
}

However, a problem arises when CRV is not included as an asset in the vault's allocations. In this case, the harvested CRV is transferred to the vault but is not accounted for when calculating the vault's NAV. As a result, the vault's NAV will be understated by the amount of unallocated CRV rewards.

More importantly, the CRV can become unrecoverable through any designed flow;. Even if the strategist or admin later becomes aware of the issue and attempts to add CRV to the vault's allocations, this is not possible because validateAndReplace requires the allocation's underlying asset to remain unchanged after the modification.

One might argue that the strategist should be aware that the Curve gauge generates CRV rewards and include CRV in the allocations before deploying the vault. However, this assumption does not hold for dynamic strategies.

For example, suppose the vault's initial intention does not include a Curve gauge as a staking adapter because the expected CRV rewards are too low to justify staking the underlying assets. At some point in the future, however, CRV emissions may increase sufficiently for the strategist to decide to allocate assets to the gauge to improve the vault's performance. Since CRV was not included in the original allocations, the subsequently harvested CRV would not be reflected in the vault's NAV and could become permanently inaccessible. Note that the issue does not only apply to CRV rewards; it also applies to any additional rewards provided by the gauge.

Remediation:

Consider swapping the reward tokens to the base asset then transferring it to the vault.

ROWA3-3 | MISSING STAKED BALANCE ACCOUNTING WHEN DERIVING PORTFOLIO VALUE IN ACTIONLIB LIBRARY

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/actions/ActionLib.sol#L76-L83

Description:

The ActionLib.derivePortfolioValues() function is used to calculate the total portfolio value, including any unallocated base currency held by the vault:

function derivePortfolioValues(
IPortfolioValuator valuator,
address strategy,
address[] memory assets,
address baseCurrency
) internal view returns (uint256[] memory values, uint256 totalValue) {
(values, totalValue) = valuator.derivePositionValues(strategy, assets);
// Check if base currency is in the allocation set
bool baseCurrencyInAllocations;
for (uint256 i; i < assets.length; ) {
if (assets[i] == baseCurrency) {
baseCurrencyInAllocations = true;
break;
}
unchecked {
++i;
}
}
// Add unallocated base currency balance to total
if (!baseCurrencyInAllocations) {
address vault_ = IStrategyLogic(strategy).vault();
uint256 baseBal = IERC20(baseCurrency).balanceOf(vault_);
if (baseBal > 0) {
uint8 baseDecimals = IERC20Metadata(baseCurrency).decimals();
totalValue += Math.mulDiv(baseBal, 1e18, 10 ** baseDecimals);
}
}
}

The function first derives the value of all allocated positions through derivePositionValues(). If the base currency is not included in the allocation set, it then adds the base currency balance held directly by the vault to the total portfolio value.

However, the function only accounts for the unstaked base currency balance held by the vault and does not account for any base currency that has been staked elsewhere.

As a result, whenever the vault's base currency has been staked, the staked amount is omitted from the portfolio valuation. This causes the portfolio value to be understated and may cause the incorrect behaviour in the action handler (eg: PartialRebalanceActionHandler).

Remediation:

Consider including the stake balance into the value calculation in the ActionLib.derivePortfolioValues() function.

ROWA3-4 | MIDASMINTADAPTER.MINT MIS-SCALES THE DEPOSIT AMOUNT, MIDAS EXPECTS 18 DECIMAL FOR USDC

Severity:

Medium

Status:

Fixed

Path:

contracts/adapaters/mint/MidasMintAdapter.sol

Description:

MidasMintAdapter.mint()pu pulls the base currency amount from the router and forwards the same value to Midas:

_pullTokens(BASE_CURRENCY, amount);
DEPOSIT_VAULT.depositInstant(
BASE_CURRENCY,
amount,
minReceiveAmount,
referrerId
);

However, Midas expectsamountto be expressed in18-decimal format, while the adapter passes the amount in the token'snative decimals(e.g. USDC = 6 decimals).

For example, a deposit of 20 USDC is pulled as20e6and passed directly to Midas, but Midas expects20e18. Midas therefore converts the value back to native decimals and attempts to pull 0 USDC.

As a result, deposits will revert.

Remediation:

Midas expects the deposit amount in 18 decimals, so scale the base-currency amount up before the call (e.g. amount * 10**(18 - BASE_DECIMALS)) and pass that to depositInstant.

ROWA3-5 | BORROW VALUATION EXCEEDS THE GAS LIMIT

Severity:

Medium

Status:

Fixed

Description:

PortfolioValuator._getStakedBalance() calls each staking adapter with a hard gas limit and treats any failure as fatal:

try
IStakingAdapter(options_[j].adapter).getPosition{ gas: ADAPTER_GAS_LIMIT }(
strategy_,
asset_
)
returns (IStakingAdapter.StakingPosition memory position_) {
...
} catch {
revert Error.StakingValuationFailed(options_[j].adapter, asset_);
}

ADAPTER_GAS_LIMIT is set to 100,000 gas. If getPosition() exceeds this limit, the call runs out of gas, the failure is caught, and the entire portfolio valuation reverts.

This can occur with AaveV3BorrowAdapter.getPosition(), which may require more than 100,000 gas when executed with cold storage accesses. The existing test_fork_aave_netEquity_underGasCap does not detect this because _supplyAndBorrow() is executed in the same transaction before the gas measurement. This warms the Aave pool, oracle, Chainlink feeds, and other storage slots under EIP-2929, causing the test to measure only ~32k gas instead of the ~153k gas required for a cold call in a real user transaction.

Remediation:

Consider increase the gas limit or use a per-adapter gas limit based on cold-state measurements. Otherwise we can remove the gas limit for the getPosition().

Proof of Concept: The PoC below demonstrates the difference between cold and warm execution:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import { IERC20 } from "@openzeppelin/contracts/interfaces/IERC20.sol";
import { console } from "forge-std/console.sol";
import { ForkAaveBorrow } from "./ForkAaveBorrow.fork.t.sol";
import { IStakingAdapter } from "../../interfaces/adapters/IStakingAdapter.sol";
import { IConstituentBound } from "../../interfaces/IConstituentBound.sol";
import { StrategyConfig } from "../../vault/StrategyConfig.sol";
interface IAaveOracleLike {
function getAssetPrice(address asset) external view returns (uint256);
function BASE_CURRENCY_UNIT() external view returns (uint256);
}
contract ForkAaveBorrowAuditPoC is ForkAaveBorrow {
address internal constant AAVE_ORACLE = 0x54586bE62E3c3580375aE3723C145253060Ca0C2;
uint256 internal constant ADAPTER_GAS_LIMIT = 100_000;
function setUp() public override {
super.setUp();
_supplyAndBorrow();
}
function test_fork_auditPoC_M2_getPositionGas_coldVsWarm() public {
bytes memory call = abi.encodeCall(
IStakingAdapter.getPosition,
(address(abStrategy), USDC)
);
// ---- COLD: the first thing this transaction touches ----
uint256 g0 = gasleft();
(bool okCold, ) = address(adapter).staticcall(call);
uint256 usedCold = g0 - gasleft();
assertTrue(okCold, "cold read must at least succeed unbounded");
// ---- WARM: everything above is now in the access list ----
uint256 g1 = gasleft();
(bool okWarm, ) = address(adapter).staticcall(call);
uint256 usedWarm = g1 - gasleft();
assertTrue(okWarm, "warm read");
console.log("getPosition gas COLD :", usedCold);
console.log("getPosition gas WARM :", usedWarm);
console.log("ADAPTER_GAS_LIMIT    :", ADAPTER_GAS_LIMIT);
// Method self-validation: if warmth were not reset between setUp and the test body,
// these two would be equal and the cold figure would be meaningless.
assertGt(usedCold, usedWarm, "warmth IS reset per tx: the cold number is real");
if (usedCold > ADAPTER_GAS_LIMIT) {
console.log(">>> COLD READ EXCEEDS THE FRAME. Overrun:", usedCold - 
ADAPTER_GAS_LIMIT);
} else {
console.log(">>> cold headroom under the frame:", ADAPTER_GAS_LIMIT - usedCold);
}
}
}

Output

getPosition gas COLD : 156945
getPosition gas WARM : 32782

ROWA3-9 | HARVEST_YIELD RESOLVES A SINGLE STAKING ADAPTER WHILE THE STAKING PATH ALLOWS ANY ADAPTER

Severity:

Medium

Status:

Fixed

Path:

contracts/rebalance/actions/HarvestYieldActionHandler.sol#L249-L271

Description:

AllocateToYieldActionHandler allows the caller to stake through any adapter. The adapter address is read directly from actionParams, and StakingExecutionLib._validate() only requires the adapter to be whitelisted for the underlying asset, active, and support STAKE. It does not require the adapter to be included in allocation.adapters or to be the first entry:

// StakingExecutionLib.sol:227-235
if (!assetRegistry.isAdapterWhitelisted(underlying, stakingAdapter)) revert ...
if (!assetRegistry.isAdapterActive(stakingAdapter)) revert ...
if (!IBaseAdapter(stakingAdapter).supportsOperation(op)) revert ...

However, HarvestYieldActionHandler resolves exactly one adapter per asset. The internal _resolveStakingAdapter() function returns the first active STAKING entry in allocation.adapters; otherwise, it falls back to AssetRegistry.getAdapterByType():

// HarvestYieldActionHandler.sol:258-278
for (uint256 j; j < allocation.adapters.length; ) {
...
if (info.adapterType == IAssetRegistry.AdapterType.STAKING && info.active) {
return candidate;   // first match wins
}
...
}
return assetReg.getAdapterByType(allocation.asset, IAssetRegistry.AdapterType.STAKING);

Consequently, positions staked through any adapter other than the resolved adapter are never harvested through this action. Multi-venue staking appears to be the intended model, making this single-adapter resolution an outlier:

  • PortfolioValuator._getStakedBalance() iterates over all active staking options, so NAV already accounts for multiple venues while harvesting only claims from one.

  • AssetRegistry.getAdaptersForAssetByType() already returns an array of adapters, but the handler does not use it.

The impact is currently latent rather than realised. CurveGaugeStakingAdapter is the only adapter whose claimRewards() currently performs any action; Lido, MetaMorpho, CurveStableLP, and CurveCryptoLP all return 0, while both borrowing adapters revert. Additionally, CurveGaugeModule.harvest() is permissionless, so CRV can still reach the vault through a direct call outside the handler.

Nevertheless, the issue is structural: the staking path, configuration schema, and valuator all support multiple venues, while the harvest path is hard-wired to a single adapter. As soon as a second rewarding adapter is introduced without a similar fallback, its rewards can become stranded.

Remediation:

Consider harvesting from all staking venues for the asset rather than only the first resolved adapter. AssetRegistry already exposes the required plural accessor, and the existing try/catch in _harvestPosition() provides isolation so that a failure in one adapter does not prevent harvesting from the others:

address[] memory stakingAdapters = assetReg.getAdaptersForAssetByType(
asset,
IAssetRegistry.AdapterType.STAKING
);
for (uint256 k; k < stakingAdapters.length; ++k) {
uint256 claimed = _harvestPosition(
strategy,
asset,
stakingAdapters[k],
assetReg
);
...
}

ROWA3-11 | EXTRA CURVE GAUGE REWARDS CAN BE STRANDED BY AN ATTACKER FRONT-RUNNING HARVEST()

Severity:

Medium

Status:

Fixed

Path:

contracts/execution/curve/CurveGaugeModule.sol#L224-L226

Description:

CurveGaugeModule.harvest() is responsible for claiming CRV and any additional rewards from the Curve Gauge and forwarding them to the vault:

function harvest() external nonReentrant returns (uint256 minted_) {
uint256 before = crv.balanceOf(address(this));
try minter.mint(address(gauge)) {} catch {
// Gauge not (yet) registered for CRV emissions — extras below still flow.
}
minted_ = crv.balanceOf(address(this)) - before;
if (minted_ > 0) {
crv.safeTransfer(vault, minted_);
}
try gauge.claim_rewards(address(this), vault) {} catch {
// Gauge has no extra-rewards surface — CRV path above is unaffected.
}
emit Harvested(minted_, vault);
}

However, the Gauge's claim_rewards() function is permissionless, allowing anyone to trigger a reward claim on behalf of the module. When _receiver is address(0), the Gauge's internal _checkpoint_rewards() function resolves the reward recipient to the _user address if no default rewards_receiver is configured:

def _checkpoint_rewards(_user: address, _total_supply: uint256, _claim: bool, _receiver: address):
user_balance: uint256 = 0
receiver: address = _receiver
if _user != ZERO_ADDRESS:
user_balance = self.balanceOf[_user]
if _claim and _receiver == ZERO_ADDRESS:
receiver = self.rewards_receiver[_user]
if receiver == ZERO_ADDRESS:
receiver = _user

Since the module address is used as _user, an attacker can front-run harvest() and directly call:

gauge.claim_rewards(address(CurveGaugeModule), address(0));

If no rewards_receiver is configured for the module, the extra rewards are transferred to the module itself rather than the vault. Because harvest() subsequently calls claim_rewards() again, the rewards may no longer be available for the intended transfer and can become stranded in the module.

Remediation:

Consider setting the module's default reward receiver to the vault during initialization:

@external
def set_rewards_receiver(_receiver: address):
"""
@notice Set the default reward receiver for the caller.
@dev When set to ZERO_ADDRESS, rewards are sent to the caller
@param _receiver Receiver address for any rewards claimed via `claim_rewards`
"""
self.rewards_receiver[msg.sender] = _receiver

This ensures that even if an attacker triggers claim_rewards() directly, the additional rewards are sent to the vault rather than remaining in the module.

ROWA3-14 | MALICIOUS STRATEGIST CAN BORROW NON-ALLOCATION ASSETS TO MANIPULATE THE VAULT'S NAV

Severity:

Medium

Status:

Fixed

Path:

docs/planning/completed/lending-borrow-design.md#venue-runbook-aave-borrow-operator-steps( contracts/execution/StrategistLendingGateway.sol#L118-L155

Description:

StrategistLendingGateway allows the vault's strategy deployer to execute lending operations on behalf of their vault through an active lending adapter:

function execute(
address router,
LendingCall[] calldata calls
) external returns (uint256[] memory moved) {
if (router == address(0)) revert Error.ZeroAddress();
if (calls.length == 0) revert Error.NotAllowed("Gateway: empty batch");
address vault = IRouterVaultView(router).vault();
if (msg.sender != IVaultWiringView(vault).strategyDeployer()) {
revert Error.NotAllowed("Gateway: caller is not the vault strategist");
}
address strategy = IVaultWiringView(vault).strategyLogic();
if (strategy == address(0)) {
revert Error.NotAllowed("Gateway: vault not wired");
}
moved = new uint256[](calls.length);
for (uint256 i = 0; i < calls.length; ++i) {
LendingCall calldata c = calls[i];
uint256 floor = _effectiveFloor(c, strategy);
moved[i] = ILendingExecutionRouter(router).lendingOperation(
c.adapter,
c.op,
c.amount,
c.minMoved,
abi.encode(floor)
);
emit StrategistLendingExecuted(
vault,
msg.sender,
c.adapter,
c.op,
c.amount,
moved[i],
floor
);
}
}

However, the function does not restrict which asset the strategist can borrow. As a result, a malicious strategist can borrow an asset that is neither included in the vault's allocations nor used as its base currency. Because the borrowed asset is not accounted for in the vault's NAV, the resulting debt can reduce the vault's NAV and create an opportunity for the strategist to profit:

  1. Borrow a non-allocation asset, reducing the vault's NAV.

  2. Deposit assets into the vault at the artificially reduced share price, receiving more shares than they should.

  3. Repay the borrowed debt, restoring the vault's NAV.

  4. Redeem the shares at the restored NAV and capture the resulting profit.

Remediation:

The design documentation states: Register the adapter (LENDING) and add the staking option on the DEBT asset (the net-equity NAV mark). DEBT must be a USD-pegged stable — the mark converts Aave's USD- denominated account data at par; USD~USDC par is an accepted residual (fail-LOW under a real depeg). Therefore, the debt asset is expected to be the vault's base currency. Enforcing this restriction would ensure that borrowed debt is always represented in the vault's NAV and prevent the strategist from manipulating the share price by borrowing an unaccounted asset.

function execute(
address router,
LendingCall[] calldata calls
) external returns (uint256[] memory moved) {
if (router == address(0)) revert Error.ZeroAddress();
if (calls.length == 0) revert Error.NotAllowed("Gateway: empty batch");
address vault = IRouterVaultView(router).vault();
if (msg.sender != IVaultWiringView(vault).strategyDeployer()) {
revert Error.NotAllowed("Gateway: caller is not the vault strategist");
}
address strategy = IVaultWiringView(vault).strategyLogic();
if (strategy == address(0)) {
revert Error.NotAllowed("Gateway: vault not wired");
}
++  address baseCurrency = strategy.asset();
moved = new uint256[](calls.length);
for (uint256 i = 0; i < calls.length; ++i) {
LendingCall calldata c = calls[i];
uint256 floor = _effectiveFloor(c, strategy);
++      require(ILendingAdapter(c.adapter).debtAsset() == baseCurrency);
moved[i] = ILendingExecutionRouter(router).lendingOperation(
c.adapter,
c.op,
c.amount,
c.minMoved,
abi.encode(floor)
);
emit StrategistLendingExecuted(
vault,
msg.sender,
c.adapter,
c.op,
c.amount,
moved[i],
floor
);
}
}

ROWA3-15 | WITHDRAWALS CAN FAIL BECAUSE MINT ADAPTER PARAMETERS ARE MISSING

Severity:

Medium

Status:

Fixed

Path:

contracts/libraries/LiquidationLib.sol#L133, contracts/rebalance/execution/BaseExecutionStrategy.sol#L647-L653

Description:

LiquidationLib hard-codes an empty payload for every sale it attempts:

ExecutionHints memory hints = ExecutionHints({
...
adapterSpecificData: ""
});

However, AaveV3SupplyAdapter.redeem() requires redemption parameters and rejects an empty payload:

if (data.length == 0) revert Error.InvalidRedeemData();

The call is wrapped in try ... catch {} (LiquidationLib.sol:136-147), so the revert is swallowed and the corresponding position is silently skipped during liquidation.

try
p.router.swapImmediate(
p.swappable[i],
p.baseCurrency,
assetAmount,
minOutNative,
hints
)
returns (uint256) {
unchecked {
++positionsSold;
}
} catch {}

This creates a mismatch with the vault's withdrawal-liquidity calculation. withdrawalLiquidity() considers any allocation leg with a primary adapter as sellable. Since aToken positions have a primary adapter, their value is included as available liquidity for withdrawals.

Consequently, the vault may report sufficient liquidity for a withdrawal that requires selling aToken positions, while the liquidation process is unable to redeem those positions and silently skips them. The required cash is therefore not raised, causing the withdrawal to fail and potentially leaving users' funds temporarily locked.

Furthermore a related issue exists in BaseExecutionStrategy._buildDeltaHints(), although it has a lower impact. The function hard-codes the minimum amount to zero:

if (isBuy && IBaseAdapter(preferredAdapter).supportsOperation(OperationType.MINT)) {
deltaHints.adapterSpecificData = abi.encode(uint256(0), bytes32(0));
} else if (!isBuy && IBaseAdapter(preferredAdapter).supportsOperation(OperationType.REDEEM)) {
deltaHints.adapterSpecificData = abi.encode(uint256(0));
}

For the MINT path, this does not cause a revert. abi.decode(data, (uint256)) successfully decodes the first 32 bytes of the 64-byte payload, ignoring the trailing word. As a result, minAtokensOut is decoded as 0, making the adapter's minimum-output check ineffective:

if (received < minAtokensOut) {
revert Error.NotAllowed("AaveV3Supply: mint below min");
}

Moreover, the oracle-derived effectiveMinBuyAmount never reaches the MINT/REDEEM adapters. _executeOrderInternal() passes this value only to the SWAP branch, while MINT/ REDEEM operations use order.executionData.

Therefore, the configured minimum buy amount is not properly enforced for MINT/REDEEM operations, potentially allowing the transaction to execute with an insufficient output amount. The same issues apply to Midas mTokens.

Remediation:

Consider encoding the scaled, oracle-raised minimum instead of uint256(0), so the MINT/ REDEEM legs carry the same floor the SWAP branch already enforces.

ROWA-4 | FEE SHOULD NOT ACCRUE WHEN VAULT IS INACTIVE

Severity:

Low

Status:

Fixed

Path:

contracts/vault/ROWAVaultUpgradeable.sol#L1156-L1201, contracts/vault/ROWAVaultUpgradeable.sol#L1213-L1219

Description:

The function _accrueFeeShares() is intended to prevent fee accrual while the vault is not active. At lines 820 824, it explicitly exits early when the vault is paused or stopped:

function _accrueFeeShares() internal {
...
// Skip accrual if vault is not active (paused/stopped)
if (vaultState != VaultState.Active) {
lastAccrual = uint64(block.timestamp);
return;
}
...
}

However, the pause(), unpause(), and stop() functions do not adjust lastAccrual or settle accrued state during state transitions. This can lead to incorrect fee accrual across inactive periods.

Consider the following scenario:

  1. At time T1, the vault is paused. Since _accrueFeeShares() is not called during pausing, lastAccrual remains d T1.

  2. At time T2, the vault is unpaused. No activity occurs between T1 and T2, so lastAccrual is still d T1.

  3. When the next user action occurs (deposit, withdraw, etc.), _accrueFeeShares() is triggered and accrues fees from lastAccrual up to the current timestamp.

This results in fees being accrued over the interval [T1, T2], even though the vault was inactive during that entire period. Consequently, the treasury receives fee shares for a time window in which no active vault operations were permitted.

Remediation:

Invoke _accrueFeeShares() explicitly during state transitions (pause, unpause, stop) to ensure fee accounting is properly synchronized.

--     function pause(string calldata reason_) public {
++     function pause(string calldata reason_, bool accrueFee) public {
++            if (accrueFee) _accrueFeeShares()
...
}
--     function unpause() public {
++     function unpause(bool accrueFee) public {
++            if (accrueFee) _accrueFeeShares()
...
}
--     function stop() public {
++     function stop(bool accrueFee) public {
++            if (accrueFee) _accrueFeeShares()
...
}

Introducing the accrueFee flag also provides operational flexibility, preventing a scenario where a failure inside _accrueFeeShares() (e.g., a revert condition) could block critical lifecycle actions such as pausing or stopping the vault.

ROWA-5 | LIQUIDATE FUNCTION COULD REVERT DUE TO DIFFERENCE IN SLIPPAGE CALCULATION

Severity:

Low

Status:

Fixed

Path:

contracts/libraries/LiquidationLib.sol#L72-L86, contracts/rebalance/PortfolioValuatorUpgradeable.sol#L219-L269

Description:

In the liquidate() function, inside the per-asset loop, targetSell18 gets distributed pro-rata across each swappable asset, then converted from value into a concrete token amount assetAmount.

uint256 assetAmount = Math.mulDiv(balance, targetSell18, totalSwappable18);

targetSell18 / totalSwappable18 is the fraction of the entire portfolio that needs to be liquidated, while balance is the vault's asset token balance.

uint256 balance = IERC20(p.swappable[i]).balanceOf(p.vault);

However, minOutNative is used as a minimum check for the swap of assetAmount, but it is not scaled by the vault balance. Instead, it is calculated using values18[i], which is scaled by walletBalance + stakedBalance.

// LiquidationLib.sol
(uint256[] memory values18, uint256 totalSwappable18) = p.valuator.derivePositionValues(
p.strategy,
p.swappable
);
...
uint256 expectedProceedsValue18 = Math.mulDiv(values18[i], assetAmount, balance);
uint256 minOutNative = Math.mulDiv(
(expectedProceedsValue18 * (10_000 - p.maxSlippageBps)) / 10_000,
10 ** p.baseDecimals,
1e18
);

The position value array values18 comes from PortfolioValuator.derivePositionValues(), which values the total balance (walletBalance + stakedBalance):

// PortfolioValuatorUpgradeable.sol
uint256 walletBalance_ = IERC20(asset_).balanceOf(vault_);
uint256 stakedBalance_ = _getStakedBalance(asset_, registry_);
uint256 totalBalance_  = walletBalance_ + stakedBalance_;     // L221
...
values_[i] = Math.mulDiv(totalBalance_, price_, 10 ** decimals_); // L268

Because values18[i] represents the value of (wallet + staked), while balance represents only the wallet balance, the implied per-unit value, values18[i] / balance, is overstated by a factor of (wallet + staked) / wallet. As a result, minOutNative is inflated by the same factor, making it be greater than liquidated asset value, and causing the swap in liquidate() to revert. ( Consequently, any investor whose withdrawal requires the liquidation of a swappable asset with both wallet and staked balances cannot complete that withdrawal.

ROWA-7 | WITHDRAWAL FEES ARE NOT INCLUDED IN LIQUIDATION SHORTFALL CALCULATION

Severity:

Low

Status:

Fixed

Path:

contracts/vault/ROWAVaultUpgradeable.sol#L540, contracts/vault/ROWAVaultUpgradeable.sol#L567

Description:

When a withdrawal fee is enabled, withdraw() and redeem() calculate a fee amount, but the internal withdrawal flow only ensures that the vault has enough base asset for the net user payout. The fee is transferred to the treasury after _withdraw() completes.

function withdraw(...)
{
...
uint256 grossAssets_;
uint256 fee_;
if (withdrawFeeBps_ == 0 || caller_ == protocolTreasury || owner == protocolTreasury) {
grossAssets_ = assets;
} else {
grossAssets_ = Math.ceilDiv(assets * (10_000 + withdrawFeeBps_), 10_000);
fee_ = grossAssets_ - assets;
}
uint256 shares_ = _convertToShares(grossAssets_, Math.Rounding.Ceil);
_trackWithdrawal(owner, assets, shares_);
_withdraw(caller_, receiver, owner, assets, shares_);
...
}
function redeem(...)
{
...
uint16 withdrawFeeBps_ = _withdrawFeeBps();
uint256 grossAssets_ = _convertToAssets(shares, Math.Rounding.Floor);
uint256 fee_ = (withdrawFeeBps_ == 0 ||
caller_ == protocolTreasury ||
owner == protocolTreasury)
? 0
: (grossAssets_ * withdrawFeeBps_) / (10_000 + withdrawFeeBps_);
uint256 netAssets_ = grossAssets_ - fee_;
_trackWithdrawal(owner, netAssets_, shares);
_withdraw(caller_, receiver, owner, netAssets_, shares);
...
}

Inside _withdraw(), liquidation is sized only against assets_, which is the net amount sent to the receiver. It does not account for the additional base asset needed for the withdrawal fee.

if (assets_ > 0 && strategyLogic != address(0)) {
address baseAsset = asset();
uint256 vaultBalance = IERC20(baseAsset).balanceOf(address(this));
if (vaultBalance < assets_) {
uint256 shortfall = assets_ - vaultBalance;
uint256 raised = IStrategyLogic(strategyLogic).liquidateForWithdrawal(
shortfall,
""
);
emit WithdrawalLiquidation(address(this), shortfall, raised);
uint256 vaultBalanceAfter = IERC20(baseAsset).balanceOf(address(this));
if (vaultBalanceAfter < assets_) {
revert Error.InsufficientLiquidityForWithdrawal(assets_, vaultBalanceAfter);
}
}
}

The treasury transfer is then attempted separately.

function _sendTransactionalFeeToTreasury(uint256 fee_, ITreasury.FeeSource source_) internal {
if (fee_ == 0) return;
address treasury_ = protocolTreasury;
address asset_ = asset();
IERC20(asset_).forceApprove(treasury_, fee_);
ITreasury(treasury_).receiveTransactionalFee(asset_, fee_, address(this), source_);
}

As a result, a fee-bearing withdrawal or redemption can fail when the vault has enough liquidity, after liquidation, to pay the receiver but not enough remaining base asset to also pay the treasury fee.

Remediation:

Include the withdrawal fee in the amount that must be available before finalizing the withdrawal.

Size withdrawal-induced liquidation against the full base asset requirement, including both receiver payout and treasury fee.

Keep the post-liquidation balance check aligned with all base asset transfers performed by the withdrawal path.

ROWA-9 | TREASURY BALANCES CAN BE BRICKED FOR MAX-SEND TOKENS LIKE COMPOUND

Severity:

Low

Status:

Fixed

Path:

src/treasury/TreasuryUpgradeable.sol:receiveTransactionalFee#L26-L38

Description:

The treasury contract exposes a function receiveTransactionalFee that allows anyone to donate into the treasury' holdings. It directly transfers the specified token from the msg.sender and adds it to _tokenBalances[token]. This m echanism can be exploited for tokens that mark type(uint).max as a special value that means ˝my entire balance˛. . These tokens do exist in major protocols, such as Compound V3 (cWETHv3: Compound WETH | Address: 0xA17581A9...6e593aE94 | Etherscan).

In the source we can see that it replaces this magic value with the sender's balance:

function transferInternal(address operator, address src, address dst, address asset, uint amount) internal 
nonReentrant {
if (isTransferPaused()) revert Paused();
if (!hasPermission(src, operator)) revert Unauthorized();
if (src == dst) revert NoSelfTransfer();
if (asset == baseToken) {
if (amount == type(uint256).max) {
amount = balanceOf(src);
}
return transferBase(src, dst, amount);
} else {
return transferCollateral(src, dst, asset, safe128(amount));
}
}

comet/contracts/Comet.sol at ed6ebcd84ac00906e8e725716891d482f4bef8b9 · compound- finance/comet

The treasury function uses transferFrom and allows the user to input the amount, so they can specify the maximum value even if that means that a balance of 0 would be transferred.

The treasury trusts this anyway and adds the maximum value to _tokenBalances[token]. Any subsequent transfer would result in an overflow revert, as the mapping entry is now at its maximum. This could completely disable a vault that uses a Compound token for fees.

function receiveTransactionalFee(
address token,
uint256 amount,
address vault,
FeeSource source
) external nonReentrant {
if (token == address(0)) revert Error.ZeroAddress();
if (amount == 0) revert Error.ZeroFeeAmount();
if (vault == address(0)) revert Error.ZeroAddress();
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
_tokenBalances[token] += amount;
emit TransactionalFeeReceived(token, amount, vault, source, msg.sender);
}

Remediation:

We recommend to use a balance check before and after the transfer to get the true amount that was transferred. This would also introduce for fee-on-transfer tokens.

Proof-of-concept:

pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import "src/treasury/TreasuryUpgradeable.sol";
contract Main is Test {
address constant CWETHV3 = 0xA17581A9E3356d9A858b789D68B4d866e593aE94;
TreasuryUpgradeable treasury;
function setUp() public {
treasury = new TreasuryUpgradeable(IRoleRegistry(address(this)));
}
function test_main() public {
CWETHV3.call(abi.encodeWithSignature("allow(address,bool)", address(treasury), true));
console.log("Balance: %d", treasury.getBalance(CWETHV3));
treasury.receiveTransactionalFee(
CWETHV3,
type(uint).max,
address(this),
ITreasury.FeeSource.DEPOSIT
);
console.log("Balance: %d", treasury.getBalance(CWETHV3));
}
}

ROWA-10 | ERC4626 LIMITS AND PREVIEWS DO NOT REFLECT VAULT- LEVEL CONSTRAINTS

Severity:

Low

Status:

Fixed

Description:

The vault relies on the default ERC4626 implementations for maxDeposit(), maxMint(), and maxRedeem(). As a result, these methods can report optimistic limits that do not account for vault-specific checks performed during the actual state-changing operations. However, deposits and mints are subject to additional checks before shares are minted. The vault enforces per-deposit limits and cumulative investor caps through _validateAndTrackDeposit().

function _validateAndTrackDeposit(address receiver_, uint256 netAssets_) internal {
StrategyConfig.InvestmentLimits memory limits_ = IStrategyLogic(strategyLogic)
.investmentLimits();
if (limits_.minimumAmount > 0 && netAssets_ < limits_.minimumAmount) {
revert Error.InvestmentBelowMinimum(netAssets_, limits_.minimumAmount);
}
if (limits_.maximumAmount > 0) {
if (netAssets_ > limits_.maximumAmount) {
revert Error.InvestmentExceedsMaximum(netAssets_, limits_.maximumAmount);
}
uint256 newTotal = investorDeposits[receiver_] + netAssets_;
if (newTotal > limits_.maximumAmount) {
revert Error.CumulativeInvestmentExceedsMaximum(
investorDeposits[receiver_],
netAssets_,
limits_.maximumAmount
);
}
}
investorDeposits[receiver_] += netAssets_;
}

The deposit hook also rejects deposits when the vault is paused or stopped, when KYC checks fail, or when the treasury is unset.

function _deposit(
address caller_,
address receiver_,
uint256 assets_,
uint256 shares_
) internal override {
if (vaultState == VaultState.Stopped) revert Error.VaultAlreadyStopped();
if (vaultState == VaultState.Paused) revert Error.NotAllowed("Vault: paused");
if (address(KYC) != address(0)) {
bytes32[] memory empty;
if (!KYC.isAllowed(caller_, empty)) revert Error.KYCCheckFailed(caller_);
if (receiver_ != caller_) {
if (!KYC.isAllowed(receiver_, empty)) revert Error.KYCCheckFailed(receiver_);
}
}
if (protocolTreasury == address(0)) revert Error.ZeroAddress();
super._deposit(caller_, receiver_, assets_, shares_);
}

Similarly, maxRedeem() can return the owner's full share balance even when redeeming all shares would fail due to KYC or insufficient liquidity after attempted liquidation.

function _withdraw(
address caller_,
address receiver_,
address owner_,
uint256 assets_,
uint256 shares_
) internal override {
if (address(KYC) != address(0)) {
bytes32[] memory empty;
if (!KYC.isAllowed(owner_, empty)) revert Error.KYCCheckFailed(owner_);
}
if (assets_ > 0 && strategyLogic != address(0)) {
address baseAsset = asset();
uint256 vaultBalance = IERC20(baseAsset).balanceOf(address(this));
if (vaultBalance < assets_) {
uint256 shortfall = assets_ - vaultBalance;
uint256 raised = IStrategyLogic(strategyLogic).liquidateForWithdrawal(
shortfall,
""
);
uint256 vaultBalanceAfter = IERC20(baseAsset).balanceOf(address(this));
if (vaultBalanceAfter < assets_) {
revert Error.InsufficientLiquidityForWithdrawal(assets_, vaultBalanceAfter);
}
}
}
super._withdraw(caller_, receiver_, owner_, assets_, shares_);
}

The preview methods also do not reflect all conditions that can cause the corresponding operation to revert. For example, previewDeposit() applies the deposit fee but does not account for pause state, KYC, per-investor caps, or treasury configuration.

function previewDeposit(uint256 assets) public view override returns (uint256) {
uint16 depositFeeBps_ = _depositFeeBps();
uint256 fee = Math.ceilDiv(assets * depositFeeBps_, 10_000 + depositFeeBps_);
return super.previewDeposit(assets - fee);
}

In addition, all state-changing ERC4626 entry points accrue fee shares before performing conversions, while previews are view functions and cannot update accrual state. When fees have accrued over time, preview values can differ from the values used by deposit(), mint(), withdraw(), or redeem() after _accrueFeeShares() runs.

function deposit(
uint256 assets,
address receiver
) public override nonReentrant returns (uint256) {
_accrueFeeShares();
...
}
function mint(uint256 shares, address receiver) public override nonReentrant returns (uint256) {
_accrueFeeShares();
...
}
function withdraw(
uint256 assets,
address receiver,
address owner
) public override nonReentrant returns (uint256) {
_accrueFeeShares();
...
}
function redeem(
uint256 shares,
address receiver,
address owner
) public override nonReentrant returns (uint256) {
_accrueFeeShares();
...
}

This makes the ERC4626 limit and preview surface unreliable for integrations that use these methods to determine whether an operation is available or to estimate the expected result.

Remediation:

Override maxDeposit() and maxMint() to reflect vault state, KYC eligibility, per-deposit limits, cumulative investor caps, and treasury availability.

Override maxRedeem() to account for owner eligibility and available withdrawal liquidity under the vault’s liquidation path.

Make preview methods return values that are consistent with the constraints enforced by their corresponding state-changing functions.

Account for pending fee-share accrual in preview calculations so estimates match the conversion state used by entry points as closely as possible.

ROWA-15 | TRIGGERENTRY.COOLDOWNSECONDS IS STORED AND EXPOSED BUT NEVER APPLIED DURING TRIGGER-ACTION EXECUTION

Severity:

Low

Status:

Fixed

Path:

contracts/rebalance/PortfolioCoordinatorUpgradeable.sol#L836-L841

Description:

ach TriggerEntry carries a cooldownSeconds field that is documented as a per-entry cooldown, with 0 meaning "fall back to the strategy default":

struct TriggerEntry {
bytes32 triggerType; // Which evaluator (from TriggerTypes library)
ActionType action; // What to do when trigger fires
TriggerPriority priority; // Resolution order (CRITICAL > LOW)
uint64 cooldownSeconds; // Per-entry cooldown (0 = use strategy default)
bool enabled; // Can be toggled without removing
bytes triggerConfig; // ABI-encoded evaluator-specific config (WHEN)
bytes actionParams; // ABI-encoded action-specific parameters (WHAT)
}

The field is readable through StrategyLogic.getTriggerEntries(), so a strategist setting it would reasonably expect it to take effect. However, executeWithTriggerAction only checks the strategy-wide cooldown derived from _getEffectiveCooldown(strategy) and never reads entry.cooldownSeconds:

uint64 lastRebalance = _lastRebalanceTimestamp[strategy];
uint64 cooldown = _getEffectiveCooldown(strategy);
if (lastRebalance > 0 && block.timestamp < lastRebalance + cooldown) {
uint64 remaining = uint64(lastRebalance + cooldown - block.timestamp);
revert Error.CooldownActive(strategy, remaining);
}

The subsequent entry loop reads enabled, triggerType, priority, action, and actionParams, but not cooldownSeconds. The contract also keeps only a single strategy-level timestamp (_lastRebalanceTimestamp[strategy]); there is no per-entry timestamp to support per-entry

intervals. As a result, a per-entry cooldown configured on one entry has no effect, every entry is gated by the same strategy-wide cooldown, and the configured value is silently ignored.

Remediation:

Enforce entry.cooldownSeconds in the action-execution loop, using the strategy default when the field is 0, so the configured value governs that entry's firing interval.

Add per-entry last-execution timestamp tracking, since the current strategy-level timestamp cannot express independent per-entry intervals.

Alternatively, if per-entry cooldowns are not intended to be supported, remove the field from TriggerEntry and its getter so the exposed configuration matches the enforced behavior.

ROWA-20 | STRATEGY CHANGES CAN BE APPLIED WITHOUT GIVING INVESTORS TIME TO EXIT

Severity:

Low

Status:

Fixed

Description:

The strategist can immediately update important parameters, including asset allocations, risk settings, trigger configurations, and rebalancing parameters. These changes take effect as soon as the transaction is executed.

As a result, investors are not given an opportunity to withdraw before the vault's investment strategy changes.

The most significant case is when a vault that is invested entirely in low-risk assets (e.g., stablecoins) is suddenly reconfigured to hold a highly volatile asset. Existing investors may have deposited based on the original risk profile, but can be exposed to a substantially different strategy without any advance notice or withdrawal window.

Remediation:

Consider introducing a timelock for strategy changes (such as allocation and risk parameter updates) if the protocol intends to provide investors with an exit window before significant mandate changes become active.

ROWA-27 | OPS ROLE CANNOT EXECUTE VIA EXECUTIONROUTER

Severity:

Low

Status:

Fixed

Path:

./contracts/ExecutionRouterUpgradeable.sol

Description:

submitOrder, executeOrder, and swapImmediate are protected by onlyAuthorizedOrOps, allowing both authorized strategies and addresses with OPS_ROLE to call them. However, _executeOrderInternal requires order.strategy to be present in _authorizedStrategies:

if (!_authorizedStrategies[order.strategy]) {
revert Error.NotAllowed("Router: strategy not authorized");
}

When an OPS_ROLE address submits an order, order.strategy is set to msg.sender (the OPS address). Since OPS addresses are not registered in _authorizedStrategies, the order can never pass execution validation during swapImmediate or executeOrder.

Remediation:

Align the authorization requirements for order creation and execution. Either only allow authorized strategies to create orders, or ensure orders created by OPS_ROLE addresses can satisfy the strategy authorization checks during execution.

ROWA-26 | CHANGING THE FEE MANAGER RESETS THE VAULT' S HIGH- WATER MARK AND ALLOWS INCORRECT PERFORMANCE FEES

Severity:

Low

Status:

Fixed

Description:

When a vault changes its fee manager usingsetFeeManager(), the new fee manager could have a a lower high-water mark (HWM). Because of this, the new fee manager starts tracking performance fees from the vault' current NAV instead of the previous HWM. If the vault is below its old HWM at the time of the switch, future recovery gains can incorrectly be treated The current implementation only updates the fee manager address:

function setFeeManager(IFeeManager feeManager_) external {
_checkAdminOrFactory();
if (address(feeManager_) == address(0)) revert Error.ZeroAddress();
feeManager = feeManager_;
emit FeeManagerSet(address(feeManager_));
}

Remediation:

Consider preserving the vault' existing high-water mark by migrating it to the new fee manager before updating thefeeManager reference, ensuring performance fees are only charged on gains above the original HWM.

ROWA2-7 | STALE ATTESTATION REMAINS VALID AFTER CONFIGURATION UPDATES

Severity:

Low

Status:

Fixed

Path:

contracts/rebalances/triggers/CorrelationTriggerEvaluator.sol

Description:

In CorrelationTriggerEvaluator, updating a strategy's correlation configuration via setConfig() does not invalidate any previously stored attestation. As a result, evaluate() may continue using an attestation that was generated for a different asset set, observation window, or base currency, potentially leading to incorrect trigger decisions.

Remediation:

Delete the stored attestation (e.g.,delete _attestatio ns[strategy];) wheneversetConfig()upda updates the strategy configuration.

ROWA2-10 | REBALANCE TRIGGERS COULD USE STALE HISTORICAL PRICES

Severity:

Low

Status:

Fixed

Path:

rebalance/triggers/MovingAverageTriggerEvaluator.sol, oracles/PriceAggregatorUpgradeable.sol

Description:

The Moving Average and Volatility triggers pull the N newest records from the history buffer and treat them as current market data — they never verify the samples fall within an expected time window. getHistoricalPrices returns timestamps alongside prices, but both evaluators discard them:

(uint256[] memory prices, ) = priceOracle.getHistoricalPrices(...);

The reason here is because the buffer is only updated via recordPrice(), which appends with no max-age enforcement, and getHistoricalPrices returns the N newest records regardless of how old they are. So the window is bounded by count, not by time. As a result, they never verify that the records is fresh before computing moving averages or volatility.

For example: Keeper stops (or slows) recording. A "30-day" MA/vol keeps computing on a buffer whose newest sample is days/weeks old — or whose 30 records span 60 days (sporadic) or 30 hours (hourly cadence). The evaluator reports it as current.

Remediation:

Consider consuming the timestamps and rejecting unless the oldest sample used is within the predefined window period.

(uint256[] memory prices, uint256[] memory ts) = priceOracle.getHistoricalPrices(asset, base, N);
if (block.timestamp - ts[ts.length - 1] > maxSampleAge) return (false, REASON_DATA_STALE, "");

ROWA2-14 | UNSPENT V3 SELL TOKENS ARE NOT RETURNED TO THE VAULT

Severity:

Low

Status:

Fixed

Path:

contracts/execution/ExecutionRouterUpgradeable.sol#L715-L790

Description:

An exact-input Uniswap or PancakeSwap V3 swap may reach its price limit before consuming the full input amount. Both adapters return any unspent sell tokens to the calling ExecutionRouter.

function _settleAndPush(
address sellAsset,
address buyAsset
) internal returns (uint256 buyAmount, uint256 sellRemaining) {
_revokeApproval(sellAsset, address(PERMIT2));
PERMIT2.approve(sellAsset, address(UNIVERSAL_ROUTER), 0, 0);
buyAmount = _pushAllTokens(buyAsset);
sellRemaining = _pushAllTokens(sellAsset);
}

The router measures and returns only the acquired buy asset. It does not reconcile the refunded sell tokens and records the full requested remainingAmount as consumed.

uint256 routerBuyBefore = IERC20(order.buyAsset).balanceOf(address(this));
...
uint256 boughtAmount =
IERC20(order.buyAsset).balanceOf(address(this)) - routerBuyBefore;
if (boughtAmount > 0) {
IERC20(order.buyAsset).safeTransfer(vault, boughtAmount);
}
...
order.filledSellAmount += remainingAmount;
...
result = ExecutionResult({
orderId: orderId,
newStatus: order.status,
soldAmount: remainingAmount,
boughtAmount: boughtAmount,
adapterUsed: effectiveAdapter,
gasUsed: gasStart - gasleft(),
txRef: keccak256(abi.encodePacked(orderId, block.number, block.timestamp))
});

If the partially consumed swap still satisfies the minimum-output and oracle checks, the transaction succeeds while the sell-token refund remains in the router. The tokens are no longer included in the vault's assets, and the order records more input as consumed than the venue actually used.

Remediation:

Measure the sell-asset balance before and after each adapter call and derive the amount actually consumed.

Return any call-specific sell-asset refund to the originating vault and update fill accounting using the consumed amount.

If exact-input orders require full consumption, revert when an adapter returns a nonzero sell- asset refund.

ROWA2-15 | STRATEGIST ROUTER PAUSE CAN BLOCK LIQUIDATION- DEPENDENT WITHDRAWALS

Severity:

Low

Status:

Fixed

Path:

contracts/execution/ExecutionRouterUpgradeable.sol#L970-L995

Description:

The vault is designed to preserve investor withdrawals during lifecycle pauses and shutdowns, as stated in the _withdraw() implementation:

function _withdraw(
address caller_,
address receiver_,
address owner_,
uint256 assets_,
uint256 shares_
) internal override {
// Withdrawals allowed even when paused or stopped (emergency exit)
// ...
super._withdraw(caller_, receiver_, owner_, assets_, shares_);
}
function _ensureBaseLiquidity(uint256 baseNeeded) internal {
// ...
uint256 raised = IStrategyLogic(strategyLogic).liquidateForWithdrawal(shortfall, "");
uint256 vaultBalanceAfter = IERC20(baseAsset).balanceOf(address(this));
if (vaultBalanceAfter < baseNeeded) {
revert Error.InsufficientLiquidityForWithdrawal(baseNeeded, vaultBalanceAfter);
}
}

When an exit exceeds the vault' idle base balance, StrategyLogic liquidates deployed positions through ExecutionRouter.swapImmediate(). This function cannot execute while the router is paused.

function swapImmediate(
address sellAsset,
address buyAsset,
uint256 sellAmount,
uint256 minBuyAmount,
ExecutionHints calldata hints
)
external
override
whenNotPaused
onlyAuthorizedStrategy
nonReentrant
returns (uint256 boughtAmount)
{
// ...
}

The router allows the vault's strategist to pause it, while only PROTOCOL_ADMIN can unpause it.

function pause() external {
if (
!roleRegistry.hasProtocolRole(Roles.PAUSER_ROLE, msg.sender) &&
!roleRegistry.hasProtocolRole(Roles.PROTOCOL_ADMIN_ROLE, msg.sender) &&
msg.sender != IVaultApproval(vault).strategyDeployer()
) {
revert Error.NotAllowed("Router: requires PAUSER, PROTOCOL_ADMIN, or strategist");
}
_pause();
}
function unpause() external onlyProtocolAdmin {
_unpause();
}

When the strategist pauses the router, liquidation attempts revert and are caught by LiquidationLib. The vault subsequently fails its final liquidity check, causing withdrawals above the idle balance to revert until the router is unpaused. Withdrawals covered entirely by idle base remain available.

Remediation:

Separate the pause state for routine execution from the pause state governing vault-initiated withdrawal liquidation.

Allow liquidation during a strategist pause only through the canonical vault and its linked strategy, while retaining existing oracle, adapter, and minimum-output checks.

Reserve a complete liquidation halt for an explicitly authorized protocol emergency role.

ROWA2-16 | DISABLING A DUPLICATE TRIGGER ENTRY LEAVES ITS CONFIGURATION ACTIVE

Severity:

Low

Status:

Fixed

Path:

contracts/libraries/TriggerPolicyLib.sol#L291-L307

Description:

Trigger entries of the same type share one evaluator configuration per strategy. Enabling an entry writes its configuration to the shared evaluator. When that entry is later disabled, removed, or changed to another type, _disableTypeIfUnreferenced() returns if another entry of the original type remains enabled, without restoring the surviving entry's configuration.

function _disableTypeIfUnreferenced(
ActionTypes.TriggerEntry[] storage entries,
ITriggerRegistry registry,
bytes32 triggerType
) internal {
uint256 len = entries.length;
for (uint256 i; i < len; ) {
if (entries[i].enabled && entries[i].triggerType == triggerType) {
return; // still referenced by another enabled entry
}
unchecked {
++i;
}
}
if (registry.isTriggerEnabled(address(this), triggerType)) {
registry.disableTrigger(address(this), triggerType);
}
}

The surviving entry can therefore be evaluated using the inactive entry' configuration. A possible scenario is:

  1. A strategy has an enabled time trigger configured with a one-day interval.

  2. A second time trigger with a 60-second interval is temporarily enabled, overwriting the shared evaluator configuration.

  3. The second trigger is disabled, leaving the one-day trigger as the only enabled entry. However, the evaluator retains the 60-second configuration.

  4. After one hour, a keeper calls the Coordinator. The surviving entry is evaluated using the retained 60-second interval, and its action is dispatched even though its stored one-day condition has not been met.

Remediation:

Restore a deterministic surviving entry’s configuration whenever a same-type entry is disabled, removed, or changed to another type.

Store evaluator configurations per entry if different configurations must be supported for entries of the same type.

ROWA2-25 | MISSING STRATEGY PATH FOR CONTINUEASYNCEXECUTION

Severity:

Low

Status:

Fixed

Path:

contracts/rebalance/PortfolioCoordinatorUpgradeable.sol#L775

Description:

PortfolioCoordinatorUpgradeable.continueAsyncExecution() is used to advance an in-flight async execution. The function is protected by the onlyAuthorizedRebalancer(strategy) modifier.

function continueAsyncExecution(
address strategy
)
external
whenNotPaused
nonReentrant
onlyAuthorizedRebalancer(strategy)
returns (bool complete)
{
...
}
modifier onlyAuthorizedRebalancer(address strategy) {
_checkRebalanceAuthorization(strategy);
_;
}
function _checkRebalanceAuthorization(address strategy) internal view {
if (strategy == address(0)) revert Error.ZeroAddress();
// Registry-level pause: refuse to rebalance a vault paused in the
// ProtocolRegistry (covers executeRebalance, executeWithTriggerAction
// and continueAsyncExecution via the shared modifier). Cancellation is
// deliberately NOT gated — aborting an in-flight schedule must remain
// possible during an incident.
if (address(protocolRegistry) != address(0)) {
address vaultAddr_ = IStrategyLogic(strategy).vault();
if (protocolRegistry.isVaultPaused(vaultAddr_)) {
revert Error.VaultPausedInRegistry(vaultAddr_);
}
if (
msg.sender == strategy &&
protocolRegistry.isVaultRegistered(vaultAddr_) &&
ICoordinatorVaultLink(vaultAddr_).strategyLogic() == strategy
) {
return;
}
}
if (roleRegistry.hasProtocolRole(Roles.KEEPER_ROLE, msg.sender)) {
return;
}
if (roleRegistry.hasProtocolRole(Roles.PROTOCOL_ADMIN_ROLE, msg.sender)) {
return;
}
revert Error.CoordinatorUnauthorized();
}

There are three entities that are authorized to call this function, one of which is the strategy contract itself. However, there is currently no function in StrategyLogicUpgradeable that invokes continueAsyncExecution() on the coordinator contract. Consequently, progressing an in-flight async execution is entirely dependent on the KEEPER_ROLE or PROTOCOL_ADMIN_ROLE.

Remediation:

Consider introducing a dedicated function in StrategyLogicUpgradeable that allows the strategy to call continueAsyncExecution() on the coordinator contract, for example:

function continueRebalance() external onlyWhenActive whenNotPaused {
bool isKeeper = roleRegistry.hasProtocolRole(Roles.KEEPER_ROLE, msg.sender);
bool isStrategist = (msg.sender == IVaultStrategyDeployer(vault).strategyDeployer());
if (!isKeeper && !isStrategist) revert Error.StrategyNotKeeperOrStrategist();
portfolioCoordinator.continueAsyncExecution(address(this));
}

This allows the strategist or keeper to progress async executions through the strategy contract without relying exclusively on protocol-level roles.

ROWA3-7 | TRIGGER CONFIGURATION CAN BYPASS LEVERAGE VALIDATION

Severity:

Low

Status:

Fixed

Path:

contracts/rebalance/triggers/HealthFactorTriggerEvaluator.sol#L89-109

Description:

TriggerConfigValidator.validateEntry() ensures that the HEALTH_FACTOR_TRIGGER and ADJUST_LEVERAGE parameters within an entry are consistent. Specifically, they must reference the same lending adapter, and the target health factor must be strictly greater than the minimum health factor. This validation is performed during deployment and whenever a trigger entry is added or updated.

if (actionAdapter != triggerAdapter) {
revert Error.InvalidTriggerConfig("leverage entry: adapter mismatch");
}
if (targetHealthFactor <= minHealthFactor) {
revert Error.InvalidTriggerConfig("leverage entry: target must exceed floor");
}

However, HealthFactorTriggerEvaluator.setConfig() provides an alternative path to update the trigger configuration without validating it against the corresponding entry.

function setConfig(
address strategy,
bytes32 entryKey,
bytes calldata config
) external override onlyTriggerConfigurator(strategy) {
(address lendingAdapter, uint256 minHealthFactor) = abi.decode(config, (address, uint256));
if (lendingAdapter == address(0)) revert Error.InvalidTriggerConfig("lendingAdapter is zero");
if (minHealthFactor <= WAD) {
revert Error.InvalidTriggerConfig("minHealthFactor must exceed 1e18 (WAD)");
}
_configs[strategy][entryKey] = HealthFactorConfig({...});

As a result, a strategist can create a valid entry and then overwrite its evaluator configuration with a different lending adapter or health-factor threshold. The entry itself retains the original action parameters, leaving the trigger and action configurations inconsistent.

Remediation:

When setConfig() is called, resolve entryKey to the actual entry and re-run the cross-parameter validation before storing the configuration.

ROWA3-12 | DELEVERAGE TRIGGER CAN BE SET TOO CLOSE TO LIQUIDATION

Severity:

Low

Status:

Fixed

Path:

contracts/rebalance/triggers/HealthFactorTriggerEvaluator.sol#L99

Description:

A deleverage entry fires when the position's health factor falls below minHealthFactor, and AdjustLeverageActionHandler then repays debt until the health factor reaches targetHealthFactor. The validation of these two values is purely relative:

// HealthFactorTriggerEvaluator.setConfig
if (minHealthFactor <= WAD) {
revert Error.InvalidTriggerConfig("minHealthFactor must exceed 1e18 (WAD)");
}
// TriggerConfigValidator._validateLeverageEntry
if (targetHealthFactor <= minHealthFactor) {
revert Error.InvalidTriggerConfig("leverage entry: target must exceed floor");
}

AdjustLeverageActionHandler.validateParams() likewise only requires targetHealthFactor > WAD.

However, none of these checks require a safety margin above the liquidation threshold. A strategist can therefore set minHealthFactor to 1e18 + 1 and targetHealthFactor to 1e18 + 2, which passes all validation checks. The trigger would then fire only when the position is already at the liquidation threshold, while the handler would repay debt only until the health factor reaches a similarly unsafe level.

As a result, the automated deleverage protection can be configured to engage too late, allowing the position to be liquidated before a keeper can act. The resulting losses fall on the vault's depositors. Since ROWAVaultFactory.deployStrategy() is permissionless, the strategist configuring these values should be considered untrusted.

Remediation:

Enforce a minimum safety buffer above the liquidation threshold for minHealthFactor, and require targetHealthFactor to restore a meaningful margin above the trigger. These limits should be protocol-defined rather than fully controlled by the strategist.

ROWA-1 | EQUALITY CHECK FOR HIGH-WATER MARK OPTIMIZATION

Severity:

Informational

Status:

Fixed

Path:

contracts/vault/ROWAVaultUpgradeable.sol:_accrueFeeShares#L815-L952

Description:

The function _accrueFeeShares()re returns early only when the share price is below the high- water mark on line 906:

if (currentHWM_ > currentPricePerShare_) return;

WhencurrentPricePerShare_ == currentHWM_, no performance gain exists and no performance fee will be charged. However, the function still proceeds and uses gas to make a external call tofeeManager.calculatePerformanceFe e()be before later exiting.

Remediation:

Include the equality case in the early-return condition:

-- if (currentHWM_ > currentPricePerShare_) return;
++ if (currentHWM_ >= currentPricePerShare_) return;

ROWA-3 | ASSETREGISTRY OPTION MANAGEMENT FUNCTIONS DO NOT WHITELIST ADAPTERS

Severity:

Informational

Status:

Fixed

Path:

contracts/registry/AssetRegistryUpgradeable.sol

Description:

The registry expects any adapter used in a staking or lending option to also be whitelisted in _allowedAdapters[asset]. This is for example done during addAsset and modifyAsset, which call _whitelistAdapter for every adapter added for the asset's staking and lending options.

for (uint256 i = 0; i < metadata.stakingOptions.length; i++) {
  stored.stakingOptions.push(metadata.stakingOptions[i]);
  _whitelistAdapter(
   
metadata.asset,
    metadata.stakingOptions[i].adapter
  );
} );
}

However, the function to alter the staking and lending option do not add the adapter to the whitelist.

function addStakingOption(
  address asset,
  StakingOption calldata option
) external onlyProtocolAdmin {
  if (!_assetList.contains(asset))
    revert Error.AssetNotRegistered(asset);
  if (option.adapter == address(0))
    revert Error.InvalidAdapterAddress();
  _assets[asset].stakingOptions.push(option);
  emit StakingOptionAdded(
    asset,
    optio
n.protocolId,
    option. option.adapter
  );
}

The same issue exists in:

  • addLendingOption

  • updateStakingOption

  • updateLendingOption

As a result, these functions can create staking or lending options that reference adapters which are not whitelisted for that particular asset.

Remediation:

Call _whitelistAdapter whenever a staking or lending option is added or updated, consistent with the behavior already implemented in addAsset and modifyAsset.

ROWA-11 | REDUNDANT CHECK IN _VALIDATEANDTRACKDEPOSIT

Severity:

Informational

Status:

Fixed

Path:

src/vault/ROWAVaultUpgradeable.sol:_validateAndTrackDeposit

Description:

In the function _validateAndTrackDeposit the vault validates the deposit amount against the fixed limits, as well as against the total deposited amount for the user. It checks netAssets_ to be less than or equal to limits_.maximumAmount and afterwards immediately checks investorDeposits[receiver_] + netAssets_ to be less than or equal to limits_.maximumAmount. In simple terms, it first checks whether the new amount is less than the total allowed amount and then checks the new total to be less than the total allowed amount. This makes the first check redundant, as the second check already includes this new value. If the new amount is already greater than the limit, then logically the new total amount would be as well.

function _validateAndTrackDeposit(address receiver_, uint256 netAssets_) internal {
StrategyConfig.InvestmentLimits memory limits_ = IStrategyLogic(strategyLogic)
.investmentLimits();
if (limits_.minimumAmount > 0 && netAssets_ < limits_.minimumAmount) {
revert Error.InvestmentBelowMinimum(netAssets_, limits_.minimumAmount);
}
if (limits_.maximumAmount > 0) {
if (netAssets_ > limits_.maximumAmount) {
revert Error.InvestmentExceedsMaximum(netAssets_, limits_.maximumAmount);
}
uint256 newTotal = investorDeposits[receiver_] + netAssets_;
if (newTotal > limits_.maximumAmount) {
revert Error.CumulativeInvestmentExceedsMaximum(
investorDeposits[receiver_],
netAssets_,
limits_.maximumAmount
);
}
}
investorDeposits[receiver_] += netAssets_;
}

Remediation:

Remove the redundant check.

ROWA-17 | FACTORY STRATEGY DEPLOYMENT DOES NOT CHECK CONFIG HASH

Severity:

Informational

Status:

Fixed

Path:

contracts/factory/ROWAVaultFactory.sol:deployStrategy#L279-L453

Description:

The ROWA factory allows for any user to permissionlessly deploy a ROWA vault. For this it uses the deployStrategy function which takes a Strategy configuration struct and a configHash, which presumably is the hash of the configuration.

The function correctly validates parameters of the strategy configuration, but it fails to validate that the config hash actually matches the configuration:

ConfigValidator.validateConfig(strategy, assetRegistry);
bytes32 effectiveConfigHash = configHash;
if (effectiveConfigHash == bytes32(0)) {
effectiveConfigHash = keccak256(abi.encode(strategy));
}
if (configHash != bytes32(0) && address(configRegistry) != address(0)) {
if (!configRegistry.validateConfig(configHash)) {
revert Error.InvalidConfigHash(configHash);
}
}

The effectiveConfigHash is set to configHash and is calculated in-line if it was zero. Only if it was non-zero, would it be checked against the configRegistry to be a valid and approved config hash.

This second logic path is flawed, if the configHash was provided the recalculation is skipped and the configHash is checked against the registry. But there is never any certainty as to whether this configuration corresponds to that approved configHash at all. Any user can create an arbitrary configuration and pass an approved configHash that does not belong to this configuration.

The configHash is later stored in the registries:

if (configHash != bytes32(0) && address(configRegistry) != address(0)) {
configRegistry.linkConfigToVault(configHash, result.vault);
}
protocolRegistry.registerVault(
result.vault,
msg.sender, // strategist (strategy deployer)
result.router,
result.rebalancer,
result.riskGuardianRegistry,
result.feeManager,
result.configHash
);

While there is no direct impact on the code, if the front-end relies on configuration hash approval to give a trusted mark to vaults, then this could potentially be exploited by an attacker.

Remediation:

Calculate the configHash from the passed Strategy configuration directly instead.

ROWA-23 | PORTFOLIOVALUATORUPGRADEABLE.PAUSE DOES NOT PAUSE FUNCTIONS

Severity:

Informational

Status:

Fixed

Path:

contracts/rebalance/PortfolioValuatorUpgradeable.sol

Description:

PortfolioValuatorUpgradeable exposes pause and unpause functions using OpenZeppelin's( PausableUpgradeable module:

function pause() external onlyPauser {
_pause();
}

However, none of the external valuation functions enforce the paused state. The (whenNotPausedmodifier is not used for the contract' es entrypoints.

Remediation:

Consider removing the pausing functionality.

ROWA-25 | COORDINATOR AND STRATEGY GENERATE SEPARATE EXECUTION IDS

Severity:

Informational

Status:

Fixed

Description:

The coordinator and strategy generate different execution IDs for the same rebalance.

executionId = _generateExecutionId(strategy);
bytes32 strategyExecutionId = IExecutionStrategy(executionStrategyAddr).execute(
strategy,
deltas,
hint
);

The _recordSnapshot would use the coordinator execution id in sync executions( _recordSn while in async executions use the execution id generated by the strategy.

if (IExecutionStrategy(executionStrategyAddr).isAsync()) {
_inFlightExecution[strategy] = strategyExecutionId;
function _finalizeAsyncExecution(address strategy, bytes32 executionId) internal {
uint256 preTotalValue = _pendingSnapshotValueBefore[strategy];
delete _inFlightExecution[strategy];
delete _inFlightExecutionStrategy[strategy];
delete _pendingSnapshotValueBefore[strategy];
_lastRebalanceTimestamp[strategy] = uint64(block.timestamp);
_recordSnapshot(strategy, executionId, preTotalValue, bytes32(0));
}

Remediation:

We would reccomand to remove the coordinator execution id and rely on the exeuction id generated by the strategy.

ROWA2-26 | CHANGING THE COW MODULE DURING AN ACTIVE REBALANCE CAN FALSELY COMPLETE THE EXECUTION AND STRAND FUNDS

Severity:

Informational

Status:

Fixed

Path:

contracts/rebalance/execution/CoWExecutionStrategy.sol#L129-L143

Description:

It currently allows setCowModule() to change the execution module even while a rebalance is still in progress. However, continueExecution() and cancel() always use the current module from cowModule[strategy] instead of the module where the rebalance originally started. If the module is changed mid-execution, the coordinator starts looking in the new module, where no orders exist. It incorrectly concludes that the rebalance has finished, while the original module still contains the active orders and committed funds. setCowModule() immediately replaces the module without checking whether an execution is currently running.

Example scenario

  1. A rebalance starts using Module A.

  2. Orders are submitted and funds are committed in Module A.

  3. An admin calls setCowModule() and switches the strategy to Module B.

  4. The keeper calls continueExecution().

  5. The protocol checks Module B, finds no orders, and incorrectly marks the rebalance as complete.

  6. The coordinator clears the in-flight state, while Module A still holds the active order and committed funds.

Consequently,

  • Funds become stranded. The original module still controls the committed funds, but the coordinator no longer tracks them.

  • Execution state becomes inconsistent. The coordinator believes the rebalance finished and will not continue or cancel the original execution.

  • NAV can become incorrect. If valuation now points to the new module, funds locked in the old module may no longer be included until manual recovery.

Remediation:

A possible solution is to store the module address when an execution is created and always use that stored module for continueExecution() and cancel(). This ensures an execution always interacts with the module that created it.

As additional protection, setCowModule() should reject module changes while a rebalance is in progress. This prevents operators from creating an inconsistent state, but pinning the module per execution is the fix that eliminates the underlying issue.

ROWA2-5 | UNREACHABLE SNAPSHOT EXISTENCE CHECK

Severity:

Informational

Status:

Fixed

Path:

contracts/rebalance/triggers/CashFlowTriggerEvaluator.sol#L135-L137

Description:

The following check in evaluate() function is effectively unreachable:

if (snapshot.timestamp == 0) {
return (false, REASON_CONFIG_MISSING, "");
}

A snapshot is always created by setConfig() via _takeSnapshot, while calls made before configuration return earlier because config.minCashFlowAmount == 0 in line 128. As a result, there is no valid execution path where a configuration exists but the snapshot timestamp is zero.

Remediation:

Remove the redundant check.

ROWA3-16 | TOTALREWARDSCLAIMED SUMS AMOUNTS DENOMINATED IN DIFFERENT REWARD TOKENS

Severity:

Informational

Status:

Fixed

Path:

contracts/rebalance/actions/HarvestYieldActionHandler.sol#L196

Description:

In HarvestYieldActionHandler.execute(), totalRewardsClaimed is used to aggregate the rewards claimed across all positions:

if (stakingAdapter != address(0)) {
uint256 claimed = _harvestPosition(
strategy,
asset,
stakingAdapter,
assetReg
);
if (claimed > 0) {
totalRewardsClaimed += claimed;
++positionsHarvested;
emit PositionHarvested(
strategy,
asset,
stakingAdapter,
claimed
);
}
}

However, the claimed value returned by _harvestPosition() is denominated in the reward token of the corresponding adapter. Since different adapters may distribute different reward tokens, summing these raw amounts does not produce a meaningful value.

For example, if one adapter returns 100 USDC and another returns 100 CRV, totalRewardsClaimed becomes 200, even though the two amounts represent completely different assets and cannot be meaningfully added together.

As a result, totalRewardsClaimed does not represent any meaningful aggregate value and may be misleading to consumers of the function or its emitted data.

Remediation:

Consider removing totalRewardsClaimed or, alternatively, tracking claimed amounts separately by reward token.

ROWA3-8 | REDUNDANT CALL IN THE EXECUTE FUNCTION OF ALLOCATETOYIELDACTIONHANDLER

Severity:

Informational

Status:

Fixed

Path:

contracts/rebalance/actions/AllocateToYieldActionHandler.sol#L135

Description:

In the function execute(), getReceiptToken() is called even though its return value is never used, resulting in an unnecessary external call on every execution.

address receipt = IStakingAdapter(stakingAdapter).getReceiptToken(underlying);

Remediation:

Remove the unused call.

ROWA3-17 | GETPOSITION() IN LIDOSTAKINGADAPTER CAN BE SIMPLIFIED

Severity:

Informational

Status:

Fixed

Path:

contracts/adapters/lending/LidoStakingAdapter.sol#L265

Description:

The LidoStakingAdapter.getPosition() function calculates the vault's staked position on Lido by converting its wstETH balance into the corresponding stETH value:

function getPosition(
address strategy,
address asset
) external view override returns (StakingPosition memory position) {
position.asset = address(WETH);
position.canWithdraw = true;
if (asset != address(WETH)) return position;
uint256 wstBal = WSTETH.balanceOf(IStrategyLogic(strategy).vault());
if (wstBal == 0) return position;
uint256 value = Math.mulDiv(wstBal, WSTETH.stEthPerToken(), WAD);
position.stakedAmount = value;
position.currentValue = value;
}

The value is calculated by multiplying the wstETH balance by stEthPerToken() and scaling down by WAD. However, the wstETH contract already provides getStETHByWstETH(), which performs a similar conversion internally:

https://etherscan.io/ address/0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0#code#F1#L1116

function getStETHByWstETH(uint256 _wstETHAmount) external view returns (uint256) {
return stETH.getPooledEthByShares(_wstETHAmount);
}

Therefore, the calculation can be simplified by using the existing helper.

Remediation:

Consider modifying the function as follows:

function getPosition(
address strategy,
address asset
) external view override returns (StakingPosition memory position) {
position.asset = address(WETH);
position.canWithdraw = true;
if (asset != address(WETH)) return position;
uint256 wstBal = WSTETH.balanceOf(IStrategyLogic(strategy).vault());
if (wstBal == 0) return position;
-   uint256 value = Math.mulDiv(wstBal, WSTETH.stEthPerToken(), WAD);
+   uint256 value = WSTETH.getStETHByWstETH(wstBal);
position.stakedAmount = value;
position.currentValue = value;
}

Table of contents