// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/access/AccessControl.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "./TreasuryVault.sol"; import "./CcipBridgeAdapter138.sol"; /** * @title StrategyExecutor138 * @notice Single "brain" that can request moves from TreasuryVault and initiate export via CcipBridgeAdapter138. * @dev Token allowlist = canonical 138 list; no calldata-provided token/receiver for export. See docs/treasury/EXECUTOR_ALLOWLIST_MATRIX.md. */ contract StrategyExecutor138 is AccessControl, ReentrancyGuard { using SafeERC20 for IERC20; /// @notice Export trigger mode: daily sweep, threshold-based, or hybrid. enum ExportMode { Daily, Threshold, Hybrid } /// @notice Policy for export (caps enforced by TreasuryVault; mode/minExportUsd for bot logic). struct ExportPolicy { ExportMode mode; uint256 minExportUsd; uint256 maxPerTxUsd; uint256 maxDailyUsd; uint256 rateLimitPerHour; uint256 cooldownBlocks; address exportAsset; uint64 destinationSelector; address destinationReceiver; } bytes32 public constant KEEPER_ROLE = keccak256("KEEPER_ROLE"); TreasuryVault public immutable vault; CcipBridgeAdapter138 public immutable ccipAdapter; mapping(address => bool) public allowedTokens; mapping(address => bool) public allowedRoutersOrLp; uint256 public cooldownBlocks; uint256 public lastExportBlock; ExportPolicy public exportPolicy; uint256 public pendingIntentAmount; address public pendingIntentToken; event ExportToMainnetRequested(uint256 amount, uint256 deadline); event CooldownBlocksSet(uint256 blocks); event ExportPolicySet(ExportMode mode, uint256 minExportUsd); event ExportIntentRecorded(address indexed token, uint256 amount); event PendingIntentProcessed(uint256 amount); error TokenNotApproved(); error RouterNotApproved(); error CooldownNotElapsed(); error ZeroAddress(); error NoPendingIntent(); error ExportsNotEnabled(); error NotImplemented(); constructor( address _vault, address _ccipAdapter, address admin ) { if (_vault == address(0) || _ccipAdapter == address(0)) revert ZeroAddress(); vault = TreasuryVault(payable(_vault)); ccipAdapter = CcipBridgeAdapter138(payable(_ccipAdapter)); _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(KEEPER_ROLE, admin); } function setToken(address token, bool approved) external onlyRole(DEFAULT_ADMIN_ROLE) { if (token == address(0)) revert ZeroAddress(); allowedTokens[token] = approved; } function setRouterOrLp(address routerOrLp, bool approved) external onlyRole(DEFAULT_ADMIN_ROLE) { if (routerOrLp == address(0)) revert ZeroAddress(); allowedRoutersOrLp[routerOrLp] = approved; } function setCooldownBlocks(uint256 blocks) external onlyRole(DEFAULT_ADMIN_ROLE) { cooldownBlocks = blocks; emit CooldownBlocksSet(blocks); } function setExportPolicy(ExportPolicy calldata policy) external onlyRole(DEFAULT_ADMIN_ROLE) { exportPolicy = policy; if (policy.cooldownBlocks != cooldownBlocks) { cooldownBlocks = policy.cooldownBlocks; emit CooldownBlocksSet(policy.cooldownBlocks); } emit ExportPolicySet(policy.mode, policy.minExportUsd); } /** * @notice Record intent to export when CCIP is not yet live. Process later with processPendingIntent(). * Only one pending intent (overwrites previous). Call when ccipAdapter.exportsEnabled() is false. */ function recordExportIntent(address token, uint256 amount) external onlyRole(KEEPER_ROLE) { if (ccipAdapter.exportsEnabled()) revert ExportsNotEnabled(); if (token == address(0)) revert ZeroAddress(); if (!allowedTokens[token]) revert TokenNotApproved(); pendingIntentToken = token; pendingIntentAmount = amount; emit ExportIntentRecorded(token, amount); } /** * @notice Process the pending export intent once CCIP exports are enabled. */ function processPendingIntent(uint256 deadline) external payable nonReentrant onlyRole(KEEPER_ROLE) { if (!ccipAdapter.exportsEnabled()) revert ExportsNotEnabled(); if (pendingIntentToken == address(0) || pendingIntentAmount == 0) revert NoPendingIntent(); address token = pendingIntentToken; uint256 amount = pendingIntentAmount; pendingIntentToken = address(0); pendingIntentAmount = 0; if (block.timestamp > deadline) revert(); if (cooldownBlocks != 0 && block.number < lastExportBlock + cooldownBlocks) revert CooldownNotElapsed(); lastExportBlock = block.number; vault.requestTransfer(token, amount, address(this)); IERC20(token).approve(address(ccipAdapter), amount); ccipAdapter.sendWeth9ToMainnet{value: msg.value}(amount, 0, deadline); emit ExportToMainnetRequested(amount, deadline); emit PendingIntentProcessed(amount); } /** * @notice Harvest fees from LP/router. Stub for bot integration; implement when LP contracts are wired. */ function harvestFees() external view onlyRole(KEEPER_ROLE) { revert NotImplemented(); } /** * @notice Rebalance LP positions. Stub for bot integration; implement when LP contracts are wired. */ function rebalanceLp() external view onlyRole(KEEPER_ROLE) { revert NotImplemented(); } /** * @notice Request WETH9 from vault and send to mainnet via CCIP. Only allowed WETH9; no calldata destinations. * @param weth9Amount Amount of WETH9 to export. * @param deadline Revert if block.timestamp > deadline. */ function exportToMainnet(address weth9Token, uint256 weth9Amount, uint256 deadline) external payable nonReentrant onlyRole(KEEPER_ROLE) { if (weth9Token == address(0)) revert ZeroAddress(); if (!allowedTokens[weth9Token]) revert TokenNotApproved(); if (block.timestamp > deadline) revert(); if (cooldownBlocks != 0 && block.number < lastExportBlock + cooldownBlocks) revert CooldownNotElapsed(); lastExportBlock = block.number; vault.requestTransfer(weth9Token, weth9Amount, address(this)); IERC20(weth9Token).approve(address(ccipAdapter), weth9Amount); ccipAdapter.sendWeth9ToMainnet{value: msg.value}(weth9Amount, 0, deadline); emit ExportToMainnetRequested(weth9Amount, deadline); } }