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 55cd8571..ac2b82b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## Unreleased + +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 +past the span. + +### Contracts + +- 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 + +- 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. +- 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. +- Check a candidate against the already-read standing before waiting under + active matches; a contradiction discards and refolds the event cache. + +### For developers + +- 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 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..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 @@ -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,15 @@ 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`. + 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 +126,17 @@ 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 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 +`DaveConsensus`. When changing staging, sentry or settlement logic, add focused +tests rather than assuming the capacity fixture covers it. ## Explicit non-claims @@ -140,3 +147,8 @@ expected. 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/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/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/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/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/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/README.md b/cartesi-rollups/node/README.md index 6fa77a5b..d0f413cc 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 @@ -396,11 +397,12 @@ 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 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. +- 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. - 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 +444,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/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/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/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/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 1831cf4c..a57064ad 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( @@ -105,8 +119,9 @@ 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(&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 +136,7 @@ impl World { chain, book, geometry, + template, checked, mined: vec![], }; @@ -453,7 +469,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 @@ -568,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 a6251861..990e8e6f 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,213 @@ 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<()> { + 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/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/lib.rs b/cartesi-rollups/node/src/lib.rs index d58cc9ef..b784a45f 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; @@ -107,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/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/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/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/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..3f20acbb 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -26,6 +26,26 @@ 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. + +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 @@ -57,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 @@ -67,16 +101,31 @@ 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. +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. + 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..65d8694b 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 @@ -245,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 @@ -260,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 @@ -587,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 diff --git a/docs/plans/audit-readiness.md b/docs/plans/audit-readiness.md index bcaf2b5d..0e33f3ba 100644 --- a/docs/plans/audit-readiness.md +++ b/docs/plans/audit-readiness.md @@ -68,7 +68,12 @@ 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). +- 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/test-harness.md b/docs/test-harness.md index d1ca1fe2..3afc6a3e 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -249,6 +249,14 @@ 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 +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 +361,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 48e013b5..46e0a551 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 @@ -51,6 +47,11 @@ carry no backlog (reviews/README.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 @@ -68,9 +69,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 @@ -116,6 +118,12 @@ carry no backlog (reviews/README.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) - 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,20 +155,11 @@ 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 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 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 }