From 086e345f920891983a962a26f5f663aff0cf0685 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:14:58 +0800 Subject: [PATCH 1/7] fix(contracts)!: cap each sealed epoch at 2^24 inputs Uncapped acceptance could finalize inputs the state transition cannot feed. Cap construction and acceptance at the Machine input span, preserving excess inputs for later epochs and finalizing only processed inputs. Check the emitted EpochSealed bounds as well as storage. Split pending input windows at the same boundary in Rust, and reject seals past that span with the capped-consensus requirement. Lua selects inputs by sealed bounds and refuses an epoch past the span; it does not split. Go rollover must select inputs by sealed index bounds across block ranges. Keep the single delay ledger and reference price explicit: capped work can still drain C, while spill-epoch queue waits do not. Re-derive the price below the throughput threshold after Linux qualification. Addresses move. Earlier uncapped deployments are unsupported and stall at the first seal past the span. Addresses PRT-1 and SLN-42. --- CHANGELOG.md | 23 +++ cartesi-rollups/contracts/AGENTS.md | 28 +-- .../contracts/src/DaveConsensus.sol | 17 +- .../contracts/src/IDaveConsensus.sol | 9 +- .../test/DaveConsensusCapacity.t.sol | 95 ++++++++++ .../node/src/blockchain_reader/mod.rs | 177 +++++++++++++++++- docs/dimensioning.md | 123 +++++++----- docs/epoch-lifecycle.md | 18 ++ docs/node-architecture.md | 50 +++-- docs/plans/audit-readiness.md | 4 +- docs/todo.md | 20 +- prt/client-lua/computation/machine.lua | 1 + prt/client-lua/tests/computation_test.lua | 23 +++ 13 files changed, 469 insertions(+), 119 deletions(-) create mode 100644 cartesi-rollups/contracts/test/DaveConsensusCapacity.t.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cd8571..ebc7dd89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## Unreleased + +A new deployment generation: the consensus bytecode +changes, moving deterministic deployment addresses. This node requires a +DaveConsensus that caps each epoch at the Machine's input span; earlier +uncapped deployments are unsupported: the node stalls at the first seal +past the span. + +### Contracts + +- DaveConsensus seals at most 2^24 pending inputs, retaining excess inputs + for later epochs. Only processed, accepted inputs become finalized. + +### Node + +- Split full pending epochs at the input span, check every later seal against + those boundaries, and stall at the first seal past the span. + +### For developers + +- Rebuild the devnet bundle with `just rollups-contracts::build-devnet` after + the contract changes. + ## v3.0.0-alpha.6 A new deployment generation (cartesi/dave#287). Nodes from earlier releases diff --git a/cartesi-rollups/contracts/AGENTS.md b/cartesi-rollups/contracts/AGENTS.md index dabcc2c7..f24ce36a 100644 --- a/cartesi-rollups/contracts/AGENTS.md +++ b/cartesi-rollups/contracts/AGENTS.md @@ -76,7 +76,8 @@ truth. staged value is echoed back to or from a claim. - One live tournament at a time - the current sealed epoch's. Acceptance settles, samples the next epoch's input bounds from the InputBox at - that moment, records the outputs root in a write-once mapping, and + that moment, capped at the Machine's 2^24-input span with excess left + pending, records the outputs root in a write-once mapping, and instantiates the next tournament from the settled state. - A sentry slot claims at most once per epoch (bitmap by slot ID). Rotation moves a slot to a new address but preserves an already-placed @@ -89,9 +90,11 @@ truth. padding rule the off-chain commitment builders mirror (docs/computation-hash.md). Changing it desynchronizes every commitment producer. - ## Change guardrails +- `MAX_INPUTS_PER_EPOCH` must equal the node's `Structure::max_inputs`. + If it moves, update the reader's eager split and docs/epoch-lifecycle.md + with it, and validate the cross-implementation boundary. - Preserve the deployed ABI, events, error selectors, and the ERC-165 surface (`IDataProvider`, `IOutputsMerkleRootValidator`) unless the task explicitly authorizes a compatibility break. @@ -119,17 +122,16 @@ just test-prt-gas # includes the leaf-proof subsidy through this provid just bind # after any interface change ``` -Honest coverage statement: `DaveConsensus` has no dedicated Foundry unit -suite today. Its behavior is exercised through `test/DaveAppFactory.t.sol` -(construction, stage and accept, `wasInputFinalized`, staging reverts on bad -proofs, root failure, sentry rotation, a winner exhausting the payment -callback), the leaf-proof gas FFI fixture (`test/gas/`), the node's -integration tests, and the e2e smoke. `test/NodeProofs.t.sol` and -`test/NodeWitnesses.t.sol` check that the contracts' verifiers and the step -accept the node's proofs and witnesses, without running `DaveConsensus`. Do -not mistake any of these for a unit suite when changing staging, -sentry, or settlement logic; adding focused tests with such a change is -expected. +Honest coverage statement: `test/DaveConsensusCapacity.t.sol` uses a mocked +fixture to cover capped epoch storage and events, backlog rollover and boundary +counts. `test/DaveAppFactory.t.sol` exercises construction, stage and accept, +`wasInputFinalized`, staging reverts on bad proofs, root failure, sentry +rotation and a winner exhausting the payment callback. The leaf-proof gas FFI fixture +(`test/gas/`), node integration tests and e2e smoke add coverage. +`test/NodeProofs.t.sol` and `test/NodeWitnesses.t.sol` check that the contracts' +verifiers and the step accept the node's proofs and witnesses, without running +`DaveConsensus`. When changing staging, sentry or settlement logic, add focused +tests rather than assuming the capacity fixture covers it. ## Explicit non-claims diff --git a/cartesi-rollups/contracts/src/DaveConsensus.sol b/cartesi-rollups/contracts/src/DaveConsensus.sol index 3280abb1..b29dba04 100644 --- a/cartesi-rollups/contracts/src/DaveConsensus.sol +++ b/cartesi-rollups/contracts/src/DaveConsensus.sol @@ -23,6 +23,7 @@ import {ITournamentFactory} from "prt-contracts/ITournamentFactory.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"; @@ -33,6 +34,10 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { using LibBinaryMerkleTree for bytes; using LibMachineValidityProof for MachineValidityProof; + /// @notice The state transition and node coordinates cannot represent + /// input counters beyond this span; excess inputs stay pending. + uint256 constant MAX_INPUTS_PER_EPOCH = 1 << EmulatorConstants.ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH; + /// @notice The input box contract IInputBox immutable _INPUT_BOX; @@ -133,7 +138,7 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { emit ConsensusCreation(inputBox, appContract, tournamentFactory); // Initialize first sealed epoch - uint256 inputIndexUpperBound = inputBox.getNumberOfInputs(appContract); + uint256 inputIndexUpperBound = _nextInputIndexUpperBound(0); _inputIndexUpperBound = inputIndexUpperBound; ITournament tournament = tournamentFactory.instantiate(initialMachineStateHash, this); _tournament = tournament; @@ -259,10 +264,11 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { Machine.Hash finalMachineStateHash = _stagedPostEpochMachineStateHash; bytes32 outputsMerkleRoot = _stagedPostEpochOutputsMerkleRoot; - // Seal current accumulating epoch, save settled output tree and machine state hash + // Settle the staged result, then seal at most one input span; + // the backlog waits for later epochs. _epochNumber++; _inputIndexLowerBound = _inputIndexUpperBound; - _inputIndexUpperBound = _INPUT_BOX.getNumberOfInputs(_APP_CONTRACT); + _inputIndexUpperBound = _nextInputIndexUpperBound(_inputIndexLowerBound); _outputsMerkleRoots[outputsMerkleRoot] = true; _lastFinalizedMachineStateHash = finalMachineStateHash; _isTournamentResultStaged = false; @@ -461,6 +467,11 @@ contract DaveConsensus is IDaveConsensus, ERC165, ApplicationChecker { return proof.validate(Machine.Hash.unwrap(finalMachineStateHash)); } + function _nextInputIndexUpperBound(uint256 lowerBound) internal view returns (uint256) { + uint256 pendingInputs = _INPUT_BOX.getNumberOfInputs(_APP_CONTRACT) - lowerBound; + return lowerBound + pendingInputs.min(MAX_INPUTS_PER_EPOCH); + } + function _doAllSentriesAgreeWithStagedTournamentResult() internal view returns (bool) { return _NUM_OF_SENTRIES > 0 && _claimCount[_epochNumber][_stagedPostEpochMachineStateHash] == _NUM_OF_SENTRIES; } diff --git a/cartesi-rollups/contracts/src/IDaveConsensus.sol b/cartesi-rollups/contracts/src/IDaveConsensus.sol index cf422377..9bcb3434 100644 --- a/cartesi-rollups/contracts/src/IDaveConsensus.sol +++ b/cartesi-rollups/contracts/src/IDaveConsensus.sol @@ -22,7 +22,7 @@ import {ISentryErrors} from "./ISentryErrors.sol"; /// @notice Consensus contract with Dave tournaments. /// /// @notice This contract validates only one application, -/// which read inputs from the InputBox contract. +/// which reads inputs from the InputBox contract. /// /// @notice This contract also manages epoch boundaries, which /// are defined in terms of input indices. We represent them @@ -41,9 +41,10 @@ import {ISentryErrors} from "./ISentryErrors.sol"; /// /// @notice At any given time, there is always one sealed epoch. /// Prior to it, every epoch has been settled. -/// After it, the next epoch is accumulating inputs. Once this epoch is settled, -/// the accumulating epoch will be sealed, and a new -/// accumulating epoch will be created. +/// After it, inputs accumulate in the InputBox. Once the sealed epoch settles, +/// the next epoch seals at most 2^24 pending inputs. Any excess remains pending +/// for later epochs; the seal's index bounds define its inputs, not block ranges. +/// The first epoch is sealed the same way at construction. /// Every sealed epoch has an associated tournament. /// Once a tournament is finished, and a winner commitment is declared, /// it can then be staged by anyone. After the claim staging period is elapsed, diff --git a/cartesi-rollups/contracts/test/DaveConsensusCapacity.t.sol b/cartesi-rollups/contracts/test/DaveConsensusCapacity.t.sol new file mode 100644 index 00000000..fcf61df4 --- /dev/null +++ b/cartesi-rollups/contracts/test/DaveConsensusCapacity.t.sol @@ -0,0 +1,95 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.30; + +import {IApplication} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplication.sol"; +import {IInputBox} from "cartesi-rollups-contracts-3.0.0/src/inputs/IInputBox.sol"; +import {Test} from "forge-std-1.9.6/src/Test.sol"; +import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; +import {Machine} from "prt-contracts/types/Machine.sol"; +import {DaveConsensus} from "src/DaveConsensus.sol"; +import {IDaveConsensus} from "src/IDaveConsensus.sol"; +import {EmulatorConstants} from "step/src/EmulatorConstants.sol"; + +contract CapacityConsensus is DaveConsensus { + constructor(IInputBox inputBox, address app, ITournamentFactory factory) + DaveConsensus(inputBox, app, factory, Machine.Hash.wrap(bytes32(0)), 0, address(0), new address[](0)) + {} + + // Isolate sealing from validity proofs and tournament resolution, covered + // by DaveAppFactoryTest. Acceptance still runs through the public entry point. + function stageForTest() external { + _isTournamentResultStaged = true; + } +} + +contract DaveConsensusCapacityTest is Test { + uint256 constant CAPACITY = 1 << EmulatorConstants.ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH; + IInputBox constant INPUT_BOX = IInputBox(address(0x100)); + address constant APP = address(0x200); + ITournamentFactory constant FACTORY = ITournamentFactory(address(0x300)); + ITournament constant TOURNAMENT = ITournament(address(0x400)); + + function setUp() external { + vm.mockCall( + address(FACTORY), abi.encodeWithSelector(ITournamentFactory.instantiate.selector), abi.encode(TOURNAMENT) + ); + vm.mockCall(APP, abi.encodeCall(IApplication.isForeclosed, ()), abi.encode(false)); + } + + function _count(uint256 count) internal { + vm.mockCall(address(INPUT_BOX), abi.encodeCall(IInputBox.getNumberOfInputs, (APP)), abi.encode(count)); + } + + function _deploy(uint256 count) internal returns (CapacityConsensus) { + _count(count); + _expectSeal(0, 0, count < CAPACITY ? count : CAPACITY); + return new CapacityConsensus(INPUT_BOX, APP, FACTORY); + } + + function _expectSeal(uint256 epoch, uint256 lower, uint256 upper) internal { + vm.expectEmit(true, false, false, true); + emit IDaveConsensus.EpochSealed(epoch, lower, upper, Machine.Hash.wrap(bytes32(0)), bytes32(0), TOURNAMENT); + } + + function _bounds(CapacityConsensus consensus, uint256 epoch, uint256 lower, uint256 upper) internal view { + (uint256 actualEpoch, uint256 actualLower, uint256 actualUpper,,,,,) = consensus.getCurrentSealedEpoch(); + assertEq(actualEpoch, epoch); + assertEq(actualLower, lower); + assertEq(actualUpper, upper); + } + + function testBacklogRollsAcrossEpochsWithoutFinalizingUnprocessedInputs() external { + CapacityConsensus consensus = _deploy(CAPACITY + 1); + _bounds(consensus, 0, 0, CAPACITY); + consensus.stageForTest(); + _expectSeal(1, CAPACITY, CAPACITY + 1); + consensus.acceptStagedTournamentResult(0); + _bounds(consensus, 1, CAPACITY, CAPACITY + 1); + assertTrue(consensus.wasInputFinalized(APP, CAPACITY - 1, 0)); + assertFalse(consensus.wasInputFinalized(APP, CAPACITY, 0)); + + _count(3 * CAPACITY + 1); + consensus.stageForTest(); + _expectSeal(2, CAPACITY + 1, 2 * CAPACITY + 1); + consensus.acceptStagedTournamentResult(1); + _bounds(consensus, 2, CAPACITY + 1, 2 * CAPACITY + 1); + assertTrue(consensus.wasInputFinalized(APP, CAPACITY, 0)); + assertFalse(consensus.wasInputFinalized(APP, CAPACITY + 1, 0)); + } + + function testCapacityBoundaryAndEmptyEpochs() external { + uint256[4] memory counts = [uint256(0), CAPACITY - 1, CAPACITY, CAPACITY + 1]; + for (uint256 i; i < counts.length; ++i) { + CapacityConsensus consensus = _deploy(counts[i]); + uint256 first = counts[i] < CAPACITY ? counts[i] : CAPACITY; + _bounds(consensus, 0, 0, first); + consensus.stageForTest(); + _expectSeal(1, first, counts[i]); + consensus.acceptStagedTournamentResult(0); + _bounds(consensus, 1, first, counts[i]); + } + } +} diff --git a/cartesi-rollups/node/src/blockchain_reader/mod.rs b/cartesi-rollups/node/src/blockchain_reader/mod.rs index 25f20d99..328ab6bb 100644 --- a/cartesi-rollups/node/src/blockchain_reader/mod.rs +++ b/cartesi-rollups/node/src/blockchain_reader/mod.rs @@ -493,7 +493,11 @@ impl BlockchainReader { self.collect_input_events(chain, prev_block, current_block) .await? }; - let inputs = self.number_inputs(&input_events, &merged_sealed_epochs)?; + let inputs = self.number_inputs( + &input_events, + &merged_sealed_epochs, + crate::engine::Structure::PRODUCTION.max_inputs(), + )?; Ok((inputs, sealed_epochs)) } @@ -566,6 +570,7 @@ impl BlockchainReader { &mut self, input_events: &[InputAdded], sealed_epochs: &[Epoch], + max_inputs: u64, ) -> Result> { let last_input = self.storage.last_input()?; // Inputs are numbered here, so a log the provider leaves out of a @@ -586,12 +591,25 @@ impl BlockchainReader { let mut input_events_peekable = input_events.iter().peekable(); for epoch in sealed_epochs { if last_input_epoch_number > epoch.epoch_number { + // Every eagerly split window before the last stored input is + // full. Count backwards from that input to check the seal. + let expected = next_index + - next_input_index_in_epoch + - (last_input_epoch_number - epoch.epoch_number - 1) * max_inputs; + anyhow::ensure!( + expected == epoch.input_index_boundary, + "epoch {} seal ends at {}, stored inputs end at {expected}; \ + this node needs a DaveConsensus that caps each epoch at {max_inputs} inputs", + epoch.epoch_number, + epoch.input_index_boundary + ); continue; } // iterate through newly sealed epochs, fill in the inputs accordingly let inputs_of_epoch = construct_input_ids( epoch.epoch_number, epoch.input_index_boundary, + max_inputs, &mut next_input_index_in_epoch, &mut next_index, &mut input_events_peekable, @@ -601,10 +619,12 @@ impl BlockchainReader { last_input_epoch_number = epoch.epoch_number + 1; } - // all remaining inputs belong to an epoch that's not sealed yet + // Full pending epochs have fixed boundaries even before their seals. + // Split them now so eager execution never overruns the input span. let inputs_of_epoch = construct_input_ids( last_input_epoch_number, u64::MAX, + max_inputs, &mut next_input_index_in_epoch, &mut next_index, &mut input_events_peekable, @@ -730,18 +750,20 @@ fn ensure_complete(head: u64, ingested: Totals, onchain: Totals) -> Result<()> { Ok(()) } -/// Numbers the events of one epoch, up to its input upper bound -/// (`u64::MAX` while open), checking each event's own index against the -/// next expected one and a sealed epoch's end against its bound: a log -/// dropped before the chunk's last one fails the tick before anything is -/// stored. One dropped at the chunk's tail is [`ensure_complete`]'s. +/// Numbers events up to a sealed epoch's input upper bound, or splits full +/// windows into successive epochs while open (`u64::MAX`). Checks each event's +/// own index against the next expected one and a sealed epoch's end against +/// its bound: a log dropped before the chunk's last one fails the tick before +/// anything is stored. One dropped at the chunk's tail is [`ensure_complete`]'s. fn construct_input_ids<'a>( - epoch_number: u64, + mut epoch_number: u64, input_index_boundary: u64, + max_inputs: u64, next_input_index_in_epoch: &mut u64, next_index: &mut u64, input_events_peekable: &mut Peekable>, ) -> Result> { + assert!(max_inputs > 0, "input span must be positive"); let mut inputs = vec![]; while let Some(input_added) = input_events_peekable.peek() { @@ -758,6 +780,15 @@ fn construct_input_ids<'a>( ), } ); + if input_index_boundary == u64::MAX && *next_input_index_in_epoch == max_inputs { + epoch_number += 1; + *next_input_index_in_epoch = 0; + } + anyhow::ensure!( + *next_input_index_in_epoch < max_inputs, + "epoch {epoch_number} exceeds its input span {max_inputs}; \ + this node needs a DaveConsensus that caps each epoch at {max_inputs} inputs" + ); let input = Input { id: InputId { epoch_number, @@ -787,9 +818,8 @@ fn construct_input_ids<'a>( ), } ); + *next_input_index_in_epoch = 0; } - // input index in epoch should be reset when a new epoch starts - *next_input_index_in_epoch = 0; Ok(inputs) } @@ -924,6 +954,132 @@ mod address_book_tests { mod input_numbering_tests { use super::*; + fn sealed(epoch_number: u64, input_index_boundary: u64) -> Epoch { + Epoch { + epoch_number, + input_index_boundary, + root_tournament: Address::ZERO, + block_created_number: 1, + } + } + + fn reader() -> (tempfile::TempDir, BlockchainReader) { + let dir = tempfile::tempdir().unwrap(); + let conn = rusqlite::Connection::open(dir.path().join("db.sqlite3")).unwrap(); + crate::storage::sql::schema::initialize(&conn).unwrap(); + drop(conn); + let book = AddressBook { + app: Address::ZERO, + consensus: Address::ZERO, + tournament_factory: Address::ZERO, + input_box: Address::ZERO, + initial_hash: [0; 32], + genesis_block_number: 0, + }; + let reader = BlockchainReader::new(Storage::new(dir.path()).unwrap(), book, Duration::ZERO); + (dir, reader) + } + + fn ids(inputs: &[Input]) -> Vec<(u64, u64)> { + inputs + .iter() + .map(|input| (input.id.epoch_number, input.id.input_index_in_epoch)) + .collect() + } + + #[test] + fn an_uncapped_late_seal_is_refused_after_eager_splitting() { + let (_dir, mut reader) = reader(); + let inputs = reader + .number_inputs(&events(&[0, 1, 2]), &[sealed(0, 0)], 2) + .unwrap(); + reader + .storage + .insert_consensus_data(1, inputs.iter(), [&sealed(0, 0)].into_iter()) + .unwrap(); + let error = reader + .number_inputs(&[], &[sealed(0, 0), sealed(1, 3)], 2) + .unwrap_err(); + assert!(error.to_string().contains("caps each epoch at 2 inputs")); + } + + #[test] + fn an_uncapped_seal_is_refused_in_the_same_chunk_as_its_inputs() { + let (_dir, mut reader) = reader(); + let error = reader + .number_inputs(&events(&[0, 1, 2]), &[sealed(0, 0), sealed(1, 3)], 2) + .unwrap_err(); + assert!(error.to_string().contains("caps each epoch at 2 inputs")); + } + + #[test] + fn capped_pending_epochs_survive_chunking_late_seals_and_an_empty_epoch() { + let (_dir, mut reader) = reader(); + let inputs = reader + .number_inputs(&events(&[0, 1, 2, 3, 4, 5]), &[sealed(0, 0)], 2) + .unwrap(); + assert_eq!( + ids(&inputs), + [(1, 0), (1, 1), (2, 0), (2, 1), (3, 0), (3, 1)] + ); + reader + .storage + .insert_consensus_data(1, inputs.iter(), [&sealed(0, 0)].into_iter()) + .unwrap(); + + let seals = [sealed(1, 2), sealed(2, 4)]; + let inputs = reader.number_inputs(&events(&[6, 7]), &seals, 2).unwrap(); + assert_eq!(ids(&inputs), [(4, 0), (4, 1)]); + reader + .storage + .insert_consensus_data(2, inputs.iter(), seals.iter()) + .unwrap(); + + let seals = [sealed(3, 6), sealed(4, 8), sealed(5, 8)]; + let inputs = reader.number_inputs(&events(&[8]), &seals, 2).unwrap(); + assert_eq!(ids(&inputs), [(6, 0)]); + reader + .storage + .insert_consensus_data(3, inputs.iter(), seals.iter()) + .unwrap(); + assert_eq!(reader.storage.total_input_count().unwrap(), 9); + for epoch in 0..=6 { + assert!(reader.storage.input_count(epoch).unwrap() <= 2); + } + } + + #[test] + fn several_seals_in_one_chunk_preserve_an_empty_epoch() { + let (_dir, mut reader) = reader(); + let seals = [sealed(0, 0), sealed(1, 2), sealed(2, 2), sealed(3, 4)]; + let inputs = reader + .number_inputs(&events(&[0, 1, 2, 3, 4]), &seals, 2) + .unwrap(); + assert_eq!(ids(&inputs), [(1, 0), (1, 1), (3, 0), (3, 1), (4, 0)]); + reader + .storage + .insert_consensus_data(1, inputs.iter(), seals.iter()) + .unwrap(); + assert_eq!(reader.storage.input_count(2).unwrap(), 0); + } + + #[test] + fn production_input_span_splits_before_the_first_unrepresentable_index() { + let max_inputs = crate::engine::Structure::PRODUCTION.max_inputs(); + let (mut within, mut next) = (max_inputs - 1, max_inputs - 1); + let events = events(&[max_inputs - 1, max_inputs]); + let inputs = construct_input_ids( + 1, + u64::MAX, + max_inputs, + &mut within, + &mut next, + &mut events.iter().peekable(), + ) + .unwrap(); + assert_eq!(ids(&inputs), [(1, max_inputs - 1), (2, 0)]); + } + fn events(indices: &[u64]) -> Vec { indices .iter() @@ -943,6 +1099,7 @@ mod input_numbering_tests { construct_input_ids( 3, bound.unwrap_or(u64::MAX), + crate::engine::Structure::PRODUCTION.max_inputs(), &mut in_epoch, &mut next, &mut events.iter().peekable(), diff --git a/docs/dimensioning.md b/docs/dimensioning.md index 8f695742..f6ce4df3 100644 --- a/docs/dimensioning.md +++ b/docs/dimensioning.md @@ -60,24 +60,52 @@ loses the join: a child its match seals, or a new epoch's root, which also waits for the runner to execute the inputs the stall held back. One reference price. The cheapest loss the model accepts is the -aggregate-throughput attack (below): inputs that outrun the honest runner. -The runner pays a fixed overhead per input, accepted or rejected, whatever the -application does (node-architecture.md, Storage). At the 0.14-0.2 s measured -on macOS, a root window of about a week (`C + T + 2G`, less a finality) holds -3-4.4 million inputs, so an epoch with more outruns the runner: about -0.9-1.3e11 gas at 30k gas per input batched through a contract, 90-1,300 ETH -on Ethereum at 1-10 gwei but only 0.09-1.3 ETH on an L2 at 0.001-0.01 gwei, -where input-priced attacks cost about a thousand times less and bonds do -not. An attack that fills a resource (disk, links, memory, -provider quota) for less than that is a wall to close; a guard against one -that costs more, or that is out of model, is a door not worth its code. The -figures rest on one machine's overhead on emulator v0.22.0 and stay -provisional until the Linux measurement, with the runner and a dispute -sharing the host and the disk under pressure (todo.md). A faster runner -raises the price by about four times at most: below about 36 ms per input (a -week over 2^24) the input span binds first, at about 5e11 gas, provided the -snapshot disk keeps up. The next emulator release's cold-load fix (about -2.5 ms per input, preliminary) would put the node there. +aggregate-throughput attack (below): inputs that outrun the honest runner. The +runner pays a fixed overhead per input, accepted or rejected, whatever the +application does (node-architecture.md, Storage). The root join may land up to +all of `C` past its own `G`; the rest of the root allowance is the reserve and +the refills the correct clock still needs after it (ClockBudgets). At the +0.14-0.2 s measured on macOS, that `C + G` (a week and five minutes), less a +finality, holds 3-4.3 million inputs, so an epoch with more outruns the +runner: about 0.9-1.3e11 gas at roughly 30k gas per input batched through a +contract, 90-1,300 ETH on Ethereum at 1-10 gwei or 0.09-1.3 ETH on an L2 at +0.001-0.01 gwei, where input-priced attacks cost about a thousand times less +and bonds do not. The testnets, with `C` of 8 hours and free gas, have no such +price. An attack that fills a resource (disk, links, memory, provider quota) +for less than that is a wall to close; one that costs more, or is out of +model, is a door, and a guard against it is not worth its code. These figures +use emulator v0.22.0 on one macOS machine and stay provisional until the Linux +measurement, with the runner and a dispute sharing cores and the disk under +pressure (todo.md). + +The input cap bounds what one seal owes the runner: at most `2^24 x t`, where +`t` is the runner's whole time per input, the node's overhead plus the +application's cheapest dismissal. Below `(C + G) / 2^24`, about 36 ms per +input where `C` is a week and 1.7 ms on the testnets, inputs alone no longer +outrun the root join, provided the snapshot disk keeps up (a full epoch keeps +2^24 / gap boundaries, 262,144 at the default gap). That closes the loss +bought with gas alone, not the drain: a full epoch still draws up to +`2^24 x t` from that epoch's `C`, less the time the chain takes to carry it, +and the ledger counts that drain like any other delay. At the cold-load fix's +preliminary 2.5 ms the drain is at most about 11.7 hours (7% of a week); +Ethereum carries at most about 165 such inputs a second, so a runner that fast +keeps up as they land there (a lead: gas limits move). Below the threshold the +reference price must be re-derived from the next wall, the drain combined with +other delays or the snapshot disk, not re-measured (todo.md). + +Inputs past the cap wait in order for later epochs. The runner reaches a spill +epoch only after finishing the one before, and each seal comes at least a root +allowance after the last, so below the threshold a spill epoch's backlog at +its seal is never more than the first's, and none below about 18 ms per input +where `C` is a week (half the root allowance over 2^24). The wait itself holds +no honest action past its `G`, so it draws nothing from `C`: it is application +latency, which the model does not bound. Each full epoch ahead of an input +delays its settlement by at least a root allowance (a week plus 140 minutes +where `C` is a week), plus the claim staging period unless every sentry +agrees. A flood buys that for every later input per 5.06e11 gas (2^24 x +30,184: 506-5,064 ETH on Ethereum at 1-10 gwei, 0.51-5.1 ETH on an L2), 3.9 to +5.6 times the reference price, so by the rule above it is a door: recorded +here, not guarded. Evenly distributed effort. The trusted application's work arrives spread over the epoch enough for the runner to keep up: one input that runs for days at @@ -104,12 +132,11 @@ to the average case. slot; the heaviest instruction measured on v0.21, `sfence.vma`, takes 17,595 usteps, about 60 times below it (one workload on one machine; re-measure with the validator-grade run, todo.md). - The input span differs in kind: no single legitimate event exceeds - it, only a flood of roughly 5e11 gas of inputs in one epoch. Past - it the contracts never feed the tail, and the Rust node panics - instead. It is an economic bound only on Ethereum (500-5,000 ETH at - 1-10 gwei; 0.5-5 ETH on a cheap L2), and the reference price binds - first: the runner falls behind long before the span. + DaveConsensus enforces the input span structurally: sealing takes at most + 2^24 pending inputs, leaving any excess for later epochs. This protects + representation and prevents finalizing inputs the step cannot feed. Input + floods below the cap remain priced against the reference price; keeping up + with them is the separate throughput assumption. - Clocks (the commitment budget `T`, responseBudget, the root slowdown budget, and the strides derived from them): these price aggregates - sums of per-step costs over whole gaps. Rare heavy @@ -159,7 +186,7 @@ adversary will find it. |---|---|---|---| | uarch span (2^20 usteps) | single event | any - honest code hits it | worst case | | barch span per input (2^48) | single event | app + input | worst case | -| input span per epoch (2^24) | input flood | anyone, at roughly 5e11 gas per epoch | beyond the reference price: the throughput loss comes first | +| input span per epoch (2^24) | input flood | anyone, about 5.06e11 gas for a full epoch | enforced at sealing; excess delays later inputs | | leaf-level dense build within the commitment budget | aggregate | trusted app | average density | | root slowdown (level-0 sampling overhead) | aggregate | trusted app | average | | positioning through an input's prefix | aggregate | trusted app (the per-input compute contract, below) | app profile | @@ -229,29 +256,29 @@ past the other out-of-model bounds, the protocol promises nothing. The per-input contract bounds one input. The root join window bounds their sum. The root allowance `C + 2G + (L - 1)(T + 2G)` holds no term for building -the level-0 commitment, because the node computes it while the inputs -arrive, so the honest root join assumes the node has kept up with the -epoch's inputs when the epoch seals. The input author is untrusted, and -sealing takes every input posted before the acceptance block, so an epoch's -total compute is adversarial even when every input respects the per-input -contract. A backlog larger than the root window at the seal makes the honest -root join miss closure, and a Sybil's claim then wins the root unopposed; the -cost is the inputs' gas and one bond, far below the 2^24 flood -(reviews/2026-10-13-prt-cyber-verification, L-01). The assumption is -therefore the app's second contract: the app digests, within the root -window, any input stream the chain can deliver in one epoch, with margin for -the node's catch-up after a burst. An app meets it the way it meets the -per-input contract, by design: it meters effort per input and prices it, -with prices that rise as the epoch's effort is consumed, so that underpriced -inputs are dismissed cheaply. Inputs that execute nearly instantly do not make -it slack: the node pays its fixed per-input overhead on every input, accepted -or rejected, so on emulator v0.22.0 the runner tops out near a handful of -inputs per second whatever the application does, and an app's pricing can dismiss an input but -not that overhead. The contract therefore binds for every application, at the -reference price above. The trigger, sealing with no input cap or minimum -notice, belongs to DaveConsensus and is priced in that campaign; a smaller -per-input overhead is the node's lever, worth at most a factor of about -four (One reference price, above). +the level-0 commitment, because the node computes it while the inputs arrive, +so the honest root join assumes the node has kept up with the epoch's inputs +when the epoch seals. The input author is untrusted, and sealing takes up to +2^24 pending inputs at the acceptance block, so an epoch's total compute is +adversarial even when every input respects the per-input contract. A backlog +at the seal longer than the root join's window (One reference price, above) +lets a Sybil's claim win the root: the join lands too late to keep the reserve +and the refills a forced delegation needs, or misses closure. The cost is the +inputs' gas and at most a bond per level, without needing to fill the input +span (reviews/2026-10-13-prt-cyber-verification, L-01). The assumption is +therefore the app's second contract: the app digests, within the root window, +any input stream the chain can deliver in one epoch, with margin for the +node's catch-up after a burst. An app meets it the way it meets the per-input +contract, by design: it meters effort per input and prices it, with prices +that rise as the epoch's effort is consumed, so that underpriced inputs are +dismissed cheaply. Inputs that execute nearly instantly do not make it slack: +the node pays its fixed per-input overhead on every input, accepted or +rejected, so on emulator v0.22.0 the runner tops out near a handful of inputs +per second whatever the application does, and an app's pricing can dismiss an +input but not that overhead. The contract therefore binds for every +application, at the reference price above, while t exceeds the threshold (One +reference price, above); below it the cap bounds each epoch's work and its +drain on C. Sealing gives no minimum notice either way. ## Base-layer censorship model diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 5647a6d8..3eb873ac 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -26,6 +26,20 @@ transaction that seals an epoch, the tournament factory's before `EpochSealed` (pinned by `DaveAppFactory.t.sol`), so a log reader holds it until the `EpochSealed` that names the tournament. +Sealing includes at most 2^24 pending inputs, derived from the Machine's +input-coordinate width, both at construction and at acceptance. Excess +inputs remain pending for later epochs. Only the inputs in accepted epochs +become finalized; a backlog does not mark its unprocessed tail finalized. +The cap bounds input count; execution time still depends on the app and runner. +This node requires a capped consensus. Earlier uncapped deployments are +unsupported: the node stalls at the first seal past the span. +The reader also splits full pending epochs at this boundary before their +seals arrive, so eager execution cannot assign an unrepresentable input +index to the open epoch. Every later seal is compared with the stored split; +a disagreement fails ingestion and names the capped-consensus requirement. +The runner waits for a seal before rolling to the next epoch. The Lua client +selects inputs directly from each sealed epoch's lower and upper bounds. + ## Epoch states, from the node's perspective 1. Open: inputs are accumulating in the InputBox; the current epoch has no @@ -77,6 +91,10 @@ template already terminal is refused at startup instead: the engine starts every epoch from a state awaiting input, so the node cannot defend such an application at all, and its epochs are the guardian's alone. +Capped epoch rollover also requires clients to select +inputs by the sealed index bounds, including inputs carried from earlier +blocks. + Settlement never touches the tournament's bond path: staging and acceptance move no value, and nothing on the consensus path calls `tryRecoveringBond`. No recipient code runs inside a settlement transaction. diff --git a/docs/node-architecture.md b/docs/node-architecture.md index 1f58b969..0a8028e6 100644 --- a/docs/node-architecture.md +++ b/docs/node-architecture.md @@ -165,33 +165,29 @@ safety, not hygiene: a terminal application panicked every node at the roll, which would have let a fabricated claim win uncontested. -Apart from the accepted halt and the trust model below, chain data -reaches no panic on the dispute path. Once a wrong epoch has settled, the Hero -stops the node (exit 30) on a root that starts from a state it did not -compute, an already-defeated protocol; it reaches that root only after the -settled epoch's refunds and sentry vote. Chain-reported coordinates reach the engine only -through validators that accept everything the contracts produce. The observer -checks each match against its tournament's descriptor (height, position, -alignment, cycle); a bisecting match must stand above height 1, since -advancing opens the children of the node below the contested one; a level's -base cycle must be aligned to its span, and a delegated child must sit one -level below its parent, on one of the parent's leaves; and every level's -stride and height must match the pinned table, which tiles the machine -coordinate. -Nothing checks that the root's base cycle is 0; the trusted factory sets it. -The observer's standing decoder likewise fronts its own `expect` on -parent-match data. The engine's asserts behind them -(`LevelCoords`, `Quartet`, the ruler) are tripwires for node bugs, so a prune -must keep these validators or first turn the asserts they front into errors. -The settle asserts compare the local result with one block's views after the -node's own win (epoch-lifecycle.md, settlement invariant), so they too fire -only on a node bug or corrupt local state. - -One input-reachable halt is accepted rather than defended. More than 2^24 -inputs in one epoch: the contracts handle it (they never feed the tail) but -the node panics, and reaching it takes a flood of about 5e11 gas, which costs -more than the aggregate-throughput loss that comes first (dimensioning.md, the -reference price); a defect accepted on cost. No input overruns its window: +Inside the trust model below, chain data reaches no panic on the dispute path. +Once a wrong epoch has settled, the Hero stops the node (exit 30) on a root +that starts from a state it did not compute, an already-defeated protocol; it +reaches that root only after the settled epoch's refunds and sentry vote. +Chain-reported coordinates reach the engine only through validators that +accept everything the contracts produce. The observer checks each match +against its tournament's descriptor (height, position, alignment, cycle); a +bisecting match must stand above height 1, since advancing opens the children +of the node below the contested one; a level's base cycle must be aligned to +its span, and a delegated child must sit one level below its parent, on one of +the parent's leaves; and every level's stride and height must match the pinned +table, which tiles the machine coordinate. Nothing checks that the root's base +cycle is 0; the trusted factory sets it. The observer's standing decoder +likewise fronts its own `expect` on parent-match data. The engine's asserts +behind them (`LevelCoords`, `Quartet`, the ruler) are tripwires for node bugs, +so a prune must keep these validators or first turn the asserts they front +into errors. The settle asserts compare the local result with one block's +views after the node's own win (epoch-lifecycle.md, settlement invariant), so +they too fire only on a node bug or corrupt local state. + +DaveConsensus seals at most 2^24 inputs per epoch, leaving excess inputs +pending for later epochs. This protects the node's coordinate limit without +depending on the cost of an input flood. No input overruns its window: delivery sets the step's per-input budget of 2^48 - 1 big cycles, so an input that exhausts it leaves the app at an overflow fixed point, which the node defends, and the engine's window tripwire (`engine/ruler.rs`) fires only on an diff --git a/docs/plans/audit-readiness.md b/docs/plans/audit-readiness.md index bcaf2b5d..034d00c1 100644 --- a/docs/plans/audit-readiness.md +++ b/docs/plans/audit-readiness.md @@ -68,7 +68,9 @@ dispute-game.md: - The leaf-proof refund is not a universal proof-class gas ceiling. - Sentry agreement does not prove a result correct; there is no on-chain recovery for a lost sentry-manager key. -- More than 2^24 inputs in one epoch is out of model (dimensioning.md). +- Nothing bounds the delay a backlog past the input span adds to later + inputs; the aggregate-throughput assumption remains separate + (dimensioning.md). ## Pre-audit items diff --git a/docs/todo.md b/docs/todo.md index 48e013b5..02148c1a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -26,10 +26,6 @@ carry no backlog (reviews/README.md). the disk per snapshot at the default gap. The default gap and the aggregate-throughput price follow from them. (measurements/constants.md, dimensioning.md) -- Price input volume against the root window in the DaveConsensus campaign: - sealing takes every input before the acceptance block with no cap or - minimum notice, and the app's aggregate-throughput contract is what makes - the honest root join land. (dimensioning.md) - On the release candidate's staging, the first on two levels, stop and restart the node mid leaf build: three-level releases never took the span-by-span, resumable tall build, which only the spec and engine-machine @@ -50,7 +46,6 @@ carry no backlog (reviews/README.md). spots. (computation-hash.md) ## Before mainnet funds - - Rehearse the backstop for an undetected node bug: on a throwaway Sepolia application, hold the node out past join closure, let a fabricated claim win, and have the guardian foreclose inside the claim staging period. Then @@ -68,9 +63,10 @@ carry no backlog (reviews/README.md). - On the emulator release that fixes the cold load of the hash-tree cache file (in progress upstream, not a blocker: about 2.5 ms per input in a preliminary implementation, with validation on an untrusted load), bump - the emulator and re-measure the per-input overhead and the reference - price. Check whether that validation is the supported `cm_verify_hash_tree` - the item below waits on (a lead). (node-architecture.md, Storage) + the emulator, re-measure the per-input overhead and re-derive the reference + price, which stops binding below about 36 ms per input. Check whether that + validation is the supported `cm_verify_hash_tree` the item below waits on + (a lead). (node-architecture.md, Storage) - Ask for EMLINK in `os::clone_file`'s copy fallback, low priority: a clone hard-links every read-only file (each snapshot's PMA file), so on btrfs and ext4 live snapshots stop at about 65k links per inode, and the runner and @@ -115,7 +111,9 @@ carry no backlog (reviews/README.md). (runbooks/submit-relay.md) ## Contracts and assurance - +- Coordinate reference-node capped epoch rollover: select inputs by sealed + index bounds, including inputs carried from earlier blocks. + (epoch-lifecycle.md) - Review the honest-survival argument (dimensioning.md, "Honest survival under the budgets") by someone other than its author, and decide whether the two-level search gates the external audit or is handed over as @@ -147,10 +145,6 @@ carry no backlog (reviews/README.md). string, which cannot tell the release from its pre-releases or from an unreleased upstream build. (test-harness.md) -- A reader unit test for one log chunk carrying several seals around an - empty epoch: only the ignored anvil test crosses epochs, one block per - chunk, and a regression would misnumber inputs on every catch-up. - (node-architecture.md, Chain ingestion stance) - Optional: a steered divergence point for the harness's tail adversary, to dispute active spans in-process. (test-harness.md) - Two honest nodes on different keys defending one epoch against one diff --git a/prt/client-lua/computation/machine.lua b/prt/client-lua/computation/machine.lua index 021eda99..9c7c3d72 100644 --- a/prt/client-lua/computation/machine.lua +++ b/prt/client-lua/computation/machine.lua @@ -212,6 +212,7 @@ function Machine:rollup_commitment(log2_stride, inputs) > (log2_stride - cartesi.ROLLUP_LOG2_MAX_UARCH_CYCLES_PER_MCYCLE)) local max_input_count = 1 << cartesi.ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH + assert(#inputs <= max_input_count, "epoch exceeds the input span") local builder = MerkleBuilder:new() local initial_hash = self:state().root_hash diff --git a/prt/client-lua/tests/computation_test.lua b/prt/client-lua/tests/computation_test.lua index 23a7baac..9037af28 100644 --- a/prt/client-lua/tests/computation_test.lua +++ b/prt/client-lua/tests/computation_test.lua @@ -337,6 +337,29 @@ return { assert(ok, failure) end), + Test.case("epochs above the input span fail instead of truncating", function() + local original = cartesi.ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH + cartesi.ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH = 3 + local ok, failure = xpcall(function() + for _, count in ipairs { 8, 9, 13 } do + local inputs = {} + for i = 1, count do + inputs[i] = "0x01" + end + local machine = wrapped_machine { halted = true } + if count == 8 then + machine:rollup_commitment(44, inputs) + else + Test.error_like("epoch exceeds the input span", function() + machine:rollup_commitment(44, inputs) + end) + end + end + end, debug.traceback) + cartesi.ROLLUP_LOG2_MAX_ADVANCE_STATES_PER_EPOCH = original + assert(ok, failure) + end), + Test.case("a terminal epoch is one fixed point", function() local machine = wrapped_machine { halted = true } From 088710286d2d2f643a858e8a85c6aa02487db098 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:14:58 +0800 Subject: [PATCH 2/7] fix(contracts)!: bind application addresses to consensus settings A caller could use newDaveApp with a victim's application parameters and attacker-chosen consensus settings to occupy the predicted app address. Bind the salt to every consensus setting in both deploy and predict. Retain the shared ApplicationFactory. Its direct-deployment squatting path remains open pending the reserved API in cartesi/rollups-contracts#561; do not send value before DaveAppCreated. If no release ships it before mainnet funds or the audit scope freeze, choose between waiting and creating Application directly. Test different settings, the identical pair and fuzzed address binding. Predicted addresses move. Partially addresses CTR-1. --- CHANGELOG.md | 7 +- cartesi-rollups/contracts/AGENTS.md | 14 +++- .../contracts/src/DaveAppFactory.sol | 18 ++++- .../contracts/src/IDaveAppFactory.sol | 3 + .../test/DaveAppFactorySquatting.t.sol | 76 +++++++++++++++++++ docs/epoch-lifecycle.md | 6 ++ docs/plans/audit-readiness.md | 3 + docs/todo.md | 6 ++ 8 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 cartesi-rollups/contracts/test/DaveAppFactorySquatting.t.sol diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc7dd89..cccd0885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -A new deployment generation: the consensus bytecode +A new deployment generation: the consensus and application-factory bytecode changes, moving deterministic deployment addresses. This node requires a DaveConsensus that caps each epoch at the Machine's input span; earlier uncapped deployments are unsupported: the node stalls at the first seal @@ -12,6 +12,11 @@ past the span. - DaveConsensus seals at most 2^24 pending inputs, retaining excess inputs for later epochs. Only processed, accepted inputs become finalized. +- DaveAppFactory binds application addresses to the full consensus settings, + so other settings cannot occupy a predicted application address. Anyone can + still occupy one through the shared ApplicationFactory, which makes + `newDaveApp` revert: do not send value to a predicted address before + `DaveAppCreated`. ### Node diff --git a/cartesi-rollups/contracts/AGENTS.md b/cartesi-rollups/contracts/AGENTS.md index f24ce36a..c309428d 100644 --- a/cartesi-rollups/contracts/AGENTS.md +++ b/cartesi-rollups/contracts/AGENTS.md @@ -65,7 +65,7 @@ truth. consensus, migrates the app's validator to it, renounces ownership. All steps run inside one `newDaveApp` call, so there is no partial-completion window; `calculateDaveAppAddress` predicts both - addresses for pre-funding and configuration. + addresses for configuration (see the squatting non-claim before funding). - `src/ISentryErrors.sol` - sentry error selectors. ## Invariants to protect @@ -90,6 +90,10 @@ truth. padding rule the off-chain commitment builders mirror (docs/computation-hash.md). Changing it desynchronizes every commitment producer. +- The application address binds the template, withdrawal configuration and + every consensus setting, so `newDaveApp` from any caller can land a + predicted address only with the pair that address was predicted for. + ## Change guardrails - `MAX_INPUTS_PER_EPOCH` must equal the node's `Structure::max_inputs`. @@ -126,7 +130,8 @@ Honest coverage statement: `test/DaveConsensusCapacity.t.sol` uses a mocked fixture to cover capped epoch storage and events, backlog rollover and boundary counts. `test/DaveAppFactory.t.sol` exercises construction, stage and accept, `wasInputFinalized`, staging reverts on bad proofs, root failure, sentry -rotation and a winner exhausting the payment callback. The leaf-proof gas FFI fixture +rotation and a winner exhausting the payment callback. The factory tests pin +the consensus-settings address binding. The leaf-proof gas FFI fixture (`test/gas/`), node integration tests and e2e smoke add coverage. `test/NodeProofs.t.sol` and `test/NodeWitnesses.t.sol` check that the contracts' verifiers and the step accept the node's proofs and witnesses, without running @@ -142,3 +147,8 @@ tests rather than assuming the capacity fixture covers it. delay. The staging period is a reaction window, not a fraud proof. - Epochs have no inherent duration: sealing bounds are sampled at acceptance time, so epoch length is a function of settlement cadence. +- The shared ApplicationFactory's salt does not bind its caller, so anyone + can occupy a predicted application address with an application that has + no validator. `newDaveApp` then reverts for those settings and salt, and + value sent there first is stranded. Do not send value to a predicted + address before `DaveAppCreated` (docs/todo.md). diff --git a/cartesi-rollups/contracts/src/DaveAppFactory.sol b/cartesi-rollups/contracts/src/DaveAppFactory.sol index 050bdbca..9afdd77f 100644 --- a/cartesi-rollups/contracts/src/DaveAppFactory.sol +++ b/cartesi-rollups/contracts/src/DaveAppFactory.sol @@ -39,7 +39,9 @@ contract DaveAppFactory is IDaveAppFactory { WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external override returns (IApplication appContract, IDaveConsensus daveConsensus) { - appContract = _newApplication(templateHash, withdrawalConfig, salt); + appContract = _newApplication( + templateHash, withdrawalConfig, _applicationSalt(claimStagingPeriod, sentryManager, sentries, salt) + ); daveConsensus = _newDaveConsensus(address(appContract), templateHash, claimStagingPeriod, sentryManager, sentries, salt); appContract.migrateToOutputsMerkleRootValidator(daveConsensus); @@ -55,7 +57,9 @@ contract DaveAppFactory is IDaveAppFactory { WithdrawalConfig calldata withdrawalConfig, bytes32 salt ) external view override returns (address appContractAddress, address daveConsensusAddress) { - appContractAddress = _calculateApplicationAddress(templateHash, withdrawalConfig, salt); + appContractAddress = _calculateApplicationAddress( + templateHash, withdrawalConfig, _applicationSalt(claimStagingPeriod, sentryManager, sentries, salt) + ); daveConsensusAddress = _calculateDaveConsensusAddress( appContractAddress, templateHash, claimStagingPeriod, sentryManager, sentries, salt ); @@ -103,6 +107,16 @@ contract DaveAppFactory is IDaveAppFactory { ); } + function _applicationSalt( + uint256 claimStagingPeriod, + address sentryManager, + address[] calldata sentries, + bytes32 salt + ) internal pure returns (bytes32) { + // Different consensus settings must never occupy the same application address. + return keccak256(abi.encode(claimStagingPeriod, sentryManager, sentries, salt)); + } + /// @notice Calculates the address of a `DaveConsensus` contract. function _calculateDaveConsensusAddress( address appContract, diff --git a/cartesi-rollups/contracts/src/IDaveAppFactory.sol b/cartesi-rollups/contracts/src/IDaveAppFactory.sol index 1d25e4ff..3af2afda 100644 --- a/cartesi-rollups/contracts/src/IDaveAppFactory.sol +++ b/cartesi-rollups/contracts/src/IDaveAppFactory.sol @@ -54,6 +54,9 @@ interface IDaveAppFactory is IApplicationFactoryErrors, ISentryErrors { /// @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 + /// @dev Do not send value to a predicted address before `DaveAppCreated`: + /// anyone can occupy the application address through the shared + /// application factory, with no validator, making `newDaveApp` revert. function calculateDaveAppAddress( bytes32 templateHash, uint256 claimStagingPeriod, diff --git a/cartesi-rollups/contracts/test/DaveAppFactorySquatting.t.sol b/cartesi-rollups/contracts/test/DaveAppFactorySquatting.t.sol new file mode 100644 index 00000000..0409b078 --- /dev/null +++ b/cartesi-rollups/contracts/test/DaveAppFactorySquatting.t.sol @@ -0,0 +1,76 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +pragma solidity ^0.8.30; + +import {WithdrawalConfig} from "cartesi-rollups-contracts-3.0.0/src/common/WithdrawalConfig.sol"; +import {IApplication} from "cartesi-rollups-contracts-3.0.0/src/dapp/IApplication.sol"; +import {RollupsTest} from "cartesi-rollups-contracts-3.0.0/test/util/RollupsTest.sol"; +import {ITournament} from "prt-contracts/ITournament.sol"; +import {ITournamentFactory} from "prt-contracts/ITournamentFactory.sol"; +import {DaveAppFactory} from "src/DaveAppFactory.sol"; +import {IDaveConsensus} from "src/IDaveConsensus.sol"; + +contract DaveAppFactorySquattingTest is RollupsTest { + DaveAppFactory factory; + ITournamentFactory constant TOURNAMENT_FACTORY = ITournamentFactory(address(0x100)); + bytes32 constant TEMPLATE = bytes32(uint256(1)); + + function setUp() external { + vm.mockCall( + address(TOURNAMENT_FACTORY), + abi.encodeWithSelector(ITournamentFactory.instantiate.selector), + abi.encode(ITournament(address(0x200))) + ); + factory = new DaveAppFactory(_contracts.core.inputBox, _contracts.core.applicationFactory, TOURNAMENT_FACTORY); + } + + function testAnotherCallerCanOnlyDeployTheExactPredictedDavePair() external { + WithdrawalConfig memory config; + address[] memory sentries = new address[](0); + (address predicted, address expectedConsensus) = + factory.calculateDaveAppAddress(TEMPLATE, 10, address(0), sentries, config, 0); + vm.deal(predicted, 1 ether); + vm.prank(address(0xbad)); + (IApplication app, IDaveConsensus consensus) = factory.newDaveApp(TEMPLATE, 10, address(0), sentries, config, 0); + assertEq(address(app), predicted); + assertEq(address(consensus), expectedConsensus); + assertEq(address(app.getOutputsMerkleRootValidator()), expectedConsensus); + assertEq(app.owner(), address(0)); + assertEq(consensus.getClaimStagingPeriod(), 10); + assertEq(predicted.balance, 1 ether); + } + + function testDifferentConsensusSettingsCannotSquatTheSameApplication() external { + WithdrawalConfig memory config; + address[] memory sentries = new address[](0); + (address victim, address expectedConsensus) = + factory.calculateDaveAppAddress(TEMPLATE, 10, address(0), sentries, config, 0); + (address attacker,) = factory.calculateDaveAppAddress(TEMPLATE, 0, address(0), sentries, config, 0); + assertNotEq(victim, attacker); + vm.prank(address(0xbad)); + factory.newDaveApp(TEMPLATE, 0, address(0), sentries, config, 0); + (IApplication app, IDaveConsensus consensus) = factory.newDaveApp(TEMPLATE, 10, address(0), sentries, config, 0); + assertEq(address(app), victim); + assertEq(address(consensus), expectedConsensus); + } + + function testFuzzAllConsensusSettingsAreBoundToTheApplicationAddress(uint64 period, address manager, bytes32 salt) + external + view + { + WithdrawalConfig memory config; + address[] memory sentries = new address[](0); + (address original,) = factory.calculateDaveAppAddress(TEMPLATE, period, manager, sentries, config, salt); + (address otherPeriod,) = + factory.calculateDaveAppAddress(TEMPLATE, uint256(period) + 1, manager, sentries, config, salt); + assertNotEq(original, otherPeriod); + (address otherManager,) = + factory.calculateDaveAppAddress(TEMPLATE, period, address(uint160(manager) ^ 1), sentries, config, salt); + assertNotEq(original, otherManager); + sentries = new address[](1); + sentries[0] = address(1); + (address otherSentries,) = factory.calculateDaveAppAddress(TEMPLATE, period, manager, sentries, config, salt); + assertNotEq(original, otherSentries); + } +} diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 3eb873ac..070f97cb 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -40,6 +40,12 @@ a disagreement fails ingestion and names the capped-consensus requirement. The runner waits for a seal before rolling to the next epoch. The Lua client selects inputs directly from each sealed epoch's lower and upper bounds. +DaveAppFactory's application address is a function of the template, withdrawal +configuration and full consensus settings. Use its calculator to predict +addresses, but do not send value to one before `DaveAppCreated`: anyone can +occupy it through the shared ApplicationFactory with an application that has +no validator (cartesi-rollups/contracts/AGENTS.md). + ## Epoch states, from the node's perspective 1. Open: inputs are accumulating in the InputBox; the current epoch has no diff --git a/docs/plans/audit-readiness.md b/docs/plans/audit-readiness.md index 034d00c1..0e33f3ba 100644 --- a/docs/plans/audit-readiness.md +++ b/docs/plans/audit-readiness.md @@ -71,6 +71,9 @@ dispute-game.md: - Nothing bounds the delay a backlog past the input span adds to later inputs; the aggregate-throughput assumption remains separate (dimensioning.md). +- Anyone can occupy a predicted application address through the shared + ApplicationFactory, which blocks that `newDaveApp` (cartesi/rollups-contracts#561; + docs/todo.md, Before mainnet funds). ## Pre-audit items diff --git a/docs/todo.md b/docs/todo.md index 02148c1a..9e862c27 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -46,6 +46,12 @@ carry no backlog (reviews/README.md). spots. (computation-hash.md) ## Before mainnet funds + +- Adopt `newReservedApplication` and its reserved address calculator once a + rollups-contracts release ships cartesi/rollups-contracts#561, retaining the + consensus-bound salt. If none ships before the first mainnet app or audit + scope freeze, choose between waiting and creating Application directly. + (cartesi-rollups/contracts/AGENTS.md) - Rehearse the backstop for an undetected node bug: on a throwaway Sepolia application, hold the node out past join closure, let a fabricated claim win, and have the guardian foreclose inside the claim staging period. Then From 8c0111af5da3af9683d0638e6a318de0eaa23886 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:14:58 +0800 Subject: [PATCH 3/7] test(node): defend terminal and nonstandard guests through contracts The underlying terminal-epoch, HTIF-read and startup-sweep fixes are already on main. Pin their behavior without claiming new fixes: defend a halted guest through a leaf proof across restarts, stage an oversized yield through settlement, and assert that initial seeding publishes its boundary after sweeping stale staging. Share the tiny guest builder with the engine tests and use one nonstandard-yield world. Keep the existing split-any-error RPC policy under provider range codes, including Alchemy's -32600. Covers SLN-3, SEAM-2, SLN-59 and the SLN-56 regression. --- cartesi-rollups/node/src/chain.rs | 24 +++++ .../node/src/engine/machine_stf.rs | 59 +++--------- cartesi-rollups/node/src/harness/mod.rs | 23 ++++- cartesi-rollups/node/src/harness/tests.rs | 91 +++++++++++++++++++ cartesi-rollups/node/src/lib.rs | 2 + cartesi-rollups/node/src/storage/open.rs | 3 +- cartesi-rollups/node/src/test_machine.rs | 54 +++++++++++ docs/epoch-lifecycle.md | 10 +- docs/test-harness.md | 9 +- docs/todo.md | 9 +- 10 files changed, 222 insertions(+), 62 deletions(-) create mode 100644 cartesi-rollups/node/src/test_machine.rs diff --git a/cartesi-rollups/node/src/chain.rs b/cartesi-rollups/node/src/chain.rs index 69bbfd79..f67d313a 100644 --- a/cartesi-rollups/node/src/chain.rs +++ b/cartesi-rollups/node/src/chain.rs @@ -383,6 +383,30 @@ mod tests { assert_eq!(chain.latest_age().await.unwrap(), std::time::Duration::ZERO); } + #[tokio::test] + async fn provider_range_codes_split_even_when_the_code_names_a_bad_request() { + for code in [35, -32047, -32700, -32600, -32601, -32602] { + let (provider, rpc, requests) = recording_provider(); + let chain = Chain::new(provider); + rpc.push_failure(alloy::rpc::json_rpc::ErrorPayload { + code, + message: "Under the Free tier plan, you can make eth_getLogs requests with up to a 10 block range".into(), + data: None, + }); + rpc.push_success(&Vec::::new()); + rpc.push_success(&Vec::::new()); + assert!( + chain + .decoded_logs::(Address::ZERO, None, 0, 15) + .await + .unwrap() + .is_empty() + ); + assert_eq!(log_ranges(&requests), [(0, 15), (0, 7), (8, 15)]); + assert!(rpc.read_q().is_empty()); + } + } + /// The `[fromBlock, toBlock]` of every get_logs request, in order. fn log_ranges(requests: &super::recording::Requests) -> Vec<(u64, u64)> { log_requests(requests) diff --git a/cartesi-rollups/node/src/engine/machine_stf.rs b/cartesi-rollups/node/src/engine/machine_stf.rs index 1903baa3..86a40c4f 100644 --- a/cartesi-rollups/node/src/engine/machine_stf.rs +++ b/cartesi-rollups/node/src/engine/machine_stf.rs @@ -825,7 +825,7 @@ mod tests { use crate::engine::constants::UARCH_MASK_TO_BARCH; use crate::engine::ruler::Run; use crate::merkle::MerkleBuilder; - use cartesi_machine::constants::ar::{RAM_START, TX_LOG2_SIZE}; + use cartesi_machine::constants::ar::TX_LOG2_SIZE; use cartesi_machine::constants::cmio::tohost::manual::TX_EXCEPTION; use cartesi_machine::constants::rollup::{ LOG2_MAX_ADVANCE_STATES_PER_EPOCH, LOG2_MAX_MCYCLES_PER_ADVANCE_STATE, @@ -927,48 +927,14 @@ mod tests { Ok(()) } - /// Clears fromhost, yields x19, then yields x20 each time it resumes. - /// With imcyclemax = 3 the x19 yield executes on the input budget's - /// last cycle. - const BUDGET_SEAM_PROGRAM: [u32; 5] = [ - 0x4000_82b7, // lui t0, 0x40008 (HTIF base) - 0x0002_b423, // sd zero, 8(t0) (fromhost) - 0x0132_b023, // sd x19, 0(t0) (tohost) - 0x0142_b023, // sd x20, 0(t0) (tohost) - 0xffdf_f06f, // j -4 - ]; - - fn manual_yield(reason: u16) -> u64 { - // HTIF_BUILD(yield device, manual command, reason, no data). - (2u64 << 56) | (1u64 << 48) | (u64::from(reason) << 32) - } - - /// Runs the guest until its budget or its x19 yield stops it; x19 and - /// x20 hold the whole tohost words it yields. - fn yield_guest(imcyclemax: u64, x19: u64, x20: u64) -> Result { - let mut config = Machine::default_config()?; - config.ram.length = 4096; - config.processor.registers.pc = RAM_START; - config.processor.registers.imcyclemax = imcyclemax; - config.processor.registers.x19 = x19; - config.processor.registers.x20 = x20; - let mut machine = Machine::create(&config, &RuntimeConfig::quiet_console())?; - let program: Vec = BUDGET_SEAM_PROGRAM - .iter() - .flat_map(|instruction| instruction.to_le_bytes()) - .collect(); - machine.write_memory(RAM_START, &program)?; - machine.run(u64::MAX)?; - Ok(machine) - } + use crate::test_machine::{manual_yield, yield_guest}; /// With imcyclemax = 3 the x19 yield executes on the budget's last /// cycle; with imcyclemax = 2 the budget runs out before the yield. fn seam_guest(imcyclemax: u64, x19_reason: u16) -> Result { let mut machine = yield_guest( imcyclemax, - manual_yield(x19_reason), - manual_yield(RX_ACCEPTED), + &[manual_yield(x19_reason, 0), manual_yield(RX_ACCEPTED, 0)], )?; assert_eq!(machine.mcycle()?, imcyclemax); Ok(machine) @@ -991,7 +957,7 @@ mod tests { config.ram.length = 4096; config.processor.registers.iflags.h = 1; config.processor.registers.iflags.y = 1; - config.processor.registers.htif.tohost = manual_yield(RX_ACCEPTED); + config.processor.registers.htif.tohost = manual_yield(RX_ACCEPTED, 0); Ok(Machine::create(&config, &RuntimeConfig::quiet_console())?) } @@ -1013,26 +979,25 @@ mod tests { Ok(Machine::create(&config, &RuntimeConfig::quiet_console())?) } - /// `tohost` with a data field declaring one byte past the TX buffer, as + /// A manual yield declaring one byte past the TX buffer, as /// a guest that bypasses libcmt can yield it: cm_receive_cmio_request /// refuses that length, while the step reads only the header. - fn oversized(tohost: u64) -> u64 { - tohost | ((1u64 << TX_LOG2_SIZE) + 1) + fn oversized(reason: u16) -> u64 { + manual_yield(reason, (1u32 << TX_LOG2_SIZE) + 1) } /// Awaits input under an oversized RX_ACCEPTED yield, and accepts the /// input under another. fn oversized_accepted_machine() -> Result { - let accepted = oversized(manual_yield(RX_ACCEPTED)); - yield_guest(u64::MAX, accepted, accepted) + let accepted = oversized(RX_ACCEPTED); + yield_guest(u64::MAX, &[accepted, accepted]) } /// Awaits input, and rejects it under an oversized RX_REJECTED yield. fn oversized_rejected_machine() -> Result { yield_guest( u64::MAX, - manual_yield(RX_ACCEPTED), - oversized(manual_yield(RX_REJECTED)), + &[manual_yield(RX_ACCEPTED, 0), oversized(RX_REJECTED)], ) } @@ -1229,7 +1194,7 @@ mod tests { let work = tempfile::tempdir()?; let mut machine = seam_guest(3, RX_ACCEPTED)?; - machine.write_reg(CM_REG_X20, manual_yield(RX_REJECTED))?; + machine.write_reg(CM_REG_X20, manual_yield(RX_REJECTED, 0))?; let revert_root = machine.root_hash()?; let checkpoint = work.path().join("checkpoint-0"); machine.store(&checkpoint)?; @@ -1330,7 +1295,7 @@ mod tests { // Delivered, with the x20 rejection due on the budget's last cycle. let delivered = |work: &Path| -> Result<(Machine, Hash, PathBuf, Vec)> { let mut machine = seam_guest(3, RX_ACCEPTED)?; - machine.write_reg(CM_REG_X20, manual_yield(RX_REJECTED))?; + machine.write_reg(CM_REG_X20, manual_yield(RX_REJECTED, 0))?; let revert_root = machine.root_hash()?; let checkpoint = work.join("checkpoint-0"); machine.store(&checkpoint)?; diff --git a/cartesi-rollups/node/src/harness/mod.rs b/cartesi-rollups/node/src/harness/mod.rs index 1831cf4c..ce3d6dcc 100644 --- a/cartesi-rollups/node/src/harness/mod.rs +++ b/cartesi-rollups/node/src/harness/mod.rs @@ -32,14 +32,14 @@ use anyhow::{Context, Result, ensure}; use cartesi_dave_contracts::dave_consensus::DaveConsensus; use cartesi_prt_contracts::tournament::Tournament; use cartesi_rollups_contracts::i_input_box::IInputBox; -use std::time::Duration; +use std::{path::PathBuf, time::Duration}; use tempfile::TempDir; use crate::{ args::discover_deployed_tournament, blockchain_reader::{ AddressBook, BlockchainReader, - test_utils::{Deploy, deploy_app, program_path, spawn_anvil, wallet_provider}, + test_utils::{Deploy, deploy_app_from, program_path, spawn_anvil, wallet_provider}, }, chain::Chain, engine::{EngineConfig, Tail, TournamentGeometry}, @@ -72,6 +72,7 @@ pub(crate) struct World { pub chain: Chain, pub book: AddressBook, geometry: TournamentGeometry, + template: PathBuf, /// Receipts are checked through this block. checked: u64, pub mined: Vec, @@ -81,6 +82,19 @@ impl World { /// Anvil over the devnet bundle with one Dave app on the echo /// template; `sentries` are anvil key indices. pub async fn spawn(sentries: &[usize], claim_staging_period: u64) -> Result { + Self::spawn_on_template( + sentries, + claim_staging_period, + program_path().join("machine-image"), + ) + .await + } + + async fn spawn_on_template( + sentries: &[usize], + claim_staging_period: u64, + template: PathBuf, + ) -> Result { let anvil = spawn_anvil(); let provider = ProviderBuilder::new() .connect_client( @@ -106,7 +120,7 @@ impl World { sentries: sentries.iter().map(|&key| anvil.addresses()[key]).collect(), claim_staging_period, }; - let book = deploy_app(&operator, &deploy) + let book = deploy_app_from(&operator, &deploy, *Template::inspect(&template)?.hash()) .await .map_err(|error| anyhow::anyhow!("deploying the app: {error}"))?; provider.anvil_set_auto_mine(false).await?; @@ -121,6 +135,7 @@ impl World { chain, book, geometry, + template, checked, mined: vec![], }; @@ -453,7 +468,7 @@ impl World { pub async fn node(&self, key: usize) -> Result { let state_dir = tempfile::tempdir()?; let seed = Seed { - image: Template::inspect(&program_path().join("machine-image"))?, + image: Template::inspect(&self.template)?, epoch: 0, // Anvil does not reorg, so a sample after discovery is as good. watermark: self diff --git a/cartesi-rollups/node/src/harness/tests.rs b/cartesi-rollups/node/src/harness/tests.rs index a6251861..0b4c9cc3 100644 --- a/cartesi-rollups/node/src/harness/tests.rs +++ b/cartesi-rollups/node/src/harness/tests.rs @@ -16,6 +16,7 @@ use alloy::{ use cartesi_dave_contracts::dave_consensus::DaveConsensus::{ acceptStagedTournamentResultCall, stageTournamentResultCall, submitSentryClaimCall, }; +use cartesi_machine::constants::cmio::tohost::manual::RX_ACCEPTED; use cartesi_prt_contracts::tournament::Tournament::{ MatchCreated, advanceMatchCall, eliminateInnerTournamentCall, eliminateMatchByTimeoutCall, joinTournamentCall, sealInnerMatchAndCreateInnerTournamentCall, tryRecoveringBondCall, @@ -81,6 +82,96 @@ async fn settle(node: &mut Node, world: &mut World, epoch: u64) -> Result<()> { .await } +#[tokio::test] +#[ignore = "needs the devnet bundle; run `just test-node-harness`"] +async fn a_guest_halted_by_an_input_is_defended_across_restart_and_never_staged() -> Result<()> { + let template_dir = tempfile::tempdir()?; + let template = template_dir.path().join("machine"); + let mut machine = crate::test_machine::yield_guest( + u64::MAX, + &[ + crate::test_machine::manual_yield(RX_ACCEPTED, 0), + crate::test_machine::halt(), + ], + )?; + machine.store(&template)?; + drop(machine); + + let mut world = World::spawn_on_template(&[], 0, template).await?; + let mut node = world.honest_node().await?; + world.add_input(vec![1]).await?; + settle(&mut node, &mut world, 0).await?; + node.roll(&mut world, 1).await?; + let settlement = Storage::new(node.state_dir.path())? + .settlement_info(1)? + .unwrap(); + assert!(!settlement.machine_validity_proof.settles()); + + let mut restarted = node.restart(&world)?; + let mut adversary = world.adversary(&restarted, 1, idle_tail(), Policy::Rational)?; + restarted + .run_with( + &mut world, + &mut [&mut adversary], + DISPUTE_ROUNDS, + |world, _| Ok(world.honest_calls(WIN_INNER).len() == world.levels() - 1), + ) + .await?; + assert_eq!(world.honest_calls(WIN_LEAF).len(), 1); + let close = world.root_closes_at().await?; + world.mine_to(close).await?; + restarted.turn(&mut world).await?; + + let won = world.consensus().canStageTournamentResult().call().await?; + assert!(won.isFinished); + assert!(!won.isTournamentFailed); + assert_eq!( + won.winnerCommitment, + alloy::primitives::B256::from(settlement.computation_hash.data()) + ); + for _ in 0..3 { + restarted.turn(&mut world).await?; + } + let mut restarted = restarted.restart(&world)?; + restarted.turn(&mut world).await?; + assert_eq!(world.honest_calls(STAGE).len(), 1); + assert_eq!(world.honest_calls(ACCEPT).len(), 1); + assert_eq!( + Storage::new(restarted.state_dir.path())? + .unfinished_epoch()? + .unwrap() + .epoch_number, + 1 + ); + Ok(()) +} + +/// A nonstandard yield still stages the committed state through the contracts. +#[tokio::test] +#[ignore = "needs the devnet bundle; run `just test-node-harness`"] +async fn nonstandard_yields_still_settle_the_committed_state() -> Result<()> { + let template_dir = tempfile::tempdir()?; + let template = template_dir.path().join("machine"); + // Accepted template, unknown automatic notification, then oversized accepted yield. + let mut machine = crate::test_machine::yield_guest( + u64::MAX, + &[ + crate::test_machine::manual_yield(RX_ACCEPTED, 32), + crate::test_machine::automatic_yield(0x7f, 0), + crate::test_machine::manual_yield(RX_ACCEPTED, u32::MAX), + ], + )?; + machine.store(&template)?; + drop(machine); + let mut world = World::spawn_on_template(&[], 0, template).await?; + let mut node = world.honest_node().await?; + world.add_input(vec![1]).await?; + settle(&mut node, &mut world, 0).await?; + settle(&mut node, &mut world, 1).await?; + assert_eq!(world.honest_calls(ACCEPT).len(), 2); + Ok(()) +} + /// Two consecutive epochs settle: epoch 0, sealed empty at deployment, and /// epoch 1 with a maximum-size input, which the reader ingests and the /// runner executes before the node joins with its root. diff --git a/cartesi-rollups/node/src/lib.rs b/cartesi-rollups/node/src/lib.rs index d58cc9ef..c9ceae08 100644 --- a/cartesi-rollups/node/src/lib.rs +++ b/cartesi-rollups/node/src/lib.rs @@ -30,6 +30,8 @@ mod tournament; #[cfg(test)] mod harness; +#[cfg(test)] +mod test_machine; use args::NodeConfig; diff --git a/cartesi-rollups/node/src/storage/open.rs b/cartesi-rollups/node/src/storage/open.rs index ee6b6bb7..87db1f23 100644 --- a/cartesi-rollups/node/src/storage/open.rs +++ b/cartesi-rollups/node/src/storage/open.rs @@ -829,7 +829,8 @@ mod tests { let template = dir.path().join("template"); store_template(&template, |_| {}); - initialize_at(&state_dir, &template, 0, Address::ZERO, 1).unwrap(); + let mut storage = initialize_at(&state_dir, &template, 0, Address::ZERO, 1).unwrap(); + assert!(storage.snapshot_dir(0, 0).unwrap().unwrap().is_dir()); assert!(Storage::is_seeded(&state_dir).unwrap()); for leftover in &leftovers { assert!(!leftover.exists(), "`{}` survived", leftover.display()); diff --git a/cartesi-rollups/node/src/test_machine.rs b/cartesi-rollups/node/src/test_machine.rs new file mode 100644 index 00000000..26d87d29 --- /dev/null +++ b/cartesi-rollups/node/src/test_machine.rs @@ -0,0 +1,54 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Tiny real guests shared by execution and lifecycle regressions. + +use anyhow::Result; +use cartesi_machine::{ + cartesi_machine_sys::CM_REG_X19, config::runtime::RuntimeConfig, constants::ar::RAM_START, + machine::Machine, +}; + +pub fn manual_yield(reason: u16, data: u32) -> u64 { + // HTIF_BUILD(yield device, manual command, reason, data). + (2u64 << 56) | (1u64 << 48) | (u64::from(reason) << 32) | u64::from(data) +} + +pub fn halt() -> u64 { + // Halt data is (exit_code << 1) | 1: the halt bit with exit code zero. + 1 +} + +pub fn automatic_yield(reason: u16, data: u32) -> u64 { + // HTIF_BUILD(yield device, automatic command, reason, data). + (2u64 << 56) | (u64::from(reason) << 32) | u64::from(data) +} + +/// Clear fromhost, write each word to tohost, then repeat the last write. +/// The first write is instruction three, so a budget of three reaches a +/// yield exactly at the budget boundary. A tohost word of one halts. +pub fn yield_guest(imcyclemax: u64, words: &[u64]) -> Result { + assert!( + !words.is_empty() && words.len() <= 13, + "guest sequence must fit the 13 registers x19..x31" + ); + let mut config = Machine::default_config()?; + config.ram.length = 4096; + config.processor.registers.pc = RAM_START; + config.processor.registers.imcyclemax = imcyclemax; + let mut machine = Machine::create(&config, &RuntimeConfig::quiet_console())?; + let mut program = vec![ + 0x4000_82b7u32, // lui t0, 0x40008 (HTIF base) + 0x0002_b423, // sd zero, 8(t0) (fromhost) + ]; + for (index, word) in words.iter().enumerate() { + machine.write_reg(CM_REG_X19 + index as u32, *word)?; + // sd x(19 + index), 0(t0) (tohost) + program.push(0x0002_b023 | ((19 + index as u32) << 20)); + } + program.push(0xffdf_f06f); // j -4 + let bytes: Vec<_> = program.iter().flat_map(|v| v.to_le_bytes()).collect(); + machine.write_memory(RAM_START, &bytes)?; + machine.run(u64::MAX)?; + Ok(machine) +} diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 070f97cb..f380a4a7 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -97,7 +97,15 @@ template already terminal is refused at startup instead: the engine starts every epoch from a state awaiting input, so the node cannot defend such an application at all, and its epochs are the guardian's alone. -Capped epoch rollover also requires clients to select +Settlement validity follows LibMachineValidityProof: a manual RX_ACCEPTED +yield and its committed TX-buffer root. The HTIF data field is ignored. +Execution reads the manual yield's reason directly from HTIF, without loading +its guest-sized payload. Unknown automatic-yield reasons do not invalidate +machine state. A mismatch between the committed TX word and a root +reconstructed from emitted outputs does not invalidate the commitment either; +reconstructed output proofs need not validate against that committed word. +Cross-client agreement on these rules requires coordination with the +reference-node team. Capped epoch rollover also requires clients to select inputs by the sealed index bounds, including inputs carried from earlier blocks. diff --git a/docs/test-harness.md b/docs/test-harness.md index d1ca1fe2..badb919b 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -249,6 +249,11 @@ another node's snapshot of a sealed epoch's start settles that epoch and never takes an earlier one, an unsettled state seeds nothing, and a restart reads no image. +A halted guest's true commitment defeats a malicious claimant through a real +leaf proof across restarts, then remains unstaged. +A guest with an unknown automatic notification and an oversized accepted-yield +declaration still stages its committed state through the contracts. + ## Steering disputes: patch chains A sybil patch `{ hash, meta_cycle = M }` garbles the leaf whose @@ -353,10 +358,6 @@ unverified claims - check before relying on them: kill in e2e, and the harness's `restart_after_*` tests. - (closed 2026-07) Revert transitions at leaf level: `stf_revert`. - Epochs at capacity boundaries (max inputs, input at the last stride). -- A terminal application against the real contracts: no test image turns - terminal on an input, and startup refuses a template that is terminal at - genesis, so the won-and-held epoch is pinned only by the epoch - manager's tests against mocked views. - Provider misbehavior: RPC errors, long-range log splits, throttling. - Multiple honest nodes defending the same epoch concurrently. - The devnet's censorship budget is 0, so kill and chaos runs keep no slack: diff --git a/docs/todo.md b/docs/todo.md index 9e862c27..46e0a551 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -117,6 +117,10 @@ carry no backlog (reviews/README.md). (runbooks/submit-relay.md) ## Contracts and assurance + +- Coordinate reference-node validity: accepted-yield lengths, unknown automatic + notifications, declared outputs roots and pending accepted yields at the + cycle boundary. (epoch-lifecycle.md) - Coordinate reference-node capped epoch rollover: select inputs by sealed index bounds, including inputs carried from earlier blocks. (epoch-lifecycle.md) @@ -156,11 +160,6 @@ carry no backlog (reviews/README.md). - Two honest nodes on different keys defending one epoch against one adversary, in the harness: no test has run two sling nodes at once. (test-harness.md) -- Release-candidate hardening: a harness test in which a terminal - application's epoch is defended, won and held through the real contracts. - It needs a guest that turns terminal on an input, a new test image, since - startup refuses a template that is terminal at genesis; today the epoch - manager's tests pin the hold against mocked views. (test-harness.md) ## Tooling From 29c70f5a5dc7ad9e3c3e2d9f3a4d5dfcb9697aac Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:14:58 +0800 Subject: [PATCH 4/7] fix(node): stop settling a foreclosed app, exit once nothing is owed Main kept defending through foreclosure but never stopped. Read foreclosure once the dispute has a result, suppress settlement while visible, and exit 30 only after defense and bond recovery are complete and foreclosure is finalized. Restarting resumes the same obligations. Keep Hero and recovery active. Enable the harness guardian and test foreclosure during a live dispute and after staging. Recover the bond before foreclosure in the latter case, so latest-only foreclosure cannot be mistaken for a finalized exit. Pin the exit across restart. Addresses the remaining SLN-4 behavior. --- CHANGELOG.md | 4 + cartesi-rollups/node/README.md | 46 ++++--- cartesi-rollups/node/src/args.rs | 1 + .../node/src/blockchain_reader/test_utils.rs | 4 +- cartesi-rollups/node/src/epoch_manager/mod.rs | 43 ++++++- .../node/src/epoch_manager/recovery.rs | 3 +- cartesi-rollups/node/src/fatal.rs | 3 +- cartesi-rollups/node/src/harness/mod.rs | 2 + cartesi-rollups/node/src/harness/tests.rs | 117 ++++++++++++++++++ cartesi-rollups/node/src/lib.rs | 1 + docs/epoch-lifecycle.md | 14 +++ docs/node-architecture.md | 5 +- docs/test-harness.md | 3 + 13 files changed, 217 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cccd0885..a3a3f3f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ past the span. ### Node +- Once the dispute has a result, skip sentry claims, staging and acceptance + while foreclosure is visible. Dispute moves and bond recovery continue; + exit 30 only after recovery completes and foreclosure is finalized. + A restart resumes the same work. - Split full pending epochs at the input span, check every later seal against those boundaries, and stall at the first seal past the span. diff --git a/cartesi-rollups/node/README.md b/cartesi-rollups/node/README.md index 6fa77a5b..d5bec3c5 100644 --- a/cartesi-rollups/node/README.md +++ b/cartesi-rollups/node/README.md @@ -352,23 +352,24 @@ Options: restart rules filter it), so alert on a last exit of 30 or 40. - The exit status says why the node stopped: 0 a requested stop; 1 any other error (restart); 2 a usage error; 30 terminal, when a flag, the seed image, - the deployment or the settled chain disagrees with the node (do not - restart: an operator must act); 40 the data directory was built under - another store format, commitment semantics, emulator, consensus or - tournament geometry (start a fresh one beside it, as above); 101 a panic - (restart with backoff and page, since a loop means a bug or corrupt local - state). A 101 loop at a snapshot-root assert means corrupt local state: - rebuild the data directory beside it as for 40, never delete a row or a - snapshot directory alone, which a replay would adopt again. A death by - signal logs nothing of its own: 137 (SIGKILL) is usually the kernel's + the deployment or the settled chain disagrees with the node, or the + application is foreclosed in finalized state and defense and bond recovery + are complete (do not restart: an operator must act); 40 the data directory + was built under another store format, commitment semantics, emulator, + consensus or tournament geometry (start a fresh one beside it, as above); + 101 a panic (restart with backoff and page, since a loop means a bug or + corrupt local state). A 101 loop at a snapshot-root assert means corrupt + local state: rebuild the data directory beside it as for 40, never delete a + row or a snapshot directory alone, which a replay would adopt again. A death + by signal logs nothing of its own: 137 (SIGKILL) is usually the kernel's out-of-memory killer; 135 (SIGBUS, systemd `status=7/BUS`) means the data directory's filesystem filled while a machine was mapped for writing; 134 and 139 are crashes below the panic path, to report with stderr's last lines. Size memory for the application's machine plus a dispute's, plus - about 250 bytes per input of the epoch, which the root composition holds - at the roll and in a root dispute, plus about three times the input bytes - of the densest 10,000 blocks since the deployment, which a catch-up holds - at once: an out-of-memory kill there repeats on every restart. + about 250 bytes per input of the epoch, which the root composition holds at + the roll and in a root dispute, plus about three times the input bytes of + the densest 10,000 blocks since the deployment, which a catch-up holds at + once: an out-of-memory kill there repeats on every restart. - Behind a relay, an error that a call has been planned for some minutes without landing, naming a last verdict rather than a revert at latest, means the relay holds, drops or refuses its sends; the nonce it names is @@ -397,10 +398,10 @@ Options: restart loop or an unanswered page draws on the same censorship budget as a provider outage. - Alert on every ERROR line, on restart loops, on free space on the data - directory's filesystem (the node refuses a batch below 1 GiB, except the - newest sealed epoch's, leaving that space to it and its dispute), and on the - signer's balance against the funding floor above, which the node checks - only when a batch is due. + directory's filesystem (the node + refuses a batch below 1 GiB, except the newest sealed epoch's, leaving that + space to it and its dispute), and on the signer's balance against the + funding floor above, which the node checks only when a batch is due. - Keep `--data-dir` on XFS with `reflink=1` in production (above). An epoch keeps one snapshot per `--snapshot-gap-inputs` inputs until it completes (a batch with no accepted input shares its predecessor's), and about two @@ -442,5 +443,12 @@ Options: A lost root's error says whether the winner ends in this node's final state or diverges from it; a sentry node claims its own state either way. After a divergent state settles, the node stops with exit 30 at the next epoch. - A foreclosed application settles nothing more: let the node finish - recovering its winning bonds, then stop it. + Foreclosure never stops dispute moves or bond recovery. Once the dispute has + a result, the node skips settlement while foreclosure is visible at that + head. It exits 30 only when recovery is complete and foreclosure is finalized. + Restarting resumes that work, so do not stop a node with an active dispute. + Between epochs it may join the next root and wait out its allowance before + exiting; a fresh directory replays history first. A foreclosure racing + inclusion can cost one transaction's gas. An eliminated commitment in an + abandoned root may keep waiting indefinitely. Foreclosure does not advance + the node's epoch completion cursor. diff --git a/cartesi-rollups/node/src/args.rs b/cartesi-rollups/node/src/args.rs index 0fd6e4f0..247f2253 100644 --- a/cartesi-rollups/node/src/args.rs +++ b/cartesi-rollups/node/src/args.rs @@ -862,6 +862,7 @@ mod tests { &Deploy { sentries: vec![anvil.addresses()[0]], claim_staging_period: 1000, + guardian: Address::ZERO, }, *Template::inspect(&terminal)?.hash(), ) diff --git a/cartesi-rollups/node/src/blockchain_reader/test_utils.rs b/cartesi-rollups/node/src/blockchain_reader/test_utils.rs index b950a884..6583a77e 100644 --- a/cartesi-rollups/node/src/blockchain_reader/test_utils.rs +++ b/cartesi-rollups/node/src/blockchain_reader/test_utils.rs @@ -116,6 +116,7 @@ pub fn wallet_provider(anvil: &AnvilInstance, index: usize) -> DynProvider { pub struct Deploy { pub sentries: Vec
, pub claim_staging_period: u64, + pub guardian: Address, } /// Deploys the app through the bundle's DaveAppFactory, sending from @@ -152,7 +153,7 @@ pub async fn deploy_app_from( let claim_staging_period = U256::from(deploy.claim_staging_period); let sentry_manager = Address::ZERO; let withdrawal_config = WithdrawalConfig { - guardian: Default::default(), + guardian: deploy.guardian, log2LeavesPerAccount: Default::default(), log2MaxNumOfAccounts: Default::default(), accountsDriveStartIndex: Default::default(), @@ -210,6 +211,7 @@ pub async fn spawn_anvil_and_provider() -> Result<(AnvilInstance, DynProvider, A &Deploy { sentries: vec![signer_address], claim_staging_period: 1000, + guardian: Address::ZERO, }, ) .await?; diff --git a/cartesi-rollups/node/src/epoch_manager/mod.rs b/cartesi-rollups/node/src/epoch_manager/mod.rs index 18782df7..3dc326c9 100644 --- a/cartesi-rollups/node/src/epoch_manager/mod.rs +++ b/cartesi-rollups/node/src/epoch_manager/mod.rs @@ -31,6 +31,7 @@ use cartesi_dave_contracts::dave_consensus::DaveConsensus; pub struct EpochManager { transaction_lane: TransactionLane, consensus: Address, + application: Address, signer_address: Address, sleep_duration: Duration, storage: Storage, @@ -76,6 +77,7 @@ impl EpochManager { pub fn new( transaction_lane: TransactionLane, consensus_address: Address, + application: Address, signer_address: Address, storage: Storage, sleep_duration: Duration, @@ -84,6 +86,7 @@ impl EpochManager { Self { transaction_lane, consensus: consensus_address, + application, signer_address, sleep_duration, storage, @@ -192,6 +195,11 @@ impl EpochManager { last.epoch_number > epoch.epoch_number && last.block_created_number <= finalized.number }); let mut wave = Vec::new(); + let mut foreclosed = false; + let application = cartesi_rollups_contracts::i_application::IApplication::new( + self.application, + chain.provider().clone(), + ); if !settled { if let Some(settlement) = self.storage.settlement_info(epoch.epoch_number)? { match self.react_dispute(chain, &epoch, &settlement).await { @@ -199,14 +207,25 @@ impl EpochManager { let result = tick.result(); let head = tick.head(); wave.extend(tick.into_request()); + // DaveConsensus is frozen by foreclosure, but tournaments + // still pay their winners: finish the dispute first. + if result.is_some() { + foreclosed = application + .isForeclosed() + .block(head.block_id()) + .call() + .await?; + } // A sentry votes the state it computed once the root // has a winner, its own or not: after a loss its claim // is the dissent a guardian reads. Staging and // acceptance follow only its own win. - if matches!( - result, - Some(HeroTerminal::Won | HeroTerminal::Lost(Loss::Root { .. })) - ) { + if !foreclosed + && matches!( + result, + Some(HeroTerminal::Won | HeroTerminal::Lost(Loss::Root { .. })) + ) + { let consensus = DaveConsensus::new(self.consensus, chain.provider().clone()); let step = if result == Some(HeroTerminal::Won) { @@ -293,6 +312,21 @@ impl EpochManager { false } }; + // Latest foreclosure can reorg away; only finalized foreclosure + // stops a node that has finished defense and recovery. + if foreclosed + && refunds_complete + && application + .isForeclosed() + .block(finalized.block_id()) + .call() + .await? + { + return Err(Fatal::terminal( + "application is foreclosed; dispute defense and bond recovery are complete", + ) + .into()); + } Ok(Some(EpochTick { epoch: epoch.epoch_number, wave, @@ -836,6 +870,7 @@ mod tests { let manager = EpochManager::new( lane, Address::repeat_byte(0xCC), + Address::repeat_byte(0xAA), address, Storage::new(path).unwrap(), Duration::ZERO, diff --git a/cartesi-rollups/node/src/epoch_manager/recovery.rs b/cartesi-rollups/node/src/epoch_manager/recovery.rs index c473c57c..4a55f6d5 100644 --- a/cartesi-rollups/node/src/epoch_manager/recovery.rs +++ b/cartesi-rollups/node/src/epoch_manager/recovery.rs @@ -163,8 +163,7 @@ pub async fn plan_recovery( wave: Vec::new(), complete: open.is_empty(), }; - // Once per process and epoch. A foreclosed epoch never settles, so this - // is the line that marks when its operator may stop the node. + // Once per process and epoch: the first tick that finds nothing open. if tick.complete && !tree.tournaments.is_empty() { info!( "epoch {}: every bond owed to {claimant} is recovered", diff --git a/cartesi-rollups/node/src/fatal.rs b/cartesi-rollups/node/src/fatal.rs index 1721e9ef..5fc6fed8 100644 --- a/cartesi-rollups/node/src/fatal.rs +++ b/cartesi-rollups/node/src/fatal.rs @@ -19,7 +19,8 @@ use std::fmt::Display; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FatalKind { /// A flag, the seed image, the deployment or the settled chain - /// disagrees with this node. + /// disagrees with this node, or finalized foreclosure has left no + /// dispute defense or bond recovery to do. Terminal, /// The data directory belongs to another store format, commitment semantics, /// emulator, consensus or tournament geometry. diff --git a/cartesi-rollups/node/src/harness/mod.rs b/cartesi-rollups/node/src/harness/mod.rs index ce3d6dcc..a57064ad 100644 --- a/cartesi-rollups/node/src/harness/mod.rs +++ b/cartesi-rollups/node/src/harness/mod.rs @@ -119,6 +119,7 @@ impl World { let deploy = Deploy { sentries: sentries.iter().map(|&key| anvil.addresses()[key]).collect(), claim_staging_period, + guardian: anvil.addresses()[OPERATOR], }; let book = deploy_app_from(&operator, &deploy, *Template::inspect(&template)?.hash()) .await @@ -583,6 +584,7 @@ impl Node { let manager = EpochManager::new( world.lane(key), world.book.consensus, + world.book.app, world.address(key), Storage::new(state_dir.path())?, Duration::ZERO, diff --git a/cartesi-rollups/node/src/harness/tests.rs b/cartesi-rollups/node/src/harness/tests.rs index 0b4c9cc3..990e8e6f 100644 --- a/cartesi-rollups/node/src/harness/tests.rs +++ b/cartesi-rollups/node/src/harness/tests.rs @@ -82,6 +82,123 @@ async fn settle(node: &mut Node, world: &mut World, epoch: u64) -> Result<()> { .await } +async fn foreclose(world: &mut World) -> Result<()> { + use cartesi_rollups_contracts::i_application::IApplication; + let application = IApplication::new(world.book.app, world.operator.clone()); + let pending = application.foreclose().send().await?; + let hash = *pending.tx_hash(); + world.mine(1).await?; + assert!( + world + .operator + .get_transaction_receipt(hash) + .await? + .unwrap() + .status() + ); + Ok(()) +} + +async fn finish_foreclosed( + node: &mut Node, + world: &mut World, + mut adversary: Option<&mut Adversary>, +) -> Result<()> { + let before = [CLAIM, STAGE, ACCEPT].map(|call| world.honest_calls(call).len()); + let mut stopped = false; + for _ in 0..DISPUTE_ROUNDS { + match node.turn(world).await { + Ok(()) => {} + Err(error) => { + assert_eq!(crate::fatal::exit_code(&error), 30, "{error:#}"); + stopped = true; + break; + } + } + if let Some(actor) = adversary.as_deref_mut() { + actor.turn(world).await?; + } + if world.honest_calls(WIN_INNER).len() == world.levels() - 1 { + let close = world.root_closes_at().await?; + world.mine_to(close).await?; + } + } + assert!( + stopped, + "foreclosed node did not finish defense and recovery" + ); + assert_eq!( + [CLAIM, STAGE, ACCEPT].map(|call| world.honest_calls(call).len()), + before + ); + let root = world.sealed_epochs().await?[0].tournament; + let recoveries = world.bonds_recovered(root).await?; + let ours: Vec<_> = recoveries + .iter() + .filter(|(event, _)| event.claimer == world.address(HONEST)) + .collect(); + assert_eq!(ours.len(), 1); + assert!(ours[0].0.payment > U256::ZERO); + Ok(()) +} + +#[tokio::test] +#[ignore = "needs the devnet bundle; run `just test-node-harness`"] +async fn foreclosure_after_recovery_stops_only_once_finalized_and_across_restart() -> Result<()> { + let mut world = World::spawn(&[], 1000).await?; + let mut node = world.honest_node().await?; + join_and_close(&mut node, &mut world).await?; + node.run_until(&mut world, ROUNDS, |world, _| { + Ok(!world.honest_calls(STAGE).is_empty()) + }) + .await?; + let root = world.sealed_epochs().await?[0].tournament; + node.run_until(&mut world, ROUNDS, |world, _| { + Ok(!world + .honest_calls(FixedBytes(tryRecoveringBondCall::SELECTOR)) + .is_empty()) + }) + .await?; + let recovery_block = world.bonds_recovered(root).await?[0].1; + world.mine_to(recovery_block + 2).await?; + assert!(world.chain.finalized_head().await?.number >= recovery_block); + node.tick(&world).await?; + foreclose(&mut world).await?; + // Recovery is already final; latest foreclosure alone must not stop us. + node.tick(&world).await?; + world.mine(2).await?; + finish_foreclosed(&mut node, &mut world, None).await?; + let mut restarted = node.restart(&world)?; + let error = restarted.tick(&world).await.unwrap_err(); + assert_eq!(crate::fatal::exit_code(&error), 30); + Ok(()) +} + +#[tokio::test] +#[ignore = "needs the devnet bundle; run `just test-node-harness`"] +async fn foreclosure_during_a_dispute_keeps_defending_and_recovers_bonds() -> Result<()> { + let mut world = World::spawn(&[HONEST], 1000).await?; + let mut node = world.honest_node().await?; + node.roll(&mut world, 0).await?; + let mut adversary = world.adversary(&node, 1, idle_tail(), Policy::Rational)?; + node.run_with(&mut world, &mut [&mut adversary], ROUNDS, |world, _| { + Ok(!world.calls(1, JOIN).is_empty() && !world.honest_calls(JOIN).is_empty()) + }) + .await?; + let root = world.sealed_epochs().await?[0].tournament; + assert!(!world.matches_created(root).await?.is_empty()); + foreclose(&mut world).await?; + let mut restarted = node.restart(&world)?; + finish_foreclosed(&mut restarted, &mut world, Some(&mut adversary)).await?; + assert_eq!(world.honest_calls(WIN_LEAF).len(), 1); + let mut restarted = restarted.restart(&world)?; + assert_eq!( + crate::fatal::exit_code(&restarted.tick(&world).await.unwrap_err()), + 30 + ); + Ok(()) +} + #[tokio::test] #[ignore = "needs the devnet bundle; run `just test-node-harness`"] async fn a_guest_halted_by_an_input_is_defended_across_restart_and_never_staged() -> Result<()> { diff --git a/cartesi-rollups/node/src/lib.rs b/cartesi-rollups/node/src/lib.rs index c9ceae08..b784a45f 100644 --- a/cartesi-rollups/node/src/lib.rs +++ b/cartesi-rollups/node/src/lib.rs @@ -109,6 +109,7 @@ pub async fn run(config: NodeConfig) -> Result<()> { let epoch_manager = EpochManager::new( transaction_lane, params.address_book.consensus, + params.address_book.app, params.signer_address, storage, params.sleep_duration, diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index f380a4a7..7a551fa9 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -77,6 +77,20 @@ application freezes epoch progress entirely. Check foreclosure status before debugging an unexpected revert on any settlement call. Deeper contract context: `cartesi-rollups/contracts/AGENTS.md`. +Foreclosure never gates the node's dispute moves, cleanup or bond recovery. +Once the dispute has a result, the node reads foreclosure at the Hero's head +and suppresses sentry claims, staging and acceptance while it is set. A reorg +can restore settlement. When recovery is complete and foreclosure is also +finalized, the node exits 30. A restart resumes defense and recovery, or exits +on its first tick once nothing remains owed. Foreclosure is not settlement, +so the epoch completion cursor does not advance. A foreclosure racing read +and inclusion can still cost one transaction's gas. + +Between epochs the node may join the next root and wait out its allowance +before it exits: an open commitment can still be paired by a late joiner. +A fresh data directory replays history first. An eliminated commitment in an +abandoned root can remain waiting indefinitely, as before. + The staging period is not only a sentry window: it is the reaction interval in which the application-layer foreclosure switch can stop a decided-but-wrong result from ever finalizing. Foreclosure freezes the diff --git a/docs/node-architecture.md b/docs/node-architecture.md index 0a8028e6..fd771b9d 100644 --- a/docs/node-architecture.md +++ b/docs/node-architecture.md @@ -241,8 +241,9 @@ is the alarm. A panic stops every worker, refunds included (`lib.rs`, The exit status tells a supervisor which of these happened (`fatal.rs`), with the numbers of the sequencer's contract: 0 a requested stop, 1 any other error (restart with backoff), 2 a usage error, 30 terminal (a flag, the -seed image, the deployment or the settled chain disagrees with the node: do not -restart, an operator must act), 40 rebuild the data directory (its store +seed image, the deployment or the settled chain disagrees with the node, or +finalized foreclosure leaves no defense or recovery to do: do not restart, +an operator must act), 40 rebuild the data directory (its store format, commitment semantics, emulator, consensus or geometry is another). A panic exits 101: restart with backoff and page, since a restart cannot make an invariant violation worse and may clear a nondeterministic one while a clock diff --git a/docs/test-harness.md b/docs/test-harness.md index badb919b..3afc6a3e 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -249,6 +249,9 @@ another node's snapshot of a sealed epoch's start settles that epoch and never takes an earlier one, an unsettled state seeds nothing, and a restart reads no image. +The foreclosure tests pin continued defense and paid bond recovery before +terminal exit, including a foreclosure during a live match and a restart. +A foreclosure that is not yet finalized does not stop the node. A halted guest's true commitment defeats a malicious claimant through a real leaf proof across restarts, then remains unstaged. A guest with an unknown automatic notification and an oversized accepted-yield From e25088cfb35923212285be79282f937487db139e Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:14:58 +0800 Subject: [PATCH 5/7] feat(node): page when an input makes the application terminal Waiting for the terminal epoch's staging error can delay the guardian's first page by a root allowance while users keep sending deposits. Detect the transition from awaiting input to a terminal state and emit one ERROR after its batch commits. Retried or abandoned batches do not page twice; later inputs in the absorbing state do not page again. Continue defending the true state and ask the guardian to foreclose. The real-runner test pins the first awaiting-to-terminal transition the guard reads, including the absorbing state on a later input; it does not capture the page or test retries. An open batch can still wait for its snapshot gap or the seal. Relates to SLN-3. --- CHANGELOG.md | 2 + cartesi-rollups/node/README.md | 5 +- .../node/src/machine_runner/mod.rs | 73 ++++++++++++++++++- docs/epoch-lifecycle.md | 15 ++-- docs/node-architecture.md | 3 +- 5 files changed, 88 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3a3f3f1..546e18ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ past the span. while foreclosure is visible. Dispute moves and bond recovery continue; exit 30 only after recovery completes and foreclosure is finalized. A restart resumes the same work. +- Page when an input makes the application terminal, after its batch commits, + while continuing to defend the true state. - Split full pending epochs at the input span, check every later seal against those boundaries, and stall at the first seal past the span. diff --git a/cartesi-rollups/node/README.md b/cartesi-rollups/node/README.md index d5bec3c5..d0f413cc 100644 --- a/cartesi-rollups/node/README.md +++ b/cartesi-rollups/node/README.md @@ -397,8 +397,9 @@ Options: since an adversary can time input load into a dispute. A host outage, a restart loop or an unanswered page draws on the same censorship budget as a provider outage. -- Alert on every ERROR line, on restart loops, on free space on the data - directory's filesystem (the node +- Alert on every ERROR line, including an input that makes the application + terminal (paged after its batch commits; ask the guardian to foreclose), on + restart loops, on free space on the data directory's filesystem (the node refuses a batch below 1 GiB, except the newest sealed epoch's, leaving that space to it and its dispute), and on the signer's balance against the funding floor above, which the node checks only when a batch is due. diff --git a/cartesi-rollups/node/src/machine_runner/mod.rs b/cartesi-rollups/node/src/machine_runner/mod.rs index d24b50ba..e7e1755c 100644 --- a/cartesi-rollups/node/src/machine_runner/mod.rs +++ b/cartesi-rollups/node/src/machine_runner/mod.rs @@ -192,6 +192,7 @@ impl MachineRunner { ); let (mut machine, mut batch) = self.storage.begin_planned_advances(&plan)?; + let mut terminal_input = None; for input in plan.inputs { if self.shutdown.is_requested() { log::info!( @@ -226,6 +227,7 @@ impl MachineRunner { stf.yielded()? || stf.terminal()?, "the working clone must await input or be terminal" ); + let awaited_input = stf.yielded()?; let mut ruler = Ruler::new_at( stf, self.structure, @@ -236,7 +238,12 @@ impl MachineRunner { self.structure.window_start(window + 1), self.log2_run_stride, )?; - let stf = ruler.into_stf(); + let mut stf = ruler.into_stf(); + // Terminal is absorbing. Page after commit so a retried batch + // does not page twice for the same input. + if awaited_input && stf.terminal()? { + terminal_input = Some(input.id); + } let reverted = stf.took_revert(); machine.put_machine(stf.into_machine()); machine.increment_input(); @@ -253,6 +260,14 @@ impl MachineRunner { assert_eq!(batch.len(), expected); drop(machine); self.storage.commit_advances(batch)?; + if let Some(input) = terminal_input { + log::error!( + "application became terminal at input {}:{}: this epoch can never settle; \ + the node keeps defending the true state; the guardian should foreclose", + input.epoch_number, + input.input_index_in_epoch + ); + } // The runner's throughput, which bounds the inputs it can keep up // with (dimensioning.md, the reference price) and sizes the disk. let elapsed = started.elapsed(); @@ -300,6 +315,62 @@ mod tests { } } + #[test] + fn only_the_first_halting_input_transitions_from_awaiting_to_terminal() { + use crate::{ + engine::{Hashing, TournamentGeometry}, + storage::sql::test_helper::seed_storage, + }; + use cartesi_machine::constants::cmio::tohost::manual::RX_ACCEPTED; + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("image"); + let mut guest = crate::test_machine::yield_guest( + u64::MAX, + &[ + crate::test_machine::manual_yield(RX_ACCEPTED, 0), + crate::test_machine::halt(), + ], + ) + .unwrap(); + guest.store(&image).unwrap(); + drop(guest); + let state = dir.path().join("state"); + let mut storage = seed_storage(&state, &image, 0, &TournamentGeometry::two_level()); + let inputs: Vec<_> = (0..2) + .map(|index| Input { + id: InputId { + epoch_number: 0, + input_index_in_epoch: index, + }, + data: vec![], + }) + .collect(); + let epoch = Epoch { + epoch_number: 0, + input_index_boundary: 2, + root_tournament: Address::ZERO, + block_created_number: 1, + }; + storage + .insert_consensus_data(1, inputs.iter(), [&epoch].into_iter()) + .unwrap(); + let mut runner = + MachineRunner::new(storage, 1, Duration::ZERO, ShutdownSignal::default()).unwrap(); + let mut before = MachineStf::load(&image, Hashing::Sampled).unwrap(); + assert!(before.yielded().unwrap()); + let plan = runner.storage.advance_plan(1).unwrap(); + runner.advance(plan).unwrap(); + let path = runner.storage.snapshot_dir(0, 1).unwrap().unwrap(); + let mut after = MachineStf::resume(&path, Hashing::Sampled).unwrap(); + assert!(before.yielded().unwrap() && after.terminal().unwrap()); + let plan = runner.storage.advance_plan(1).unwrap(); + runner.advance(plan).unwrap(); + let path = runner.storage.snapshot_dir(0, 2).unwrap().unwrap(); + let mut later = MachineStf::resume(&path, Hashing::Sampled).unwrap(); + assert!(!after.yielded().unwrap()); + assert!(later.terminal().unwrap()); + } + #[test] fn gap_three_scheduling_waits_for_open_tail_and_drains_sealed_remainder() { assert_eq!(plan_action(&plan(0, 0, false), 3), PlanAction::Idle); diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 7a551fa9..3f20acbb 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -101,12 +101,15 @@ application layer's deposit-refund path keys on, while withdrawals fall back to the last finalized state. The freeze is an intended terminal state, not a stranded-value bug. -A terminal application (halted, or yielded with an exception, on any -input) ends its epoch in a state no machine validity proof accepts, so that -epoch can never be staged. The node still defends that true state in the -root tournament: an undefended root would let a fabricated, stageable claim -win by timeout. Having won, the node holds the epoch with an error log, like -a no-winner result, and foreclosure is how the application moves on. A +A terminal application (halted, out of cycle budget, or yielded with an +exception, on any input) ends its epoch in a state no machine validity proof +accepts, so that epoch can never be staged. The node still defends that true +state in the root tournament: an undefended root would let a fabricated, +stageable claim win by timeout. Having won, the node holds the epoch with an +error log, like a no-winner result, and foreclosure is how the application +moves on. The runner pages after committing the halting input's batch, so the +guardian need not wait for the root allowance before hearing about the halt. +An open batch waits for the snapshot gap or the seal before it can commit. A template already terminal is refused at startup instead: the engine starts every epoch from a state awaiting input, so the node cannot defend such an application at all, and its epochs are the guardian's alone. diff --git a/docs/node-architecture.md b/docs/node-architecture.md index fd771b9d..309e36d3 100644 --- a/docs/node-architecture.md +++ b/docs/node-architecture.md @@ -257,7 +257,8 @@ winnerless dispute (a loss names its case: elimination, another class's child win, a root won with this node's final state, or a divergence), an obligation the local commitment already missed (joins closed before it joined, its clock expired, its match or its match's child is eliminable), a won epoch that -cannot settle, a submission the endpoint rejected (one it did not answer is +cannot settle, an input that makes the application terminal (paged after its +batch commits), a submission the endpoint rejected (one it did not answer is WARN until its call pages), a batch the signer cannot cover, a failure or a planned call that persists past G (the paging rule above), a worker exit, or a contract answer the node does not know (an unknown bond From 160cd09a53b6c3a0306e7fc1313147f7b88e1fc2 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:14:58 +0800 Subject: [PATCH 6/7] fix(node): check candidates against the existing standing read An omitted pairing can leave the event-derived candidate idle while its match clock runs. Detect that contradiction only under active matches, where waiting can lose defense, using the standing already read at the same head rather than another RPC. Retain the active candidate in the projection and propagate a typed mismatch so the reader discards and refolds its cache. Logs remain a trusted provider property; this is defense in depth for SLN-60. --- CHANGELOG.md | 2 + cartesi-rollups/node/src/hero/action.rs | 2 + cartesi-rollups/node/src/hero/context.rs | 14 +++- cartesi-rollups/node/src/hero/gc_planner.rs | 1 + cartesi-rollups/node/src/hero/planner.rs | 10 ++- cartesi-rollups/node/src/tournament/domain.rs | 12 ++- .../node/src/tournament/observer.rs | 57 +++++++++++++-- cartesi-rollups/node/src/tournament/reader.rs | 73 ++++++++++++++++++- docs/node-architecture.md | 12 +-- 9 files changed, 162 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 546e18ac..8ef4596b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ past the span. while continuing to defend the true state. - Split full pending epochs at the input span, check every later seal against those boundaries, and stall at the first seal past the span. +- Check a candidate against the already-read standing before waiting under + active matches; a contradiction discards and refolds the event cache. ### For developers diff --git a/cartesi-rollups/node/src/hero/action.rs b/cartesi-rollups/node/src/hero/action.rs index d888d3a2..3913735a 100644 --- a/cartesi-rollups/node/src/hero/action.rs +++ b/cartesi-rollups/node/src/hero/action.rs @@ -607,6 +607,7 @@ mod tests { commitment, TournamentStanding::MatchesActive { accepts_joins: false, + candidate: None, }, LocalCommitmentStanding::Engaged(engagement), ); @@ -812,6 +813,7 @@ mod tests { parent_descriptor, TournamentStanding::MatchesActive { accepts_joins: false, + candidate: None, }, parent_commitment, LocalCommitmentStanding::Engaged(parent_engagement), diff --git a/cartesi-rollups/node/src/hero/context.rs b/cartesi-rollups/node/src/hero/context.rs index 3d1e228c..fad26148 100644 --- a/cartesi-rollups/node/src/hero/context.rs +++ b/cartesi-rollups/node/src/hero/context.rs @@ -22,7 +22,7 @@ use crate::{ Engagement, LocalCommitmentStanding, ParentLink, SemanticSnapshot, TournamentDescriptor, TournamentStanding, }, - observer::read_match, + observer::{check_candidate, read_match}, }, }; @@ -96,7 +96,14 @@ impl HeroContext { let (local_standing, next) = match tournament.position(&local_commitment) { CommitmentPosition::NotJoined => (LocalCommitmentStanding::NotJoined, None), - CommitmentPosition::Candidate { .. } => (LocalCommitmentStanding::Candidate, None), + CommitmentPosition::Candidate { .. } => { + // Guard against a provider omission outside the trust model. + // This call-site has no direct regression; the pure check + // and refold do. + check_candidate(address, local_commitment, standing) + .map_err(ContextError::CandidateMismatch)?; + (LocalCommitmentStanding::Candidate, None) + } CommitmentPosition::Eliminated { reason, .. } => { (LocalCommitmentStanding::Eliminated { reason }, None) } @@ -178,6 +185,8 @@ impl HeroContext { #[derive(Debug, Error)] pub enum ContextError { + #[error("{0}")] + CandidateMismatch(#[source] crate::tournament::observer::ObserverError), #[error("accepted standings are missing tournament {tournament}")] MissingStanding { tournament: Address }, #[error("tournament {tournament} base cycle {base_cycle} is not aligned to level span {span}")] @@ -375,6 +384,7 @@ mod tests { descriptor: root_descriptor, standing: TournamentStanding::MatchesActive { accepts_joins: false, + candidate: None, }, local_commitment: digest(3), local_standing: LocalCommitmentStanding::Engaged(engagement), diff --git a/cartesi-rollups/node/src/hero/gc_planner.rs b/cartesi-rollups/node/src/hero/gc_planner.rs index 2a367eda..e87f5e39 100644 --- a/cartesi-rollups/node/src/hero/gc_planner.rs +++ b/cartesi-rollups/node/src/hero/gc_planner.rs @@ -179,6 +179,7 @@ mod tests { fn active() -> TournamentStanding { TournamentStanding::MatchesActive { accepts_joins: true, + candidate: None, } } diff --git a/cartesi-rollups/node/src/hero/planner.rs b/cartesi-rollups/node/src/hero/planner.rs index f1d2387c..680b71ff 100644 --- a/cartesi-rollups/node/src/hero/planner.rs +++ b/cartesi-rollups/node/src/hero/planner.rs @@ -337,6 +337,7 @@ mod tests { root_descriptor(kind), TournamentStanding::MatchesActive { accepts_joins: false, + candidate: None, }, local, LocalCommitmentStanding::Engaged(engagement), @@ -408,7 +409,10 @@ mod tests { (false, HeroDecision::Wait(WaitReason::JoinsClosed)), ] { let snapshot = root_snapshot( - TournamentStanding::MatchesActive { accepts_joins }, + TournamentStanding::MatchesActive { + accepts_joins, + candidate: None, + }, local, LocalCommitmentStanding::NotJoined, ); @@ -432,6 +436,7 @@ mod tests { let blocked = root_snapshot( TournamentStanding::MatchesActive { accepts_joins: true, + candidate: None, }, local, LocalCommitmentStanding::Candidate, @@ -457,6 +462,7 @@ mod tests { let snapshot = root_snapshot( TournamentStanding::MatchesActive { accepts_joins: false, + candidate: None, }, digest(1), LocalCommitmentStanding::Eliminated { @@ -658,6 +664,7 @@ mod tests { child_descriptor, TournamentStanding::MatchesActive { accepts_joins: true, + candidate: None, }, digest(40), LocalCommitmentStanding::NotJoined, @@ -677,6 +684,7 @@ mod tests { parent_descriptor, TournamentStanding::MatchesActive { accepts_joins: false, + candidate: None, }, parent_commitment, LocalCommitmentStanding::Engaged(engagement), diff --git a/cartesi-rollups/node/src/tournament/domain.rs b/cartesi-rollups/node/src/tournament/domain.rs index ca883956..3ff8ed45 100644 --- a/cartesi-rollups/node/src/tournament/domain.rs +++ b/cartesi-rollups/node/src/tournament/domain.rs @@ -369,12 +369,17 @@ pub enum InnerEliminationReason { /// Current-only tournament result and closure disposition. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TournamentStanding { - MatchesActive { accepts_joins: bool }, + MatchesActive { + accepts_joins: bool, + candidate: Option, + }, AwaitingClosure, RootWinner(RootWinner), RootFailed, InnerWinner(InnerWinner), - InnerEliminable { reason: InnerEliminationReason }, + InnerEliminable { + reason: InnerEliminationReason, + }, } impl TournamentStanding { @@ -382,7 +387,8 @@ impl TournamentStanding { matches!( self, Self::MatchesActive { - accepts_joins: true + accepts_joins: true, + .. } | Self::AwaitingClosure ) } diff --git a/cartesi-rollups/node/src/tournament/observer.rs b/cartesi-rollups/node/src/tournament/observer.rs index 24272fb1..7e7e8c5d 100644 --- a/cartesi-rollups/node/src/tournament/observer.rs +++ b/cartesi-rollups/node/src/tournament/observer.rs @@ -83,6 +83,14 @@ pub enum ObserverError { event_status: &'static str, observed_phase: MatchPhase, }, + #[error( + "event-derived candidate {expected} in tournament {tournament} disagrees with pinned candidate {observed:?}" + )] + EventCandidateMismatch { + tournament: Address, + expected: Digest, + observed: Option, + }, #[error("cannot observe resolved match {match_id_hash}")] ResolvedMatchSelected { match_id_hash: Digest }, #[error("tournament standing is not legal for this root/inner position")] @@ -133,11 +141,11 @@ pub async fn read_bond(chain: &Chain, tournament: Address, head: ChainHead) -> A /// All calls are pinned to `head.hash` and provider pressure is bounded. The /// event-derived tree supplies root/inner position and the exact parent match /// used to interpret an inner winner. It is not reconciled with redundant -/// match-count, final-state, or topology projections. Fields the decoded -/// standing does not carry (a nonterminal candidate, the finish and -/// winner-expiry instants, a non-active standing's join flag) go unchecked: -/// events own commitment placement, Hero acts only on current state, and a -/// value that disagrees with the standing must not stall a tick. +/// match-count, final-state, or topology projections. Finish and winner-expiry +/// instants and a non-active standing's join flag remain event-derived. The +/// active candidate is the one checked exception: it detects a missed pairing +/// without another point read. Otherwise a value that disagrees with the +/// standing must not stall a tick. pub async fn read_standings( chain: &Chain, dispute: &Dispute, @@ -173,6 +181,24 @@ pub async fn read_standings( Ok(decoded) } +/// Check the candidate only while waiting could lose an active match. +pub fn check_candidate( + tournament: Address, + expected: Digest, + standing: TournamentStanding, +) -> ObserverResult<()> { + if let TournamentStanding::MatchesActive { candidate, .. } = standing + && candidate != Some(expected) + { + return Err(ObserverError::EventCandidateMismatch { + tournament, + expected, + observed: candidate, + }); + } + Ok(()) +} + /// Observe the one event-selected live match needed for a Hero decision. /// /// Clocked and sealed-leaf matches need a timeout classification plus exactly @@ -332,6 +358,7 @@ fn decode_standing( let standing = match wire.standing { 0 => TournamentStanding::MatchesActive { accepts_joins: wire.acceptsJoins, + candidate: wire.hasCandidate.then(|| wire.candidate.into()), }, 1 => TournamentStanding::AwaitingClosure, 2 => { @@ -641,6 +668,25 @@ mod tests { } } + #[test] + fn a_candidate_is_checked_only_while_matches_are_active() { + for observed in [Some(digest(10)), Some(digest(20)), None] { + let result = check_candidate( + address(1), + digest(10), + TournamentStanding::MatchesActive { + accepts_joins: true, + candidate: observed, + }, + ); + assert_eq!(result.is_ok(), observed == Some(digest(10))); + } + assert!( + check_candidate(address(1), digest(10), TournamentStanding::AwaitingClosure).is_ok() + ); + assert!(check_candidate(address(1), digest(10), TournamentStanding::RootFailed).is_ok()); + } + fn bisecting_wire() -> AbiBisectingMatchView { AbiBisectingMatchView { revealingParent: hash(3), @@ -825,6 +871,7 @@ mod tests { decode_standing(root, None, standing_wire(0, false, Some(digest(66))),).unwrap(), TournamentStanding::MatchesActive { accepts_joins: false, + candidate: Some(digest(66)), } ); } diff --git a/cartesi-rollups/node/src/tournament/reader.rs b/cartesi-rollups/node/src/tournament/reader.rs index b501e44c..e3719895 100644 --- a/cartesi-rollups/node/src/tournament/reader.rs +++ b/cartesi-rollups/node/src/tournament/reader.rs @@ -63,9 +63,9 @@ impl StateReader { .map(|solid| (solid.head, &solid.dispute)) } - /// Drops Solid when `error` holds a pinned phase read that contradicts a - /// match's folded status, so the next fetch refolds from the root's - /// creation block as a restart would. A missed finalized seal or + /// Drops Solid when a pinned phase or active-candidate read contradicts + /// the folded match status or candidate, so the next fetch refolds from + /// the root's creation block as a restart would. A missed finalized seal or /// delegation still folds, and Foam extends Solid only past `F`, so /// otherwise every tick would fail the same way. A lagging or mixed Foam /// tail costs one refold; a persistent cause, one refold per tick. @@ -73,7 +73,10 @@ impl StateReader { let contradicted = error.chain().any(|cause| { matches!( cause.downcast_ref::(), - Some(observer::ObserverError::EventPhaseMismatch { .. }) + Some( + observer::ObserverError::EventPhaseMismatch { .. } + | observer::ObserverError::EventCandidateMismatch { .. } + ) ) }); if contradicted { @@ -1312,6 +1315,68 @@ mod tests { ); } + #[tokio::test] + async fn a_missing_pairing_is_refolded_when_the_candidate_disagrees() { + let root = address(1); + let finalized = head(41, 0x41); + let id = MatchID { + commitment_one: digest(10), + commitment_two: digest(20), + }; + let ours = join_log(root, finalized, 0, id.commitment_one); + let complete = vec![ + ours.clone(), + join_log(root, finalized, 1, id.commitment_two), + match_created_log(root, finalized, 2, id, 60), + ]; + let (chain, rpc, _) = recording_chain(); + let mut reader = StateReader::new(chain.clone(), 40); + let fold = |logs: &[Log]| { + rpc.push_success(&Some(block(finalized, B256::repeat_byte(0x40)))); + push_call_response::( + &rpc, + &descriptor_response(0, TournamentKind::Leaf), + ); + rpc.push_success(&logs); + rpc.push_success(&Some(block(finalized, B256::repeat_byte(0x40)))); + }; + fold(&[ours]); + reader.fetch_from_root(root).await.unwrap(); + assert!(matches!( + reader + .solid() + .unwrap() + .1 + .root() + .position(&id.commitment_one), + crate::tournament::dispute::CommitmentPosition::Candidate { .. } + )); + let error = observer::check_candidate( + root, + id.commitment_one, + crate::tournament::domain::TournamentStanding::MatchesActive { + accepts_joins: true, + candidate: None, + }, + ) + .unwrap_err() + .into(); + reader.forget_contradicted_solid(&error); + assert!(reader.solid().is_none()); + fold(&complete); + reader.fetch_from_root(root).await.unwrap(); + assert!(matches!( + reader + .solid() + .unwrap() + .1 + .root() + .position(&id.commitment_one), + crate::tournament::dispute::CommitmentPosition::Engaged { .. } + )); + assert!(rpc.read_q().is_empty()); + } + /// A missed finalized seal still folds, so only the Hero's pinned phase /// read sees it, and its failed tick hands that error back. #[tokio::test] diff --git a/docs/node-architecture.md b/docs/node-architecture.md index 309e36d3..65d8694b 100644 --- a/docs/node-architecture.md +++ b/docs/node-architecture.md @@ -585,12 +585,12 @@ Hero's tick, which then drops Solid the same way; a lagging or mixed Foam tail costs one such refold. Both drops repeat while their cause persists (a provider that keeps omitting the log, or a node bug): every tick then refolds and fails with the same warning, adding reads to a stall that exists anyway. -Any other omission that still folds, such as a missed advance, a missed join -until a match pairs it, or a missed deletion of a delegated match (the Hero -reads nothing there), stays silent, and one may surface too late: a -missed creation of the Hero's own match, when the Hero moves first, fails to -fold only at the opponent's timeout deletion. Log completeness is a trusted -RPC property. +Before waiting as a candidate under active matches, Hero checks the candidate +root against the standing already read at the event head, with no extra RPC. A +missing pairing that leaves it falsely idle therefore drops Solid and +refetches. Other omissions that still fold, such as a missed advance or +deletion outside Hero's path, can remain silent. These checks help detect +contradictions; log completeness remains a trusted RPC property. After Solid advances, the reader samples latest `H`, deep-clones Solid, and recursively extends the clone over the numeric range `F + 1..H`. This latest From 2e48dcbbf2d961bc82b3ccbb4d70604f90c92963 Mon Sep 17 00:00:00 2001 From: Stephen Chen <20940639+stephenctw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:14:58 +0800 Subject: [PATCH 7/7] ci: install pinned just assets directly Release-list discovery reported the published pinned just version missing and stopped CI before tests. Fetch the exact Linux assets for both host architectures and verify their checksums before exposing the executable. Keep the version pinned: floating latest silently changes CI's toolchain. The native installer and both archive checksums were verified locally. --- .github/actions/setup-tools/action.yml | 33 ++++++++++++++++++++------ CHANGELOG.md | 2 ++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-tools/action.yml b/.github/actions/setup-tools/action.yml index 591d653c..2c2354f2 100644 --- a/.github/actions/setup-tools/action.yml +++ b/.github/actions/setup-tools/action.yml @@ -33,13 +33,32 @@ runs: cache-bin: false - name: Install just - uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4.0.0 - with: - # Pinned like the other toolchain components: a floating "latest" - # upgrades CI's toolchain silently mid-week (1.56 -> 1.57 - # landed between two runs of this workflow and muddied a - # two-day CI hunt). Bump deliberately. - just-version: "1.57.0" + shell: bash + run: | + # Pin deliberately: floating latest silently changes CI's toolchain. + # Fetch the pinned asset directly: release-list lookup has reported + # this published version missing during CI setup. + version=1.57.0 + case "$(uname -m)" in + x86_64) + target=x86_64-unknown-linux-musl + checksum=45b548094283cb9739af8f13273b8cddeee869f5b4ef2bb631b1f311cb566155 + ;; + aarch64) + target=aarch64-unknown-linux-musl + checksum=f225044a81adea6e0b3a8b9370aaf374e6af76c8735ae263ac993df55fd137ec + ;; + *) echo "Unsupported just host architecture: $(uname -m)" >&2; exit 1 ;; + esac + install_dir=$(mktemp -d "${RUNNER_TEMP}/just.XXXXXX") + archive="${install_dir}/just.tar.gz" + curl --fail --location --retry 3 \ + "https://github.com/casey/just/releases/download/${version}/just-${version}-${target}.tar.gz" \ + --output "$archive" + printf '%s %s\n' "$checksum" "$archive" | sha256sum --check + tar -xzf "$archive" -C "$install_dir" just + "$install_dir/just" --version + echo "$install_dir" >> "$GITHUB_PATH" - name: Set Foundry version id: set-foundry-version diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ef4596b..ac2b82b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ past the span. - Rebuild the devnet bundle with `just rollups-contracts::build-devnet` after the contract changes. +- Fetch pinned just assets directly and verify their checksums instead of + relying on release-list discovery. ## v3.0.0-alpha.6