// 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"; /** * @title TreasuryVault * @notice Asset custody for the export path; only approved modules can move funds; only approved tokens can leave. * @dev Enforces per-tx cap, daily cap, and hourly rate limit. See docs/treasury/EXECUTOR_ALLOWLIST_MATRIX.md. */ contract TreasuryVault is AccessControl, ReentrancyGuard { using SafeERC20 for IERC20; bytes32 public constant MODULE_ROLE = keccak256("MODULE_ROLE"); bytes32 public constant CAP_MANAGER_ROLE = keccak256("CAP_MANAGER_ROLE"); uint256 public maxPerTx; uint256 public dailyCap; uint256 public rateLimitPerHour; uint256 public dailyUsed; uint256 public dayStartTimestamp; uint256 public hourlyCount; uint256 public hourStartTimestamp; mapping(address => bool) public allowedTokens; event TransferRequested(address indexed token, address indexed toModule, uint256 amount); event ModuleSet(address indexed module, bool approved); event TokenSet(address indexed token, bool approved); event CapsUpdated(uint256 maxPerTx, uint256 dailyCap, uint256 rateLimitPerHour); error NotModule(); error TokenNotApproved(); error ExceedsPerTxCap(); error ExceedsDailyCap(); error ExceedsRateLimit(); error ZeroAddress(); error ZeroAmount(); constructor(address admin) { _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(CAP_MANAGER_ROLE, admin); dayStartTimestamp = block.timestamp; hourStartTimestamp = block.timestamp; } /** * @notice Request transfer of tokens to an approved module. * @param token Approved token address (canonical 138 list only). * @param amount Amount to transfer. * @param toModule Recipient module; must be an approved module. */ function requestTransfer(address token, uint256 amount, address toModule) external nonReentrant onlyRole(MODULE_ROLE) { if (token == address(0) || toModule == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); if (!hasRole(MODULE_ROLE, toModule)) revert NotModule(); if (!allowedTokens[token]) revert TokenNotApproved(); if (maxPerTx != 0 && amount > maxPerTx) revert ExceedsPerTxCap(); _updateDailyHourly(amount); if (dailyCap != 0 && dailyUsed + amount > dailyCap) revert ExceedsDailyCap(); if (rateLimitPerHour != 0 && hourlyCount > rateLimitPerHour) revert ExceedsRateLimit(); dailyUsed += amount; IERC20(token).safeTransfer(toModule, amount); emit TransferRequested(token, toModule, amount); } function _updateDailyHourly(uint256) internal { uint256 now_ = block.timestamp; if (now_ - dayStartTimestamp >= 1 days) { dayStartTimestamp = now_; dailyUsed = 0; } if (now_ - hourStartTimestamp >= 1 hours) { hourStartTimestamp = now_; hourlyCount = 0; } hourlyCount++; } function setModule(address module, bool approved) external onlyRole(DEFAULT_ADMIN_ROLE) { if (module == address(0)) revert ZeroAddress(); if (approved) { _grantRole(MODULE_ROLE, module); } else { _revokeRole(MODULE_ROLE, module); } emit ModuleSet(module, approved); } function setToken(address token, bool approved) external onlyRole(DEFAULT_ADMIN_ROLE) { if (token == address(0)) revert ZeroAddress(); allowedTokens[token] = approved; emit TokenSet(token, approved); } function setCaps(uint256 _maxPerTx, uint256 _dailyCap, uint256 _rateLimitPerHour) external onlyRole(CAP_MANAGER_ROLE) { maxPerTx = _maxPerTx; dailyCap = _dailyCap; rateLimitPerHour = _rateLimitPerHour; emit CapsUpdated(_maxPerTx, _dailyCap, _rateLimitPerHour); } function deposit(address token, uint256 amount) external nonReentrant { if (token == address(0)) revert ZeroAddress(); if (amount == 0) revert ZeroAmount(); if (!allowedTokens[token]) revert TokenNotApproved(); IERC20(token).safeTransferFrom(msg.sender, address(this), amount); } }