CCIP token-pool fee updates silently undercollateralize remote-deposit shares
Yieldcoin assumes a successful CCIP transfer delivers the full amount sent. Where the token pool deducts a transfer fee, shares are minted against value that never arrived and existing holders absorb the shortfall silently.
Description
Yieldcoin assumes that a successful CCIP token transfer delivers the full amount passed to ccipSend. That assumption does not hold for the vendored CCIP V2 token-pool semantics.
IPoolV2.TokenTransferFeeConfig documents that finalityTransferFeeBps and fastFinalityTransferFeeBps are deducted from the transferred asset. The token-pool owner can enable either fee for a destination chain through TokenPool.applyTokenTransferFeeConfigUpdates. TokenPool.lockOrBurn then calculates destTokenAmount = sourceAmount - feeAmount, and OnRamp._lockOrBurnSingleToken places that smaller amount in the successful CCIP message.
Yieldcoin does not bind its remote epoch accounting to the destination amount:
ParentVaultEpochLib.closeEpochcalculates new shares from the full deposit amount and immediately incorporates them intos_totalShares.BaseVaultCcipLib._sendPackedsends the nominal net-deposit amount and only checks whetherccipSendsucceeds.ChildVault._ccipReceivedeposits the smaller, authenticated post-fee amount and emits its normal success event.ParentVault.completeEpochDepositmakes the epoch claimable without receiving or checking the deposited amount.
Every external call succeeds and the message reaches the intended chain and vault, but users receive
nominal shares against reduced backing. No off-chain component participates: the divergence is created
by the token pool inside an otherwise successful ccipSend, and consumed by the contract that mints
against the amount it asked to send rather than the amount that arrived.
The same divergence is already handled on the opposite side of the protocol, deliberately. KI-005
records that at settlement the vault overwrites its price-locked withdraw estimate with the actual
amount produced by the adapter or delivered by CCIP, and uses that actual figure for distribution —
the accepted rationale being that anything else strands underlying the vault cannot see. The withdraw
path therefore already assumes bridged output can differ from expectation and reconciles to reality.
The deposit path makes the opposite choice without stating it: shares are minted from the nominal
amount at close and never reconciled against what the destination received.
The declared assumptions are asymmetric in the same direction. ENV-007 states that the rebalance
workflow calls completeRebalance only once the complete amount has reached and been deposited into
the target strategy, and that ParentVault does not prove that. A nonzero pool transfer fee falsifies
that assumption directly for the rebalance path, because the complete amount can no longer arrive. For
the epoch-deposit path there is no equivalent declaration at all, so the same divergence is neither
prevented by the contract nor recorded as an accepted environmental condition.
Vulnerable Scenario:
- The vault has 1,000,000 USDC deployed in its remote strategy.
- The CCIP source-pool owner enables a 1% transfer fee for the destination chain using the pool's advertised owner-only configuration function.
- A user deposits 100,000 USDC and the epoch operator closes the epoch using the truthful 1,000,000 USDC TVL.
- Yieldcoin allocates shares for the full deposit and submits a successful CCIP transfer for 100,000 USDC.
- The source pool deducts 1,000 USDC and places 99,000 USDC in the successful destination message.
- The Child deposits 99,000 USDC, emits
EpochDepositToStrategySuccess, and enters no recovery state. - Following the documented completion condition of a successful Child event with no recovery, the operator calls
completeEpochDeposit. - The user claims the full nominal share allocation, while total strategy backing is only 1,099,000 USDC.
No Yieldcoin role acts maliciously. An ordinary external pool-governance action changes the economic meaning of an otherwise successful call.
Impact
Existing and newly minted shareholders lose underlying value because the vault issues shares against assets retained as a fee by the external source pool. The live configuration permits fees approaching the complete bridged amount, and the deficit can accumulate across every later remote net-deposit epoch without a revert or recovery alert.
The failure is silent. There is no revert, no stored recovery, no divergence between the emitted event and the state it reports, and nothing for an operator to alert on: the epoch settles normally and every subsequent one does too, with the shortfall compounding into the share price rather than surfacing as an incident. Cross-chain failures in this protocol otherwise announce themselves by halting settlement, so an operational posture calibrated on that behaviour will not detect this one.
Impact is High because this is direct loss of backing. Likelihood is Low because an ordinary user cannot configure the external pool, but its owner can enable the fee through an advertised operation. The resulting severity is Medium.
Recommendation
Fail closed before every CCIP token transfer unless the vault can prove that the current source pool will deliver the full requested amount:
- Immediately before
ccipSend, resolve the current OnRamp and source pool from the live Router and token registry rather than relying on constructor-time compatibility. - If the pool supports
IPoolV2, read its destination-specificTokenTransferFeeConfigand revert if the applicablefinalityTransferFeeBpsorfastFinalityTransferFeeBpsis nonzero. - Revert if the current pool or fee semantics cannot be inspected reliably.
- Perform this validation outside
ChildVault's sendtry/catch, so an unsupported fee unwinds the operation atomically instead of becoming fixed-parameter recovery.
For example, _sendPacked should be guarded as follows, with _requireFullDestinationAmount resolving and validating the live dependency path described above:
function _sendPacked(BaseVaultStore.BaseVaultStorage storage $, SendParams memory params) private {+ _requireFullDestinationAmount(+ params.ccipRouter,+ params.destinationChainSelector,+ params.asset+ );address vault =_validateCcipSend($, params.bridgeAmount, params.destinationChainSelector, params.thisChainSelector);// ... construct message and call ccipSend}
If fee-charging pools must be supported, add an authenticated destination acknowledgement carrying the amount actually deposited. Do not finalize a remote-deposit epoch or permanently allocate its nominal shares until the Parent reconciles that amount. Any shortfall must reduce the affected epoch's share allocation rather than being socialized across the vault.
Resolution
Fixed in 2136cc1: completeEpochDeposit now takes the delivered amount, rejects a value above the expected net, and scales the epoch allocation and total shares down by any shortfall, binding shares to what arrived rather than what was sent.
Affected files
evm/src/libraries/vaults/BaseVaultCcipLib.sol#L178-L204evm/src/libraries/vaults/ParentVaultEpochLib.sol#L203-L241,#L305-L318evm/src/vaults/ParentVault.sol#L383-L435evm/src/vaults/ChildVault.sol#L82-L110