diff --git a/Cargo.lock b/Cargo.lock index b75aa03a..cb83ba29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4409,6 +4409,7 @@ dependencies = [ "alloy", "anyhow", "cartesi-dave-contracts", + "cartesi-prt-contracts", "cartesi-prt-core", "log", "num-traits", diff --git a/cartesi-rollups/contracts/script/Deployment.s.sol b/cartesi-rollups/contracts/script/Deployment.s.sol index dcfa4091..d5cfa0dd 100644 --- a/cartesi-rollups/contracts/script/Deployment.s.sol +++ b/cartesi-rollups/contracts/script/Deployment.s.sol @@ -18,12 +18,25 @@ contract DeploymentScript is BaseDeploymentScript { address inputBox = _loadDeployment(".", "InputBox"); address appFactory = _loadDeployment(".", "ApplicationFactory"); address tournamentFactory = _loadDeployment(".", "MultiLevelTournamentFactory"); + address safetyGateTaskSpawner = _loadDeployment("../../prt/contracts", "SafetyGateTaskSpawner"); + address securityCouncil = tx.origin; vmSafe.startBroadcast(); _storeDeployment( type(DaveAppFactory).name, - _create2(type(DaveAppFactory).creationCode, abi.encode(inputBox, appFactory, tournamentFactory)) + _create2( + type(DaveAppFactory).creationCode, + abi.encode(inputBox, appFactory, tournamentFactory, securityCouncil) + ) + ); + + _storeDeployment( + "DaveAppFactorySafetyGate", + _create2( + type(DaveAppFactory).creationCode, + abi.encode(inputBox, appFactory, safetyGateTaskSpawner, securityCouncil) + ) ); if (block.chainid == 31337) { diff --git a/cartesi-rollups/contracts/src/DaveAppFactory.sol b/cartesi-rollups/contracts/src/DaveAppFactory.sol index 22a96a15..7d8cd62d 100644 --- a/cartesi-rollups/contracts/src/DaveAppFactory.sol +++ b/cartesi-rollups/contracts/src/DaveAppFactory.sol @@ -13,7 +13,7 @@ import {IApplication} from "cartesi-rollups-contracts-2.1.1/src/dapp/IApplicatio import {IApplicationFactory} from "cartesi-rollups-contracts-2.1.1/src/dapp/IApplicationFactory.sol"; import {IInputBox} from "cartesi-rollups-contracts-2.1.1/src/inputs/IInputBox.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; import {DaveConsensus} from "./DaveConsensus.sol"; @@ -23,14 +23,16 @@ import {IDaveConsensus} from "./IDaveConsensus.sol"; contract DaveAppFactory is IDaveAppFactory { IInputBox immutable INPUT_BOX; IApplicationFactory immutable APP_FACTORY; - ITournamentFactory immutable TOURNAMENT_FACTORY; + ITaskSpawner immutable TASK_SPAWNER; + address immutable SECURITY_COUNCIL; IOutputsMerkleRootValidator constant NO_VALIDATOR = IOutputsMerkleRootValidator(address(0)); - constructor(IInputBox inputBox, IApplicationFactory appFactory, ITournamentFactory tournamentFactory) { + constructor(IInputBox inputBox, IApplicationFactory appFactory, ITaskSpawner taskSpawner, address securityCouncil) { INPUT_BOX = inputBox; APP_FACTORY = appFactory; - TOURNAMENT_FACTORY = tournamentFactory; + TASK_SPAWNER = taskSpawner; + SECURITY_COUNCIL = securityCouncil; } function newDaveApp(bytes32 templateHash, bytes32 salt) @@ -74,7 +76,10 @@ contract DaveAppFactory is IDaveAppFactory { returns (DaveConsensus) { Machine.Hash initialMachineStateHash = Machine.Hash.wrap(templateHash); - return new DaveConsensus{salt: salt}(INPUT_BOX, appContract, TOURNAMENT_FACTORY, initialMachineStateHash); + return + new DaveConsensus{salt: salt}( + INPUT_BOX, appContract, TASK_SPAWNER, SECURITY_COUNCIL, initialMachineStateHash + ); } /// @notice Calculates the address of an application contract. @@ -95,7 +100,7 @@ contract DaveAppFactory is IDaveAppFactory { keccak256( abi.encodePacked( type(DaveConsensus).creationCode, - abi.encode(INPUT_BOX, appContract, TOURNAMENT_FACTORY, templateHash) + abi.encode(INPUT_BOX, appContract, TASK_SPAWNER, SECURITY_COUNCIL, templateHash) ) ) ); diff --git a/cartesi-rollups/contracts/src/DaveConsensus.sol b/cartesi-rollups/contracts/src/DaveConsensus.sol index 5b7a687d..3c136b80 100644 --- a/cartesi-rollups/contracts/src/DaveConsensus.sol +++ b/cartesi-rollups/contracts/src/DaveConsensus.sol @@ -13,11 +13,10 @@ import {IInputBox} from "cartesi-rollups-contracts-2.1.1/src/inputs/IInputBox.so import {LibMerkle32} from "cartesi-rollups-contracts-2.1.1/src/library/LibMerkle32.sol"; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; +import {ITask} from "prt-contracts/ITask.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; -import {Tree} from "prt-contracts/types/Tree.sol"; import {EmulatorConstants} from "step/src/EmulatorConstants.sol"; import {Memory} from "step/src/Memory.sol"; @@ -25,7 +24,7 @@ import {Memory} from "step/src/Memory.sol"; import {IDaveConsensus} from "./IDaveConsensus.sol"; import {Merkle} from "./Merkle.sol"; -/// @notice Consensus contract with Dave tournaments. +/// @notice Consensus contract with Dave tasks. /// /// @notice This contract validates only one application, /// which read inputs from the InputBox contract. @@ -57,8 +56,11 @@ contract DaveConsensus is IDaveConsensus, ERC165 { /// @notice The application contract address immutable _APP_CONTRACT; - /// @notice The contract used to instantiate tournaments - ITournamentFactory immutable _TOURNAMENT_FACTORY; + /// @notice Security council address + address immutable _SECURITY_COUNCIL; + + /// @notice The contract used to instantiate tasks + ITaskSpawner _taskSpawner; /// @notice Deployment block number uint256 immutable _DEPLOYMENT_BLOCK_NUMBER = block.number; @@ -72,51 +74,72 @@ contract DaveConsensus is IDaveConsensus, ERC165 { /// @notice Input index (exclusive) upper bound of the current sealed epoch uint256 _inputIndexUpperBound; - /// @notice Current sealed epoch tournament - ITournament _tournament; + /// @notice Current sealed epoch task + ITask _task; /// @notice Settled output trees' merkle root hash mapping(bytes32 => bool) _outputsMerkleRoots; + /// @notice Whether settlement is paused + bool _paused; + constructor( IInputBox inputBox, address appContract, - ITournamentFactory tournamentFactory, + ITaskSpawner taskSpawner, + address securityCouncil, Machine.Hash initialMachineStateHash ) { // Initialize immutable variables _INPUT_BOX = inputBox; _APP_CONTRACT = appContract; - _TOURNAMENT_FACTORY = tournamentFactory; - emit ConsensusCreation(inputBox, appContract, tournamentFactory); + _SECURITY_COUNCIL = securityCouncil; + _taskSpawner = taskSpawner; + emit ConsensusCreation(inputBox, appContract, taskSpawner); // Initialize first sealed epoch uint256 inputIndexUpperBound = inputBox.getNumberOfInputs(appContract); _inputIndexUpperBound = inputIndexUpperBound; - ITournament tournament = tournamentFactory.instantiate(initialMachineStateHash, this); - _tournament = tournament; - emit EpochSealed(0, 0, inputIndexUpperBound, initialMachineStateHash, bytes32(0), tournament); + ITask task = taskSpawner.spawn(initialMachineStateHash, this); + _task = task; + emit EpochSealed(0, 0, inputIndexUpperBound, initialMachineStateHash, bytes32(0), task); + } + + function _onlySecurityCouncil() internal view { + require(msg.sender == _SECURITY_COUNCIL, NotSecurityCouncil()); + } + + modifier onlySecurityCouncil() { + _onlySecurityCouncil(); + _; } function canSettle() external view override - returns (bool isFinished, uint256 epochNumber, Tree.Node winnerCommitment) + returns (bool isFinished, uint256 epochNumber, Machine.Hash finalState) { - (isFinished, winnerCommitment,) = _tournament.arbitrationResult(); + if (_paused) { + (isFinished, finalState) = (false, Machine.ZERO_STATE); + } else { + (isFinished, finalState) = _task.result(); + } + epochNumber = _epochNumber; } function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] calldata proof) external override { - // Check tournament settlement + require(!_paused, PausedError()); + + // Check task settlement require(epochNumber == _epochNumber, IncorrectEpochNumber(epochNumber, _epochNumber)); - // Check tournament finished - (bool isFinished,, Machine.Hash finalMachineStateHash) = _tournament.arbitrationResult(); + // Check task finished + (bool isFinished, Machine.Hash finalMachineStateHash) = _task.result(); require(isFinished, TournamentNotFinishedYet()); - ITournament oldTournament = _tournament; - _tournament = ITournament(address(0)); + ITask oldTask = _task; + _task = ITask(address(0)); // Check outputs Merkle root _validateOutputTree(finalMachineStateHash, outputsMerkleRoot, proof); @@ -127,36 +150,26 @@ contract DaveConsensus is IDaveConsensus, ERC165 { _inputIndexUpperBound = _INPUT_BOX.getNumberOfInputs(_APP_CONTRACT); _outputsMerkleRoots[outputsMerkleRoot] = true; - // Start new tournament - _tournament = _TOURNAMENT_FACTORY.instantiate(finalMachineStateHash, this); + // Start new task + _task = _taskSpawner.spawn(finalMachineStateHash, this); emit EpochSealed( - _epochNumber, - _inputIndexLowerBound, - _inputIndexUpperBound, - finalMachineStateHash, - outputsMerkleRoot, - _tournament + _epochNumber, _inputIndexLowerBound, _inputIndexUpperBound, finalMachineStateHash, outputsMerkleRoot, _task ); - oldTournament.tryRecoveringBond(); + _tryCleanup(oldTask); } function getCurrentSealedEpoch() external view override - returns ( - uint256 epochNumber, - uint256 inputIndexLowerBound, - uint256 inputIndexUpperBound, - ITournament tournament - ) + returns (uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, ITask task) { epochNumber = _epochNumber; inputIndexLowerBound = _inputIndexLowerBound; inputIndexUpperBound = _inputIndexUpperBound; - tournament = _tournament; + task = _task; } function getInputBox() external view override returns (IInputBox) { @@ -167,8 +180,37 @@ contract DaveConsensus is IDaveConsensus, ERC165 { return _APP_CONTRACT; } - function getTournamentFactory() external view override returns (ITournamentFactory) { - return _TOURNAMENT_FACTORY; + function getTaskSpawner() external view override returns (ITaskSpawner) { + return _taskSpawner; + } + + function getSecurityCouncil() external view override returns (address) { + return _SECURITY_COUNCIL; + } + + function isPaused() external view override returns (bool) { + return _paused; + } + + function upgrade(Machine.Hash newInitialState, ITaskSpawner newTaskSpawner) external override onlySecurityCouncil { + _taskSpawner = newTaskSpawner; + _task = newTaskSpawner.spawn(newInitialState, this); + + emit TaskUpgraded(_epochNumber, newInitialState, newTaskSpawner, _task); + } + + function pause() external override onlySecurityCouncil { + if (!_paused) { + _paused = true; + emit Paused(msg.sender); + } + } + + function unpause() external override onlySecurityCouncil { + if (_paused) { + _paused = false; + emit Unpaused(msg.sender); + } } function provideMerkleRootOfInput(uint256 inputIndexWithinEpoch, bytes calldata input) @@ -227,4 +269,12 @@ contract DaveConsensus is IDaveConsensus, ERC165 { require(machineStateHash == allegedStateHash, InvalidOutputsMerkleRootProof(finalMachineStateHash)); } + + function _tryCleanup(ITask task) internal { + if (address(task) == address(0)) { + return; + } + + try task.cleanup() returns (bool) {} catch {} + } } diff --git a/cartesi-rollups/contracts/src/IDaveConsensus.sol b/cartesi-rollups/contracts/src/IDaveConsensus.sol index d43b2c5a..d5870159 100644 --- a/cartesi-rollups/contracts/src/IDaveConsensus.sol +++ b/cartesi-rollups/contracts/src/IDaveConsensus.sol @@ -9,13 +9,12 @@ import { import {IInputBox} from "cartesi-rollups-contracts-2.1.1/src/inputs/IInputBox.sol"; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; +import {ITask} from "prt-contracts/ITask.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; -import {Tree} from "prt-contracts/types/Tree.sol"; -/// @notice Consensus contract with Dave tournaments. +/// @notice Consensus contract with Dave tasks. /// /// @notice This contract validates only one application, /// which read inputs from the InputBox contract. @@ -41,8 +40,8 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator { /// @notice Consensus contract was created /// @param inputBox the input box contract /// @param appContract the application contract - /// @param tournamentFactory the tournament factory contract - event ConsensusCreation(IInputBox inputBox, address appContract, ITournamentFactory tournamentFactory); + /// @param taskSpawner the task spawner contract + event ConsensusCreation(IInputBox inputBox, address appContract, ITaskSpawner taskSpawner); /// @notice An epoch was sealed /// @param epochNumber the sealed epoch number @@ -50,23 +49,44 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator { /// @param inputIndexUpperBound the input index (exclusive) upper bound in the sealed epoch /// @param initialMachineStateHash the initial machine state hash /// @param outputsMerkleRoot the Merkle root hash of the outputs tree - /// @param tournament the sealed epoch tournament contract + /// @param task the sealed epoch task contract event EpochSealed( uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, Machine.Hash initialMachineStateHash, bytes32 outputsMerkleRoot, - ITournament tournament + ITask task ); + /// @notice Task was upgraded for the current epoch. + /// @param epochNumber The current sealed epoch number + /// @param newInitialState The new initial state used to respawn the task + /// @param newTaskSpawner The new task spawner used for future tasks + /// @param task The new task instance + event TaskUpgraded( + uint256 indexed epochNumber, Machine.Hash newInitialState, ITaskSpawner newTaskSpawner, ITask task + ); + + /// @notice Emitted when the contract is paused. + /// @param account The caller that triggered the pause + event Paused(address indexed account); + + /// @notice Emitted when the contract is unpaused. + /// @param account The caller that triggered the unpause + event Unpaused(address indexed account); + /// @notice Received epoch number is different from actual /// @param received The epoch number received as argument /// @param actual The actual epoch number in storage error IncorrectEpochNumber(uint256 received, uint256 actual); - /// @notice Tournament is not finished yet + /// @notice Task is not finished yet error TournamentNotFinishedYet(); + /// @notice Caller is not the security council + error NotSecurityCouncil(); + /// @notice Contract is paused + error PausedError(); /// @notice Hash of received input blob is different from stored on-chain /// @param fromReceivedInput Hash of received input blob @@ -95,29 +115,30 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator { /// @notice Get the address of the application contract. function getApplicationContract() external view returns (address); - /// @notice Get the tournament factory contract used to instantiate root tournaments. - function getTournamentFactory() external view returns (ITournamentFactory); + /// @notice Get the task spawner contract used to instantiate root tasks. + function getTaskSpawner() external view returns (ITaskSpawner); + + /// @notice Get the security council address. + function getSecurityCouncil() external view returns (address); - /// @notice Get the current sealed epoch number, boundaries, and tournament. + /// @notice Returns whether the contract is paused. + function isPaused() external view returns (bool); + + /// @notice Get the current sealed epoch number, boundaries, and task. /// @param epochNumber The epoch number /// @param inputIndexLowerBound The epoch input index (inclusive) lower bound /// @param inputIndexUpperBound The epoch input index (exclusive) upper bound - /// @param tournament The tournament that will decide the post-epoch state + /// @param task The task that will decide the post-epoch state function getCurrentSealedEpoch() external view - returns ( - uint256 epochNumber, - uint256 inputIndexLowerBound, - uint256 inputIndexUpperBound, - ITournament tournament - ); + returns (uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, ITask task); /// @notice Check whether the current sealed epoch can be settled. - /// @return isFinished Whether the current sealed epoch tournament has finished yet + /// @return isFinished Whether the current sealed epoch task has finished yet /// @return epochNumber The current sealed epoch number - /// @return winnerCommitment If the tournament has finished, the winning commitment - function canSettle() external view returns (bool isFinished, uint256 epochNumber, Tree.Node winnerCommitment); + /// @return finalState If the task has finished, the final machine state + function canSettle() external view returns (bool isFinished, uint256 epochNumber, Machine.Hash finalState); /// @notice Settle the current sealed epoch. /// @param epochNumber The current sealed epoch number (used to avoid race conditions) @@ -125,4 +146,16 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator { /// @param proof The bottom-up Merkle proof of the outputs Merkle root in the final machine state /// @dev On success, emits an `EpochSealed` event. function settle(uint256 epochNumber, bytes32 outputsMerkleRoot, bytes32[] calldata proof) external; + + /// @notice Pause settlement. Only callable by the security council. + function pause() external; + + /// @notice Unpause settlement. Only callable by the security council. + function unpause() external; + + /// @notice Upgrade the current task and task spawner. + /// @param newInitialState The new initial machine state hash + /// @param newTaskSpawner The new task spawner + /// @dev Emits `TaskUpgraded` on success. + function upgrade(Machine.Hash newInitialState, ITaskSpawner newTaskSpawner) external; } diff --git a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol index 7913e5b8..f1213ea4 100644 --- a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol +++ b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol @@ -11,22 +11,22 @@ import {IInputBox} from "cartesi-rollups-contracts-2.1.1/src/inputs/IInputBox.so import {InputBox} from "cartesi-rollups-contracts-2.1.1/src/inputs/InputBox.sol"; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; -import {ITournament} from "prt-contracts/ITournamentFactory.sol"; +import {ITask} from "prt-contracts/ITask.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; import {DaveAppFactory} from "src/DaveAppFactory.sol"; import {IDaveAppFactory} from "src/IDaveAppFactory.sol"; import {IDaveConsensus} from "src/IDaveConsensus.sol"; -contract MockTournamentFactory is ITournamentFactory { - address tournamentAddress; +contract MockTaskSpawner is ITaskSpawner { + address taskAddress; function setAddress(address _addr) external { - tournamentAddress = _addr; + taskAddress = _addr; } - function instantiate(Machine.Hash, IDataProvider) external view returns (ITournament) { - return ITournament(tournamentAddress); + function spawn(Machine.Hash, IDataProvider) external view returns (ITask) { + return ITask(taskAddress); } } @@ -34,18 +34,20 @@ contract DaveConsensusFactoryTest is Test { IApplicationFactory _appFactory; IDaveAppFactory _daveAppFactory; IInputBox _inputBox; - MockTournamentFactory _tournamentFactory; + MockTaskSpawner _taskSpawner; Machine.Hash _initialMachineStateHash; + address _securityCouncil; function setUp() external { _inputBox = new InputBox(); _appFactory = new ApplicationFactory(); - _tournamentFactory = new MockTournamentFactory(); - _daveAppFactory = new DaveAppFactory(_inputBox, _appFactory, _tournamentFactory); + _taskSpawner = new MockTaskSpawner(); + _securityCouncil = address(0xBEEF); + _daveAppFactory = new DaveAppFactory(_inputBox, _appFactory, _taskSpawner, _securityCouncil); _initialMachineStateHash = Machine.Hash.wrap(keccak256("foo")); } - function testNewDaveApp(address randomTournamentAddress, bytes32 templateHash, bytes32 salt) external { + function testNewDaveApp(address randomTaskAddress, bytes32 templateHash, bytes32 salt) external { IApplication appContract; IDaveConsensus daveConsensus; @@ -58,7 +60,7 @@ contract DaveConsensusFactoryTest is Test { // Deploy app and Dave consensus addresses vm.recordLogs(); - _tournamentFactory.setAddress(randomTournamentAddress); + _taskSpawner.setAddress(randomTaskAddress); (appContract, daveConsensus) = _daveAppFactory.newDaveApp(templateHash, salt); // Check if addresses match those pre-calculated ones @@ -88,7 +90,7 @@ contract DaveConsensusFactoryTest is Test { } else if ( entry.emitter == address(daveConsensus) && entry.topics[0] == IDaveConsensus.EpochSealed.selector ) { - _checkEpochSealedData(entry.data, templateHash, randomTournamentAddress); + _checkEpochSealedData(entry.data, templateHash, randomTaskAddress); } else if ( entry.emitter == address(_appFactory) && entry.topics[0] == IApplicationFactory.ApplicationCreated.selector @@ -114,17 +116,18 @@ contract DaveConsensusFactoryTest is Test { } // Check current sealed epoch - (uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, ITournament tournament) = + (uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, ITask task) = daveConsensus.getCurrentSealedEpoch(); assertEq(epochNumber, 0); assertEq(inputIndexLowerBound, 0); assertEq(inputIndexUpperBound, 0); - assertEq(address(tournament), randomTournamentAddress); + assertEq(address(task), randomTaskAddress); // Check getters assertEq(address(daveConsensus.getInputBox()), address(_inputBox)); assertEq(address(daveConsensus.getApplicationContract()), address(appContract)); - assertEq(address(daveConsensus.getTournamentFactory()), address(_tournamentFactory)); + assertEq(address(daveConsensus.getTaskSpawner()), address(_taskSpawner)); + assertEq(daveConsensus.getSecurityCouncil(), _securityCouncil); { address appContractAddress; @@ -142,17 +145,14 @@ contract DaveConsensusFactoryTest is Test { _daveAppFactory.newDaveApp(templateHash, salt); } - function _checkEpochSealedData(bytes memory data, bytes32 templateHash, address randomTournamentAddress) - internal - pure - { + function _checkEpochSealedData(bytes memory data, bytes32 templateHash, address randomTaskAddress) internal pure { ( uint256 epochNumber, uint256 inputIndexLowerBound, uint256 inputIndexUpperBound, bytes32 initialMachineStateHash, bytes32 outputTreeHash, - address tournamentAddress + address taskAddress ) = abi.decode(data, (uint256, uint256, uint256, bytes32, bytes32, address)); assertEq(epochNumber, 0); @@ -160,6 +160,6 @@ contract DaveConsensusFactoryTest is Test { assertEq(inputIndexUpperBound, 0); assertEq(initialMachineStateHash, templateHash); assertEq(outputTreeHash, bytes32(0)); - assertEq(tournamentAddress, randomTournamentAddress); + assertEq(taskAddress, randomTaskAddress); } } diff --git a/cartesi-rollups/contracts/test/DaveConsensus.t.sol b/cartesi-rollups/contracts/test/DaveConsensus.t.sol index da91e095..5f6ecf21 100644 --- a/cartesi-rollups/contracts/test/DaveConsensus.t.sol +++ b/cartesi-rollups/contracts/test/DaveConsensus.t.sol @@ -17,13 +17,9 @@ import {InputBox} from "cartesi-rollups-contracts-2.1.1/src/inputs/InputBox.sol" import {LibMerkle32} from "cartesi-rollups-contracts-2.1.1/src/library/LibMerkle32.sol"; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; -import {Clock} from "prt-contracts/tournament/libs/Clock.sol"; -import {Match} from "prt-contracts/tournament/libs/Match.sol"; -import {Time} from "prt-contracts/tournament/libs/Time.sol"; +import {ITask} from "prt-contracts/ITask.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; -import {Tree} from "prt-contracts/types/Tree.sol"; import {EmulatorConstants} from "step/src/EmulatorConstants.sol"; import {Memory} from "step/src/Memory.sol"; @@ -44,11 +40,10 @@ contract MerkleProxy { } } -contract MockTournament is ITournament { +contract MockTask is ITask { Machine.Hash immutable _INITIAL_STATE; IDataProvider immutable _PROVIDER; bool _finished; - Tree.Node _winnerCommitment; Machine.Hash _finalState; constructor(Machine.Hash initialState, IDataProvider provider) { @@ -56,9 +51,8 @@ contract MockTournament is ITournament { _PROVIDER = provider; } - function finish(Tree.Node winnerCommitment, Machine.Hash finalState) external { + function finish(Machine.Hash finalState) external { _finished = true; - _winnerCommitment = winnerCommitment; _finalState = finalState; } @@ -70,136 +64,35 @@ contract MockTournament is ITournament { return _PROVIDER; } - function arbitrationResult() - external - view - returns (bool finished, Tree.Node winnerCommitment, Machine.Hash finalState) - { + function result() external view returns (bool finished, Machine.Hash finalState) { finished = _finished; - winnerCommitment = _winnerCommitment; finalState = _finalState; } - function tryRecoveringBond() external pure override returns (bool) { - return true; - } - - error NotImplemented(); - - function bondValue() external pure override returns (uint256) { - revert NotImplemented(); - } - - function joinTournament(Machine.Hash, bytes32[] calldata, Tree.Node, Tree.Node) external payable override { - revert NotImplemented(); - } - - function advanceMatch(Match.Id calldata, Tree.Node, Tree.Node, Tree.Node, Tree.Node) external pure override { - revert NotImplemented(); - } - - function winMatchByTimeout(Match.Id calldata, Tree.Node, Tree.Node) external pure override { - revert NotImplemented(); - } - - function eliminateMatchByTimeout(Match.Id calldata) external pure override { - revert NotImplemented(); - } - - function sealInnerMatchAndCreateInnerTournament( - Match.Id calldata, - Tree.Node, - Tree.Node, - Machine.Hash, - bytes32[] calldata - ) external pure override { - revert NotImplemented(); - } - - function winInnerTournament(ITournament, Tree.Node, Tree.Node) external pure override { - revert NotImplemented(); - } - - function eliminateInnerTournament(ITournament) external pure override { - revert NotImplemented(); - } - - function sealLeafMatch(Match.Id calldata, Tree.Node, Tree.Node, Machine.Hash, bytes32[] calldata) - external - pure - override - { - revert NotImplemented(); - } - - function winLeafMatch(Match.Id calldata, Tree.Node, Tree.Node, bytes calldata) external pure override { - revert NotImplemented(); - } - - function canBeEliminated() external pure override returns (bool) { - revert NotImplemented(); - } - - function innerTournamentWinner() external pure override returns (bool, Tree.Node, Tree.Node, Clock.State memory) { - revert NotImplemented(); - } - - function tournamentArguments() external pure override returns (TournamentArguments memory) { - revert NotImplemented(); - } - - function canWinMatchByTimeout(Match.Id calldata) external pure override returns (bool) { - revert NotImplemented(); - } - - function getCommitment(Tree.Node) external pure override returns (Clock.State memory, Machine.Hash) { - revert NotImplemented(); - } - - function getMatch(Match.IdHash) external pure override returns (Match.State memory) { - revert NotImplemented(); - } - - function getMatchCycle(Match.IdHash) external pure override returns (uint256) { - revert NotImplemented(); - } - - function tournamentLevelConstants() external pure override returns (uint64, uint64, uint64, uint64) { - revert NotImplemented(); - } - - function isClosed() external pure override returns (bool) { - revert NotImplemented(); - } - - function isFinished() external view override returns (bool) { + function cleanup() external returns (bool) { return _finished; } - function timeFinished() external pure override returns (bool, Time.Instant) { - revert NotImplemented(); + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ITask).interfaceId; } } -contract MockTournamentFactory is ITournamentFactory { - MockTournament[] _mockTournaments; +contract MockTaskSpawner is ITaskSpawner { + MockTask[] _mockTasks; bytes32 _salt; error IndexOutOfBounds(); - function instantiate(Machine.Hash initialState, IDataProvider provider) external returns (ITournament) { - MockTournament mockTournament = new MockTournament{salt: _salt}(initialState, provider); - _mockTournaments.push(mockTournament); - return mockTournament; + function spawn(Machine.Hash initialState, IDataProvider provider) external returns (ITask) { + MockTask mockTask = new MockTask{salt: _salt}(initialState, provider); + _mockTasks.push(mockTask); + return mockTask; } - function calculateTournamentAddress(Machine.Hash initialState, IDataProvider provider) - external - view - returns (address) - { + function calculateTaskAddress(Machine.Hash initialState, IDataProvider provider) external view returns (address) { return Create2.computeAddress( - _salt, keccak256(abi.encodePacked(type(MockTournament).creationCode, abi.encode(initialState, provider))) + _salt, keccak256(abi.encodePacked(type(MockTask).creationCode, abi.encode(initialState, provider))) ); } @@ -207,13 +100,13 @@ contract MockTournamentFactory is ITournamentFactory { _salt = salt; } - function getNumberOfMockTournaments() external view returns (uint256) { - return _mockTournaments.length; + function getNumberOfMockTasks() external view returns (uint256) { + return _mockTasks.length; } - function getMockTournament(uint256 index) external view returns (MockTournament) { - if (index < _mockTournaments.length) { - return _mockTournaments[index]; + function getMockTask(uint256 index) external view returns (MockTask) { + if (index < _mockTasks.length) { + return _mockTasks[index]; } else { revert IndexOutOfBounds(); } @@ -232,22 +125,24 @@ contract LibMerkle32Wrapper { contract DaveConsensusTest is Test { IInputBox _inputBox; - MockTournamentFactory _mockTournamentFactory; + MockTaskSpawner _mockTaskSpawner; MerkleProxy _merkleProxy; + address _securityCouncil; function setUp() external { _inputBox = new InputBox(); - _mockTournamentFactory = new MockTournamentFactory(); + _mockTaskSpawner = new MockTaskSpawner(); _merkleProxy = new MerkleProxy(); + _securityCouncil = address(0xBEEF); } - function testMockTournamentFactory() external view { - assertEq(_mockTournamentFactory.getNumberOfMockTournaments(), 0); + function testMockTaskSpawner() external view { + assertEq(_mockTaskSpawner.getNumberOfMockTasks(), 0); } - function testMockTournamentFactory(uint256 index) external { - vm.expectRevert(MockTournamentFactory.IndexOutOfBounds.selector); - _mockTournamentFactory.getMockTournament(index); + function testMockTaskSpawner(uint256 index) external { + vm.expectRevert(MockTaskSpawner.IndexOutOfBounds.selector); + _mockTaskSpawner.getMockTask(index); } function testConstructorAndSettle( @@ -255,7 +150,6 @@ contract DaveConsensusTest is Test { bytes32[3] calldata outputsMerkleRoots, uint256[2] memory inputCounts, bytes32[3] calldata salts, - Tree.Node[2] calldata winnerCommitments, uint256 deploymentBlockNumber ) external { vm.roll(deploymentBlockNumber); @@ -269,32 +163,30 @@ contract DaveConsensusTest is Test { (Machine.Hash state0,,) = _statesAndProofs(outputsMerkleRoots[0]); DaveConsensus daveConsensus; - MockTournament mockTournament; + MockTask mockTask; { address daveConsensusAddress = _calculateNewDaveConsensus(appContract, state0, salts[0]); - _mockTournamentFactory.setSalt(salts[1]); - address mockTournamentAddress = - _mockTournamentFactory.calculateTournamentAddress(state0, IDataProvider(daveConsensusAddress)); + _mockTaskSpawner.setSalt(salts[1]); + address mockTaskAddress = _mockTaskSpawner.calculateTaskAddress(state0, IDataProvider(daveConsensusAddress)); vm.expectEmit(daveConsensusAddress); - emit IDaveConsensus.ConsensusCreation(_inputBox, appContract, _mockTournamentFactory); + emit IDaveConsensus.ConsensusCreation(_inputBox, appContract, _mockTaskSpawner); vm.expectEmit(daveConsensusAddress); - emit IDaveConsensus.EpochSealed( - 0, 0, inputCounts[0], state0, bytes32(0), ITournament(mockTournamentAddress) - ); + emit IDaveConsensus.EpochSealed(0, 0, inputCounts[0], state0, bytes32(0), ITask(mockTaskAddress)); daveConsensus = _newDaveConsensus(appContract, state0, salts[0]); assertEq(address(daveConsensus), daveConsensusAddress); assertEq(address(daveConsensus.getInputBox()), address(_inputBox)); assertEq(daveConsensus.getApplicationContract(), appContract); - assertEq(address(daveConsensus.getTournamentFactory()), address(_mockTournamentFactory)); + assertEq(address(daveConsensus.getTaskSpawner()), address(_mockTaskSpawner)); + assertEq(daveConsensus.getSecurityCouncil(), _securityCouncil); assertEq(daveConsensus.getDeploymentBlockNumber(), deploymentBlockNumber); - mockTournament = MockTournament(mockTournamentAddress); + mockTask = MockTask(mockTaskAddress); } { @@ -311,41 +203,38 @@ contract DaveConsensusTest is Test { uint256 epochNumber; uint256 inputIndexLowerBound; uint256 inputIndexUpperBound; - ITournament tournament; + ITask task; - (epochNumber, inputIndexLowerBound, inputIndexUpperBound, tournament) = - daveConsensus.getCurrentSealedEpoch(); + (epochNumber, inputIndexLowerBound, inputIndexUpperBound, task) = daveConsensus.getCurrentSealedEpoch(); assertEq(epochNumber, 0); assertEq(inputIndexLowerBound, 0); assertEq(inputIndexUpperBound, inputCounts[0]); - assertEq(address(tournament), address(mockTournament)); + assertEq(address(task), address(mockTask)); } - assertEq(_mockTournamentFactory.getNumberOfMockTournaments(), 1); - assertEq(address(_mockTournamentFactory.getMockTournament(0)), address(mockTournament)); + assertEq(_mockTaskSpawner.getNumberOfMockTasks(), 1); + assertEq(address(_mockTaskSpawner.getMockTask(0)), address(mockTask)); - assertEq(Machine.Hash.unwrap(mockTournament.getInitialState()), Machine.Hash.unwrap(state0)); - assertEq(address(mockTournament.getProvider()), address(daveConsensus)); + assertEq(Machine.Hash.unwrap(mockTask.getInitialState()), Machine.Hash.unwrap(state0)); + assertEq(address(mockTask.getProvider()), address(daveConsensus)); { - (bool isFinished,,) = mockTournament.arbitrationResult(); + (bool isFinished,) = mockTask.result(); assertFalse(isFinished); } (Machine.Hash state1,,) = _statesAndProofs(outputsMerkleRoots[1]); - mockTournament.finish(winnerCommitments[0], state1); + mockTask.finish(state1); { bool isFinished; - Tree.Node winnerCommitmentTmp; Machine.Hash finalStateTmp; - (isFinished, winnerCommitmentTmp, finalStateTmp) = mockTournament.arbitrationResult(); + (isFinished, finalStateTmp) = mockTask.result(); assertTrue(isFinished); - assertEq(Tree.Node.unwrap(winnerCommitmentTmp), Tree.Node.unwrap(winnerCommitments[0])); assertEq(Machine.Hash.unwrap(finalStateTmp), Machine.Hash.unwrap(state1)); } @@ -364,23 +253,23 @@ contract DaveConsensusTest is Test { _addInputs(appContract, inputCounts[1]); { - _mockTournamentFactory.setSalt(salts[2]); - address mockTournamentAddress = _mockTournamentFactory.calculateTournamentAddress(state1, daveConsensus); + _mockTaskSpawner.setSalt(salts[2]); + address mockTaskAddress = _mockTaskSpawner.calculateTaskAddress(state1, daveConsensus); (, bytes32[] memory proof1, bytes32 leaf1) = _statesAndProofs(outputsMerkleRoots[1]); vm.expectEmit(address(daveConsensus)); emit IDaveConsensus.EpochSealed( - 1, inputCounts[0], inputCounts[0] + inputCounts[1], state1, leaf1, ITournament(mockTournamentAddress) + 1, inputCounts[0], inputCounts[0] + inputCounts[1], state1, leaf1, ITask(mockTaskAddress) ); daveConsensus.settle(0, leaf1, proof1); - assertEq(_mockTournamentFactory.getNumberOfMockTournaments(), 2); + assertEq(_mockTaskSpawner.getNumberOfMockTasks(), 2); - mockTournament = _mockTournamentFactory.getMockTournament(1); + mockTask = _mockTaskSpawner.getMockTask(1); - assertEq(address(mockTournament), mockTournamentAddress); + assertEq(address(mockTask), mockTaskAddress); } { @@ -397,22 +286,21 @@ contract DaveConsensusTest is Test { uint256 epochNumber; uint256 inputIndexLowerBound; uint256 inputIndexUpperBound; - ITournament tournament; + ITask task; - (epochNumber, inputIndexLowerBound, inputIndexUpperBound, tournament) = - daveConsensus.getCurrentSealedEpoch(); + (epochNumber, inputIndexLowerBound, inputIndexUpperBound, task) = daveConsensus.getCurrentSealedEpoch(); assertEq(epochNumber, 1); assertEq(inputIndexLowerBound, inputCounts[0]); assertEq(inputIndexUpperBound, inputCounts[0] + inputCounts[1]); - assertEq(address(tournament), address(mockTournament)); + assertEq(address(task), address(mockTask)); } - assertEq(Machine.Hash.unwrap(mockTournament.getInitialState()), Machine.Hash.unwrap(state1)); - assertEq(address(mockTournament.getProvider()), address(daveConsensus)); + assertEq(Machine.Hash.unwrap(mockTask.getInitialState()), Machine.Hash.unwrap(state1)); + assertEq(address(mockTask.getProvider()), address(daveConsensus)); { - (bool isFinished,,) = mockTournament.arbitrationResult(); + (bool isFinished,) = mockTask.result(); assertFalse(isFinished); } @@ -420,17 +308,15 @@ contract DaveConsensusTest is Test { assertTrue(daveConsensus.isOutputsMerkleRootValid(appContract, outputsMerkleRoots[1])); (Machine.Hash state2,,) = _statesAndProofs(outputsMerkleRoots[2]); - mockTournament.finish(winnerCommitments[1], state2); + mockTask.finish(state2); { bool isFinished; - Tree.Node winnerCommitmentTmp; Machine.Hash finalStateTmp; - (isFinished, winnerCommitmentTmp, finalStateTmp) = mockTournament.arbitrationResult(); + (isFinished, finalStateTmp) = mockTask.result(); assertTrue(isFinished); - assertEq(Tree.Node.unwrap(winnerCommitmentTmp), Tree.Node.unwrap(winnerCommitments[1])); assertEq(Machine.Hash.unwrap(finalStateTmp), Machine.Hash.unwrap(state2)); } } @@ -450,7 +336,7 @@ contract DaveConsensusTest is Test { _addInputs(appContract, inputCounts[0]); - _mockTournamentFactory.setSalt(salts[0]); + _mockTaskSpawner.setSalt(salts[0]); DaveConsensus daveConsensus = _newDaveConsensus(appContract, states[0], salts[1]); @@ -463,6 +349,67 @@ contract DaveConsensusTest is Test { daveConsensus.settle(0, bytes32(0), new bytes32[](0)); } + function testPauseBlocksSettle( + address appContract, + Machine.Hash initialState, + bytes32 outputsMerkleRoot, + bytes32 salt + ) external { + _mockTaskSpawner.setSalt(salt); + DaveConsensus daveConsensus = _newDaveConsensus(appContract, initialState, salt); + + MockTask task = _mockTaskSpawner.getMockTask(0); + (Machine.Hash finalState, bytes32[] memory proof, bytes32 outputRoot) = _statesAndProofs(outputsMerkleRoot); + task.finish(finalState); + + vm.prank(_securityCouncil); + daveConsensus.pause(); + + vm.expectRevert(IDaveConsensus.PausedError.selector); + daveConsensus.settle(0, outputRoot, proof); + + vm.prank(_securityCouncil); + daveConsensus.unpause(); + + daveConsensus.settle(0, outputRoot, proof); + } + + function testUpgradeOnlySecurityCouncil(address appContract, Machine.Hash initialState, bytes32 salt) external { + DaveConsensus daveConsensus = _newDaveConsensus(appContract, initialState, salt); + + MockTaskSpawner newSpawner = new MockTaskSpawner(); + vm.expectRevert(IDaveConsensus.NotSecurityCouncil.selector); + daveConsensus.upgrade(initialState, newSpawner); + } + + function testUpgradeSpawnsNewTask(address appContract, Machine.Hash initialState, bytes32 salt, bytes32 newSalt) + external + { + DaveConsensus daveConsensus = _newDaveConsensus(appContract, initialState, salt); + + MockTaskSpawner newSpawner = new MockTaskSpawner(); + newSpawner.setSalt(newSalt); + + Machine.Hash newState = Machine.Hash.wrap(keccak256("upgrade-state")); + address expectedTask = newSpawner.calculateTaskAddress(newState, IDataProvider(address(daveConsensus))); + + vm.expectEmit(address(daveConsensus)); + emit IDaveConsensus.TaskUpgraded(0, newState, newSpawner, ITask(expectedTask)); + + vm.prank(_securityCouncil); + daveConsensus.upgrade(newState, newSpawner); + + assertEq(address(daveConsensus.getTaskSpawner()), address(newSpawner)); + + (uint256 epochNumber,,, ITask task) = daveConsensus.getCurrentSealedEpoch(); + assertEq(epochNumber, 0); + assertEq(address(task), expectedTask); + + MockTask spawned = MockTask(expectedTask); + assertEq(Machine.Hash.unwrap(spawned.getInitialState()), Machine.Hash.unwrap(newState)); + assertEq(address(spawned.getProvider()), address(daveConsensus)); + } + function testProvideMerkleRootOfInput( address appContract, bytes[] calldata payloads, @@ -473,7 +420,7 @@ contract DaveConsensusTest is Test { ) external { bytes[] memory inputs = _addInputs(appContract, payloads); - _mockTournamentFactory.setSalt(salts[0]); + _mockTaskSpawner.setSalt(salts[0]); DaveConsensus daveConsensus = _newDaveConsensus(appContract, initialState, salts[1]); @@ -575,7 +522,7 @@ contract DaveConsensusTest is Test { keccak256( abi.encodePacked( type(DaveConsensus).creationCode, - abi.encode(_inputBox, appContract, _mockTournamentFactory, initialState) + abi.encode(_inputBox, appContract, _mockTaskSpawner, _securityCouncil, initialState) ) ) ); @@ -585,7 +532,7 @@ contract DaveConsensusTest is Test { internal returns (DaveConsensus) { - return new DaveConsensus{salt: salt}(_inputBox, appContract, _mockTournamentFactory, initialState); + return new DaveConsensus{salt: salt}(_inputBox, appContract, _mockTaskSpawner, _securityCouncil, initialState); } function _statesAndProofs(bytes32 outputsMerkleRoot) private returns (Machine.Hash, bytes32[] memory, bytes32) { diff --git a/cartesi-rollups/contracts/test/Merkle.t.sol b/cartesi-rollups/contracts/test/Merkle.t.sol index dc890480..fd738ed2 100644 --- a/cartesi-rollups/contracts/test/Merkle.t.sol +++ b/cartesi-rollups/contracts/test/Merkle.t.sol @@ -364,7 +364,7 @@ contract MerkleTest is Test { } function testGetHashOfLeafAtIndex(bytes calldata data, uint256 index) external pure { - index = bound(index, _getNumOfLeaves(data.length), type(uint256).max); + index = bound(index, _getNumOfLeaves(data.length), type(uint256).max >> MerkleConstants.LOG2_LEAF_SIZE); bytes32 leafHash = PristineMerkleTree.getNodeAtHeight(0); assertEq(MerkleWrapper.getHashOfLeafAtIndex(data, index), leafHash); } diff --git a/cartesi-rollups/node/blockchain-reader/src/lib.rs b/cartesi-rollups/node/blockchain-reader/src/lib.rs index 23973a57..2d733920 100644 --- a/cartesi-rollups/node/blockchain-reader/src/lib.rs +++ b/cartesi-rollups/node/blockchain-reader/src/lib.rs @@ -257,7 +257,7 @@ impl BlockchainReader { .inputIndexUpperBound .to_u64() .expect("fail to convert epoch boundary"), - root_tournament: e.tournament, + root_tournament: e.task, block_created_number: meta.block_number.expect("block number should exist"), }; info!( diff --git a/cartesi-rollups/node/cartesi-rollups-prt-node/src/lib.rs b/cartesi-rollups/node/cartesi-rollups-prt-node/src/lib.rs index 5d9589bf..c8f5997b 100644 --- a/cartesi-rollups/node/cartesi-rollups-prt-node/src/lib.rs +++ b/cartesi-rollups/node/cartesi-rollups-prt-node/src/lib.rs @@ -95,6 +95,7 @@ pub fn create_epoch_manager_task(watch: Watch, parameters: &PRTConfig) -> thread let epoch_manager = EpochManager::new( Arc::new(Mutex::new(arena_sender)), params.address_book.consensus, + params.signer_address, state_manager, params.sleep_duration, ); diff --git a/cartesi-rollups/node/epoch-manager/Cargo.toml b/cartesi-rollups/node/epoch-manager/Cargo.toml index 22591a1e..f5134664 100644 --- a/cartesi-rollups/node/epoch-manager/Cargo.toml +++ b/cartesi-rollups/node/epoch-manager/Cargo.toml @@ -11,6 +11,7 @@ repository.workspace = true [dependencies] cartesi-dave-contracts = { workspace = true } +cartesi-prt-contracts = { workspace = true } cartesi-prt-core = { workspace = true } rollups-state-manager = { workspace = true } diff --git a/cartesi-rollups/node/epoch-manager/src/lib.rs b/cartesi-rollups/node/epoch-manager/src/lib.rs index cc07239a..14ce53e8 100644 --- a/cartesi-rollups/node/epoch-manager/src/lib.rs +++ b/cartesi-rollups/node/epoch-manager/src/lib.rs @@ -3,10 +3,9 @@ mod error; -use alloy::{ - primitives::{Address, B256}, - providers::{DynProvider, Provider}, -}; +use alloy::primitives::{Address, B256, FixedBytes}; +use alloy::providers::{DynProvider, Provider}; +use alloy::sol_types::SolInterface; use error::Result; use log::{debug, info, trace}; use num_traits::cast::ToPrimitive; @@ -14,34 +13,38 @@ use std::{ops::ControlFlow, sync::Arc, time::Duration}; use tokio::sync::Mutex; use cartesi_dave_contracts::dave_consensus::DaveConsensus; +use cartesi_prt_contracts::safety_gate_task; use cartesi_prt_core::{ db::dispute_state_access::{Input, Leaf}, strategy::player::Player, tournament::{ArenaSender, allow_revert_rethrow_others}, }; -use rollups_state_manager::{Epoch, Proof, StateManager, sync::Watch}; +use rollups_state_manager::{Epoch, Proof, Settlement, StateManager, sync::Watch}; pub struct EpochManager { arena_sender: Arc>, consensus: Address, + signer_address: Address, sleep_duration: Duration, state_manager: SM, - last_react_epoch: (Option>, u64), + last_react_epoch: (Option>, u64, Address), } impl EpochManager { pub fn new( arena_sender: Arc>, consensus_address: Address, + signer_address: Address, state_manager: SM, sleep_duration: Duration, ) -> Self { Self { arena_sender, consensus: consensus_address, + signer_address, sleep_duration, state_manager, - last_react_epoch: (None, 0), + last_react_epoch: (None, 0, Address::ZERO), } } @@ -80,10 +83,10 @@ impl EpochManager { )? { Some(settlement) => { assert_eq!( - settlement.computation_hash.data(), - can_settle.winnerCommitment, - "Winner commitment mismatch, notify all users!" + settlement.final_state, can_settle.finalState, + "Winner state mismatch, notify all users!" ); + info!( "settle epoch {} with claim {}", can_settle.epochNumber, @@ -116,12 +119,20 @@ impl EpochManager { .state_manager .settlement_info(last_sealed_epoch.epoch_number)? { - Some(_) => { + Some(settlement) => { trace!( "dispute tournaments for epoch {}", last_sealed_epoch.epoch_number ); - self.react_dispute(provider, &last_sealed_epoch).await? + let tournament_address = self + .resolve_tournament_address( + provider.clone(), + last_sealed_epoch.root_tournament, + &settlement, + ) + .await?; + self.react_dispute(provider, &last_sealed_epoch, tournament_address) + .await? } None => { debug!( @@ -138,8 +149,9 @@ impl EpochManager { &mut self, provider: DynProvider, last_sealed_epoch: &Epoch, + tournament_address: Address, ) -> Result<()> { - self.get_latest_player(last_sealed_epoch, provider)?; + self.get_latest_player(last_sealed_epoch, provider, tournament_address)?; self.last_react_epoch .0 .as_mut() @@ -154,6 +166,7 @@ impl EpochManager { &mut self, last_sealed_epoch: &Epoch, provider: DynProvider, + tournament_address: Address, ) -> Result<()> { let snapshot = self .state_manager @@ -164,6 +177,7 @@ impl EpochManager { // we need to instantiate new epoch player with appropriate data if self.last_react_epoch.0.is_none() || self.last_react_epoch.1 != last_sealed_epoch.epoch_number + || self.last_react_epoch.2 != tournament_address { let inputs = self .state_manager @@ -188,18 +202,77 @@ impl EpochManager { leafs, provider.erased(), snapshot.to_string_lossy().to_string(), - last_sealed_epoch.root_tournament, + tournament_address, last_sealed_epoch.block_created_number, self.state_manager .epoch_directory(last_sealed_epoch.epoch_number)?, ) .expect("fail to initialize prt player"); - self.last_react_epoch = (Some(player), last_sealed_epoch.epoch_number); + self.last_react_epoch = ( + Some(player), + last_sealed_epoch.epoch_number, + tournament_address, + ); } Ok(()) } + + async fn resolve_tournament_address( + &self, + provider: DynProvider, + task_address: Address, + settlement: &Settlement, + ) -> Result
{ + if let Some(inner_task) = self + .try_safety_gate(provider, task_address, settlement) + .await? + { + Ok(inner_task) + } else { + Ok(task_address) + } + } + + async fn try_safety_gate( + &self, + provider: DynProvider, + task_address: Address, + settlement: &Settlement, + ) -> Result> { + if !supports_interface(provider.clone(), task_address, safety_gate_interface_id()).await { + return Ok(None); + } + + let safety_gate = safety_gate_task::SafetyGateTask::new(task_address, provider.clone()); + + self.try_sentry_vote(&safety_gate, settlement).await?; + + let inner_task = safety_gate.INNER_TASK().call().await?; + Ok(Some(inner_task)) + } + + async fn try_sentry_vote( + &self, + safety_gate: &safety_gate_task::SafetyGateTask::SafetyGateTaskInstance, + settlement: &Settlement, + ) -> Result<()> { + let is_sentry = safety_gate.isSentry(self.signer_address).call().await?; + if !is_sentry { + return Ok(()); + } + + let has_voted = safety_gate.hasVoted(self.signer_address).call().await?; + if has_voted { + return Ok(()); + } + + let vote = B256::from(settlement.final_state); + let tx_result = safety_gate.sentryVote(vote).send().await; + allow_revert_rethrow_others("sentryVote", tx_result).await?; + Ok(()) + } } fn to_bytes_32_vec(proof: Proof) -> Vec { @@ -209,3 +282,41 @@ fn to_bytes_32_vec(proof: Proof) -> Vec { fn vec_u8_to_bytes_32(hash: Vec) -> B256 { B256::from_slice(&hash) } + +fn safety_gate_interface_id() -> FixedBytes<4> { + interface_id_from_selectors(safety_gate_task::SafetyGateTask::SafetyGateTaskCalls::selectors()) +} + +fn interface_id_from_selectors(selectors: I) -> FixedBytes<4> +where + I: IntoIterator, +{ + let mut id = [0u8; 4]; + for selector in selectors { + id[0] ^= selector[0]; + id[1] ^= selector[1]; + id[2] ^= selector[2]; + id[3] ^= selector[3]; + } + FixedBytes::from(id) +} + +async fn supports_interface( + provider: DynProvider, + contract: Address, + interface_id: FixedBytes<4>, +) -> bool { + let erc165 = safety_gate_task::SafetyGateTask::new(contract, provider); + match erc165.supportsInterface(interface_id).call().await { + Ok(value) => value, + Err(err) => { + let message = err.to_string(); + if message.contains("execution reverted") { + trace!("supportsInterface reverted: {}", message); + } else { + debug!("supportsInterface call failed: {}", message); + } + false + } + } +} diff --git a/cartesi-rollups/node/state-manager/src/lib.rs b/cartesi-rollups/node/state-manager/src/lib.rs index 442afc82..bb83317d 100644 --- a/cartesi-rollups/node/state-manager/src/lib.rs +++ b/cartesi-rollups/node/state-manager/src/lib.rs @@ -65,6 +65,7 @@ impl Proof { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Settlement { pub computation_hash: Digest, + pub final_state: Hash, pub output_merkle: Hash, pub output_proof: Proof, } diff --git a/cartesi-rollups/node/state-manager/src/persistent_state_access.rs b/cartesi-rollups/node/state-manager/src/persistent_state_access.rs index b11d772f..d8a618d6 100644 --- a/cartesi-rollups/node/state-manager/src/persistent_state_access.rs +++ b/cartesi-rollups/node/state-manager/src/persistent_state_access.rs @@ -12,6 +12,7 @@ use crate::{ use alloy::primitives::U256; use cartesi_dave_merkle::{Digest, MerkleBuilder}; +use cartesi_machine::types::Hash; use rusqlite::Connection; #[derive(Debug)] @@ -212,7 +213,7 @@ impl StateManager for PersistentStateAccess { let settlement = { let leafs = rollup_data::get_all_commitments(&self.connection, previous_epoch_number)?; - let computation_hash = if !leafs.is_empty() { + let (computation_hash, final_state) = if !leafs.is_empty() { build_commitment_from_hashes(&leafs) } else { assert_eq!(machine.next_input_index_in_epoch(), 0); @@ -226,6 +227,7 @@ impl StateManager for PersistentStateAccess { Settlement { computation_hash, + final_state, output_merkle, output_proof, } @@ -286,7 +288,7 @@ impl StateManager for PersistentStateAccess { } } -fn build_commitment_from_hashes(state_hashes: &[CommitmentLeaf]) -> Digest { +fn build_commitment_from_hashes(state_hashes: &[CommitmentLeaf]) -> (Digest, Hash) { let mut builder = MerkleBuilder::default(); assert!(!state_hashes.is_empty()); @@ -306,7 +308,7 @@ fn build_commitment_from_hashes(state_hashes: &[CommitmentLeaf]) -> Digest { ); let tree = builder.build(); - tree.root_hash() + (tree.root_hash(), last.hash) } #[cfg(test)] @@ -523,13 +525,14 @@ mod tests { access.roll_epoch()?; assert_eq!(access.latest_snapshot()?.epoch(), 1); + let (computation_hash, final_state) = + build_commitment_from_hashes(&[commitment_leaf_1.clone(), commitment_leaf_2.clone()]); + assert_eq!( access.settlement_info(0)?.unwrap(), Settlement { - computation_hash: build_commitment_from_hashes(&[ - commitment_leaf_1.clone(), - commitment_leaf_2.clone() - ]), + computation_hash, + final_state, output_merkle, output_proof }, diff --git a/cartesi-rollups/node/state-manager/src/sql/migrations.sql b/cartesi-rollups/node/state-manager/src/sql/migrations.sql index e14e5fa9..9006e653 100644 --- a/cartesi-rollups/node/state-manager/src/sql/migrations.sql +++ b/cartesi-rollups/node/state-manager/src/sql/migrations.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS settlement_info ( epoch_number INTEGER NOT NULL PRIMARY KEY CHECK (epoch_number >= 0), computation_hash BLOB NOT NULL, + final_state BLOB NOT NULL, output_merkle BLOB NOT NULL, output_proof BLOB NOT NULL ); diff --git a/cartesi-rollups/node/state-manager/src/sql/rollup_data.rs b/cartesi-rollups/node/state-manager/src/sql/rollup_data.rs index 0b9be5d2..25463648 100644 --- a/cartesi-rollups/node/state-manager/src/sql/rollup_data.rs +++ b/cartesi-rollups/node/state-manager/src/sql/rollup_data.rs @@ -29,16 +29,21 @@ fn convert_row_to_settlement(row: &rusqlite::Row) -> rusqlite::Result [u8;32] + let fs_blob: Vec = row.get(1)?; + let final_state: Hash = fs_blob.try_into().expect("final_state must be 32 bytes"); + // output_merkle blob -> [u8;32] - let om_blob: Vec = row.get(1)?; + let om_blob: Vec = row.get(2)?; let output_merkle: Hash = om_blob.try_into().expect("output_merkle must be 32 bytes"); // output_proof blob -> Proof - let proof_blob: Vec = row.get(2)?; + let proof_blob: Vec = row.get(3)?; let output_proof = Proof::from_flattened(proof_blob); Ok(Settlement { computation_hash, + final_state, output_merkle, output_proof, }) @@ -108,7 +113,7 @@ pub fn settlement_info(conn: &Connection, epoch_number: u64) -> Result=8 signers with >=75% threshold, and membership diversity across entities and jurisdictions. +- Holds "break-glass" powers: upgrade, pause, and output-tree excision (app-specific). + +### 6) Sentries + +- Hot EOAs or low-threshold multisigs that can attest to the correct final state. +- Designed to be fast and replaceable. +- Designed to have minimal powers (i.e. delay only). + +## Protocol Flow (Normal Operation) + +1. DaveConsensus spawns a task for the current epoch using the current task spawner. +2. The task resolves to a final machine hash. +3. DaveConsensus validates the outputs Merkle root against that final hash. +4. The epoch is sealed and the next epoch task is spawned. + +If the Safety Gate is used, `result()` is mediated by sentries and a disagreement window. + +## Pause Primitive (DaveConsensus) + +Function (DaveConsensus): + +- `pause()` / `unpause()` by Security Council only. + +Behavior: + +- While paused, `settle()` reverts and no new epoch can be sealed. +- The current task continues to run off-chain; the pause only blocks finalization on L1. + +Rationale: + +- Buys time to diagnose issues and coordinate an upgrade during live incidents. + + +## Upgrade Primitive + +Function (DaveConsensus): + +- `upgrade(newInitialState, newTaskSpawner)` by Security Council only. + +Behavior (in-flight task swap): + +- The task spawner is swapped immediately. +- A new task is spawned for the *current epoch bounds* (a "replay") using the new initial state. +- The old task is ghosted (a "zombie"); its result is ignored. + +Rationale: + +- Allows recovery from proof system bugs or application bugs. +- Keeps the application logic minimal and agnostic to the proof system implementation. + +Operational note: + +- Validators must have the new upgraded machine snapshot. + + +## Safety Gate Task + +Decision logic: + +- All sentries vote and agree on a claim. +- If any sentry (1-of-N) disagrees or fails to vote, anyone can start a fallback timer once the inner task + is finished. +- After the disagreement window elapses, the inner task result is accepted. + +Properties: + +- Sentries can delay settlement, but cannot change the result. +- One byzantine or fail-stop sentry can force delay; this is intentional to prioritize safety. +- Sentry set is immutable per task; Security Council can update it for future tasks. + +## Stage 1 Alignment (L2Beat) + +Our design fulfills the Stage 1 requirements in the following ways: + +- **Permissionless Proof System**: +- **Challenge period**: The dispute window is configured to be >=7 days. +- **Single point of emergency authority**: Only the Security Council can upgrade the task spawner and change the rollup trajectory. All other roles can only delay. +- **Upgrade window**: There are no non-SC upgrade paths in this design. +- **Walkaway test**: If the Security Council becomes inactive, the system still resolves by falling back to the permissionless proof system. Moreover, the Sentry cannot halt the chain. + +The Security Council configuration above is intended to satisfy the robustness guidelines of the +L2Beat framework. + +## Threat Scenarios and Outcomes + +This section lists key failure modes and why funds remain safe (or not). + +### Sentry failures + +- **Single sentry byzantine**: + - Effect: Forces disagreement path and delays settlement by the disagreement window. + - Outcome: No safety loss. Security Council can rotate sentries. + +- **Single sentry fail-stop**: + - Effect: Missing votes trigger fallback timer, adding delay. + - Outcome: Liveness degradation only. + +- **All sentries fail-stop**: + - Effect: Same as above; anyone can start fallback after inner task finishes. + - Outcome: Liveness degradation; no safety loss. + +- **All sentries byzantine**: + - Effect: They can delay (by disagreeing) or agree with a bad result. + - Outcome: If they agree with a bad result, the safety gate offers no protection. We rely + on the proof system (or Security Council). + +### Proof system failures (Dave/PRT bug) + +- **PRT bug + honest sentries**: + - Effect: Mismatch between sentry claim and inner result triggers buffer. + - Outcome: Security Council can pause/upgrade before settlement. + +- **PRT bug + all sentries compromised to match bad result**: + - Effect: No buffer; bad result proceeds. + - Outcome: Funds are safe only if the Security Council intervenes in time (pause and upgrade). + +### Security Council failures + +- **Security Council fail-stop**: + - Effect: No upgrades or emergency actions. + - Outcome: Chain still resolves via proof system; liveness preserved (walkaway test). + +- **Security Council byzantine (>= threshold)**: + - Effect: Can upgrade to arbitrary state, pause, or censor withdrawals. + - Outcome: This is the explicit Stage 1 trust assumption. + +## What Must Go Wrong for Funds to Be Stolen + +At least one of the following must occur: + +1. A Security Council threshold is compromised (>=75% of SC signers), or +2. A proof-system bug exists *and* all sentries fail to raise a discrepancy *and* the Security Council fails to intervene before outputs are executed, or + +In other words, safety is compromised only if a bug aligns with a governance failure, or the Security Council itself is compromised. + +## Operational Practices + +- Maintain at least one independent, always-on validator per ecosystem stakeholder. +- Monitor disagreements and ensure the fallback timer is started when needed. +- Run periodic key-rotation exercises for sentries. +- Require multi-party signing ceremonies for upgrades. +- Maintain a tested playbook for pause and upgrade procedures. + +## Current Implementation Scope + +Implemented in this repo: + +- Task interface (`ITask` / `ITaskSpawner`). +- DaveConsensus with Security Council upgrade primitive. +- Safety Gate Task with multi-sentry disagreement buffer. + +## References + +- https://forum.l2beat.com/t/the-stages-framework/291 +- https://forum.l2beat.com/t/stages-update-a-high-level-guiding-principle-for-stage-1/338 diff --git a/machine/rust-bindings/cartesi-machine-sys/build.rs b/machine/rust-bindings/cartesi-machine-sys/build.rs index 527c6543..67a9f363 100644 --- a/machine/rust-bindings/cartesi-machine-sys/build.rs +++ b/machine/rust-bindings/cartesi-machine-sys/build.rs @@ -30,7 +30,7 @@ fn main() { } // static link - println!("cargo:rustc-link-lib=slirp"); + // println!("cargo:rustc-link-lib=slirp"); cfg_if::cfg_if! { if #[cfg(feature = "remote_machine")] { println!("cargo:rustc-link-lib=static=cartesi_jsonrpc"); diff --git a/prt/client-rs/core/src/tournament/sender.rs b/prt/client-rs/core/src/tournament/sender.rs index 4e240d7b..798aa368 100644 --- a/prt/client-rs/core/src/tournament/sender.rs +++ b/prt/client-rs/core/src/tournament/sender.rs @@ -120,8 +120,13 @@ impl ArenaSender for EthArenaSender { .map(|h| -> B256 { (*h).into() }) .collect(); trace!( - "final state for tournament {} at position {}", - proof.node, proof.position + "join tournament {:?} with final_state {} at position {}, left {}, right {}, proof_len {}", + tournament, + proof.node, + proof.position, + left_child, + right_child, + proof.siblings.len() ); let tx_result = tournament .joinTournament( diff --git a/prt/contracts/justfile b/prt/contracts/justfile index adfdacbb..3e1f942a 100644 --- a/prt/contracts/justfile +++ b/prt/contracts/justfile @@ -1,7 +1,7 @@ BINDINGS_DIR := "./bindings-rs/src/contract" DEPLOYMENTS_DIR := "./deployments" SRC_DIR := "." -BINDINGS_FILTER := "^[^I].+TournamentFactory|^Tournament$" +BINDINGS_FILTER := "^[^I].+TournamentFactory|^Tournament$|^SafetyGateTask$" default: build diff --git a/prt/contracts/script/Deployment.s.sol b/prt/contracts/script/Deployment.s.sol index df9c0f6a..40876431 100644 --- a/prt/contracts/script/Deployment.s.sol +++ b/prt/contracts/script/Deployment.s.sol @@ -23,10 +23,13 @@ import { import { RiscVStateTransition } from "src/state-transition/RiscVStateTransition.sol"; -import {Tournament} from "src/tournament/Tournament.sol"; import { MultiLevelTournamentFactory -} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +} from "src/tournament/MultiLevelTournamentFactory.sol"; +import { + SafetyGateTaskSpawner +} from "src/safety-gate-task/SafetyGateTaskSpawner.sol"; +import {Tournament} from "src/tournament/Tournament.sol"; import {Time} from "src/tournament/libs/Time.sol"; type Milliseconds is uint64; @@ -190,7 +193,7 @@ contract DeploymentScript is BaseDeploymentScript { ) ); - _storeDeployment( + address multiLevelTournamentFactory = _storeDeployment( type(MultiLevelTournamentFactory).name, _create2( type(MultiLevelTournamentFactory).creationCode, @@ -202,6 +205,17 @@ contract DeploymentScript is BaseDeploymentScript { ) ); + address[] memory sentries = new address[](1); + sentries[0] = tx.origin; + + _storeDeployment( + type(SafetyGateTaskSpawner).name, + _create2( + type(SafetyGateTaskSpawner).creationCode, + abi.encode(tx.origin, multiLevelTournamentFactory, maxAllowance, sentries) + ) + ); + vmSafe.stopBroadcast(); } diff --git a/prt/contracts/src/ITask.sol b/prt/contracts/src/ITask.sol new file mode 100644 index 00000000..b1ee8f2a --- /dev/null +++ b/prt/contracts/src/ITask.sol @@ -0,0 +1,23 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.17; + +import {IERC165} from "@openzeppelin-contracts-5.5.0/utils/introspection/IERC165.sol"; +import {Machine} from "prt-contracts/types/Machine.sol"; + +/// @notice Task interface for asynchronous proof systems. +interface ITask is IERC165 { + /// @notice Get the task result. + /// @return finished Whether the task has finished + /// @return finalState The finalized machine state (if finished) + function result() + external + view + returns (bool finished, Machine.Hash finalState); + + /// @notice Best-effort cleanup hook for post-settlement actions. + /// @dev Should be safe to call multiple times and return false if not applicable. + /// @return cleaned Whether any cleanup action succeeded. + function cleanup() external returns (bool cleaned); +} diff --git a/prt/contracts/src/ITournamentFactory.sol b/prt/contracts/src/ITaskSpawner.sol similarity index 52% rename from prt/contracts/src/ITournamentFactory.sol rename to prt/contracts/src/ITaskSpawner.sol index c835bca5..5cb39eac 100644 --- a/prt/contracts/src/ITournamentFactory.sol +++ b/prt/contracts/src/ITaskSpawner.sol @@ -4,13 +4,12 @@ pragma solidity ^0.8.17; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITask} from "prt-contracts/ITask.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; -interface ITournamentFactory { - event TournamentCreated(ITournament tournament); - - function instantiate(Machine.Hash initialState, IDataProvider provider) +/// @notice Spawner interface for tasks/proof systems. +interface ITaskSpawner { + function spawn(Machine.Hash initial, IDataProvider provider) external - returns (ITournament); + returns (ITask); } diff --git a/prt/contracts/src/safety-gate-task/ISafetyGateTask.sol b/prt/contracts/src/safety-gate-task/ISafetyGateTask.sol new file mode 100644 index 00000000..c53021f1 --- /dev/null +++ b/prt/contracts/src/safety-gate-task/ISafetyGateTask.sol @@ -0,0 +1,68 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.17; + +import {ITask} from "prt-contracts/ITask.sol"; +import {Machine} from "prt-contracts/types/Machine.sol"; +import {Time} from "prt-contracts/tournament/libs/Time.sol"; + +/// @title ISafetyGateTask +/// @notice Interface for a safety-gated task wrapper. +/// @dev Semantics: +/// - All sentries must vote and agree on a non-zero claim to form consensus. +/// - If sentries disagree or fail to vote, anyone may start a fallback timer; +/// after it elapses, the inner task result is accepted. +/// - This interface does not prescribe auto-start of the fallback timer; +/// an offchain actor must call `startFallbackTimer` for liveness. +interface ISafetyGateTask is ITask { + /// @notice Inner task that provides the primary result. + function INNER_TASK() external view returns (ITask); + + /// @notice Delay window before falling back to the inner task result. + function DISAGREEMENT_WINDOW() external view returns (Time.Duration); + + /// @notice Total number of sentries configured at task creation. + function sentryCount() external view returns (uint256); + + /// @notice Total number of sentry votes submitted for this task. + function sentryTotalVotes() external view returns (uint256); + + /// @notice Current sentry claim (ZERO_STATE means disagreement or no votes). + function currentSentryClaim() external view returns (Machine.Hash); + + /// @notice Whether an address is a sentry for this task (configured list). + function isSentry(address) external view returns (bool); + + /// @notice Whether a given sentry has already voted. + function hasVoted(address) external view returns (bool); + + /// @notice Start of the fallback timer; zero means not started. + function disagreementStart() external view returns (Time.Instant); + + /// @notice Submit a sentry vote for the expected final state. + /// @dev + /// - Each sentry can vote once. + /// - A zero vote is invalid. + /// - If any vote differs from the first, the claim becomes ZERO_STATE. + function sentryVote(Machine.Hash vote) external; + + /// @notice Returns whether all sentries voted and agreed on a claim. + /// @return ok True if all sentries voted and agree on a non-zero claim. + /// @return claim The agreed claim if ok, otherwise ZERO_STATE. + function sentryVotingConsensus() + external + view + returns (bool ok, Machine.Hash claim); + + /// @notice Start the fallback timer if sentries disagree or are missing. + /// @dev Anyone can call this; required for liveness in disagreement cases. + /// This does not resolve immediately; `result()` returns the inner + /// outcome only after the timer elapses. + /// @return started True if the timer was started in this call. + function startFallbackTimer() external returns (bool started); + + /// @notice Returns whether the fallback timer can be started now. + /// @dev True only if inner task finished AND sentry consensus is missing/mismatched. + function canStartFallbackTimer() external view returns (bool); +} diff --git a/prt/contracts/src/safety-gate-task/SafetyGateTask.sol b/prt/contracts/src/safety-gate-task/SafetyGateTask.sol new file mode 100644 index 00000000..0acd4c6c --- /dev/null +++ b/prt/contracts/src/safety-gate-task/SafetyGateTask.sol @@ -0,0 +1,185 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.17; + +import {ITask} from "prt-contracts/ITask.sol"; +import {ISafetyGateTask} from "prt-contracts/safety-gate-task/ISafetyGateTask.sol"; +import {IERC165} from "@openzeppelin-contracts-5.5.0/utils/introspection/IERC165.sol"; +import {Time} from "prt-contracts/tournament/libs/Time.sol"; +import {Machine} from "prt-contracts/types/Machine.sol"; + +/// @title SafetyGateTask +/// @notice Middleware that gates an inner task result behind N sentry votes. +/// @dev Semantics: +/// - All sentries must vote and agree on a non-zero claim to form consensus. +/// - If sentries disagree or fail to vote, anyone may start a fallback timer; +/// after it elapses, the inner task result is accepted. +/// - This contract does not auto-start the fallback timer; an offchain actor +/// must call `startFallbackTimer`. This is a deliberate liveness assumption. +contract SafetyGateTask is ISafetyGateTask { + using Machine for Machine.Hash; + using Time for Time.Instant; + using Time for Time.Duration; + + /// @notice Inner task that provides the primary result (e.g., PRT/Dave). + ITask public immutable INNER_TASK; + + /// @notice Delay window before falling back to the inner task result. + Time.Duration public immutable DISAGREEMENT_WINDOW; + + /// @notice Total number of sentries configured at task creation. + uint256 public sentryCount; + + /// @notice Total number of sentry votes submitted for this task. + uint256 public sentryTotalVotes; + + /// @notice Current sentry claim (ZERO_STATE means disagreement or no votes). + Machine.Hash public currentSentryClaim; + + /// @notice Whether an address is a sentry for this task (configured list). + mapping(address => bool) public isSentry; + + /// @notice Whether a given sentry has already voted. + mapping(address => bool) public hasVoted; + + /// @notice Start of the fallback timer; zero means not started. + Time.Instant public disagreementStart; + + /// @notice Emitted when a sentry casts a vote. + event SentryVoted(address indexed sentry, Machine.Hash vote); + + /// @notice Emitted when the fallback timer is started. + event DisagreementWindowStarted(Time.Instant start); + + error NotSentry(); + error AlreadyVoted(); + error InvalidSentryVote(); + + /// @dev Restricts to sentries configured at construction time. + modifier onlySentry() { + require(isSentry[msg.sender], NotSentry()); + _; + } + + /// @notice Create a safety-gated task around an inner task. + /// @param innerTask The inner task whose result is gated. + /// @param disagreementWindow The delay window before falling back to inner task. + /// @param initialSentries Immutable list of sentries for this task instance. + constructor( + ITask innerTask, + Time.Duration disagreementWindow, + address[] memory initialSentries + ) { + INNER_TASK = innerTask; + DISAGREEMENT_WINDOW = disagreementWindow; + + for (uint256 i = 0; i < initialSentries.length; i++) { + address sentry = initialSentries[i]; + isSentry[sentry] = true; + sentryCount++; + } + } + + /// @inheritdoc ISafetyGateTask + function sentryVote(Machine.Hash vote) external onlySentry { + require(!hasVoted[msg.sender], AlreadyVoted()); + require(!vote.eq(Machine.ZERO_STATE), InvalidSentryVote()); + + if (sentryTotalVotes == 0) { + currentSentryClaim = vote; + } else if (!currentSentryClaim.eq(vote)) { + currentSentryClaim = Machine.ZERO_STATE; + } + + hasVoted[msg.sender] = true; + sentryTotalVotes++; + emit SentryVoted(msg.sender, vote); + } + + /// @inheritdoc ISafetyGateTask + function sentryVotingConsensus() public view returns (bool, Machine.Hash) { + bool sentriesAgree = !currentSentryClaim.eq(Machine.ZERO_STATE); + + if (sentryCount == sentryTotalVotes && sentriesAgree) { + return (true, currentSentryClaim); + } else { + return (false, Machine.ZERO_STATE); + } + } + + /// @inheritdoc ISafetyGateTask + function startFallbackTimer() external returns (bool) { + if (!canStartFallbackTimer()) { + return false; + } + + disagreementStart = Time.currentTime(); + emit DisagreementWindowStarted(disagreementStart); + return true; + } + + /// @inheritdoc ISafetyGateTask + function canStartFallbackTimer() public view returns (bool) { + if (!disagreementStart.isZero()) { + return false; + } + + (bool innerFinished, Machine.Hash innerState) = INNER_TASK.result(); + if (!innerFinished) { + return false; + } + + (bool sentriesAgree, Machine.Hash sentryClaim) = sentryVotingConsensus(); + return !sentriesAgree || !sentryClaim.eq(innerState); + } + + /// @inheritdoc ITask + function result() + external + view + override + returns (bool finished, Machine.Hash finalState) + { + (bool innerFinished, Machine.Hash innerState) = INNER_TASK.result(); + if (!innerFinished) { + return (false, Machine.ZERO_STATE); + } + + (bool sentriesAgree, Machine.Hash sentryClaim) = sentryVotingConsensus(); + + if (sentriesAgree && sentryClaim.eq(innerState)) { + return (true, innerState); + } else if (disagreementStart.isZero()) { + return (false, Machine.ZERO_STATE); + } else if (disagreementStart.timeoutElapsed(DISAGREEMENT_WINDOW)) { + return (true, innerState); + } else { + return (false, Machine.ZERO_STATE); + } + } + + /// @inheritdoc ITask + function cleanup() external override returns (bool cleaned) { + (bool innerFinished,) = INNER_TASK.result(); + if (!innerFinished) { + return false; + } + + try INNER_TASK.cleanup() returns (bool ok) { + return ok; + } catch { + return false; + } + } + + function supportsInterface(bytes4 interfaceId) + external + view + returns (bool) + { + return interfaceId == type(IERC165).interfaceId + || interfaceId == type(ITask).interfaceId + || interfaceId == type(ISafetyGateTask).interfaceId; + } +} diff --git a/prt/contracts/src/safety-gate-task/SafetyGateTaskSpawner.sol b/prt/contracts/src/safety-gate-task/SafetyGateTaskSpawner.sol new file mode 100644 index 00000000..29510b71 --- /dev/null +++ b/prt/contracts/src/safety-gate-task/SafetyGateTaskSpawner.sol @@ -0,0 +1,108 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.17; + +import {IDataProvider} from "prt-contracts/IDataProvider.sol"; +import {ITask} from "prt-contracts/ITask.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; +import { + SafetyGateTask +} from "prt-contracts/safety-gate-task/SafetyGateTask.sol"; +import {Time} from "prt-contracts/tournament/libs/Time.sol"; +import {Machine} from "prt-contracts/types/Machine.sol"; + +/// @title SafetyGateTaskSpawner +/// @notice Spawns safety-gated tasks around an inner task spawner. +/// @dev The sentry list is mutable here, but immutable per spawned task. +contract SafetyGateTaskSpawner is ITaskSpawner { + /// @notice Security council address that manages the sentry set. + address public immutable SECURITY_COUNCIL; + /// @notice Inner task spawner (e.g., Dave/PRT factory). + ITaskSpawner public immutable INNER_SPAWNER; + /// @notice Delay window before falling back to the inner task result. + Time.Duration public immutable DISAGREEMENT_WINDOW; + + /// @notice Current sentry list used for future tasks. + /// @dev Tooling can read the full list from this public array getter. + address[] public sentries; + /// @notice Emitted when a safety-gated task is spawned. + event SafetyGateTaskSpawned( + SafetyGateTask indexed task, ITask indexed innerTask + ); + /// @notice Emitted when the sentry list is replaced. + event SentriesUpdated(address[] sentries); + + error NotSecurityCouncil(); + + /// @dev Restricts to the security council. + modifier onlySecurityCouncil() { + require(msg.sender == SECURITY_COUNCIL, NotSecurityCouncil()); + _; + } + + /// @notice Create a safety-gate task spawner. + /// @param securityCouncil The security council address. + /// @param innerSpawner The inner task spawner to wrap. + /// @param disagreementWindow Delay window before fallback to inner result. + /// @param initialSentries Initial sentry list for future tasks. + constructor( + address securityCouncil, + ITaskSpawner innerSpawner, + Time.Duration disagreementWindow, + address[] memory initialSentries + ) { + SECURITY_COUNCIL = securityCouncil; + INNER_SPAWNER = innerSpawner; + DISAGREEMENT_WINDOW = disagreementWindow; + + _overrideSentries(initialSentries); + } + + /// @inheritdoc ITaskSpawner + /// @dev Uses a snapshot of the current sentry list; later changes do not + /// affect already-spawned tasks. + function spawn(Machine.Hash initial, IDataProvider provider) + external + override + returns (ITask) + { + ITask innerTask = INNER_SPAWNER.spawn(initial, provider); + SafetyGateTask task = + new SafetyGateTask(innerTask, DISAGREEMENT_WINDOW, sentries); + emit SafetyGateTaskSpawned(task, innerTask); + return ITask(address(task)); + } + + /// @notice Replace the full sentry list (affects future tasks only). + /// @dev This does not validate the list; governance is responsible for correctness. + function setSentries(address[] calldata newSentries) + external + onlySecurityCouncil + { + _overrideSentries(newSentries); + } + + /// @notice Returns whether an address is a sentry in the spawner list. + /// @dev If duplicates are present, this still returns true on the first match. + function isSentry(address sentry) external view returns (bool) { + address[] memory current = sentries; + for (uint256 i = 0; i < current.length; i++) { + if (current[i] == sentry) { + return true; + } + } + return false; + } + + /// @dev No validation: list is stored verbatim. + function _overrideSentries(address[] memory newSentries) private { + delete sentries; + + for (uint256 i = 0; i < newSentries.length; i++) { + sentries.push(newSentries[i]); + } + + emit SentriesUpdated(sentries); + } +} diff --git a/prt/contracts/src/tournament/factories/IMultiLevelTournamentFactory.sol b/prt/contracts/src/tournament/IMultiLevelTournamentFactory.sol similarity index 79% rename from prt/contracts/src/tournament/factories/IMultiLevelTournamentFactory.sol rename to prt/contracts/src/tournament/IMultiLevelTournamentFactory.sol index 2962c8a1..abd57add 100644 --- a/prt/contracts/src/tournament/factories/IMultiLevelTournamentFactory.sol +++ b/prt/contracts/src/tournament/IMultiLevelTournamentFactory.sol @@ -4,13 +4,13 @@ pragma solidity ^0.8.17; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; +import {ITournament} from "prt-contracts/tournament/ITournament.sol"; import {Time} from "prt-contracts/tournament/libs/Time.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; import {Tree} from "prt-contracts/types/Tree.sol"; -interface IMultiLevelTournamentFactory is ITournamentFactory { +interface IMultiLevelTournamentFactory is ITaskSpawner { function instantiateInner( Machine.Hash _initialHash, Tree.Node _contestedCommitmentOne, diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/tournament/ITournament.sol similarity index 99% rename from prt/contracts/src/ITournament.sol rename to prt/contracts/src/tournament/ITournament.sol index 33c71ba5..989c84d4 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/tournament/ITournament.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.17; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; import {IStateTransition} from "prt-contracts/IStateTransition.sol"; +import {ITask} from "prt-contracts/ITask.sol"; import {Clock} from "prt-contracts/tournament/libs/Clock.sol"; import {Commitment} from "prt-contracts/tournament/libs/Commitment.sol"; import {Match} from "prt-contracts/tournament/libs/Match.sol"; @@ -13,7 +14,7 @@ import {Machine} from "prt-contracts/types/Machine.sol"; import {Tree} from "prt-contracts/types/Tree.sol"; /// @notice Tournament interface -interface ITournament { +interface ITournament is ITask { // // Types // diff --git a/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol b/prt/contracts/src/tournament/MultiLevelTournamentFactory.sol similarity index 93% rename from prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol rename to prt/contracts/src/tournament/MultiLevelTournamentFactory.sol index 975c8d03..567580af 100644 --- a/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol +++ b/prt/contracts/src/tournament/MultiLevelTournamentFactory.sol @@ -5,13 +5,16 @@ pragma solidity ^0.8.17; import {Clones} from "@openzeppelin-contracts-5.5.0/proxy/Clones.sol"; -import {IMultiLevelTournamentFactory} from "./IMultiLevelTournamentFactory.sol"; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; import {IStateTransition} from "prt-contracts/IStateTransition.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITask} from "prt-contracts/ITask.sol"; import { ITournamentParametersProvider } from "prt-contracts/arbitration-config/ITournamentParametersProvider.sol"; +import { + IMultiLevelTournamentFactory +} from "prt-contracts/tournament/IMultiLevelTournamentFactory.sol"; +import {ITournament} from "prt-contracts/tournament/ITournament.sol"; import {Tournament} from "prt-contracts/tournament/Tournament.sol"; import {Commitment} from "prt-contracts/tournament/libs/Commitment.sol"; import {Time} from "prt-contracts/tournament/libs/Time.sol"; @@ -24,10 +27,20 @@ import {Tree} from "prt-contracts/types/Tree.sol"; contract MultiLevelTournamentFactory is IMultiLevelTournamentFactory { using Clones for address; + event TournamentCreated(ITournament tournament); + Tournament immutable IMPL; ITournamentParametersProvider immutable TOURNAMENT_PARAMETERS_PROVIDER; IStateTransition immutable STATE_TRANSITION; + function spawn(Machine.Hash _initialHash, IDataProvider _provider) + external + override + returns (ITask) + { + return this.instantiate(_initialHash, _provider); + } + constructor( Tournament _impl, ITournamentParametersProvider _tournamentParametersProvider, @@ -40,7 +53,6 @@ contract MultiLevelTournamentFactory is IMultiLevelTournamentFactory { function instantiate(Machine.Hash _initialHash, IDataProvider _provider) external - override returns (ITournament) { ITournament _tournament = instantiateTop(_initialHash, _provider); diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index fa0e4bb5..231a0d0a 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -5,12 +5,14 @@ pragma solidity ^0.8.17; import {Clones} from "@openzeppelin-contracts-5.5.0/proxy/Clones.sol"; import {Math} from "@openzeppelin-contracts-5.5.0/utils/math/Math.sol"; +import {IERC165} from "@openzeppelin-contracts-5.5.0/utils/introspection/IERC165.sol"; import {IStateTransition} from "prt-contracts/IStateTransition.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITask} from "prt-contracts/ITask.sol"; import { IMultiLevelTournamentFactory -} from "prt-contracts/tournament/factories/IMultiLevelTournamentFactory.sol"; +} from "prt-contracts/tournament/IMultiLevelTournamentFactory.sol"; +import {ITournament} from "prt-contracts/tournament/ITournament.sol"; import {Clock} from "prt-contracts/tournament/libs/Clock.sol"; import {Commitment} from "prt-contracts/tournament/libs/Commitment.sol"; import {Gas} from "prt-contracts/tournament/libs/Gas.sol"; @@ -814,6 +816,40 @@ contract Tournament is ITournament { return (true, _danglingCommitment, _finalState); } + /// @inheritdoc ITask + function result() + external + view + override + returns (bool finished, Machine.Hash finalState) + { + (finished,, finalState) = this.arbitrationResult(); + } + + /// @inheritdoc ITask + /// @dev Best-effort bond recovery for finished tournaments. + function cleanup() external override returns (bool cleaned) { + if (!isFinished()) { + return false; + } + + try this.tryRecoveringBond() returns (bool ok) { + return ok; + } catch { + return false; + } + } + + function supportsInterface(bytes4 interfaceId) + external + view + returns (bool) + { + return interfaceId == type(IERC165).interfaceId + || interfaceId == type(ITask).interfaceId + || interfaceId == type(ITournament).interfaceId; + } + // // Internal functions // diff --git a/prt/contracts/src/tournament/libs/Commitment.sol b/prt/contracts/src/tournament/libs/Commitment.sol index bcb5e24f..602b9221 100644 --- a/prt/contracts/src/tournament/libs/Commitment.sol +++ b/prt/contracts/src/tournament/libs/Commitment.sol @@ -7,7 +7,7 @@ import { Hashes } from "@openzeppelin-contracts-5.5.0/utils/cryptography/Hashes.sol"; -import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITournament} from "prt-contracts/tournament/ITournament.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; import {Tree} from "prt-contracts/types/Tree.sol"; diff --git a/prt/contracts/src/tournament/libs/Match.sol b/prt/contracts/src/tournament/libs/Match.sol index 6a4defc4..ed738614 100644 --- a/prt/contracts/src/tournament/libs/Match.sol +++ b/prt/contracts/src/tournament/libs/Match.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.17; -import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITournament} from "prt-contracts/tournament/ITournament.sol"; import {Commitment} from "prt-contracts/tournament/libs/Commitment.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; import {Tree} from "prt-contracts/types/Tree.sol"; @@ -204,10 +204,7 @@ library Match { return args.toCycle(state.runningLeafPosition); } - function getDivergence( - State memory state, - Commitment.Arguments memory args - ) + function getDivergence(State memory state, Commitment.Arguments memory args) internal pure returns ( diff --git a/prt/contracts/test/BottomTournament.t.sol b/prt/contracts/test/BottomTournament.t.sol index 000f1f44..b6a138fc 100644 --- a/prt/contracts/test/BottomTournament.t.sol +++ b/prt/contracts/test/BottomTournament.t.sol @@ -15,16 +15,16 @@ pragma solidity ^0.8.0; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; import {IStateTransition} from "src/IStateTransition.sol"; -import {ITournament} from "src/ITournament.sol"; import { ArbitrationConstants } from "src/arbitration-config/ArbitrationConstants.sol"; import { CartesiStateTransition } from "src/state-transition/CartesiStateTransition.sol"; +import {ITournament} from "src/tournament/ITournament.sol"; import { MultiLevelTournamentFactory -} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +} from "src/tournament/MultiLevelTournamentFactory.sol"; import {Clock} from "src/tournament/libs/Clock.sol"; import {Match} from "src/tournament/libs/Match.sol"; import {Time} from "src/tournament/libs/Time.sol"; diff --git a/prt/contracts/test/MiddleTournament.t.sol b/prt/contracts/test/MiddleTournament.t.sol index 7c53bf8f..fe8f1a66 100644 --- a/prt/contracts/test/MiddleTournament.t.sol +++ b/prt/contracts/test/MiddleTournament.t.sol @@ -14,13 +14,13 @@ pragma solidity ^0.8.0; import {Vm} from "forge-std-1.9.6/src/Vm.sol"; -import {ITournament} from "src/ITournament.sol"; import { ArbitrationConstants } from "src/arbitration-config/ArbitrationConstants.sol"; +import {ITournament} from "src/tournament/ITournament.sol"; import { MultiLevelTournamentFactory -} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +} from "src/tournament/MultiLevelTournamentFactory.sol"; import {Clock} from "src/tournament/libs/Clock.sol"; import {Match} from "src/tournament/libs/Match.sol"; import {Time} from "src/tournament/libs/Time.sol"; diff --git a/prt/contracts/test/SafetyGateTask.t.sol b/prt/contracts/test/SafetyGateTask.t.sol new file mode 100644 index 00000000..9e691d40 --- /dev/null +++ b/prt/contracts/test/SafetyGateTask.t.sol @@ -0,0 +1,366 @@ +// Copyright 2023 Cartesi Pte. Ltd. + +// SPDX-License-Identifier: Apache-2.0 +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 + +// Unless required by applicable law or agreed to in writing, software distributed +// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +// CONDITIONS OF ANY KIND, either express or implied. See the License for the +// specific language governing permissions and limitations under the License. + +pragma solidity ^0.8.0; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {IDataProvider} from "src/IDataProvider.sol"; +import {ITask} from "src/ITask.sol"; +import {ITaskSpawner} from "src/ITaskSpawner.sol"; +import {SafetyGateTask} from "src/safety-gate-task/SafetyGateTask.sol"; +import { + SafetyGateTaskSpawner +} from "src/safety-gate-task/SafetyGateTaskSpawner.sol"; +import {Time} from "src/tournament/libs/Time.sol"; +import {Machine} from "src/types/Machine.sol"; + +contract MockTask is ITask { + bool private _finished; + Machine.Hash private _state; + + function setResult(bool finished, Machine.Hash state) external { + _finished = finished; + _state = state; + } + + function result() external view returns (bool, Machine.Hash) { + return (_finished, _state); + } + + function cleanup() external returns (bool) { + return _finished; + } + + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ITask).interfaceId; + } +} + +contract MockSpawner is ITaskSpawner { + MockTask public lastTask; + Machine.Hash public lastInitial; + IDataProvider public lastProvider; + bool public nextFinished; + Machine.Hash public nextState; + + function setNextResult(bool finished, Machine.Hash state) external { + nextFinished = finished; + nextState = state; + } + + function spawn(Machine.Hash initial, IDataProvider provider) + external + returns (ITask) + { + lastInitial = initial; + lastProvider = provider; + lastTask = new MockTask(); + lastTask.setResult(nextFinished, nextState); + return ITask(address(lastTask)); + } +} + +contract SafetyGateTaskTest is Test { + using Machine for Machine.Hash; + using Time for Time.Instant; + + Machine.Hash constant STATE_ONE = Machine.Hash.wrap(bytes32(uint256(1))); + Machine.Hash constant STATE_TWO = Machine.Hash.wrap(bytes32(uint256(2))); + Time.Duration constant WINDOW = Time.Duration.wrap(10); + + address constant SENTRY_ONE = address(0x1001); + address constant SENTRY_TWO = address(0x1002); + address constant OTHER = address(0x2001); + address constant SECURITY_COUNCIL = address(0x3001); + + function _newTask(address[] memory sentries) + internal + returns (SafetyGateTask task, MockTask inner) + { + inner = new MockTask(); + task = new SafetyGateTask(inner, WINDOW, sentries); + } + + function testConstructorSetsSentriesAndCount() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + + (SafetyGateTask task,) = _newTask(sentries); + + assertEq(task.sentryCount(), 2); + assertTrue(task.isSentry(SENTRY_ONE)); + assertTrue(task.isSentry(SENTRY_TWO)); + (bool ok, Machine.Hash claim) = task.sentryVotingConsensus(); + assertFalse(ok); + assertTrue(claim.eq(Machine.ZERO_STATE)); + } + + function testSentryVoteRequiresSentry() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + (SafetyGateTask task,) = _newTask(sentries); + + vm.expectRevert( + abi.encodeWithSelector(SafetyGateTask.NotSentry.selector) + ); + vm.prank(OTHER); + task.sentryVote(STATE_ONE); + } + + function testSentryVoteRejectsZero() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + (SafetyGateTask task,) = _newTask(sentries); + + vm.expectRevert( + abi.encodeWithSelector(SafetyGateTask.InvalidSentryVote.selector) + ); + vm.prank(SENTRY_ONE); + task.sentryVote(Machine.ZERO_STATE); + } + + function testSentryVoteOnlyOnce() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + (SafetyGateTask task,) = _newTask(sentries); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + + vm.expectRevert( + abi.encodeWithSelector(SafetyGateTask.AlreadyVoted.selector) + ); + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + } + + function testConsensusAfterAllVotesAgree() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + + (SafetyGateTask task,) = _newTask(sentries); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_ONE); + + (bool ok, Machine.Hash claim) = task.sentryVotingConsensus(); + assertTrue(ok); + assertTrue(claim.eq(STATE_ONE)); + } + + function testDisagreementResetsClaim() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + + (SafetyGateTask task,) = _newTask(sentries); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_TWO); + + assertTrue(task.currentSentryClaim().eq(Machine.ZERO_STATE)); + (bool ok, Machine.Hash claim) = task.sentryVotingConsensus(); + assertFalse(ok); + assertTrue(claim.eq(Machine.ZERO_STATE)); + } + + function testResultWhenInnerNotFinished() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + (SafetyGateTask task, MockTask inner) = _newTask(sentries); + inner.setResult(false, STATE_ONE); + + (bool finished, Machine.Hash finalState) = task.result(); + assertFalse(finished); + assertTrue(finalState.eq(Machine.ZERO_STATE)); + } + + function testResultWhenSentriesAgreeAndMatchInner() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + + (SafetyGateTask task, MockTask inner) = _newTask(sentries); + inner.setResult(true, STATE_ONE); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_ONE); + + (bool finished, Machine.Hash finalState) = task.result(); + assertTrue(finished); + assertTrue(finalState.eq(STATE_ONE)); + } + + function testResultMismatchRequiresFallbackTimer() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + + (SafetyGateTask task, MockTask inner) = _newTask(sentries); + inner.setResult(true, STATE_ONE); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_TWO); + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_TWO); + + (bool finished, Machine.Hash finalState) = task.result(); + assertFalse(finished); + assertTrue(finalState.eq(Machine.ZERO_STATE)); + + assertTrue(task.canStartFallbackTimer()); + + assertTrue(task.startFallbackTimer()); + assertFalse(task.canStartFallbackTimer()); + uint256 startBlock = Time.Instant.unwrap(task.disagreementStart()); + assertGt(startBlock, 0); + + vm.roll(startBlock + Time.Duration.unwrap(WINDOW) - 1); + (finished, finalState) = task.result(); + assertFalse(finished); + assertTrue(finalState.eq(Machine.ZERO_STATE)); + + vm.roll(startBlock + Time.Duration.unwrap(WINDOW)); + (finished, finalState) = task.result(); + assertTrue(finished); + assertTrue(finalState.eq(STATE_ONE)); + } + + function testResultMissingVotesRequiresFallbackTimer() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + + (SafetyGateTask task, MockTask inner) = _newTask(sentries); + inner.setResult(true, STATE_ONE); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + + assertTrue(task.canStartFallbackTimer()); + assertTrue(task.startFallbackTimer()); + + uint256 startBlock = Time.Instant.unwrap(task.disagreementStart()); + vm.roll(startBlock + Time.Duration.unwrap(WINDOW)); + (bool finished, Machine.Hash finalState) = task.result(); + assertTrue(finished); + assertTrue(finalState.eq(STATE_ONE)); + } + + function testStartFallbackTimerRequiresInnerFinished() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + (SafetyGateTask task, MockTask inner) = _newTask(sentries); + inner.setResult(false, STATE_ONE); + + assertFalse(task.canStartFallbackTimer()); + assertFalse(task.startFallbackTimer()); + assertTrue(task.disagreementStart().isZero()); + } + + function testStartFallbackTimerIdempotent() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + (SafetyGateTask task, MockTask inner) = _newTask(sentries); + inner.setResult(true, STATE_ONE); + + assertTrue(task.startFallbackTimer()); + uint256 startBlock = Time.Instant.unwrap(task.disagreementStart()); + assertGt(startBlock, 0); + + vm.roll(startBlock + 1); + assertFalse(task.startFallbackTimer()); + assertEq(Time.Instant.unwrap(task.disagreementStart()), startBlock); + } + + function testSpawnerOnlySecurityCouncilCanSetSentries() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + MockSpawner innerSpawner = new MockSpawner(); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SECURITY_COUNCIL, innerSpawner, WINDOW, sentries + ); + + address[] memory newSentries = new address[](1); + newSentries[0] = SENTRY_TWO; + + vm.expectRevert( + abi.encodeWithSelector( + SafetyGateTaskSpawner.NotSecurityCouncil.selector + ) + ); + vm.prank(OTHER); + spawner.setSentries(newSentries); + } + + function testSpawnerSpawnUsesSnapshot() public { + address[] memory sentries = new address[](1); + sentries[0] = SENTRY_ONE; + + MockSpawner innerSpawner = new MockSpawner(); + innerSpawner.setNextResult(true, STATE_ONE); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SECURITY_COUNCIL, innerSpawner, WINDOW, sentries + ); + + SafetyGateTask taskOne = SafetyGateTask( + address(spawner.spawn(STATE_ONE, IDataProvider(address(0)))) + ); + assertTrue(taskOne.isSentry(SENTRY_ONE)); + assertFalse(taskOne.isSentry(SENTRY_TWO)); + + address[] memory nextSentries = new address[](1); + nextSentries[0] = SENTRY_TWO; + vm.prank(SECURITY_COUNCIL); + spawner.setSentries(nextSentries); + + SafetyGateTask taskTwo = SafetyGateTask( + address(spawner.spawn(STATE_TWO, IDataProvider(address(0)))) + ); + assertFalse(taskTwo.isSentry(SENTRY_ONE)); + assertTrue(taskTwo.isSentry(SENTRY_TWO)); + } + + function testSpawnerStoresDuplicatesVerbatim() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_ONE; + + MockSpawner innerSpawner = new MockSpawner(); + innerSpawner.setNextResult(true, STATE_ONE); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SECURITY_COUNCIL, innerSpawner, WINDOW, sentries + ); + + SafetyGateTask task = SafetyGateTask( + address(spawner.spawn(STATE_ONE, IDataProvider(address(0)))) + ); + assertEq(task.sentryCount(), 2); + assertTrue(task.isSentry(SENTRY_ONE)); + } +} diff --git a/prt/contracts/test/TopTournament.t.sol b/prt/contracts/test/TopTournament.t.sol index d88d5936..a334cfe7 100644 --- a/prt/contracts/test/TopTournament.t.sol +++ b/prt/contracts/test/TopTournament.t.sol @@ -12,13 +12,13 @@ pragma solidity ^0.8.0; -import {ITournament} from "src/ITournament.sol"; import { ArbitrationConstants } from "src/arbitration-config/ArbitrationConstants.sol"; +import {ITournament} from "src/tournament/ITournament.sol"; import { MultiLevelTournamentFactory -} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +} from "src/tournament/MultiLevelTournamentFactory.sol"; import {Match} from "src/tournament/libs/Match.sol"; import {Time} from "src/tournament/libs/Time.sol"; import {Machine} from "src/types/Machine.sol"; diff --git a/prt/contracts/test/Tournament.t.sol b/prt/contracts/test/Tournament.t.sol index ee49aec0..0ec96725 100644 --- a/prt/contracts/test/Tournament.t.sol +++ b/prt/contracts/test/Tournament.t.sol @@ -12,13 +12,13 @@ pragma solidity ^0.8.0; -import {ITournament} from "src/ITournament.sol"; import { ArbitrationConstants } from "src/arbitration-config/ArbitrationConstants.sol"; +import {ITournament} from "src/tournament/ITournament.sol"; import { MultiLevelTournamentFactory -} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +} from "src/tournament/MultiLevelTournamentFactory.sol"; import {Clock} from "src/tournament/libs/Clock.sol"; import {Match} from "src/tournament/libs/Match.sol"; import {Time} from "src/tournament/libs/Time.sol"; diff --git a/prt/contracts/test/TournamentFactory.t.sol b/prt/contracts/test/TournamentFactory.t.sol index bb448413..b9046e4f 100644 --- a/prt/contracts/test/TournamentFactory.t.sol +++ b/prt/contracts/test/TournamentFactory.t.sol @@ -13,13 +13,13 @@ pragma solidity ^0.8.0; import {IDataProvider} from "src/IDataProvider.sol"; -import {ITournament} from "src/ITournament.sol"; import { ArbitrationConstants } from "src/arbitration-config/ArbitrationConstants.sol"; +import {ITournament} from "src/tournament/ITournament.sol"; import { MultiLevelTournamentFactory -} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +} from "src/tournament/MultiLevelTournamentFactory.sol"; import {Util} from "./Util.sol"; diff --git a/prt/contracts/test/Util.sol b/prt/contracts/test/Util.sol index de6d17bf..21742ac1 100644 --- a/prt/contracts/test/Util.sol +++ b/prt/contracts/test/Util.sol @@ -15,7 +15,6 @@ pragma solidity ^0.8.0; import {Test} from "forge-std-1.9.6/src/Test.sol"; import {IDataProvider} from "src/IDataProvider.sol"; -import {ITournament} from "src/ITournament.sol"; import { ArbitrationConstants } from "src/arbitration-config/ArbitrationConstants.sol"; @@ -34,10 +33,11 @@ import { import { RiscVStateTransition } from "src/state-transition/RiscVStateTransition.sol"; -import {Tournament} from "src/tournament/Tournament.sol"; +import {ITournament} from "src/tournament/ITournament.sol"; import { MultiLevelTournamentFactory -} from "src/tournament/factories/MultiLevelTournamentFactory.sol"; +} from "src/tournament/MultiLevelTournamentFactory.sol"; +import {Tournament} from "src/tournament/Tournament.sol"; import {Match} from "src/tournament/libs/Match.sol"; import {Time} from "src/tournament/libs/Time.sol"; import {Machine} from "src/types/Machine.sol";