Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0011·business-logic

Report-driven completion of a Parent-local rebalance target can lock backing in transit

Fixedvaultcrosschainepoch-accountinggithub.com/contractlevel/yield-v2
TL;DR

completeRebalance does not check that the pending target is remote. Calling it for a Parent-local target finalises the rebalance while the principal is still in transit, so the inbound delivery later arrives with no route to the strategy.

Severity
LOW
Impact
LOW
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
LOW
02Section · Description

Description

A rebalance whose target is a Parent-local strategy completes itself. When the inbound CCIP message arrives, _ccipReceive sets and funds the local adapter and then calls _finalizeRebalance directly. No external completion call is needed on that route, and none should be made.

completeRebalance does not know this. It forwards s_rebalance.pendingStrategy to _finalizeRebalance with no check that the target is remote, and its own guards cannot detect the problem: the nonce matches, the state is REBALANCING, the vault is unpaused, the caller holds REBALANCE_OPERATOR_ROLE, and no recovery is active. All of these hold while the principal is still in CCIP custody.

Calling it during that window records the local target as the active strategy without ever funding its adapter. That overwrites s_rebalance.activeStrategy.chainSelector, which is the exact value _ccipReceive uses to authorise the inbound message. When the authentic delivery arrives it is rejected with BaseVault__InvalidSourceChainSelector(child, parent). The check runs before any recovery handling, so the transaction reverts atomically and stores nothing.

The route is also the one route where the operator has nothing to verify. On a Parent-to-Child or Child-to-Child rebalance the destination child emits RebalanceDepositSuccess, so completion can be gated on observing it. A Child-to-Parent rebalance emits no such event — the deposit happens on Parent itself — so the confirmation step that protects every other route is undefined here, and nothing on-chain distinguishes the two cases for the caller.

ENV-007 already records that ParentVault does not prove the offchain facts behind a completeRebalance call. This finding is narrower: on this route the call is not merely unproven, it is never correct.

Parent reports zero vault-visible TVL while every share remains outstanding, the source child holds no position and no adapter, and the authentic transfer cannot be delivered: depositors lose access to the entire strategy principal until an upgrade or an out-of-band custody intervention restores it. No adversary participates at any point — the sequence requires a privileged operator to invoke a legitimate control during a specific window, and an operator who inspects getTVL() or the active adapter before completing will not proceed — so it is rated Low on likelihood, and reported rather than dropped because the consequence is unrecoverable through the protocol's own interfaces and the guard that prevents it is three lines.

Vulnerable scenario

Manual completion is a documented control. KI-013 describes a rebalance held in REBALANCING while operators reconcile the destination result, and OPERATIONS.md gives the procedure for finalising it by hand under a temporary role grant. The sequence below uses that procedure at the wrong moment.

  1. The active strategy is on a child. A rebalance back to a Parent-local strategy is initiated.
  2. The source child withdraws the full position, clears its active adapter, and sends the principal to Parent over CCIP. Parent remains REBALANCING; both recovery modes are NONE.
  3. While the message is in custody, an operator holding REBALANCE_OPERATOR_ROLE calls completeRebalance with the current nonce. Every guard passes.
  4. Parent clears the rebalance, advances the nonce, and records the Parent-local protocol as active with s_activeProtocolAdapter still unset.
  5. The authentic delivery reverts on the source-chain check and the principal stays in CCIP custody.

Recovery attempts that do not resolve it

  • Retrying delivery reproduces the same rejection, because the state it depends on was overwritten.
  • executeRecovery reverts on both vaults with BaseVault__NoPendingRecovery; the rejected delivery stored nothing.
  • A further rebalance rolls back, because the recorded local active adapter is address(0) and the withdrawal step fails.
  • setInitialActiveProtocolAdapter is one-shot and already consumed.
  • No setter exists for the active adapter or for recovery state, and setCrosschainVaults does not help: the failing comparison is against s_rebalance.activeStrategy.chainSelector, not the crosschain registry.
03Section · Recommendation

Recommendation

Reject report-driven completion of a Parent-local target. That route is finalised by _ccipReceive on delivery, so refusing it here removes the only way to clear the state that authorises the inbound message:

diff
diff --git a/evm/src/interfaces/vaults/IParentVault.sol b/evm/src/interfaces/vaults/IParentVault.sol
@@
error ParentVault__NoRebalanceInProgress();
+ error ParentVault__LocalRebalanceCompletesOnCcipReceipt();
diff --git a/evm/src/vaults/ParentVault.sol b/evm/src/vaults/ParentVault.sol
@@
function completeRebalance(uint256 expectedRebalanceNonce) external ... {
_requireNoRecovery(_baseVaultStorage());
Types.Rebalance storage s_rebalance = _parentVaultStorage().s_rebalance;
+ if (s_rebalance.pendingStrategy.chainSelector == i_thisChainSelector) {
+ revert ParentVault__LocalRebalanceCompletesOnCcipReceipt();
+ }
_finalizeRebalance(expectedRebalanceNonce, s_rebalance.pendingStrategy);
}

Any automated completion handler should additionally submit the nonce carried by RebalanceDepositSuccess rather than reading the current nonce from Parent, so a stale event cannot be applied to a later rebalance.

When to re-rate. Two changes would remove the operator judgement that currently bounds likelihood: an automated completion handler that derives its nonce from live Parent state rather than from the nonce carried by the triggering event, and Parent-local strategies becoming a routine rebalance target rather than an occasional one.

04Section · Resolution

Resolution

Fixed in 8ecd597: completeRebalance now rejects a Parent-local pending strategy, which is finalised on CCIP receipt instead, and OPERATIONS.md no longer points an operator at that route.

05Section · Affected files

Affected files

  • evm/src/vaults/ParentVault.sol#L513-L522 (completeRebalance)
  • evm/src/vaults/ParentVault.sol#L311-L344 (_ccipReceive, inbound rebalance delivery)
  • evm/src/libraries/vaults/ParentVaultRebalanceLib.sol#L180-L207 (_finalizeRebalance)
  • docs/operator/OPERATIONS.md (manual rebalance completion procedure)
Status
Fixed
Fix commit
8ecd597
F-2026-0011