feat: implement checkpoint fraud proofs (#4277)
### Description Implements 4 categories of checkpoint fraud proofs for use in future validator slashing protocol: 1. **premature**: if a checkpoint index is greater than the corresponding mailbox count, it is fraudulent 2. **non local**: if a checkpoint origin does not match the checkpoint's mailbox domain, it is fraudulent 3. **message id**: if a checkpoint message ID differs from the actual message ID (verified by merkle proof) at the checkpoint index, it is fraudulent 4. **root**: if a checkpoint root differs from the actual root (verified by merkle proof + root reconstruction) at the checkpoint index, it is fraudulent Notably this is implemented independently from signature verification to allow for multiple checkpoint signing schemes to reuse the same checkpoint logic. ### Related issues - Touches https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/3799 - See https://github.com/hyperlane-xyz/hyperlane-monorepo/pull/2431 for previous discussions ### Backward compatibility Yes ### Testing Unit testing with fixtures in `vectors/merkle.json` that are generated by the rust merkle tree and proof codepull/4302/head
parent
d1bf212bf7
commit
cb404cb85c
@ -1,5 +1,5 @@ |
||||
--- |
||||
"@hyperlane-xyz/cli": patch |
||||
'@hyperlane-xyz/cli': patch |
||||
--- |
||||
|
||||
Require at least 1 chain selection in warp init |
||||
|
@ -0,0 +1,5 @@ |
||||
--- |
||||
'@hyperlane-xyz/core': minor |
||||
--- |
||||
|
||||
Implement checkpoint fraud proofs for use in slashing |
@ -0,0 +1,143 @@ |
||||
// SPDX-License-Identifier: MIT OR Apache-2.0 |
||||
pragma solidity >=0.8.0; |
||||
|
||||
import {Address} from "@openzeppelin/contracts/utils/Address.sol"; |
||||
|
||||
import {TypeCasts} from "./libs/TypeCasts.sol"; |
||||
import {Checkpoint, CheckpointLib} from "./libs/CheckpointLib.sol"; |
||||
import {MerkleLib, TREE_DEPTH} from "./libs/Merkle.sol"; |
||||
import {MerkleTreeHook} from "./hooks/MerkleTreeHook.sol"; |
||||
import {IMailbox} from "./interfaces/IMailbox.sol"; |
||||
|
||||
struct StoredIndex { |
||||
uint32 index; |
||||
bool exists; |
||||
} |
||||
|
||||
contract CheckpointFraudProofs { |
||||
using CheckpointLib for Checkpoint; |
||||
using Address for address; |
||||
|
||||
mapping(address merkleTree => mapping(bytes32 root => StoredIndex index)) |
||||
public storedCheckpoints; |
||||
|
||||
function storedCheckpointContainsMessage( |
||||
address merkleTree, |
||||
uint32 index, |
||||
bytes32 messageId, |
||||
bytes32[TREE_DEPTH] calldata proof |
||||
) public view returns (bool) { |
||||
bytes32 root = MerkleLib.branchRoot(messageId, proof, index); |
||||
StoredIndex storage storedIndex = storedCheckpoints[merkleTree][root]; |
||||
return storedIndex.exists && storedIndex.index >= index; |
||||
} |
||||
|
||||
modifier onlyMessageInStoredCheckpoint( |
||||
Checkpoint calldata checkpoint, |
||||
bytes32[TREE_DEPTH] calldata proof, |
||||
bytes32 messageId |
||||
) { |
||||
require( |
||||
storedCheckpointContainsMessage( |
||||
checkpoint.merkleTreeAddress(), |
||||
checkpoint.index, |
||||
messageId, |
||||
proof |
||||
), |
||||
"message must be member of stored checkpoint" |
||||
); |
||||
_; |
||||
} |
||||
|
||||
function isLocal( |
||||
Checkpoint calldata checkpoint |
||||
) public view returns (bool) { |
||||
address merkleTree = checkpoint.merkleTreeAddress(); |
||||
return |
||||
merkleTree.isContract() && |
||||
MerkleTreeHook(merkleTree).localDomain() == checkpoint.origin; |
||||
} |
||||
|
||||
modifier onlyLocal(Checkpoint calldata checkpoint) { |
||||
require(isLocal(checkpoint), "must be local checkpoint"); |
||||
_; |
||||
} |
||||
|
||||
/** |
||||
* @notice Stores the latest checkpoint of the provided merkle tree hook |
||||
* @param merkleTree Address of the merkle tree hook to store the latest checkpoint of. |
||||
* @dev Must be called before proving fraud to circumvent race on message insertion and merkle proof construction. |
||||
*/ |
||||
function storeLatestCheckpoint( |
||||
address merkleTree |
||||
) external returns (bytes32 root, uint32 index) { |
||||
(root, index) = MerkleTreeHook(merkleTree).latestCheckpoint(); |
||||
storedCheckpoints[merkleTree][root] = StoredIndex(index, true); |
||||
} |
||||
|
||||
/** |
||||
* @notice Checks whether the provided checkpoint is premature (fraud). |
||||
* @param checkpoint Checkpoint to check. |
||||
* @dev Checks whether checkpoint.index is greater than or equal to mailbox count |
||||
* @return Whether the provided checkpoint is premature. |
||||
*/ |
||||
function isPremature( |
||||
Checkpoint calldata checkpoint |
||||
) public view onlyLocal(checkpoint) returns (bool) { |
||||
// count is the number of messages in the mailbox (i.e. the latest index + 1) |
||||
uint32 count = MerkleTreeHook(checkpoint.merkleTreeAddress()).count(); |
||||
|
||||
// index >= count is equivalent to index > latest index |
||||
return checkpoint.index >= count; |
||||
} |
||||
|
||||
/** |
||||
* @notice Checks whether the provided checkpoint has a fraudulent message ID. |
||||
* @param checkpoint Checkpoint to check. |
||||
* @param proof Merkle proof of the actual message ID at checkpoint.index on checkpoint.merkleTree |
||||
* @param actualMessageId Actual message ID at checkpoint.index on checkpoint.merkleTree |
||||
* @dev Must produce proof of inclusion for actualMessageID against some stored checkpoint. |
||||
* @return Whether the provided checkpoint has a fraudulent message ID. |
||||
*/ |
||||
function isFraudulentMessageId( |
||||
Checkpoint calldata checkpoint, |
||||
bytes32[TREE_DEPTH] calldata proof, |
||||
bytes32 actualMessageId |
||||
) |
||||
public |
||||
view |
||||
onlyLocal(checkpoint) |
||||
onlyMessageInStoredCheckpoint(checkpoint, proof, actualMessageId) |
||||
returns (bool) |
||||
{ |
||||
return actualMessageId != checkpoint.messageId; |
||||
} |
||||
|
||||
/** |
||||
* @notice Checks whether the provided checkpoint has a fraudulent root. |
||||
* @param checkpoint Checkpoint to check. |
||||
* @param proof Merkle proof of the checkpoint.messageId at checkpoint.index on checkpoint.merkleTree |
||||
* @dev Must produce proof of inclusion for checkpoint.messageId against some stored checkpoint. |
||||
* @return Whether the provided checkpoint has a fraudulent message ID. |
||||
*/ |
||||
function isFraudulentRoot( |
||||
Checkpoint calldata checkpoint, |
||||
bytes32[TREE_DEPTH] calldata proof |
||||
) |
||||
public |
||||
view |
||||
onlyLocal(checkpoint) |
||||
onlyMessageInStoredCheckpoint(checkpoint, proof, checkpoint.messageId) |
||||
returns (bool) |
||||
{ |
||||
// proof of checkpoint.messageId at checkpoint.index is the list of siblings from the leaf node to some stored root |
||||
// once verifying the proof, we can reconstruct the specific root at checkpoint.index by replacing siblings greater |
||||
// than the index (right subtrees) with zeroes |
||||
bytes32 root = MerkleLib.reconstructRoot( |
||||
checkpoint.messageId, |
||||
proof, |
||||
checkpoint.index |
||||
); |
||||
return root != checkpoint.root; |
||||
} |
||||
} |
@ -0,0 +1,295 @@ |
||||
// SPDX-License-Identifier: Apache-2.0 |
||||
pragma solidity ^0.8.13; |
||||
|
||||
import "forge-std/Test.sol"; |
||||
|
||||
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; |
||||
|
||||
import "../contracts/libs/TypeCasts.sol"; |
||||
import "../contracts/test/TestMailbox.sol"; |
||||
import "../contracts/CheckpointFraudProofs.sol"; |
||||
import "../contracts/test/TestMerkleTreeHook.sol"; |
||||
import "../contracts/test/TestPostDispatchHook.sol"; |
||||
|
||||
// must have keys ordered alphabetically |
||||
struct Proof { |
||||
uint32 index; |
||||
bytes32 leaf; |
||||
bytes32[] path; // cannot be static length or json parsing breaks |
||||
} |
||||
|
||||
// must have keys ordered alphabetically |
||||
struct Fixture { |
||||
bytes32 expectedRoot; |
||||
string[] leaves; |
||||
Proof[] proofs; |
||||
string testName; |
||||
} |
||||
|
||||
uint8 constant FIXTURE_COUNT = 5; |
||||
|
||||
contract CheckpointFraudProofsTest is Test { |
||||
using TypeCasts for address; |
||||
using stdJson for string; |
||||
|
||||
uint32 localDomain = 1000; |
||||
uint32 remoteDomain = 2000; |
||||
|
||||
string json = vm.readFile("../vectors/merkle.json"); |
||||
|
||||
TestMailbox mailbox; |
||||
TestMerkleTreeHook merkleTreeHook; |
||||
|
||||
CheckpointFraudProofs cfp; |
||||
|
||||
function setUp() public { |
||||
mailbox = new TestMailbox(localDomain); |
||||
cfp = new CheckpointFraudProofs(); |
||||
} |
||||
|
||||
function loadFixture( |
||||
uint32 fixtureIndex |
||||
) |
||||
internal |
||||
returns ( |
||||
Checkpoint[] memory checkpoints, |
||||
bytes32[TREE_DEPTH][] memory proofs |
||||
) |
||||
{ |
||||
bytes memory data = json.parseRaw( |
||||
string.concat(".[", vm.toString(fixtureIndex), "]") |
||||
); |
||||
Fixture memory fixture = abi.decode(data, (Fixture)); |
||||
console.log(fixture.testName); |
||||
|
||||
merkleTreeHook = new TestMerkleTreeHook(address(mailbox)); |
||||
bytes32 merkleBytes = address(merkleTreeHook).addressToBytes32(); |
||||
|
||||
checkpoints = new Checkpoint[](fixture.leaves.length); |
||||
proofs = new bytes32[TREE_DEPTH][](fixture.proofs.length); |
||||
|
||||
for (uint32 index = 0; index < fixture.leaves.length; index++) { |
||||
bytes32 leaf = ECDSA.toEthSignedMessageHash( |
||||
abi.encodePacked(fixture.leaves[index]) |
||||
); |
||||
merkleTreeHook.insert(leaf); |
||||
checkpoints[index] = Checkpoint( |
||||
localDomain, |
||||
merkleBytes, |
||||
merkleTreeHook.root(), |
||||
index, |
||||
leaf |
||||
); |
||||
proofs[index] = parseProof(fixture.proofs[index]); |
||||
} |
||||
|
||||
assert(fixture.expectedRoot == merkleTreeHook.root()); |
||||
} |
||||
|
||||
function parseProof( |
||||
Proof memory proof |
||||
) internal pure returns (bytes32[TREE_DEPTH] memory path) { |
||||
for (uint8 i = 0; i < proof.path.length; i++) { |
||||
path[i] = proof.path[i]; |
||||
} |
||||
} |
||||
|
||||
function test_isLocal(uint8 fixtureIndex) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
|
||||
(Checkpoint[] memory checkpoints, ) = loadFixture(fixtureIndex); |
||||
|
||||
for (uint32 i = 0; i < checkpoints.length; i++) { |
||||
assertTrue(cfp.isLocal(checkpoints[i])); |
||||
checkpoints[i].origin = remoteDomain; |
||||
assertFalse(cfp.isLocal(checkpoints[i])); |
||||
} |
||||
} |
||||
|
||||
function test_isPremature(uint8 fixtureIndex) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
(Checkpoint[] memory checkpoints, ) = loadFixture(fixtureIndex); |
||||
|
||||
for (uint32 i = 0; i < checkpoints.length; i++) { |
||||
assertFalse(cfp.isPremature(checkpoints[i])); |
||||
} |
||||
|
||||
Checkpoint memory prematureCheckpoint = Checkpoint( |
||||
localDomain, |
||||
address(merkleTreeHook).addressToBytes32(), |
||||
0, |
||||
merkleTreeHook.count(), |
||||
0 |
||||
); |
||||
assertTrue(cfp.isPremature(prematureCheckpoint)); |
||||
|
||||
merkleTreeHook.insert(bytes32("0xdeadbeef")); |
||||
assertFalse(cfp.isPremature(prematureCheckpoint)); |
||||
} |
||||
|
||||
function test_RevertWhenNotLocal_isPremature(uint8 fixtureIndex) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
(Checkpoint[] memory checkpoints, ) = loadFixture(fixtureIndex); |
||||
for (uint32 i = 0; i < checkpoints.length; i++) { |
||||
checkpoints[i].origin = remoteDomain; |
||||
vm.expectRevert("must be local checkpoint"); |
||||
cfp.isPremature(checkpoints[i]); |
||||
} |
||||
} |
||||
|
||||
function test_isFraudulentMessageId(uint8 fixtureIndex) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
|
||||
( |
||||
Checkpoint[] memory checkpoints, |
||||
bytes32[32][] memory proofs |
||||
) = loadFixture(fixtureIndex); |
||||
|
||||
// cannot store checkpoint when count is 0 |
||||
if (checkpoints.length != 0) { |
||||
cfp.storeLatestCheckpoint(address(merkleTreeHook)); |
||||
} |
||||
|
||||
for (uint32 i = 0; i < checkpoints.length; i++) { |
||||
assertFalse( |
||||
cfp.isFraudulentMessageId( |
||||
checkpoints[i], |
||||
proofs[i], |
||||
checkpoints[i].messageId |
||||
) |
||||
); |
||||
bytes32 actualMessageId = checkpoints[i].messageId; |
||||
checkpoints[i].messageId = ~actualMessageId; |
||||
assertTrue( |
||||
cfp.isFraudulentMessageId( |
||||
checkpoints[i], |
||||
proofs[i], |
||||
actualMessageId |
||||
) |
||||
); |
||||
} |
||||
} |
||||
|
||||
function test_RevertWhenNotStored_isFraudulentMessageId( |
||||
uint8 fixtureIndex |
||||
) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
|
||||
( |
||||
Checkpoint[] memory checkpoints, |
||||
bytes32[32][] memory proofs |
||||
) = loadFixture(fixtureIndex); |
||||
|
||||
for (uint32 index = 0; index < checkpoints.length; index++) { |
||||
vm.expectRevert("message must be member of stored checkpoint"); |
||||
cfp.isFraudulentMessageId( |
||||
checkpoints[index], |
||||
proofs[index], |
||||
checkpoints[index].messageId |
||||
); |
||||
} |
||||
|
||||
// cannot store checkpoint when count is 0 |
||||
if (checkpoints.length != 0) { |
||||
cfp.storeLatestCheckpoint(address(merkleTreeHook)); |
||||
} |
||||
|
||||
// providing an invalid merkle proof should revert with not stored |
||||
for (uint32 index = 0; index < checkpoints.length; index++) { |
||||
proofs[index][0] = ~proofs[index][0]; |
||||
vm.expectRevert("message must be member of stored checkpoint"); |
||||
cfp.isFraudulentMessageId( |
||||
checkpoints[index], |
||||
proofs[index], |
||||
checkpoints[index].messageId |
||||
); |
||||
} |
||||
} |
||||
|
||||
function test_RevertWhenNotLocal_isFraudulentMessageId( |
||||
uint8 fixtureIndex |
||||
) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
|
||||
( |
||||
Checkpoint[] memory checkpoints, |
||||
bytes32[32][] memory proofs |
||||
) = loadFixture(fixtureIndex); |
||||
|
||||
for (uint32 i = 0; i < checkpoints.length; i++) { |
||||
checkpoints[i].origin = remoteDomain; |
||||
vm.expectRevert("must be local checkpoint"); |
||||
cfp.isFraudulentMessageId( |
||||
checkpoints[i], |
||||
proofs[i], |
||||
checkpoints[i].messageId |
||||
); |
||||
} |
||||
} |
||||
|
||||
function test_IsFraudulentRoot(uint8 fixtureIndex) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
|
||||
( |
||||
Checkpoint[] memory checkpoints, |
||||
bytes32[32][] memory proofs |
||||
) = loadFixture(fixtureIndex); |
||||
|
||||
// cannot store checkpoint when count is 0 |
||||
if (checkpoints.length != 0) { |
||||
cfp.storeLatestCheckpoint(address(merkleTreeHook)); |
||||
} |
||||
|
||||
// check all messages against latest stored checkpoint |
||||
for (uint32 i = 0; i < checkpoints.length; i++) { |
||||
assertFalse(cfp.isFraudulentRoot(checkpoints[i], proofs[i])); |
||||
checkpoints[i].root = ~checkpoints[i].root; |
||||
assertTrue(cfp.isFraudulentRoot(checkpoints[i], proofs[i])); |
||||
} |
||||
} |
||||
|
||||
function test_RevertWhenNotStored_isFraudulentRoot( |
||||
uint8 fixtureIndex |
||||
) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
|
||||
( |
||||
Checkpoint[] memory checkpoints, |
||||
bytes32[32][] memory proofs |
||||
) = loadFixture(fixtureIndex); |
||||
|
||||
for (uint32 index = 0; index < checkpoints.length; index++) { |
||||
vm.expectRevert("message must be member of stored checkpoint"); |
||||
cfp.isFraudulentRoot(checkpoints[index], proofs[index]); |
||||
} |
||||
|
||||
// cannot store checkpoint when count is 0 |
||||
if (checkpoints.length != 0) { |
||||
cfp.storeLatestCheckpoint(address(merkleTreeHook)); |
||||
} |
||||
|
||||
// providing an invalid merkle proof should revert with not stored |
||||
for (uint32 index = 0; index < checkpoints.length; index++) { |
||||
proofs[index][0] = ~proofs[index][0]; |
||||
vm.expectRevert("message must be member of stored checkpoint"); |
||||
cfp.isFraudulentRoot(checkpoints[index], proofs[index]); |
||||
} |
||||
} |
||||
|
||||
function test_RevertWhenNotLocal_isFraudulentRoot( |
||||
uint8 fixtureIndex |
||||
) public { |
||||
vm.assume(fixtureIndex < FIXTURE_COUNT); |
||||
|
||||
( |
||||
Checkpoint[] memory checkpoints, |
||||
bytes32[32][] memory proofs |
||||
) = loadFixture(fixtureIndex); |
||||
|
||||
for (uint32 i = 0; i < checkpoints.length; i++) { |
||||
checkpoints[i].origin = remoteDomain; |
||||
vm.expectRevert("must be local checkpoint"); |
||||
cfp.isFraudulentRoot(checkpoints[i], proofs[i]); |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue