// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {IERC4626} from "@openzeppelin/contracts/interfaces/IERC4626.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// @notice Educational EVM-only composer for ONE reviewed asset, source EID and ERC-4626 vault. /// Not a production-audited deployment. No admin, arbitrary calls or fund sweeping. contract DocsVaultComposer is ReentrancyGuard { using SafeERC20 for IERC20; address public immutable endpoint; address public immutable facade; uint32 public immutable sourceEid; IERC20 public immutable token; IERC4626 public immutable vault; mapping(bytes32 => bool) public handled; struct Credit { address beneficiary; uint256 amount; } mapping(bytes32 => Credit) public credits; error Untrusted(); error InvalidMessage(); error AlreadyHandled(); error DepositUnavailable(); event Deposited(bytes32 indexed guid, address indexed beneficiary, uint256 assets, uint256 shares); event RefundAvailable(bytes32 indexed guid, address indexed beneficiary, uint256 assets); event Refunded(bytes32 indexed guid, address indexed beneficiary, address recipient, uint256 assets); constructor(address endpoint_, address facade_, uint32 sourceEid_, IERC20 token_, IERC4626 vault_) { require(endpoint_.code.length != 0 && facade_ != address(0) && sourceEid_ != 0, "configuration"); require(address(token_) != address(0) && vault_.asset() == address(token_), "vault asset"); endpoint = endpoint_; facade = facade_; sourceEid = sourceEid_; token = token_; vault = vault_; } /// @dev Standard OFT envelope: nonce(8), srcEid(4), amountLD(32), composeFrom(32), payload. /// Payload: abi.encode(address beneficiary, uint256 minShares, uint256 deadline). function lzCompose(address from, bytes32 guid, bytes calldata message, address, bytes calldata) external payable nonReentrant { if (msg.sender != endpoint || from != facade) revert Untrusted(); if (msg.value != 0 || message.length != 172 || uint32(bytes4(message[8:12])) != sourceEid) revert InvalidMessage(); bytes32 sender = bytes32(message[44:76]); if (sender == bytes32(0) || uint256(sender) >> 160 != 0) revert InvalidMessage(); if (handled[guid]) revert AlreadyHandled(); uint256 amount = uint256(bytes32(message[12:44])); (address beneficiary, uint256 minShares, uint256 deadline) = abi.decode(message[76:], (address, uint256, uint256)); if (beneficiary == address(0) || amount == 0) revert InvalidMessage(); handled[guid] = true; // Self-call makes deposit + minShares check atomic. Failed deposits retain their full assets. try this.deposit(amount, beneficiary, minShares, deadline) returns (uint256 shares) { emit Deposited(guid, beneficiary, amount, shares); } catch { credits[guid] = Credit(beneficiary, amount); emit RefundAvailable(guid, beneficiary, amount); } } function deposit(uint256 amount, address beneficiary, uint256 minShares, uint256 deadline) external returns (uint256 shares) { if (msg.sender != address(this)) revert Untrusted(); if (block.timestamp > deadline) revert DepositUnavailable(); token.forceApprove(address(vault), amount); shares = vault.deposit(amount, beneficiary); if (shares < minShares || shares == 0) revert DepositUnavailable(); token.forceApprove(address(vault), 0); } /// @notice Only the payload's beneficiary can redirect or claim this message's refund. function claim(bytes32 guid, address recipient) external nonReentrant { Credit memory credit = credits[guid]; if (credit.beneficiary != msg.sender || credit.amount == 0 || recipient == address(0)) revert Untrusted(); delete credits[guid]; token.safeTransfer(recipient, credit.amount); emit Refunded(guid, msg.sender, recipient, credit.amount); } }