diff --git a/packages/contracts-bedrock/snapshots/abi/DisputeMonitorHelper.json b/packages/contracts-bedrock/snapshots/abi/DisputeMonitorHelper.json new file mode 100644 index 00000000000..3af4e4c2f42 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/abi/DisputeMonitorHelper.json @@ -0,0 +1,89 @@ +[ + { + "inputs": [ + { + "internalType": "contract IDisputeGameFactory", + "name": "_factory", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_creationRangeStart", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_creationRangeEnd", + "type": "uint256" + } + ], + "name": "getUnresolvedGames", + "outputs": [ + { + "internalType": "contract IDisputeGame[]", + "name": "unresolvedGames_", + "type": "address[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IDisputeGameFactory", + "name": "_factory", + "type": "address" + }, + { + "internalType": "contract IDisputeGame", + "name": "_game", + "type": "address" + } + ], + "name": "isGameRegistered", + "outputs": [ + { + "internalType": "bool", + "name": "isValid_", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "contract IDisputeGameFactory", + "name": "_factory", + "type": "address" + }, + { + "internalType": "uint256", + "name": "_targetTimestamp", + "type": "uint256" + }, + { + "internalType": "enum DisputeMonitorHelper.SearchDirection", + "name": "_direction", + "type": "uint8" + } + ], + "name": "search", + "outputs": [ + { + "internalType": "uint256", + "name": "index_", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "DisputeMonitorHelper_InvalidSearchRange", + "type": "error" + } +] \ No newline at end of file diff --git a/packages/contracts-bedrock/snapshots/storageLayout/DisputeMonitorHelper.json b/packages/contracts-bedrock/snapshots/storageLayout/DisputeMonitorHelper.json new file mode 100644 index 00000000000..0637a088a01 --- /dev/null +++ b/packages/contracts-bedrock/snapshots/storageLayout/DisputeMonitorHelper.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/packages/contracts-bedrock/src/periphery/monitoring/DisputeMonitorHelper.sol b/packages/contracts-bedrock/src/periphery/monitoring/DisputeMonitorHelper.sol new file mode 100644 index 00000000000..8f5dee3a1c2 --- /dev/null +++ b/packages/contracts-bedrock/src/periphery/monitoring/DisputeMonitorHelper.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +// Interfaces +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { Timestamp, GameType, Claim } from "src/dispute/lib/Types.sol"; + +/// @title DisputeMonitorHelper +/// @notice Peripheral contract that can help to monitor dispute games. Supplements offchain tools +/// by simplifying certain queries about dispute games. +contract DisputeMonitorHelper { + /// @notice Thrown when the end index is less than the start index. + error DisputeMonitorHelper_InvalidSearchRange(); + + /// @notice Enum representing the direction of the search. + enum SearchDirection { + OLDER_THAN_OR_EQ, + NEWER_THAN_OR_EQ + } + + /// @notice Checks if a game was created by the provided factory. + /// @param _factory The factory of the dispute games. + /// @param _game The game to check. + /// @return isValid_ True if the game was created by the factory, false otherwise. + function isGameRegistered(IDisputeGameFactory _factory, IDisputeGame _game) public view returns (bool isValid_) { + // Grab the game and game data. + (GameType gameType, Claim rootClaim, bytes memory extraData) = _game.gameData(); + + // Grab the verified address of the game based on the game data. + (IDisputeGame _factoryRegisteredGame,) = + _factory.games({ _gameType: gameType, _rootClaim: rootClaim, _extraData: extraData }); + + // Return whether the game is factory registered. + isValid_ = address(_factoryRegisteredGame) == address(_game); + } + + /// @notice Finds all unresolved games in a given time range. + /// @param _factory The factory of the dispute games. + /// @param _creationRangeStart Start of the range of game creation timestamps. + /// @param _creationRangeEnd End of the range of game creation timestamps. + /// @return unresolvedGames_ The array of unresolved games. + function getUnresolvedGames( + IDisputeGameFactory _factory, + uint256 _creationRangeStart, + uint256 _creationRangeEnd + ) + public + view + returns (IDisputeGame[] memory unresolvedGames_) + { + // Check that the max + if (_creationRangeEnd < _creationRangeStart) { + revert DisputeMonitorHelper_InvalidSearchRange(); + } + + // If there are no games, return an empty array. In theory we could error here too but it's + // easier for offchain tooling if this case just returns empty. Either is fine, but this is + // likely to be a common standard case and it'd be nicer if it didn't error. + if (_factory.gameCount() == 0) { + return new IDisputeGame[](0); + } + + // Try to find a suitable start and end index. If startIdx is type(uint256).max then we did + // not find any newer games and the creation range start must be after the timestamp of the + // latest game. Similarly, if endIdx is type(uint256).max then we did not find any older + // games and the creation range end must be before the timestamp of the earliest game. In + // either case, we can return an empty array. + uint256 startIdx = search(_factory, _creationRangeStart, SearchDirection.NEWER_THAN_OR_EQ); + uint256 endIdx = search(_factory, _creationRangeEnd, SearchDirection.OLDER_THAN_OR_EQ); + if (startIdx == type(uint256).max || endIdx == type(uint256).max) { + return new IDisputeGame[](0); + } + + // Additionally, if the end index is less than the start index, then the range is between + // two dispute games. We return an empty array in this case. + if (endIdx < startIdx) { + return new IDisputeGame[](0); + } + + // Allocate the array and fill it + unresolvedGames_ = new IDisputeGame[](endIdx - startIdx + 1); + uint256 unresolvedGameCount = 0; + for (uint256 i = startIdx; i <= endIdx; i++) { + (,, IDisputeGame game) = _factory.gameAtIndex(i); + if (game.resolvedAt().raw() == 0) { + unresolvedGames_[unresolvedGameCount] = game; + unresolvedGameCount++; + } + } + + // Clobber the size of the array to return the right size. + assembly { + mstore(unresolvedGames_, unresolvedGameCount) + } + } + + /// @notice Searches for a game by timestamp, returning the index of the game that best matches the + /// given search direction. + /// @param _factory The factory of the dispute games. + /// @param _targetTimestamp The timestamp to search for. + /// @param _direction The direction to search in (older or newer). + /// @return index_ The index of the matching game, if it exists. + function search( + IDisputeGameFactory _factory, + uint256 _targetTimestamp, + SearchDirection _direction + ) + public + view + returns (uint256 index_) + { + uint256 gameCount = _factory.gameCount(); + // If there are no games, return max to indicate "not found." + if (gameCount == 0) { + return type(uint256).max; + } + + uint256 left = 0; + uint256 right = gameCount - 1; + + // We'll store the candidate here. If it remains max, no suitable game was found. + index_ = type(uint256).max; + + while (left <= right) { + uint256 mid = left + (right - left) / 2; + (, Timestamp timestamp,) = _factory.gameAtIndex(mid); + uint256 gameTimestamp = uint64(timestamp.raw()); + + if (_direction == SearchDirection.OLDER_THAN_OR_EQ) { + // Rightmost index where timestamp <= _targetTimestamp + if (gameTimestamp <= _targetTimestamp) { + index_ = mid; + left = mid + 1; + } else { + if (mid == 0) { + // Prevent underflow + break; + } + right = mid - 1; + } + } else { + // Leftmost index where timestamp >= _targetTimestamp + if (gameTimestamp >= _targetTimestamp) { + index_ = mid; + if (mid == 0) { + // Prevent underflow + break; + } + right = mid - 1; + } else { + left = mid + 1; + } + } + } + } +} diff --git a/packages/contracts-bedrock/test/periphery/monitoring/DisputeMonitorHelper.t.sol b/packages/contracts-bedrock/test/periphery/monitoring/DisputeMonitorHelper.t.sol new file mode 100644 index 00000000000..332d20f81d7 --- /dev/null +++ b/packages/contracts-bedrock/test/periphery/monitoring/DisputeMonitorHelper.t.sol @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.15; + +// Testing +import { CommonTest } from "test/setup/CommonTest.sol"; + +// Contracts +import { DisputeMonitorHelper } from "src/periphery/monitoring/DisputeMonitorHelper.sol"; +import { GameTypes, Claim } from "src/dispute/lib/Types.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; + +contract DisputeMonitorHelper_TestInit is CommonTest { + DisputeMonitorHelper helper; + + function setUp() public override { + super.setUp(); + helper = new DisputeMonitorHelper(); + + // Skip everything for forked networks. Tests here involve carefully controlling the list + // of games in the factory, which is not possible on forked networks. + skipIfForkTest("DisputeMonitorHelper tests are not applicable to forked networks"); + } + + /// @notice Helper to create a game with a specific timestamp. + /// @param _timestamp The timestamp to set for the game creation. + /// @param _claim The claim for the game. + /// @return gameIndex_ The index of the created game. + function createGameWithTimestamp(uint256 _timestamp, bytes32 _claim) internal returns (uint256 gameIndex_) { + // Store current timestamp to restore later. + uint256 currentTimestamp = block.timestamp; + + // Warp to the desired timestamp. + vm.warp(_timestamp); + + // Create the game. + disputeGameFactory.create(GameTypes.CANNON, Claim.wrap(_claim), abi.encode(999999)); + + // Get the game index. + gameIndex_ = disputeGameFactory.gameCount() - 1; + + // Restore the original timestamp. + vm.warp(currentTimestamp); + } +} + +contract DisputeMonitorHelper_isGameRegistered_Test is DisputeMonitorHelper_TestInit { + /// @notice Test that a game created through the factory is registered. + function test_isGameRegistered_validGame_succeeds() external { + // Create a game through the factory + uint256 gameIndex = createGameWithTimestamp(block.timestamp, bytes32(uint256(1))); + + // Get the game address + (,, IDisputeGame game) = disputeGameFactory.gameAtIndex(gameIndex); + + // Check that the game is registered + bool isRegistered = helper.isGameRegistered(disputeGameFactory, game); + assertTrue(isRegistered, "Game should be registered"); + } + + /// @notice Test that a random address is not registered as a game. + function test_isGameRegistered_invalidGame_fails() external { + // Create a random address that is not a registered game + address randomAddress = address(uint160(uint256(keccak256(abi.encodePacked(block.timestamp))))); + IDisputeGame fakeGame = IDisputeGame(randomAddress); + + // Mock the gameData call on the fake game to return something + vm.mockCall( + randomAddress, + abi.encodeCall(IDisputeGame.gameData, ()), + abi.encode(GameTypes.CANNON, Claim.wrap(bytes32(uint256(1))), abi.encode(999999)) + ); + + // Check that the random address is not registered + bool isRegistered = helper.isGameRegistered(disputeGameFactory, fakeGame); + assertFalse(isRegistered, "Random address should not be registered as a game"); + } +} + +contract DisputeMonitorHelper_search_Test is DisputeMonitorHelper_TestInit { + /// @notice Fuzz test for searching with random timestamps and directions. + /// @param _numGames Number of games to generate for the test. + /// @param _searchOlderThan Search direction. + function testFuzz_search_succeeds(uint8 _numGames, bool _searchOlderThan) external { + // Convert into search directon. + DisputeMonitorHelper.SearchDirection direction = _searchOlderThan + ? DisputeMonitorHelper.SearchDirection.OLDER_THAN_OR_EQ + : DisputeMonitorHelper.SearchDirection.NEWER_THAN_OR_EQ; + + // Create an array to store game timestamps and indices. + uint256[] memory gameTimestamps = new uint256[](_numGames); + uint256[] memory gameIndices = new uint256[](_numGames); + + // Start with a base timestamp. + uint256 currentTimestamp = 1000; + + // Create games with increasing timestamps. + for (uint256 i = 0; i < _numGames; i++) { + // Generate a random timestamp increase (between 0 and 1000). Games can have the same + // exact timestamp. If this happens, we expect the earliest index in the NEWER_THAN + // case or the latest index in the OLDER_THAN case. + uint256 timestampIncrease = vm.randomUint(0, 1000); + currentTimestamp += timestampIncrease; + + // Store the timestamp. + gameTimestamps[i] = currentTimestamp; + + // Create the game and store its index. + gameIndices[i] = createGameWithTimestamp(currentTimestamp, bytes32(i + 1)); + } + + // Verify the game count. + assertEq(disputeGameFactory.gameCount(), _numGames, "wrong number of created games"); + + // If we have no games, expect the NoGames error no matter the timestamp. + if (_numGames == 0) { + uint256 foundIndex = + helper.search(disputeGameFactory, vm.randomUint(0, block.timestamp + 1000000), direction); + assertEq(foundIndex, type(uint256).max, "found index should be max"); + } else { + // Do 10 random tests inside the range we just created. + for (uint256 i = 0; i < 10; i++) { + // Pick a random timestamp that might be outside of the bounds of the available + // timestamps. We'll use cases that fall outside of the available bounds to make sure + // that errors are working as expected. + uint256 rangeStart = gameTimestamps[0]; + uint256 rangeEnd = gameTimestamps[gameTimestamps.length - 1]; + uint256 randomTimestamp = vm.randomUint(rangeStart - 500, rangeEnd + 500); + + // Different assertions for different cases. + if ( + (direction == DisputeMonitorHelper.SearchDirection.OLDER_THAN_OR_EQ && randomTimestamp < rangeStart) + || ( + direction == DisputeMonitorHelper.SearchDirection.NEWER_THAN_OR_EQ && randomTimestamp > rangeEnd + ) + ) { + // If we fall outside of the range, expect the max index representing that no + // valid game was found. + uint256 foundIndex = helper.search(disputeGameFactory, randomTimestamp, direction); + assertEq(foundIndex, type(uint256).max, "found index should be max"); + } else { + // Otherwise, we expect a valid index. Manual linear search to figure out what + // the right answer should be. + uint256 targetIndex; + for (uint256 j = 0; j < _numGames; j++) { + if (direction == DisputeMonitorHelper.SearchDirection.OLDER_THAN_OR_EQ) { + if (gameTimestamps[j] <= randomTimestamp) { + // Need to find newer indices for the OLDER_THAN_OR_EQ case. + targetIndex = j; + } + } else { + if (gameTimestamps[j] >= randomTimestamp) { + // Only find the first index for the NEWER_THAN_OR_EQ case. + targetIndex = j; + break; + } + } + } + + // Perform the search with our function. + uint256 foundIndex = helper.search(disputeGameFactory, randomTimestamp, direction); + + // Indices should match. + assertEq(foundIndex, targetIndex, "found incorrect index"); + } + } + } + } + + /// @notice Test that searching for a game with no games returns the max index. + /// @param _timestamp The timestamp to search for. + /// @param _searchOlderThan Whether to search for an older or newer game. + function testFuzz_search_noGames_succeeds(uint256 _timestamp, bool _searchOlderThan) external view { + // Convert into search directon. + DisputeMonitorHelper.SearchDirection direction = _searchOlderThan + ? DisputeMonitorHelper.SearchDirection.OLDER_THAN_OR_EQ + : DisputeMonitorHelper.SearchDirection.NEWER_THAN_OR_EQ; + + // Search for a game with no games. + uint256 foundIndex = helper.search(disputeGameFactory, _timestamp, direction); + + // Should return the max index. + assertEq(foundIndex, type(uint256).max, "found index should be max"); + } + + /// @notice Test that searching for a game older than all games returns the max index. + function test_search_olderThanEverything_succeeds() external { + // Select a target timestamp. + uint256 targetTimestamp = vm.randomUint(1, 100); + + // Create one game. + createGameWithTimestamp(targetTimestamp, bytes32(uint256(1))); + + // Search by providing a timestamp that is before all games. + uint256 foundIndex = helper.search( + disputeGameFactory, targetTimestamp - 1, DisputeMonitorHelper.SearchDirection.OLDER_THAN_OR_EQ + ); + assertEq(foundIndex, type(uint256).max, "found index should be max"); + } + + /// @notice Test that searching for a game newer than all games returns the max index. + function test_search_newerThanEverything_succeeds() external { + // Select a target timestamp. + uint256 targetTimestamp = vm.randomUint(1, 100); + + // Create one game. + createGameWithTimestamp(targetTimestamp, bytes32(uint256(1))); + + // Search by providing a timestamp that is after all games. + uint256 foundIndex = helper.search( + disputeGameFactory, targetTimestamp + 1, DisputeMonitorHelper.SearchDirection.NEWER_THAN_OR_EQ + ); + assertEq(foundIndex, type(uint256).max, "found index should be max"); + } +} + +contract DisputeMonitorHelper_getUnresolvedGames_Test is DisputeMonitorHelper_TestInit { + /// @notice Fuzz test for searching for unresolved games. + /// @param _numGames Number of games to create. + /// @param _resolvedPercent Percentage of games to mark as resolved. + function testFuzz_getUnresolvedGames_succeeds(uint8 _numGames, uint8 _resolvedPercent) external { + // Want _resolvedPercent to have 5% steps. + _resolvedPercent = _resolvedPercent % 20; + + // Create an array to store game timestamps and indices. + bool[] memory gameStatuses = new bool[](_numGames); + uint256[] memory gameTimestamps = new uint256[](_numGames); + uint256[] memory gameIndices = new uint256[](_numGames); + + // Start with a base timestamp. + uint256 currentTimestamp = 1000; + + // Create games with increasing timestamps. + for (uint256 i = 0; i < _numGames; i++) { + // Generate a random timestamp increase (between 0 and 1000). + uint256 timestampIncrease = vm.randomUint(0, 1000); + currentTimestamp += timestampIncrease; + + // Store the timestamp. + gameTimestamps[i] = currentTimestamp; + + // Create the game and store its index. + gameIndices[i] = createGameWithTimestamp(currentTimestamp, bytes32(i + 1)); + + // Decide if the game should be resolved or not. + if (_resolvedPercent != 0 && vm.randomUint(0, 20) <= _resolvedPercent) { + // Winner winner! + // Mock the resolvedAt timestamp to anything but 0. + gameStatuses[i] = true; + (,, IDisputeGame game) = disputeGameFactory.gameAtIndex(i); + vm.mockCall(address(game), abi.encodeCall(game.resolvedAt, ()), abi.encode(block.timestamp)); + } else { + gameStatuses[i] = false; + } + } + + // If we have no games, expect an empty array always + if (_numGames == 0) { + uint256 creationRangeStart = vm.randomUint(0, block.timestamp + 1000000); + uint256 creationRangeEnd = vm.randomUint(creationRangeStart, creationRangeStart + 1000000); + IDisputeGame[] memory results = + helper.getUnresolvedGames(disputeGameFactory, creationRangeStart, creationRangeEnd); + assertEq(results.length, 0, "empty case returned games"); + } else { + // Do 10 random tests inside the range we just created. + for (uint256 i = 0; i < 10; i++) { + // Pick a random timestamp that might be outside of the bounds of the available + // timestamps. We'll use cases that fall outside of the available bounds to make sure + // that errors are working as expected. + uint256 rangeStart = gameTimestamps[0]; + uint256 rangeEnd = gameTimestamps[gameTimestamps.length - 1]; + uint256 randomRangeStart = vm.randomUint(rangeStart - 500, rangeEnd + 500); + uint256 randomRangeEnd = vm.randomUint(rangeStart - 500, rangeEnd + 500); + + // Different assertions for different cases. + if (randomRangeStart > randomRangeEnd) { + // If the boundaries are invalid, expect an error. + vm.expectRevert(DisputeMonitorHelper.DisputeMonitorHelper_InvalidSearchRange.selector); + helper.getUnresolvedGames(disputeGameFactory, randomRangeStart, randomRangeEnd); + } else if (randomRangeEnd < rangeStart || randomRangeStart > rangeEnd) { + // If the boundaries are valid but the range is outside of the range of + // timestamps created by the array of games, expect an empty array. + IDisputeGame[] memory results = + helper.getUnresolvedGames(disputeGameFactory, randomRangeStart, randomRangeEnd); + assertEq(results.length, 0, "results should be empty"); + } else { + // Otherwise, we expect a number of results equal to the number of games that + // are unresolved within the range. Start by allocating an array with the total + // number of games, though actual size will be less. + IDisputeGame[] memory expected = new IDisputeGame[](_numGames); + + // Create the array of expected results. + uint256 insertedCount = 0; + for (uint256 j = 0; j < _numGames; j++) { + if ( + gameStatuses[j] == false && gameTimestamps[j] >= randomRangeStart + && gameTimestamps[j] <= randomRangeEnd + ) { + (,, IDisputeGame game) = disputeGameFactory.gameAtIndex(j); + expected[insertedCount] = game; + insertedCount++; + } + } + + // Perform the search with our function. + IDisputeGame[] memory results = + helper.getUnresolvedGames(disputeGameFactory, randomRangeStart, randomRangeEnd); + + // Should have a number of results equal to the elements inserted. + assertEq(results.length, insertedCount, "unexpected results length"); + + // Each element should match. + for (uint256 j = 0; j < results.length; j++) { + assertEq(address(results[j]), address(expected[j])); + } + } + } + } + } + + /// @notice Test that getting unresolved games with no games returns an empty array. + /// @param _creationRangeStart The start of the creation range. + /// @param _creationRangeEnd The end of the creation range. + function testFuzz_getUnresolvedGames_noGames_succeeds( + uint256 _creationRangeStart, + uint256 _creationRangeEnd + ) + external + view + { + // Make sure the boundaries are valid. + _creationRangeEnd = bound(_creationRangeEnd, _creationRangeStart, type(uint256).max); + + // Get the unresolved games. + IDisputeGame[] memory results = + helper.getUnresolvedGames(disputeGameFactory, _creationRangeStart, _creationRangeEnd); + assertEq(results.length, 0, "empty case returned games"); + } + + /// @notice Test that getting unresolved games between two timestamps returns an empty array. + function test_getUnresolvedGames_betweenTimestamps_succeeds() external { + // Select two timestamps. + uint256 timestamp1 = 100; + uint256 timestamp2 = 200; + + // Create two games. + createGameWithTimestamp(timestamp1, bytes32(uint256(1))); + createGameWithTimestamp(timestamp2, bytes32(uint256(2))); + + // Select a range that falls between the two timestamps. + uint256 rangeStart = timestamp1 + 1; + uint256 rangeEnd = timestamp2 - 1; + + // Get the unresolved games. + IDisputeGame[] memory results = helper.getUnresolvedGames(disputeGameFactory, rangeStart, rangeEnd); + assertEq(results.length, 0, "expected 0 games"); + } + + /// @notice Fuzz test for getting unresolved games with bad boundaries. + /// @param _creationRangeStart The start of the creation range. + /// @param _creationRangeEnd The end of the creation range. + function testFuzz_getUnresolvedGames_badBoundaries_reverts( + uint256 _creationRangeStart, + uint256 _creationRangeEnd + ) + external + { + // Make sure the boundaries are deliberately invalid. + _creationRangeStart = bound(_creationRangeStart, 1, type(uint256).max); + _creationRangeEnd = bound(_creationRangeEnd, 0, _creationRangeStart - 1); + + // Get the unresolved games. + vm.expectRevert(DisputeMonitorHelper.DisputeMonitorHelper_InvalidSearchRange.selector); + helper.getUnresolvedGames(disputeGameFactory, _creationRangeStart, _creationRangeEnd); + } + + /// @notice Fuzz test for getting unresolved games when all games are resolved. + /// @param _numGames Number of games to create. + function testFuzz_getUnresolvedGames_allResolved_succeeds(uint8 _numGames) external { + // Create 5 games. + for (uint256 i = 0; i < _numGames; i++) { + createGameWithTimestamp(1000, bytes32(uint256(i + 1))); + (,, IDisputeGame game) = disputeGameFactory.gameAtIndex(i); + vm.mockCall(address(game), abi.encodeCall(game.resolvedAt, ()), abi.encode(block.timestamp)); + } + + // Get the unresolved games. + IDisputeGame[] memory results = helper.getUnresolvedGames(disputeGameFactory, 0, type(uint256).max); + assertEq(results.length, 0, "expected 0 games"); + } + + /// @notice Fuzz test for getting unresolved games when all games are unresolved. + /// @param _numGames Number of games to create. + function testFuzz_getUnresolvedGames_allUnresolved_succeeds(uint8 _numGames) external { + // Create 5 games. + for (uint256 i = 0; i < _numGames; i++) { + createGameWithTimestamp(1000, bytes32(uint256(i + 1))); + } + + // Get the unresolved games. + IDisputeGame[] memory results = helper.getUnresolvedGames(disputeGameFactory, 0, type(uint256).max); + assertEq(results.length, _numGames, "expected 5 games"); + } +}