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
parent
c2c5bb9bbc
commit
76f7ecafff
@ -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); |
||||
} |
||||
} |
@ -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…
Reference in new issue