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-0005·incorrect-accounting

Outbound CCIP recovery is reported as backing while committed to a settled epoch

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

A failed epoch-withdraw send is counted as live backing even though it is already committed to the previous epoch's claim pool. The next epoch can settle against that double-counted value, letting one cohort exit in full while the remaining holder loses access to their shares.

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

Description

ChildVault._getTVL() always adds s_ccipSendRecovery.amount to the active adapter's TVL. For an EPOCH_NET_WITHDRAW send failure, that recovery amount is not backing for the live share supply: it has already been withdrawn from the adapter and assigned to the preceding epoch's fixed asset-claim pool. Nevertheless, the Parent opens the next epoch while that preceding epoch remains EXECUTING, so an honest next-epoch TVL observation can include the reserved amount.

The preceding-epoch check in ParentVaultEpochLib.closeEpoch is evaluated only when the signed report executes. If permissionless executeRecovery() succeeds after the report observes TVL but before its first delivery, the reserved assets move to ParentVault and make the preceding epoch CLAIMABLE. The report then passes the guard while settling the next epoch with the pre-recovery TVL. This assigns the same physical assets to both epochs.

For P = 1,000,000 USDC, let W = 500,000 USDC be the first cohort's withdrawal and R = 500,000 USDC the remaining backing:

CheckpointPhysical location of WEpoch allocationChild TVL used for the next close
Epoch 2 closedActive Child adapterEpoch 2 records a W claimP
Withdraw succeeds; CCIP send failsLoose in ChildVault as CCIP_SEND(W)Epoch 2 still owns Wadapter R + recovery W = P
Honest epoch-3 observationSame loose recovery balanceEpoch 2 still owns WSigned value is P
Recovery succeedsParentVault's epoch-2 reserveEpoch 2 becomes CLAIMABLELive Child TVL falls to R
Signed report is first deliveredParentVault's epoch-2 reserveEpoch 3 is allocated P as wellReport supplies old P

Vulnerable scenario:

  1. Two holders each own half the shares backed by 1,000,000 USDC in a remote Child adapter.
  2. The first holder withdraws all its shares in epoch 2. An honest close allocates 500,000 USDC, marks epoch 2 EXECUTING, reduces authoritative shares by half, and opens epoch 3.
  3. Child withdraws 500,000 USDC, but an ordinary transient CCIP fee or funding failure causes the send to fail. Child holds that amount in CCIP_SEND recovery while the adapter retains 500,000 USDC; getTVL() reports 1,000,000 USDC.
  4. The second holder queues all remaining shares in epoch 3. Honest CRE samples the exposed 1,000,000-USDC TVL and signs a close report that has not yet been delivered.
  5. Once the transient condition clears, any account calls ChildVault.executeRecovery(). The 500,000 USDC reaches ParentVault, epoch 2 becomes CLAIMABLE, and live Child TVL becomes 500,000 USDC.
  6. The honest signed report is delivered for the first time. Its nonce is current and the preceding epoch is now claimable, so it closes epoch 3 using 1,000,000 USDC.
  7. The epoch-3 command asks Child to withdraw 1,000,000 USDC from an adapter holding 500,000 USDC. Child records EPOCH_WITHDRAW(1,000,000 USDC) after the withdrawal fails.
  8. The first holder claims its complete 500,000-USDC exit. Retrying recovery continues to request the impossible exact amount, while the second holder's shares are escrowed and its 500,000 USDC remains in the adapter.

No false report, forged message, privileged deviation, strategy loss, or unsolicited supply is required. The signed TVL exactly equals the value the Child exposes at the moment it is sampled. The sequence does require the TVL to be sampled while the recovery is pending and the settlement to execute after it clears; the section below sets out what currently prevents that and why the protection is fragile.

Where this sits relative to the existing protections, and what currently prevents it

The Parent's preceding-epoch guard is correct and does its job: while the reserved amount is latched on the Child, the earlier epoch is EXECUTING and no later epoch can close. The guard is not defeated in this sequence.

The recovery-inclusive TVL formula is also deliberate, and for three of the four amounts it sums it is right. Assets held in an epoch-deposit, rebalance-deposit or rebalance send recovery genuinely do back the live share supply; excluding them would understate TVL and misprice shares in the opposite direction. The documented intent is that pending recovery amounts are attributable exactly once and that the accounting must neither omit nor double-count them. The formula was built to prevent omission, and the single case it does not distinguish is the one where the amount is already committed elsewhere.

What prevents the wrong figure from being consumed today is neither of those. It is the order in which the offchain workflow performs its reads. The documented epoch cron reads the Parent's epoch state first and the strategy TVL second, and an epoch becomes claimable only once the reserved amount has actually reached the Parent — which is the same event that clears the recovery. Under that ordering the state check can only pass after the recovery has cleared, by which point the TVL read returns the corrected figure.

