diff --git a/script/configure/allowlist/UpdateAdvancedPoolHooks.s.sol b/script/configure/allowlist/UpdateAdvancedPoolHooks.s.sol index 304e5bc..26afe52 100644 --- a/script/configure/allowlist/UpdateAdvancedPoolHooks.s.sol +++ b/script/configure/allowlist/UpdateAdvancedPoolHooks.s.sol @@ -1,10 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; -import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; -import {IAdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/interfaces/IAdvancedPoolHooks.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /** * @title UpdateAdvancedPoolHooks @@ -14,7 +14,7 @@ import {IAdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/interfaces * Usage: * TOKEN_POOL=0x... NEW_HOOK=0x... forge script script/configure/allowlist/UpdateAdvancedPoolHooks.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME --broadcast */ -contract UpdateAdvancedPoolHooks is Script { +contract UpdateAdvancedPoolHooks is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -46,22 +46,8 @@ contract UpdateAdvancedPoolHooks is Script { console.log(string.concat("New Pool Hooks: ", vm.toString(newHookAddress))); console.log(""); - vm.startBroadcast(); - TokenPool tokenPool = TokenPool(tokenPoolAddress); - try tokenPool.updateAdvancedPoolHooks(IAdvancedPoolHooks(newHookAddress)) { - vm.stopBroadcast(); - console.log(unicode"✅ AdvancedPoolHooks updated successfully!"); - } catch (bytes memory err) { - vm.stopBroadcast(); - console.log(unicode"❌ Error: updateAdvancedPoolHooks() reverted."); - console.log(" Raw revert data:"); - console.logBytes(err); - console.log( - " If the error is OnlyCallableByOwner(), ensure you are broadcasting with the pool owner's account." - ); - console.log(" If the function selector is missing, the pool may be v1 (requires TokenPool v2.0+)."); - revert("updateAdvancedPoolHooks() reverted - see raw error above"); - } + executeCalls(CctActions.updateAdvancedPoolHooks(tokenPoolAddress, newHookAddress)); + console.log(unicode"✅ AdvancedPoolHooks updated successfully!"); console.log(""); console.log("========================================"); console.log(string.concat(unicode"✅ Pool hooks updated on ", chainName, "!")); diff --git a/script/configure/allowlist/UpdateAllowList.s.sol b/script/configure/allowlist/UpdateAllowList.s.sol index 0b08ad3..ac273fb 100644 --- a/script/configure/allowlist/UpdateAllowList.s.sol +++ b/script/configure/allowlist/UpdateAllowList.s.sol @@ -1,14 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {HelperUtils} from "../../utils/HelperUtils.s.sol"; -import {AdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/pools/AdvancedPoolHooks.sol"; - -interface ITokenPoolV1 { - function applyAllowListUpdates(address[] calldata removes, address[] calldata adds) external; -} +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /** * @title UpdateAllowList @@ -19,7 +16,7 @@ interface ITokenPoolV1 { * TOKEN_POOL=0x... POOL_HOOKS=0x... forge script script/configure/allowlist/UpdateAllowList.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account $KEYSTORE_NAME --broadcast * (If POOL_HOOKS is not set, will try to call on pool contract. If not found, throws error with guidance.) */ -contract UpdateAllowList is Script { +contract UpdateAllowList is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -56,47 +53,13 @@ contract UpdateAllowList is Script { console.log("========================================"); console.log(""); - vm.startBroadcast(); - bool success = false; - string memory errorMsg = ""; - if (hooksAddress != address(0)) { - // Try to call on AdvancedPoolHooks - try AdvancedPoolHooks(hooksAddress).applyAllowListUpdates(removes, adds) { - success = true; - } catch (bytes memory err) { - console.log(unicode"❌ Error: applyAllowListUpdates() reverted on AdvancedPoolHooks."); - console.log(" Raw revert data:"); - console.logBytes(err); - console.log( - " If the error is OnlyCallableByOwner(), ensure you are broadcasting with the hooks owner's account." - ); - errorMsg = unicode"❌ Error: applyAllowListUpdates() reverted - see raw error above."; - } - } else { - // Try to call on TokenPool (v1) via interface - try ITokenPoolV1(tokenPoolAddress).applyAllowListUpdates(removes, adds) { - success = true; - } catch (bytes memory err) { - console.log(unicode"❌ Error: applyAllowListUpdates() reverted on TokenPool."); - console.log(" Raw revert data:"); - console.logBytes(err); - console.log( - " If the function selector is not found, you may be using TokenPool v2.0+ which requires AdvancedPoolHooks." - ); - console.log( - " Deploy AdvancedPoolHooks using DeployAdvancedPoolHooks.s.sol, connect it via UpdateAdvancedPoolHooks.s.sol, then pass its address as POOL_HOOKS." - ); - errorMsg = unicode"❌ Error: applyAllowListUpdates() reverted - see raw error above."; - } - } - vm.stopBroadcast(); + // Target the AdvancedPoolHooks (v2) when POOL_HOOKS is set, otherwise the pool itself (v1). Both + // expose the identical applyAllowListUpdates(address[],address[]) selector, so one builder serves + // both. A revert (e.g. OnlyCallableByOwner, or a v2 pool without hooks) bubbles up unchanged. + address allowListTarget = hooksAddress != address(0) ? hooksAddress : tokenPoolAddress; + executeCalls(CctActions.applyAllowListUpdates(allowListTarget, removes, adds)); - if (success) { - console.log(unicode"✅ AllowList updated successfully!"); - } else { - console.log(errorMsg); - revert(errorMsg); - } + console.log(unicode"✅ AllowList updated successfully!"); console.log(""); console.log("========================================"); console.log(string.concat(unicode"✅ Allowlist updated on ", chainName, "!")); diff --git a/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol b/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol index c02c92c..9b54f4f 100644 --- a/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol +++ b/script/configure/authorized-callers/UpdateAuthorizedCallers.s.sol @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {HelperUtils} from "../../utils/HelperUtils.s.sol"; -import {AuthorizedCallers} from "@chainlink/contracts/src/v0.8/shared/access/AuthorizedCallers.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /** * @title UpdateAuthorizedCallers @@ -21,7 +22,7 @@ import {AuthorizedCallers} from "@chainlink/contracts/src/v0.8/shared/access/Aut * ADD_ADDRESSES — CSV or JSON array of addresses to add * REMOVE_ADDRESSES — CSV or JSON array of addresses to remove */ -contract UpdateAuthorizedCallers is Script { +contract UpdateAuthorizedCallers is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -71,14 +72,7 @@ contract UpdateAuthorizedCallers is Script { } console.log(""); - vm.startBroadcast(); - - AuthorizedCallers(contractAddress) - .applyAuthorizedCallerUpdates( - AuthorizedCallers.AuthorizedCallerArgs({addedCallers: addCallers, removedCallers: removeCallers}) - ); - - vm.stopBroadcast(); + executeCalls(CctActions.applyAuthorizedCallerUpdates(contractAddress, addCallers, removeCallers)); console.log(""); console.log("========================================"); diff --git a/script/configure/dynamic-config/SetDynamicConfig.s.sol b/script/configure/dynamic-config/SetDynamicConfig.s.sol index 372d979..02af247 100644 --- a/script/configure/dynamic-config/SetDynamicConfig.s.sol +++ b/script/configure/dynamic-config/SetDynamicConfig.s.sol @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /// @notice Updates the dynamic configuration of a TokenPool (router, rateLimitAdmin, feeAdmin). /// @@ -20,7 +22,7 @@ import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol /// RATE_LIMIT_ADMIN=0xYourRateLimitAdminAddress \ /// FEE_ADMIN=0xYourFeeAdminAddress \ /// forge script script/configure/dynamic-config/SetDynamicConfig.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast -contract SetDynamicConfig is Script { +contract SetDynamicConfig is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -61,14 +63,12 @@ contract SetDynamicConfig is Script { console.log(string.concat(" Fee Admin: ", vm.toString(currentFeeAdmin))); console.log(""); - vm.startBroadcast(); - // Defaults: env var → current on-chain value → broadcaster (as last resort if unset) - (, address broadcaster,) = vm.readCallers(); + address broadcasterAddr = broadcaster(); address router = vm.envOr("ROUTER", currentRouter); address rateLimitAdmin = - vm.envOr("RATE_LIMIT_ADMIN", currentRateLimitAdmin != address(0) ? currentRateLimitAdmin : broadcaster); - address feeAdmin = vm.envOr("FEE_ADMIN", currentFeeAdmin != address(0) ? currentFeeAdmin : broadcaster); + vm.envOr("RATE_LIMIT_ADMIN", currentRateLimitAdmin != address(0) ? currentRateLimitAdmin : broadcasterAddr); + address feeAdmin = vm.envOr("FEE_ADMIN", currentFeeAdmin != address(0) ? currentFeeAdmin : broadcasterAddr); console.log("New Configuration:"); console.log(string.concat(" Router: ", vm.toString(router))); @@ -77,9 +77,7 @@ contract SetDynamicConfig is Script { console.log(""); console.log(string.concat("[Step 1] Setting dynamic config on ", chainName)); - tokenPool.setDynamicConfig(router, rateLimitAdmin, feeAdmin); - - vm.stopBroadcast(); + executeCalls(CctActions.setDynamicConfig(tokenPoolAddress, router, rateLimitAdmin, feeAdmin)); console.log(unicode"✅ Dynamic config updated successfully!"); console.log(""); diff --git a/script/configure/fee-config/UpdateTokenTransferFeeConfig.s.sol b/script/configure/fee-config/UpdateTokenTransferFeeConfig.s.sol index d798010..02cfc05 100644 --- a/script/configure/fee-config/UpdateTokenTransferFeeConfig.s.sol +++ b/script/configure/fee-config/UpdateTokenTransferFeeConfig.s.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; import {IPoolV2} from "@chainlink/contracts-ccip/contracts/interfaces/IPoolV2.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /// @notice Applies token transfer fee configuration updates to a token pool on a given destination lane. /// @@ -43,7 +45,7 @@ import {IPoolV2} from "@chainlink/contracts-ccip/contracts/interfaces/IPoolV2.so /// DEST_CHAIN=MANTLE_SEPOLIA \ /// DISABLE=true \ /// forge script script/configure/fee-config/UpdateTokenTransferFeeConfig.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast -contract UpdateTokenTransferFeeConfig is Script { +contract UpdateTokenTransferFeeConfig is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -107,23 +109,12 @@ contract UpdateTokenTransferFeeConfig is Script { toDisable[0] = destChainSelector; TokenPool.TokenTransferFeeConfigArgs[] memory emptyArgs = new TokenPool.TokenTransferFeeConfigArgs[](0); - vm.startBroadcast(); - // applyTokenTransferFeeConfigUpdates() was introduced in TokenPool v2.0. // On v1 pools, fee configuration is handled by FeeQuoter and requires // a direct request to the Chainlink team — it cannot be modified here. - try tokenPool.applyTokenTransferFeeConfigUpdates(emptyArgs, toDisable) { - console.log(unicode"✅ Fee config disabled for this lane."); - console.log(" The OnRamp will now use FeeQuoter defaults for this destination."); - } catch (bytes memory err) { - console.log(unicode"❌ Error: applyTokenTransferFeeConfigUpdates() reverted."); - console.log(" Raw revert data:"); - console.logBytes(err); - console.log(" If the function selector is missing, the pool may be v1 (requires TokenPool v2.0+)."); - revert("applyTokenTransferFeeConfigUpdates() reverted - see raw error above"); - } - - vm.stopBroadcast(); + executeCalls(CctActions.applyTokenTransferFeeConfigUpdates(tokenPoolAddress, emptyArgs, toDisable)); + console.log(unicode"✅ Fee config disabled for this lane."); + console.log(" The OnRamp will now use FeeQuoter defaults for this destination."); } else { // ── Read current on-chain config as defaults ─────────────────── IPoolV2.TokenTransferFeeConfig memory currentConfig; @@ -169,22 +160,11 @@ contract UpdateTokenTransferFeeConfig is Script { string.concat("[Step 1] Applying fee config for lane to ", helperConfig.getChainName(destChainId)) ); - vm.startBroadcast(); - // applyTokenTransferFeeConfigUpdates() was introduced in TokenPool v2.0. // On v1 pools, fee configuration is handled by FeeQuoter and requires // a direct request to the Chainlink team — it cannot be modified here. - try tokenPool.applyTokenTransferFeeConfigUpdates(args, emptyDisable) { - console.log(unicode"✅ Fee config applied successfully!"); - } catch (bytes memory err) { - console.log(unicode"❌ Error: applyTokenTransferFeeConfigUpdates() reverted."); - console.log(" Raw revert data:"); - console.logBytes(err); - console.log(" If the function selector is missing, the pool may be v1 (requires TokenPool v2.0+)."); - revert("applyTokenTransferFeeConfigUpdates() reverted - see raw error above"); - } - - vm.stopBroadcast(); + executeCalls(CctActions.applyTokenTransferFeeConfigUpdates(tokenPoolAddress, args, emptyDisable)); + console.log(unicode"✅ Fee config applied successfully!"); } // ── Footer ───────────────────────────────────────────────────────── diff --git a/script/configure/finality-config/SetFinalityConfig.s.sol b/script/configure/finality-config/SetFinalityConfig.s.sol index 6d764ed..0d379ef 100644 --- a/script/configure/finality-config/SetFinalityConfig.s.sol +++ b/script/configure/finality-config/SetFinalityConfig.s.sol @@ -1,13 +1,15 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; import {FinalityCodec} from "@chainlink/contracts-ccip/contracts/libraries/FinalityCodec.sol"; import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; import {RateLimiterUtils, ITokenPoolV1RateLimiter} from "../../utils/RateLimiterUtils.s.sol"; import {FinalityConfigUtils} from "../../utils/FinalityConfigUtils.s.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /// @notice Sets the allowed finality configuration on a TokenPool, and optionally updates rate limits /// for the fast finality bucket on a specific remote chain lane. @@ -58,7 +60,7 @@ import {FinalityConfigUtils} from "../../utils/FinalityConfigUtils.s.sol"; /// /// # Reset to default finality (disables fast finality transfers): /// forge script script/configure/finality-config/SetFinalityConfig.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast -contract SetFinalityConfig is Script { +contract SetFinalityConfig is EoaExecutor { HelperConfig public helperConfig; // ── Storage: avoids EVM stack pressure inside run() ──────────────────── @@ -145,22 +147,10 @@ contract SetFinalityConfig is Script { } // ── Step 1: Set finality config ──────────────────────────────────── - vm.startBroadcast(); - console.log(string.concat("[Step 1] Setting finality config on ", chainName)); - try tokenPool.setAllowedFinalityConfig(s_newFinalityConfig) { - console.log(unicode"✅ Finality config set successfully!"); - } catch (bytes memory err) { - console.log(unicode"❌ Error: setAllowedFinalityConfig() reverted."); - console.log(" Raw revert data:"); - console.logBytes(err); - console.log( - " If the error is OnlyCallableByOwner(), ensure you are broadcasting with the pool owner's account." - ); - console.log(" If the function selector is missing, the pool may be v1 (requires TokenPool v2.0+)."); - revert("setAllowedFinalityConfig() reverted - see raw error above"); - } + executeCalls(CctActions.setAllowedFinalityConfig(tokenPoolAddress, s_newFinalityConfig)); + console.log(unicode"✅ Finality config set successfully!"); // ── Step 2: Apply rate limit update (if requested) ───────────────── if (hasRateLimitUpdate) { @@ -169,8 +159,6 @@ contract SetFinalityConfig is Script { ); } - vm.stopBroadcast(); - // ── Log updated rate limits (if DEST_CHAIN provided) ────────────── if (destChainSet) { console.log(""); @@ -262,14 +250,9 @@ contract SetFinalityConfig is Script { RateLimiterUtils.logNewConfig(u.updateOutbound, outbound, u.updateInbound, inbound); - TokenPool.RateLimitConfigArgs[] memory args = new TokenPool.RateLimitConfigArgs[](1); - args[0] = TokenPool.RateLimitConfigArgs({ - remoteChainSelector: remoteChainSelector, - fastFinality: true, - outboundRateLimiterConfig: outbound, - inboundRateLimiterConfig: inbound - }); - tokenPool.setRateLimitConfig(args); + // Fast-finality bucket update (fastFinality=true) through the version-detected action dispatch. + // setAllowedFinalityConfig above already established this is a v2 pool. + executeCalls(CctActions.setRateLimits(address(tokenPool), isV2, remoteChainSelector, true, outbound, inbound)); console.log(unicode"✅ Rate limits updated successfully!"); } diff --git a/script/configure/rate-limiter/UpdateRateLimiters.s.sol b/script/configure/rate-limiter/UpdateRateLimiters.s.sol index 4cc360d..7265df9 100644 --- a/script/configure/rate-limiter/UpdateRateLimiters.s.sol +++ b/script/configure/rate-limiter/UpdateRateLimiters.s.sol @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; import {RateLimiterUtils, ITokenPoolV1RateLimiter} from "../../utils/RateLimiterUtils.s.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /// @notice Updates rate limiter configuration on a TokenPool, compatible with both v1 and v2 pools. /// @@ -41,7 +43,7 @@ import {RateLimiterUtils, ITokenPoolV1RateLimiter} from "../../utils/RateLimiter /// # Disable outbound only: /// DEST_CHAIN=MANTLE_SEPOLIA OUTBOUND_RATE_LIMIT_ENABLED=false \ /// forge script script/configure/rate-limiter/UpdateRateLimiters.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast -contract UpdateRateLimiters is Script { +contract UpdateRateLimiters is EoaExecutor { // ── Storage: shared between run() and helper functions ───────────────── // Using storage instead of function parameters eliminates EVM stack pressure. HelperConfig public helperConfig; @@ -154,26 +156,15 @@ contract UpdateRateLimiters is Script { _broadcastRateLimitConfig(outbound, inbound); } - /// @dev Broadcasts the on-chain call (v1 or v2 pool). + /// @dev Routes the update through the version-detected action-layer dispatch: v2 pools take + /// `setRateLimitConfig(RateLimitConfigArgs[])`, v1 pools take `setChainRateLimiterConfig`. The + /// `s_isV2` capability was detected via `RateLimiterUtils.isV2Pool` above (probe stays script-side). function _broadcastRateLimitConfig(RateLimiter.Config memory outbound, RateLimiter.Config memory inbound) internal { - vm.startBroadcast(); - if (s_isV2) { - TokenPool.RateLimitConfigArgs[] memory args = new TokenPool.RateLimitConfigArgs[](1); - args[0] = TokenPool.RateLimitConfigArgs({ - remoteChainSelector: s_selector, - fastFinality: s_fastFinality, - outboundRateLimiterConfig: outbound, - inboundRateLimiterConfig: inbound - }); - TokenPool(s_poolAddress).setRateLimitConfig(args); - } else { - if (s_fastFinality) { - console.log( - unicode"⚠️ Warning: FAST_FINALITY=true is ignored on v1 pools. Updating the standard bucket." - ); - } - ITokenPoolV1RateLimiter(s_poolAddress).setChainRateLimiterConfig(s_selector, outbound, inbound); + if (!s_isV2 && s_fastFinality) { + console.log( + unicode"⚠️ Warning: FAST_FINALITY=true is ignored on v1 pools. Updating the standard bucket." + ); } - vm.stopBroadcast(); + executeCalls(CctActions.setRateLimits(s_poolAddress, s_isV2, s_selector, s_fastFinality, outbound, inbound)); } } diff --git a/script/configure/remote-pools/AddRemotePool.s.sol b/script/configure/remote-pools/AddRemotePool.s.sol index 048a364..67d90aa 100644 --- a/script/configure/remote-pools/AddRemotePool.s.sol +++ b/script/configure/remote-pools/AddRemotePool.s.sol @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /// @notice Adds a remote pool address to a TokenPool for a given remote chain. /// @@ -20,7 +22,7 @@ import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol /// DEST_CHAIN=MANTLE_SEPOLIA \ /// REMOTE_POOL_ADDRESS=0xNewRemotePoolAddress \ /// forge script script/configure/remote-pools/AddRemotePool.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast -contract AddRemotePool is Script { +contract AddRemotePool is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -85,13 +87,9 @@ contract AddRemotePool is Script { } console.log(""); - vm.startBroadcast(); - console.log(string.concat("[Step 1] Adding remote pool on ", sourceChainName)); - tokenPool.addRemotePool(remoteChainSelector, abi.encode(remotePoolAddress)); - - vm.stopBroadcast(); + executeCalls(CctActions.addRemotePool(tokenPoolAddress, remoteChainSelector, abi.encode(remotePoolAddress))); console.log(unicode"✅ Remote pool added successfully!"); console.log(""); diff --git a/script/configure/remote-pools/RemoveRemotePool.s.sol b/script/configure/remote-pools/RemoveRemotePool.s.sol index 66d3510..bde74cb 100644 --- a/script/configure/remote-pools/RemoveRemotePool.s.sol +++ b/script/configure/remote-pools/RemoveRemotePool.s.sol @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../../HelperConfig.s.sol"; import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {CctActions} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; /// @notice Removes a remote pool address from a TokenPool for a given remote chain. /// @@ -19,7 +21,7 @@ import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol /// DEST_CHAIN=MANTLE_SEPOLIA \ /// REMOTE_POOL_ADDRESS=0xOldRemotePoolAddress \ /// forge script script/configure/remote-pools/RemoveRemotePool.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast -contract RemoveRemotePool is Script { +contract RemoveRemotePool is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -88,13 +90,9 @@ contract RemoveRemotePool is Script { } console.log(""); - vm.startBroadcast(); - console.log(string.concat("[Step 1] Removing remote pool on ", sourceChainName)); - tokenPool.removeRemotePool(remoteChainSelector, abi.encode(remotePoolAddress)); - - vm.stopBroadcast(); + executeCalls(CctActions.removeRemotePool(tokenPoolAddress, remoteChainSelector, abi.encode(remotePoolAddress))); console.log(unicode"✅ Remote pool removed successfully!"); console.log(""); diff --git a/script/operations/DepositToLockBox.s.sol b/script/operations/DepositToLockBox.s.sol index 40ae891..e4cff19 100644 --- a/script/operations/DepositToLockBox.s.sol +++ b/script/operations/DepositToLockBox.s.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../HelperConfig.s.sol"; import {ERC20LockBox} from "@chainlink/contracts-ccip/contracts/pools/ERC20LockBox.sol"; import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; /** * @title DepositToLockBox @@ -19,7 +21,7 @@ import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; * LOCK_BOX — (required) address of the ERC20LockBox contract * AMOUNT — (optional) amount to deposit (defaults to tokenAmountToTransfer from script/input/token.json) */ -contract DepositToLockBox is Script { +contract DepositToLockBox is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -67,31 +69,25 @@ contract DepositToLockBox is Script { IERC20 token = IERC20(tokenAddress); - vm.startBroadcast(); - - (, address broadcaster,) = vm.readCallers(); + address depositor = broadcaster(); console.log("Deposit Parameters:"); console.log(string.concat(" LockBox: ", vm.toString(lockBoxAddress))); console.log(string.concat(" Token: ", vm.toString(tokenAddress))); - console.log(string.concat(" Depositor: ", vm.toString(broadcaster))); + console.log(string.concat(" Depositor: ", vm.toString(depositor))); console.log(string.concat(" Amount: ", vm.toString(amount))); console.log(""); - uint256 balanceBefore = token.balanceOf(broadcaster); + uint256 balanceBefore = token.balanceOf(depositor); require(balanceBefore >= amount, "Insufficient token balance"); + // approve(lockbox, amount) then deposit(token, 0, amount) as one batch through the action layer. console.log(string.concat("[Step 1] Approving ", vm.toString(amount), " tokens to LockBox")); - token.approve(lockBoxAddress, amount); - console.log(unicode"✅ Approval successful!"); - - console.log(string.concat("\n[Step 2] Depositing ", vm.toString(amount), " tokens into LockBox")); - lockBox.deposit(tokenAddress, 0, amount); // remoteChainSelector is unused, pass 0 + console.log(string.concat("[Step 2] Depositing ", vm.toString(amount), " tokens into LockBox")); + executeCalls(CctActions.lockboxDeposit(lockBoxAddress, tokenAddress, amount)); console.log(unicode"✅ Deposit successful!"); - vm.stopBroadcast(); - - uint256 balanceAfter = token.balanceOf(broadcaster); + uint256 balanceAfter = token.balanceOf(depositor); uint256 lockBoxBalance = token.balanceOf(lockBoxAddress); console.log(""); diff --git a/script/operations/MintTokens.s.sol b/script/operations/MintTokens.s.sol index 0eeae69..9849fa1 100644 --- a/script/operations/MintTokens.s.sol +++ b/script/operations/MintTokens.s.sol @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../HelperConfig.s.sol"; // Network configuration helper import {CrossChainToken} from "@chainlink/contracts-ccip/contracts/tokens/CrossChainToken.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; -contract MintTokens is Script { +contract MintTokens is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -50,11 +52,8 @@ contract MintTokens is Script { console.log(string.concat(" Token Symbol: ", token.symbol())); console.log(string.concat(" Amount: ", vm.toString(amount))); - vm.startBroadcast(); - - (, address broadcaster,) = vm.readCallers(); // Get receiver address from environment variable or use broadcaster by default - address receiverAddress = vm.envOr("MINT_RECEIVER", broadcaster); + address receiverAddress = vm.envOr("MINT_RECEIVER", broadcaster()); console.log(string.concat(" Receiver: ", vm.toString(receiverAddress))); console.log(""); @@ -63,11 +62,9 @@ contract MintTokens is Script { "\n[Step 1] Minting ", vm.toString(amount), " ", token.symbol(), " to ", vm.toString(receiverAddress) ) ); - token.mint(receiverAddress, amount); + executeCalls(CctActions.mint(tokenAddress, receiverAddress, amount)); console.log(unicode"✅ Tokens minted successfully!"); - vm.stopBroadcast(); - uint256 newBalance = token.balanceOf(receiverAddress); console.log(""); diff --git a/script/operations/WithdrawFeeTokens.s.sol b/script/operations/WithdrawFeeTokens.s.sol index c88efa8..e7d447e 100644 --- a/script/operations/WithdrawFeeTokens.s.sol +++ b/script/operations/WithdrawFeeTokens.s.sol @@ -1,11 +1,13 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../HelperConfig.s.sol"; import {HelperUtils} from "../utils/HelperUtils.s.sol"; import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; import {FeeTokenLogger} from "../utils/FeeTokenLogger.s.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; /// @notice Withdraws accrued fee token balances from a token pool to a specified recipient. /// @@ -37,7 +39,7 @@ import {FeeTokenLogger} from "../utils/FeeTokenLogger.s.sol"; /// RECIPIENT=0xYourAddress \ /// FEE_TOKENS="0xFirstFeeToken,0xSecondFeeToken" \ /// forge script script/operations/WithdrawFeeTokens.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast -contract WithdrawFeeTokens is Script { +contract WithdrawFeeTokens is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -104,30 +106,15 @@ contract WithdrawFeeTokens is Script { // ── Broadcast ───────────────────────────────────────────────────── console.log(string.concat("[Step 1] Withdrawing fee tokens on ", chainName)); - vm.startBroadcast(); - // RECIPIENT defaults to the broadcaster (the account signing the transaction). - (, address broadcaster,) = vm.readCallers(); - address recipient = vm.envOr("RECIPIENT", broadcaster); + address recipient = vm.envOr("RECIPIENT", broadcaster()); console.log(string.concat("Recipient: ", vm.toString(recipient))); // withdrawFeeTokens() was introduced in TokenPool v2.0. // On v1 pools, fee configuration is handled by FeeQuoter and there is no // pool-level fee withdrawal mechanism — fees are not accrued by the pool contract. - try tokenPool.withdrawFeeTokens(tokensToWithdraw, recipient) { - console.log(unicode"✅ Fee tokens withdrawn successfully!"); - } catch (bytes memory err) { - console.log(unicode"❌ Error: withdrawFeeTokens() reverted."); - console.log(" Raw revert data:"); - console.logBytes(err); - console.log( - " If the error is OnlyCallableByOwner(), ensure you are broadcasting with the pool owner's account." - ); - console.log(" If the function selector is missing, the pool may be v1 (requires TokenPool v2.0+)."); - revert("withdrawFeeTokens() reverted - see raw error above"); - } - - vm.stopBroadcast(); + executeCalls(CctActions.withdrawFeeTokens(tokenPoolAddress, tokensToWithdraw, recipient)); + console.log(unicode"✅ Fee tokens withdrawn successfully!"); // ── Footer ───────────────────────────────────────────────────────── console.log(""); diff --git a/script/operations/WithdrawFromLockBox.s.sol b/script/operations/WithdrawFromLockBox.s.sol index ace6adb..cc7f55e 100644 --- a/script/operations/WithdrawFromLockBox.s.sol +++ b/script/operations/WithdrawFromLockBox.s.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import {Script, console} from "forge-std/Script.sol"; +import {console} from "forge-std/Script.sol"; import {HelperConfig} from "../HelperConfig.s.sol"; import {ERC20LockBox} from "@chainlink/contracts-ccip/contracts/pools/ERC20LockBox.sol"; import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; /** * @title WithdrawFromLockBox @@ -20,7 +22,7 @@ import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; * AMOUNT — (optional) amount to withdraw (defaults to entire lockbox balance) * RECIPIENT — (optional) address to receive withdrawn tokens (defaults to broadcaster) */ -contract WithdrawFromLockBox is Script { +contract WithdrawFromLockBox is EoaExecutor { HelperConfig public helperConfig; function run() external { @@ -68,10 +70,7 @@ contract WithdrawFromLockBox is Script { require(amount > 0, "Amount must be greater than 0"); require(amount <= lockBoxBalance, "Amount exceeds lockbox balance"); - vm.startBroadcast(); - - (, address broadcaster,) = vm.readCallers(); - address recipient = vm.envOr("RECIPIENT", broadcaster); + address recipient = vm.envOr("RECIPIENT", broadcaster()); console.log("Withdrawal Parameters:"); console.log(string.concat(" LockBox: ", vm.toString(lockBoxAddress))); @@ -90,11 +89,9 @@ contract WithdrawFromLockBox is Script { uint256 recipientBalanceBefore = token.balanceOf(recipient); console.log(string.concat("[Step 1] Withdrawing ", vm.toString(amount), " tokens from LockBox")); - lockBox.withdraw(tokenAddress, 0, amount, recipient); // remoteChainSelector is unused, pass 0 + executeCalls(CctActions.lockboxWithdraw(lockBoxAddress, tokenAddress, amount, recipient)); console.log(unicode"✅ Withdrawal successful!"); - vm.stopBroadcast(); - uint256 lockBoxBalanceAfter = token.balanceOf(lockBoxAddress); uint256 recipientBalanceAfter = token.balanceOf(recipient); uint256 actualWithdrawn = recipientBalanceAfter - recipientBalanceBefore; diff --git a/src/actions/CctActions.sol b/src/actions/CctActions.sol index e1a6ac8..553693b 100644 --- a/src/actions/CctActions.sol +++ b/src/actions/CctActions.sol @@ -11,6 +11,25 @@ import { IAccessControlDefaultAdminRules } from "@openzeppelin/contracts@5.3.0/access/extensions/IAccessControlDefaultAdminRules.sol"; import {IAccessControl} from "@openzeppelin/contracts@5.3.0/access/IAccessControl.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; +import {IAdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/interfaces/IAdvancedPoolHooks.sol"; +import {AdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/pools/AdvancedPoolHooks.sol"; +import {ERC20LockBox} from "@chainlink/contracts-ccip/contracts/pools/ERC20LockBox.sol"; +import {AuthorizedCallers} from "@chainlink/contracts/src/v0.8/shared/access/AuthorizedCallers.sol"; +import {IBurnMintERC20} from "@chainlink/contracts-ccip/contracts/interfaces/IBurnMintERC20.sol"; +import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; + +/// @notice Minimal view of the v1.x TokenPool rate-limiter setter (`setChainRateLimiterConfig`), which v2 +/// pools replaced with `setRateLimitConfig(RateLimitConfigArgs[])`. Declared here so the action +/// layer can build the v1 setter's calldata without importing a script-side utility. The function +/// selector is identical wherever the signature is declared, so builder calldata stays canonical. +interface IRateLimiterV1 { + function setChainRateLimiterConfig( + uint64 remoteChainSelector, + RateLimiter.Config memory outboundConfig, + RateLimiter.Config memory inboundConfig + ) external; +} /// @title CctActions /// @notice The shared action layer: every CCT write operation is defined here exactly once, as a pure @@ -171,6 +190,191 @@ library CctActions { return concat(grantRole(target, role, newHolder), revokeRole(target, role, oldHolder)); } + // ───────────────────────────────────────────────────────────────────────── + // Rate limiting (version-detected dispatch) + // ───────────────────────────────────────────────────────────────────────── + + /// @notice v1.x rate-limit setter: `setChainRateLimiterConfig(selector, outbound, inbound)`. v1 pools + /// carry a single (standard-finality) bucket per lane, so there is no fast-finality parameter. + function setChainRateLimiterConfig( + address pool, + uint64 remoteChainSelector, + RateLimiter.Config memory outbound, + RateLimiter.Config memory inbound + ) internal pure returns (Call[] memory) { + return _one( + pool, abi.encodeCall(IRateLimiterV1.setChainRateLimiterConfig, (remoteChainSelector, outbound, inbound)) + ); + } + + /// @notice v2.0+ rate-limit setter: `setRateLimitConfig(RateLimitConfigArgs[])`. The args carry the + /// `fastFinality` flag, so one call can target either the standard or the fast-finality bucket. + function setRateLimitConfig(address pool, TokenPool.RateLimitConfigArgs[] memory args) + internal + pure + returns (Call[] memory) + { + return _one(pool, abi.encodeCall(TokenPool.setRateLimitConfig, (args))); + } + + /// @notice Version-detected rate-limit dispatch: routes a single-lane update to the v1 setter + /// (`setChainRateLimiterConfig`) or the v2 setter (`setRateLimitConfig`) based on `isV2` — the + /// same capability the docs-team `RateLimiterUtils.isV2Pool` probe detects script-side. `isV2` + /// stays a parameter (like the claim-path probe) so the builder remains pure. `fastFinality` is + /// ignored on v1 (v1 pools have only the standard bucket). + function setRateLimits( + address pool, + bool isV2, + uint64 remoteChainSelector, + bool fastFinality, + RateLimiter.Config memory outbound, + RateLimiter.Config memory inbound + ) internal pure returns (Call[] memory) { + if (isV2) { + TokenPool.RateLimitConfigArgs[] memory args = new TokenPool.RateLimitConfigArgs[](1); + args[0] = TokenPool.RateLimitConfigArgs({ + remoteChainSelector: remoteChainSelector, + fastFinality: fastFinality, + outboundRateLimiterConfig: outbound, + inboundRateLimiterConfig: inbound + }); + return setRateLimitConfig(pool, args); + } + return setChainRateLimiterConfig(pool, remoteChainSelector, outbound, inbound); + } + + // ───────────────────────────────────────────────────────────────────────── + // Remote pools / dynamic config / finality / fee config (pool configuration) + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Add a remote pool address for a supported remote chain. `encodedRemotePool` is the + /// chain-family-encoded address (EVM: `abi.encode(address)`); the action layer passes it through. + function addRemotePool(address pool, uint64 remoteChainSelector, bytes memory encodedRemotePool) + internal + pure + returns (Call[] memory) + { + return _one(pool, abi.encodeCall(TokenPool.addRemotePool, (remoteChainSelector, encodedRemotePool))); + } + + /// @notice Remove a remote pool address for a remote chain. + function removeRemotePool(address pool, uint64 remoteChainSelector, bytes memory encodedRemotePool) + internal + pure + returns (Call[] memory) + { + return _one(pool, abi.encodeCall(TokenPool.removeRemotePool, (remoteChainSelector, encodedRemotePool))); + } + + /// @notice Update the pool's dynamic config (router, rateLimitAdmin, feeAdmin). + function setDynamicConfig(address pool, address router, address rateLimitAdmin, address feeAdmin) + internal + pure + returns (Call[] memory) + { + return _one(pool, abi.encodeCall(TokenPool.setDynamicConfig, (router, rateLimitAdmin, feeAdmin))); + } + + /// @notice Set the pool's allowed fast-finality config (a `bytes4` FinalityCodec value). v2.0+ only. + function setAllowedFinalityConfig(address pool, bytes4 finalityConfig) internal pure returns (Call[] memory) { + return _one(pool, abi.encodeCall(TokenPool.setAllowedFinalityConfig, (finalityConfig))); + } + + /// @notice Apply per-lane token-transfer fee-config updates (add/update `feeConfigArgs`, disable + /// `removeSelectors`). v2.0+ only. + function applyTokenTransferFeeConfigUpdates( + address pool, + TokenPool.TokenTransferFeeConfigArgs[] memory feeConfigArgs, + uint64[] memory removeSelectors + ) internal pure returns (Call[] memory) { + return _one( + pool, abi.encodeCall(TokenPool.applyTokenTransferFeeConfigUpdates, (feeConfigArgs, removeSelectors)) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Hooks / access control (allowlist, authorized callers) + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Point a v2 pool at a new `AdvancedPoolHooks` contract. + function updateAdvancedPoolHooks(address pool, address newHook) internal pure returns (Call[] memory) { + return _one(pool, abi.encodeCall(TokenPool.updateAdvancedPoolHooks, (IAdvancedPoolHooks(newHook)))); + } + + /// @notice Apply allowlist updates (`removes`, `adds`) on an `AdvancedPoolHooks` (v2) or a v1 pool — + /// both expose the identical `applyAllowListUpdates(address[],address[])` selector. + /// @dev IMMUTABILITY TRAP: on `AdvancedPoolHooks`, `allowlistEnabled` is fixed at deploy time from + /// whether the INITIAL allowlist was non-empty. If the hooks were deployed with an empty allowlist, + /// allowlisting is permanently disabled and this call reverts `AllowListNotEnabled()` — enabling + /// allowlisting later requires deploying a NEW hooks contract with a non-empty initial allowlist. + function applyAllowListUpdates(address target, address[] memory removes, address[] memory adds) + internal + pure + returns (Call[] memory) + { + return _one(target, abi.encodeCall(AdvancedPoolHooks.applyAllowListUpdates, (removes, adds))); + } + + /// @notice Apply authorized-caller updates on any `AuthorizedCallers` contract — the shared base of + /// both `AdvancedPoolHooks` and `ERC20LockBox`, so one builder serves both variants. + function applyAuthorizedCallerUpdates(address target, address[] memory adds, address[] memory removes) + internal + pure + returns (Call[] memory) + { + return _one( + target, + abi.encodeCall( + AuthorizedCallers.applyAuthorizedCallerUpdates, + (AuthorizedCallers.AuthorizedCallerArgs({addedCallers: adds, removedCallers: removes})) + ) + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Operations (mint / lockbox deposit-withdraw / fee-token withdrawal / approve) + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Mint `amount` of a burn/mint token to `to`. The executing account must hold the mint role. + function mint(address token, address to, uint256 amount) internal pure returns (Call[] memory) { + return _one(token, abi.encodeCall(IBurnMintERC20.mint, (to, amount))); + } + + /// @notice ERC20 `approve(spender, amount)` — the allowance step `DepositToLockBox` needs before a + /// lockbox deposit pulls the tokens. + function approve(address token, address spender, uint256 amount) internal pure returns (Call[] memory) { + return _one(token, abi.encodeCall(IERC20.approve, (spender, amount))); + } + + /// @notice Deposit `amount` of `token` into an `ERC20LockBox` as ONE batch: `approve(lockbox, amount)` + /// on the token, then `deposit(token, 0, amount)` on the lockbox (the unused remoteChainSelector + /// param is 0 per v2.0 semantics). The executing account must be an authorized lockbox caller. + function lockboxDeposit(address lockbox, address token, uint256 amount) internal pure returns (Call[] memory) { + return concat( + approve(token, lockbox, amount), + _one(lockbox, abi.encodeCall(ERC20LockBox.deposit, (token, uint64(0), amount))) + ); + } + + /// @notice Withdraw `amount` of `token` from an `ERC20LockBox` to `recipient` (`remoteChainSelector` + /// param is 0, unused). The executing account must be an authorized lockbox caller. + function lockboxWithdraw(address lockbox, address token, uint256 amount, address recipient) + internal + pure + returns (Call[] memory) + { + return _one(lockbox, abi.encodeCall(ERC20LockBox.withdraw, (token, uint64(0), amount, recipient))); + } + + /// @notice Withdraw accrued fee-token balances from a v2 pool to `recipient`. Owner/feeAdmin gated. + function withdrawFeeTokens(address pool, address[] memory feeTokens, address recipient) + internal + pure + returns (Call[] memory) + { + return _one(pool, abi.encodeCall(TokenPool.withdrawFeeTokens, (feeTokens, recipient))); + } + // ───────────────────────────────────────────────────────────────────────── // Composition // ───────────────────────────────────────────────────────────────────────── diff --git a/test/BaseForkTest.t.sol b/test/BaseForkTest.t.sol index 5cd37d6..cc60f23 100644 --- a/test/BaseForkTest.t.sol +++ b/test/BaseForkTest.t.sol @@ -5,6 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {HelperConfig} from "../script/HelperConfig.s.sol"; import {DeployToken} from "../script/deploy/DeployToken.s.sol"; import {DeployBurnMintTokenPool} from "../script/deploy/DeployBurnMintTokenPool.s.sol"; +import {CctActions} from "../src/actions/CctActions.sol"; /// @title BaseForkTest /// @notice Shared base for Ethereum Sepolia fork tests. @@ -88,4 +89,19 @@ abstract contract BaseForkTest is Test { (, broadcaster,) = vm.readCallers(); vm.stopBroadcast(); } + + /// @dev Executes a `CctActions.Call[]` in order, each pranked as `sender` — the exact `Call[]` the + /// refactored scripts hand to `EoaExecutor.executeCalls`, so a test proves the action-layer calldata + /// against on-chain getters. Reverts (with the underlying reason) on the first failing call. + function _exec(address sender, CctActions.Call[] memory calls) internal { + for (uint256 i = 0; i < calls.length; i++) { + vm.prank(sender); + (bool ok, bytes memory ret) = calls[i].target.call{value: calls[i].value}(calls[i].data); + if (!ok) { + assembly { + revert(add(ret, 0x20), mload(ret)) + } + } + } + } } diff --git a/test/actions/ConfigureActions.t.sol b/test/actions/ConfigureActions.t.sol new file mode 100644 index 0000000..4e2b905 --- /dev/null +++ b/test/actions/ConfigureActions.t.sol @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {BurnMintTokenPool} from "@chainlink/contracts-ccip/contracts/pools/BurnMintTokenPool.sol"; +import {IBurnMintERC20} from "@chainlink/contracts-ccip/contracts/interfaces/IBurnMintERC20.sol"; +import {IPoolV2} from "@chainlink/contracts-ccip/contracts/interfaces/IPoolV2.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; +import {FinalityCodec} from "@chainlink/contracts-ccip/contracts/libraries/FinalityCodec.sol"; +import {CctActions, IRateLimiterV1} from "../../src/actions/CctActions.sol"; +import {RateLimiterUtils} from "../../script/utils/RateLimiterUtils.s.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; + +/// @dev A minimal pool that exposes ONLY the v1.x rate-limiter surface (`setChainRateLimiterConfig` + +/// the two per-direction getters) and NOT the v2 `getCurrentRateLimiterState(uint64,bool)` getter, +/// so the capability probe (`RateLimiterUtils.isV2Pool`) detects it as v1. It faithfully stores the +/// config the v1 setter writes, so the v1 dispatch path can be fork-executed and read back — the +/// 1.6.x generation is no longer in this repo's dependency set, so a minimal faithful v1 ABI is the +/// honest way to prove the extracted dual-generation dispatch still routes correctly. +contract MockV1RateLimiterPool { + address public owner; + + mapping(uint64 => RateLimiter.TokenBucket) internal s_outbound; + mapping(uint64 => RateLimiter.TokenBucket) internal s_inbound; + + constructor() { + owner = msg.sender; + } + + function setChainRateLimiterConfig( + uint64 remoteChainSelector, + RateLimiter.Config memory outbound, + RateLimiter.Config memory inbound + ) external { + require(msg.sender == owner, "not owner"); + s_outbound[remoteChainSelector] = RateLimiter.TokenBucket({ + tokens: outbound.capacity, + lastUpdated: 0, + isEnabled: outbound.isEnabled, + capacity: outbound.capacity, + rate: outbound.rate + }); + s_inbound[remoteChainSelector] = RateLimiter.TokenBucket({ + tokens: inbound.capacity, + lastUpdated: 0, + isEnabled: inbound.isEnabled, + capacity: inbound.capacity, + rate: inbound.rate + }); + } + + function getCurrentOutboundRateLimiterState(uint64 sel) external view returns (RateLimiter.TokenBucket memory) { + return s_outbound[sel]; + } + + function getCurrentInboundRateLimiterState(uint64 sel) external view returns (RateLimiter.TokenBucket memory) { + return s_inbound[sel]; + } +} + +/// @dev A BurnMintTokenPool subclass that exposes the internal fast-finality consume path so the +/// fallback behaviour (fast bucket disabled -> default bucket consumed, TokenPool.sol +/// `_consumeFastFinalityOutboundRateLimit`) can be exercised without a full cross-chain send. +contract FastFinalityProbePool is BurnMintTokenPool { + constructor(IBurnMintERC20 token, uint8 decimals, address rmnProxy, address router) + BurnMintTokenPool(token, decimals, address(0), rmnProxy, router) + {} + + function probeFastOutbound(uint64 remoteChainSelector, uint256 amount) external { + _consumeFastFinalityOutboundRateLimit(address(getToken()), remoteChainSelector, amount); + } +} + +/// @notice Fork parity tests for the PR 1.2 action-layer rollout of the `configure/*` write-script groups. +/// Each refactored operation is exercised through its `CctActions` builder (the exact `Call[]` the scripts +/// now hand to `EoaExecutor`), then asserted against the pool's on-chain getters. +contract ConfigureActionsForkTest is BaseForkTest { + uint64 internal constant SELECTOR = 8236463271206331221; // Mantle Sepolia + address internal constant REMOTE_POOL = address(0x1111111111111111111111111111111111111111); + address internal constant REMOTE_TOKEN = address(0x2222222222222222222222222222222222222222); + + address internal token; + address internal pool; + address internal owner; + + function setUp() public override { + super.setUp(); + (token, pool) = deployTokenAndPoolFixture(); + owner = _scriptBroadcaster(); + _addLane(owner, pool, SELECTOR, REMOTE_POOL, REMOTE_TOKEN); + } + + // ── Lane bootstrap ──────────────────────────────────────────────────────── + + function _addLane(address asOwner, address p, uint64 selector, address remotePool, address remoteToken) internal { + bytes[] memory remotePools = new bytes[](1); + remotePools[0] = abi.encode(remotePool); + TokenPool.ChainUpdate[] memory updates = new TokenPool.ChainUpdate[](1); + updates[0] = TokenPool.ChainUpdate({ + remoteChainSelector: selector, + remotePoolAddresses: remotePools, + remoteTokenAddress: abi.encode(remoteToken), + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + _exec(asOwner, CctActions.applyChainUpdates(p, new uint64[](0), updates)); + } + + function _cfg(bool enabled, uint128 capacity, uint128 rate) internal pure returns (RateLimiter.Config memory) { + return RateLimiter.Config({isEnabled: enabled, capacity: capacity, rate: rate}); + } + + // ───────────────────────────────────────────────────────────────────────── + // Rate limits — standard and fast-finality buckets, asserted via getters + // ───────────────────────────────────────────────────────────────────────── + + function test_RateLimits_StandardBucket_ViaGetter() public { + RateLimiter.Config memory out = _cfg(true, 1_000e18, 10e18); + RateLimiter.Config memory inb = _cfg(true, 2_000e18, 20e18); + + _exec(owner, CctActions.setRateLimits(pool, true, SELECTOR, false, out, inb)); + + (RateLimiter.TokenBucket memory o, RateLimiter.TokenBucket memory i) = + TokenPool(pool).getCurrentRateLimiterState(SELECTOR, false); + assertTrue(o.isEnabled, "outbound enabled"); + assertEq(o.capacity, 1_000e18, "outbound capacity"); + assertEq(o.rate, 10e18, "outbound rate"); + assertTrue(i.isEnabled, "inbound enabled"); + assertEq(i.capacity, 2_000e18, "inbound capacity"); + assertEq(i.rate, 20e18, "inbound rate"); + } + + function test_RateLimits_FastFinalityBucket_ViaGetter() public { + RateLimiter.Config memory out = _cfg(true, 500e18, 5e18); + RateLimiter.Config memory inb = _cfg(false, 0, 0); + + _exec(owner, CctActions.setRateLimits(pool, true, SELECTOR, true, out, inb)); + + (RateLimiter.TokenBucket memory o, RateLimiter.TokenBucket memory i) = + TokenPool(pool).getCurrentRateLimiterState(SELECTOR, true); + assertTrue(o.isEnabled, "fast outbound enabled"); + assertEq(o.capacity, 500e18, "fast outbound capacity"); + assertFalse(i.isEnabled, "fast inbound disabled"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Version-detected dispatch — 1.6.x -> v1 setter, 2.0.0 -> v2 setter + // ───────────────────────────────────────────────────────────────────────── + + function test_VersionDetection_ProbeRoutesByCapability() public { + MockV1RateLimiterPool v1 = new MockV1RateLimiterPool(); + assertFalse(RateLimiterUtils.isV2Pool(TokenPool(address(v1)), SELECTOR, false), "v1 surface detects as v1"); + assertTrue(RateLimiterUtils.isV2Pool(TokenPool(pool), SELECTOR, false), "2.0.0 pool detects as v2"); + } + + function test_VersionDispatch_V1Calldata() public { + MockV1RateLimiterPool v1 = new MockV1RateLimiterPool(); + RateLimiter.Config memory out = _cfg(true, 2, 1); + RateLimiter.Config memory inb = _cfg(true, 100_000e18, 100e18); + + CctActions.Call[] memory calls = CctActions.setRateLimits(address(v1), false, SELECTOR, false, out, inb); + assertEq(calls.length, 1, "v1 dispatch is one call"); + assertEq(calls[0].target, address(v1), "targets the v1 pool"); + assertEq( + calls[0].data, + abi.encodeCall(IRateLimiterV1.setChainRateLimiterConfig, (SELECTOR, out, inb)), + "v1 setter calldata" + ); + assertEq(bytes4(calls[0].data), IRateLimiterV1.setChainRateLimiterConfig.selector, "v1 selector"); + } + + function test_VersionDispatch_V2Calldata() public view { + RateLimiter.Config memory out = _cfg(true, 2, 1); + RateLimiter.Config memory inb = _cfg(true, 100_000e18, 100e18); + + TokenPool.RateLimitConfigArgs[] memory expected = new TokenPool.RateLimitConfigArgs[](1); + expected[0] = TokenPool.RateLimitConfigArgs({ + remoteChainSelector: SELECTOR, + fastFinality: false, + outboundRateLimiterConfig: out, + inboundRateLimiterConfig: inb + }); + + CctActions.Call[] memory calls = CctActions.setRateLimits(pool, true, SELECTOR, false, out, inb); + assertEq(calls[0].target, pool, "targets the v2 pool"); + assertEq(calls[0].data, abi.encodeCall(TokenPool.setRateLimitConfig, (expected)), "v2 setter calldata"); + } + + function test_VersionDispatch_V1SetterOnV2PoolReverts() public { + RateLimiter.Config memory out = _cfg(true, 1e18, 1e18); + // Force the v1 calldata (isV2=false) but aim it at the real 2.0.0 pool: the selector does not exist. + CctActions.Call[] memory calls = CctActions.setRateLimits(pool, false, SELECTOR, false, out, out); + vm.prank(owner); + (bool ok,) = calls[0].target.call(calls[0].data); + assertFalse(ok, "v1 setter must not exist on a 2.0.0 pool"); + } + + function test_VersionDispatch_V1ForkExecution() public { + MockV1RateLimiterPool v1 = new MockV1RateLimiterPool(); // deployed by this test => test is owner + RateLimiter.Config memory out = _cfg(true, 2, 1); + RateLimiter.Config memory inb = _cfg(true, 100_000e18, 100e18); + + _exec(address(this), CctActions.setRateLimits(address(v1), false, SELECTOR, false, out, inb)); + + RateLimiter.TokenBucket memory o = v1.getCurrentOutboundRateLimiterState(SELECTOR); + assertTrue(o.isEnabled, "v1 outbound enabled"); + assertEq(o.capacity, 2, "v1 outbound capacity"); + assertEq(o.rate, 1, "v1 outbound rate"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Remote pools — getRemotePools + // ───────────────────────────────────────────────────────────────────────── + + function test_AddRemotePool_ViaGetter() public { + address extraPool = address(0x3333333333333333333333333333333333333333); + uint256 before = TokenPool(pool).getRemotePools(SELECTOR).length; + + _exec(owner, CctActions.addRemotePool(pool, SELECTOR, abi.encode(extraPool))); + + bytes[] memory pools = TokenPool(pool).getRemotePools(SELECTOR); + assertEq(pools.length, before + 1, "remote pool count grew"); + assertTrue(TokenPool(pool).isRemotePool(SELECTOR, abi.encode(extraPool)), "new remote pool registered"); + + _exec(owner, CctActions.removeRemotePool(pool, SELECTOR, abi.encode(extraPool))); + assertFalse(TokenPool(pool).isRemotePool(SELECTOR, abi.encode(extraPool)), "remote pool removed"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Dynamic config — getDynamicConfig + // ───────────────────────────────────────────────────────────────────────── + + function test_SetDynamicConfig_ViaGetter() public { + address rla = address(0xA11CE); + address fa = address(0xBEEF); + _exec(owner, CctActions.setDynamicConfig(pool, networkConfig.router, rla, fa)); + + (address router, address rateLimitAdmin, address feeAdmin) = TokenPool(pool).getDynamicConfig(); + assertEq(router, networkConfig.router, "router set"); + assertEq(rateLimitAdmin, rla, "rate limit admin set"); + assertEq(feeAdmin, fa, "fee admin set"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Finality matrix — getAllowedFinalityConfig (four modes) + // ───────────────────────────────────────────────────────────────────────── + + function test_Finality_ModeBlockDepth() public { + _exec(owner, CctActions.setAllowedFinalityConfig(pool, FinalityCodec._encodeBlockDepth(5))); + assertEq(TokenPool(pool).getAllowedFinalityConfig(), bytes4(0x00000005), "block-depth 5"); + } + + function test_Finality_ModeWaitForSafe() public { + _exec(owner, CctActions.setAllowedFinalityConfig(pool, FinalityCodec.WAIT_FOR_SAFE_FLAG)); + assertEq(TokenPool(pool).getAllowedFinalityConfig(), bytes4(0x00010000), "wait-for-safe"); + } + + function test_Finality_ModeCombined() public { + _exec(owner, CctActions.setAllowedFinalityConfig(pool, FinalityCodec._encodeBlockDepthAndSafeFlag(5))); + assertEq(TokenPool(pool).getAllowedFinalityConfig(), bytes4(0x00010005), "combined depth|safe"); + } + + function test_Finality_ModeResetToDefault() public { + _exec(owner, CctActions.setAllowedFinalityConfig(pool, FinalityCodec._encodeBlockDepthAndSafeFlag(5))); + _exec(owner, CctActions.setAllowedFinalityConfig(pool, FinalityCodec.WAIT_FOR_FINALITY_FLAG)); + assertEq(TokenPool(pool).getAllowedFinalityConfig(), bytes4(0x00000000), "reset to default"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Fee config — getTokenTransferFeeConfig (the getter the script uses) + // ───────────────────────────────────────────────────────────────────────── + + function test_FeeConfig_EnableThenDisable_ViaGetter() public { + TokenPool.TokenTransferFeeConfigArgs[] memory args = new TokenPool.TokenTransferFeeConfigArgs[](1); + args[0] = TokenPool.TokenTransferFeeConfigArgs({ + destChainSelector: SELECTOR, + tokenTransferFeeConfig: IPoolV2.TokenTransferFeeConfig({ + destGasOverhead: 50_000, + destBytesOverhead: 0, + finalityFeeUSDCents: 0, + fastFinalityFeeUSDCents: 100, + finalityTransferFeeBps: 0, + fastFinalityTransferFeeBps: 50, + isEnabled: true + }) + }); + _exec(owner, CctActions.applyTokenTransferFeeConfigUpdates(pool, args, new uint64[](0))); + + IPoolV2.TokenTransferFeeConfig memory cfg = + TokenPool(pool).getTokenTransferFeeConfig(address(0), SELECTOR, 0, ""); + assertTrue(cfg.isEnabled, "fee config enabled"); + assertEq(cfg.destGasOverhead, 50_000, "gas overhead"); + assertEq(cfg.fastFinalityFeeUSDCents, 100, "fast finality fee"); + assertEq(cfg.fastFinalityTransferFeeBps, 50, "fast finality bps"); + + uint64[] memory disable = new uint64[](1); + disable[0] = SELECTOR; + _exec( + owner, + CctActions.applyTokenTransferFeeConfigUpdates(pool, new TokenPool.TokenTransferFeeConfigArgs[](0), disable) + ); + + cfg = TokenPool(pool).getTokenTransferFeeConfig(address(0), SELECTOR, 0, ""); + assertFalse(cfg.isEnabled, "fee config disabled"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Fast-finality fallback (behavioural): fast bucket disabled + default enabled -> + // a fast-finality transfer consumes the DEFAULT bucket (TokenPool.sol + // _consumeFastFinalityOutboundRateLimit). Leaving the fast bucket unconfigured is safe. + // ───────────────────────────────────────────────────────────────────────── + + function test_FastFinalityFallback_ConsumesDefaultBucket() public { + FastFinalityProbePool probe = + new FastFinalityProbePool(IBurnMintERC20(token), 18, networkConfig.rmnProxy, networkConfig.router); + // This test contract is the probe pool's owner. + _addLane(address(this), address(probe), SELECTOR, REMOTE_POOL, REMOTE_TOKEN); + + // Default (standard) outbound bucket enabled; fast-finality bucket left UNCONFIGURED (disabled). + _exec( + address(this), + CctActions.setRateLimits( + address(probe), true, SELECTOR, false, _cfg(true, 1_000e18, 10e18), _cfg(false, 0, 0) + ) + ); + + (RateLimiter.TokenBucket memory defBefore,) = probe.getCurrentRateLimiterState(SELECTOR, false); + (RateLimiter.TokenBucket memory fastBefore,) = probe.getCurrentRateLimiterState(SELECTOR, true); + assertTrue(defBefore.isEnabled, "default bucket enabled"); + assertFalse(fastBefore.isEnabled, "fast bucket unconfigured (disabled)"); + + uint256 amount = 100e18; + probe.probeFastOutbound(SELECTOR, amount); + + (RateLimiter.TokenBucket memory defAfter,) = probe.getCurrentRateLimiterState(SELECTOR, false); + (RateLimiter.TokenBucket memory fastAfter,) = probe.getCurrentRateLimiterState(SELECTOR, true); + // Same block => no refill: the fast-finality consume fell back to the DEFAULT bucket. + assertEq(defBefore.tokens - defAfter.tokens, amount, "default bucket consumed by the fast transfer"); + assertFalse(fastAfter.isEnabled, "fast bucket stayed unconfigured (no bypass)"); + assertEq(fastAfter.tokens, 0, "unconfigured fast bucket never held tokens"); + } +} diff --git a/test/actions/HooksActions.t.sol b/test/actions/HooksActions.t.sol new file mode 100644 index 0000000..835a4af --- /dev/null +++ b/test/actions/HooksActions.t.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {AdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/pools/AdvancedPoolHooks.sol"; +import {ERC20LockBox} from "@chainlink/contracts-ccip/contracts/pools/ERC20LockBox.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {DeployAdvancedPoolHooks} from "../../script/configure/allowlist/DeployAdvancedPoolHooks.s.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; + +/// @notice Fork parity tests for the PR 1.2 rollout of the `allowlist/` and `authorized-callers/` write +/// scripts. Hooks are deployed from the repo's own `DeployAdvancedPoolHooks` script (driven by +/// `script/input/advanced-pool-hooks.json`, with the allowlist overridden via the `ALLOWLIST` env var); +/// allowlist and authorized-caller state changes are exercised through the `CctActions` builders and +/// asserted via `getAllowList` / `checkAllowList` / `getAllAuthorizedCallers`. +contract HooksActionsForkTest is BaseForkTest { + // A FIXED allowlist value shared by every test that runs the deploy script. `vm.setEnv` is process-wide, + // so keeping the value identical across suites makes the deploy deterministic under parallel runs. + address internal constant ALLOWED = address(0x00000000000000000000000000000000000000A1); + + address internal token; + address internal pool; + address internal owner; + + function setUp() public override { + super.setUp(); + (token, pool) = deployTokenAndPoolFixture(); + owner = _scriptBroadcaster(); + } + + /// @dev Runs the repo's DeployAdvancedPoolHooks script with a non-empty allowlist (ALLOWLIST env + /// override) and returns the deployed hooks address, recovered from the broadcaster's CREATE + /// nonce (the deployment file the script writes is racy under parallel suites). + function _deployHooksWithAllowlist(address allowed) internal returns (AdvancedPoolHooks hooks) { + vm.setEnv("ALLOWLIST", vm.toString(allowed)); + uint256 nonceBefore = vm.getNonce(owner); + new DeployAdvancedPoolHooks().run(); + hooks = AdvancedPoolHooks(vm.computeCreateAddress(owner, nonceBefore)); + assertGt(address(hooks).code.length, 0, "hooks not deployed at computed address"); + } + + function _isAllowListed(AdvancedPoolHooks hooks, address who) internal view returns (bool) { + try hooks.checkAllowList(who) { + return true; + } catch { + return false; + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Allowlist: deploy with a non-empty allowlist, connect to the pool, assert getters + // ───────────────────────────────────────────────────────────────────────── + + function test_Hooks_DeployConnectAndAllowList() public { + AdvancedPoolHooks hooks = _deployHooksWithAllowlist(ALLOWED); + + // Deployed with a non-empty allowlist => allowlisting is enabled and ALLOWED is on the list. + assertTrue(hooks.getAllowListEnabled(), "allowlist enabled"); + address[] memory list = hooks.getAllowList(); + assertEq(list.length, 1, "one allowlisted address"); + assertEq(list[0], ALLOWED, "ALLOWED is on the list"); + assertTrue(_isAllowListed(hooks, ALLOWED), "ALLOWED passes checkAllowList"); + assertFalse(_isAllowListed(hooks, address(0xDEAD)), "a stranger fails checkAllowList"); + + // Connect the hooks to the fixture pool through the action layer. + _exec(owner, CctActions.updateAdvancedPoolHooks(pool, address(hooks))); + + // Add + remove an allowlisted address through the action layer. + address newAllowed = address(0xB2); + address[] memory adds = new address[](1); + adds[0] = newAllowed; + _exec(owner, CctActions.applyAllowListUpdates(address(hooks), new address[](0), adds)); + assertTrue(_isAllowListed(hooks, newAllowed), "newAllowed added"); + assertEq(hooks.getAllowList().length, 2, "list grew to two"); + + address[] memory removes = new address[](1); + removes[0] = newAllowed; + _exec(owner, CctActions.applyAllowListUpdates(address(hooks), removes, new address[](0))); + assertFalse(_isAllowListed(hooks, newAllowed), "newAllowed removed"); + assertEq(hooks.getAllowList().length, 1, "list back to one"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Immutability trap: allowlistEnabled is fixed at deploy from whether the initial allowlist is empty. + // Enabling allowlisting later needs a NEW hooks contract — applyAllowListUpdates reverts on a + // hooks deployed with an empty allowlist (AllowListNotEnabled). + // ───────────────────────────────────────────────────────────────────────── + + function test_Hooks_ImmutabilityTrap_EmptyAllowlistCannotEnableLater() public { + // Empty allowlist at construction => allowlisting is permanently disabled on this instance. + AdvancedPoolHooks emptyHooks = new AdvancedPoolHooks(new address[](0), 0, address(0), new address[](0)); + assertFalse(emptyHooks.getAllowListEnabled(), "empty-allowlist hooks: allowlisting disabled"); + + address[] memory adds = new address[](1); + adds[0] = ALLOWED; + // Trying to add later reverts — the only fix is to deploy a NEW hooks contract with a non-empty list. + vm.prank(emptyHooks.owner()); + (bool ok, bytes memory ret) = + address(emptyHooks).call(abi.encodeCall(AdvancedPoolHooks.applyAllowListUpdates, (new address[](0), adds))); + assertFalse(ok, "cannot enable allowlisting after an empty deploy"); + assertEq(bytes4(ret), AdvancedPoolHooks.AllowListNotEnabled.selector, "AllowListNotEnabled"); + + // A fresh hooks deployed WITH a non-empty allowlist is enabled — the trap's escape hatch. + AdvancedPoolHooks enabledHooks = _deployHooksWithAllowlist(ALLOWED); + assertTrue(enabledHooks.getAllowListEnabled(), "new hooks with non-empty allowlist: enabled"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Authorized callers (ERC20LockBox variant): add then remove, asserted via getAllAuthorizedCallers + // ───────────────────────────────────────────────────────────────────────── + + function test_LockBox_AuthorizedCallerUpdates() public { + ERC20LockBox lockBox = new ERC20LockBox(token); // deployed by this test => owner is this contract + assertEq(lockBox.getAllAuthorizedCallers().length, 0, "no callers initially"); + + address caller = address(0xCA11E4); + address[] memory adds = new address[](1); + adds[0] = caller; + _exec(address(this), CctActions.applyAuthorizedCallerUpdates(address(lockBox), adds, new address[](0))); + + address[] memory after1 = lockBox.getAllAuthorizedCallers(); + assertEq(after1.length, 1, "caller added"); + assertEq(after1[0], caller, "caller is the one added"); + + address[] memory removes = new address[](1); + removes[0] = caller; + _exec(address(this), CctActions.applyAuthorizedCallerUpdates(address(lockBox), new address[](0), removes)); + assertEq(lockBox.getAllAuthorizedCallers().length, 0, "caller removed"); + } +} diff --git a/test/actions/LockboxOps.t.sol b/test/actions/LockboxOps.t.sol new file mode 100644 index 0000000..d548b0a --- /dev/null +++ b/test/actions/LockboxOps.t.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {LockReleaseTokenPool} from "@chainlink/contracts-ccip/contracts/pools/LockReleaseTokenPool.sol"; +import {ERC20LockBox} from "@chainlink/contracts-ccip/contracts/pools/ERC20LockBox.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; +import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {DeployERC20LockBox} from "../../script/deploy/DeployERC20LockBox.s.sol"; +import {DeployLockReleaseTokenPool} from "../../script/deploy/DeployLockReleaseTokenPool.s.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; + +/// @notice LockRelease + ERC20LockBox fork fixture for the PR 1.2 rollout. The fixture is built with the +/// repo's OWN deploy scripts in the README order — lockbox first, then the LockReleaseTokenPool, then the +/// pool is authorized on the lockbox via the authorized-callers action. It proves the invariant that a +/// lockbox deposit/withdraw succeeds ONLY after the caller is authorized, and exercises both README +/// LockRelease topologies (single-lock-chain and lock-and-lock) at the shared local lock/release layer. +contract LockboxOpsForkTest is BaseForkTest { + // Selectors for the two README LockRelease patterns (remote-lane wiring differs; the LOCAL lock/release + // mechanics through the lockbox are identical, which is exactly what this fixture exercises). + uint64 internal constant SINGLE_LOCK_CHAIN_SELECTOR = 8236463271206331221; // Mantle Sepolia (remote burns/mints) + uint64 internal constant LOCK_AND_LOCK_SELECTOR = 16015286601757825753; // Ethereum Sepolia selector (remote also locks) + + address internal token; + address internal owner; + ERC20LockBox internal lockBox; + LockReleaseTokenPool internal pool; + + uint256 internal constant AMOUNT = 100e18; + + function setUp() public override { + super.setUp(); + token = deployTokenFixture(); + owner = _scriptBroadcaster(); + vm.setEnv("TOKEN", vm.toString(token)); + + // README order, step 1: deploy the lockbox (no authorized callers yet — the gate is proven below). + uint256 nonceBox = vm.getNonce(owner); + new DeployERC20LockBox().run(); + lockBox = ERC20LockBox(vm.computeCreateAddress(owner, nonceBox)); + assertGt(address(lockBox).code.length, 0, "lockbox not deployed"); + + // README order, step 2: deploy the LockReleaseTokenPool pointed at the lockbox. + vm.setEnv("LOCK_BOX", vm.toString(address(lockBox))); + uint256 noncePool = vm.getNonce(owner); + new DeployLockReleaseTokenPool().run(); + pool = LockReleaseTokenPool(vm.computeCreateAddress(owner, noncePool)); + assertGt(address(pool).code.length, 0, "pool not deployed"); + } + + // ── Fixture wiring (README order) ─────────────────────────────────────────── + + function test_Fixture_BuiltInReadmeOrder() public view { + assertEq(pool.getLockBox(), address(lockBox), "pool -> lockbox wired at deploy"); + assertEq(address(lockBox.getToken()), token, "lockbox holds the fixture token"); + assertTrue(lockBox.isTokenSupported(token), "token supported by lockbox"); + // Step 3 (authorize the pool) has NOT run yet: the lockbox has no authorized callers. + assertEq(lockBox.getAllAuthorizedCallers().length, 0, "no authorized callers before step 3"); + } + + // ── README order, step 3: authorize the pool on the lockbox ───────────────── + + function test_AuthorizePoolOnLockBox() public { + address[] memory adds = new address[](1); + adds[0] = address(pool); + _exec(owner, CctActions.applyAuthorizedCallerUpdates(address(lockBox), adds, new address[](0))); + + address[] memory callers = lockBox.getAllAuthorizedCallers(); + assertEq(callers.length, 1, "pool authorized"); + assertEq(callers[0], address(pool), "the pool is the authorized caller"); + } + + // ── The invariant: deposit/withdraw work ONLY after authorization ─────────── + + function test_Deposit_RevertsBeforeAuthorization() public { + address operator = makeAddr("operatorUnauth"); + deal(token, operator, AMOUNT); + CctActions.Call[] memory dep = CctActions.lockboxDeposit(address(lockBox), token, AMOUNT); + // approve (call 0) succeeds; the deposit (call 1) must revert — unauthorized caller. + vm.prank(operator); + (bool okApprove,) = dep[0].target.call(dep[0].data); + assertTrue(okApprove, "approve ok"); + vm.prank(operator); + (bool okDeposit, bytes memory ret) = dep[1].target.call(dep[1].data); + assertFalse(okDeposit, "deposit must revert before authorization"); + assertEq(bytes4(ret), bytes4(keccak256("UnauthorizedCaller(address)")), "UnauthorizedCaller"); + } + + /// @dev Pattern 1 — single-lock-chain: this chain LOCKs (deposit) and RELEASEs (withdraw); the remote + /// chain burns/mints. An authorized operator round-trips liquidity through the lockbox. + function test_Pattern_SingleLockChain_DepositWithdrawRoundTrip() public { + _wireLane(SINGLE_LOCK_CHAIN_SELECTOR, address(0xB0740B), address(0x70CE0A)); + _authorizeAndRoundTrip(); + } + + /// @dev Pattern 2 — lock-and-lock: BOTH chains lock/release; locally the mechanics are the same + /// deposit(lock)/withdraw(release) through the lockbox, which is what we exercise here. + function test_Pattern_LockAndLock_DepositWithdrawRoundTrip() public { + _wireLane(LOCK_AND_LOCK_SELECTOR, address(0x10C11A), address(0x70CE1B)); + _authorizeAndRoundTrip(); + } + + // ── Helpers ───────────────────────────────────────────────────────────────── + + function _wireLane(uint64 selector, address remotePool, address remoteToken) internal { + bytes[] memory remotePools = new bytes[](1); + remotePools[0] = abi.encode(remotePool); + TokenPool.ChainUpdate[] memory updates = new TokenPool.ChainUpdate[](1); + updates[0] = TokenPool.ChainUpdate({ + remoteChainSelector: selector, + remotePoolAddresses: remotePools, + remoteTokenAddress: abi.encode(remoteToken), + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + _exec(owner, CctActions.applyChainUpdates(address(pool), new uint64[](0), updates)); + assertTrue(pool.isSupportedChain(selector), "lane wired"); + } + + /// @dev Authorize an operator (the authorize step deposit/withdraw depend on), then lock (deposit) and + /// release (withdraw), asserting the lockbox balance is conserved across the round-trip. + function _authorizeAndRoundTrip() internal { + address operator = address(0x09E7A704); + deal(token, operator, AMOUNT); + + address[] memory adds = new address[](1); + adds[0] = operator; + _exec(owner, CctActions.applyAuthorizedCallerUpdates(address(lockBox), adds, new address[](0))); + + uint256 boxBefore = IERC20(token).balanceOf(address(lockBox)); + + // LOCK: approve + deposit as one batch. + CctActions.Call[] memory dep = CctActions.lockboxDeposit(address(lockBox), token, AMOUNT); + assertEq(dep.length, 2, "deposit batch = approve + deposit"); + _exec(operator, dep); + assertEq(IERC20(token).balanceOf(address(lockBox)), boxBefore + AMOUNT, "lock landed in lockbox"); + assertEq(IERC20(token).balanceOf(operator), 0, "operator liquidity locked"); + + // RELEASE: withdraw back to the operator. + _exec(operator, CctActions.lockboxWithdraw(address(lockBox), token, AMOUNT, operator)); + assertEq(IERC20(token).balanceOf(address(lockBox)), boxBefore, "lockbox balance conserved after release"); + assertEq(IERC20(token).balanceOf(operator), AMOUNT, "release returned liquidity"); + } +} diff --git a/test/actions/OperationsActions.t.sol b/test/actions/OperationsActions.t.sol new file mode 100644 index 0000000..e6f2027 --- /dev/null +++ b/test/actions/OperationsActions.t.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; + +/// @notice Fork parity tests for the PR 1.2 rollout of the `operations/` write scripts (mint, fee-token +/// withdrawal). Deposit/withdraw round-trips live in `LockboxOps.t.sol`. Each op is exercised through its +/// `CctActions` builder and asserted via balance deltas. +contract OperationsActionsForkTest is BaseForkTest { + address internal token; + address internal pool; + address internal owner; + + function setUp() public override { + super.setUp(); + (token, pool) = deployTokenAndPoolFixture(); + owner = _scriptBroadcaster(); + } + + // ── Mint: balanceOf delta ────────────────────────────────────────────────── + + function test_Mint_ChangesBalanceOf() public { + address receiver = address(0xBEEF); + uint256 amount = 1_234e18; + uint256 before = IERC20(token).balanceOf(receiver); + + _exec(owner, CctActions.mint(token, receiver, amount)); + + assertEq(IERC20(token).balanceOf(receiver) - before, amount, "mint credited the receiver"); + } + + // ── Fee-token withdrawal: drains the pool's fee-token balance to the recipient ── + + function test_WithdrawFeeTokens_DrainsPoolToRecipient() public { + // Mint fee tokens straight onto the pool to simulate accrued fees, then sweep them. + uint256 fees = 500e18; + _exec(owner, CctActions.mint(token, pool, fees)); + assertEq(IERC20(token).balanceOf(pool), fees, "pool holds accrued fees"); + + address recipient = address(0xF00D); + uint256 recipientBefore = IERC20(token).balanceOf(recipient); + + address[] memory feeTokens = new address[](1); + feeTokens[0] = token; + _exec(owner, CctActions.withdrawFeeTokens(pool, feeTokens, recipient)); + + assertEq(IERC20(token).balanceOf(pool), 0, "pool fee balance drained"); + assertEq(IERC20(token).balanceOf(recipient) - recipientBefore, fees, "recipient received the fees"); + } + + // ── Gate: only the owner/feeAdmin can withdraw fees ────────────────────────── + + function test_WithdrawFeeTokens_GatedToOwner() public { + _exec(owner, CctActions.mint(token, pool, 1e18)); + address[] memory feeTokens = new address[](1); + feeTokens[0] = token; + CctActions.Call[] memory calls = CctActions.withdrawFeeTokens(pool, feeTokens, address(0xF00D)); + vm.prank(address(0xBAD)); + (bool ok,) = calls[0].target.call(calls[0].data); + assertFalse(ok, "a stranger cannot withdraw fee tokens"); + } +}