Report-driven completion of a Parent-local rebalance target can lock backing in transit
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.
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.
- The active strategy is on a child. A rebalance back to a Parent-local strategy is initiated.
- 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 areNONE. - While the message is in custody, an operator holding
REBALANCE_OPERATOR_ROLEcallscompleteRebalancewith the current nonce. Every guard passes. - Parent clears the rebalance, advances the nonce, and records the Parent-local protocol as active
with
s_activeProtocolAdapterstill unset. - 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.
executeRecoveryreverts on both vaults withBaseVault__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. setInitialActiveProtocolAdapteris one-shot and already consumed.- No setter exists for the active adapter or for recovery state, and
setCrosschainVaultsdoes not help: the failing comparison is againsts_rebalance.activeStrategy.chainSelector, not the crosschain registry.
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 --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.
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.
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)