459 lines
16 KiB
Solidity
459 lines
16 KiB
Solidity
|
|
// 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];
|
||
|
|
}
|
||
|
|
}
|