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-0006·error-handling

Over-capacity CCIP transfer is stored as replayable recovery, permanently locking every holder's principal

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

Every CCIP send failure is stored as replayable recovery, including a transfer that exceeds the lane's rate-limit capacity and can never succeed. Once the position outgrows the lane, recovery replays into itself and every holder's principal is locked.

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

Description

ChildVault._ccipSend treats every ccipSend failure as transient:

solidity
try this.tryCcipSend(bridgeAmount, destinationChainSelector, ccipTxType, nonce, protocolId) {}
catch {
_storeCcipSendRecovery($_baseVault, bridgeAmount, destinationChainSelector, ccipTxType, nonce, protocolId);
}

The stored recovery holds the exact bridgeAmount, and executeRecovery() replays that same amount. That is correct for a transient failure (fee spike, momentary router unavailability) and wrong for a permanent one, because a permanent failure replays into itself forever.

The distinction is one the code already makes. The line directly above the try reads "Validate outside try/catch so configuration errors revert instead of being stored as recovery", and _validateCcipSend is hoisted out for exactly that reason. Configuration errors are meant to revert; only transient failures are meant to become replayable recovery. A permanently-unsatisfiable transfer is the former, and it lands on the wrong side of that line.

CCIP token pools have exactly such a permanent failure. RateLimiter._consume distinguishes two cases, and only the second is a wait:

ConditionErrorClears?
capacity < requestTokensTokenMaxCapacityExceededNever — independent of fill level and of elapsed time
tokens < requestTokensTokenRateLimitReachedYes, on refill

The capacity comparison sits after the refill step and reads only capacity, never the current token balance and never elapsed time. A bucket can never hold more than capacity, so a single transfer larger than the lane's configured capacity is rejected permanently, no matter how long the vault waits. The rate-limit branch, by contrast, returns a minWaitInSeconds.

Nothing bounds the amount against that ceiling. BaseVaultCcipLib._validateCcipSend checks only that the amount is nonzero, the destination selector is valid and non-local, and a counterpart vault is registered. A cross-chain rebalance withdraws type(uint256).max from the source adapter (ChildVault.sol#L301, and again at #L528 in recovery) and bridges the entire resulting position in one message, so the amount scales with TVL and will cross any fixed ceiling as the protocol grows.

Once the send is caught, _requireNoRecovery rejects every other ChildVault entry point, so the position cannot be reduced below the ceiling by any in-protocol path. The Child is closed to further work and the assets sit in its balance.

The stranded operation also holds the Parent. The epoch whose remote leg never completes cannot leave EXECUTING, and ParentVaultEpochLib refuses to close any later epoch while it has not become CLAIMABLE:

solidity
uint256 previousEpochNonce = epochNonce - 1;
if (previousEpochNonce != 0 && $.s_epochs[previousEpochNonce].status != Types.EpochStatus.CLAIMABLE) {
revert IParentVault.ParentVault__EpochNotClaimable(previousEpochNonce);
}

Who can trigger it

The precondition is reached by the position growing past the lane's capacity, with both operator roles performing their ordinary duties and no privileged misbehaviour anywhere.

It is not limited to organic growth. ParentVault.withdraw is permissionless, and executeEpochWithdraw bridges the epoch's net withdrawal amount, so the bridged value is determined in aggregate by users. A party willing to lock its own principal alongside everyone else's can contribute enough withdrawal volume — alone, or on top of organic withdrawals — to carry an epoch's net amount across the ceiling. That self-inflicted cost is what bounds the likelihood, not any privilege requirement.

KI-016 accepts a failed CCIP send because the caller can "simply retry the same call once market conditions allow it." That holds for the LINK-balance and router-liveness causes it considers, and not here: no change in conditions permits an over-capacity transfer, so the retry is not a retry.

The ceiling itself is CCIP token-pool configuration rather than a protocol parameter, and RateLimiter._consume skips the check entirely when the bucket is disabled. The in-scope mechanism is unaffected either way: a permanently unsatisfiable send is filed as replayable recovery.

Vulnerable Scenario:

  1. The strategy position on a ChildVault grows beyond the configured outbound capacity of the CCIP lane back to the parent chain.
  2. A net-withdraw epoch closes, or a rebalance is initiated. The epoch operator calls the corresponding ChildVault entry point; the vault withdraws from the adapter and attempts the bridge.
  3. The token pool rejects the transfer with TokenMaxCapacityExceeded. _ccipSend catches it and stores CCIP_SEND recovery for the full amount.
  4. Every executeRecovery() call replays that amount into the same ceiling and reverts. No elapsed time changes the outcome.
  5. _requireNoRecovery blocks every other ChildVault entry point, so the position cannot be reduced.
  6. The Parent epoch cannot advance out of EXECUTING, and every later closeEpoch reverts ParentVault__EpochNotClaimable.
03Section · Impact

Impact

Every holder loses access to their principal for as long as the position exceeds the lane's capacity. The affected epoch's participants can neither claim (their epoch never reaches CLAIMABLE) nor cancel (KI-017 — their epoch has left OPEN), and no later epoch can settle for anyone else. Recovery cannot resolve it, because recovery is the thing that is stuck. Restoring service requires Chainlink to raise the lane's capacity, or a purpose-built upgrade that can rewrite the stored recovery amount.

04Section · Recommendation

Recommendation

Do not file a permanently-unsatisfiable send as replayable recovery. Re-throwing turns an unrecoverable vault into the atomic-revert shape KI-016 already accepts, where the epoch or rebalance rolls back cleanly and the operator can redirect or wait for a capacity change:

diff
+import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol";
+
try this.tryCcipSend(bridgeAmount, destinationChainSelector, ccipTxType, nonce, protocolId) {}
- catch {
+ catch (bytes memory err) {
+ if (bytes4(err) == RateLimiter.TokenMaxCapacityExceeded.selector) {
+ assembly { revert(add(err, 0x20), mload(err)) }
+ }
_storeCcipSendRecovery($_baseVault, bridgeAmount, destinationChainSelector, ccipTxType, nonce, protocolId);
}

That contains the damage but does not make the transfer possible. To keep large positions bridgeable, also either bound the position against the destination lane's capacity when selecting a rebalance target, or split an over-capacity transfer across multiple messages. Whichever is chosen, record the dependency as an explicit environment assumption alongside ENV-002, since the current set does not state that a bridged amount must fit within the lane's configured capacity.

05Section · Resolution

Resolution

Fixed in 65d4776: a TokenMaxCapacityExceeded failure now bubbles instead of being stored as replayable recovery, turning the terminal state into the atomic revert already accepted elsewhere.

06Section · Affected files

Affected files

  • evm/src/vaults/ChildVault.sol#L162-L179 (_ccipSend — the unconditional catch)
  • evm/src/vaults/ChildVault.sol#L296-L347, #L518-L535 (rebalance and its recovery, both bridging the full position)
  • evm/src/libraries/vaults/BaseVaultCcipLib.sol#L240-L253 (_validateCcipSend — no upper bound on the amount)
  • evm/src/libraries/vaults/ParentVaultEpochLib.sol#L192-L194 (previous-epoch guard)
Status
Fixed
Fix commit
65d4776
F-2026-0006