diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 0000000..06e9c74 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -0,0 +1,19 @@ +name: Contracts + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + - name: Run contract tests + run: forge test -vvv \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85198aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..5b577c9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "lib/openzeppelin-contracts-upgradeable"] + path = lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable.git diff --git a/foundry.toml b/foundry.toml new file mode 100644 index 0000000..5038d32 --- /dev/null +++ b/foundry.toml @@ -0,0 +1,11 @@ +[profile.default] +src = "src" +test = "test" +script = "script" +out = "out" +libs = ["lib"] +cache_path = "cache" +broadcast = "broadcast" +via_ir = true + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 0000000..10d5299 --- /dev/null +++ b/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 10d5299a89561a3fd2c864371de157cd294a129e diff --git a/src/KeyManager.sol b/src/KeyManager.sol new file mode 100644 index 0000000..ca2f506 --- /dev/null +++ b/src/KeyManager.sol @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +contract KeyManager is Initializable, OwnableUpgradeable, UUPSUpgradeable { + struct CommitteeMember { + /// @notice public key for consensus votes, also used as the primary label for a node + bytes sigKey; + /// @notice DH public key used for authenticated network messages + bytes dhKey; + /// @notice public key for encrypting DKG-specific payloads + bytes dkgKey; + /// @notice a network address: `ip:port` or `hostname:port` + string networkAddress; + } + + /// @notice The consensus committee rotates with each epoch, registered by contract `manager`. + /// @notice Timeboost makes the simplifying decision that this committee is exactly the keyset + struct Committee { + /// @notice unique identifier for the committee, assigned by this contract + uint64 id; + /// @notice wall clock time since unix epoch for this committee to be active + uint64 effectiveTimestamp; + /// @notice constituting members and their key materials + CommitteeMember[] members; + } + + /// @notice Emitted when a committee is created. + /// @param id The id of the committee. + event CommitteeCreated(uint64 indexed id); + + /// @notice Emitted when the threshold encryption key is set. + /// @param thresholdEncryptionKey The threshold encryption key. + event ThresholdEncryptionKeyUpdated(bytes thresholdEncryptionKey); + + /// @notice Emitted when the manager is changed. + /// @param oldManager The old manager. + /// @param newManager The new manager. + event ManagerChanged(address indexed oldManager, address indexed newManager); + + /// @notice Emitted when a committee is removed. + /// @param fromId The id of the first committee to prune. + /// @param toId The id of the last committee to prune. + event CommitteesPruned(uint64 indexed fromId, uint64 indexed toId); + + /// @notice Thrown when the caller is not the manager. + /// @param caller The address that called the function. + error NotManager(address caller); + + /// @notice Thrown when the address is invalid. + error InvalidAddress(); + + /// @notice Thrown when the threshold encryption key is already set. + error ThresholdEncryptionKeyAlreadySet(); + + /// @notice Thrown when the committee id does not exist. + /// @param committeeId The id of the committee. + error CommitteeIdDoesNotExist(uint64 committeeId); + /// @notice Thrown when the committee is empty. + error EmptyCommitteeMembers(); + /// @notice Thrown when the effective timestamp is invalid. + error InvalidEffectiveTimestamp(uint64 effectiveTimestamp, uint64 lastEffectiveTimestamp); + /// @notice Thrown when there is no committee scheduled. + error NoCommitteeScheduled(); + /// @notice Thrown when the committee id overflows. + error CommitteeIdOverflow(); + /// @notice Thrown when the committee is too recent to remove. + error CannotRemoveRecentCommittees(); + /// @notice Thrown when pruning with invalid range. + error InvalidPruneRange(uint64 upToCommitteeId, uint64 oldestStored, uint64 nextCommitteeId); + + /// @notice The threshold encryption key for the committee. + bytes public thresholdEncryptionKey; + /// @notice The mapping of committee ids to committees. + mapping(uint64 => Committee) public committees; + /// @notice The manager of the contract. + address public manager; + /// @notice The next committee id. + uint64 public nextCommitteeId; + /// @notice The oldest committee id still stored in the mapping + uint64 private _oldestStoredCommitteeId; + /// @notice The gap for future upgrades. + uint256[48] private __gap; + + /// @notice Modifier to check if the caller is the manager. + modifier onlyManager() { + _onlyManager(); + _; + } + + /// @notice Internal function to check if the caller is the manager. + function _onlyManager() internal view { + if (msg.sender != manager) { + revert NotManager(msg.sender); + } + } + + constructor() { + _disableInitializers(); + } + + /** + * @notice This function is used to initialize the contract. + * @dev Reverts if the manager is the zero address. + * @dev Assumes that the manager is valid. + * @dev This must be called once when the contract is first deployed. + * @param initialManager The initial manager. + */ + function initialize(address initialManager) external initializer { + if (initialManager == address(0)) { + revert InvalidAddress(); + } + __Ownable_init(msg.sender); + __UUPSUpgradeable_init(); + manager = initialManager; + } + + /** + * @notice This function is used to authorize the upgrade of the contract. + * @dev Reverts if the caller is not the owner. + * @dev Assumes that the new implementation is valid. + * @param newImplementation The new implementation. + */ + function _authorizeUpgrade(address newImplementation) internal override onlyOwner {} + + /** + * @notice This function is used to set the manager. + * @dev Reverts if the manager is the zero address or the same as the current manager. + * @dev Reverts if the caller is not the owner. + * @dev Assumes that the manager is valid. + * @param newManager The new manager. + */ + function setManager(address newManager) external virtual onlyOwner { + if (newManager == address(0) || newManager == manager) { + revert InvalidAddress(); + } + address oldManager = manager; + manager = newManager; + emit ManagerChanged(oldManager, newManager); + } + + /** + * @notice This function is used to set the threshold encryption key. + * @dev Reverts if the threshold encryption key is already set. + * @dev Reverts if the caller is not the manager. + * @dev Assumes that the threshold encryption key is valid. + * @param newThresholdEncryptionKey The threshold encryption key. + */ + function setThresholdEncryptionKey(bytes calldata newThresholdEncryptionKey) external virtual onlyManager { + if (thresholdEncryptionKey.length > 0) { + revert ThresholdEncryptionKeyAlreadySet(); + } + thresholdEncryptionKey = newThresholdEncryptionKey; + emit ThresholdEncryptionKeyUpdated(newThresholdEncryptionKey); + } + + /** + * @notice This function is used to set the next committee. + * @dev Reverts if the members array is empty. + * @dev Reverts if the effective timestamp is less than the last effective timestamp. + * @dev Reverts if the committees mapping is at uint64.max. + * @dev Assumes that the committee members are valid. + * @param effectiveTimestamp The effective timestamp of the committee. + * @param members The committee members. + * @return committeeId The id of the new committee. + */ + function setNextCommittee(uint64 effectiveTimestamp, CommitteeMember[] calldata members) + external + virtual + onlyManager + returns (uint64 committeeId) + { + if (members.length == 0) { + revert EmptyCommitteeMembers(); + } + + // ensure the effective timestamp is greater than the last effective timestamp + if (nextCommitteeId > 0) { + uint64 lastTimestamp = committees[nextCommitteeId - 1].effectiveTimestamp; + if (effectiveTimestamp <= lastTimestamp) { + revert InvalidEffectiveTimestamp(effectiveTimestamp, lastTimestamp); + } + } + + if (nextCommitteeId == type(uint64).max) revert CommitteeIdOverflow(); + + committees[nextCommitteeId] = + Committee({id: nextCommitteeId, effectiveTimestamp: effectiveTimestamp, members: members}); + + nextCommitteeId++; + + emit CommitteeCreated(nextCommitteeId - 1); + return nextCommitteeId - 1; + } + + /** + * @notice This function is used to get the committee by id. + * @dev Reverts if the id is greater than the length of the committees mapping. + * @dev Reverts if the id is less than the head committee id. + * @param id The id of the committee. + * @return committee The committee. + */ + function getCommitteeById(uint64 id) external view virtual returns (Committee memory committee) { + if (id < _oldestStoredCommitteeId || committees[id].id != id) { + revert CommitteeIdDoesNotExist(id); + } + + return committees[id]; + } + + /** + * @notice This function is used to get the current committee id. + * @dev Reverts if there is no committee scheduled at the current timestamp. + * @dev Searches backwards through existing committees to find the active one. + * @return committeeId The current committee id. + */ + function currentCommitteeId() public view virtual returns (uint64 committeeId) { + uint64 currentTimestamp = uint64(block.timestamp); + + if (nextCommitteeId == 0 || _oldestStoredCommitteeId >= nextCommitteeId) { + revert NoCommitteeScheduled(); + } + + // Search backwards from most recent committee to oldest stored + uint64 currCommitteeId = nextCommitteeId - 1; + while (currCommitteeId >= _oldestStoredCommitteeId) { + if (currentTimestamp >= committees[currCommitteeId].effectiveTimestamp) { + return currCommitteeId; + } + + if (currCommitteeId == 0) { + break; + } + + currCommitteeId--; + } + + revert NoCommitteeScheduled(); + } + + /** + * @notice Prunes all committees from _oldestStoredCommitteeId up to and including upToCommitteeId. + * @dev This matches timeboost's garbage collection behavior of removing old committees in bulk. + * @dev Reverts if upToCommitteeId is not in a valid range for pruning. + * @dev Reverts if any committee in the range became effective within the last 10 minutes. + * @param upToCommitteeId The highest committee ID to prune (inclusive). + */ + function pruneUntil(uint64 upToCommitteeId) external virtual onlyManager { + if (upToCommitteeId < _oldestStoredCommitteeId || upToCommitteeId >= nextCommitteeId) { + revert InvalidPruneRange(upToCommitteeId, _oldestStoredCommitteeId, nextCommitteeId); + } + + // Delete all committees in range + uint64 cutOffTime = uint64(block.timestamp - 10 minutes); + uint64 oldOldestStored = _oldestStoredCommitteeId; + for (uint64 id = _oldestStoredCommitteeId; id <= upToCommitteeId; id++) { + if (committees[id].effectiveTimestamp >= cutOffTime) { + revert CannotRemoveRecentCommittees(); + } + delete committees[id]; + } + + _oldestStoredCommitteeId = upToCommitteeId + 1; + + emit CommitteesPruned(oldOldestStored, upToCommitteeId); + } +} diff --git a/test/KeyManager.t.sol b/test/KeyManager.t.sol new file mode 100644 index 0000000..3b1eb7a --- /dev/null +++ b/test/KeyManager.t.sol @@ -0,0 +1,290 @@ + // SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console} from "forge-std/Test.sol"; +import {KeyManager} from "../src/KeyManager.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +contract KeyManagerTest is Test { + KeyManager public keyManagerProxy; + address public manager; + address public owner; + + function setUp() public { + owner = makeAddr("owner"); + manager = makeAddr("manager"); + KeyManager keyManagerImpl = new KeyManager(); + bytes memory data = abi.encodeWithSelector(KeyManager.initialize.selector, manager); + vm.prank(owner); + ERC1967Proxy proxy = new ERC1967Proxy(address(keyManagerImpl), data); + keyManagerProxy = KeyManager(address(proxy)); + } + + function createTestMembers() internal pure returns (KeyManager.CommitteeMember[] memory) { + KeyManager.CommitteeMember[] memory members = new KeyManager.CommitteeMember[](1); + bytes memory randomBytes = abi.encodePacked("1"); + members[0] = KeyManager.CommitteeMember({ + sigKey: randomBytes, + dhKey: randomBytes, + dkgKey: randomBytes, + networkAddress: "127.0.0.1:8080" + }); + return members; + } + + function test_setThresholdEncryptionKey() public { + bytes memory thresholdEncKey = abi.encodePacked("1"); + vm.prank(manager); + vm.expectEmit(true, true, true, true); + emit KeyManager.ThresholdEncryptionKeyUpdated(thresholdEncKey); + keyManagerProxy.setThresholdEncryptionKey(thresholdEncKey); + assertEq(keyManagerProxy.thresholdEncryptionKey(), thresholdEncKey); + } + + function test_setNextCommittee() public { + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + vm.prank(manager); + vm.expectEmit(true, true, true, true); + emit KeyManager.CommitteeCreated(0); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), committeeMembers); + + // Test accessing the committee data + KeyManager.Committee memory retrievedCommittee = keyManagerProxy.getCommitteeById(0); + assertEq(retrievedCommittee.effectiveTimestamp, uint64(block.timestamp)); + assertEq(retrievedCommittee.members.length, 1); + assertEq(retrievedCommittee.members[0].sigKey, committeeMembers[0].sigKey); + assertEq(retrievedCommittee.members[0].dhKey, committeeMembers[0].dhKey); + assertEq(retrievedCommittee.members[0].dkgKey, committeeMembers[0].dkgKey); + assertEq(retrievedCommittee.members[0].networkAddress, committeeMembers[0].networkAddress); + + // Test accessing the current committee + uint64 currentCommitteeId = keyManagerProxy.currentCommitteeId(); + assertEq(currentCommitteeId, 0); + retrievedCommittee = keyManagerProxy.getCommitteeById(currentCommitteeId); + assertEq(retrievedCommittee.effectiveTimestamp, uint64(block.timestamp)); + assertEq(retrievedCommittee.members.length, 1); + assertEq(retrievedCommittee.members[0].sigKey, committeeMembers[0].sigKey); + assertEq(retrievedCommittee.members[0].dhKey, committeeMembers[0].dhKey); + assertEq(retrievedCommittee.members[0].dkgKey, committeeMembers[0].dkgKey); + } + + function test_revertWhenEmptyCommittee_setNextCommittee() public { + vm.startPrank(manager); + vm.expectRevert(abi.encodeWithSelector(KeyManager.EmptyCommitteeMembers.selector)); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), new KeyManager.CommitteeMember[](0)); + vm.stopPrank(); + } + + function test_revertWhenInvalidEffectiveTimestamp_setNextCommittee() public { + KeyManager.CommitteeMember[] memory members = createTestMembers(); + + vm.startPrank(manager); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), members); + + // Try to create committee with earlier timestamp + vm.expectRevert( + abi.encodeWithSelector( + KeyManager.InvalidEffectiveTimestamp.selector, uint64(block.timestamp - 1), uint64(block.timestamp) + ) + ); + keyManagerProxy.setNextCommittee(uint64(block.timestamp - 1), members); + vm.stopPrank(); + } + + function test_setManager() public { + address newManager = makeAddr("newManager"); + vm.prank(owner); + vm.expectEmit(true, true, true, true); + emit KeyManager.ManagerChanged(manager, newManager); + keyManagerProxy.setManager(newManager); + assertEq(keyManagerProxy.manager(), newManager); + } + + function test_revertWhenInvalidAddress_setManager() public { + vm.startPrank(owner); + // revert for the zero address + vm.expectRevert(abi.encodeWithSelector(KeyManager.InvalidAddress.selector)); + keyManagerProxy.setManager(address(0)); + + // revert for the same manager + vm.expectRevert(abi.encodeWithSelector(KeyManager.InvalidAddress.selector)); + keyManagerProxy.setManager(manager); + vm.stopPrank(); + } + + function test_revertWhenNotOwner_setManager() public { + vm.startPrank(manager); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, manager)); + keyManagerProxy.setManager(manager); + vm.stopPrank(); + } + + function test_revertWhenNotManager_setThresholdEncryptionKey() public { + bytes memory thresholdEncKey = abi.encodePacked("1"); + vm.expectRevert(abi.encodeWithSelector(KeyManager.NotManager.selector, address(this))); + keyManagerProxy.setThresholdEncryptionKey(thresholdEncKey); + } + + function test_revertWhenThresholdEncryptionKeyAlreadySet_setThresholdEncryptionKey() public { + bytes memory thresholdEncKey = abi.encodePacked("1"); + vm.startPrank(manager); + keyManagerProxy.setThresholdEncryptionKey(thresholdEncKey); + vm.expectRevert(abi.encodeWithSelector(KeyManager.ThresholdEncryptionKeyAlreadySet.selector)); + keyManagerProxy.setThresholdEncryptionKey(thresholdEncKey); + vm.stopPrank(); + } + + function test_revertWhenNotManager_setNextCommittee() public { + vm.expectRevert(abi.encodeWithSelector(KeyManager.NotManager.selector, address(this))); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), new KeyManager.CommitteeMember[](0)); + + // the owner should not be able to schedule the committee as it's not the manager + // the owner can become the manager by calling setManager + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(KeyManager.NotManager.selector, owner)); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), new KeyManager.CommitteeMember[](0)); + } + + // Tests for currentCommitteeId function + function test_revertWhenEmptyCommittees_currentCommitteeId() public { + vm.expectRevert(abi.encodeWithSelector(KeyManager.NoCommitteeScheduled.selector)); + keyManagerProxy.currentCommitteeId(); + } + + function test_currentCommitteeId_oneCommitteeScheduled_effectiveNow() public { + // Create a committee that's effective now + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + uint64 effectiveTimestamp = uint64(block.timestamp); + vm.prank(manager); + keyManagerProxy.setNextCommittee(effectiveTimestamp, committeeMembers); + + uint64 currentCommitteeId = keyManagerProxy.currentCommitteeId(); + assertEq(currentCommitteeId, 0); + } + + function test_revertWhenNoCommitteeScheduledAtCurrentTimestamp_currentCommitteeId() public { + // Create a committee that's effective in the future + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + uint64 effectiveTimestamp = uint64(block.timestamp + 100); + vm.prank(manager); + keyManagerProxy.setNextCommittee(effectiveTimestamp, committeeMembers); + + vm.expectRevert(abi.encodeWithSelector(KeyManager.NoCommitteeScheduled.selector)); + keyManagerProxy.currentCommitteeId(); + } + + function test_currentCommitteeId_singleCommittee_effectiveInThePast() public { + // Create a committee that was effective in the past + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + uint64 effectiveTimestamp = 100; + vm.prank(manager); + keyManagerProxy.setNextCommittee(effectiveTimestamp, committeeMembers); + + vm.warp(101); + uint64 currentCommitteeId = keyManagerProxy.currentCommitteeId(); + assertEq(currentCommitteeId, 0); + } + + function test_currentCommitteeId_multipleCommittees() public { + // Create multiple committees with different timestamps + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + vm.startPrank(manager); + + // Committee 0: effective now + uint64 timestamp0 = uint64(block.timestamp); + keyManagerProxy.setNextCommittee(timestamp0, committeeMembers); + + // Committee 1: effective in 100 seconds + uint64 timestamp1 = uint64(block.timestamp + 100); + keyManagerProxy.setNextCommittee(timestamp1, committeeMembers); + + // Committee 2: effective in 200 seconds + uint64 timestamp2 = uint64(block.timestamp + 200); + keyManagerProxy.setNextCommittee(timestamp2, committeeMembers); + + vm.stopPrank(); + + // Test current committee (should be committee 0) + uint64 currentCommitteeId = keyManagerProxy.currentCommitteeId(); + assertEq(currentCommitteeId, 0); + + // Test at timestamp1 - only warp once to minimize gas + vm.warp(timestamp1); + currentCommitteeId = keyManagerProxy.currentCommitteeId(); + assertEq(currentCommitteeId, 1); + + // Test at timestamp2 - only warp once more + vm.warp(timestamp2); + currentCommitteeId = keyManagerProxy.currentCommitteeId(); + assertEq(currentCommitteeId, 2); + } + + function test_nextCommitteeId() public { + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + vm.startPrank(manager); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), committeeMembers); + keyManagerProxy.setNextCommittee(uint64(block.timestamp + 100), committeeMembers); + vm.stopPrank(); + + uint64 nextCommitteeId = keyManagerProxy.nextCommitteeId(); + assertEq(nextCommitteeId, 2); + } + + function test_pruneUntil() public { + KeyManager.CommitteeMember[] memory members = createTestMembers(); + + vm.startPrank(manager); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), members); + keyManagerProxy.setNextCommittee(uint64(block.timestamp + 10 minutes), members); + + vm.warp(uint64(block.timestamp + 20 minutes)); + + // Remove first committee + vm.expectEmit(true, true, true, true); + emit KeyManager.CommitteesPruned(0, 0); + keyManagerProxy.pruneUntil(0); + + // Verify first committee is deleted + vm.expectRevert(); + keyManagerProxy.getCommitteeById(0); + + // Verify second committee still exists + KeyManager.Committee memory committee1 = keyManagerProxy.getCommitteeById(1); + assertEq(committee1.id, 1); + vm.stopPrank(); + } + + function test_revertWhenCannotRemoveRecentCommittees_pruneUntil() public { + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + vm.startPrank(manager); + keyManagerProxy.setNextCommittee(uint64(block.timestamp), committeeMembers); + keyManagerProxy.setNextCommittee(uint64(block.timestamp + 10 minutes), committeeMembers); + keyManagerProxy.setNextCommittee(uint64(block.timestamp + 20 minutes), committeeMembers); + vm.warp(uint64(block.timestamp + 10 minutes)); + vm.expectRevert(abi.encodeWithSelector(KeyManager.CannotRemoveRecentCommittees.selector)); + keyManagerProxy.pruneUntil(0); + vm.expectRevert(abi.encodeWithSelector(KeyManager.CannotRemoveRecentCommittees.selector)); + keyManagerProxy.pruneUntil(1); + } + + function test_revertWhenInvalidPruneRange_pruneUntil() public { + vm.startPrank(manager); + vm.expectRevert(abi.encodeWithSelector(KeyManager.InvalidPruneRange.selector, 0, 0, 0)); + keyManagerProxy.pruneUntil(0); + + KeyManager.CommitteeMember[] memory committeeMembers = createTestMembers(); + + keyManagerProxy.setNextCommittee(uint64(block.timestamp), committeeMembers); + vm.expectRevert(abi.encodeWithSelector(KeyManager.InvalidPruneRange.selector, 1, 0, 1)); + keyManagerProxy.pruneUntil(1); + vm.stopPrank(); + } +}