That ordering is a real protection, but it is not a contract guarantee and is not recorded anywhere as a safety requirement. Two properties make it fragile. First, nothing on-chain constrains it: a workflow that samples strategy TVL before the Parent's epoch state — an ordinary thing to do when consolidating reads — makes the sequence live immediately, with no on-chain signal that anything changed. Second, the two values cannot be made atomic. For a remote strategy the epoch state lives on the Parent chain and the TVL on the Child chain, so they are necessarily two reads at two different moments; consolidating the Parent-side reads into a single call does not close the gap between them, and mirroring the contract's checks offchain does not constrain when the inputs were sampled.

03Section · Impact

Impact

The second withdrawal epoch receives a 1,000,000-USDC claim against only 500,000 USDC of remaining strategy assets. The first cohort exits in full, while the remaining holder loses access to all of its shares and underlying. Ordinary recovery cannot finish because it retries the stored exact amount; later epochs and rebalances cannot progress while that epoch remains EXECUTING. Restoring liveness requires at least the 500,000-USDC shortfall from exceptional yield, recapitalization, or a contract upgrade/state repair.

Reachability. Under the currently documented workflow read order this sequence does not occur, for the reasons given above. The finding is that the Child reports a figure its own accounting knows to be committed elsewhere, and that nothing in the contracts prevents that figure being consumed — the only barrier is an undocumented offchain ordering in a component that is expected to change. The proof of concept below demonstrates what follows if the figure is ever consumed; it constructs the settlement report directly rather than modelling the workflow's read sequence.

04Section · Recommendation

Recommendation

Exclude only an EPOCH_NET_WITHDRAW CCIP-send recovery from share-backing TVL when an adapter remains active. That amount is already committed to the preceding withdrawal cohort. Continue counting EPOCH_NET_DEPOSIT recovery because it is newly arrived backing, and continue counting REBALANCE send recovery when the old adapter has been cleared.

diff
function _getTVL() internal view override returns (uint256 tvl) {
BaseVaultStorage storage $_baseVault = _baseVaultStorage();
ChildVaultStorage storage $ = _childVaultStorage();
address activeAdapter = $_baseVault.s_activeProtocolAdapter;
if (activeAdapter != address(0)) {
+ uint256 ccipSendBacking = $.s_ccipSendRecovery.ccipTxType == Types.CcipTx.EPOCH_NET_WITHDRAW
+ ? 0
+ : $.s_ccipSendRecovery.amount;
tvl = IProtocolAdapter(activeAdapter).getTVL() + $.s_epochDepositRecovery.amount
- + $_baseVault.s_rebalanceDepositRecovery.amount + $.s_ccipSendRecovery.amount;
+ + $_baseVault.s_rebalanceDepositRecovery.amount + ccipSendBacking;
} else {
// The adapter is cleared before a remote rebalance send; a failed send remains locally recoverable
tvl = $.s_ccipSendRecovery.amount;
}
}

Add a regression test that creates a real EPOCH_NET_WITHDRAW send recovery and asserts that its amount is excluded from getTVL() both before and after the next epoch becomes eligible to close.

The value of this change is that it removes the dependency on offchain read ordering altogether. With the exclusion in place the Child never reports a figure that is committed elsewhere, so it no longer matters in which order a consumer samples the two chains, nor whether a future workflow revision happens to preserve the current sequence.

If the ordering is instead relied upon as the control, it should be recorded as a safety requirement rather than left as an implementation detail — specifically that the Parent's epoch state must be sampled no earlier than the strategy TVL. Note that this cannot be satisfied by consolidating reads into a single call: for a remote strategy the two values live on different chains, so they are always two reads at two moments, and mirroring the contract's guards offchain constrains the checks but not the age of their inputs.

As defense in depth, bind every close report to an observation timestamp or block commitment, so a sampled figure cannot be settled arbitrarily long after it was taken.

05Section · Resolution

Resolution

Fixed in 79b5662: getTVL now counts a pending CCIP-send recovery only when it belongs to a rebalance, excluding epoch-withdraw amounts already reserved for a settled epoch.

06Section · Affected files

Affected files

  • evm/src/vaults/ChildVault.sol#L175-L177
  • evm/src/vaults/ChildVault.sol#L245-L252
  • evm/src/vaults/ChildVault.sol#L459-L469
  • evm/src/vaults/ChildVault.sol#L569-L577
  • evm/src/vaults/ChildVault.sol#L770-L780
  • evm/src/vaults/ParentVault.sol#L395-L419
  • evm/src/libraries/vaults/ParentVaultEpochLib.sol#L192-L219
Status
Fixed
Fix commit
79b5662
F-2026-0005