diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index e6e41e16..7d149285 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -12,7 +12,7 @@ inputs: foundry-version: description: 'Foundry version to install' required: false - default: 'v1.4.3' + default: 'v1.5.1' setup-qemu: description: 'Whether to setup QEMU for riscv support' required: false diff --git a/Cargo.lock b/Cargo.lock index dbb0d57e..656ef85f 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 a5774a4d..081250dc 100644 --- a/cartesi-rollups/contracts/script/Deployment.s.sol +++ b/cartesi-rollups/contracts/script/Deployment.s.sol @@ -14,13 +14,17 @@ contract DeploymentScript is BaseDeploymentScript { address inputBox = _loadDeployment(".", "InputBox"); address appFactory = _loadDeployment(".", "ApplicationFactory"); - address tournamentFactory = _loadDeployment(".", "MultiLevelTournamentFactory"); + + // The factory's bound proof system; apps that want a safety gate + // deploy one at app-creation time via `newGatedDaveApp` (see + // prt/docs/safety-gate.md). + address taskSpawner = _loadDeployment(".", "MultiLevelTournamentFactory"); vmSafe.startBroadcast(); _storeDeployment( type(DaveAppFactory).name, - _create2(type(DaveAppFactory).creationCode, abi.encode(inputBox, appFactory, tournamentFactory)) + _create2(type(DaveAppFactory).creationCode, abi.encode(inputBox, appFactory, taskSpawner)) ); vmSafe.stopBroadcast(); diff --git a/cartesi-rollups/contracts/src/DaveAppFactory.sol b/cartesi-rollups/contracts/src/DaveAppFactory.sol index c051735d..64549377 100644 --- a/cartesi-rollups/contracts/src/DaveAppFactory.sol +++ b/cartesi-rollups/contracts/src/DaveAppFactory.sol @@ -14,7 +14,9 @@ import {IApplication} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplicatio import {IApplicationFactory} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplicationFactory.sol"; import {IInputBox} from "cartesi-rollups-contracts-3.0.0/src/inputs/IInputBox.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; +import {SafetyGateTaskSpawner} from "prt-contracts/safety-gate-task/SafetyGateTaskSpawner.sol"; +import {Time} from "prt-contracts/tournament/libs/Time.sol"; import {Machine} from "prt-contracts/types/Machine.sol"; import {DaveConsensus} from "./DaveConsensus.sol"; @@ -24,14 +26,14 @@ 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; IOutputsMerkleRootValidator constant NO_VALIDATOR = IOutputsMerkleRootValidator(address(0)); - constructor(IInputBox inputBox, IApplicationFactory appFactory, ITournamentFactory tournamentFactory) { + constructor(IInputBox inputBox, IApplicationFactory appFactory, ITaskSpawner taskSpawner) { INPUT_BOX = inputBox; APP_FACTORY = appFactory; - TOURNAMENT_FACTORY = tournamentFactory; + TASK_SPAWNER = taskSpawner; } function newDaveApp(bytes32 templateHash, WithdrawalConfig calldata withdrawalConfig, bytes32 salt) @@ -40,9 +42,8 @@ contract DaveAppFactory is IDaveAppFactory { returns (IApplication appContract, IDaveConsensus daveConsensus) { appContract = _newApplication(templateHash, withdrawalConfig, salt); - daveConsensus = _newDaveConsensus(address(appContract), templateHash, salt); - appContract.migrateToOutputsMerkleRootValidator(daveConsensus); - appContract.renounceOwnership(); + daveConsensus = _newDaveConsensus(address(appContract), templateHash, TASK_SPAWNER, salt); + _wireApp(appContract, daveConsensus); emit DaveAppCreated(appContract, daveConsensus); } @@ -53,7 +54,45 @@ contract DaveAppFactory is IDaveAppFactory { returns (address appContractAddress, address daveConsensusAddress) { appContractAddress = _calculateApplicationAddress(templateHash, withdrawalConfig, salt); - daveConsensusAddress = _calculateDaveConsensusAddress(appContractAddress, templateHash, salt); + daveConsensusAddress = _calculateDaveConsensusAddress(appContractAddress, templateHash, TASK_SPAWNER, salt); + } + + function newGatedDaveApp( + bytes32 templateHash, + WithdrawalConfig calldata withdrawalConfig, + address sentryManager, + Time.Duration disagreementWindow, + address[] calldata sentries, + bytes32 salt + ) + external + override + returns (IApplication appContract, IDaveConsensus daveConsensus, SafetyGateTaskSpawner gateSpawner) + { + appContract = _newApplication(templateHash, withdrawalConfig, salt); + gateSpawner = new SafetyGateTaskSpawner{salt: salt}(sentryManager, TASK_SPAWNER, disagreementWindow, sentries); + daveConsensus = _newDaveConsensus(address(appContract), templateHash, gateSpawner, salt); + _wireApp(appContract, daveConsensus); + emit GatedDaveAppCreated(appContract, daveConsensus, gateSpawner); + } + + function calculateGatedDaveAppAddress( + bytes32 templateHash, + WithdrawalConfig calldata withdrawalConfig, + address sentryManager, + Time.Duration disagreementWindow, + address[] calldata sentries, + bytes32 salt + ) + external + view + override + returns (address appContractAddress, address daveConsensusAddress, address gateSpawnerAddress) + { + appContractAddress = _calculateApplicationAddress(templateHash, withdrawalConfig, salt); + gateSpawnerAddress = _calculateGateSpawnerAddress(sentryManager, disagreementWindow, sentries, salt); + daveConsensusAddress = + _calculateDaveConsensusAddress(appContractAddress, templateHash, ITaskSpawner(gateSpawnerAddress), salt); } /// @notice Encode the data availability blob for applications that only use the input box as DA. @@ -61,6 +100,13 @@ contract DaveAppFactory is IDaveAppFactory { return abi.encodeCall(DataAvailability.InputBox, (INPUT_BOX)); } + /// @notice Hand the application over to its consensus: set the outputs + /// Merkle root validator and renounce the factory's temporary ownership. + function _wireApp(IApplication appContract, IDaveConsensus daveConsensus) internal { + appContract.migrateToOutputsMerkleRootValidator(daveConsensus); + appContract.renounceOwnership(); + } + /// @notice Instantiate a new application contract owned by the current contract, /// with no outputs Merkle root validator (the zero address), and with the input box /// as the only data availability source. @@ -76,12 +122,12 @@ contract DaveAppFactory is IDaveAppFactory { } /// @notice Instantiate a new `DaveConsensus` contract. - function _newDaveConsensus(address appContract, bytes32 templateHash, bytes32 salt) + function _newDaveConsensus(address appContract, bytes32 templateHash, ITaskSpawner taskSpawner, bytes32 salt) internal 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, taskSpawner, initialMachineStateHash); } /// @notice Calculates the address of an application contract. @@ -97,19 +143,38 @@ contract DaveAppFactory is IDaveAppFactory { } /// @notice Calculates the address of a `DaveConsensus` contract. - function _calculateDaveConsensusAddress(address appContract, bytes32 templateHash, bytes32 salt) + function _calculateDaveConsensusAddress( + address appContract, + bytes32 templateHash, + ITaskSpawner taskSpawner, + bytes32 salt + ) internal view returns (address) { + return _calculateCreate2Address( + type(DaveConsensus).creationCode, abi.encode(INPUT_BOX, appContract, taskSpawner, templateHash), salt + ); + } + + /// @notice Calculates the address of a `SafetyGateTaskSpawner` contract. + function _calculateGateSpawnerAddress( + address sentryManager, + Time.Duration disagreementWindow, + address[] calldata sentries, + bytes32 salt + ) internal view returns (address) { + return _calculateCreate2Address( + type(SafetyGateTaskSpawner).creationCode, + abi.encode(sentryManager, TASK_SPAWNER, disagreementWindow, sentries), + salt + ); + } + + /// @notice Address of a contract this factory would CREATE2-deploy from + /// the given creation code and constructor arguments under `salt`. + function _calculateCreate2Address(bytes memory creationCode, bytes memory args, bytes32 salt) internal view returns (address) { - return Create2.computeAddress( - salt, - keccak256( - abi.encodePacked( - type(DaveConsensus).creationCode, - abi.encode(INPUT_BOX, appContract, TOURNAMENT_FACTORY, templateHash) - ) - ) - ); + return Create2.computeAddress(salt, keccak256(abi.encodePacked(creationCode, args))); } } diff --git a/cartesi-rollups/contracts/src/DaveConsensus.sol b/cartesi-rollups/contracts/src/DaveConsensus.sol index bde7f1ad..65888517 100644 --- a/cartesi-rollups/contracts/src/DaveConsensus.sol +++ b/cartesi-rollups/contracts/src/DaveConsensus.sol @@ -16,18 +16,17 @@ import {LibKeccak256} from "cartesi-rollups-contracts-3.0.0/src/library/LibKecca import {LibMath} from "cartesi-rollups-contracts-3.0.0/src/library/LibMath.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"; import {IDaveConsensus} from "./IDaveConsensus.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. @@ -60,8 +59,8 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { /// @notice The application contract address immutable _APP_CONTRACT; - /// @notice The contract used to instantiate tournaments - ITournamentFactory immutable _TOURNAMENT_FACTORY; + /// @notice The contract used to instantiate tasks + ITaskSpawner immutable _TASK_SPAWNER; /// @notice Deployment block number uint256 immutable _DEPLOYMENT_BLOCK_NUMBER = block.number; @@ -75,8 +74,8 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { /// @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; @@ -87,30 +86,30 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { constructor( IInputBox inputBox, address appContract, - ITournamentFactory tournamentFactory, + ITaskSpawner taskSpawner, Machine.Hash initialMachineStateHash ) { // Initialize immutable variables _INPUT_BOX = inputBox; _APP_CONTRACT = appContract; - _TOURNAMENT_FACTORY = tournamentFactory; - emit ConsensusCreation(inputBox, appContract, tournamentFactory); + _TASK_SPAWNER = 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 canSettle() external view override - returns (bool isFinished, uint256 epochNumber, Tree.Node winnerCommitment) + returns (bool isFinished, uint256 epochNumber, Machine.Hash finalState) { - (isFinished, winnerCommitment,) = _tournament.arbitrationResult(); + (isFinished, finalState) = _task.result(); epochNumber = _epochNumber; } @@ -119,14 +118,14 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { override notForeclosed(_APP_CONTRACT) { - // Check tournament settlement + // 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); @@ -138,36 +137,26 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { _outputsMerkleRoots[outputsMerkleRoot] = true; _lastFinalizedMachineStateHash = finalMachineStateHash; - // Start new tournament - _tournament = _TOURNAMENT_FACTORY.instantiate(finalMachineStateHash, this); + // Start new task + _task = _TASK_SPAWNER.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) { @@ -178,8 +167,8 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { return _APP_CONTRACT; } - function getTournamentFactory() external view override returns (ITournamentFactory) { - return _TOURNAMENT_FACTORY; + function getTaskSpawner() external view override returns (ITaskSpawner) { + return _TASK_SPAWNER; } function provideMerkleRootOfInput(uint256 inputIndexWithinEpoch, bytes calldata input) @@ -250,6 +239,12 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { require(machineStateHash == allegedStateHash, InvalidOutputsMerkleRootProof(finalMachineStateHash)); } + /// @dev Best-effort: settlement must never be blocked by a task whose + /// cleanup reverts. + function _tryCleanup(ITask task) internal { + try task.cleanup() returns (bool) {} catch {} + } + modifier onlyValidAppContract(address appContract) { _ensureAppContractIsValid(appContract); _; diff --git a/cartesi-rollups/contracts/src/IDaveAppFactory.sol b/cartesi-rollups/contracts/src/IDaveAppFactory.sol index 7715778d..ed5e143d 100644 --- a/cartesi-rollups/contracts/src/IDaveAppFactory.sol +++ b/cartesi-rollups/contracts/src/IDaveAppFactory.sol @@ -7,16 +7,25 @@ import {WithdrawalConfig} from "cartesi-rollups-contracts-3.0.0/src/common/Withd import {IApplication} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplication.sol"; import {IApplicationFactoryErrors} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplicationFactoryErrors.sol"; +import {SafetyGateTaskSpawner} from "prt-contracts/safety-gate-task/SafetyGateTaskSpawner.sol"; +import {Time} from "prt-contracts/tournament/libs/Time.sol"; + import {IDaveConsensus} from "./IDaveConsensus.sol"; /// @title Dave-App Pair Factory /// @notice Allows anyone to reliably deploy an application -/// validated a newly-deployed `IDaveConsensus` contract. +/// validated by a newly-deployed `IDaveConsensus` contract, optionally +/// gated by a safety gate (see `prt/docs/safety-gate.md`). +/// @dev Factory events are the canonical provenance check: they certify +/// that an app's settlement mechanism (consensus, proof system, and gate, +/// if any) is genuine, and distinguish gated from bare apps. App-declared +/// parameters (template hash, withdrawal config, gate governance) are not +/// certified — they are the deployer's responsibility, inspectable on-chain. interface IDaveAppFactory is IApplicationFactoryErrors { /// @notice A Dave-App pair was created. /// @param appContract The application contract /// @param daveConsensus The Dave consensus contract - event DaveAppCreated(IApplication appContract, IDaveConsensus daveConsensus); + event DaveAppCreated(IApplication indexed appContract, IDaveConsensus indexed daveConsensus); /// @notice Deploy a new Dave-App pair deterministically. /// @param templateHash The application template hash @@ -38,4 +47,55 @@ interface IDaveAppFactory is IApplicationFactoryErrors { external view returns (address appContractAddress, address daveConsensusAddress); + + /// @notice A gated Dave-App pair was created. + /// @param appContract The application contract + /// @param daveConsensus The Dave consensus contract + /// @param gateSpawner The app-specific safety gate spawner + event GatedDaveAppCreated( + IApplication indexed appContract, IDaveConsensus indexed daveConsensus, SafetyGateTaskSpawner gateSpawner + ); + + /// @notice Deploy a new Dave-App pair deterministically, with a safety + /// gate wrapping the factory's proof system. + /// @param templateHash The application template hash + /// @param withdrawalConfig The withdrawal configuration + /// @param sentryManager The address allowed to rotate the gate's sentry set + /// @param disagreementWindow The gate's delay window before falling back + /// to the proof system result + /// @param sentries The gate's initial sentry list + /// @param salt A 32-byte value used to add entropy to the addresses + /// @return appContract The application contract + /// @return daveConsensus The Dave consensus contract + /// @return gateSpawner The app-specific safety gate spawner + /// @dev The gate governance parameters are app-declared: this factory + /// certifies the gate mechanism, not the chosen manager/sentries/window. + function newGatedDaveApp( + bytes32 templateHash, + WithdrawalConfig calldata withdrawalConfig, + address sentryManager, + Time.Duration disagreementWindow, + address[] calldata sentries, + bytes32 salt + ) external returns (IApplication appContract, IDaveConsensus daveConsensus, SafetyGateTaskSpawner gateSpawner); + + /// @notice Calculate the address of a gated Dave-App pair. + /// @param templateHash The application template hash + /// @param withdrawalConfig The withdrawal configuration + /// @param sentryManager The address allowed to rotate the gate's sentry set + /// @param disagreementWindow The gate's delay window before falling back + /// to the proof system result + /// @param sentries The gate's initial sentry list + /// @param salt A 32-byte value used to add entropy to the addresses + /// @return appContractAddress The application contract address + /// @return daveConsensusAddress The Dave consensus contract address + /// @return gateSpawnerAddress The app-specific safety gate spawner address + function calculateGatedDaveAppAddress( + bytes32 templateHash, + WithdrawalConfig calldata withdrawalConfig, + address sentryManager, + Time.Duration disagreementWindow, + address[] calldata sentries, + bytes32 salt + ) external view returns (address appContractAddress, address daveConsensusAddress, address gateSpawnerAddress); } diff --git a/cartesi-rollups/contracts/src/IDaveConsensus.sol b/cartesi-rollups/contracts/src/IDaveConsensus.sol index 092228b2..19d86f2a 100644 --- a/cartesi-rollups/contracts/src/IDaveConsensus.sol +++ b/cartesi-rollups/contracts/src/IDaveConsensus.sol @@ -11,13 +11,12 @@ import {IApplicationChecker} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApp import {IInputBox} from "cartesi-rollups-contracts-3.0.0/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. @@ -43,8 +42,8 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator, IApplica /// @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 @@ -52,14 +51,14 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator, IApplica /// @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 Received epoch number is different from actual @@ -67,7 +66,9 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator, IApplica /// @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 + /// @dev Kept under its historical name: downstream nodes classify + /// settle reverts by error name. error TournamentNotFinishedYet(); /// @notice Hash of received input blob is different from stored on-chain @@ -97,29 +98,24 @@ interface IDaveConsensus is IDataProvider, IOutputsMerkleRootValidator, IApplica /// @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 current sealed epoch number, boundaries, and tournament. + /// @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) diff --git a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol index c67c4bb1..dda3c671 100644 --- a/cartesi-rollups/contracts/test/DaveAppFactory.t.sol +++ b/cartesi-rollups/contracts/test/DaveAppFactory.t.sol @@ -18,7 +18,6 @@ import {IApplicationFactory} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApp import {IApplicationFactoryErrors} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplicationFactoryErrors.sol"; import {IInputBox} from "cartesi-rollups-contracts-3.0.0/src/inputs/IInputBox.sol"; import {InputBox} from "cartesi-rollups-contracts-3.0.0/src/inputs/InputBox.sol"; -import {LibBinaryMerkleTree} from "cartesi-rollups-contracts-3.0.0/src/library/LibBinaryMerkleTree.sol"; import {LibBytes} from "cartesi-rollups-contracts-3.0.0/src/library/LibBytes.sol"; import {LibKeccak256} from "cartesi-rollups-contracts-3.0.0/src/library/LibKeccak256.sol"; import {LibWithdrawalConfig} from "cartesi-rollups-contracts-3.0.0/src/library/LibWithdrawalConfig.sol"; @@ -28,8 +27,8 @@ import {Memory} from "step/src/Memory.sol"; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; import {IStateTransition} from "prt-contracts/IStateTransition.sol"; +import {ITask} from "prt-contracts/ITask.sol"; import {ITournament} from "prt-contracts/ITournament.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; import { CanonicalTournamentParametersProvider } from "prt-contracts/arbitration-config/CanonicalTournamentParametersProvider.sol"; @@ -47,17 +46,7 @@ import {DaveAppFactory} from "src/DaveAppFactory.sol"; import {IDaveAppFactory} from "src/IDaveAppFactory.sol"; import {IDaveConsensus} from "src/IDaveConsensus.sol"; -library LibExternalBinaryKeccak256MerkleTree { - using LibBinaryMerkleTree for bytes32[]; - - function merkleRootAfterReplacement(bytes32[] calldata sibs, uint256 nodeIndex, bytes32 node) - external - pure - returns (bytes32) - { - return sibs.merkleRootAfterReplacement(nodeIndex, node, LibKeccak256.hashPair); - } -} +import {LibExternalBinaryKeccak256MerkleTree, getCommitmentChildren} from "./DaveTestLib.sol"; contract DaveAppFactoryTest is Test { using LibExternalBinaryKeccak256MerkleTree for bytes32[]; @@ -70,7 +59,7 @@ contract DaveAppFactoryTest is Test { IInputBox _inputBox; IApplicationFactory _appFactory; IStateTransition _stateTransition; - ITournamentFactory _tournamentFactory; + MultiLevelTournamentFactory _tournamentFactory; IDaveAppFactory _daveAppFactory; Time.Duration constant MATCH_EFFORT = Time.Duration.wrap(5); @@ -164,7 +153,13 @@ contract DaveAppFactoryTest is Test { inputs[i] = _addInput(address(appContract), inputPayloads[i]); } - (,,, ITournament tournament) = daveConsensus.getCurrentSealedEpoch(); + ITournament tournament; + { + (,,, ITask task) = daveConsensus.getCurrentSealedEpoch(); + // the task spawner is the tournament factory itself here, + // so the task is a root tournament + tournament = ITournament(address(task)); + } bytes32[] memory outputsMerkleRootProof = _randomProof(Memory.LOG2_MAX_SIZE); bytes32 machineMerkleRoot = outputsMerkleRootProof.merkleRootAfterReplacement( @@ -173,7 +168,7 @@ contract DaveAppFactoryTest is Test { ); bytes32[] memory finalStateProof = _randomProof(tournament.tournamentArguments().commitmentArgs.height); - (bytes32 leftChild, bytes32 rightChild) = _getCommitmentChildren(machineMerkleRoot, finalStateProof); + (bytes32 leftChild, bytes32 rightChild) = getCommitmentChildren(machineMerkleRoot, finalStateProof); bytes32 commitment = LibKeccak256.hashPair(leftChild, rightChild); address submitter = vm.randomAddress(); @@ -249,7 +244,7 @@ contract DaveAppFactoryTest is Test { uint256 val1; uint256 val2; uint256 val3; - ITournament val4; + ITask val4; (val1, val2, val3, val4) = daveConsensus.getCurrentSealedEpoch(); @@ -299,7 +294,7 @@ contract DaveAppFactoryTest is Test { uint256 val1; uint256 val2; uint256 val3; - ITournament val4; + ITask val4; (val1, val2, val3, val4) = daveConsensus.getCurrentSealedEpoch(); @@ -313,13 +308,13 @@ contract DaveAppFactoryTest is Test { { bool val1; uint256 val2; - Tree.Node val3; + Machine.Hash val3; (val1, val2, val3) = daveConsensus.canSettle(); assertTrue(val1); // isFinished assertEq(val2, 0); // epochNumber - assertEq(Tree.Node.unwrap(val3), commitment); + assertEq(Machine.Hash.unwrap(val3), machineMerkleRoot); } vm.startPrank(settler); @@ -360,12 +355,15 @@ contract DaveAppFactoryTest is Test { uint256 val1; uint256 val2; uint256 val3; + ITask val4; - (val1, val2, val3, tournament) = daveConsensus.getCurrentSealedEpoch(); + (val1, val2, val3, val4) = daveConsensus.getCurrentSealedEpoch(); assertEq(val1, 1); // epochNumber assertEq(val2, 0); // inputIndexLowerBound assertEq(val3, inputs.length); // inputIndexUpperBound + + tournament = ITournament(address(val4)); } uint256 numOfTournamentCreatedEvents; @@ -374,7 +372,7 @@ contract DaveAppFactoryTest is Test { for (uint256 i; i < logs.length; ++i) { Vm.Log memory log = logs[i]; if (log.emitter == address(_tournamentFactory)) { - if (log.topics[0] == ITournamentFactory.TournamentCreated.selector) { + if (log.topics[0] == MultiLevelTournamentFactory.TournamentCreated.selector) { ++numOfTournamentCreatedEvents; address arg1; arg1 = abi.decode(log.data, (address)); @@ -449,12 +447,15 @@ contract DaveAppFactoryTest is Test { uint256 val1; uint256 val2; uint256 val3; + ITask val4; - (val1, val2, val3, tournament) = daveConsensus.getCurrentSealedEpoch(); + (val1, val2, val3, val4) = daveConsensus.getCurrentSealedEpoch(); assertEq(val1, 0); // epochNumber assertEq(val2, 0); // inputIndexLowerBound assertEq(val3, 0); // inputIndexUpperBound + + tournament = ITournament(address(val4)); } for (uint256 i; i < logs.length; ++i) { @@ -539,16 +540,14 @@ contract DaveAppFactoryTest is Test { } else if (log.emitter == address(_daveAppFactory)) { if (log.topics[0] == IDaveAppFactory.DaveAppCreated.selector) { ++numOfDaveAppCreatedEvents; - address arg1; - address arg2; - (arg1, arg2) = abi.decode(log.data, (address, address)); - assertEq(arg1, address(appContract)); - assertEq(arg2, address(daveConsensus)); + assertEq(log.topics[1], bytes32(uint256(uint160(address(appContract))))); + assertEq(log.topics[2], bytes32(uint256(uint160(address(daveConsensus))))); + assertEq(log.data.length, 0); } else { revert UnexpectedLogTopic0(log); } } else if (log.emitter == address(_tournamentFactory)) { - if (log.topics[0] == ITournamentFactory.TournamentCreated.selector) { + if (log.topics[0] == MultiLevelTournamentFactory.TournamentCreated.selector) { ++numOfTournamentCreatedEvents; address arg1; arg1 = abi.decode(log.data, (address)); @@ -622,7 +621,7 @@ contract DaveAppFactoryTest is Test { assertEq(address(daveConsensus.getInputBox()), address(_inputBox)); assertEq(address(daveConsensus.getApplicationContract()), address(appContract)); - assertEq(address(daveConsensus.getTournamentFactory()), address(_tournamentFactory)); + assertEq(address(daveConsensus.getTaskSpawner()), address(_tournamentFactory)); assertEq(daveConsensus.getDeploymentBlockNumber(), vm.getBlockNumber()); assertTrue(daveConsensus.supportsInterface(type(IERC165).interfaceId)); assertTrue(daveConsensus.supportsInterface(type(IOutputsMerkleRootValidator).interfaceId)); @@ -695,19 +694,6 @@ contract DaveAppFactoryTest is Test { } } - function _getCommitmentChildren(bytes32 machineMerkleRoot, bytes32[] memory proof) - internal - pure - returns (bytes32 leftChild, bytes32 rightChild) - { - leftChild = proof[proof.length - 1]; - - rightChild = machineMerkleRoot; - for (uint256 i; i < proof.length - 1; ++i) { - rightChild = LibKeccak256.hashPair(proof[i], rightChild); - } - } - function _addInput(address appContract, bytes memory payload) internal returns (bytes memory input) { uint256 index = _inputBox.getNumberOfInputs(appContract); diff --git a/cartesi-rollups/contracts/test/DaveSafetyGate.t.sol b/cartesi-rollups/contracts/test/DaveSafetyGate.t.sol new file mode 100644 index 00000000..e8d62859 --- /dev/null +++ b/cartesi-rollups/contracts/test/DaveSafetyGate.t.sol @@ -0,0 +1,262 @@ +pragma solidity ^0.8.22; + +import {Test} from "forge-std-1.9.6/src/Test.sol"; + +import {WithdrawalConfig} from "cartesi-rollups-contracts-3.0.0/src/common/WithdrawalConfig.sol"; +import {ApplicationFactory} from "cartesi-rollups-contracts-3.0.0/src/dapp/ApplicationFactory.sol"; +import {IApplication} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplication.sol"; +import {IApplicationFactory} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplicationFactory.sol"; +import {IInputBox} from "cartesi-rollups-contracts-3.0.0/src/inputs/IInputBox.sol"; +import {InputBox} from "cartesi-rollups-contracts-3.0.0/src/inputs/InputBox.sol"; + +import {EmulatorConstants} from "step/src/EmulatorConstants.sol"; +import {Memory} from "step/src/Memory.sol"; + +import {IStateTransition} from "prt-contracts/IStateTransition.sol"; +import {ITask} from "prt-contracts/ITask.sol"; +import {ITournament} from "prt-contracts/ITournament.sol"; +import { + CanonicalTournamentParametersProvider +} from "prt-contracts/arbitration-config/CanonicalTournamentParametersProvider.sol"; +import {ISafetyGateTask} from "prt-contracts/safety-gate-task/ISafetyGateTask.sol"; +import {SafetyGateTask} from "prt-contracts/safety-gate-task/SafetyGateTask.sol"; +import {SafetyGateTaskSpawner} from "prt-contracts/safety-gate-task/SafetyGateTaskSpawner.sol"; +import {CartesiStateTransition} from "prt-contracts/state-transition/CartesiStateTransition.sol"; +import {CmioStateTransition} from "prt-contracts/state-transition/CmioStateTransition.sol"; +import {RiscVStateTransition} from "prt-contracts/state-transition/RiscVStateTransition.sol"; +import {Tournament} from "prt-contracts/tournament/Tournament.sol"; +import {MultiLevelTournamentFactory} from "prt-contracts/tournament/factories/MultiLevelTournamentFactory.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"; + +import {DaveAppFactory} from "src/DaveAppFactory.sol"; +import {IDaveAppFactory} from "src/IDaveAppFactory.sol"; +import {IDaveConsensus} from "src/IDaveConsensus.sol"; + +import {LibExternalBinaryKeccak256MerkleTree, getCommitmentChildren} from "./DaveTestLib.sol"; + +/// @notice End-to-end test of a gated Dave app deployed via +/// `DaveAppFactory.newGatedDaveApp`, settling through a SafetyGateTask that +/// wraps a real root tournament. +contract DaveSafetyGateTest is Test { + using LibExternalBinaryKeccak256MerkleTree for bytes32[]; + using Machine for Machine.Hash; + + IInputBox _inputBox; + IApplicationFactory _appFactory; + IStateTransition _stateTransition; + MultiLevelTournamentFactory _tournamentFactory; + IDaveAppFactory _daveAppFactory; + + Time.Duration constant MATCH_EFFORT = Time.Duration.wrap(5); + Time.Duration constant MAX_ALLOWANCE = Time.Duration.wrap(120); + Time.Duration constant WINDOW = Time.Duration.wrap(10); + + address constant SENTRY_ONE = address(0x1001); + address constant SENTRY_TWO = address(0x1002); + address constant SENTRY_MANAGER = address(0x3001); + + bytes32 constant TEMPLATE_HASH = bytes32(uint256(0xdead)); + + function setUp() external { + _inputBox = new InputBox(); + _appFactory = new ApplicationFactory(); + _stateTransition = new CartesiStateTransition(new RiscVStateTransition(), new CmioStateTransition()); + _tournamentFactory = new MultiLevelTournamentFactory( + new Tournament(), new CanonicalTournamentParametersProvider(MATCH_EFFORT, MAX_ALLOWANCE), _stateTransition + ); + + _daveAppFactory = new DaveAppFactory(_inputBox, _appFactory, _tournamentFactory); + } + + function _sentries() internal pure returns (address[] memory sentries) { + sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + } + + struct Fixture { + IApplication appContract; + IDaveConsensus daveConsensus; + SafetyGateTaskSpawner gateSpawner; + SafetyGateTask gate; + ITournament tournament; + address submitter; + uint256 bondValue; + bytes32 machineMerkleRoot; + bytes32 outputsMerkleRoot; + bytes32[] outputsMerkleRootProof; + } + + /// @notice Deploy a gated Dave app through the factory and join its + /// inner tournament with a fabricated commitment, so the tournament can + /// finish by timeout. + function _newJoinedFixture() internal returns (Fixture memory f) { + WithdrawalConfig memory zeroConfig; + + (address precalcApp, address precalcConsensus, address precalcGateSpawner) = _daveAppFactory.calculateGatedDaveAppAddress( + TEMPLATE_HASH, zeroConfig, SENTRY_MANAGER, WINDOW, _sentries(), bytes32(0) + ); + + vm.expectEmit(true, true, false, true, address(_daveAppFactory)); + emit IDaveAppFactory.GatedDaveAppCreated( + IApplication(precalcApp), IDaveConsensus(precalcConsensus), SafetyGateTaskSpawner(precalcGateSpawner) + ); + (f.appContract, f.daveConsensus, f.gateSpawner) = + _daveAppFactory.newGatedDaveApp(TEMPLATE_HASH, zeroConfig, SENTRY_MANAGER, WINDOW, _sentries(), bytes32(0)); + + assertEq(precalcApp, address(f.appContract), "app address precalc mismatch"); + assertEq(precalcConsensus, address(f.daveConsensus), "consensus address precalc mismatch"); + assertEq(precalcGateSpawner, address(f.gateSpawner), "gate spawner address precalc mismatch"); + + // the gate spawner carries the app-declared governance parameters + // and wraps the factory's bound proof system + assertEq(f.gateSpawner.SENTRY_MANAGER(), SENTRY_MANAGER); + assertEq(address(f.gateSpawner.INNER_SPAWNER()), address(_tournamentFactory)); + assertEq(Time.Duration.unwrap(f.gateSpawner.DISAGREEMENT_WINDOW()), Time.Duration.unwrap(WINDOW)); + assertEq(f.gateSpawner.getSentries(), _sentries()); + assertEq(address(f.daveConsensus.getTaskSpawner()), address(f.gateSpawner)); + + (,,, ITask task) = f.daveConsensus.getCurrentSealedEpoch(); + assertTrue(task.supportsInterface(type(ISafetyGateTask).interfaceId), "task should be a safety gate"); + f.gate = SafetyGateTask(address(task)); + + ITask innerTask = f.gate.INNER_TASK(); + assertTrue(innerTask.supportsInterface(type(ITournament).interfaceId), "inner task should be a tournament"); + f.tournament = ITournament(address(innerTask)); + + f.outputsMerkleRoot = bytes32(uint256(0xbeef)); + f.outputsMerkleRootProof = _zeroProof(Memory.LOG2_MAX_SIZE); + f.machineMerkleRoot = f.outputsMerkleRootProof + .merkleRootAfterReplacement( + EmulatorConstants.AR_CMIO_TX_BUFFER_START >> EmulatorConstants.HASH_TREE_LOG2_WORD_SIZE, + keccak256(abi.encode(f.outputsMerkleRoot)) + ); + + bytes32[] memory finalStateProof = _zeroProof(f.tournament.tournamentArguments().commitmentArgs.height); + (bytes32 leftChild, bytes32 rightChild) = getCommitmentChildren(f.machineMerkleRoot, finalStateProof); + + f.submitter = vm.addr(0xb0b); + f.bondValue = f.tournament.bondValue(); + vm.deal(f.submitter, f.bondValue); + + vm.prank(f.submitter); + f.tournament.joinTournament{value: f.bondValue}( + Machine.Hash.wrap(f.machineMerkleRoot), + finalStateProof, + Tree.Node.wrap(leftChild), + Tree.Node.wrap(rightChild) + ); + } + + function _rollUntilInnerFinished(Fixture memory f) internal { + vm.roll(vm.getBlockNumber() + Time.Duration.unwrap(MAX_ALLOWANCE)); + assertTrue(f.tournament.isFinished(), "inner tournament should be finished"); + } + + function _assertCannotSettle(Fixture memory f) internal { + (bool isFinished,,) = f.daveConsensus.canSettle(); + assertFalse(isFinished, "epoch should not be settleable"); + + vm.expectRevert(IDaveConsensus.TournamentNotFinishedYet.selector); + f.daveConsensus.settle(0, f.outputsMerkleRoot, f.outputsMerkleRootProof); + } + + function _settleAndCheck(Fixture memory f) internal { + (bool isFinished, uint256 epochNumber, Machine.Hash finalState) = f.daveConsensus.canSettle(); + assertTrue(isFinished, "epoch should be settleable"); + assertEq(epochNumber, 0); + assertEq(Machine.Hash.unwrap(finalState), f.machineMerkleRoot, "gate must expose the inner result"); + + f.daveConsensus.settle(0, f.outputsMerkleRoot, f.outputsMerkleRootProof); + + (uint256 newEpochNumber,,, ITask newTask) = f.daveConsensus.getCurrentSealedEpoch(); + assertEq(newEpochNumber, 1); + assertTrue( + newTask.supportsInterface(type(ISafetyGateTask).interfaceId), "next epoch task should be a safety gate" + ); + assertTrue(newTask != ITask(address(f.gate)), "next epoch should have a fresh gate"); + + // cleanup cascaded through the gate and recovered the inner bond + assertEq(f.submitter.balance, f.bondValue, "submitter should have the bond back"); + } + + function testSettleWithSentryAgreement() external { + Fixture memory f = _newJoinedFixture(); + + _assertCannotSettle(f); + + _rollUntilInnerFinished(f); + + // inner task is finished, but the gate still holds settlement + _assertCannotSettle(f); + + vm.prank(SENTRY_ONE); + f.gate.sentryVote(Machine.Hash.wrap(f.machineMerkleRoot)); + + // one vote is not enough + _assertCannotSettle(f); + + vm.prank(SENTRY_TWO); + f.gate.sentryVote(Machine.Hash.wrap(f.machineMerkleRoot)); + + _settleAndCheck(f); + } + + function testSettleWithMissingVotesAfterFallback() external { + Fixture memory f = _newJoinedFixture(); + + _rollUntilInnerFinished(f); + + vm.prank(SENTRY_ONE); + f.gate.sentryVote(Machine.Hash.wrap(f.machineMerkleRoot)); + + assertTrue(f.gate.canStartFallbackTimer()); + assertTrue(f.gate.startFallbackTimer()); + + // window has not elapsed yet + _assertCannotSettle(f); + + vm.roll(vm.getBlockNumber() + Time.Duration.unwrap(WINDOW)); + + _settleAndCheck(f); + } + + function testSentriesCannotChangeResultOnlyDelay() external { + Fixture memory f = _newJoinedFixture(); + + _rollUntilInnerFinished(f); + + // unanimous sentry agreement on a *wrong* state must not settle, + // and after the fallback window the *inner* result wins + bytes32 wrongState = bytes32(uint256(0xbad)); + vm.prank(SENTRY_ONE); + f.gate.sentryVote(Machine.Hash.wrap(wrongState)); + vm.prank(SENTRY_TWO); + f.gate.sentryVote(Machine.Hash.wrap(wrongState)); + + _assertCannotSettle(f); + + assertTrue(f.gate.canStartFallbackTimer()); + assertTrue(f.gate.startFallbackTimer()); + + vm.roll(vm.getBlockNumber() + Time.Duration.unwrap(WINDOW)); + + _settleAndCheck(f); + } + + function testGatedAndBareShareAppAddressSpace() external { + WithdrawalConfig memory zeroConfig; + _daveAppFactory.newDaveApp(TEMPLATE_HASH, zeroConfig, bytes32(0)); + + // same (templateHash, config, salt) collides on the application + // address, whichever deployment flavor came first + vm.expectRevert(); + _daveAppFactory.newGatedDaveApp(TEMPLATE_HASH, zeroConfig, SENTRY_MANAGER, WINDOW, _sentries(), bytes32(0)); + } + + function _zeroProof(uint256 n) internal pure returns (bytes32[] memory proof) { + proof = new bytes32[](n); + } +} diff --git a/cartesi-rollups/contracts/test/DaveTestLib.sol b/cartesi-rollups/contracts/test/DaveTestLib.sol new file mode 100644 index 00000000..9b1d4d77 --- /dev/null +++ b/cartesi-rollups/contracts/test/DaveTestLib.sol @@ -0,0 +1,35 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.22; + +import {LibBinaryMerkleTree} from "cartesi-rollups-contracts-3.0.0/src/library/LibBinaryMerkleTree.sol"; +import {LibKeccak256} from "cartesi-rollups-contracts-3.0.0/src/library/LibKeccak256.sol"; + +/// @notice `LibBinaryMerkleTree.merkleRootAfterReplacement` re-exposed as an +/// external function so tests can pass a `calldata` sibling array. +library LibExternalBinaryKeccak256MerkleTree { + using LibBinaryMerkleTree for bytes32[]; + + function merkleRootAfterReplacement(bytes32[] calldata sibs, uint256 nodeIndex, bytes32 node) + external + pure + returns (bytes32) + { + return sibs.merkleRootAfterReplacement(nodeIndex, node, LibKeccak256.hashPair); + } +} + +/// @notice Reconstruct the (left, right) children of a commitment from a final +/// machine state hash and its bottom-up proof, as a joining player would. +function getCommitmentChildren(bytes32 machineMerkleRoot, bytes32[] memory proof) + pure + returns (bytes32 leftChild, bytes32 rightChild) +{ + leftChild = proof[proof.length - 1]; + + rightChild = machineMerkleRoot; + for (uint256 i; i < proof.length - 1; ++i) { + rightChild = LibKeccak256.hashPair(proof[i], rightChild); + } +} diff --git a/cartesi-rollups/node/blockchain-reader/src/lib.rs b/cartesi-rollups/node/blockchain-reader/src/lib.rs index e86472bd..c59d1a7e 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/blockchain-reader/src/test_utils.rs b/cartesi-rollups/node/blockchain-reader/src/test_utils.rs index e618dc02..12378571 100644 --- a/cartesi-rollups/node/blockchain-reader/src/test_utils.rs +++ b/cartesi-rollups/node/blockchain-reader/src/test_utils.rs @@ -63,6 +63,7 @@ pub async fn spawn_anvil_and_provider() -> Result<(AnvilInstance, DynProvider, A let mut signer: PrivateKeySigner = anvil.keys()[0].clone().into(); signer.set_chain_id(Some(anvil.chain_id())); + let signer_address = signer.address(); let wallet = EthereumWallet::from(signer); let provider = ProviderBuilder::new() @@ -98,17 +99,35 @@ pub async fn spawn_anvil_and_provider() -> Result<(AnvilInstance, DynProvider, A let salt = FixedBytes::default(); + // Deploy a *gated* app: the test signer plays sentry manager and sole + // sentry, exercising the safety gate in the e2e pipeline. + let sentries = vec![signer_address]; + let disagreement_window = 10u64; + let dave_app_factory_contract = IDaveAppFactory::new(dave_app_factory, &provider); - let (app, consensus) = dave_app_factory_contract - .calculateDaveAppAddress(initial_hash.into(), withdrawal_config.clone(), salt) + let addresses = dave_app_factory_contract + .calculateGatedDaveAppAddress( + initial_hash.into(), + withdrawal_config.clone(), + signer_address, + disagreement_window, + sentries.clone(), + salt, + ) .call() .await - .expect("failed to calculate Dave app addresses") - .try_into() - .unwrap(); + .expect("failed to calculate gated Dave app addresses"); + let (app, consensus) = (addresses.appContractAddress, addresses.daveConsensusAddress); dave_app_factory_contract - .newDaveApp(initial_hash.into(), withdrawal_config.clone(), salt) + .newGatedDaveApp( + initial_hash.into(), + withdrawal_config.clone(), + signer_address, + disagreement_window, + sentries, + salt, + ) .send() .await? .watch() 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 647fb362..25eed47f 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, params.long_block_range_error_codes.clone(), 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 43106e71..48052bc1 100644 --- a/cartesi-rollups/node/epoch-manager/src/lib.rs +++ b/cartesi-rollups/node/epoch-manager/src/lib.rs @@ -4,7 +4,7 @@ mod error; use alloy::{ - primitives::{Address, B256}, + primitives::{Address, B256, FixedBytes}, providers::{DynProvider, Provider}, }; use error::Result; @@ -14,26 +14,37 @@ 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}; + +/// `type(ISafetyGateTask).interfaceId`, pinned by +/// `testInterfaceIdMatchesNodeConstant` in `prt/contracts`. +/// +/// Note: Solidity interface ids exclude inherited functions (`result`, +/// `cleanup`, `supportsInterface`), so this cannot be derived by XORing the +/// selectors of the full contract ABI. +const SAFETY_GATE_TASK_INTERFACE_ID: FixedBytes<4> = FixedBytes::new([0xf7, 0x7c, 0x35, 0x59]); pub struct EpochManager { arena_sender: Arc>, consensus: Address, + signer_address: Address, sleep_duration: Duration, long_block_range_error_codes: Vec, 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, long_block_range_error_codes: Vec, @@ -41,10 +52,11 @@ impl EpochManager { Self { arena_sender, consensus: consensus_address, + signer_address, sleep_duration, long_block_range_error_codes, state_manager, - last_react_epoch: (None, 0), + last_react_epoch: (None, 0, Address::ZERO), } } @@ -83,9 +95,8 @@ 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 {}", @@ -119,12 +130,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!( @@ -141,8 +160,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() @@ -157,6 +177,7 @@ impl EpochManager { &mut self, last_sealed_epoch: &Epoch, provider: DynProvider, + tournament_address: Address, ) -> Result<()> { let snapshot = self .state_manager @@ -167,6 +188,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 @@ -191,7 +213,7 @@ 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.long_block_range_error_codes.clone(), self.state_manager @@ -199,13 +221,99 @@ impl EpochManager { ) .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(()) + } + + /// Resolve the address the PRT player should drive for this epoch. + /// + /// If the epoch task is a safety gate, participate in it (cast the sentry + /// vote) and return the inner tournament address. Otherwise the task is + /// itself the tournament, so return it unchanged. + async fn resolve_tournament_address( + &self, + provider: DynProvider, + task_address: Address, + settlement: &Settlement, + ) -> Result
{ + let is_gate = supports_interface( + provider.clone(), + task_address, + SAFETY_GATE_TASK_INTERFACE_ID, + ) + .await?; + if !is_gate { + return Ok(task_address); + } + + let safety_gate = safety_gate_task::SafetyGateTask::new(task_address, provider.clone()); + + // Deliberately NOT calling `startFallbackTimer` here: starting the + // fallback timer is a manual, monitored operation (see + // prt/docs/safety-gate.md). The node only votes. + self.try_sentry_vote(&safety_gate, settlement).await?; + + let inner_task = safety_gate.INNER_TASK().call().await?; + Ok(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); + info!( + "sentry vote {} on safety gate {}", + vote, + safety_gate.address() + ); + let tx_result = safety_gate.sentryVote(vote).send().await; + allow_revert_rethrow_others("sentryVote", tx_result).await?; Ok(()) } } +/// ERC-165 probe for the safety-gate interface. +/// +/// A plain execution revert means the contract does not implement the +/// interface (a bare tournament, or an EOA at that address): that is a real +/// answer, `false`. Any other error (transport failure, timeout, wrong chain) +/// is propagated so the caller retries on the next tick, rather than being +/// silently misread as "not a gate" — which would point the PRT player at a +/// SafetyGateTask address as if it were a tournament. +async fn supports_interface( + provider: DynProvider, + contract: Address, + interface_id: FixedBytes<4>, +) -> Result { + let erc165 = safety_gate_task::SafetyGateTask::new(contract, provider); + match erc165.supportsInterface(interface_id).call().await { + Ok(value) => Ok(value), + Err(err) if err.to_string().contains("execution reverted") => { + trace!("supportsInterface reverted (treating as unsupported): {err}"); + Ok(false) + } + Err(err) => Err(err.into()), + } +} + fn to_bytes_32_vec(proof: Proof) -> Vec { proof.inner().iter().map(B256::from).collect() } 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 704d1a60..f49a85c7 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)] @@ -526,13 +528,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..4ae0002f 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 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 231059d5..d1e7b485 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..55721646 100644 --- a/prt/contracts/script/Deployment.s.sol +++ b/prt/contracts/script/Deployment.s.sol @@ -14,9 +14,6 @@ import { import { CartesiStateTransition } from "src/state-transition/CartesiStateTransition.sol"; -import { - CartesiStateTransition -} from "src/state-transition/CartesiStateTransition.sol"; import { CmioStateTransition } from "src/state-transition/CmioStateTransition.sol"; diff --git a/prt/contracts/src/ITask.sol b/prt/contracts/src/ITask.sol new file mode 100644 index 00000000..d933796c --- /dev/null +++ b/prt/contracts/src/ITask.sol @@ -0,0 +1,36 @@ +// (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. +/// @dev A task computes the final machine state for a fixed input range. +/// Consumers that only need the settled result should depend on this +/// interface rather than on a concrete proof system. +interface ITask is IERC165 { + /// @notice Get the task result. + /// @dev Implementations may revert on catastrophic terminal states + /// (e.g. a tournament that finished with every commitment eliminated) + /// rather than encode them in the return value. + /// @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. + /// @dev Reentrancy hazard: implementations may call untrusted contracts + /// (e.g. an Ether transfer to a bond recipient). Callers MUST follow + /// checks-effects-interactions and invoke this only after all state + /// transitions have been performed. + /// @return cleaned Whether any cleanup action succeeded. + function cleanup() external returns (bool cleaned); +} diff --git a/prt/contracts/src/ITaskSpawner.sol b/prt/contracts/src/ITaskSpawner.sol new file mode 100644 index 00000000..5d49b963 --- /dev/null +++ b/prt/contracts/src/ITaskSpawner.sol @@ -0,0 +1,18 @@ +// (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 {Machine} from "prt-contracts/types/Machine.sol"; + +/// @notice Spawner interface for tasks/proof systems. +interface ITaskSpawner { + /// @notice Spawn a new task starting from an initial machine state. + /// @param initial The initial machine state hash + /// @param provider The contract that provides input Merkle roots + function spawn(Machine.Hash initial, IDataProvider provider) + external + returns (ITask); +} diff --git a/prt/contracts/src/ITournament.sol b/prt/contracts/src/ITournament.sol index 3f894e56..02da045e 100644 --- a/prt/contracts/src/ITournament.sol +++ b/prt/contracts/src/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/ITournamentFactory.sol b/prt/contracts/src/ITournamentFactory.sol deleted file mode 100644 index c835bca5..00000000 --- a/prt/contracts/src/ITournamentFactory.sol +++ /dev/null @@ -1,16 +0,0 @@ -// (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 {ITournament} from "prt-contracts/ITournament.sol"; -import {Machine} from "prt-contracts/types/Machine.sol"; - -interface ITournamentFactory { - event TournamentCreated(ITournament tournament); - - function instantiate(Machine.Hash initialState, IDataProvider provider) - external - returns (ITournament); -} 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..97ecceec --- /dev/null +++ b/prt/contracts/src/safety-gate-task/ISafetyGateTask.sol @@ -0,0 +1,87 @@ +// (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 {Time} from "prt-contracts/tournament/libs/Time.sol"; +import {Machine} from "prt-contracts/types/Machine.sol"; + +/// @title ISafetyGateTask +/// @notice Interface for a safety-gated task wrapper. +/// @dev Semantics: +/// - All sentries must vote and corroborate the inner task result for the +/// gate to finish without delay. +/// - Otherwise, anyone may start a fallback timer once the inner task is +/// finished; after it elapses, the inner task result is accepted as-is. +/// - The gate is delay-only: `result()` only ever surfaces the inner task's +/// state. Sentries decide *when* it becomes visible, never *what* it is. +/// - This interface does not prescribe auto-start of the fallback timer; +/// an offchain actor must call `startFallbackTimer` for liveness. +interface ISafetyGateTask is ITask { + /// @notice Aggregate status of the sentry voting process. + /// @dev DISAGREED is absorbing: once two sentries cast conflicting + /// votes, the status stays DISAGREED for the lifetime of the task. + enum SentryStatus { + VOTING, // votes still accumulating, no conflict observed so far + AGREED, // all sentries voted and corroborate the same claim + DISAGREED // conflicting votes were cast + } + + /// @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 distinct 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 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 Submit a sentry vote for the expected final state. + /// @dev + /// - Each sentry can vote once; the zero state is an invalid vote. + /// - A vote that conflicts with an earlier vote permanently marks the + /// sentry set as disagreeing for this task (see `SentryStatus`). + /// - Votes are still accepted after the gate has finished; they are + /// harmless, as `result()` is monotone and cannot become unfinished. + function sentryVote(Machine.Hash vote) external; + + /// @notice Aggregate status of the sentry voting process. + /// @return status See `SentryStatus`. + /// @return claim The corroborated claim if status is AGREED, + /// otherwise ZERO_STATE. + function sentryStatus() + external + view + returns (SentryStatus status, Machine.Hash claim); + + /// @notice State of the fallback timer. + /// @return started Whether the timer has been started. + /// @return startInstant When the timer was started (meaningless unless started). + /// @return elapsed Whether the disagreement window has fully elapsed. + function fallbackTimer() + external + view + returns (bool started, Time.Instant startInstant, bool elapsed); + + /// @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 the timer has not started, the inner task finished, + /// AND the sentries do not corroborate the inner result. + 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..2b1405b5 --- /dev/null +++ b/prt/contracts/src/safety-gate-task/SafetyGateTask.sol @@ -0,0 +1,256 @@ +// (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 {ITask} from "prt-contracts/ITask.sol"; +import { + ISafetyGateTask +} from "prt-contracts/safety-gate-task/ISafetyGateTask.sol"; +import {Time} from "prt-contracts/tournament/libs/Time.sol"; +import {Machine} from "prt-contracts/types/Machine.sol"; + +// Upper bound on the sentry-set size, enforced both by SafetyGateTask's +// constructor and by SafetyGateTaskSpawner (which imports this as the single +// source of truth). Caps the constructor's per-sentry storage loop so an +// oversized list can never make deployment -- and thus settlement -- run out +// of gas. 16 is already a large sentry set; the bound is a safety ceiling, +// not a recommendation. +uint256 constant MAX_SENTRIES = 16; + +/// @title SafetyGateTask +/// @notice Middleware that gates an inner task result behind N sentry votes. +/// @dev See `ISafetyGateTask` for the gate semantics. Implementation notes: +/// - The sentry claim collapses to ZERO_STATE on the first conflicting vote; +/// since zero votes are rejected, `_claim == ZERO && votes > 0` uniquely +/// encodes DISAGREED and no extra flag is needed. +/// - `result()` reverts if the inner task's `result()` reverts (e.g. a root +/// tournament that finished with every commitment eliminated). The gate +/// deliberately does not shield that catastrophic state. +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 distinct sentries configured at task creation. + uint256 public sentryCount; + + /// @notice Total number of sentry votes submitted for this task. + uint256 public sentryTotalVotes; + + /// @dev Running claim; collapses to ZERO_STATE on the first conflict. + Machine.Hash private _claim; + + /// @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; + + /// @dev Start of the fallback timer; zero means not started. + Time.Instant private _fallbackTimerStart; + + /// @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(); + error TooManySentries(); + error ZeroSentry(); + + /// @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; + + // Bound the loop below so construction can never exhaust gas: this + // is the sole anti-brick mechanism (an oversized set is the only way + // to freeze settlement; a zero or duplicate can at worst force the + // fallback window, i.e. delay). + require(initialSentries.length <= MAX_SENTRIES, TooManySentries()); + + // Reject the zero address loudly (it can never vote, so it is almost + // always an uninitialized-slot mistake). Deduplicate silently: a + // repeat is harmless once collapsed, and doing so keeps AGREED + // reachable rather than inflating the count past what can vote. + for (uint256 i = 0; i < initialSentries.length; i++) { + address sentry = initialSentries[i]; + require(sentry != address(0), ZeroSentry()); + if (!isSentry[sentry]) { + isSentry[sentry] = true; + sentryCount++; + } + } + } + + /// @inheritdoc ISafetyGateTask + function sentryVote(Machine.Hash vote) external onlySentry { + require(!hasVoted[msg.sender], AlreadyVoted()); + require(!vote.notInitialized(), InvalidSentryVote()); + + if (sentryTotalVotes == 0) { + _claim = vote; + } else if (!_claim.eq(vote)) { + _claim = Machine.ZERO_STATE; + } + + hasVoted[msg.sender] = true; + sentryTotalVotes++; + emit SentryVoted(msg.sender, vote); + } + + /// @inheritdoc ISafetyGateTask + function sentryStatus() + public + view + returns (SentryStatus status, Machine.Hash claim) + { + if (sentryTotalVotes > 0 && _claim.notInitialized()) { + return (SentryStatus.DISAGREED, Machine.ZERO_STATE); + } else if (sentryTotalVotes == sentryCount && sentryCount > 0) { + return (SentryStatus.AGREED, _claim); + } else { + return (SentryStatus.VOTING, Machine.ZERO_STATE); + } + } + + /// @inheritdoc ISafetyGateTask + function fallbackTimer() + public + view + returns (bool started, Time.Instant startInstant, bool elapsed) + { + started = !_fallbackTimerStart.isZero(); + startInstant = _fallbackTimerStart; + // Overflow-safe elapsed check: measure blocks *since* the start + // (current - start, which never overflows since start is a past + // block) rather than start + window, which would overflow uint64 for + // a pathologically large window and permanently revert result(). + elapsed = started + && !DISAGREEMENT_WINDOW.gt( + Time.currentTime().timeSpan(_fallbackTimerStart) + ); + } + + /// @inheritdoc ISafetyGateTask + function startFallbackTimer() external returns (bool) { + if (!canStartFallbackTimer()) { + return false; + } + + _fallbackTimerStart = Time.currentTime(); + emit DisagreementWindowStarted(_fallbackTimerStart); + return true; + } + + /// @inheritdoc ISafetyGateTask + function canStartFallbackTimer() public view returns (bool) { + if (!_fallbackTimerStart.isZero()) { + return false; + } + + (bool innerFinished, Machine.Hash innerState) = INNER_TASK.result(); + if (!innerFinished) { + return false; + } + + return !_sentriesCorroborate(innerState); + } + + /// @inheritdoc ITask + function result() + external + view + override + returns (bool finished, Machine.Hash finalState) + { + (bool innerFinished, Machine.Hash innerState) = INNER_TASK.result(); + if (!innerFinished) { + // inner task still running: the gate cannot be finished + return (false, Machine.ZERO_STATE); + } + + if (_sentriesCorroborate(innerState)) { + // all sentries corroborate the inner result: settle with no delay + // (fast path: no need to read the fallback timer) + return (true, innerState); + } + + (, Time.Instant timerStart, bool timerElapsed) = fallbackTimer(); + + if (timerStart.isZero()) { + // no corroboration and no timer: hold until someone starts it + return (false, Machine.ZERO_STATE); + } else if (timerElapsed) { + // the delay has been served: the inner result passes through, + // regardless of what the sentries did (or failed to do) + return (true, innerState); + } else { + // timer still running: keep holding + return (false, Machine.ZERO_STATE); + } + } + + /// @inheritdoc ITask + /// @dev Reentrancy hazard: forwards to the inner task's `cleanup`, which + /// may call untrusted contracts (see `ITask.cleanup`). Call last. + 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 + pure + returns (bool) + { + return interfaceId == type(IERC165).interfaceId + || interfaceId == type(ITask).interfaceId + || interfaceId == type(ISafetyGateTask).interfaceId; + } + + /// @dev Whether all sentries voted and their claim matches `innerState`. + function _sentriesCorroborate(Machine.Hash innerState) + private + view + returns (bool) + { + (SentryStatus status, Machine.Hash claim) = sentryStatus(); + return status == SentryStatus.AGREED && claim.eq(innerState); + } +} 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..ebf4dfdb --- /dev/null +++ b/prt/contracts/src/safety-gate-task/SafetyGateTaskSpawner.sol @@ -0,0 +1,134 @@ +// (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 { + MAX_SENTRIES, + 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: +/// rotating sentries takes effect on the next spawned task (next epoch). +contract SafetyGateTaskSpawner is ITaskSpawner { + using Time for Time.Duration; + + /// @notice Address allowed to rotate the sentry set. + /// @dev In production this role is expected to be held by a high-threshold + /// governance body (e.g. a security council). Note its powers stop there: + /// it cannot affect results or in-flight tasks. + address public immutable SENTRY_MANAGER; + /// @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. + 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 NotSentryManager(); + error TooManySentries(); + error ZeroSentry(); + error ZeroDisagreementWindow(); + + /// @dev Restricts to the sentry manager. + modifier onlySentryManager() { + require(msg.sender == SENTRY_MANAGER, NotSentryManager()); + _; + } + + /// @notice Create a safety-gate task spawner. + /// @param sentryManager The address allowed to rotate the sentry set. + /// @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 sentryManager, + ITaskSpawner innerSpawner, + Time.Duration disagreementWindow, + address[] memory initialSentries + ) { + // A zero window is a delay-only gate with no delay: reject the + // (likely accidental) misconfiguration rather than deploy a gate + // that provides no protection. + require(!disagreementWindow.isZero(), ZeroDisagreementWindow()); + + SENTRY_MANAGER = sentryManager; + 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 + onlySentryManager + { + _overrideSentries(newSentries); + } + + /// @notice Get the full sentry list used for future tasks. + function getSentries() external view returns (address[] memory) { + return sentries; + } + + /// @notice Returns whether an address is a sentry in the spawner list. + function isSentry(address sentry) external view returns (bool) { + for (uint256 i = 0; i < sentries.length; i++) { + if (sentries[i] == sentry) { + return true; + } + } + return false; + } + + /// @dev Bounds the length and rejects the zero address at the point the + /// list is set, so those mistakes surface loudly to whoever set it + /// (deployer or sentry manager) and so a spawned task's constructor can + /// never revert on them and freeze settlement. The length bound is the + /// anti-brick guarantee; duplicates are left in place (a spawned task + /// deduplicates them harmlessly). + function _overrideSentries(address[] memory newSentries) private { + require(newSentries.length <= MAX_SENTRIES, TooManySentries()); + + delete sentries; + + for (uint256 i = 0; i < newSentries.length; i++) { + require(newSentries[i] != address(0), ZeroSentry()); + sentries.push(newSentries[i]); + } + + emit SentriesUpdated(sentries); + } +} diff --git a/prt/contracts/src/tournament/Tournament.sol b/prt/contracts/src/tournament/Tournament.sol index 8098278e..3412ddab 100644 --- a/prt/contracts/src/tournament/Tournament.sol +++ b/prt/contracts/src/tournament/Tournament.sol @@ -4,9 +4,13 @@ pragma solidity ^0.8.17; import {Clones} from "@openzeppelin-contracts-5.5.0/proxy/Clones.sol"; +import { + IERC165 +} from "@openzeppelin-contracts-5.5.0/utils/introspection/IERC165.sol"; import {Math} from "@openzeppelin-contracts-5.5.0/utils/math/Math.sol"; import {IStateTransition} from "prt-contracts/IStateTransition.sol"; +import {ITask} from "prt-contracts/ITask.sol"; import {ITournament} from "prt-contracts/ITournament.sol"; import { IMultiLevelTournamentFactory @@ -838,6 +842,51 @@ contract Tournament is ITournament { return (true, _danglingCommitment, _finalState); } + /// @inheritdoc ITask + /// @dev Projection of `arbitrationResult` without the winner commitment. + /// Inherits its root-only semantics and its `TournamentFailedNoWinner` + /// revert when the tournament finishes with every commitment eliminated. + 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. + /// @dev Reentrancy hazard: `tryRecoveringBond` transfers Ether to the + /// winning claimer, which may be a contract (see `ITask.cleanup`). + /// Call last. + function cleanup() external override returns (bool cleaned) { + if (!isFinished()) { + return false; + } + + // External self-call on purpose: `try` requires an external call, + // and `tryRecoveringBond` reverts in reachable states (no winner; + // bond already recovered). The try/catch turns those reverts into + // `false`, which is `cleanup`'s contract. + try this.tryRecoveringBond() returns (bool ok) { + return ok; + } catch { + return false; + } + } + + function supportsInterface(bytes4 interfaceId) + external + pure + override + returns (bool) + { + return interfaceId == type(IERC165).interfaceId + || interfaceId == type(ITask).interfaceId + || interfaceId == type(ITournament).interfaceId; + } + // // Internal functions // diff --git a/prt/contracts/src/tournament/factories/IMultiLevelTournamentFactory.sol b/prt/contracts/src/tournament/factories/IMultiLevelTournamentFactory.sol index 2962c8a1..4458500a 100644 --- a/prt/contracts/src/tournament/factories/IMultiLevelTournamentFactory.sol +++ b/prt/contracts/src/tournament/factories/IMultiLevelTournamentFactory.sol @@ -4,13 +4,17 @@ pragma solidity ^0.8.17; import {IDataProvider} from "prt-contracts/IDataProvider.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; import {ITournament} from "prt-contracts/ITournament.sol"; -import {ITournamentFactory} from "prt-contracts/ITournamentFactory.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 instantiate(Machine.Hash initialState, IDataProvider provider) + external + returns (ITournament); + function instantiateInner( Machine.Hash _initialHash, Tree.Node _contestedCommitmentOne, diff --git a/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol b/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol index 975c8d03..56f942a1 100644 --- a/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol +++ b/prt/contracts/src/tournament/factories/MultiLevelTournamentFactory.sol @@ -8,6 +8,8 @@ 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 {ITask} from "prt-contracts/ITask.sol"; +import {ITaskSpawner} from "prt-contracts/ITaskSpawner.sol"; import {ITournament} from "prt-contracts/ITournament.sol"; import { ITournamentParametersProvider @@ -24,6 +26,8 @@ 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; @@ -38,10 +42,26 @@ contract MultiLevelTournamentFactory is IMultiLevelTournamentFactory { STATE_TRANSITION = _stateTransition; } + /// @inheritdoc ITaskSpawner + function spawn(Machine.Hash _initialHash, IDataProvider _provider) + external + override + returns (ITask) + { + return _instantiate(_initialHash, _provider); + } + function instantiate(Machine.Hash _initialHash, IDataProvider _provider) external override returns (ITournament) + { + return _instantiate(_initialHash, _provider); + } + + function _instantiate(Machine.Hash _initialHash, IDataProvider _provider) + private + returns (ITournament) { ITournament _tournament = instantiateTop(_initialHash, _provider); emit TournamentCreated(_tournament); diff --git a/prt/contracts/src/tournament/libs/Match.sol b/prt/contracts/src/tournament/libs/Match.sol index 9f44149f..6c6b403c 100644 --- a/prt/contracts/src/tournament/libs/Match.sol +++ b/prt/contracts/src/tournament/libs/Match.sol @@ -195,10 +195,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/SafetyGateTask.t.sol b/prt/contracts/test/SafetyGateTask.t.sol new file mode 100644 index 00000000..01c14f79 --- /dev/null +++ b/prt/contracts/test/SafetyGateTask.t.sol @@ -0,0 +1,738 @@ +// 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 {Vm} from "forge-std-1.9.6/src/Vm.sol"; + +import { + IERC165 +} from "@openzeppelin-contracts-5.5.0/utils/introspection/IERC165.sol"; + +import {IDataProvider} from "src/IDataProvider.sol"; +import {ITask} from "src/ITask.sol"; +import {ITaskSpawner} from "src/ITaskSpawner.sol"; +import {ISafetyGateTask} from "src/safety-gate-task/ISafetyGateTask.sol"; +import { + MAX_SENTRIES, + 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 view returns (bool) { + return _finished; + } + + function supportsInterface(bytes4 interfaceId) + external + pure + returns (bool) + { + return interfaceId == type(ITask).interfaceId; + } +} + +/// @notice Mock whose `cleanup` always reverts, and whose `result` reverts +/// once finished (mimicking a tournament that failed with no winner). +contract RevertingMockTask is ITask { + bool private _finished; + + error MockResultRevert(); + error MockCleanupRevert(); + + function setFinished(bool finished) external { + _finished = finished; + } + + function result() external view returns (bool, Machine.Hash) { + if (_finished) { + revert MockResultRevert(); + } + return (false, Machine.ZERO_STATE); + } + + function cleanup() external pure returns (bool) { + revert MockCleanupRevert(); + } + + 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 SENTRY_MANAGER = address(0x3001); + + function _newTask(address[] memory sentries) + internal + returns (SafetyGateTask task, MockTask inner) + { + inner = new MockTask(); + task = new SafetyGateTask(inner, WINDOW, sentries); + } + + function _oneSentry() internal pure returns (address[] memory sentries) { + sentries = new address[](1); + sentries[0] = SENTRY_ONE; + } + + function _twoSentries() internal pure returns (address[] memory sentries) { + sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_TWO; + } + + function _assertStatus( + SafetyGateTask task, + ISafetyGateTask.SentryStatus expectedStatus, + Machine.Hash expectedClaim + ) internal view { + (ISafetyGateTask.SentryStatus status, Machine.Hash claim) = + task.sentryStatus(); + assertEq(uint8(status), uint8(expectedStatus)); + assertTrue(claim.eq(expectedClaim)); + } + + function testConstructorSetsSentriesAndCount() public { + (SafetyGateTask task,) = _newTask(_twoSentries()); + + assertEq(task.sentryCount(), 2); + assertTrue(task.isSentry(SENTRY_ONE)); + assertTrue(task.isSentry(SENTRY_TWO)); + _assertStatus( + task, ISafetyGateTask.SentryStatus.VOTING, Machine.ZERO_STATE + ); + } + + function testConstructorDeduplicatesSentries() public { + address[] memory sentries = new address[](3); + sentries[0] = SENTRY_ONE; + sentries[1] = SENTRY_ONE; + sentries[2] = SENTRY_TWO; + + (SafetyGateTask task, MockTask inner) = _newTask(sentries); + + // A duplicate is collapsed, so it stays harmless (AGREED remains + // reachable) rather than inflating the count past what can vote. + assertEq(task.sentryCount(), 2); + + inner.setResult(true, STATE_ONE); + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_ONE); + + _assertStatus(task, ISafetyGateTask.SentryStatus.AGREED, STATE_ONE); + } + + function testConstructorRejectsZeroAddressSentry() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = address(0); + + // address(0) can never vote, so it must not be accepted. + MockTask inner = new MockTask(); + vm.expectRevert(SafetyGateTask.ZeroSentry.selector); + new SafetyGateTask(inner, WINDOW, sentries); + } + + function testConstructorRejectsOversizedSentryList() public { + uint256 max = MAX_SENTRIES; + address[] memory sentries = new address[](max + 1); + for (uint256 i = 0; i < sentries.length; i++) { + sentries[i] = address(uint160(i + 1)); + } + + MockTask inner = new MockTask(); + vm.expectRevert(SafetyGateTask.TooManySentries.selector); + new SafetyGateTask(inner, WINDOW, sentries); + } + + function testConstructorAcceptsMaxSentryList() public { + uint256 max = MAX_SENTRIES; + address[] memory sentries = new address[](max); + for (uint256 i = 0; i < sentries.length; i++) { + sentries[i] = address(uint160(i + 1)); + } + + MockTask inner = new MockTask(); + SafetyGateTask task = new SafetyGateTask(inner, WINDOW, sentries); + assertEq(task.sentryCount(), max); + } + + /// @dev A task with a pathologically large window must not brick: once the + /// fallback timer elapses, result() must return the inner state, not revert + /// on a uint64 overflow of (start + window). + function testHugeWindowDoesNotBrickResult() public { + Time.Duration hugeWindow = Time.Duration.wrap(type(uint64).max); + MockTask inner = new MockTask(); + SafetyGateTask task = + new SafetyGateTask(inner, hugeWindow, _oneSentry()); + + inner.setResult(true, STATE_ONE); + assertTrue(task.startFallbackTimer()); + + // Before the (astronomical) window elapses: still holding, no revert. + (bool finished, Machine.Hash finalState) = task.result(); + assertFalse(finished); + assertTrue(finalState.eq(Machine.ZERO_STATE)); + + // fallbackTimer() must not revert either. + (bool started,, bool elapsed) = task.fallbackTimer(); + assertTrue(started); + assertFalse(elapsed); + } + + function testSentryVoteRequiresSentry() public { + (SafetyGateTask task,) = _newTask(_oneSentry()); + + vm.expectRevert( + abi.encodeWithSelector(SafetyGateTask.NotSentry.selector) + ); + vm.prank(OTHER); + task.sentryVote(STATE_ONE); + } + + function testSentryVoteRejectsZero() public { + (SafetyGateTask task,) = _newTask(_oneSentry()); + + vm.expectRevert( + abi.encodeWithSelector(SafetyGateTask.InvalidSentryVote.selector) + ); + vm.prank(SENTRY_ONE); + task.sentryVote(Machine.ZERO_STATE); + } + + function testSentryVoteOnlyOnce() public { + (SafetyGateTask task,) = _newTask(_oneSentry()); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + + vm.expectRevert( + abi.encodeWithSelector(SafetyGateTask.AlreadyVoted.selector) + ); + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + } + + function testSentryVoteEmitsEvent() public { + (SafetyGateTask task,) = _newTask(_oneSentry()); + + vm.expectEmit(true, false, false, true, address(task)); + emit SafetyGateTask.SentryVoted(SENTRY_ONE, STATE_ONE); + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + } + + function testStatusWhileVotesAccumulate() public { + (SafetyGateTask task,) = _newTask(_twoSentries()); + + // partial consistent votes are still VOTING, and no claim leaks + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + _assertStatus( + task, ISafetyGateTask.SentryStatus.VOTING, Machine.ZERO_STATE + ); + + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_ONE); + _assertStatus(task, ISafetyGateTask.SentryStatus.AGREED, STATE_ONE); + } + + function testDisagreementIsAbsorbing() public { + (SafetyGateTask task,) = _newTask(_twoSentries()); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_TWO); + + _assertStatus( + task, ISafetyGateTask.SentryStatus.DISAGREED, Machine.ZERO_STATE + ); + } + + function testResultWhenInnerNotFinished() public { + (SafetyGateTask task, MockTask inner) = _newTask(_oneSentry()); + inner.setResult(false, STATE_ONE); + + (bool finished, Machine.Hash finalState) = task.result(); + assertFalse(finished); + assertTrue(finalState.eq(Machine.ZERO_STATE)); + } + + function testResultWhenSentriesAgreeAndMatchInner() public { + (SafetyGateTask task, MockTask inner) = _newTask(_twoSentries()); + 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 testResultNeverReturnsSentryClaim() public { + (SafetyGateTask task, MockTask inner) = _newTask(_oneSentry()); + inner.setResult(true, STATE_ONE); + + // Full sentry agreement on a state that differs from the inner + // result must never surface the sentry claim. + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_TWO); + + assertTrue(task.startFallbackTimer()); + (, Time.Instant startInstant,) = task.fallbackTimer(); + vm.roll( + Time.Instant.unwrap(startInstant) + Time.Duration.unwrap(WINDOW) + ); + + (bool finished, Machine.Hash finalState) = task.result(); + assertTrue(finished); + assertTrue(finalState.eq(STATE_ONE)); + } + + function testResultMismatchRequiresFallbackTimer() public { + (SafetyGateTask task, MockTask inner) = _newTask(_twoSentries()); + 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()); + + (bool started, Time.Instant startInstant, bool elapsed) = + task.fallbackTimer(); + assertTrue(started); + assertFalse(elapsed); + uint256 startBlock = Time.Instant.unwrap(startInstant); + assertGt(startBlock, 0); + + vm.roll(startBlock + Time.Duration.unwrap(WINDOW) - 1); + (finished, finalState) = task.result(); + assertFalse(finished); + assertTrue(finalState.eq(Machine.ZERO_STATE)); + (,, elapsed) = task.fallbackTimer(); + assertFalse(elapsed); + + vm.roll(startBlock + Time.Duration.unwrap(WINDOW)); + (finished, finalState) = task.result(); + assertTrue(finished); + assertTrue(finalState.eq(STATE_ONE)); + (,, elapsed) = task.fallbackTimer(); + assertTrue(elapsed); + } + + function testResultMissingVotesRequiresFallbackTimer() public { + (SafetyGateTask task, MockTask inner) = _newTask(_twoSentries()); + inner.setResult(true, STATE_ONE); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + + assertTrue(task.canStartFallbackTimer()); + assertTrue(task.startFallbackTimer()); + + (, Time.Instant startInstant,) = task.fallbackTimer(); + vm.roll( + Time.Instant.unwrap(startInstant) + Time.Duration.unwrap(WINDOW) + ); + (bool finished, Machine.Hash finalState) = task.result(); + assertTrue(finished); + assertTrue(finalState.eq(STATE_ONE)); + } + + function testResultIsMonotoneUnderLateVotes() public { + (SafetyGateTask task, MockTask inner) = _newTask(_twoSentries()); + inner.setResult(true, STATE_ONE); + + vm.prank(SENTRY_ONE); + task.sentryVote(STATE_ONE); + + assertTrue(task.startFallbackTimer()); + (, Time.Instant startInstant,) = task.fallbackTimer(); + vm.roll( + Time.Instant.unwrap(startInstant) + Time.Duration.unwrap(WINDOW) + ); + + (bool finished,) = task.result(); + assertTrue(finished); + + // a late conflicting vote cannot un-finish the gate + vm.prank(SENTRY_TWO); + task.sentryVote(STATE_TWO); + + (finished,) = task.result(); + assertTrue(finished); + } + + function testResultRevertPassthrough() public { + RevertingMockTask inner = new RevertingMockTask(); + SafetyGateTask task = new SafetyGateTask(inner, WINDOW, _oneSentry()); + + // gate is transparent to inner `result()` reverts + inner.setFinished(true); + vm.expectRevert( + abi.encodeWithSelector(RevertingMockTask.MockResultRevert.selector) + ); + task.result(); + } + + function testStartFallbackTimerRequiresInnerFinished() public { + (SafetyGateTask task, MockTask inner) = _newTask(_oneSentry()); + inner.setResult(false, STATE_ONE); + + assertFalse(task.canStartFallbackTimer()); + assertFalse(task.startFallbackTimer()); + (bool started,,) = task.fallbackTimer(); + assertFalse(started); + } + + function testStartFallbackTimerIdempotent() public { + (SafetyGateTask task, MockTask inner) = _newTask(_oneSentry()); + inner.setResult(true, STATE_ONE); + + vm.expectEmit(false, false, false, true, address(task)); + emit SafetyGateTask.DisagreementWindowStarted(Time.Instant + .wrap(uint64(vm.getBlockNumber()))); + assertTrue(task.startFallbackTimer()); + + (bool started, Time.Instant startInstant,) = task.fallbackTimer(); + assertTrue(started); + uint256 startBlock = Time.Instant.unwrap(startInstant); + assertGt(startBlock, 0); + + vm.roll(startBlock + 1); + assertFalse(task.startFallbackTimer()); + (, startInstant,) = task.fallbackTimer(); + assertEq(Time.Instant.unwrap(startInstant), startBlock); + } + + function testCleanupDelegatesToInner() public { + (SafetyGateTask task, MockTask inner) = _newTask(_oneSentry()); + + inner.setResult(false, STATE_ONE); + assertFalse(task.cleanup()); + + inner.setResult(true, STATE_ONE); + assertTrue(task.cleanup()); + } + + function testCleanupSwallowsInnerRevert() public { + RevertingMockTask inner = new RevertingMockTask(); + SafetyGateTask task = new SafetyGateTask(inner, WINDOW, _oneSentry()); + + // inner unfinished: cleanup short-circuits + assertFalse(task.cleanup()); + + // inner cleanup reverts: swallowed into false + // (use a MockTask wrapper state where result() is fine but cleanup reverts) + MockRevertingCleanupTask innerCleanup = new MockRevertingCleanupTask(); + SafetyGateTask task2 = + new SafetyGateTask(innerCleanup, WINDOW, _oneSentry()); + assertFalse(task2.cleanup()); + } + + function testSupportsInterface() public { + (SafetyGateTask task,) = _newTask(_oneSentry()); + + assertTrue(task.supportsInterface(type(IERC165).interfaceId)); + assertTrue(task.supportsInterface(type(ITask).interfaceId)); + assertTrue(task.supportsInterface(type(ISafetyGateTask).interfaceId)); + assertFalse(task.supportsInterface(bytes4(0xffffffff))); + } + + function testInterfaceIdMatchesNodeConstant() public pure { + // The node hardcodes this id to detect safety gates behind the + // EpochSealed task address (SAFETY_GATE_TASK_INTERFACE_ID in the + // epoch-manager crate). Solidity computes interface ids excluding + // inherited functions, so it cannot be derived by XORing the full + // contract ABI. If this assert breaks, update the node constant. + assertEq( + bytes32(type(ISafetyGateTask).interfaceId), + bytes32(bytes4(0xf77c3559)) + ); + } + + function testSpawnerOnlySentryManagerCanSetSentries() public { + MockSpawner innerSpawner = new MockSpawner(); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, _oneSentry() + ); + + address[] memory newSentries = new address[](1); + newSentries[0] = SENTRY_TWO; + + vm.expectRevert( + abi.encodeWithSelector( + SafetyGateTaskSpawner.NotSentryManager.selector + ) + ); + vm.prank(OTHER); + spawner.setSentries(newSentries); + } + + function testSpawnerSpawnUsesSnapshot() public { + MockSpawner innerSpawner = new MockSpawner(); + innerSpawner.setNextResult(true, STATE_ONE); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, _oneSentry() + ); + + 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.expectEmit(false, false, false, true, address(spawner)); + emit SafetyGateTaskSpawner.SentriesUpdated(nextSentries); + vm.prank(SENTRY_MANAGER); + 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 testSpawnerEmitsSpawnEvent() public { + MockSpawner innerSpawner = new MockSpawner(); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, _oneSentry() + ); + + // task/innerTask addresses are unknown upfront: check only that the + // event was emitted by the spawner, then check consistency after + vm.recordLogs(); + ITask task = spawner.spawn(STATE_ONE, IDataProvider(address(0))); + + bool foundSpawnEvent; + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 i = 0; i < logs.length; i++) { + if ( + logs[i].emitter == address(spawner) + && logs[i].topics[0] + == SafetyGateTaskSpawner.SafetyGateTaskSpawned.selector + ) { + foundSpawnEvent = true; + assertEq( + address(uint160(uint256(logs[i].topics[1]))), address(task) + ); + assertEq( + address(uint160(uint256(logs[i].topics[2]))), + address(innerSpawner.lastTask()) + ); + } + } + assertTrue(foundSpawnEvent, "SafetyGateTaskSpawned not emitted"); + } + + function testSpawnerSentryViews() public { + MockSpawner innerSpawner = new MockSpawner(); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, _twoSentries() + ); + + assertTrue(spawner.isSentry(SENTRY_ONE)); + assertTrue(spawner.isSentry(SENTRY_TWO)); + assertFalse(spawner.isSentry(OTHER)); + + address[] memory current = spawner.getSentries(); + assertEq(current.length, 2); + assertEq(current[0], SENTRY_ONE); + assertEq(current[1], 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( + SENTRY_MANAGER, innerSpawner, WINDOW, sentries + ); + + // The spawner stores duplicates verbatim (no O(n^2) scan)... + assertEq(spawner.sentries(0), SENTRY_ONE); + assertEq(spawner.sentries(1), SENTRY_ONE); + assertEq(spawner.getSentries().length, 2); + + // ...and the spawned task collapses them, so this can never brick. + SafetyGateTask task = SafetyGateTask( + address(spawner.spawn(STATE_ONE, IDataProvider(address(0)))) + ); + assertEq(task.sentryCount(), 1); + assertTrue(task.isSentry(SENTRY_ONE)); + } + + function testSpawnerRejectsZeroSentry() public { + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_ONE; + sentries[1] = address(0); + + MockSpawner innerSpawner = new MockSpawner(); + vm.expectRevert(SafetyGateTaskSpawner.ZeroSentry.selector); + new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, sentries + ); + } + + function testSpawnerSetSentriesRejectsZero() public { + MockSpawner innerSpawner = new MockSpawner(); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, _oneSentry() + ); + + address[] memory sentries = new address[](2); + sentries[0] = SENTRY_TWO; + sentries[1] = address(0); + + vm.prank(SENTRY_MANAGER); + vm.expectRevert(SafetyGateTaskSpawner.ZeroSentry.selector); + spawner.setSentries(sentries); + } + + function testSpawnerRejectsZeroWindow() public { + MockSpawner innerSpawner = new MockSpawner(); + vm.expectRevert(SafetyGateTaskSpawner.ZeroDisagreementWindow.selector); + new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, Time.Duration.wrap(0), _oneSentry() + ); + } + + function testSpawnerConstructorRejectsOversizedList() public { + address[] memory sentries = new address[](MAX_SENTRIES + 1); + for (uint256 i = 0; i < sentries.length; i++) { + sentries[i] = address(uint160(i + 1)); + } + + MockSpawner innerSpawner = new MockSpawner(); + vm.expectRevert(SafetyGateTaskSpawner.TooManySentries.selector); + new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, sentries + ); + } + + /// @dev The compromised/careless-manager DoS the review flagged: an + /// oversized setSentries() must revert, so it can never brick spawn(). + function testSpawnerSetSentriesRejectsOversizedList() public { + MockSpawner innerSpawner = new MockSpawner(); + SafetyGateTaskSpawner spawner = new SafetyGateTaskSpawner( + SENTRY_MANAGER, innerSpawner, WINDOW, _oneSentry() + ); + + address[] memory sentries = new address[](MAX_SENTRIES + 1); + for (uint256 i = 0; i < sentries.length; i++) { + sentries[i] = address(uint160(i + 1)); + } + + vm.prank(SENTRY_MANAGER); + vm.expectRevert(SafetyGateTaskSpawner.TooManySentries.selector); + spawner.setSentries(sentries); + } +} + +/// @notice Mock whose `result` reports finished but whose `cleanup` reverts. +contract MockRevertingCleanupTask is ITask { + error MockCleanupRevert(); + + function result() external pure returns (bool, Machine.Hash) { + return (true, Machine.Hash.wrap(bytes32(uint256(1)))); + } + + function cleanup() external pure returns (bool) { + revert MockCleanupRevert(); + } + + function supportsInterface(bytes4 interfaceId) + external + pure + returns (bool) + { + return interfaceId == type(ITask).interfaceId; + } +} diff --git a/prt/contracts/test/TopTournament.t.sol b/prt/contracts/test/TopTournament.t.sol index 2c73ea9f..37410808 100644 --- a/prt/contracts/test/TopTournament.t.sol +++ b/prt/contracts/test/TopTournament.t.sol @@ -12,7 +12,12 @@ pragma solidity ^0.8.0; +import { + IERC165 +} from "@openzeppelin-contracts-5.5.0/utils/introspection/IERC165.sol"; + import {IDataProvider} from "src/IDataProvider.sol"; +import {ITask} from "src/ITask.sol"; import {ITournament} from "src/ITournament.sol"; import { ArbitrationConstants @@ -100,6 +105,41 @@ contract TopTournamentTest is Util { ); } + function testTaskResult() public { + topTournament = Util.initializePlayer0Tournament(FACTORY); + + assertTrue(topTournament.supportsInterface(type(IERC165).interfaceId)); + assertTrue(topTournament.supportsInterface(type(ITask).interfaceId)); + assertTrue( + topTournament.supportsInterface(type(ITournament).interfaceId) + ); + assertFalse(topTournament.supportsInterface(bytes4(0xffffffff))); + + // before finish: result mirrors arbitrationResult, cleanup is a no-op + (bool _finished, Machine.Hash _finalState) = topTournament.result(); + assertFalse(_finished, "task shouldn't be finished"); + assertTrue( + _finalState.eq(Machine.ZERO_STATE), "final state should be zero" + ); + assertFalse(topTournament.cleanup(), "no cleanup before finish"); + + vm.roll(vm.getBlockNumber() + Time.Duration.unwrap(MAX_ALLOWANCE)); + + (bool _arbFinished,, Machine.Hash _arbFinalState) = + topTournament.arbitrationResult(); + (_finished, _finalState) = topTournament.result(); + assertTrue(_finished, "task should be finished"); + assertTrue(_arbFinished, "arbitration result should be finished"); + assertTrue( + _finalState.eq(_arbFinalState), + "result should project arbitrationResult" + ); + + // first cleanup recovers the bond; repeats are harmless no-ops + assertTrue(topTournament.cleanup(), "cleanup should recover bond"); + assertFalse(topTournament.cleanup(), "repeated cleanup is a no-op"); + } + function testInner() public { topTournament = Util.initializePlayer0Tournament(FACTORY); diff --git a/prt/docs/safety-gate.md b/prt/docs/safety-gate.md new file mode 100644 index 00000000..08ee9d09 --- /dev/null +++ b/prt/docs/safety-gate.md @@ -0,0 +1,218 @@ +# Safety Gate + +This document describes the Safety Gate, a delay middleware for PRT tournament +results, and the task abstraction that supports it. + +The design gives a permissioned *Sentry* layer the power to **delay** — and +only delay — the settlement of an epoch, buying time for a higher-level +authority (e.g. a Security Council) to act on proof system or application bugs. +Intervention itself (pause, upgrade, output excision) happens at a different +layer and is out of scope here. + +## Task abstraction + +### `ITask` + +A *task* is an asynchronous on-chain computation that eventually resolves to a +final machine state: + +```solidity +interface ITask is IERC165 { + function result() external view returns (bool finished, Machine.Hash finalState); + function cleanup() external returns (bool cleaned); +} +``` + +- `result()` reports whether the task has finished and, if so, the final + machine state hash. +- `cleanup()` is a best-effort post-settlement hook (e.g. bond recovery). + It must be safe to call at any time, multiple times. + +`ITournament` extends `ITask`: a root tournament's `result()` is the +projection of `arbitrationResult()` without the winner commitment, and its +`cleanup()` recovers the winner's bond. Consumers that only need the settled +state (like `DaveConsensus`) depend on `ITask` and stay agnostic to the +underlying proof system. + +### `ITaskSpawner` + +```solidity +interface ITaskSpawner { + function spawn(Machine.Hash initial, IDataProvider provider) external returns (ITask); +} +``` + +`DaveConsensus` spawns one task per epoch through an `ITaskSpawner`. +`MultiLevelTournamentFactory` implements it by instantiating a root +tournament; `SafetyGateTaskSpawner` implements it by wrapping another +spawner's task in a safety gate. + +Tasks implement ERC-165 so tooling and nodes can discover what stands behind +the `EpochSealed` task address. + +## SafetyGateTask + +A `SafetyGateTask` wraps an inner task (typically a PRT root tournament) and +gates its result behind a set of sentries, fixed per task instance. + +Decision logic for `result()`: + +1. If the inner task is unfinished, the gate is unfinished. +2. If **all** sentries voted and agree on the inner task's final state, the + gate finishes immediately with that state. +3. Otherwise (missing votes, disagreement among sentries, or unanimous + agreement on a state that differs from the inner result), the gate stays + unfinished until a *fallback timer* is started and the *disagreement + window* elapses — after which the inner result is accepted as-is. + +Properties: + +- **Delay-only.** `result()` only ever surfaces the inner task's state. + Sentries decide *when* it becomes visible, never *what* it is. +- **1-of-N delay.** A single byzantine or fail-stop sentry forces the + disagreement window. This is intentional: safety over liveness. +- **Walkaway-safe.** If every sentry disappears, anyone can start the + fallback timer once the inner task finishes; the system degrades to the + permissionless proof system plus a fixed delay. + +Sentry voting rules (`sentryVote`): + +- Only configured sentries can vote, each exactly once. +- The zero state is an invalid vote. +- A vote that conflicts with an earlier vote permanently marks the sentry + set as disagreeing for this task instance. +- Votes cast after the gate has finished are accepted and harmless: the + gate's `result()` is monotone and can never become unfinished again. + +The aggregate voting state is exposed as `sentryStatus()`, which returns one +of three states — `VOTING` (accumulating, no conflict so far), `AGREED` (all +sentries voted the same claim, also returned), or `DISAGREED` (a conflict +was observed; absorbing). + +The fallback timer (`startFallbackTimer`): + +- Can be started by **anyone**, but only after the inner task has finished + and only while the sentries do not corroborate the inner result. +- Is started at most once; `canStartFallbackTimer()` reports whether a call + would succeed, and `fallbackTimer()` reports its state + (started/start-instant/elapsed). +- The gate never starts it on its own, and **neither does the node**: + starting the timer is a deliberate, monitored manual operation. Whoever + operates the deployment should alert when `canStartFallbackTimer()` + becomes true, investigate why the sentries are not corroborating, and — + once satisfied that falling back is the right call — run: + + ```sh + cast send $SAFETY_GATE_TASK "startFallbackTimer()" \ + --rpc-url $RPC_URL --private-key $ANY_FUNDED_KEY + ``` + + Note that in an adversarial scenario the attacker will call this at the + earliest possible moment, so the disagreement window must be sized + assuming the timer starts as soon as the inner task finishes. + +## SafetyGateTaskSpawner and sentry rotation + +`SafetyGateTaskSpawner` wraps an inner `ITaskSpawner` and deploys a +`SafetyGateTask` around every task it spawns, passing a snapshot of its +current sentry list and a fixed disagreement window. + +Sentry rotation: + +- `setSentries(address[])` replaces the whole list; only the *sentry + manager* address (fixed at construction) may call it. In production this + role is expected to be held by a high-threshold governance body (e.g. a + security council); note its powers stop at rotating sentries for future + tasks — it cannot affect results or in-flight tasks. +- The list is **mutable in the spawner, immutable per task**: a rotation + affects the next spawned task (i.e. the next epoch), never in-flight ones. +- The spawner stores the list verbatim (readable via `getSentries()`); the + spawned task deduplicates repeated addresses so that full participation + stays achievable. + +## Deployment + +The sentry manager, sentry list and disagreement window are app-specific +parameters with no reasonable defaults, so no standalone gate spawner is +deployed as shared infrastructure. Instead, gates are deployed **per app, +at app-creation time**: `DaveAppFactory.newGatedDaveApp(templateHash, +withdrawalConfig, sentryManager, window, sentries, salt)` atomically +deploys an app-specific `SafetyGateTaskSpawner` (wrapping the factory's +bound proof system) and a `DaveConsensus` wired to it, emitting a distinct +`GatedDaveAppCreated` event. Ungated apps go through `newDaveApp` as usual. + +Factory *events* are the canonical provenance check — they certify the +settlement mechanism (genuine consensus, gate and proof system) and +distinguish gated from bare apps; note this cannot be told from the factory +address alone. The gate's governance parameters remain app-declared and +inspectable on-chain, like the template hash. + +### Deterministic addresses are first-come-first-served + +Like any shared CREATE2 factory, the application address is a function of +`(templateHash, withdrawalConfig, salt)` only, so whoever calls first with a +given tuple occupies that address — this is pre-existing behaviour of +`newDaveApp`, not introduced by the gate. Because the gate's governance is +*not* part of the application salt, a gated and a bare deployment (or two +gated deployments with different sentries) that share the tuple compete for +the same application address; the first to land wins and the other reverts. + +This is a griefing/address-prediction concern, never a fund-safety one: the +application that ends up deployed is always a genuine factory application, +its `DaveConsensus` address *does* bind the gate (so different governance +yields a different consensus), and the emitted event states the truth. +Deployers should therefore treat provenance as "which event did the factory +emit", not "which address did I predict", and use a fresh `salt` if a +collision is a concern. + +## Node responsibilities + +The epoch task address emitted in `EpochSealed` may be a tournament or a +safety gate. The node (epoch-manager) detects gates via ERC-165 using the +pinned `ISafetyGateTask` interface id (`0xf77c3559`, guarded by +`testInterfaceIdMatchesNodeConstant`): + +- resolves `INNER_TASK()` and points the PRT player at the actual tournament; +- casts a sentry vote for the locally-computed final state, when its signer + is in the task's sentry set and has not voted yet; +- settles through `DaveConsensus.canSettle()/settle()`, comparing the local + `final_state` against the gate's reported state. + +The node deliberately does **not** start the fallback timer; see the +fallback timer section above for the manual procedure and its rationale. + +The node stores the epoch's final machine state (`final_state`) alongside the +computation hash in `settlement_info`, since `canSettle` now reports the +final state rather than the winner commitment. + +## Threat scenarios + +| Scenario | Effect | Outcome | +| --- | --- | --- | +| One sentry byzantine or fail-stop | Forces the disagreement window | Delay only | +| All sentries fail-stop | Anyone starts the fallback timer | Delay only | +| All sentries byzantine, matching a bad inner result | Gate provides no buffer | Rely on the proof system / higher-level authority | +| Proof system bug, honest sentries | Mismatch blocks settlement for the window | Time for intervention at a higher layer | +| Sentry manager key compromise | Can rotate sentries for future epochs | Delay only — cannot change results | + +The sentry manager's power over the gate is limited to rotating sentries +for future epochs; even a fully compromised manager cannot use the gate to +change a result or to block settlement indefinitely. In particular the +sentry set is length-bounded (`MAX_SENTRIES`), which is the sole anti-brick +guarantee: an oversized rotation is the only way a rotation could exhaust gas +on the next spawn, and it is rejected. A zero address is also rejected and +duplicates are collapsed, but neither could freeze settlement anyway — at +worst they force the fallback window. The manager can delay, never freeze. + +## Gas notes + +Measured with `forge test --gas-report` (Foundry 1.5.1): + +- `SafetyGateTaskSpawner.spawn` costs ~700k gas, of which ~577k is deploying + the full `SafetyGateTask` bytecode. This happens once per epoch. Unlike + tournaments (which are spawned as minimal-proxy clones), the gate is a + full contract deployment; if epochs ever become much shorter, converting + the gate to a clone with an initializer (clones cannot carry immutables) + would save ~500k gas per epoch. Deferred deliberately, for simplicity. +- `sentryVote` costs 55–92k gas per sentry per epoch; `startFallbackTimer` + ~69k; `result()` and the other views are free (off-chain) or negligible. diff --git a/test/Dockerfile b/test/Dockerfile index f742b521..7ff9b09f 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -2,7 +2,7 @@ ARG RUST_VERSION=1.90 ARG DEBIAN_VERSION=trixie -ARG FOUNDRY_VERSION=1.4.3 +ARG FOUNDRY_VERSION=1.5.1 ARG PNPM_VERSION=10.7.0 ARG JUST_VERSION=1.46.0 @@ -39,8 +39,8 @@ RUN <