Files
smom-dbis-138/contracts/treasury/StrategyExecutor138.sol
2026-03-02 12:14:09 -08:00

182 lines
6.7 KiB
Solidity

// 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);
}
}