Outbound CCIP recovery is reported as backing while committed to a settled epoch
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.
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:
| Checkpoint | Physical location of W | Epoch allocation | Child TVL used for the next close |
|---|---|---|---|
| Epoch 2 closed | Active Child adapter | Epoch 2 records a W claim | P |
| Withdraw succeeds; CCIP send fails | Loose in ChildVault as CCIP_SEND(W) | Epoch 2 still owns W | adapter R + recovery W = P |
| Honest epoch-3 observation | Same loose recovery balance | Epoch 2 still owns W | Signed value is P |
| Recovery succeeds | ParentVault's epoch-2 reserve | Epoch 2 becomes CLAIMABLE | Live Child TVL falls to R |
| Signed report is first delivered | ParentVault's epoch-2 reserve | Epoch 3 is allocated P as well | Report supplies old P |
Vulnerable scenario:
- Two holders each own half the shares backed by
1,000,000 USDCin a remote Child adapter. - The first holder withdraws all its shares in epoch 2. An honest close allocates
500,000 USDC, marks epoch 2EXECUTING, reduces authoritative shares by half, and opens epoch 3. - Child withdraws
500,000 USDC, but an ordinary transient CCIP fee or funding failure causes the send to fail. Child holds that amount inCCIP_SENDrecovery while the adapter retains500,000 USDC;getTVL()reports1,000,000 USDC. - The second holder queues all remaining shares in epoch 3. Honest CRE samples the exposed
1,000,000-USDCTVL and signs a close report that has not yet been delivered. - Once the transient condition clears, any account calls
ChildVault.executeRecovery(). The500,000 USDCreaches ParentVault, epoch 2 becomesCLAIMABLE, and live Child TVL becomes500,000 USDC. - 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. - The epoch-3 command asks Child to withdraw
1,000,000 USDCfrom an adapter holding500,000 USDC. Child recordsEPOCH_WITHDRAW(1,000,000 USDC)after the withdrawal fails. - The first holder claims its complete
500,000-USDCexit. Retrying recovery continues to request the impossible exact amount, while the second holder's shares are escrowed and its500,000 USDCremains 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.
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.
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.
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 recoverabletvl = $.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.
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.
Affected files
evm/src/vaults/ChildVault.sol#L175-L177evm/src/vaults/ChildVault.sol#L245-L252evm/src/vaults/ChildVault.sol#L459-L469evm/src/vaults/ChildVault.sol#L569-L577evm/src/vaults/ChildVault.sol#L770-L780evm/src/vaults/ParentVault.sol#L395-L419evm/src/libraries/vaults/ParentVaultEpochLib.sol#L192-L219