// 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 EtherlinkRelayReceiver * @notice Relay-compatible receiver on Etherlink (chain 42793). Accepts relayMintOrUnlock from off-chain relay. * @dev When CCIP does not support Etherlink, custom relay monitors source and calls this contract. * Idempotency via messageId; only RELAYER_ROLE can call. */ contract EtherlinkRelayReceiver is AccessControl, ReentrancyGuard { using SafeERC20 for IERC20; bytes32 public constant RELAYER_ROLE = keccak256("RELAYER_ROLE"); mapping(bytes32 => bool) public processed; event RelayMintOrUnlock( bytes32 indexed messageId, address indexed token, address indexed recipient, uint256 amount ); constructor(address admin) { _grantRole(DEFAULT_ADMIN_ROLE, admin); _grantRole(RELAYER_ROLE, admin); } /** * @notice Mint or unlock tokens to recipient. Only relayer; idempotent per messageId. * @param messageId Source chain message id (for idempotency). * @param token Token address (address(0) for native). * @param recipient Recipient on Etherlink. * @param amount Amount to transfer. */ function relayMintOrUnlock( bytes32 messageId, address token, address recipient, uint256 amount ) external onlyRole(RELAYER_ROLE) nonReentrant { require(!processed[messageId], "already processed"); require(recipient != address(0), "zero recipient"); require(amount > 0, "zero amount"); processed[messageId] = true; if (token == address(0)) { (bool sent,) = payable(recipient).call{value: amount}(""); require(sent, "transfer failed"); } else { IERC20(token).safeTransfer(recipient, amount); } emit RelayMintOrUnlock(messageId, token, recipient, amount); } receive() external payable {} }