fix: add threshold change timelock and harden USDC migration accounting#2095
fix: add threshold change timelock and harden USDC migration accounting#2095alva-p wants to merge 1 commit into
Conversation
AdvancedPoolHooks: setThresholdAmount now schedules a pending change instead of applying immediately. The new value takes effect only after THRESHOLD_CHANGE_DELAY (2 days) via a separate applyThresholdAmount call. This prevents a retroactive threshold decrease from blocking in-flight messages that were committed under a higher threshold and therefore never collected the additional CCVs that would be required under the new value. SiloedUSDCTokenPool: three fixes to the CCTP migration flow. burnLockedUSDC now derives tokensToBurn from the actual lockbox balance rather than the owner-set snapshot. If excluded in-flight messages are delivered before Circle calls burnLockedUSDC, both the lockbox balance and the excluded counter are decremented together, so using the lockbox balance avoids attempting to withdraw tokens that are no longer there. The snapshot is kept as an upper bound to prevent burning USDC deposited directly to the lockbox without going through the bridge. proposeCCTPMigration now sets a grace period (MIGRATION_PROPOSAL_GRACE_PERIOD, 1 hour) before the migration guard becomes active in releaseOrMint. This gives the owner time to pause the lane before messages committed on the remote chain but not yet executed start reverting with InsufficientLiquidity. setLockedUSDCToBurn now validates that the new snapshot is not below the current s_tokensExcludedFromBurn value. Setting a snapshot below the exclusion amount would cause an arithmetic underflow in burnLockedUSDC and block the migration until the owner cancels and reproposes.
There was a problem hiding this comment.
Pull request overview
This PR hardens CCIP token pool behavior in two areas: (1) it introduces a timelocked threshold change mechanism in AdvancedPoolHooks to prevent retroactive CCV requirement changes from permanently blocking in-flight messages, and (2) it fixes ordering/accounting pitfalls in the SiloedUSDCTokenPool USDC CCTP migration flow (grace period for migration guard, safer burn accounting, and snapshot validation).
Changes:
AdvancedPoolHooks: replace immediatesetThresholdAmountwith a schedule/apply flow using a 2-day delay and add pending-threshold observability.SiloedUSDCTokenPool: add a 1-hour grace period before the migration guard activates, validate burn snapshot vs excluded amount, and cap burn amount using the lockbox’s actual balance.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
chains/evm/contracts/pools/USDC/SiloedUSDCTokenPool.sol |
Adds migration grace-period gating to avoid immediate failures for in-flight messages; hardens burnLockedUSDC accounting using lockbox balance and snapshot validation. |
chains/evm/contracts/pools/AdvancedPoolHooks.sol |
Introduces timelocked threshold changes with pending state + apply function to avoid retroactive CCV requirement tightening. |
Comments suppressed due to low confidence (2)
chains/evm/contracts/pools/USDC/SiloedUSDCTokenPool.sol:143
- The comment says the lane-level supply lock is required "once migration is proposed/completed", but the new grace-period logic intentionally does not activate the guard immediately on proposal (it only activates after s_migrationActiveAt). The comment should be updated to reflect that the guard becomes active only after the grace period (or when excludedTokens != 0 / migrated).
// Circle's migration procedure requires a lane-level supply lock once migration is proposed/completed.
// For those lanes, releaseOrMint must only consume the explicitly excluded in-flight reserve, and this
// guard must still apply when excludedTokens == 0 to prevent releasing newly deposited lockbox liquidity.
if (releaseOrMintIn.sourceDenominatedAmount > excludedTokens) {
chains/evm/contracts/pools/USDC/SiloedUSDCTokenPool.sol:353
- burnLockedUSDC() clears s_proposedUSDCMigrationChain and s_lockedUSDCToBurn but leaves s_migrationActiveAt set. While it’s currently harmless (since the guard checks the proposed chain), deleting s_migrationActiveAt here as well would keep migration state consistent and avoid stale values lingering across proposals.
// Apply migration state updates before external calls to preserve CEI.
delete s_proposedUSDCMigrationChain;
delete s_lockedUSDCToBurn;
s_migratedChains.add(burnChainSelector);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// @notice Applies the pending threshold change once the delay has elapsed. | ||
| /// @dev Reverts if the delay period has not yet passed. | ||
| function applyThresholdAmount() public virtual onlyOwner { | ||
| uint256 effectiveAt = s_thresholdEffectiveAt; | ||
| if (block.timestamp < effectiveAt) revert ThresholdChangeNotReady(effectiveAt); | ||
|
|
||
| uint256 newThreshold = s_pendingThreshold; | ||
| s_thresholdAmountForAdditionalCCVs = newThreshold; | ||
| delete s_pendingThreshold; | ||
| delete s_thresholdEffectiveAt; |
| /// @notice Schedules a threshold change to take effect after THRESHOLD_CHANGE_DELAY. | ||
| /// @dev The new threshold is not applied immediately. Call applyThresholdAmount after the delay expires. | ||
| /// This prevents in-flight messages from being permanently blocked by a retroactive threshold decrease. | ||
| /// @param thresholdAmount The new threshold amount to schedule. | ||
| function setThresholdAmount( | ||
| uint256 thresholdAmount | ||
| ) public virtual onlyOwner { | ||
| s_thresholdAmountForAdditionalCCVs = thresholdAmount; | ||
| s_pendingThreshold = thresholdAmount; | ||
| s_thresholdEffectiveAt = block.timestamp + THRESHOLD_CHANGE_DELAY; | ||
|
|
||
| emit ThresholdAmountScheduled(thresholdAmount, s_thresholdEffectiveAt); | ||
| } |
| error LockBoxCannotBeShared(uint64 chainSelectorA, uint64 chainSelectorB, address lockBox); | ||
| error InsufficientLiquidity(uint256 availableLiquidity, uint256 requestedAmount); | ||
| error LockedUSDCBelowExcluded(uint256 lockedUSDC, uint256 excludedTokens); | ||
| error MigrationGracePeriodActive(uint256 activeAt); |
Summary
This PR addresses four issues found during a security review of the CCIP token pool contracts: one design gap in
AdvancedPoolHooksand three accounting/ordering issues in theSiloedUSDCTokenPoolCCTP migration flow.Reviewed commit:
d601419f87a1dbf6ff7b28c1cdba1655b9f0d643. Verified that the affected contracts are unchanged at currentmain(6c8ce3b).AdvancedPoolHooks: retroactive threshold decrease can permanently block in-flight messages
setThresholdAmounttakes effect immediately with no delay. The OffRamp evaluates CCV requirements at execution time by callingpool.getRequiredCCVs()live, not at send time. A message committed when the threshold was high enough to require only base CCVs can land in a stricter configuration that demands additional CCVs that were never collected. The OffRamp then entersFAILUREstate and subsequent retries revert withNoStateProgressMade, permanently blocking the message until the owner manually restores a compatible threshold.Fix:
setThresholdAmountnow schedules a pending change instead of applying immediately. The change becomes active only afterTHRESHOLD_CHANGE_DELAY(2 days) via a separateapplyThresholdAmountcall. AddedgetPendingThresholdfor observability.SiloedUSDCTokenPool: burnLockedUSDC can over-withdraw if excluded messages execute before the burn
burnLockedUSDCcomputestokensToBurn = s_lockedUSDCToBurn - s_tokensExcludedFromBurn. When an excluded in-flight message is delivered via the permissionless OffRamp,releaseOrMintdecrementss_tokensExcludedFromBurnand withdraws from the lockbox. If all excluded messages execute before Circle callsburnLockedUSDC, the exclusion counter reaches zero while the snapshot stays at its original value, causing the function to attempt a withdrawal larger than the remaining lockbox balance. The migration is blocked until the owner cancels and reproposes.The comment at line 133 (
burnableUSDC = lockboxBalance - excludedInFlight) already describes the correct formula. The implementation did not match.Fix:
burnLockedUSDCnow reads the actual lockbox balance viaIERC20(address(i_token)).balanceOf(address(lockBox)). The snapshot is kept as an upper bound to prevent burning USDC that was deposited directly to the lockbox without going through the bridge.SiloedUSDCTokenPool: proposeCCTPMigration instantly blocks in-flight messages with no grace period
proposeCCTPMigrationsetss_proposedUSDCMigrationChainimmediately. AnyreleaseOrMintfor that chain then requires the amount to be covered bys_tokensExcludedFromBurn, which is zero until the owner callsexcludeTokensFromBurn. Messages committed on the remote chain before the proposal but not yet executed revert withInsufficientLiquidity(0, amount)and enterFAILUREstate. Finality windows of 5 to 15 minutes mean this race window cannot be eliminated purely through operational care.Fix:
proposeCCTPMigrationsetss_migrationActiveAt = block.timestamp + MIGRATION_PROPOSAL_GRACE_PERIOD(1 hour). The migration guard inreleaseOrMintonly activates after this timestamp, giving the owner time to pause the lane before committed messages start reverting.SiloedUSDCTokenPool: setLockedUSDCToBurn allows snapshot below current exclusion amount, causing underflow in burnLockedUSDC
setLockedUSDCToBurnsetss_lockedUSDCToBurnwithout checking that the new value is at least as large as the currents_tokensExcludedFromBurn[remoteChainSelector]. Setting a snapshot below the exclusion amount causess_lockedUSDCToBurn - s_tokensExcludedFromBurninburnLockedUSDCto underflow under Solidity 0.8 checked arithmetic, reverting with a panic and blocking the migration until the owner cancels and reproposes.Fix:
setLockedUSDCToBurnnow validateslockedUSDCToBurn >= s_tokensExcludedFromBurn[remoteChainSelector]and reverts withLockedUSDCBelowExcludedif not.Test plan
The existing Foundry suite compiles and the non-security baseline tests pass. The PoC tests in the audit suite now correctly fail to reproduce the vulnerable behavior, confirming the fixes are effective.
Recommendation: add regression tests covering the new
applyThresholdAmount, the grace-period guard inreleaseOrMint, theLockedUSDCBelowExcludedrevert path, and the lockbox-balance-capped burn amount inburnLockedUSDC.