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 code
pull/4302/head
Yorke Rhodes 3 months ago committed by GitHub
parent d1bf212bf7
commit cb404cb85c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .changeset/fast-schools-battle.md
  2. 5
      .changeset/two-tigers-sniff.md
  3. 143
      solidity/contracts/CheckpointFraudProofs.sol
  4. 50
      solidity/contracts/libs/CheckpointLib.sol
  5. 36
      solidity/contracts/libs/Merkle.sol
  6. 1
      solidity/foundry.toml
  7. 295
      solidity/test/CheckpointFraudProofs.t.sol

@ -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;
}
}

@ -1,14 +1,24 @@
// SPDX-License-Identifier: MIT OR Apache-2.0 // SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0; pragma solidity >=0.8.0;
// ============ External Imports ============
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {TypeCasts} from "./TypeCasts.sol";
struct Checkpoint {
uint32 origin;
bytes32 merkleTree;
bytes32 root;
uint32 index;
bytes32 messageId;
}
library CheckpointLib { library CheckpointLib {
using TypeCasts for bytes32;
/** /**
* @notice Returns the digest validators are expected to sign when signing checkpoints. * @notice Returns the digest validators are expected to sign when signing checkpoints.
* @param _origin The origin domain of the checkpoint. * @param _origin The origin domain of the checkpoint.
* @param _originmerkleTreeHook The address of the origin merkle tree hook as bytes32. * @param _merkleTreeHook The address of the origin merkle tree hook as bytes32.
* @param _checkpointRoot The root of the checkpoint. * @param _checkpointRoot The root of the checkpoint.
* @param _checkpointIndex The index of the checkpoint. * @param _checkpointIndex The index of the checkpoint.
* @param _messageId The message ID of the checkpoint. * @param _messageId The message ID of the checkpoint.
@ -17,12 +27,12 @@ library CheckpointLib {
*/ */
function digest( function digest(
uint32 _origin, uint32 _origin,
bytes32 _originmerkleTreeHook, bytes32 _merkleTreeHook,
bytes32 _checkpointRoot, bytes32 _checkpointRoot,
uint32 _checkpointIndex, uint32 _checkpointIndex,
bytes32 _messageId bytes32 _messageId
) internal pure returns (bytes32) { ) internal pure returns (bytes32) {
bytes32 _domainHash = domainHash(_origin, _originmerkleTreeHook); bytes32 _domainHash = domainHash(_origin, _merkleTreeHook);
return return
ECDSA.toEthSignedMessageHash( ECDSA.toEthSignedMessageHash(
keccak256( keccak256(
@ -36,16 +46,40 @@ library CheckpointLib {
); );
} }
/**
* @notice Returns the digest validators are expected to sign when signing checkpoints.
* @param checkpoint The checkpoint (struct) to hash.
* @return The digest of the checkpoint.
*/
function digest(
Checkpoint calldata checkpoint
) internal pure returns (bytes32) {
return
digest(
checkpoint.origin,
checkpoint.merkleTree,
checkpoint.root,
checkpoint.index,
checkpoint.messageId
);
}
function merkleTreeAddress(
Checkpoint calldata checkpoint
) internal pure returns (address) {
return checkpoint.merkleTree.bytes32ToAddress();
}
/** /**
* @notice Returns the domain hash that validators are expected to use * @notice Returns the domain hash that validators are expected to use
* when signing checkpoints. * when signing checkpoints.
* @param _origin The origin domain of the checkpoint. * @param _origin The origin domain of the checkpoint.
* @param _originmerkleTreeHook The address of the origin merkle tree as bytes32. * @param _merkleTreeHook The address of the origin merkle tree as bytes32.
* @return The domain hash. * @return The domain hash.
*/ */
function domainHash( function domainHash(
uint32 _origin, uint32 _origin,
bytes32 _originmerkleTreeHook bytes32 _merkleTreeHook
) internal pure returns (bytes32) { ) internal pure returns (bytes32) {
// Including the origin merkle tree address in the signature allows the slashing // Including the origin merkle tree address in the signature allows the slashing
// protocol to enroll multiple trees. Otherwise, a valid signature for // protocol to enroll multiple trees. Otherwise, a valid signature for
@ -53,8 +87,6 @@ library CheckpointLib {
// The slashing protocol should slash if validators sign attestations for // The slashing protocol should slash if validators sign attestations for
// anything other than a whitelisted tree. // anything other than a whitelisted tree.
return return
keccak256( keccak256(abi.encodePacked(_origin, _merkleTreeHook, "HYPERLANE"));
abi.encodePacked(_origin, _originmerkleTreeHook, "HYPERLANE")
);
} }
} }

@ -3,15 +3,15 @@ pragma solidity >=0.6.11;
// work based on eth2 deposit contract, which is used under CC0-1.0 // work based on eth2 deposit contract, which is used under CC0-1.0
uint256 constant TREE_DEPTH = 32;
uint256 constant MAX_LEAVES = 2 ** TREE_DEPTH - 1;
/** /**
* @title MerkleLib * @title MerkleLib
* @author Celo Labs Inc. * @author Celo Labs Inc.
* @notice An incremental merkle tree modeled on the eth2 deposit contract. * @notice An incremental merkle tree modeled on the eth2 deposit contract.
**/ **/
library MerkleLib { library MerkleLib {
uint256 internal constant TREE_DEPTH = 32;
uint256 internal constant MAX_LEAVES = 2 ** TREE_DEPTH - 1;
/** /**
* @notice Struct representing incremental merkle tree. Contains current * @notice Struct representing incremental merkle tree. Contains current
* branch and the number of inserted leaves in the tree. * branch and the number of inserted leaves in the tree.
@ -140,6 +140,36 @@ library MerkleLib {
} }
} }
/**
* @notice Calculates and returns the merkle root as if the index is
* the topmost leaf in the tree.
* @param _item Merkle leaf
* @param _branch Merkle proof
* @param _index Index of `_item` in tree
* @dev Replaces siblings greater than the index (right subtrees) with zeroes.
* @return _current Calculated merkle root
**/
function reconstructRoot(
bytes32 _item,
bytes32[TREE_DEPTH] memory _branch, // cheaper than calldata indexing
uint256 _index
) internal pure returns (bytes32 _current) {
_current = _item;
bytes32[TREE_DEPTH] memory _zeroes = zeroHashes();
for (uint256 i = 0; i < TREE_DEPTH; i++) {
uint256 _ithBit = (_index >> i) & 0x01;
// cheaper than calldata indexing _branch[i*32:(i+1)*32];
if (_ithBit == 1) {
_current = keccak256(abi.encodePacked(_branch[i], _current));
} else {
// remove right subtree from proof
_current = keccak256(abi.encodePacked(_current, _zeroes[i]));
}
}
}
// keccak256 zero hashes // keccak256 zero hashes
bytes32 internal constant Z_0 = bytes32 internal constant Z_0 =
hex"0000000000000000000000000000000000000000000000000000000000000000"; hex"0000000000000000000000000000000000000000000000000000000000000000";

@ -12,6 +12,7 @@ optimizer = true
optimizer_runs = 999_999 optimizer_runs = 999_999
fs_permissions = [ fs_permissions = [
{ access = "read", path = "./script/avs/"}, { access = "read", path = "./script/avs/"},
{ access = "read", path = "../vectors" },
{ access = "write", path = "./fixtures" } { access = "write", path = "./fixtures" }
] ]
ignored_warnings_from = [ ignored_warnings_from = [

@ -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…
Cancel
Save