// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "./BondManager.sol"; import "./libraries/MerkleProofVerifier.sol"; import "./libraries/FraudProofTypes.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /** * @title ChallengeManager * @notice Manages fraud proof challenges for trustless bridge claims * @dev Permissionless challenging mechanism with automated slashing on successful challenges */ contract ChallengeManager is ReentrancyGuard { BondManager public immutable bondManager; uint256 public immutable challengeWindow; // Challenge window duration in seconds enum FraudProofType { NonExistentDeposit, // Deposit doesn't exist on source chain IncorrectAmount, // Amount mismatch IncorrectRecipient, // Recipient mismatch DoubleSpend // Deposit already claimed elsewhere } struct Challenge { address challenger; uint256 depositId; FraudProofType proofType; bytes proof; uint256 timestamp; bool resolved; } struct Claim { uint256 depositId; // Slot 0 address asset; // Slot 1 (20 bytes) + 12 bytes padding address recipient; // Slot 2 (20 bytes) + 12 bytes padding uint256 amount; // Slot 3 uint256 challengeWindowEnd; // Slot 4 bool finalized; // Slot 5 (1 byte) + 31 bytes padding bool challenged; // Slot 6 (1 byte) + 31 bytes padding // Note: Could pack finalized and challenged in same slot, but keeping separate for clarity } mapping(uint256 => Claim) public claims; // depositId => Claim mapping(uint256 => Challenge) public challenges; // depositId => Challenge event ClaimSubmitted( uint256 indexed depositId, address indexed asset, uint256 amount, address indexed recipient, uint256 challengeWindowEnd ); event ClaimChallenged( uint256 indexed depositId, address indexed challenger, FraudProofType proofType ); event FraudProven( uint256 indexed depositId, address indexed challenger, FraudProofType proofType, uint256 slashedAmount ); event ChallengeRejected( uint256 indexed depositId, address indexed challenger ); event ClaimFinalized( uint256 indexed depositId ); error ZeroDepositId(); error ClaimNotFound(); error ClaimAlreadyFinalized(); error ClaimAlreadyChallenged(); error ChallengeWindowExpired(); error ChallengeWindowNotExpired(); error InvalidFraudProof(); error ChallengeNotFound(); error ChallengeAlreadyResolved(); /** * @notice Constructor * @param _bondManager Address of BondManager contract * @param _challengeWindow Challenge window duration in seconds */ constructor(address _bondManager, uint256 _challengeWindow) { require(_bondManager != address(0), "ChallengeManager: zero bond manager"); require(_challengeWindow > 0, "ChallengeManager: zero challenge window"); bondManager = BondManager(payable(_bondManager)); challengeWindow = _challengeWindow; } /** * @notice Register a claim (called by InboxETH) * @param depositId Deposit ID from source chain * @param asset Asset address (address(0) for native ETH) * @param amount Deposit amount * @param recipient Recipient address */ function registerClaim( uint256 depositId, address asset, uint256 amount, address recipient ) external { if (depositId == 0) revert ZeroDepositId(); // Only allow one claim per deposit ID require(claims[depositId].depositId == 0, "ChallengeManager: claim already registered"); uint256 challengeWindowEnd = block.timestamp + challengeWindow; claims[depositId] = Claim({ depositId: depositId, asset: asset, amount: amount, recipient: recipient, challengeWindowEnd: challengeWindowEnd, finalized: false, challenged: false }); emit ClaimSubmitted(depositId, asset, amount, recipient, challengeWindowEnd); } /** * @notice Challenge a claim with fraud proof * @param depositId Deposit ID of the claim to challenge * @param proofType Type of fraud proof * @param proof Fraud proof data (format depends on proofType) */ function challengeClaim( uint256 depositId, FraudProofType proofType, bytes calldata proof ) external nonReentrant { if (depositId == 0) revert ZeroDepositId(); Claim storage claim = claims[depositId]; if (claim.depositId == 0) revert ClaimNotFound(); if (claim.finalized) revert ClaimAlreadyFinalized(); if (claim.challenged) revert ClaimAlreadyChallenged(); if (block.timestamp > claim.challengeWindowEnd) revert ChallengeWindowExpired(); // Verify fraud proof (pass storage reference to save gas) if (!_verifyFraudProof(depositId, claim, proofType, proof)) { revert InvalidFraudProof(); } // Mark claim as challenged claim.challenged = true; // Store challenge challenges[depositId] = Challenge({ challenger: msg.sender, depositId: depositId, proofType: proofType, proof: proof, timestamp: block.timestamp, resolved: false }); emit ClaimChallenged(depositId, msg.sender, proofType); // Automatically slash bond and mark challenge as resolved (uint256 challengerReward, ) = bondManager.slashBond(depositId, msg.sender); challenges[depositId].resolved = true; emit FraudProven(depositId, msg.sender, proofType, challengerReward * 2); // Total slashed amount } /** * @notice Finalize a claim after challenge window expires without challenge * @param depositId Deposit ID to finalize */ function finalizeClaim(uint256 depositId) external { if (depositId == 0) revert ZeroDepositId(); Claim storage claim = claims[depositId]; if (claim.depositId == 0) revert ClaimNotFound(); if (claim.finalized) revert ClaimAlreadyFinalized(); if (claim.challenged) revert ClaimAlreadyChallenged(); if (block.timestamp <= claim.challengeWindowEnd) revert ChallengeWindowNotExpired(); claim.finalized = true; emit ClaimFinalized(depositId); } /** * @notice Finalize multiple claims in batch (gas optimization) * @param depositIds Array of deposit IDs to finalize */ function finalizeClaimsBatch(uint256[] calldata depositIds) external { uint256 length = depositIds.length; require(length > 0, "ChallengeManager: empty array"); require(length <= 50, "ChallengeManager: batch too large"); // Prevent gas limit issues for (uint256 i = 0; i < length; i++) { uint256 depositId = depositIds[i]; if (depositId == 0) continue; // Skip zero IDs Claim storage claim = claims[depositId]; if (claim.depositId == 0) continue; // Skip non-existent claims if (claim.finalized) continue; // Skip already finalized if (claim.challenged) continue; // Skip challenged claims if (block.timestamp <= claim.challengeWindowEnd) continue; // Skip if window not expired claim.finalized = true; emit ClaimFinalized(depositId); } } /** * @notice Verify fraud proof (internal function) * @dev Verifies fraud proofs against source chain state using Merkle proofs * @param depositId Deposit ID * @param claim Claim data * @param proofType Type of fraud proof * @param proof Proof data (encoded according to proofType) * @return True if fraud proof is valid */ function _verifyFraudProof( uint256 depositId, Claim storage claim, // Changed to storage to save gas FraudProofType proofType, bytes calldata proof ) internal view returns (bool) { if (proof.length == 0) return false; if (proofType == FraudProofType.NonExistentDeposit) { return _verifyNonExistentDeposit(depositId, claim, proof); } else if (proofType == FraudProofType.IncorrectAmount) { return _verifyIncorrectAmount(depositId, claim, proof); } else if (proofType == FraudProofType.IncorrectRecipient) { return _verifyIncorrectRecipient(depositId, claim, proof); } else if (proofType == FraudProofType.DoubleSpend) { return _verifyDoubleSpend(depositId, claim, proof); } return false; } /** * @notice Verify non-existent deposit fraud proof * @param depositId Deposit ID * @param claim Claim data * @param proof Encoded NonExistentDepositProof * @return True if proof is valid */ function _verifyNonExistentDeposit( uint256 depositId, Claim storage claim, // Changed to storage to save gas bytes calldata proof ) internal view returns (bool) { FraudProofTypes.NonExistentDepositProof memory fraudProof = FraudProofTypes.decodeNonExistentDeposit(proof); // Verify state root against block header if (!MerkleProofVerifier.verifyStateRoot(fraudProof.blockHeader, fraudProof.stateRoot)) { return false; } // Hash the claimed deposit data bytes32 claimedDepositHash = MerkleProofVerifier.hashDepositData( depositId, claim.asset, claim.amount, claim.recipient, block.timestamp // Note: In production, use actual deposit timestamp from source chain ); // Verify that the claimed deposit hash matches the proof if (claimedDepositHash != fraudProof.depositHash) { return false; } // Verify non-existence proof (deposit doesn't exist in Merkle tree) return MerkleProofVerifier.verifyDepositNonExistence( fraudProof.stateRoot, fraudProof.depositHash, fraudProof.merkleProof, fraudProof.leftSibling, fraudProof.rightSibling ); } /** * @notice Verify incorrect amount fraud proof * @param depositId Deposit ID * @param claim Claim data * @param proof Encoded IncorrectAmountProof * @return True if proof is valid */ function _verifyIncorrectAmount( uint256 depositId, Claim storage claim, // Changed to storage to save gas bytes calldata proof ) internal view returns (bool) { FraudProofTypes.IncorrectAmountProof memory fraudProof = FraudProofTypes.decodeIncorrectAmount(proof); // Verify state root against block header if (!MerkleProofVerifier.verifyStateRoot(fraudProof.blockHeader, fraudProof.stateRoot)) { return false; } // Verify that actual amount differs from claimed amount if (fraudProof.actualAmount == claim.amount) { return false; // Amounts match, not a fraud } // Hash the actual deposit data bytes32 actualDepositHash = MerkleProofVerifier.hashDepositData( depositId, claim.asset, fraudProof.actualAmount, claim.recipient, block.timestamp // Note: In production, use actual deposit timestamp ); // Verify Merkle proof for actual deposit return MerkleProofVerifier.verifyDepositExistence( fraudProof.stateRoot, actualDepositHash, fraudProof.merkleProof ); } /** * @notice Verify incorrect recipient fraud proof * @param depositId Deposit ID * @param claim Claim data * @param proof Encoded IncorrectRecipientProof * @return True if proof is valid */ function _verifyIncorrectRecipient( uint256 depositId, Claim storage claim, // Changed to storage to save gas bytes calldata proof ) internal view returns (bool) { FraudProofTypes.IncorrectRecipientProof memory fraudProof = FraudProofTypes.decodeIncorrectRecipient(proof); // Verify state root against block header if (!MerkleProofVerifier.verifyStateRoot(fraudProof.blockHeader, fraudProof.stateRoot)) { return false; } // Verify that actual recipient differs from claimed recipient if (fraudProof.actualRecipient == claim.recipient) { return false; // Recipients match, not a fraud } // Hash the actual deposit data bytes32 actualDepositHash = MerkleProofVerifier.hashDepositData( depositId, claim.asset, claim.amount, fraudProof.actualRecipient, block.timestamp // Note: In production, use actual deposit timestamp ); // Verify Merkle proof for actual deposit return MerkleProofVerifier.verifyDepositExistence( fraudProof.stateRoot, actualDepositHash, fraudProof.merkleProof ); } /** * @notice Verify double spend fraud proof * @param depositId Deposit ID * @param claim Claim data * @param proof Encoded DoubleSpendProof * @return True if proof is valid */ function _verifyDoubleSpend( uint256 depositId, Claim storage claim, // Changed to storage to save gas bytes calldata proof ) internal view returns (bool) { FraudProofTypes.DoubleSpendProof memory fraudProof = FraudProofTypes.decodeDoubleSpend(proof); // Verify that the previous claim ID is different (same deposit claimed twice) if (fraudProof.previousClaimId == depositId) { // Check if previous claim exists and is finalized (use storage to save gas) Claim storage previousClaim = claims[fraudProof.previousClaimId]; if (previousClaim.depositId == 0 || !previousClaim.finalized) { return false; // Previous claim doesn't exist or isn't finalized } // Verify that the deposit data matches (same deposit, different claim) if ( previousClaim.asset == claim.asset && previousClaim.amount == claim.amount && previousClaim.recipient == claim.recipient ) { return true; // Double spend detected } } return false; } /** * @notice Check if a claim can be finalized * @param depositId Deposit ID to check * @return canFinalize_ True if claim can be finalized * @return reason Reason if cannot finalize */ function canFinalize(uint256 depositId) external view returns (bool canFinalize_, string memory reason) { Claim memory claim = claims[depositId]; if (claim.depositId == 0) { return (false, "Claim not found"); } if (claim.finalized) { return (false, "Already finalized"); } if (claim.challenged) { return (false, "Claim was challenged"); } if (block.timestamp <= claim.challengeWindowEnd) { return (false, "Challenge window not expired"); } return (true, ""); } /** * @notice Get claim information * @param depositId Deposit ID * @return Claim data */ function getClaim(uint256 depositId) external view returns (Claim memory) { return claims[depositId]; } /** * @notice Get challenge information * @param depositId Deposit ID * @return Challenge data */ function getChallenge(uint256 depositId) external view returns (Challenge memory) { return challenges[depositId]; } }