From 109b40c6273fa44f838c44c9627ab39ae298985b Mon Sep 17 00:00:00 2001 From: Guilherme Dantas Date: Wed, 1 Jul 2026 08:19:07 -0300 Subject: [PATCH 1/3] chore: bump Foundry from 1.4.3 to 1.5.1 - This commit bumps Foundry on the CI and Dockefile. This is to align with the version of Foundry used on rollups-contracts, so that the node team can also bump Foundry (given that it uses the devnet image built in the dave repo still). --- .github/actions/setup-tools/action.yml | 2 +- prt/contracts/src/tournament/libs/Match.sol | 5 +---- test/Dockerfile | 6 +++--- 3 files changed, 5 insertions(+), 8 deletions(-) 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/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/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 < Date: Wed, 1 Jul 2026 10:47:43 -0300 Subject: [PATCH 2/3] chore: bump Honeypot from 3.0.0 to 3.0.1 - Honeypot 3.0.1 fixes a bug in the rootfs Dockerfile that would lead to its build to fail. The resulting image is the same as 3.0.0 but we can now build the custom Honeypot rootfs image with devnet config. --- test/programs/justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/programs/justfile b/test/programs/justfile index 6f30ba3a..5a604d04 100644 --- a/test/programs/justfile +++ b/test/programs/justfile @@ -33,7 +33,7 @@ clean-echo: (clean-program "echo") # honeypot/honeypot (requires docker) build-honeypot-snapshot: clean-honeypot-snapshot clean-honeypot-project git clone https://github.com/cartesi/honeypot.git honeypot/project - git -C honeypot/project reset --hard 34d00721a527eeb7ed8cce2a13a142e3d8de9aad # v3.0.0 + git -C honeypot/project reset --hard 589a9c1a26522904f79df4955caff69537d38b13 # v3.0.1 sed -i 's/,filename:/,data_filename:/g' honeypot/project/Makefile sed -i 's/--append-bootargs=ro/--append-bootargs=rw/g' honeypot/project/Makefile sed -i 's/label:state,length:4096,user:dapp/label:state,length:4096,user:dapp,mke2fs:false,mount:false/g' honeypot/project/Makefile From 63d155e1b13a67a9317a85c2a42e299438de4806 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 9 Jul 2026 11:14:34 -0300 Subject: [PATCH 3/3] feat!: extract ITask from ITournament and add the Safety Gate Introduce a minimal task abstraction (ITask/ITaskSpawner) that decouples DaveConsensus from the proof system: - ITournament now extends ITask; result() projects arbitrationResult without the winner commitment, and cleanup() recovers bonds. - ITournamentFactory is replaced by ITaskSpawner. - DaveConsensus spawns tasks and settles on result(); canSettle reports the final machine state instead of the winner commitment. Add the Safety Gate, a delay-only middleware task: - SafetyGateTask gates an inner task behind unanimous sentry votes, with a permissionless fallback timer for liveness. Voting state is exposed as a tri-state SentryStatus (VOTING/AGREED/DISAGREED). - SafetyGateTaskSpawner wraps an inner spawner; the sentry manager can rotate the sentry set, effective from the next spawned task. - Starting the fallback timer is a manual, monitored operation; the node only casts sentry votes (see prt/docs/safety-gate.md). Deploy gates per app, not as shared infrastructure: - DaveAppFactory.newGatedDaveApp atomically deploys an app-specific SafetyGateTaskSpawner (wrapping the factory's bound proof system) and a DaveConsensus wired to it. Gate governance (sentry manager, sentries, disagreement window) is app-declared, like the template hash; deployment scripts carry no app-specific configuration. - Factory events are the canonical provenance check: DaveAppCreated and GatedDaveAppCreated certify the settlement mechanism and distinguish gated from bare apps. Both index the app and consensus addresses. Update the node accordingly: - epoch-manager detects a safety gate behind the EpochSealed task via ERC-165 (pinned ISafetyGateTask id, guarded by a Solidity test), casts a sentry vote when the signer is a sentry, and points the PRT player at INNER_TASK. - state-manager stores the epoch's final machine state alongside the computation hash, since canSettle now reports the final state. - the blockchain-reader e2e harness deploys a gated app through the factory, keeping the safety gate exercised in the node test pipeline. --- Cargo.lock | 1 + .../contracts/script/Deployment.s.sol | 8 +- .../contracts/src/DaveAppFactory.sol | 105 ++- .../contracts/src/DaveConsensus.sol | 73 +- .../contracts/src/IDaveAppFactory.sol | 64 +- .../contracts/src/IDaveConsensus.sol | 40 +- .../contracts/test/DaveAppFactory.t.sol | 72 +- .../contracts/test/DaveSafetyGate.t.sol | 262 +++++++ .../contracts/test/DaveTestLib.sol | 35 + .../node/blockchain-reader/src/lib.rs | 2 +- .../node/blockchain-reader/src/test_utils.rs | 31 +- .../node/cartesi-rollups-prt-node/src/lib.rs | 1 + cartesi-rollups/node/epoch-manager/Cargo.toml | 1 + cartesi-rollups/node/epoch-manager/src/lib.rs | 132 +++- cartesi-rollups/node/state-manager/src/lib.rs | 1 + .../src/persistent_state_access.rs | 17 +- .../node/state-manager/src/sql/migrations.sql | 1 + .../node/state-manager/src/sql/rollup_data.rs | 22 +- prt/README.md | 4 + prt/client-rs/core/src/tournament/sender.rs | 9 +- prt/contracts/justfile | 2 +- prt/contracts/script/Deployment.s.sol | 3 - prt/contracts/src/ITask.sol | 36 + prt/contracts/src/ITaskSpawner.sol | 18 + prt/contracts/src/ITournament.sol | 3 +- prt/contracts/src/ITournamentFactory.sol | 16 - .../src/safety-gate-task/ISafetyGateTask.sol | 87 +++ .../src/safety-gate-task/SafetyGateTask.sol | 256 ++++++ .../SafetyGateTaskSpawner.sol | 134 ++++ prt/contracts/src/tournament/Tournament.sol | 49 ++ .../IMultiLevelTournamentFactory.sol | 8 +- .../factories/MultiLevelTournamentFactory.sol | 20 + prt/contracts/test/SafetyGateTask.t.sol | 738 ++++++++++++++++++ prt/contracts/test/TopTournament.t.sol | 40 + prt/docs/safety-gate.md | 218 ++++++ 35 files changed, 2323 insertions(+), 186 deletions(-) create mode 100644 cartesi-rollups/contracts/test/DaveSafetyGate.t.sol create mode 100644 cartesi-rollups/contracts/test/DaveTestLib.sol create mode 100644 prt/contracts/src/ITask.sol create mode 100644 prt/contracts/src/ITaskSpawner.sol delete mode 100644 prt/contracts/src/ITournamentFactory.sol create mode 100644 prt/contracts/src/safety-gate-task/ISafetyGateTask.sol create mode 100644 prt/contracts/src/safety-gate-task/SafetyGateTask.sol create mode 100644 prt/contracts/src/safety-gate-task/SafetyGateTaskSpawner.sol create mode 100644 prt/contracts/test/SafetyGateTask.t.sol create mode 100644 prt/docs/safety-gate.md 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/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.