feat: attributable fraud for signers (#4284)

### Description

Implements fraud proof attributions for checkpoint signers (validators).
It is important to have a record of attribution in storage for slashing
replay protection. We also maintain a [whitelist of merkle tree
hooks](b14f997810/solidity/contracts/libs/CheckpointLib.sol (L50-L54))
such that validators cannot simply lie about this part of the checkpoint
and circumvent fraud proofs.

### Backward compatibility

Yes

### Testing

Unit Tests
pull/4305/head
Yorke Rhodes 3 months ago committed by GitHub
parent c2c5bb9bbc
commit 76f7ecafff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      .changeset/real-guests-search.md
  2. 5
      .changeset/short-cobras-wink.md
  3. 140
      solidity/contracts/AttributeCheckpointFraud.sol
  4. 2
      solidity/contracts/libs/CheckpointLib.sol
  5. 202
      solidity/test/AttributeCheckpointFraud.t.sol

@ -1,5 +1,5 @@
---
"@hyperlane-xyz/core": patch
'@hyperlane-xyz/core': patch
---
fix: only evaluate dynamic revert reasons in reverting branch

@ -0,0 +1,5 @@
---
"@hyperlane-xyz/core": minor
---
feat: attributable fraud for signers

@ -0,0 +1,140 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
pragma solidity >=0.8.0;
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 {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;
}
/**
* @title AttributeCheckpointFraud
* @dev The AttributeCheckpointFraud contract is used to attribute fraud to a specific ECDSA checkpoint signer.
*/
contract AttributeCheckpointFraud is Ownable {
using CheckpointLib for Checkpoint;
using Address for address;
CheckpointFraudProofs public immutable checkpointFraudProofs =
new CheckpointFraudProofs();
mapping(address => bool) public merkleTreeWhitelist;
mapping(address signer => mapping(bytes32 digest => Attribution))
internal _attributions;
function _recover(
Checkpoint calldata checkpoint,
bytes calldata signature
) internal pure returns (address signer, bytes32 digest) {
digest = checkpoint.digest();
signer = ECDSA.recover(digest, signature);
}
function _attribute(
bytes calldata signature,
Checkpoint calldata checkpoint,
FraudType fraudType
) internal {
(address signer, bytes32 digest) = _recover(checkpoint, signature);
require(
_attributions[signer][digest].timestamp == 0,
"fraud already attributed to signer for digest"
);
_attributions[signer][digest] = Attribution({
fraudType: fraudType,
timestamp: uint48(block.timestamp)
});
}
function attributions(
Checkpoint calldata checkpoint,
bytes calldata signature
) external view returns (Attribution memory) {
(address signer, bytes32 digest) = _recover(checkpoint, signature);
return _attributions[signer][digest];
}
function whitelist(address merkleTree) external onlyOwner {
require(
merkleTree.isContract(),
"merkle tree must be a valid contract"
);
merkleTreeWhitelist[merkleTree] = true;
}
function attributeWhitelist(
Checkpoint calldata checkpoint,
bytes calldata signature
) external {
require(
checkpointFraudProofs.isLocal(checkpoint),
"checkpoint must be local"
);
require(
!merkleTreeWhitelist[checkpoint.merkleTreeAddress()],
"merkle tree is whitelisted"
);
_attribute(signature, checkpoint, FraudType.Whitelist);
}
function attributePremature(
Checkpoint calldata checkpoint,
bytes calldata signature
) external {
require(
checkpointFraudProofs.isPremature(checkpoint),
"checkpoint must be premature"
);
_attribute(signature, checkpoint, FraudType.Premature);
}
function attributeMessageId(
Checkpoint calldata checkpoint,
bytes32[TREE_DEPTH] calldata proof,
bytes32 actualMessageId,
bytes calldata signature
) external {
require(
checkpointFraudProofs.isFraudulentMessageId(
checkpoint,
proof,
actualMessageId
),
"checkpoint must have fraudulent message ID"
);
_attribute(signature, checkpoint, FraudType.MessageId);
}
function attributeRoot(
Checkpoint calldata checkpoint,
bytes32[TREE_DEPTH] calldata proof,
bytes calldata signature
) external {
require(
checkpointFraudProofs.isFraudulentRoot(checkpoint, proof),
"checkpoint must have fraudulent root"
);
_attribute(signature, checkpoint, FraudType.Root);
}
}

@ -52,7 +52,7 @@ library CheckpointLib {
* @return The digest of the checkpoint.
*/
function digest(
Checkpoint calldata checkpoint
Checkpoint memory checkpoint
) internal pure returns (bytes32) {
return
digest(

@ -0,0 +1,202 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../contracts/libs/CheckpointLib.sol";
import "../contracts/test/TestMailbox.sol";
import "../contracts/test/TestMerkleTreeHook.sol";
import "../contracts/CheckpointFraudProofs.sol";
import "../contracts/AttributeCheckpointFraud.sol";
contract AttributeCheckpointFraudTest is Test {
using CheckpointLib for Checkpoint;
using TypeCasts for address;
uint32 domain = 1;
TestMailbox mailbox;
TestMerkleTreeHook merkleTreeHook;
AttributeCheckpointFraud acf;
function setUp() public {
acf = new AttributeCheckpointFraud();
mailbox = new TestMailbox(domain);
merkleTreeHook = new TestMerkleTreeHook(address(mailbox));
}
function test_whitelist() public {
vm.expectRevert("merkle tree must be a valid contract");
acf.whitelist(address(0));
vm.prank(address(0x1));
vm.expectRevert("Ownable: caller is not the owner");
acf.whitelist(address(merkleTreeHook));
acf.whitelist(address(merkleTreeHook));
assert(acf.merkleTreeWhitelist(address(merkleTreeHook)));
}
function sign(
Checkpoint memory checkpoint,
uint256 privateKey
) internal pure returns (bytes memory signature) {
vm.assume(
privateKey > 0 &&
// private key must be less than the secp256k1 curve order
privateKey <
115792089237316195423570985008687907852837564279074904382605163141518161494337
);
bytes32 digest = checkpoint.digest();
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
return abi.encodePacked(r, s, v);
}
function test_attributeWhitelist(
Checkpoint memory checkpoint,
uint256 privateKey
) public {
checkpoint.origin = domain;
checkpoint.merkleTree = address(merkleTreeHook).addressToBytes32();
bytes memory signature = sign(checkpoint, privateKey);
acf.attributeWhitelist(checkpoint, signature);
assert(
acf.attributions(checkpoint, signature).fraudType ==
FraudType.Whitelist
);
vm.expectRevert("fraud already attributed to signer for digest");
acf.attributeWhitelist(checkpoint, signature);
acf.whitelist(address(merkleTreeHook));
vm.expectRevert("merkle tree is whitelisted");
acf.attributeWhitelist(checkpoint, signature);
checkpoint.origin = domain + 1;
vm.expectRevert("checkpoint must be local");
acf.attributeWhitelist(checkpoint, signature);
}
function test_attributePremature(
Checkpoint calldata checkpoint,
uint256 privateKey
) public {
bytes memory signature = sign(checkpoint, privateKey);
vm.mockCall(
address(acf.checkpointFraudProofs()),
abi.encodeWithSelector(
CheckpointFraudProofs.isPremature.selector,
checkpoint
),
abi.encode(false)
);
vm.expectRevert("checkpoint must be premature");
acf.attributePremature(checkpoint, signature);
vm.mockCall(
address(acf.checkpointFraudProofs()),
abi.encodeWithSelector(
CheckpointFraudProofs.isPremature.selector,
checkpoint
),
abi.encode(true)
);
acf.attributePremature(checkpoint, signature);
assert(
acf.attributions(checkpoint, signature).fraudType ==
FraudType.Premature
);
vm.expectRevert("fraud already attributed to signer for digest");
acf.attributePremature(checkpoint, signature);
}
function test_attributeMessageId(
Checkpoint memory checkpoint,
bytes32[TREE_DEPTH] calldata proof,
uint256 privateKey
) public {
bytes memory signature = sign(checkpoint, privateKey);
vm.mockCall(
address(acf.checkpointFraudProofs()),
abi.encodeWithSelector(
CheckpointFraudProofs.isFraudulentMessageId.selector
),
abi.encode(false)
);
vm.expectRevert("checkpoint must have fraudulent message ID");
acf.attributeMessageId(
checkpoint,
proof,
checkpoint.messageId,
signature
);
vm.mockCall(
address(acf.checkpointFraudProofs()),
abi.encodeWithSelector(
CheckpointFraudProofs.isFraudulentMessageId.selector
),
abi.encode(true)
);
acf.attributeMessageId(
checkpoint,
proof,
checkpoint.messageId,
signature
);
assert(
acf.attributions(checkpoint, signature).fraudType ==
FraudType.MessageId
);
vm.expectRevert("fraud already attributed to signer for digest");
acf.attributeMessageId(
checkpoint,
proof,
checkpoint.messageId,
signature
);
}
function test_attributeRoot(
Checkpoint memory checkpoint,
bytes32[TREE_DEPTH] calldata proof,
uint256 privateKey
) public {
bytes memory signature = sign(checkpoint, privateKey);
vm.mockCall(
address(acf.checkpointFraudProofs()),
abi.encodeWithSelector(
CheckpointFraudProofs.isFraudulentRoot.selector
),
abi.encode(false)
);
vm.expectRevert("checkpoint must have fraudulent root");
acf.attributeRoot(checkpoint, proof, signature);
vm.mockCall(
address(acf.checkpointFraudProofs()),
abi.encodeWithSelector(
CheckpointFraudProofs.isFraudulentRoot.selector
),
abi.encode(true)
);
acf.attributeRoot(checkpoint, proof, signature);
assert(
acf.attributions(checkpoint, signature).fraudType == FraudType.Root
);
vm.expectRevert("fraud already attributed to signer for digest");
acf.attributeRoot(checkpoint, proof, signature);
}
}
Loading…
Cancel
Save