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 |
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