// SPDX-License-Identifier: BUSL-1.1 pragma solidity ^0.8.24; import {IBurnMintERC20} from "../../interfaces/IBurnMintERC20.sol"; import {ILockBox} from "../../interfaces/ILockBox.sol"; import {Pool} from "../../libraries/Pool.sol"; import {SiloedLockReleaseTokenPool} from "../SiloedLockReleaseTokenPool.sol"; import {AuthorizedCallers} from "@chainlink/contracts/src/v0.8/shared/access/AuthorizedCallers.sol"; import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; import {EnumerableMap} from "@openzeppelin/contracts@5.3.0/utils/structs/EnumerableMap.sol"; import {EnumerableSet} from "@openzeppelin/contracts@5.3.0/utils/structs/EnumerableSet.sol"; /// @notice A token pool for USDC which inherits the Siloed token functionality while adding the CCTP migration functionality. /// @dev The CCTP migration functions have been previously audited. The code has been moved from its own contract /// to this, to maximize simplicity. The only difference is that custom balance tracking /// has been removed and instead is now inherited from the SiloedLockReleaseTokenPool. /// @dev All chains should be siloed, otherwise the chain will not be /// able to migrate to CCTP in the future, due to the inability to manage the token /// balances under CCTP accounting rules defined at: /// https://github.com/circlefin/stablecoin-evm/blob/master/doc/bridged_USDC_standard.md contract SiloedUSDCTokenPool is SiloedLockReleaseTokenPool, AuthorizedCallers { using EnumerableMap for EnumerableMap.UintToAddressMap; using EnumerableSet for EnumerableSet.UintSet; event CCTPMigrationProposed(uint64 remoteChainSelector); event CCTPMigrationExecuted(uint64 remoteChainSelector, uint256 USDCBurned); event CCTPMigrationCancelled(uint64 existingProposalSelector); event CircleMigratorAddressSet(address migratorAddress); event LockedUSDCToBurnSet(uint64 indexed remoteChainSelector, uint256 lockedUSDCToBurn); event TokensExcludedFromBurn( uint64 indexed remoteChainSelector, uint256 amount, uint256 burnableAmountAfterExclusion ); error OnlyCircle(); error InvalidChainSelector(); error ExistingMigrationProposal(); error NoMigrationProposalPending(); error ChainAlreadyMigrated(uint64 remoteChainSelector); error LockBoxCannotBeShared(uint64 chainSelectorA, uint64 chainSelectorB, address lockBox); error InsufficientLiquidity(uint256 availableLiquidity, uint256 requestedAmount); /// @notice The address of the circle-controlled wallet which will execute a CCTP lane migration address internal s_circleUSDCMigrator; uint64 internal s_proposedUSDCMigrationChain; /// @notice The tokens excluded from being burned in a CCTP-migration. mapping(uint64 remoteChainSelector => uint256 excludedTokens) internal s_tokensExcludedFromBurn; /// @notice Snapshot of locked USDC for the currently proposed migration lane. /// @dev Set by the owner using offchain net accounting of lockbox Deposit/Withdrawal events for the lane's lockbox. /// This avoids using raw lockbox balance as burn input, which could include direct transfers that were never bridged. uint256 internal s_lockedUSDCToBurn; /// @notice The chains that have been migrated to CCTP. EnumerableSet.UintSet internal s_migratedChains; /// @dev The authorized callers are set as empty since the USDCTokenPoolProxy is the only authorized caller, /// but cannot be deployed until after this contract. The allowed callers will be set after deployment. /// @param token The token managed by this pool. /// @param localTokenDecimals The number of decimals of the local token. /// @param advancedPoolHooks Optional advanced pool hooks contract (can be address(0)). /// @param rmnProxy The RMN proxy address. /// @param router The router address. constructor( IERC20 token, uint8 localTokenDecimals, address advancedPoolHooks, address rmnProxy, address router ) SiloedLockReleaseTokenPool(token, localTokenDecimals, advancedPoolHooks, rmnProxy, router) AuthorizedCallers(new address[](0)) {} /// @dev Using a function because constant state variables cannot be overridden by child contracts. function typeAndVersion() external pure virtual override returns (string memory) { return "SiloedUSDCTokenPool 2.0.0"; } /// @notice Configure lockboxes. /// @param lockBoxConfigs The lockbox configurations to set. /// @dev USDC lanes must remain fully siloed to keep migration burn accounting lane-specific. /// Sharing one lockbox across selectors would couple balances across lanes and break migration assumptions. function configureLockBoxes( LockBoxConfig[] calldata lockBoxConfigs ) public override onlyOwner { super.configureLockBoxes(lockBoxConfigs); // Validate globally after applying updates so checks are order-independent for batched updates. uint256 length = s_lockBoxes.length(); uint64[] memory chainSelectors = new uint64[](length); address[] memory lockBoxes = new address[](length); for (uint256 i = 0; i < length; ++i) { (uint256 chainSelector, address lockBox) = s_lockBoxes.at(i); chainSelectors[i] = uint64(chainSelector); lockBoxes[i] = lockBox; } for (uint256 i = 0; i < length; ++i) { address lockBoxA = lockBoxes[i]; for (uint256 j = i + 1; j < length; ++j) { if (lockBoxA == lockBoxes[j]) { revert LockBoxCannotBeShared(chainSelectors[i], chainSelectors[j], lockBoxA); } } } } /// @notice Mint tokens from the pool to the recipient. /// @dev Uses a fixed remote decimal of 6 for canonical USDC. /// @param releaseOrMintIn The release or mint input parameters. /// @param requestedFinalityConfig Requested finality encoding (see `FinalityCodec`). function releaseOrMint( Pool.ReleaseOrMintInV1 calldata releaseOrMintIn, bytes4 requestedFinalityConfig ) public virtual override returns (Pool.ReleaseOrMintOutV1 memory) { // Since USDC is 6 decimals on all chains, we don't need to convert to a different denomination. _validateReleaseOrMint(releaseOrMintIn, releaseOrMintIn.sourceDenominatedAmount, requestedFinalityConfig); uint64 remoteChainSelector = releaseOrMintIn.remoteChainSelector; uint256 excludedTokens = s_tokensExcludedFromBurn[remoteChainSelector]; if ( excludedTokens != 0 || remoteChainSelector == s_proposedUSDCMigrationChain || s_migratedChains.contains(remoteChainSelector) ) { // 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) { revert InsufficientLiquidity(excludedTokens, releaseOrMintIn.sourceDenominatedAmount); } s_tokensExcludedFromBurn[remoteChainSelector] -= releaseOrMintIn.sourceDenominatedAmount; // During a proposed migration, the lockbox balance is the source of truth. Any release here reduces the // lockbox balance, so burnLockedUSDC will not over-burn even without separate per-chain accounting. // Keeping excludedTokens in sync with each in-flight release preserves burnLockedUSDC accounting: // burnableUSDC = lockboxBalance - excludedInFlight. } // No excluded tokens is the common path, as it means no migration has occurred yet, and any released // tokens should come from the stored token balance of previously deposited tokens. // Release to the recipient using the lockbox tied to the remote chain selector. _releaseOrMint( releaseOrMintIn.receiver, releaseOrMintIn.sourceDenominatedAmount, releaseOrMintIn.remoteChainSelector ); emit ReleasedOrMinted({ remoteChainSelector: releaseOrMintIn.remoteChainSelector, token: address(i_token), sender: msg.sender, recipient: releaseOrMintIn.receiver, amount: releaseOrMintIn.sourceDenominatedAmount }); return Pool.ReleaseOrMintOutV1({destinationAmount: releaseOrMintIn.sourceDenominatedAmount}); } // ================================================================ // │ Access │ // ================================================================ /// @dev This function is overridden to remove the On-Ramp check, as this pool does not receive calls /// from the ramps directly, instead receiving them from a proxy contract first. /// @param remoteChainSelector The remote chain selector. function _onlyOnRamp( uint64 remoteChainSelector ) internal view virtual override { if (!isSupportedChain(remoteChainSelector)) revert ChainNotAllowed(remoteChainSelector); // Validate logic is inherited from AuthorizedCallers, and is used to validate that the caller is the authorized USDC proxy contract rather than the ramp. _validateCaller(); } /// @dev This function is overridden to remove the Off-Ramp check, as this pool does not receive calls /// from the ramps directly, instead receiving them from a proxy contract first. /// @param remoteChainSelector The remote chain selector. function _onlyOffRamp( uint64 remoteChainSelector ) internal view virtual override { if (!isSupportedChain(remoteChainSelector)) revert ChainNotAllowed(remoteChainSelector); // Validate logic is inherited from AuthorizedCallers, and is used to validate that the caller is the authorized USDC proxy contract rather than the ramp. _validateCaller(); } // ================================================================ // │ CCTP Migration functions │ // ================================================================ /// @notice Propose a destination chain to migrate from lock/release mechanism to CCTP enabled burn/mint /// through a Circle controlled burn. /// @param remoteChainSelector the CCIP specific selector for the remote chain currently using a /// non-canonical form of USDC which they wish to update to canonical. Function will revert if an existing migration /// proposal is already in progress. /// @dev This function can only be called by the owner. /// @dev IMPORTANT: Before calling this function, the lane MUST be paused to prevent new lock or burn / release or mint transactions. /// This ensures that the locked-USDC snapshot set via setLockedUSDCToBurn remains valid until the burn. /// Without pausing, releases occurring after the snapshot but before the burn could reduce the /// lockbox balance, causing burnLockedUSDC to over-burn. function proposeCCTPMigration( uint64 remoteChainSelector ) external onlyOwner { // Selector 0 is reserved as the "no proposal pending" sentinel in state. if (remoteChainSelector == 0) revert InvalidChainSelector(); // Prevent overwriting existing migration proposals until the current one is finished if (s_proposedUSDCMigrationChain != 0) revert ExistingMigrationProposal(); if (s_migratedChains.contains(remoteChainSelector)) revert ChainAlreadyMigrated(remoteChainSelector); delete s_lockedUSDCToBurn; s_proposedUSDCMigrationChain = remoteChainSelector; emit CCTPMigrationProposed(remoteChainSelector); } /// @notice Cancel an existing proposal to migrate a lane to CCTP. /// @notice This function will revert if no proposal is currently in progress. function cancelExistingCCTPMigrationProposal() external onlyOwner { if (s_proposedUSDCMigrationChain == 0) revert NoMigrationProposalPending(); uint64 currentProposalChainSelector = s_proposedUSDCMigrationChain; delete s_proposedUSDCMigrationChain; // If a migration is cancelled, the tokens excluded from burn should be reset, and must be manually // re-excluded if the proposal is re-proposed in the future delete s_tokensExcludedFromBurn[currentProposalChainSelector]; delete s_lockedUSDCToBurn; emit CCTPMigrationCancelled(currentProposalChainSelector); } /// @notice retrieve the chain selector for an ongoing CCTP migration in progress. /// @return uint64 the chain selector of the lane to be migrated. Will be zero if no proposal currently /// exists function getCurrentProposedCCTPChainMigration() public view returns (uint64) { return s_proposedUSDCMigrationChain; } /// @notice Set the address of the circle-controlled wallet which will execute a CCTP lane migration /// @dev The function should only be invoked once the address has been confirmed by Circle prior to /// chain expansion. /// @param migrator The Circle-controlled migrator address. function setCircleMigratorAddress( address migrator ) external onlyOwner { s_circleUSDCMigrator = migrator; emit CircleMigratorAddressSet(migrator); } /// @notice Sets the locked-USDC snapshot that burnLockedUSDC should use for the current migration proposal. /// @dev The owner computes this value offchain as: /// netLockedUSDC = sum(Deposit events) - sum(Withdrawal events) /// on the lockbox configured for the proposed lane. This tracks only bridged liquidity movements and intentionally /// excludes direct token transfers to the lockbox, so burnLockedUSDC cannot burn non-bridged USDC. /// @param remoteChainSelector The lane being migrated. Must match the active proposal. /// @param lockedUSDCToBurn The offchain-computed locked-USDC snapshot for the proposed lane. function setLockedUSDCToBurn( uint64 remoteChainSelector, uint256 lockedUSDCToBurn ) external onlyOwner { // Selector 0 is reserved as the "no proposal pending" sentinel in state. if (remoteChainSelector == 0) revert InvalidChainSelector(); if (s_proposedUSDCMigrationChain != remoteChainSelector) revert NoMigrationProposalPending(); s_lockedUSDCToBurn = lockedUSDCToBurn; emit LockedUSDCToBurnSet(remoteChainSelector, lockedUSDCToBurn); } /// @notice Exclude tokens to be burned in a CCTP-migration because the amount are locked in an undelivered message. /// @dev When a message is sitting in manual execution from the L/R chain, those tokens need to be excluded from /// being burned in a CCTP-migration otherwise the message will never be able to be delivered due to it not having /// an attestation on the source-chain to mint. In that instance it should use provided liquidity that was designated /// @dev This function should ONLY be called on the home chain, where tokens are locked, NOT on the remote chain /// and strict scrutiny should be applied to ensure that the amount of tokens excluded is accurate. /// @param remoteChainSelector The remote chain selector. /// @param amount The amount of tokens to exclude from burn. function excludeTokensFromBurn( uint64 remoteChainSelector, uint256 amount ) external onlyOwner { // Selector 0 is reserved as the "no proposal pending" sentinel in state. if (remoteChainSelector == 0) revert InvalidChainSelector(); if (s_proposedUSDCMigrationChain != remoteChainSelector) revert NoMigrationProposalPending(); s_tokensExcludedFromBurn[remoteChainSelector] += amount; uint256 burnableAmountAfterExclusion = s_lockedUSDCToBurn - s_tokensExcludedFromBurn[remoteChainSelector]; emit TokensExcludedFromBurn(remoteChainSelector, amount, burnableAmountAfterExclusion); } /// @notice Get the amount of tokens excluded from being burned in a CCTP-migration /// @dev The sum of locked tokens and excluded tokens should equal the supply of the token on the remote chain /// @param remoteChainSelector The chain for which the excluded tokens are being queried /// @return uint256 amount of tokens excluded from being burned in a CCTP-migration function getExcludedTokensByChain( uint64 remoteChainSelector ) external view returns (uint256) { return s_tokensExcludedFromBurn[remoteChainSelector]; } /// @notice Gets the locked-USDC snapshot currently configured for burnLockedUSDC. /// @return lockedUSDCToBurn The owner-set locked-USDC snapshot. function getLockedUSDCToBurn() external view returns (uint256 lockedUSDCToBurn) { return s_lockedUSDCToBurn; } /// @notice Burn USDC locked for a specific lane so that destination USDC can be converted from /// non-canonical to canonical USDC. /// @dev This function can only be called by an address specified by the owner to be controlled by circle. /// @dev This function signature should NEVER be overwritten, otherwise it will be unable to be called by /// circle to properly migrate USDC over to CCTP. function burnLockedUSDC() external { if (msg.sender != s_circleUSDCMigrator) revert OnlyCircle(); uint64 burnChainSelector = s_proposedUSDCMigrationChain; if (burnChainSelector == 0) revert NoMigrationProposalPending(); ILockBox lockBox = getLockBox(burnChainSelector); // Burnable tokens is the owner-set locked snapshot minus the amount excluded from burn. uint256 tokensToBurn = s_lockedUSDCToBurn - s_tokensExcludedFromBurn[burnChainSelector]; // Apply migration state updates before external calls to preserve CEI. delete s_proposedUSDCMigrationChain; delete s_lockedUSDCToBurn; s_migratedChains.add(burnChainSelector); // The CCTP burn function will attempt to burn out of the contract that calls it, so we need to withdraw the tokens // from the lock box first otherwise the burn will revert. lockBox.withdraw(address(i_token), burnChainSelector, tokensToBurn, address(this)); // This should only be called after this contract has been granted a "zero allowance minter role" on USDC by Circle, // otherwise the call will revert. Executing this burn will functionally convert all USDC on the destination chain // to canonical USDC by removing the canonical USDC backing it from circulation. IBurnMintERC20(address(i_token)).burn(tokensToBurn); emit CCTPMigrationExecuted(burnChainSelector, tokensToBurn); } /// @notice No-op override to purge the unused code path from the contract. function _postflightCheck( Pool.ReleaseOrMintInV1 calldata, uint256, bytes4 ) internal virtual override {} /// @notice No-op override to purge the unused code path from the contract. function _preflightCheck( Pool.LockOrBurnInV1 calldata, bytes4, bytes memory, uint256 ) internal virtual override {} }