diff --git a/solidity/contracts/AttributeCheckpointFraud.sol b/solidity/contracts/AttributeCheckpointFraud.sol index 33c533e378..ee80855141 100644 --- a/solidity/contracts/AttributeCheckpointFraud.sol +++ b/solidity/contracts/AttributeCheckpointFraud.sol @@ -5,29 +5,16 @@ import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; -import {PackageVersioned} from "contracts/PackageVersioned.sol"; +import {PackageVersioned} from "./PackageVersioned.sol"; import {TREE_DEPTH} from "./libs/Merkle.sol"; import {CheckpointLib, Checkpoint} from "./libs/CheckpointLib.sol"; import {CheckpointFraudProofs} from "./CheckpointFraudProofs.sol"; - -enum FraudType { - Whitelist, - Premature, - MessageId, - Root -} - -struct Attribution { - FraudType fraudType; - // for comparison with staking epoch - uint48 timestamp; -} +import {FraudType, Attribution} from "./libs/FraudMessage.sol"; /** * @title AttributeCheckpointFraud * @dev The AttributeCheckpointFraud contract is used to attribute fraud to a specific ECDSA checkpoint signer. */ - contract AttributeCheckpointFraud is Ownable, PackageVersioned { using CheckpointLib for Checkpoint; using Address for address; @@ -72,6 +59,13 @@ contract AttributeCheckpointFraud is Ownable, PackageVersioned { return _attributions[signer][digest]; } + function attributions( + address signer, + bytes32 digest + ) external view returns (Attribution memory) { + return _attributions[signer][digest]; + } + function whitelist(address merkleTree) external onlyOwner { require( merkleTree.isContract(), diff --git a/solidity/contracts/libs/FraudMessage.sol b/solidity/contracts/libs/FraudMessage.sol new file mode 100644 index 0000000000..41426f8163 --- /dev/null +++ b/solidity/contracts/libs/FraudMessage.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +enum FraudType { + Whitelist, + Premature, + MessageId, + Root +} + +struct Attribution { + FraudType fraudType; + // for comparison with staking epoch + uint48 timestamp; +} + +library FraudMessage { + function encode( + address signer, + bytes32 digest, + Attribution memory attribution + ) internal pure returns (bytes memory) { + return + abi.encodePacked( + signer, + digest, + uint8(attribution.fraudType), + attribution.timestamp + ); + } + + function decode( + bytes calldata _message + ) internal pure returns (address, bytes32, Attribution memory) { + require(_message.length == 59, "Invalid message length"); + + address signer = address(bytes20(_message[0:20])); + bytes32 digest = bytes32(_message[20:52]); + FraudType fraudType = FraudType(uint8(_message[52])); + uint48 timestamp = uint48(bytes6(_message[53:59])); + + return (signer, digest, Attribution(fraudType, timestamp)); + } +} diff --git a/solidity/contracts/middleware/FraudProofRouter.sol b/solidity/contracts/middleware/FraudProofRouter.sol new file mode 100644 index 0000000000..8387d4e500 --- /dev/null +++ b/solidity/contracts/middleware/FraudProofRouter.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +/*@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@ HYPERLANE @@@@@@@ + @@@@@@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ + @@@@@@@@@ @@@@@@@@@ +@@@@@@@@@ @@@@@@@@*/ + +import {TypeCasts} from "../libs/TypeCasts.sol"; +import {FraudType, FraudMessage, Attribution} from "../libs/FraudMessage.sol"; +import {AttributeCheckpointFraud} from "../AttributeCheckpointFraud.sol"; +import {GasRouter} from "../client/GasRouter.sol"; + +contract FraudProofRouter is GasRouter { + // ===================== State Variables ======================= + + // The AttributeCheckpointFraud contract to obtain the attributions from + AttributeCheckpointFraud public immutable attributeCheckpointFraud; + + // Mapping to store the fraud attributions for a given origin, signer, and digest for easy access for client contracts to aide slashing + mapping(uint32 origin => mapping(address signer => mapping(bytes32 digest => Attribution))) + public fraudAttributions; + + // ===================== Events ======================= + + event FraudProofSent( + address indexed signer, + bytes32 indexed digest, + Attribution attribution + ); + + event LocalFraudProofReceived( + address indexed signer, + bytes32 indexed digest, + Attribution attribution + ); + + event FraudProofReceived( + uint32 indexed origin, + address indexed signer, + bytes32 indexed digest, + Attribution attribution + ); + + // ===================== Constructor ======================= + + /** + * @notice Initializes the FraudProofRouter with the mailbox address and AttributeCheckpointFraud contract. + * @param _mailbox The address of the mailbox contract. + * @param _attributeCheckpointFraud The address of the AttributeCheckpointFraud contract. + */ + constructor( + address _mailbox, + address _attributeCheckpointFraud + ) GasRouter(_mailbox) { + require( + _attributeCheckpointFraud != address(0), + "Invalid AttributeCheckpointFraud address" + ); + attributeCheckpointFraud = AttributeCheckpointFraud( + _attributeCheckpointFraud + ); + hook = mailbox.defaultHook(); + } + + /** + * @notice Sends a fraud proof attribution. + * @param _signer The address of the signer attributed with fraud. + * @param _digest The digest associated with the fraud. + * @return The message ID of the sent fraud proof. + */ + function sendFraudProof( + uint32 _destination, + address _signer, + bytes32 _digest + ) external returns (bytes32) { + Attribution memory attribution = attributeCheckpointFraud.attributions( + _signer, + _digest + ); + + require(attribution.timestamp != 0, "Attribution does not exist"); + + if (_destination == mailbox.localDomain()) { + fraudAttributions[_destination][_signer][_digest] = attribution; + + emit LocalFraudProofReceived(_signer, _digest, attribution); + + return bytes32(0); + } else { + bytes memory encodedMessage = FraudMessage.encode( + _signer, + _digest, + attribution + ); + + emit FraudProofSent(_signer, _digest, attribution); + + return + _Router_dispatch( + _destination, + 0, + encodedMessage, + "", + address(hook) + ); + } + } + + /** + * @notice Handles by decoding the inbound fraud proof message. + * @param _origin The origin domain of the fraud proof. + * @param _message The encoded fraud proof message. + */ + function _handle( + uint32 _origin, + bytes32, + /*_sender*/ + bytes calldata _message + ) internal override { + ( + address signer, + bytes32 digest, + Attribution memory attribution + ) = FraudMessage.decode(_message); + + fraudAttributions[_origin][signer][digest] = attribution; + + emit FraudProofReceived(_origin, signer, digest, attribution); + } +} diff --git a/solidity/contracts/test/TestAttributeCheckpointFraud.sol b/solidity/contracts/test/TestAttributeCheckpointFraud.sol new file mode 100644 index 0000000000..d7b862a49a --- /dev/null +++ b/solidity/contracts/test/TestAttributeCheckpointFraud.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {FraudType, Attribution} from "../libs/FraudMessage.sol"; +import {AttributeCheckpointFraud} from "../AttributeCheckpointFraud.sol"; + +contract TestAttributeCheckpointFraud is AttributeCheckpointFraud { + constructor() AttributeCheckpointFraud() {} + + function mockSetAttribution( + address signer, + bytes32 digest, + FraudType fraudType + ) external { + _attributions[signer][digest] = Attribution({ + fraudType: fraudType, + timestamp: uint48(block.timestamp) + }); + } +} diff --git a/solidity/test/FraudProofRouter.t.sol b/solidity/test/FraudProofRouter.t.sol new file mode 100644 index 0000000000..3535043837 --- /dev/null +++ b/solidity/test/FraudProofRouter.t.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; + +import {FraudType, Attribution} from "../contracts/libs/FraudMessage.sol"; +import {TypeCasts} from "../contracts/libs/TypeCasts.sol"; +import {TestAttributeCheckpointFraud} from "../contracts/test/TestAttributeCheckpointFraud.sol"; +import {FraudProofRouter} from "../contracts/middleware/FraudProofRouter.sol"; +import {MockMailbox} from "../contracts/mock/MockMailbox.sol"; + +contract FraudProofRouterTest is Test { + using TypeCasts for address; + + uint32 public constant LOCAL_DOMAIN = 1; + uint32 public constant DESTINATION_DOMAIN = 2; + MockMailbox internal localMailbox; + MockMailbox internal remoteMailbox; + TestAttributeCheckpointFraud public testAcf; + FraudProofRouter public originFpr; + FraudProofRouter public remoteFpr; + address public constant OWNER = address(0x1); + address public constant SIGNER = address(0x2); + bytes32 DIGEST = keccak256(abi.encodePacked("digest")); + + function setUp() public { + vm.warp(1000); + localMailbox = new MockMailbox(LOCAL_DOMAIN); + remoteMailbox = new MockMailbox(DESTINATION_DOMAIN); + localMailbox.addRemoteMailbox(DESTINATION_DOMAIN, remoteMailbox); + remoteMailbox.addRemoteMailbox(LOCAL_DOMAIN, localMailbox); + + testAcf = new TestAttributeCheckpointFraud(); + + vm.startPrank(OWNER); + originFpr = new FraudProofRouter( + address(localMailbox), + address(testAcf) + ); + remoteFpr = new FraudProofRouter( + address(remoteMailbox), + address(testAcf) + ); + + originFpr.enrollRemoteRouter( + DESTINATION_DOMAIN, + address(remoteFpr).addressToBytes32() + ); + remoteFpr.enrollRemoteRouter( + LOCAL_DOMAIN, + address(originFpr).addressToBytes32() + ); + + vm.stopPrank(); + } + + function test_setAttributeCheckpointFraud_invalidAddress() public { + vm.expectRevert("Invalid AttributeCheckpointFraud address"); + new FraudProofRouter(address(localMailbox), address(0)); + } + + function test_sendFraudProof( + address _signer, + bytes32 _digest, + uint8 _fraudType, + uint48 _timestamp + ) public { + vm.assume(_fraudType <= uint8(FraudType.Root)); + vm.assume(_timestamp > 0); + vm.warp(_timestamp); + FraudType fraudTypeEnum = FraudType(_fraudType); + + testAcf.mockSetAttribution(_signer, _digest, fraudTypeEnum); + + vm.expectEmit(true, true, true, true, address(originFpr)); + emit FraudProofRouter.FraudProofSent( + _signer, + _digest, + Attribution(fraudTypeEnum, uint48(block.timestamp)) + ); + + originFpr.sendFraudProof(DESTINATION_DOMAIN, _signer, _digest); + + vm.expectEmit(true, true, true, true, address(remoteFpr)); + emit FraudProofRouter.FraudProofReceived( + LOCAL_DOMAIN, + _signer, + _digest, + Attribution(fraudTypeEnum, uint48(block.timestamp)) + ); + remoteMailbox.processNextInboundMessage(); + + (FraudType actualFraudType, uint48 actualTimestamp) = remoteFpr + .fraudAttributions(LOCAL_DOMAIN, _signer, _digest); + + assert(actualFraudType == fraudTypeEnum); + assertEq(actualTimestamp, block.timestamp); + } + + function test_sendFraudProof_noAttribution() public { + vm.expectRevert("Attribution does not exist"); + originFpr.sendFraudProof(DESTINATION_DOMAIN, SIGNER, DIGEST); + } + + function test_sendFraudProof_routerNotEnrolled() public { + FraudType fraudType = FraudType.Whitelist; + testAcf.mockSetAttribution(SIGNER, DIGEST, fraudType); + + vm.expectRevert("No router enrolled for domain: 3"); + originFpr.sendFraudProof(3, SIGNER, DIGEST); + } + + function test_sendFraudProof_localDomain() public { + testAcf.mockSetAttribution(SIGNER, DIGEST, FraudType.Whitelist); + + originFpr.sendFraudProof(LOCAL_DOMAIN, SIGNER, DIGEST); + + (FraudType actualFraudType, uint48 actualTimestamp) = originFpr + .fraudAttributions(LOCAL_DOMAIN, SIGNER, DIGEST); + + assert(actualFraudType == FraudType.Whitelist); + assertEq(actualTimestamp, block.timestamp); + } +}