// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "./BondManager.sol"; import "./ChallengeManager.sol"; import "./LiquidityPoolETH.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /** * @title InboxETH * @notice Receives and processes claims from relayers for trustless bridge deposits * @dev Permissionless claim submission requiring bonds and challenge mechanism */ contract InboxETH is ReentrancyGuard { BondManager public immutable bondManager; ChallengeManager public immutable challengeManager; LiquidityPoolETH public immutable liquidityPool; // Rate limiting uint256 public constant MIN_DEPOSIT = 0.001 ether; // Minimum deposit to prevent dust uint256 public constant COOLDOWN_PERIOD = 60 seconds; // Cooldown between claims per relayer mapping(address => uint256) public lastClaimTime; // relayer => last claim timestamp mapping(address => uint256) public claimsPerHour; // relayer => claims in current hour mapping(address => uint256) public hourStart; // relayer => current hour start timestamp uint256 public constant MAX_CLAIMS_PER_HOUR = 100; // Max claims per hour per relayer // Relayer fees (optional, can be enabled via governance) uint256 public relayerFeeBps = 0; // Basis points (0 = disabled, 100 = 1%) mapping(uint256 => RelayerFee) public relayerFees; // depositId => RelayerFee struct RelayerFee { address relayer; uint256 amount; bool claimed; } struct ClaimData { uint256 depositId; address asset; uint256 amount; address recipient; address relayer; uint256 timestamp; bool exists; } mapping(uint256 => ClaimData) public claims; // depositId => ClaimData event RelayerFeeSet(uint256 newFeeBps); event RelayerFeeClaimed(uint256 indexed depositId, address indexed relayer, uint256 amount); event ClaimSubmitted( uint256 indexed depositId, address indexed relayer, address asset, uint256 amount, address indexed recipient, uint256 bondAmount, uint256 challengeWindowEnd ); error ZeroDepositId(); error ZeroAsset(); error ZeroAmount(); error ZeroRecipient(); error ClaimAlreadyExists(); error InsufficientBond(); error DepositTooSmall(); error CooldownActive(); error RateLimitExceeded(); error RelayerFeeNotEnabled(); /** * @notice Constructor * @param _bondManager Address of BondManager contract * @param _challengeManager Address of ChallengeManager contract * @param _liquidityPool Address of LiquidityPoolETH contract */ constructor( address _bondManager, address _challengeManager, address _liquidityPool ) { require(_bondManager != address(0), "InboxETH: zero bond manager"); require(_challengeManager != address(0), "InboxETH: zero challenge manager"); require(_liquidityPool != address(0), "InboxETH: zero liquidity pool"); bondManager = BondManager(payable(_bondManager)); challengeManager = ChallengeManager(payable(_challengeManager)); liquidityPool = LiquidityPoolETH(payable(_liquidityPool)); } /** * @notice Submit a claim for a deposit from source chain * @param depositId Deposit ID from source chain (ChainID 138) * @param asset Asset address (address(0) for native ETH) * @param amount Deposit amount * @param recipient Recipient address on Ethereum * @return bondAmount Amount of bond posted */ function submitClaim( uint256 depositId, address asset, uint256 amount, address recipient, bytes calldata ) external payable nonReentrant returns (uint256 bondAmount) { if (depositId == 0) revert ZeroDepositId(); if (asset == address(0) && amount == 0) revert ZeroAmount(); if (recipient == address(0)) revert ZeroRecipient(); // Rate limiting checks if (amount < MIN_DEPOSIT) revert DepositTooSmall(); // Cooldown check if (block.timestamp < lastClaimTime[msg.sender] + COOLDOWN_PERIOD) { revert CooldownActive(); } // Hourly rate limit check uint256 currentHour = block.timestamp / 3600; if (hourStart[msg.sender] != currentHour) { hourStart[msg.sender] = currentHour; claimsPerHour[msg.sender] = 0; } if (claimsPerHour[msg.sender] >= MAX_CLAIMS_PER_HOUR) { revert RateLimitExceeded(); } // Check if claim already exists if (claims[depositId].exists) revert ClaimAlreadyExists(); // Calculate required bond uint256 requiredBond = bondManager.getRequiredBond(amount); // Calculate relayer fee if enabled uint256 relayerFee = 0; uint256 bridgeAmount = amount; if (relayerFeeBps > 0) { relayerFee = (amount * relayerFeeBps) / 10000; bridgeAmount = amount - relayerFee; // Store relayer fee relayerFees[depositId] = RelayerFee({ relayer: msg.sender, amount: relayerFee, claimed: false }); } if (msg.value < requiredBond) revert InsufficientBond(); // Post bond (pass relayer address explicitly) bondAmount = bondManager.postBond{value: requiredBond}(depositId, bridgeAmount, msg.sender); // Update rate limiting lastClaimTime[msg.sender] = block.timestamp; claimsPerHour[msg.sender]++; // Register claim with ChallengeManager (use bridgeAmount after fee) challengeManager.registerClaim(depositId, asset, bridgeAmount, recipient); // Determine asset type for liquidity pool LiquidityPoolETH.AssetType assetType = asset == address(0) ? LiquidityPoolETH.AssetType.ETH : LiquidityPoolETH.AssetType.WETH; // Add pending claim to liquidity pool (use bridgeAmount after fee deduction) liquidityPool.addPendingClaim(bridgeAmount, assetType); // Store claim data (use bridgeAmount for amount) claims[depositId] = ClaimData({ depositId: depositId, asset: asset, amount: bridgeAmount, // Store bridge amount (after fee) recipient: recipient, relayer: msg.sender, timestamp: block.timestamp, exists: true }); // Get challenge window end time (uint256 challengeWindowEnd, ) = _getChallengeWindowEnd(depositId); emit ClaimSubmitted( depositId, msg.sender, asset, bridgeAmount, // Emit bridge amount (after fee) recipient, bondAmount, challengeWindowEnd ); return bondAmount; } /** * @notice Submit multiple claims in batch (gas optimization) * @param depositIds Array of deposit IDs * @param assets Array of asset addresses * @param amounts Array of deposit amounts * @param recipients Array of recipient addresses * @param proofs Array of proof data * @return totalBondAmount Total bond amount posted */ function submitClaimsBatch( uint256[] calldata depositIds, address[] calldata assets, uint256[] calldata amounts, address[] calldata recipients, bytes[] calldata proofs ) external payable nonReentrant returns (uint256 totalBondAmount) { uint256 length = depositIds.length; require(length > 0, "InboxETH: empty array"); require(length <= 20, "InboxETH: batch too large"); // Prevent gas limit issues require(length == assets.length && length == amounts.length && length == recipients.length && length == proofs.length, "InboxETH: length mismatch"); // Calculate total required bond uint256 totalRequiredBond = 0; for (uint256 i = 0; i < length; i++) { if (depositIds[i] == 0) revert ZeroDepositId(); if (assets[i] == address(0) && amounts[i] == 0) revert ZeroAmount(); if (recipients[i] == address(0)) revert ZeroRecipient(); if (claims[depositIds[i]].exists) revert ClaimAlreadyExists(); totalRequiredBond += bondManager.getRequiredBond(amounts[i]); } if (msg.value < totalRequiredBond) revert InsufficientBond(); // Process each claim for (uint256 i = 0; i < length; i++) { // Rate limiting checks (simplified for batch - check first item) if (i == 0) { if (amounts[i] < MIN_DEPOSIT) revert DepositTooSmall(); if (block.timestamp < lastClaimTime[msg.sender] + COOLDOWN_PERIOD) { revert CooldownActive(); } uint256 currentHour = block.timestamp / 3600; if (hourStart[msg.sender] != currentHour) { hourStart[msg.sender] = currentHour; claimsPerHour[msg.sender] = 0; } } if (claimsPerHour[msg.sender] + i >= MAX_CLAIMS_PER_HOUR) { revert RateLimitExceeded(); } // Calculate relayer fee if enabled uint256 relayerFee = 0; uint256 bridgeAmount = amounts[i]; if (relayerFeeBps > 0) { relayerFee = (amounts[i] * relayerFeeBps) / 10000; bridgeAmount = amounts[i] - relayerFee; relayerFees[depositIds[i]] = RelayerFee({ relayer: msg.sender, amount: relayerFee, claimed: false }); } uint256 requiredBond = bondManager.getRequiredBond(bridgeAmount); // Post bond uint256 bondAmount = bondManager.postBond{value: requiredBond}( depositIds[i], bridgeAmount, msg.sender ); totalBondAmount += bondAmount; // Register claim (use bridgeAmount) challengeManager.registerClaim(depositIds[i], assets[i], bridgeAmount, recipients[i]); // Determine asset type LiquidityPoolETH.AssetType assetType = assets[i] == address(0) ? LiquidityPoolETH.AssetType.ETH : LiquidityPoolETH.AssetType.WETH; // Add pending claim (use bridgeAmount) liquidityPool.addPendingClaim(bridgeAmount, assetType); // Store claim data (use bridgeAmount) claims[depositIds[i]] = ClaimData({ depositId: depositIds[i], asset: assets[i], amount: bridgeAmount, recipient: recipients[i], relayer: msg.sender, timestamp: block.timestamp, exists: true }); // Get challenge window end time (uint256 challengeWindowEnd, ) = _getChallengeWindowEnd(depositIds[i]); emit ClaimSubmitted( depositIds[i], msg.sender, assets[i], bridgeAmount, recipients[i], bondAmount, challengeWindowEnd ); } // Update rate limiting lastClaimTime[msg.sender] = block.timestamp; claimsPerHour[msg.sender] += length; // Refund excess bond if any if (msg.value > totalBondAmount) { (bool success, ) = payable(msg.sender).call{value: msg.value - totalBondAmount}(""); require(success, "InboxETH: refund failed"); } return totalBondAmount; } /** * @notice Get claim status * @param depositId Deposit ID * @return exists True if claim exists * @return finalized True if claim is finalized * @return challenged True if claim was challenged * @return challengeWindowEnd Timestamp when challenge window ends */ function getClaimStatus( uint256 depositId ) external view returns ( bool exists, bool finalized, bool challenged, uint256 challengeWindowEnd ) { if (!claims[depositId].exists) { return (false, false, false, 0); } ChallengeManager.Claim memory claim = challengeManager.getClaim(depositId); (challengeWindowEnd, ) = _getChallengeWindowEnd(depositId); return ( true, claim.finalized, claim.challenged, challengeWindowEnd ); } /** * @notice Get claim data * @param depositId Deposit ID * @return Claim data */ function getClaim(uint256 depositId) external view returns (ClaimData memory) { return claims[depositId]; } /** * @notice Internal function to get challenge window end time * @param depositId Deposit ID * @return challengeWindowEnd Timestamp * @return exists True if claim exists */ function _getChallengeWindowEnd( uint256 depositId ) internal view returns (uint256 challengeWindowEnd, bool exists) { ChallengeManager.Claim memory claim = challengeManager.getClaim(depositId); if (claim.depositId == 0) { return (0, false); } return (claim.challengeWindowEnd, true); } /** * @notice Set relayer fee (only callable by owner/multisig in future upgrade) * @param _relayerFeeBps New relayer fee in basis points (0 = disabled) */ function setRelayerFee(uint256 _relayerFeeBps) external { // Note: In production, add access control (owner/multisig) // For now, this is a placeholder for future governance require(_relayerFeeBps <= 1000, "InboxETH: fee too high"); // Max 10% relayerFeeBps = _relayerFeeBps; emit RelayerFeeSet(_relayerFeeBps); } /** * @notice Claim relayer fee for a finalized deposit * @param depositId Deposit ID to claim fee for */ function claimRelayerFee(uint256 depositId) external nonReentrant { if (relayerFeeBps == 0) revert RelayerFeeNotEnabled(); RelayerFee storage fee = relayerFees[depositId]; if (fee.relayer == address(0)) revert("InboxETH: no fee for deposit"); if (fee.claimed) revert("InboxETH: fee already claimed"); if (fee.relayer != msg.sender) revert("InboxETH: not fee recipient"); // Verify claim is finalized ChallengeManager.Claim memory claim = challengeManager.getClaim(depositId); if (!claim.finalized) revert("InboxETH: claim not finalized"); fee.claimed = true; // Transfer fee to relayer (bool success, ) = payable(msg.sender).call{value: fee.amount}(""); require(success, "InboxETH: fee transfer failed"); emit RelayerFeeClaimed(depositId, msg.sender, fee.amount); } /** * @notice Get relayer fee for a deposit * @param depositId Deposit ID * @return fee Relayer fee information */ function getRelayerFee(uint256 depositId) external view returns (RelayerFee memory) { return relayerFees[depositId]; } }