From 89cbe0e7de1e0d153c164950971ff787dca1c878 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Thu, 27 Aug 2026 09:37:12 -0300 Subject: [PATCH] feat!(node): stage machine validity proofs --- Cargo.lock | 11 - Cargo.toml | 1 - README.md | 11 +- cartesi-rollups/node/Cargo.toml | 1 - cartesi-rollups/node/src/args.rs | 2 +- cartesi-rollups/node/src/bin/measure.rs | 2 +- .../node/src/blockchain_reader/mod.rs | 2 +- cartesi-rollups/node/src/engine/config.rs | 8 +- cartesi-rollups/node/src/engine/constants.rs | 114 ++++- cartesi-rollups/node/src/engine/dispute.rs | 2 +- .../node/src/engine/machine_stf.rs | 4 +- cartesi-rollups/node/src/engine/spec.rs | 4 +- cartesi-rollups/node/src/epoch_manager/mod.rs | 81 +++- cartesi-rollups/node/src/storage/advance.rs | 95 ++-- cartesi-rollups/node/src/storage/convert.rs | 4 +- cartesi-rollups/node/src/storage/dispute.rs | 4 +- cartesi-rollups/node/src/storage/mod.rs | 87 ++-- cartesi-rollups/node/src/storage/open.rs | 14 +- cartesi-rollups/node/src/storage/queries.rs | 323 +++++++++++++- .../node/src/storage/rollups_machine.rs | 419 +++++++++++++++++- cartesi-rollups/node/src/storage/snapshots.rs | 4 +- .../node/src/storage/sql/discipline.rs | 95 +++- .../node/src/storage/sql/migrations.rs | 64 --- cartesi-rollups/node/src/storage/sql/mod.rs | 7 +- .../node/src/storage/sql/schema.rs | 241 ++++++++++ .../sql/{migrations.sql => schema.sql} | 122 +++-- .../node/src/storage/sql/test_helper.rs | 16 +- .../inject_batch_boundary_failure.sql | 6 + .../testdata/obsolete_settlement_schema.sql | 4 + .../remove_batch_boundary_failure.sql | 1 + cartesi-rollups/node/src/tournament/reader.rs | 21 +- cartesi-rollups/node/tests/engine_machine.rs | 20 +- docs/build-system.md | 34 +- docs/computation-hash.md | 2 +- docs/epoch-lifecycle.md | 4 +- docs/node-architecture.md | 32 +- docs/test-harness.md | 3 + justfile | 17 +- script/bootstrap-worktree.sh | 12 +- script/doctor.sh | 4 +- test/e2e/rollups/dave/node.lua | 2 +- test/programs/script/doctor.sh | 19 +- 42 files changed, 1578 insertions(+), 341 deletions(-) delete mode 100644 cartesi-rollups/node/src/storage/sql/migrations.rs create mode 100644 cartesi-rollups/node/src/storage/sql/schema.rs rename cartesi-rollups/node/src/storage/sql/{migrations.sql => schema.sql} (74%) create mode 100644 cartesi-rollups/node/src/storage/sql/testdata/inject_batch_boundary_failure.sql create mode 100644 cartesi-rollups/node/src/storage/sql/testdata/obsolete_settlement_schema.sql create mode 100644 cartesi-rollups/node/src/storage/sql/testdata/remove_batch_boundary_failure.sql diff --git a/Cargo.lock b/Cargo.lock index 54007370f..8db9cf2c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1735,7 +1735,6 @@ dependencies = [ "reqwest", "ruint", "rusqlite", - "rusqlite_migration", "serde_json", "tempfile", "testcontainers-modules", @@ -4368,16 +4367,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rusqlite_migration" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55709bc01054c69e2f1cefdc886642b5e6376a8db3c86f761be0c423eebf178b" -dependencies = [ - "log", - "rusqlite", -] - [[package]] name = "rustc-hash" version = "2.1.1" diff --git a/Cargo.toml b/Cargo.toml index 118f5c754..b86325554 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,7 +77,6 @@ tokio = { version = "1", features = ["full"] } # sqlite lazy_static = "1.4" rusqlite = { version = "0.31.0", features = ["bundled", "functions"] } -rusqlite_migration = "1.2.0" clap = { version = "4.5", features = ["derive", "env"] } hex = "0.4" diff --git a/README.md b/README.md index 8bfb223f9..cd34625f8 100644 --- a/README.md +++ b/README.md @@ -153,14 +153,15 @@ smaller build and test prerequisites as well. At any point, run: ```bash -just doctor # build and pre-commit-check readiness -just doctor-e2e # machine images, devnet, and E2E state +just doctor # build/check readiness, including echo and yield images +just doctor-e2e # full E2E images, devnet, and E2E state just doctor-all # both scopes ``` Each command prints the fix for anything missing. Keeping E2E artifacts out of -the base doctor prevents an otherwise build-ready checkout from appearing -permanently unhealthy. `just --list` shows every available recipe, and -`just check` is the pre-commit gate. +the base doctor, except for the echo/yield images used by standard Rust tests, +prevents an otherwise check-ready checkout from appearing permanently +unhealthy. `just --list` shows every available recipe, and `just check` is the +pre-commit gate. ### Running Examples diff --git a/cartesi-rollups/node/Cargo.toml b/cartesi-rollups/node/Cargo.toml index a971b3ee6..ec94c3046 100644 --- a/cartesi-rollups/node/Cargo.toml +++ b/cartesi-rollups/node/Cargo.toml @@ -46,7 +46,6 @@ tokio = { workspace = true } lazy_static = { workspace = true } log = { workspace = true } rusqlite = { workspace = true } -rusqlite_migration = { workspace = true } hex = { workspace = true } serde_json = "1.0" tempfile = "3" diff --git a/cartesi-rollups/node/src/args.rs b/cartesi-rollups/node/src/args.rs index 68cc21eca..1d1118173 100644 --- a/cartesi-rollups/node/src/args.rs +++ b/cartesi-rollups/node/src/args.rs @@ -288,7 +288,7 @@ impl NodeConfig { .web3_submit_rpc_url .unwrap_or_else(|| args.web3_rpc_url.clone()); - let mut storage = Storage::migrate( + let mut storage = Storage::initialize( &args.state_dir, &args.machine_path, address_book.genesis_block_number, diff --git a/cartesi-rollups/node/src/bin/measure.rs b/cartesi-rollups/node/src/bin/measure.rs index 026a389c0..a71447029 100644 --- a/cartesi-rollups/node/src/bin/measure.rs +++ b/cartesi-rollups/node/src/bin/measure.rs @@ -501,7 +501,7 @@ fn bench_quartets( let mut results = Vec::new(); for (index, (label, log2_stride, height)) in spans.into_iter().enumerate() { let state_dir = scratch(scratch_root, &format!("quartet-{index}"))?; - let mut storage = Storage::migrate(&state_dir, image, 0, Address::ZERO)?; + let mut storage = Storage::initialize(&state_dir, image, 0, Address::ZERO)?; let rows: Vec = inputs .iter() .enumerate() diff --git a/cartesi-rollups/node/src/blockchain_reader/mod.rs b/cartesi-rollups/node/src/blockchain_reader/mod.rs index 16880de3f..5fcb9091d 100644 --- a/cartesi-rollups/node/src/blockchain_reader/mod.rs +++ b/cartesi-rollups/node/src/blockchain_reader/mod.rs @@ -426,7 +426,7 @@ mod blockchain_reader_tests { .unwrap(); machine.store(&machine_path).unwrap(); - let acc = Storage::migrate(state_dir, &machine_path, 0, Address::ZERO).unwrap(); + let acc = Storage::initialize(state_dir, &machine_path, 0, Address::ZERO).unwrap(); (state_dir_, acc) } diff --git a/cartesi-rollups/node/src/engine/config.rs b/cartesi-rollups/node/src/engine/config.rs index 2248fb9c8..8cd71e065 100644 --- a/cartesi-rollups/node/src/engine/config.rs +++ b/cartesi-rollups/node/src/engine/config.rs @@ -4,8 +4,8 @@ //! The write-once configuration: everything contextual the cache rows //! deliberately do not carry. //! -//! This is migration-time state: the node's migration owns the DDL -//! (storage/sql/migrations.sql) and `pin` writes the row exactly once +//! This is initialization-time state: the node's schema owns the DDL +//! (storage/sql/schema.sql) and `pin` writes the row exactly once //! at database creation; the dispute module only reads and asserts //! (`assert_compatible`). @@ -23,7 +23,7 @@ pub struct EngineConfig { } /// Pins the configuration, once per database; the schema comes from -/// the node migration. Idempotent for an identical configuration; any +/// node initialization. Idempotent for an identical configuration; any /// drift is refused. pub fn pin(connection: &Connection, config: &EngineConfig) -> Result<()> { config.structure.assert_valid(); @@ -111,7 +111,7 @@ mod tests { fn config_is_write_once() -> Result<()> { let dir = tempfile::tempdir()?; let path = dir.path().join("cache.db"); - crate::storage::sql::migrations::migrate_to_latest(&mut Connection::open(&path)?)?; + crate::storage::sql::schema::initialize(&Connection::open(&path)?)?; let structure = Structure { log2_input_span: 1, log2_barch_span: 1, diff --git a/cartesi-rollups/node/src/engine/constants.rs b/cartesi-rollups/node/src/engine/constants.rs index eab609094..aa09dc45c 100644 --- a/cartesi-rollups/node/src/engine/constants.rs +++ b/cartesi-rollups/node/src/engine/constants.rs @@ -36,11 +36,38 @@ pub use cartesi_machine::constants::ar::SHADOW_REVERT_ROOT_HASH_START as CHECKPO #[cfg(test)] mod tests { use super::{CHECKPOINT_ADDRESS, LOG2_EPOCH_RULER_SPAN}; - use cartesi_machine::constants::rollup::{ - LOG2_MAX_ADVANCE_STATES_PER_EPOCH, LOG2_MAX_MCYCLES_PER_ADVANCE_STATE, - LOG2_MAX_UARCH_CYCLES_PER_MCYCLE, + use cartesi_machine::{ + Machine, + cartesi_machine_sys::{ + CM_HTIF_CMD_MASK, CM_HTIF_CMD_SHIFT, CM_HTIF_DEV_MASK, CM_HTIF_DEV_SHIFT, + CM_HTIF_DEV_YIELD, CM_HTIF_REASON_MASK, CM_HTIF_REASON_SHIFT, CM_HTIF_YIELD_CMD_MANUAL, + CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED, CM_REG_HTIF_TOHOST, CM_REG_IFLAGS_Y, + }, + constants::{ + ar::TX_START, + machine::{HASH_TREE_LOG2_ROOT_SIZE, HASH_TREE_LOG2_WORD_SIZE}, + rollup::{ + LOG2_MAX_ADVANCE_STATES_PER_EPOCH, LOG2_MAX_MCYCLES_PER_ADVANCE_STATE, + LOG2_MAX_UARCH_CYCLES_PER_MCYCLE, + }, + }, }; + fn solidity_constant(source: &str, marker: &str) -> u64 { + let pos = source + .find(marker) + .unwrap_or_else(|| panic!("{marker} not found in contract source")); + let after = &source[pos + marker.len()..]; + let eq = after.find('=').expect("expected `=` after constant name"); + let semi = after.find(';').expect("expected `;` after constant value"); + let value = after[eq + 1..semi].trim(); + value + .strip_prefix("0x") + .map(|hex| u64::from_str_radix(hex, 16)) + .unwrap_or_else(|| value.parse()) + .unwrap_or_else(|_| panic!("{marker} is not a numeric Solidity constant")) + } + /// Guardrail: step's `EmulatorConstants.sol` is auto-generated from the /// emulator C++ source, and `REVERT_ROOT_HASH_ADDRESS` must equal the /// emulator's `CM_AR_SHADOW_REVERT_ROOT_HASH_START` - otherwise the @@ -60,22 +87,7 @@ mod tests { let source = std::fs::read_to_string(&emulator_constants_sol) .unwrap_or_else(|e| panic!("failed to read {}: {e}", emulator_constants_sol.display())); - // Find: `uint64 constant REVERT_ROOT_HASH_ADDRESS = 0x;` - let marker = "REVERT_ROOT_HASH_ADDRESS"; - let pos = source.find(marker).unwrap_or_else(|| { - panic!("{marker} not found in {}", emulator_constants_sol.display()) - }); - let after = &source[pos + marker.len()..]; - let eq = after.find('=').expect("expected `=` after constant name"); - let semi = after.find(';').expect("expected `;` after constant value"); - let value_str = after[eq + 1..semi].trim(); - let step_value = if let Some(hex) = value_str.strip_prefix("0x") { - u64::from_str_radix(hex, 16).expect("REVERT_ROOT_HASH_ADDRESS not valid hex") - } else { - value_str - .parse::() - .expect("REVERT_ROOT_HASH_ADDRESS not valid decimal") - }; + let step_value = solidity_constant(&source, "REVERT_ROOT_HASH_ADDRESS"); assert_eq!( CHECKPOINT_ADDRESS, step_value, @@ -85,6 +97,70 @@ mod tests { ); } + #[test] + fn machine_validity_proof_geometry_matches_solidity() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let root = manifest_dir.join("../.."); + let emulator_constants = std::fs::read_to_string(root.join( + "cartesi-rollups/contracts/dependencies/\ + cartesi-rollups-contracts-3.0.0-alpha.9/dependencies/\ + cartesi-machine-solidity-step-0.15.0/src/EmulatorConstants.sol", + )) + .expect("read DaveConsensus's vendored EmulatorConstants.sol"); + let memory = std::fs::read_to_string(root.join( + "cartesi-rollups/contracts/dependencies/\ + cartesi-rollups-contracts-3.0.0-alpha.9/dependencies/\ + cartesi-machine-solidity-step-0.15.0/src/Memory.sol", + )) + .expect("read DaveConsensus's vendored Memory.sol"); + let canonical_machine = std::fs::read_to_string(root.join( + "cartesi-rollups/contracts/dependencies/\ + cartesi-rollups-contracts-3.0.0-alpha.9/src/common/CanonicalMachine.sol", + )) + .expect("read CanonicalMachine.sol"); + + assert_eq!( + Machine::reg_address(CM_REG_IFLAGS_Y).unwrap(), + solidity_constant(&emulator_constants, "IFLAGS_Y_ADDRESS") + ); + assert_eq!( + Machine::reg_address(CM_REG_HTIF_TOHOST).unwrap(), + solidity_constant(&emulator_constants, "HTIF_TOHOST_ADDRESS") + ); + assert_eq!( + TX_START, + solidity_constant(&emulator_constants, "AR_CMIO_TX_BUFFER_START") + ); + assert_eq!( + u64::from(HASH_TREE_LOG2_WORD_SIZE), + solidity_constant(&memory, "uint8 constant LOG2_LEAF") + ); + assert_eq!( + u64::from(HASH_TREE_LOG2_ROOT_SIZE), + solidity_constant(&canonical_machine, "LOG2_MEMORY_SIZE") + ); + for (marker, emulator_value) in [ + ("HTIF_DEV_MASK", CM_HTIF_DEV_MASK), + ("HTIF_CMD_MASK", CM_HTIF_CMD_MASK), + ("HTIF_REASON_MASK", CM_HTIF_REASON_MASK), + ("HTIF_DEV_SHIFT", u64::from(CM_HTIF_DEV_SHIFT)), + ("HTIF_CMD_SHIFT", u64::from(CM_HTIF_CMD_SHIFT)), + ("HTIF_REASON_SHIFT", u64::from(CM_HTIF_REASON_SHIFT)), + ("HTIF_DEV_YIELD", u64::from(CM_HTIF_DEV_YIELD)), + ("HTIF_YIELD_CMD_MANUAL", u64::from(CM_HTIF_YIELD_CMD_MANUAL)), + ( + "HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED", + u64::from(CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED), + ), + ] { + assert_eq!( + emulator_value, + solidity_constant(&emulator_constants, marker), + "Cartesi Machine and DaveConsensus disagree on {marker}" + ); + } + } + /// The first number appearing after `marker` in `source` (digits /// only, delimiters skipped): dumb but loud, like the parser above. fn first_number_after(source: &str, marker: &str) -> u64 { diff --git a/cartesi-rollups/node/src/engine/dispute.rs b/cartesi-rollups/node/src/engine/dispute.rs index d417c8955..1da9151f8 100644 --- a/cartesi-rollups/node/src/engine/dispute.rs +++ b/cartesi-rollups/node/src/engine/dispute.rs @@ -184,7 +184,7 @@ impl DisputeSource { // window's root stores its fanout there - and counting those // once bricked reconstruction after the hero's own join. A // store the runner never processed (the engine harnesses; a - // freshly migrated node) has an empty prefix and the machine + // freshly initialized node) has an empty prefix and the machine // serves everything - the pre-frontier full-replay behavior. // A nonzero prefix must match the closed epoch's input count // exactly; anything else is corruption. The padding value is diff --git a/cartesi-rollups/node/src/engine/machine_stf.rs b/cartesi-rollups/node/src/engine/machine_stf.rs index 7559da637..c4fbb9929 100644 --- a/cartesi-rollups/node/src/engine/machine_stf.rs +++ b/cartesi-rollups/node/src/engine/machine_stf.rs @@ -466,7 +466,7 @@ impl ProvingStf for MachineStf { /// from the boundary store's nearest stored machine and advancing /// the remainder. The store is live: boundaries recorded by any /// writer (the open regime's gap fill, a future dispute write-back) -/// shorten the next positioning. On a freshly migrated store only +/// shorten the next positioning. On a freshly initialized store only /// the epoch start exists, which is the full-replay behavior the /// prototype had. Constructed only by [`DisputeSource::on_store`]; /// the type is public for signatures alone. @@ -488,7 +488,7 @@ pub struct Positioner { /// engine pieces. impl DisputeSource { pub fn on_store(mut storage: Storage, epoch: u64, work_dir: PathBuf) -> Result { - // The migration pinned the config; assert engine + // Initialization pinned the config; assert engine // compatibility before serving any quartet. let structure = Structure::PRODUCTION; super::config::assert_compatible( diff --git a/cartesi-rollups/node/src/engine/spec.rs b/cartesi-rollups/node/src/engine/spec.rs index 7d45f3922..79ed57c34 100644 --- a/cartesi-rollups/node/src/engine/spec.rs +++ b/cartesi-rollups/node/src/engine/spec.rs @@ -185,8 +185,8 @@ pub(crate) fn toy_storage(structure: Structure) -> Storage { emulator_version: "toy".into(), }; let dir = tempfile::tempdir().unwrap().keep(); - let mut connection = Connection::open(dir.join("db.sqlite3")).unwrap(); - crate::storage::sql::migrations::migrate_to_latest(&mut connection).unwrap(); + let connection = Connection::open(dir.join("db.sqlite3")).unwrap(); + crate::storage::sql::schema::initialize(&connection).unwrap(); super::config::pin(&connection, &config).unwrap(); Storage::new(&dir).unwrap() } diff --git a/cartesi-rollups/node/src/epoch_manager/mod.rs b/cartesi-rollups/node/src/epoch_manager/mod.rs index 111afe437..2d55ab5b3 100644 --- a/cartesi-rollups/node/src/epoch_manager/mod.rs +++ b/cartesi-rollups/node/src/epoch_manager/mod.rs @@ -13,7 +13,10 @@ use std::{sync::Arc, time::Duration}; use crate::chain::Chain; use crate::provider::{LaneRequest, TransactionLane}; -use crate::storage::{Epoch, Proof, Storage}; +use crate::storage::{ + Epoch, LeafProof as StoredLeafProof, MachineValidityProof as StoredMachineValidityProof, + Storage, +}; use crate::sync::ShutdownSignal; use crate::{ hero::{Hero, HeroTick, TournamentResult}, @@ -320,8 +323,7 @@ impl EpochManager { let request = dave_consensus .stageTournamentResult( can_stage.epochNumber, - vec_u8_to_bytes_32(settlement.outputs_merkle_root.into()), - to_bytes_32_vec(settlement.outputs_merkle_root_proof), + to_machine_validity_proof(settlement.machine_validity_proof), ) .gas(gas_limit()) .into_transaction_request(); @@ -362,7 +364,7 @@ impl EpochManager { "Staged final state mismatch, notify all users!" ); assert_eq!( - vec_u8_to_bytes_32(settlement.outputs_merkle_root.into()), + vec_u8_to_bytes_32(settlement.outputs_merkle_root().into()), can_accept.stagedPostEpochOutputsMerkleRoot, "Staged outputs Merkle root mismatch, notify all users!" ); @@ -506,8 +508,21 @@ impl EpochManager { } } -fn to_bytes_32_vec(proof: Proof) -> Vec { - proof.inner().iter().map(B256::from).collect() +fn to_leaf_proof(proof: StoredLeafProof) -> DaveConsensus::LeafProof { + DaveConsensus::LeafProof { + dataBlock: B256::from(proof.data_block), + siblings: proof.siblings.inner().iter().map(B256::from).collect(), + } +} + +fn to_machine_validity_proof( + proof: StoredMachineValidityProof, +) -> DaveConsensus::MachineValidityProof { + DaveConsensus::MachineValidityProof { + iflagsYProof: to_leaf_proof(proof.iflags_y_proof), + htifTohostProof: to_leaf_proof(proof.htif_tohost_proof), + txBufferProof: to_leaf_proof(proof.tx_buffer_proof), + } } fn vec_u8_to_bytes_32(hash: Vec) -> B256 { @@ -521,7 +536,61 @@ fn finalized_epoch_matches_latest(finalized: Option, latest: U256) -> bool #[cfg(test)] mod tests { use super::*; + use crate::storage::{ + LeafProof, MACHINE_MEMORY_PROOF_SIBLING_COUNT, MachineValidityProof, Proof, + }; use alloy::rpc::types::TransactionRequest; + use alloy::sol_types::SolCall; + + fn proof_leaf(data_byte: u8, sibling_byte: u8) -> LeafProof { + LeafProof { + data_block: [data_byte; 32], + siblings: Proof::new(vec![[sibling_byte; 32]; MACHINE_MEMORY_PROOF_SIBLING_COUNT]) + .unwrap(), + } + } + + #[test] + fn stage_tournament_result_encodes_machine_validity_proof() { + let proof = MachineValidityProof { + iflags_y_proof: proof_leaf(0x11, 0xA1), + htif_tohost_proof: proof_leaf(0x22, 0xA2), + tx_buffer_proof: proof_leaf(0x33, 0xA3), + }; + let call = DaveConsensus::stageTournamentResultCall { + epochNumber: U256::from(7), + proof: to_machine_validity_proof(proof), + }; + + let encoded = call.abi_encode(); + let decoded = DaveConsensus::stageTournamentResultCall::abi_decode(&encoded).unwrap(); + + assert_eq!(decoded.epochNumber, U256::from(7)); + assert_eq!( + decoded.proof.iflagsYProof.dataBlock, + B256::repeat_byte(0x11) + ); + assert_eq!( + decoded.proof.htifTohostProof.dataBlock, + B256::repeat_byte(0x22) + ); + assert_eq!( + decoded.proof.txBufferProof.dataBlock, + B256::repeat_byte(0x33) + ); + assert_eq!( + decoded.proof.iflagsYProof.siblings, + vec![B256::repeat_byte(0xA1); MACHINE_MEMORY_PROOF_SIBLING_COUNT] + ); + assert_eq!( + decoded.proof.htifTohostProof.siblings, + vec![B256::repeat_byte(0xA2); MACHINE_MEMORY_PROOF_SIBLING_COUNT] + ); + assert_eq!( + decoded.proof.txBufferProof.siblings, + vec![B256::repeat_byte(0xA3); MACHINE_MEMORY_PROOF_SIBLING_COUNT] + ); + } fn tick_wave(labels: &[&str]) -> Vec { labels diff --git a/cartesi-rollups/node/src/storage/advance.rs b/cartesi-rollups/node/src/storage/advance.rs index 0f6d8576a..00fc9340c 100644 --- a/cartesi-rollups/node/src/storage/advance.rs +++ b/cartesi-rollups/node/src/storage/advance.rs @@ -547,8 +547,7 @@ impl Storage { ); let computation_hash = self.settlement_root(&mut machine)?; - let (outputs_merkle_root, outputs_merkle_root_proof) = - machine.outputs_merkle_root_with_proof()?; + let (proof_root, machine_validity_proof) = machine.machine_validity_proof()?; machine.finish_epoch(); @@ -559,13 +558,17 @@ impl Storage { .store_boundary(&mut machine) .map_err(anyhow::Error::from)?; + assert_eq!( + state_hash, proof_root, + "stored post-epoch boundary differs from the machine validity proof root" + ); + // The post-epoch state the settlement protocol claims and // stages is exactly the new epoch's initial boundary. let settlement = Settlement { computation_hash, final_state: state_hash, - outputs_merkle_root, - outputs_merkle_root_proof, + machine_validity_proof, }; let orphans = self.write(|tx| { @@ -676,24 +679,41 @@ pub(super) fn insert_settlement_in( settlement: &Settlement, epoch_number: u64, ) -> Result<()> { + super::rollups_machine::validate_machine_validity_proof( + settlement.final_state, + &settlement.machine_validity_proof, + ) + .unwrap_or_else(|error| { + panic!("refusing invalid settlement proof for epoch {epoch_number}: {error:#}") + }); + let mut stmt = tx .prepare_cached( r#" INSERT INTO settlement_info - (epoch_number, computation_hash, outputs_merkle_root, outputs_merkle_root_proof, final_state) - VALUES (?1, ?2, ?3, ?4, ?5) + (epoch_number, computation_hash, final_state, + iflags_y_data_block, iflags_y_siblings, + htif_tohost_data_block, htif_tohost_siblings, + tx_buffer_data_block, tx_buffer_siblings) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT (epoch_number) DO NOTHING "#, ) .map_err(anyhow::Error::from)?; + let proof = &settlement.machine_validity_proof; + let count = stmt .execute(params![ u64_to_i64(epoch_number), settlement.computation_hash.data(), - &settlement.outputs_merkle_root, - &settlement.outputs_merkle_root_proof.flatten(), &settlement.final_state, + &proof.iflags_y_proof.data_block, + &proof.iflags_y_proof.siblings.flatten(), + &proof.htif_tohost_proof.data_block, + &proof.htif_tohost_proof.siblings.flatten(), + &proof.tx_buffer_proof.data_block, + &proof.tx_buffer_proof.siblings.flatten(), ]) .map_err(anyhow::Error::from)?; @@ -770,10 +790,11 @@ fn dir_size(path: &Path) -> u64 { #[cfg(test)] mod tests { + use super::super::queries::{setup_settlement_storage, test_settlement}; use super::super::sql::test_helper::setup_storage; use super::*; use crate::merkle::MerkleBuilder; - use crate::storage::{Epoch, Proof}; + use crate::storage::Epoch; use alloy::primitives::Address; /// A window's runs for tests: arbitrary interior, tail carrying @@ -967,15 +988,10 @@ mod tests { #[test] fn settlement_absorbs_identical_refuses_drift() { - let (_handle, mut s) = setup_storage(); + let (_handle, mut s) = setup_settlement_storage(); assert!(s.settlement_info(42).unwrap().is_none()); - let settlement = Settlement { - computation_hash: [0xAA; 32].into(), - final_state: [0xDD; 32], - outputs_merkle_root: [0xBB; 32], - outputs_merkle_root_proof: Proof::new(vec![[0; 32]]), - }; + let settlement = test_settlement(); s.write(|tx| insert_settlement_in(tx, &settlement, 42)) .unwrap(); assert_eq!(s.settlement_info(42).unwrap().unwrap(), settlement); @@ -988,21 +1004,38 @@ mod tests { #[test] #[should_panic(expected = "nondeterminism or corruption")] fn settlement_drift_panics() { - let (_handle, mut s) = setup_storage(); - let settlement = Settlement { - computation_hash: [0xAA; 32].into(), - final_state: [0xDD; 32], - outputs_merkle_root: [0xBB; 32], - outputs_merkle_root_proof: Proof::new(vec![[0; 32]]), - }; + let (_handle, mut s) = setup_settlement_storage(); + let settlement = test_settlement(); s.write(|tx| insert_settlement_in(tx, &settlement, 42)) .unwrap(); let mut drifted = settlement.clone(); - drifted.outputs_merkle_root = [0xCC; 32]; + drifted.computation_hash = [0xCC; 32].into(); let _ = s.write(|tx| insert_settlement_in(tx, &drifted, 42)); } + #[test] + fn invalid_settlement_proof_is_not_persisted() { + let (_handle, mut s) = setup_settlement_storage(); + let mut settlement = test_settlement(); + settlement + .machine_validity_proof + .htif_tohost_proof + .data_block[0] ^= 1; + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = s.write(|tx| insert_settlement_in(tx, &settlement, 42)); + })) + .expect_err("invalid settlement proof must panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or("non-string panic"); + assert!(message.contains("refusing invalid settlement proof")); + assert!(s.settlement_info(42).unwrap().is_none()); + } + #[test] fn gap_three_plan_waits_open_and_materializes_the_sealed_tail() { let (_handle, mut s) = setup_storage(); @@ -1309,11 +1342,9 @@ mod tests { append_inputs(&mut s, 0, 3); let raw = rusqlite::Connection::open(crate::storage::open::db_path(s.state_dir())).unwrap(); - raw.execute_batch( - "CREATE TRIGGER fail_batch_boundary BEFORE INSERT ON epoch_snapshot_info - WHEN NEW.epoch_number = 0 AND NEW.input_number = 3 - BEGIN SELECT RAISE(ABORT, 'injected boundary failure'); END;", - ) + raw.execute_batch(include_str!( + "sql/testdata/inject_batch_boundary_failure.sql" + )) .unwrap(); let (batch, final_hash) = record_mutated_batch(&mut s, 3); @@ -1343,8 +1374,10 @@ mod tests { "the failed commit must not leave window-root rows" ); - raw.execute_batch("DROP TRIGGER fail_batch_boundary") - .unwrap(); + raw.execute_batch(include_str!( + "sql/testdata/remove_batch_boundary_failure.sql" + )) + .unwrap(); let (batch, replay_hash) = record_mutated_batch(&mut s, 3); assert_eq!(replay_hash, final_hash); diff --git a/cartesi-rollups/node/src/storage/convert.rs b/cartesi-rollups/node/src/storage/convert.rs index 0d4604316..0c1d6ab6d 100644 --- a/cartesi-rollups/node/src/storage/convert.rs +++ b/cartesi-rollups/node/src/storage/convert.rs @@ -4,8 +4,8 @@ //! Conversions at the SQLite boundary. Integers saturate and blobs //! produce structured errors: the domain values we persist are always //! non-negative, well within i64, and exactly 32 bytes where hashes -//! are concerned, so a violation means a corrupted or foreign row - -//! which should degrade or error, never crash the process. +//! are concerned. Callers decide whether a corrupted or foreign row is +//! recoverable or a durable invariant that must fail loudly. use crate::merkle::Digest; use anyhow::anyhow; diff --git a/cartesi-rollups/node/src/storage/dispute.rs b/cartesi-rollups/node/src/storage/dispute.rs index fb47b4f38..c536a1fd5 100644 --- a/cartesi-rollups/node/src/storage/dispute.rs +++ b/cartesi-rollups/node/src/storage/dispute.rs @@ -23,12 +23,12 @@ use alloy::{ use rusqlite::{OptionalExtension, params}; impl Storage { - /// The pinned engine configuration; the migration writes it once. + /// The pinned engine configuration; initialization writes it once. pub fn sling_config(&self) -> Result { crate::engine::config::stored(&self.connection) .map_err(StorageError::InnerError)? .ok_or_else(|| StorageError::DataNotFound { - description: "engine config row (the migration pins it)".into(), + description: "engine config row (initialization pins it)".into(), }) } diff --git a/cartesi-rollups/node/src/storage/mod.rs b/cartesi-rollups/node/src/storage/mod.rs index e466a80af..a7c1b7273 100644 --- a/cartesi-rollups/node/src/storage/mod.rs +++ b/cartesi-rollups/node/src/storage/mod.rs @@ -36,41 +36,51 @@ use self::error::Result; use crate::merkle::Digest; use alloy::primitives::Address; use cartesi_machine::types::Hash; +use std::fmt; pub type Blob = Vec; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Proof(Vec<[u8; 32]>); +pub const MACHINE_MEMORY_PROOF_SIBLING_COUNT: usize = 59; +const MACHINE_MEMORY_PROOF_BYTES: usize = MACHINE_MEMORY_PROOF_SIBLING_COUNT * 32; + +#[derive(Clone, PartialEq, Eq)] +pub struct Proof([Hash; MACHINE_MEMORY_PROOF_SIBLING_COUNT]); impl Proof { - pub fn new(siblings: Vec<[u8; 32]>) -> Self { - Self(siblings) + pub fn new(siblings: Vec) -> Result { + let actual = siblings.len(); + let siblings = siblings.try_into().map_err(|_| { + anyhow::anyhow!( + "machine memory proof has {actual} siblings, expected {MACHINE_MEMORY_PROOF_SIBLING_COUNT}" + ) + })?; + Ok(Self(siblings)) } - pub fn inner(&self) -> Vec<[u8; 32]> { - self.0.clone() + pub fn inner(&self) -> &[Hash] { + &self.0 } - fn from_flattened(input: Vec) -> Result { - if !input.len().is_multiple_of(32) { + pub(crate) fn from_flattened(input: Vec) -> Result { + if input.len() != MACHINE_MEMORY_PROOF_BYTES { return Err(anyhow::anyhow!( - "stored proof has {} bytes, expected a multiple of 32", - input.len() + "stored machine memory proof has {} bytes, expected {MACHINE_MEMORY_PROOF_BYTES}", + input.len(), ) .into()); } - let mut result = Vec::new(); + let mut result = Vec::with_capacity(MACHINE_MEMORY_PROOF_SIBLING_COUNT); for chunk in input.chunks(32) { let mut array = [0u8; 32]; array.copy_from_slice(chunk); result.push(array); } - Ok(Proof(result)) + Self::new(result) } - fn flatten(&self) -> Vec { + pub(crate) fn flatten(&self) -> Vec { self.0 .iter() .flat_map(|array| array.iter()) @@ -79,14 +89,48 @@ impl Proof { } } +impl fmt::Debug for Proof { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Proof") + .field("sibling_count", &self.0.len()) + .field("first", &self.0.first()) + .field("last", &self.0.last()) + .finish() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LeafProof { + pub data_block: Hash, + pub siblings: Proof, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MachineValidityProof { + pub iflags_y_proof: LeafProof, + pub htif_tohost_proof: LeafProof, + pub tx_buffer_proof: LeafProof, +} + +impl MachineValidityProof { + pub fn outputs_merkle_root(&self) -> Hash { + self.tx_buffer_proof.data_block + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Settlement { pub computation_hash: Digest, /// The post-epoch machine state hash: the new epoch's initial /// boundary, claimed by sentries and staged on-chain. pub final_state: Hash, - pub outputs_merkle_root: Hash, - pub outputs_merkle_root_proof: Proof, + pub machine_validity_proof: MachineValidityProof, +} + +impl Settlement { + pub fn outputs_merkle_root(&self) -> Hash { + self.machine_validity_proof.outputs_merkle_root() + } } #[derive(Clone, Debug, Default)] @@ -335,15 +379,9 @@ mod tests { "computation_hash shouldn't exist" ); - let (final_state, outputs_merkle_root, outputs_merkle_root_proof) = { + let (final_state, machine_validity_proof) = { let mut machine = access.latest_snapshot()?; - let (outputs_merkle_root, outputs_merkle_root_proof) = - machine.outputs_merkle_root_with_proof()?; - ( - machine.state_hash()?, - outputs_merkle_root, - outputs_merkle_root_proof, - ) + machine.machine_validity_proof()? }; access.roll_epoch()?; assert_eq!(access.latest_snapshot()?.epoch(), 1); @@ -362,8 +400,7 @@ mod tests { Settlement { computation_hash: expected_root, final_state, - outputs_merkle_root, - outputs_merkle_root_proof + machine_validity_proof }, "settlement info of epoch 0 should match" ); diff --git a/cartesi-rollups/node/src/storage/open.rs b/cartesi-rollups/node/src/storage/open.rs index a2f231715..f87051507 100644 --- a/cartesi-rollups/node/src/storage/open.rs +++ b/cartesi-rollups/node/src/storage/open.rs @@ -8,7 +8,7 @@ use super::error::Result; use super::rollups_machine::RollupsMachine; -use super::sql::migrations; +use super::sql::schema; use crate::engine::{EngineConfig, Structure, config as sling_config}; use crate::merkle::Digest; use alloy::primitives::Address; @@ -42,12 +42,12 @@ pub struct Storage { } impl Storage { - /// Process setup: creates the state directory, runs the - /// migration, seeds the genesis watermark, stores and registers + /// Process setup: creates the state directory, initializes the + /// schema, seeds the genesis watermark, stores and registers /// the template machine, and pins the engine configuration (which /// fails loudly on app or emulator drift against an existing /// state dir). - pub fn migrate( + pub fn initialize( state_dir: &Path, initial_machine_path: &Path, genesis_block_number: u64, @@ -56,8 +56,8 @@ impl Storage { create_directory_structure(state_dir)?; let state_dir = state_dir.canonicalize().map_err(anyhow::Error::from)?; - let mut connection = open_writer_connection(&db_path(&state_dir))?; - migrations::migrate_to_latest(&mut connection)?; + let connection = open_writer_connection(&db_path(&state_dir))?; + schema::initialize(&connection)?; let mut storage = Self { connection, @@ -81,7 +81,7 @@ impl Storage { Ok(storage) } - /// A writer handle onto an already-migrated database. One + /// A writer handle onto an already-initialized database. One /// connection per worker thread; SQLite's WAL plus the busy /// timeout arbitrate between them. pub fn new(state_dir: &Path) -> Result { diff --git a/cartesi-rollups/node/src/storage/queries.rs b/cartesi-rollups/node/src/storage/queries.rs index cc2127ff5..551276f17 100644 --- a/cartesi-rollups/node/src/storage/queries.rs +++ b/cartesi-rollups/node/src/storage/queries.rs @@ -7,11 +7,11 @@ use super::convert::{blob_to_hash, i64_to_u64, u64_to_i64}; use super::error::{Result, StorageError}; -use super::{Epoch, Input, InputId, Proof, Settlement, Storage}; +use super::{Epoch, Input, InputId, LeafProof, MachineValidityProof, Proof, Settlement, Storage}; use alloy::hex::FromHex; use alloy::primitives::Address; -use rusqlite::{OptionalExtension, Transaction, params}; +use rusqlite::{OptionalExtension, Row, Transaction, params, types::ValueRef}; impl Storage { pub fn latest_processed_block(&mut self) -> Result { @@ -204,36 +204,147 @@ pub(super) fn settlement_info_in( let mut stmt = tx .prepare_cached( r#" - SELECT computation_hash, outputs_merkle_root, outputs_merkle_root_proof, final_state + SELECT computation_hash, final_state, + iflags_y_data_block, iflags_y_siblings, + htif_tohost_data_block, htif_tohost_siblings, + tx_buffer_data_block, tx_buffer_siblings FROM settlement_info WHERE epoch_number = ?1 "#, ) .map_err(anyhow::Error::from)?; - let row = stmt + Ok(stmt .query_row(params![u64_to_i64(epoch_number)], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, Vec>(1)?, - row.get::<_, Vec>(2)?, - row.get::<_, Vec>(3)?, - )) + row_to_settlement(row, epoch_number) }) .optional() - .map_err(anyhow::Error::from)?; + .map_err(anyhow::Error::from)?) +} - row.map( - |(computation_hash, outputs_merkle_root, outputs_merkle_root_proof, final_state)| { - Ok(Settlement { - computation_hash: super::convert::blob_to_digest(computation_hash)?, - final_state: blob_to_hash(final_state)?, - outputs_merkle_root: blob_to_hash(outputs_merkle_root)?, - outputs_merkle_root_proof: Proof::from_flattened(outputs_merkle_root_proof)?, - }) +fn row_to_settlement(row: &Row<'_>, epoch_number: u64) -> rusqlite::Result { + let settlement = Settlement { + computation_hash: settlement_value( + epoch_number, + "computation_hash", + super::convert::blob_to_digest(settlement_blob( + row, + 0, + epoch_number, + "computation_hash", + )?), + ), + final_state: settlement_value( + epoch_number, + "final_state", + blob_to_hash(settlement_blob(row, 1, epoch_number, "final_state")?), + ), + machine_validity_proof: MachineValidityProof { + iflags_y_proof: LeafProof { + data_block: settlement_value( + epoch_number, + "iflags_y_data_block", + blob_to_hash(settlement_blob( + row, + 2, + epoch_number, + "iflags_y_data_block", + )?), + ), + siblings: settlement_value( + epoch_number, + "iflags_y_siblings", + Proof::from_flattened(settlement_blob( + row, + 3, + epoch_number, + "iflags_y_siblings", + )?), + ), + }, + htif_tohost_proof: LeafProof { + data_block: settlement_value( + epoch_number, + "htif_tohost_data_block", + blob_to_hash(settlement_blob( + row, + 4, + epoch_number, + "htif_tohost_data_block", + )?), + ), + siblings: settlement_value( + epoch_number, + "htif_tohost_siblings", + Proof::from_flattened(settlement_blob( + row, + 5, + epoch_number, + "htif_tohost_siblings", + )?), + ), + }, + tx_buffer_proof: LeafProof { + data_block: settlement_value( + epoch_number, + "tx_buffer_data_block", + blob_to_hash(settlement_blob( + row, + 6, + epoch_number, + "tx_buffer_data_block", + )?), + ), + siblings: settlement_value( + epoch_number, + "tx_buffer_siblings", + Proof::from_flattened(settlement_blob( + row, + 7, + epoch_number, + "tx_buffer_siblings", + )?), + ), + }, }, + }; + super::rollups_machine::validate_machine_validity_proof( + settlement.final_state, + &settlement.machine_validity_proof, ) - .transpose() + .unwrap_or_else(|error| { + panic!( + "settlement for epoch {epoch_number} has an invalid machine validity proof: \ + {error:#} (corruption or incompatible state dir)" + ) + }); + Ok(settlement) +} + +fn settlement_blob( + row: &Row<'_>, + index: usize, + epoch_number: u64, + field: &str, +) -> rusqlite::Result> { + let value = row.get_ref(index)?; + match value { + ValueRef::Blob(blob) => Ok(blob.to_vec()), + _ => panic!( + "settlement for epoch {epoch_number} has invalid {field}: expected BLOB, found {:?} \ + (corruption or incompatible state dir)", + value.data_type() + ), + } +} + +fn settlement_value(epoch_number: u64, field: &str, value: Result) -> T { + value.unwrap_or_else(|error| { + panic!( + "settlement for epoch {epoch_number} has invalid {field}: {error} \ + (corruption or incompatible state dir)" + ) + }) } fn row_to_epoch(row: &rusqlite::Row) -> rusqlite::Result> { @@ -255,3 +366,175 @@ fn row_to_epoch(row: &rusqlite::Row) -> rusqlite::Result> { block_created_number: i64_to_u64(block_created_number), })) } + +#[cfg(test)] +pub(super) fn test_settlement() -> Settlement { + use cartesi_machine::{ + cartesi_machine_sys::{ + CM_HTIF_CMD_SHIFT, CM_HTIF_DEV_SHIFT, CM_HTIF_DEV_YIELD, CM_HTIF_REASON_SHIFT, + CM_HTIF_YIELD_CMD_MANUAL, CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED, CM_REG_HTIF_TOHOST, + CM_REG_IFLAGS_Y, + }, + config::runtime::RuntimeConfig, + constants::ar::TX_START, + machine::Machine, + }; + + let mut config = Machine::default_config().unwrap(); + config.ram.length = 4096; + let mut machine = Machine::create(&config, &RuntimeConfig::quiet_console()).unwrap(); + let htif_tohost = (u64::from(CM_HTIF_DEV_YIELD) << CM_HTIF_DEV_SHIFT) + | (u64::from(CM_HTIF_YIELD_CMD_MANUAL) << CM_HTIF_CMD_SHIFT) + | (u64::from(CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED) << CM_HTIF_REASON_SHIFT); + machine.write_reg(CM_REG_IFLAGS_Y, 1).unwrap(); + machine.write_reg(CM_REG_HTIF_TOHOST, htif_tohost).unwrap(); + machine.write_memory(TX_START, &[0x33; 32]).unwrap(); + let (final_state, machine_validity_proof) = + super::rollups_machine::machine_validity_proof_for(&mut machine).unwrap(); + + Settlement { + computation_hash: [0xAA; 32].into(), + final_state, + machine_validity_proof, + } +} + +#[cfg(test)] +pub(super) fn setup_settlement_storage() -> (tempfile::TempDir, Storage) { + let dir = tempfile::tempdir().unwrap(); + let conn = rusqlite::Connection::open(dir.path().join("db.sqlite3")).unwrap(); + super::sql::schema::initialize(&conn).unwrap(); + drop(conn); + let storage = Storage::new(dir.path()).unwrap(); + (dir, storage) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + + const RAW_SETTLEMENT_INSERT: &str = r#" + INSERT INTO settlement_info + (epoch_number, computation_hash, final_state, + iflags_y_data_block, iflags_y_siblings, + htif_tohost_data_block, htif_tohost_siblings, + tx_buffer_data_block, tx_buffer_siblings) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + "#; + + #[test] + fn settlement_row_maps_columns_to_domain() { + let (_handle, mut storage) = setup_settlement_storage(); + let settlement = test_settlement(); + let proof = &settlement.machine_validity_proof; + storage + .connection + .execute( + RAW_SETTLEMENT_INSERT, + params![ + 42, + settlement.computation_hash.data(), + settlement.final_state, + proof.iflags_y_proof.data_block, + proof.iflags_y_proof.siblings.flatten(), + proof.htif_tohost_proof.data_block, + proof.htif_tohost_proof.siblings.flatten(), + proof.tx_buffer_proof.data_block, + proof.tx_buffer_proof.siblings.flatten(), + ], + ) + .unwrap(); + + assert_eq!(storage.settlement_info(42).unwrap(), Some(settlement)); + } + + #[test] + #[should_panic(expected = "invalid htif_tohost_siblings")] + fn malformed_settlement_proof_is_not_retryable() { + let (_handle, mut storage) = setup_settlement_storage(); + storage + .connection + .pragma_update(None, "ignore_check_constraints", "ON") + .unwrap(); + storage + .connection + .execute( + RAW_SETTLEMENT_INSERT, + params![ + 42, + vec![0u8; 32], + vec![0u8; 32], + vec![0u8; 32], + vec![0u8; 1888], + vec![0u8; 32], + vec![0u8; 1856], + vec![0u8; 32], + vec![0u8; 1888], + ], + ) + .unwrap(); + + let _ = storage.settlement_info(42); + } + + #[test] + #[should_panic(expected = "invalid machine validity proof")] + fn same_length_settlement_corruption_is_not_retryable() { + let (_handle, mut storage) = setup_settlement_storage(); + let settlement = test_settlement(); + let proof = &settlement.machine_validity_proof; + let mut corrupted_siblings = proof.htif_tohost_proof.siblings.flatten(); + corrupted_siblings[0] ^= 1; + storage + .connection + .execute( + RAW_SETTLEMENT_INSERT, + params![ + 42, + settlement.computation_hash.data(), + settlement.final_state, + proof.iflags_y_proof.data_block, + proof.iflags_y_proof.siblings.flatten(), + proof.htif_tohost_proof.data_block, + corrupted_siblings, + proof.tx_buffer_proof.data_block, + proof.tx_buffer_proof.siblings.flatten(), + ], + ) + .unwrap(); + + let _ = storage.settlement_info(42); + } + + #[test] + #[should_panic(expected = "expected BLOB, found Text")] + fn wrong_settlement_sqlite_type_is_not_retryable() { + let (_handle, mut storage) = setup_settlement_storage(); + let settlement = test_settlement(); + let proof = &settlement.machine_validity_proof; + storage + .connection + .pragma_update(None, "ignore_check_constraints", "ON") + .unwrap(); + storage + .connection + .execute( + RAW_SETTLEMENT_INSERT, + params![ + 42, + "x".repeat(32), + settlement.final_state, + proof.iflags_y_proof.data_block, + proof.iflags_y_proof.siblings.flatten(), + proof.htif_tohost_proof.data_block, + proof.htif_tohost_proof.siblings.flatten(), + proof.tx_buffer_proof.data_block, + proof.tx_buffer_proof.siblings.flatten(), + ], + ) + .unwrap(); + + let _ = storage.settlement_info(42); + } +} diff --git a/cartesi-rollups/node/src/storage/rollups_machine.rs b/cartesi-rollups/node/src/storage/rollups_machine.rs index 45a534305..41feaee86 100644 --- a/cartesi-rollups/node/src/storage/rollups_machine.rs +++ b/cartesi-rollups/node/src/storage/rollups_machine.rs @@ -4,15 +4,204 @@ use std::path::Path; use crate::engine::constants::{LOG2_EPOCH_RULER_SPAN, LOG2_INPUT_WINDOW_SPAN}; -use crate::storage::Proof; +use crate::merkle::Digest; +use crate::storage::{LeafProof, MACHINE_MEMORY_PROOF_SIBLING_COUNT, MachineValidityProof, Proof}; +use anyhow::ensure; use cartesi_machine::{ + cartesi_machine_sys::{ + CM_HTIF_CMD_MASK, CM_HTIF_CMD_SHIFT, CM_HTIF_DEV_MASK, CM_HTIF_DEV_SHIFT, + CM_HTIF_DEV_YIELD, CM_HTIF_REASON_MASK, CM_HTIF_REASON_SHIFT, CM_HTIF_YIELD_CMD_MANUAL, + CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED, CM_REG_HTIF_TOHOST, CM_REG_IFLAGS_Y, + }, config::runtime::RuntimeConfig, - constants::{ar::TX_START, machine::HASH_TREE_LOG2_ROOT_SIZE}, + constants::{ + ar::TX_START, + machine::{HASH_TREE_LOG2_ROOT_SIZE, HASH_TREE_LOG2_WORD_SIZE}, + }, error::MachineResult, machine::Machine, - types::{Hash, SharingMode}, + types::{Hash, SharingMode, memory_proof::Proof as MachineMemoryProof}, }; +const DATA_BLOCK_SIZE: u64 = 1 << HASH_TREE_LOG2_WORD_SIZE; +const DATA_BLOCK_MASK: u64 = DATA_BLOCK_SIZE - 1; +const _: () = assert!( + MACHINE_MEMORY_PROOF_SIBLING_COUNT + == (HASH_TREE_LOG2_ROOT_SIZE - HASH_TREE_LOG2_WORD_SIZE) as usize +); + +fn machine_memory_root(target_address: u64, target_hash: Hash, siblings: &[Hash]) -> Hash { + let mut index = target_address >> HASH_TREE_LOG2_WORD_SIZE; + let mut node = Digest::new(target_hash); + for sibling in siblings { + let sibling = Digest::new(*sibling); + node = if index & 1 == 0 { + node.join(&sibling) + } else { + sibling.join(&node) + }; + index >>= 1; + } + node.into() +} + +fn data_block_word(data_block: &Hash, address: u64) -> u64 { + let offset = (address & DATA_BLOCK_MASK) as usize; + let end = offset + size_of::(); + let bytes = data_block + .get(offset..end) + .expect("canonical machine register must fit in its data block"); + u64::from_le_bytes(bytes.try_into().unwrap()) +} + +fn validity_register_addresses() -> (u64, u64) { + let iflags_y = Machine::reg_address(CM_REG_IFLAGS_Y) + .unwrap_or_else(|error| panic!("Cartesi Machine has no iflags_Y address: {error}")); + let htif_tohost = Machine::reg_address(CM_REG_HTIF_TOHOST) + .unwrap_or_else(|error| panic!("Cartesi Machine has no HTIF tohost address: {error}")); + (iflags_y, htif_tohost) +} + +fn validate_leaf_root( + label: &str, + address: u64, + expected_root: Hash, + proof: &LeafProof, +) -> anyhow::Result<()> { + let target_address = address & !DATA_BLOCK_MASK; + let target_hash: Hash = Digest::from_data(&proof.data_block).into(); + ensure!( + machine_memory_root(target_address, target_hash, proof.siblings.inner()) == expected_root, + "{label} proof does not reconstruct the final machine state" + ); + Ok(()) +} + +/// Verifies exactly the proof and terminal-state predicates enforced by +/// LibMachineValidityProof. The HTIF data field is intentionally ignored. +pub(super) fn validate_machine_validity_proof( + final_state: Hash, + proof: &MachineValidityProof, +) -> anyhow::Result<()> { + let (iflags_y_address, htif_tohost_address) = validity_register_addresses(); + + validate_leaf_root( + "iflags_Y", + iflags_y_address, + final_state, + &proof.iflags_y_proof, + )?; + validate_leaf_root( + "HTIF tohost", + htif_tohost_address, + final_state, + &proof.htif_tohost_proof, + )?; + validate_leaf_root( + "CMIO tx buffer", + TX_START, + final_state, + &proof.tx_buffer_proof, + )?; + + ensure!( + data_block_word(&proof.iflags_y_proof.data_block, iflags_y_address) != 0, + "post-epoch machine is not yielded" + ); + + let htif_tohost = data_block_word(&proof.htif_tohost_proof.data_block, htif_tohost_address); + let device = (htif_tohost & CM_HTIF_DEV_MASK) >> CM_HTIF_DEV_SHIFT; + let command = (htif_tohost & CM_HTIF_CMD_MASK) >> CM_HTIF_CMD_SHIFT; + let reason = (htif_tohost & CM_HTIF_REASON_MASK) >> CM_HTIF_REASON_SHIFT; + ensure!( + device == u64::from(CM_HTIF_DEV_YIELD) + && command == u64::from(CM_HTIF_YIELD_CMD_MANUAL) + && reason == u64::from(CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED), + "post-epoch machine is not yielded manually with RX_ACCEPTED" + ); + + Ok(()) +} + +fn validate_machine_memory_proof( + label: &str, + target_address: u64, + data_block: Hash, + expected_root: Hash, + proof: MachineMemoryProof, +) -> LeafProof { + assert_eq!( + proof.target_address, target_address, + "invalid {label} machine memory proof target address" + ); + assert_eq!( + proof.log2_target_size, + u64::from(HASH_TREE_LOG2_WORD_SIZE), + "invalid {label} machine memory proof target size" + ); + assert_eq!( + proof.log2_root_size, + u64::from(HASH_TREE_LOG2_ROOT_SIZE), + "invalid {label} machine memory proof root size" + ); + assert_eq!( + proof.sibling_hashes.len(), + MACHINE_MEMORY_PROOF_SIBLING_COUNT, + "invalid {label} machine memory proof sibling count" + ); + + let target_hash: Hash = Digest::from_data(&data_block).into(); + assert_eq!( + proof.target_hash, target_hash, + "invalid {label} machine memory proof target hash" + ); + assert_eq!( + proof.root_hash, expected_root, + "invalid {label} machine memory proof reported root" + ); + assert_eq!( + machine_memory_root(target_address, target_hash, &proof.sibling_hashes), + expected_root, + "invalid {label} machine memory proof reconstructed root" + ); + + let siblings = Proof::new(proof.sibling_hashes) + .expect("machine memory proof sibling count was checked above"); + LeafProof { + data_block, + siblings, + } +} + +fn capture_machine_memory_proof( + machine: &mut Machine, + label: &str, + address: u64, + expected_root: Hash, +) -> MachineResult { + let target_address = address & !DATA_BLOCK_MASK; + let proof = machine.proof( + target_address, + HASH_TREE_LOG2_WORD_SIZE, + HASH_TREE_LOG2_ROOT_SIZE, + )?; + let data = machine.read_memory(target_address, DATA_BLOCK_SIZE)?; + let actual = data.len(); + let data_block = data.try_into().unwrap_or_else(|_| { + panic!( + "invalid {label} machine memory proof data block: read returned {actual} bytes, expected {DATA_BLOCK_SIZE}" + ) + }); + + Ok(validate_machine_memory_proof( + label, + target_address, + data_block, + expected_root, + proof, + )) +} + // gap of each leaf in the commitment tree, should use the same value as ArbitrationConstants.sol:log2step(0) pub const LOG2_STRIDE: u64 = 44; @@ -118,16 +307,10 @@ impl RollupsMachine { self.next_input_index_in_epoch = 0; } - /// The contracts' canonical outputsMerkleRoot (the machine leaves - /// it at TX_START) plus the siblings that prove it against the - /// machine state hash - what stageTournamentResult consumes. - pub fn outputs_merkle_root_with_proof(&mut self) -> MachineResult<(Hash, Proof)> { - let proof = self.inner().proof(TX_START, 5, HASH_TREE_LOG2_ROOT_SIZE)?; - let siblings = Proof::new(proof.sibling_hashes); - let outputs_merkle_root = self.inner().read_memory(TX_START, 32)?; - - assert_eq!(outputs_merkle_root.len(), 32); - Ok((outputs_merkle_root.try_into().unwrap(), siblings)) + /// Captures the three data-block proofs consumed by DaveConsensus. + /// Every proof is checked against the same current machine root. + pub fn machine_validity_proof(&mut self) -> MachineResult<(Hash, MachineValidityProof)> { + machine_validity_proof_for(self.inner()) } pub fn state_hash(&mut self) -> MachineResult { @@ -163,3 +346,213 @@ impl RollupsMachine { self.inner().store(dir) } } + +pub(super) fn machine_validity_proof_for( + machine: &mut Machine, +) -> MachineResult<(Hash, MachineValidityProof)> { + let (iflags_y_address, htif_tohost_address) = validity_register_addresses(); + let root = machine.root_hash()?; + + let iflags_y_proof = capture_machine_memory_proof(machine, "iflags_Y", iflags_y_address, root)?; + let htif_tohost_proof = + capture_machine_memory_proof(machine, "HTIF tohost", htif_tohost_address, root)?; + let tx_buffer_proof = capture_machine_memory_proof(machine, "CMIO tx buffer", TX_START, root)?; + + let proof = MachineValidityProof { + iflags_y_proof, + htif_tohost_proof, + tx_buffer_proof, + }; + validate_machine_validity_proof(root, &proof) + .unwrap_or_else(|error| panic!("invalid post-epoch machine validity proof: {error:#}")); + Ok((root, proof)) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + + use alloy::{ + primitives::{Address, U256}, + sol_types::SolCall, + }; + use cartesi_machine::{ + config::runtime::RuntimeConfig, + constants::break_reason, + types::cmio::{CmioRequest, CmioResponseReason, ManualReason}, + }; + use cartesi_rollups_contracts::inputs::Inputs::EvmAdvanceCall; + + fn required_image(program: &str) -> PathBuf { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../test/programs") + .join(program) + .join("machine-image"); + path.canonicalize().unwrap_or_else(|error| { + panic!( + "{program} machine image is unavailable at {}: {error}; run `just programs::build-{program}`", + path.display() + ) + }) + } + + fn evm_advance_input(payload: &[u8]) -> Vec { + EvmAdvanceCall { + chainId: U256::from(31337), + appContract: Address::ZERO, + msgSender: Address::ZERO, + blockNumber: U256::from(1), + blockTimestamp: U256::from(1), + prevRandao: U256::ZERO, + index: U256::ZERO, + payload: payload.to_vec().into(), + } + .abi_encode() + } + + fn machine_after_advance(program: &str) -> MachineResult { + let mut machine = Machine::load(&required_image(program), &RuntimeConfig::quiet_console())?; + assert!(machine.iflags_y()?, "template machine must be yielded"); + assert!( + matches!( + machine.receive_cmio_request()?, + CmioRequest::Manual(ManualReason::RxAccepted { .. }) + ), + "template machine must be awaiting input" + ); + + let revert_root = machine.root_hash()?; + machine.send_cmio_response( + CmioResponseReason::Advance, + &evm_advance_input(b"hello dave"), + Some(&revert_root), + )?; + assert!( + !machine.iflags_y()?, + "Advance response must clear the yield flag" + ); + Ok(machine) + } + + fn run_to_manual_yield(machine: &mut Machine) -> MachineResult { + loop { + let reason = machine.run(u64::MAX)?; + assert_ne!(reason, break_reason::FAILED, "machine run failed"); + assert_ne!( + reason, + break_reason::HALTED, + "machine halted before yielding" + ); + assert_ne!( + reason, + break_reason::MCYCLE_OVERFLOW, + "machine overflowed before yielding" + ); + if machine.iflags_y()? { + return machine.receive_cmio_request(); + } + } + } + + #[test] + fn proof_requires_the_canonical_machine_tree_height() { + assert!(Proof::new(vec![[0; 32]; MACHINE_MEMORY_PROOF_SIBLING_COUNT - 1]).is_err()); + assert!(Proof::new(vec![[0; 32]; MACHINE_MEMORY_PROOF_SIBLING_COUNT + 1]).is_err()); + + let proof = Proof::new(vec![[0xAB; 32]; MACHINE_MEMORY_PROOF_SIBLING_COUNT]).unwrap(); + let flattened = proof.flatten(); + assert_eq!(Proof::from_flattened(flattened).unwrap(), proof); + assert!( + Proof::from_flattened(vec![0; MACHINE_MEMORY_PROOF_SIBLING_COUNT * 32 - 1]).is_err() + ); + } + + #[test] + #[should_panic(expected = "invalid test machine memory proof sibling count")] + fn malformed_machine_proof_is_not_retryable() { + let proof = MachineMemoryProof { + target_address: 0, + log2_target_size: u64::from(HASH_TREE_LOG2_WORD_SIZE), + target_hash: [0; 32], + log2_root_size: u64::from(HASH_TREE_LOG2_ROOT_SIZE), + root_hash: [0; 32], + sibling_hashes: vec![[0; 32]; MACHINE_MEMORY_PROOF_SIBLING_COUNT - 1], + }; + + let _ = validate_machine_memory_proof("test", 0, [0; 32], [0; 32], proof); + } + + #[test] + #[should_panic(expected = "post-epoch machine is not yielded")] + fn capture_refuses_a_real_machine_before_it_yields() { + let mut machine = machine_after_advance("echo").unwrap(); + + let _ = machine_validity_proof_for(&mut machine); + } + + #[test] + fn captures_proof_after_a_real_echo_advance() -> MachineResult<()> { + let mut machine = machine_after_advance("echo")?; + let output_hashes_root_hash = match run_to_manual_yield(&mut machine)? { + CmioRequest::Manual(ManualReason::RxAccepted { + output_hashes_root_hash, + }) => output_hashes_root_hash, + request => panic!("echo machine must accept the input, found {request:?}"), + }; + let expected_root = machine.root_hash()?; + + let (root, proof) = machine_validity_proof_for(&mut machine)?; + + assert_eq!(root, expected_root); + assert_eq!( + proof.outputs_merkle_root().as_slice(), + output_hashes_root_hash.as_slice() + ); + Ok(()) + } + + #[test] + #[should_panic(expected = "not yielded manually with RX_ACCEPTED")] + fn capture_refuses_a_real_rx_rejected_yield() { + let mut machine = machine_after_advance("yield").unwrap(); + let request = run_to_manual_yield(&mut machine).unwrap(); + assert!( + matches!(request, CmioRequest::Manual(ManualReason::RxRejected)), + "yield machine must reject the input" + ); + + let _ = machine_validity_proof_for(&mut machine); + } + + #[test] + fn proof_validation_ignores_the_htif_data_field() -> MachineResult<()> { + let mut config = Machine::default_config()?; + config.ram.length = 4096; + let mut machine = Machine::create(&config, &RuntimeConfig::quiet_console())?; + + let htif_tohost = (u64::from(CM_HTIF_DEV_YIELD) << CM_HTIF_DEV_SHIFT) + | (u64::from(CM_HTIF_YIELD_CMD_MANUAL) << CM_HTIF_CMD_SHIFT) + | (u64::from(CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED) << CM_HTIF_REASON_SHIFT) + | 0xFFFF_FFFF; + machine.write_reg(CM_REG_IFLAGS_Y, 1)?; + machine.write_reg(CM_REG_HTIF_TOHOST, htif_tohost)?; + + let (_, proof) = machine_validity_proof_for(&mut machine)?; + + assert_eq!( + word_at( + &proof.htif_tohost_proof.data_block, + Machine::reg_address(CM_REG_HTIF_TOHOST)? + ), + htif_tohost + ); + Ok(()) + } + + fn word_at(data_block: &Hash, address: u64) -> u64 { + let offset = (address & DATA_BLOCK_MASK) as usize; + u64::from_le_bytes(data_block[offset..offset + 8].try_into().unwrap()) + } +} diff --git a/cartesi-rollups/node/src/storage/snapshots.rs b/cartesi-rollups/node/src/storage/snapshots.rs index 7f61ea074..ccce952fc 100644 --- a/cartesi-rollups/node/src/storage/snapshots.rs +++ b/cartesi-rollups/node/src/storage/snapshots.rs @@ -501,7 +501,7 @@ impl Storage { /// The newest snapshot boundary: (path, epoch, input, state_hash) - /// the machine runner's resume point. At least one exists from the -/// migration's epoch-0 seed; its absence means a foreign or torn +/// initialization's epoch-0 seed; its absence means a foreign or torn /// database. pub(super) fn latest_boundary_in(tx: &Transaction) -> Result<(PathBuf, u64, u64, Hash)> { let mut stmt = tx @@ -530,7 +530,7 @@ pub(super) fn latest_boundary_in(tx: &Transaction) -> Result<(PathBuf, u64, u64, .map_err(anyhow::Error::from)?; let (path, epoch, input, hash) = row.ok_or_else(|| StorageError::DataNotFound { - description: "snapshot boundary (the migration seeds epoch 0)".into(), + description: "snapshot boundary (initialization seeds epoch 0)".into(), })?; Ok(( diff --git a/cartesi-rollups/node/src/storage/sql/discipline.rs b/cartesi-rollups/node/src/storage/sql/discipline.rs index c75db3973..523d0e589 100644 --- a/cartesi-rollups/node/src/storage/sql/discipline.rs +++ b/cartesi-rollups/node/src/storage/sql/discipline.rs @@ -8,12 +8,12 @@ use rusqlite::{Connection, params}; -/// A migrated schema on a raw connection - no machine image, no -/// genesis seeding; the trigger layer is pure DDL. -fn migrated_conn() -> (tempfile::TempDir, Connection) { +/// An initialized schema on a raw connection - no machine image or +/// deployment-specific genesis state. +fn initialized_conn() -> (tempfile::TempDir, Connection) { let dir = tempfile::tempdir().unwrap(); - let mut conn = Connection::open(dir.path().join("db.sqlite3")).unwrap(); - super::migrations::migrate_to_latest(&mut conn).unwrap(); + let conn = Connection::open(dir.path().join("db.sqlite3")).unwrap(); + super::schema::initialize(&conn).unwrap(); (dir, conn) } @@ -26,13 +26,27 @@ fn expect_abort(result: rusqlite::Result, message_fragment: &str) { ); } +// +// node_metadata: permanent write-once identity +// + +#[test] +fn node_metadata_is_write_once() { + let (_dir, conn) = initialized_conn(); + expect_abort( + conn.execute("UPDATE node_metadata SET node_version = 'other'", []), + "write-once", + ); + expect_abort(conn.execute("DELETE FROM node_metadata", []), "write-once"); +} + // // epochs: append-only, dense from 0 // #[test] fn epochs_refuse_gaps_updates_and_deletes() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); let insert = "INSERT INTO epochs VALUES (?1, 0, '0x00', 0)"; expect_abort(conn.execute(insert, params![1]), "densely from 0"); @@ -53,7 +67,7 @@ fn epochs_refuse_gaps_updates_and_deletes() { #[test] fn inputs_refuse_non_contiguous_coordinates() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); let insert = "INSERT INTO inputs VALUES (?1, ?2, x'00')"; // the first input of the database must open an epoch @@ -75,7 +89,7 @@ fn inputs_refuse_non_contiguous_coordinates() { #[test] fn inputs_refuse_updates_and_deletes() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); conn.execute("INSERT INTO inputs VALUES (0, 0, x'00')", []) .unwrap(); expect_abort( @@ -91,7 +105,7 @@ fn inputs_refuse_updates_and_deletes() { #[test] fn latest_processed_only_rises_and_never_disappears() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); let update = "UPDATE latest_processed SET block = ?1 WHERE id = 1"; conn.execute(update, params![10]).unwrap(); @@ -110,9 +124,14 @@ fn latest_processed_only_rises_and_never_disappears() { #[test] fn settlement_info_is_write_once() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); conn.execute( - "INSERT INTO settlement_info VALUES (0, x'00', x'01', x'02', x'03')", + "INSERT INTO settlement_info VALUES ( + 0, zeroblob(32), zeroblob(32), + zeroblob(32), zeroblob(1888), + zeroblob(32), zeroblob(1888), + zeroblob(32), zeroblob(1888) + )", [], ) .unwrap(); @@ -132,7 +151,7 @@ fn settlement_info_is_write_once() { #[test] fn sling_config_is_write_once() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); conn.execute( "INSERT INTO sling_config VALUES (0, 24, 27, 20, x'00', x'01', 'v')", [], @@ -151,7 +170,7 @@ fn sling_config_is_write_once() { #[test] fn template_machine_absorbs_identical_and_refuses_drift() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); // satisfy the FK on machine_state_snapshots conn.execute( "INSERT INTO machine_state_snapshots VALUES (?1, '/a')", @@ -192,7 +211,7 @@ fn template_machine_absorbs_identical_and_refuses_drift() { #[test] fn sling_nodes_collision_aborts_in_the_database_itself() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); let insert = "INSERT INTO sling_nodes VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT DO NOTHING"; @@ -222,7 +241,7 @@ fn sling_nodes_collision_aborts_in_the_database_itself() { #[test] fn snapshot_index_verifies_replays_and_refuses_updates() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); conn.execute( "INSERT INTO machine_state_snapshots VALUES (?1, '/a')", params![[1u8; 32]], @@ -244,7 +263,7 @@ fn snapshot_index_verifies_replays_and_refuses_updates() { #[test] fn cas_rows_pin_their_path() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); let insert = "INSERT INTO machine_state_snapshots VALUES (?1, ?2) ON CONFLICT DO NOTHING"; conn.execute(insert, params![[1u8; 32], "/a"]).unwrap(); @@ -266,7 +285,7 @@ fn cas_rows_pin_their_path() { #[test] fn tournament_events_stay_behind_the_watermark_and_final() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); let insert = "INSERT INTO tournament_events VALUES ('aa', ?1, 0, x'00')"; // No watermark row yet: nothing is finalized, nothing may land. @@ -301,7 +320,7 @@ fn tournament_events_stay_behind_the_watermark_and_final() { #[test] fn tournament_events_watermark_only_rises() { - let (_dir, conn) = migrated_conn(); + let (_dir, conn) = initialized_conn(); conn.execute( "INSERT INTO tournament_events_watermark VALUES ('aa', 10)", [], @@ -378,3 +397,43 @@ fn mutation_taxonomy_holds_at_source_level() { prune and the unreferenced-snapshot sweep in the boundary store" ); } + +#[test] +fn schema_file_is_the_only_ddl_source() { + fn rust_sources(path: &std::path::Path, sources: &mut Vec) { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + rust_sources(&path, sources); + } else if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { + sources.push(path); + } + } + } + + let storage_src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/storage"); + let markers = [ + ["CREATE", "TABLE"].join(" "), + ["ALTER", "TABLE"].join(" "), + ["DROP", "TABLE"].join(" "), + ["CREATE", "TRIGGER"].join(" "), + ["DROP", "TRIGGER"].join(" "), + ["CREATE", "INDEX"].join(" "), + ["DROP", "INDEX"].join(" "), + ["CREATE", "VIEW"].join(" "), + ["DROP", "VIEW"].join(" "), + ]; + let mut sources = Vec::new(); + rust_sources(&storage_src, &mut sources); + + for path in sources { + let source = std::fs::read_to_string(&path).unwrap(); + for marker in &markers { + assert!( + !source.contains(marker), + "inline DDL `{marker}` found in {}; keep all DDL in storage/sql/schema.sql", + path.display() + ); + } + } +} diff --git a/cartesi-rollups/node/src/storage/sql/migrations.rs b/cartesi-rollups/node/src/storage/sql/migrations.rs deleted file mode 100644 index 255a0347f..000000000 --- a/cartesi-rollups/node/src/storage/sql/migrations.rs +++ /dev/null @@ -1,64 +0,0 @@ -use lazy_static::lazy_static; -use rusqlite::Connection; -use rusqlite_migration::{M, Migrations}; - -lazy_static! { - pub static ref MIGRATIONS: Migrations<'static> = Migrations::new(vec![ - M::up(include_str!("migrations.sql")), - // The runs table died: the window-root row is the runner's - // only level-0 artifact. v1's DDL no longer creates it, but a - // store that ran the old v1 carries the table, its triggers, - // and its never-GC'd rows forever (user_version gates by - // number, not content); the explicit drop keeps every store - // identical to a fresh one. IF EXISTS makes it a no-op on - // fresh databases; SQLite drops the triggers with the table. - M::up("DROP TABLE IF EXISTS machine_state_hashes;"), - // Staged settlement (next/3.0 contracts): settlement_info - // gained final_state, the post-epoch machine state hash the - // node claims as a sentry and asserts at stage/accept. The - // column lives in v1's DDL, so for fresh stores this step is - // the idempotent no-op below; stores below v3 are refused in - // migrate_to_latest instead - see the comment there. - M::up( - "CREATE TABLE IF NOT EXISTS settlement_info ( - epoch_number INTEGER NOT NULL PRIMARY KEY CHECK (epoch_number >= 0), - computation_hash BLOB NOT NULL, - outputs_merkle_root BLOB NOT NULL, - outputs_merkle_root_proof BLOB NOT NULL, - final_state BLOB NOT NULL - );", - ), - // The settlement columns took the contracts' canonical names - // (outputsMerkleRoot): v1's DDL renamed in place, so fresh - // stores are born v4 via the idempotent no-op below, and v3 - // stores (old column names) are refused in migrate_to_latest. - M::up( - "CREATE TABLE IF NOT EXISTS settlement_info ( - epoch_number INTEGER NOT NULL PRIMARY KEY CHECK (epoch_number >= 0), - computation_hash BLOB NOT NULL, - outputs_merkle_root BLOB NOT NULL, - outputs_merkle_root_proof BLOB NOT NULL, - final_state BLOB NOT NULL - );", - ), - ]); -} - -/// Stores below v4 predate settlement_info's current shape (v3 added -/// final_state, which cannot be backfilled - gc_old_epochs may have -/// dropped the boundary rows that held it - and v4 renamed the -/// settlement columns to the contracts' canonical names). A partial -/// upgrade would trip the settlement asserts as a false consensus -/// alarm or fail on the old column names, so the upgrade refuses -/// loudly instead of wedging or alarming. Fresh stores initialize at -/// the current shape. -pub fn migrate_to_latest(conn: &mut Connection) -> anyhow::Result<()> { - let version: u32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; - anyhow::ensure!( - version == 0 || version >= 4, - "store schema v{version} predates settlement_info's current shape and cannot be \ - upgraded in place; wipe the state dir and let the node rebuild" - ); - MIGRATIONS.to_latest(conn)?; - Ok(()) -} diff --git a/cartesi-rollups/node/src/storage/sql/mod.rs b/cartesi-rollups/node/src/storage/sql/mod.rs index c8a6e893b..0cca90204 100644 --- a/cartesi-rollups/node/src/storage/sql/mod.rs +++ b/cartesi-rollups/node/src/storage/sql/mod.rs @@ -1,11 +1,10 @@ // (c) Cartesi and individual authors (see AUTHORS) // SPDX-License-Identifier: Apache-2.0 (see LICENSE) -//! The DDL and its guards: the single migration (one migration, one -//! DDL path) and the discipline tests that drive every schema -//! trigger to its abort. +//! The create-only DDL, its node/schema identity guard, and the discipline +//! tests that drive every schema trigger to its abort. -pub mod migrations; +pub mod schema; #[cfg(test)] mod discipline; diff --git a/cartesi-rollups/node/src/storage/sql/schema.rs b/cartesi-rollups/node/src/storage/sql/schema.rs new file mode 100644 index 000000000..de8214e83 --- /dev/null +++ b/cartesi-rollups/node/src/storage/sql/schema.rs @@ -0,0 +1,241 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//! Clean-slate schema initialization. The store is rebuildable from the chain +//! and machine image, so incompatible stores are refused rather than migrated. + +use alloy::primitives::{B256, keccak256}; +use anyhow::Context; +use rusqlite::{Connection, OptionalExtension, params}; + +const SCHEMA: &str = include_str!("schema.sql"); +const NODE_VERSION: &str = env!("CARGO_PKG_VERSION"); +const WIPE_GUIDANCE: &str = "wipe the state dir and let the node rebuild"; + +fn schema_fingerprint() -> B256 { + keccak256(SCHEMA.as_bytes()) +} + +fn schema_is_empty(conn: &Connection) -> anyhow::Result { + let object_count: u64 = + conn.query_row("SELECT COUNT(*) FROM sqlite_schema", [], |row| row.get(0))?; + Ok(object_count == 0) +} + +/// Creates and stamps an empty database. A nonempty database is accepted only +/// when its immutable node version and schema fingerprint match this binary. +pub fn initialize(conn: &Connection) -> anyhow::Result<()> { + let tx = conn + .unchecked_transaction() + .context("begin current-schema initialization")?; + let expected_fingerprint = schema_fingerprint(); + + if schema_is_empty(&tx)? { + tx.execute_batch(SCHEMA) + .context("create current node database schema")?; + tx.execute( + "INSERT INTO node_metadata (id, node_version, schema_fingerprint) + VALUES (0, ?1, ?2)", + params![NODE_VERSION, expected_fingerprint.as_slice()], + ) + .context("stamp current node database identity")?; + tx.commit() + .context("commit current-schema initialization")?; + return Ok(()); + } + + let has_metadata_table: bool = tx.query_row( + "SELECT EXISTS ( + SELECT 1 FROM sqlite_schema + WHERE type = 'table' AND name = 'node_metadata' + )", + [], + |row| row.get(0), + )?; + anyhow::ensure!( + has_metadata_table, + "database predates node compatibility metadata; {WIPE_GUIDANCE}" + ); + + let stored = tx + .query_row( + "SELECT node_version, schema_fingerprint + FROM node_metadata WHERE id = 0", + [], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)), + ) + .optional() + .with_context(|| format!("database compatibility metadata is invalid; {WIPE_GUIDANCE}"))?; + let (stored_version, stored_fingerprint) = stored.ok_or_else(|| { + anyhow::anyhow!( + "database compatibility metadata is missing; initialization may be incomplete; \ + {WIPE_GUIDANCE}" + ) + })?; + + anyhow::ensure!( + stored_version == NODE_VERSION, + "database belongs to node version {stored_version}, but this node is version \ + {NODE_VERSION}; {WIPE_GUIDANCE}" + ); + anyhow::ensure!( + stored_fingerprint.as_slice() == expected_fingerprint.as_slice(), + "database schema fingerprint {} does not match this node's {}; {WIPE_GUIDANCE}", + hex::encode(stored_fingerprint), + hex::encode(expected_fingerprint.as_slice()) + ); + + tx.commit() + .context("commit current-schema compatibility check")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn apply_schema_with_identity(conn: &Connection, version: &str, fingerprint: &[u8]) { + conn.execute_batch(SCHEMA).unwrap(); + conn.execute( + "INSERT INTO node_metadata (id, node_version, schema_fingerprint) + VALUES (0, ?1, ?2)", + params![version, fingerprint], + ) + .unwrap(); + } + + fn assert_wipe_error(error: anyhow::Error, expected: &str) { + let error = format!("{error:#}"); + assert!( + error.contains(expected) && error.contains(WIPE_GUIDANCE), + "unexpected initialization error: {error}" + ); + } + + #[test] + fn fresh_database_is_created_and_stamped() { + let conn = Connection::open_in_memory().unwrap(); + + initialize(&conn).unwrap(); + + let (version, fingerprint): (String, Vec) = conn + .query_row( + "SELECT node_version, schema_fingerprint + FROM node_metadata WHERE id = 0", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(version, NODE_VERSION); + assert_eq!(fingerprint, schema_fingerprint().as_slice().to_vec()); + + let user_version: u32 = conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(user_version, 0); + } + + #[test] + fn current_database_reopens_without_reapplying_schema() { + let conn = Connection::open_in_memory().unwrap(); + initialize(&conn).unwrap(); + conn.execute("INSERT INTO epochs VALUES (0, 0, '0x00', 0)", []) + .unwrap(); + + initialize(&conn).unwrap(); + + let epoch_count: u64 = conn + .query_row("SELECT COUNT(*) FROM epochs", [], |row| row.get(0)) + .unwrap(); + assert_eq!(epoch_count, 1); + } + + #[test] + fn legacy_nonempty_database_is_refused_without_mutation() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("testdata/obsolete_settlement_schema.sql")) + .unwrap(); + conn.execute("INSERT INTO settlement_info VALUES (7, zeroblob(32))", []) + .unwrap(); + let schema_version_before: u64 = conn + .query_row("PRAGMA schema_version", [], |row| row.get(0)) + .unwrap(); + + assert_wipe_error( + initialize(&conn).unwrap_err(), + "predates node compatibility metadata", + ); + let schema_version_after: u64 = conn + .query_row("PRAGMA schema_version", [], |row| row.get(0)) + .unwrap(); + assert_eq!(schema_version_after, schema_version_before); + let legacy_rows: u64 = conn + .query_row("SELECT COUNT(*) FROM settlement_info", [], |row| row.get(0)) + .unwrap(); + assert_eq!(legacy_rows, 1); + let metadata_tables: u64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_schema WHERE name = 'node_metadata'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(metadata_tables, 0); + } + + #[test] + fn missing_metadata_row_is_refused() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(SCHEMA).unwrap(); + + assert_wipe_error( + initialize(&conn).unwrap_err(), + "compatibility metadata is missing", + ); + } + + #[test] + fn different_node_version_is_refused() { + let conn = Connection::open_in_memory().unwrap(); + let fingerprint = schema_fingerprint(); + apply_schema_with_identity(&conn, "0.0.0-incompatible", fingerprint.as_slice()); + + assert_wipe_error( + initialize(&conn).unwrap_err(), + "database belongs to node version 0.0.0-incompatible", + ); + } + + #[test] + fn different_schema_fingerprint_is_refused() { + let conn = Connection::open_in_memory().unwrap(); + apply_schema_with_identity(&conn, NODE_VERSION, &[0xa5; 32]); + + assert_wipe_error( + initialize(&conn).unwrap_err(), + "database schema fingerprint", + ); + } + + #[test] + fn settlement_proof_blobs_have_exact_schema_lengths() { + let conn = Connection::open_in_memory().unwrap(); + initialize(&conn).unwrap(); + + let error = conn + .execute( + "INSERT INTO settlement_info VALUES ( + 0, zeroblob(32), zeroblob(32), + zeroblob(32), zeroblob(1887), + zeroblob(32), zeroblob(1888), + zeroblob(32), zeroblob(1888) + )", + [], + ) + .unwrap_err(); + assert!( + error.to_string().contains("CHECK constraint failed"), + "unexpected settlement shape error: {error}" + ); + } +} diff --git a/cartesi-rollups/node/src/storage/sql/migrations.sql b/cartesi-rollups/node/src/storage/sql/schema.sql similarity index 74% rename from cartesi-rollups/node/src/storage/sql/migrations.sql rename to cartesi-rollups/node/src/storage/sql/schema.sql index 2dc8f1670..34446a564 100644 --- a/cartesi-rollups/node/src/storage/sql/migrations.sql +++ b/cartesi-rollups/node/src/storage/sql/schema.sql @@ -1,36 +1,61 @@ -- (c) Cartesi and individual authors (see AUTHORS) -- SPDX-License-Identifier: Apache-2.0 (see LICENSE) -CREATE TABLE IF NOT EXISTS settlement_info ( +-- Create-only schema. schema.rs executes this file only for an empty database, +-- then atomically stamps its raw Keccak fingerprint and the node version. + +CREATE TABLE node_metadata ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + node_version TEXT NOT NULL CHECK (node_version <> ''), + schema_fingerprint BLOB NOT NULL + CHECK ( + typeof(schema_fingerprint) = 'blob' + AND length(schema_fingerprint) = 32 + ) +) WITHOUT ROWID; + +CREATE TABLE settlement_info ( epoch_number INTEGER NOT NULL PRIMARY KEY CHECK (epoch_number >= 0), - computation_hash BLOB NOT NULL, - outputs_merkle_root BLOB NOT NULL, - outputs_merkle_root_proof BLOB NOT NULL, + computation_hash BLOB NOT NULL + CHECK (typeof(computation_hash) = 'blob' AND length(computation_hash) = 32), final_state BLOB NOT NULL + CHECK (typeof(final_state) = 'blob' AND length(final_state) = 32), + iflags_y_data_block BLOB NOT NULL + CHECK (typeof(iflags_y_data_block) = 'blob' AND length(iflags_y_data_block) = 32), + iflags_y_siblings BLOB NOT NULL + CHECK (typeof(iflags_y_siblings) = 'blob' AND length(iflags_y_siblings) = 1888), + htif_tohost_data_block BLOB NOT NULL + CHECK (typeof(htif_tohost_data_block) = 'blob' AND length(htif_tohost_data_block) = 32), + htif_tohost_siblings BLOB NOT NULL + CHECK (typeof(htif_tohost_siblings) = 'blob' AND length(htif_tohost_siblings) = 1888), + tx_buffer_data_block BLOB NOT NULL + CHECK (typeof(tx_buffer_data_block) = 'blob' AND length(tx_buffer_data_block) = 32), + tx_buffer_siblings BLOB NOT NULL + CHECK (typeof(tx_buffer_siblings) = 'blob' AND length(tx_buffer_siblings) = 1888) ); -CREATE TABLE IF NOT EXISTS epochs ( +CREATE TABLE epochs ( epoch_number INTEGER NOT NULL PRIMARY KEY CHECK (epoch_number >= 0), input_index_boundary INTEGER NOT NULL, root_tournament TEXT NOT NULL, block_created_number INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS inputs ( +CREATE TABLE inputs ( epoch_number INTEGER NOT NULL CHECK (epoch_number >= 0), input_index_in_epoch INTEGER NOT NULL, input BLOB NOT NULL, PRIMARY KEY (epoch_number, input_index_in_epoch) ); -CREATE TABLE IF NOT EXISTS latest_processed ( +CREATE TABLE latest_processed ( id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), block INTEGER NOT NULL CHECK (block >= 0) ); -INSERT OR IGNORE INTO latest_processed (id, block) +INSERT INTO latest_processed (id, block) VALUES (1, 0); -CREATE TABLE IF NOT EXISTS template_machine ( +CREATE TABLE template_machine ( id INTEGER PRIMARY KEY CHECK (id = 1), state_hash BLOB NOT NULL UNIQUE @@ -38,12 +63,12 @@ CREATE TABLE IF NOT EXISTS template_machine ( ON DELETE RESTRICT ) WITHOUT ROWID; -CREATE TABLE IF NOT EXISTS machine_state_snapshots ( +CREATE TABLE machine_state_snapshots ( state_hash BLOB NOT NULL PRIMARY KEY, file_path TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS epoch_snapshot_info ( +CREATE TABLE epoch_snapshot_info ( epoch_number INTEGER NOT NULL CHECK (epoch_number >= 0), input_number INTEGER NOT NULL CHECK (input_number >= 0), state_hash BLOB NOT NULL, @@ -65,10 +90,10 @@ CREATE TABLE IF NOT EXISTS epoch_snapshot_info ( -- row. -- The sling dispute schema: the quartet cache and its write-once --- configuration (sling/config.rs). This migration is the only DDL --- path; config::pin writes the row once after it runs. +-- configuration (sling/config.rs). This file is the only DDL path; +-- config::pin writes the row once after schema initialization. -CREATE TABLE IF NOT EXISTS sling_config ( +CREATE TABLE sling_config ( id INTEGER PRIMARY KEY CHECK (id = 0), log2_input_span INTEGER NOT NULL, log2_barch_span INTEGER NOT NULL, @@ -78,7 +103,7 @@ CREATE TABLE IF NOT EXISTS sling_config ( emulator_version TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS sling_nodes ( +CREATE TABLE sling_nodes ( epoch INTEGER NOT NULL, log2_stride INTEGER NOT NULL, height INTEGER NOT NULL, @@ -96,7 +121,7 @@ CREATE TABLE IF NOT EXISTS sling_nodes ( -- current contract ABI as the decode authority and let the fused reader -- reconstruct its recursive model after restart. Prunable derived store: -- refetchable from the chain, deleted with the settled epoch. -CREATE TABLE IF NOT EXISTS tournament_events ( +CREATE TABLE tournament_events ( root_tournament TEXT NOT NULL, -- encode_hex, as epochs stores it block_number INTEGER NOT NULL, log_index INTEGER NOT NULL, @@ -107,7 +132,7 @@ CREATE TABLE IF NOT EXISTS tournament_events ( -- Monotonic watermark: the highest finalized block whose events are -- fully persisted for this dispute. Advances every tick, events or -- not, so the live tail refetch stays bounded. -CREATE TABLE IF NOT EXISTS tournament_events_watermark ( +CREATE TABLE tournament_events_watermark ( root_tournament TEXT NOT NULL PRIMARY KEY, finalized_block INTEGER NOT NULL ) WITHOUT ROWID; @@ -122,9 +147,24 @@ CREATE TABLE IF NOT EXISTS tournament_events_watermark ( -- connection. The Rust writer keeps its own checks; these are -- defense-in-depth, not the primary line. +-- node_metadata: the immutable identity of the node and schema that created +-- this rebuildable store. + +CREATE TRIGGER trg_node_metadata_no_update +BEFORE UPDATE ON node_metadata +BEGIN + SELECT RAISE(ABORT, 'node_metadata is write-once'); +END; + +CREATE TRIGGER trg_node_metadata_no_delete +BEFORE DELETE ON node_metadata +BEGIN + SELECT RAISE(ABORT, 'node_metadata is write-once'); +END; + -- epochs: append-only log, dense from 0 (mirrors insert_epochs). -CREATE TRIGGER IF NOT EXISTS trg_epochs_dense +CREATE TRIGGER trg_epochs_dense BEFORE INSERT ON epochs FOR EACH ROW WHEN NEW.epoch_number != (SELECT COALESCE(MAX(epoch_number) + 1, 0) FROM epochs) @@ -132,13 +172,13 @@ BEGIN SELECT RAISE(ABORT, 'epochs must be inserted densely from 0'); END; -CREATE TRIGGER IF NOT EXISTS trg_epochs_no_update +CREATE TRIGGER trg_epochs_no_update BEFORE UPDATE ON epochs BEGIN SELECT RAISE(ABORT, 'epochs is an append-only log'); END; -CREATE TRIGGER IF NOT EXISTS trg_epochs_no_delete +CREATE TRIGGER trg_epochs_no_delete BEFORE DELETE ON epochs BEGIN SELECT RAISE(ABORT, 'epochs is an append-only log'); @@ -148,7 +188,7 @@ END; -- next index within the last epoch, or index 0 in any later epoch -- (epochs with no inputs are skipped, not padded). -CREATE TRIGGER IF NOT EXISTS trg_inputs_contiguous +CREATE TRIGGER trg_inputs_contiguous BEFORE INSERT ON inputs FOR EACH ROW WHEN NOT ( @@ -168,13 +208,13 @@ BEGIN SELECT RAISE(ABORT, 'inputs must advance per InputId::validate_next'); END; -CREATE TRIGGER IF NOT EXISTS trg_inputs_no_update +CREATE TRIGGER trg_inputs_no_update BEFORE UPDATE ON inputs BEGIN SELECT RAISE(ABORT, 'inputs is an append-only log'); END; -CREATE TRIGGER IF NOT EXISTS trg_inputs_no_delete +CREATE TRIGGER trg_inputs_no_delete BEFORE DELETE ON inputs BEGIN SELECT RAISE(ABORT, 'inputs is an append-only log'); @@ -182,7 +222,7 @@ END; -- latest_processed: monotonic watermark on a permanent singleton. -CREATE TRIGGER IF NOT EXISTS trg_latest_processed_monotone +CREATE TRIGGER trg_latest_processed_monotone BEFORE UPDATE OF block ON latest_processed FOR EACH ROW WHEN NEW.block < OLD.block @@ -190,7 +230,7 @@ BEGIN SELECT RAISE(ABORT, 'latest_processed only rises'); END; -CREATE TRIGGER IF NOT EXISTS trg_latest_processed_no_delete +CREATE TRIGGER trg_latest_processed_no_delete BEFORE DELETE ON latest_processed BEGIN SELECT RAISE(ABORT, 'latest_processed is a permanent singleton'); @@ -198,13 +238,13 @@ END; -- settlement_info: write-once cell per epoch. -CREATE TRIGGER IF NOT EXISTS trg_settlement_info_no_update +CREATE TRIGGER trg_settlement_info_no_update BEFORE UPDATE ON settlement_info BEGIN SELECT RAISE(ABORT, 'settlement_info is write-once per epoch'); END; -CREATE TRIGGER IF NOT EXISTS trg_settlement_info_no_delete +CREATE TRIGGER trg_settlement_info_no_delete BEFORE DELETE ON settlement_info BEGIN SELECT RAISE(ABORT, 'settlement_info is write-once per epoch'); @@ -213,13 +253,13 @@ END; -- sling_config: write-once cell (config::pin absorbs an identical -- re-pin and refuses drift in Rust; the triggers close the raw path). -CREATE TRIGGER IF NOT EXISTS trg_sling_config_no_update +CREATE TRIGGER trg_sling_config_no_update BEFORE UPDATE ON sling_config BEGIN SELECT RAISE(ABORT, 'sling_config is write-once'); END; -CREATE TRIGGER IF NOT EXISTS trg_sling_config_no_delete +CREATE TRIGGER trg_sling_config_no_delete BEFORE DELETE ON sling_config BEGIN SELECT RAISE(ABORT, 'sling_config is write-once'); @@ -229,7 +269,7 @@ END; -- absorbed a DISAGREEING rewrite silently; the verify trigger closes -- that (equal rewrites still absorb via the conflict clause). -CREATE TRIGGER IF NOT EXISTS trg_template_machine_write_once_verify +CREATE TRIGGER trg_template_machine_write_once_verify BEFORE INSERT ON template_machine FOR EACH ROW WHEN EXISTS ( @@ -240,13 +280,13 @@ BEGIN SELECT RAISE(ABORT, 'template_machine disagrees with its stored row'); END; -CREATE TRIGGER IF NOT EXISTS trg_template_machine_no_update +CREATE TRIGGER trg_template_machine_no_update BEFORE UPDATE ON template_machine BEGIN SELECT RAISE(ABORT, 'template_machine is write-once'); END; -CREATE TRIGGER IF NOT EXISTS trg_template_machine_no_delete +CREATE TRIGGER trg_template_machine_no_delete BEFORE DELETE ON template_machine BEGIN SELECT RAISE(ABORT, 'template_machine is write-once'); @@ -258,7 +298,7 @@ END; -- behind the live dispute - DaveConsensus settles epoch N before -- sealing N + 1, so those tournaments are finished). -CREATE TRIGGER IF NOT EXISTS trg_sling_nodes_collision +CREATE TRIGGER trg_sling_nodes_collision BEFORE INSERT ON sling_nodes FOR EACH ROW WHEN EXISTS ( @@ -271,7 +311,7 @@ BEGIN SELECT RAISE(ABORT, 'node cache collision: nondeterminism or version drift'); END; -CREATE TRIGGER IF NOT EXISTS trg_sling_nodes_no_update +CREATE TRIGGER trg_sling_nodes_no_update BEFORE UPDATE ON sling_nodes BEGIN SELECT RAISE(ABORT, 'sling_nodes rows are write-once'); @@ -281,7 +321,7 @@ END; -- replay semantics on the boundary coordinate (a reprocessed boundary -- must reproduce the same machine state). -CREATE TRIGGER IF NOT EXISTS trg_epoch_snapshot_info_write_once_verify +CREATE TRIGGER trg_epoch_snapshot_info_write_once_verify BEFORE INSERT ON epoch_snapshot_info FOR EACH ROW WHEN EXISTS ( @@ -294,7 +334,7 @@ BEGIN SELECT RAISE(ABORT, 'snapshot boundary disagrees with its stored row: nondeterminism or corruption'); END; -CREATE TRIGGER IF NOT EXISTS trg_epoch_snapshot_info_no_update +CREATE TRIGGER trg_epoch_snapshot_info_no_update BEFORE UPDATE ON epoch_snapshot_info BEGIN SELECT RAISE(ABORT, 'epoch_snapshot_info rows are write-once (prune-only)'); @@ -304,7 +344,7 @@ END; -- pure function of the hash, so a re-registration at a different -- path is corruption. -CREATE TRIGGER IF NOT EXISTS trg_snapshots_cas_immutable +CREATE TRIGGER trg_snapshots_cas_immutable BEFORE INSERT ON machine_state_snapshots FOR EACH ROW WHEN EXISTS ( @@ -315,7 +355,7 @@ BEGIN SELECT RAISE(ABORT, 'content-addressed snapshot re-registered at a different path'); END; -CREATE TRIGGER IF NOT EXISTS trg_snapshots_no_update +CREATE TRIGGER trg_snapshots_no_update BEFORE UPDATE ON machine_state_snapshots BEGIN SELECT RAISE(ABORT, 'machine_state_snapshots rows are write-once (prune-only)'); @@ -326,13 +366,13 @@ END; -- nothing past a dispute's watermark may be stored - the tail is -- scratch by design. -CREATE TRIGGER IF NOT EXISTS trg_tournament_events_no_update +CREATE TRIGGER trg_tournament_events_no_update BEFORE UPDATE ON tournament_events BEGIN SELECT RAISE(ABORT, 'tournament_events rows are final (prune-only)'); END; -CREATE TRIGGER IF NOT EXISTS trg_tournament_events_finalized_only +CREATE TRIGGER trg_tournament_events_finalized_only BEFORE INSERT ON tournament_events FOR EACH ROW WHEN NEW.block_number > COALESCE(( @@ -345,7 +385,7 @@ END; -- tournament_events_watermark: monotonic; pruned with its dispute. -CREATE TRIGGER IF NOT EXISTS trg_tournament_events_watermark_monotone +CREATE TRIGGER trg_tournament_events_watermark_monotone BEFORE UPDATE OF finalized_block ON tournament_events_watermark FOR EACH ROW WHEN NEW.finalized_block < OLD.finalized_block diff --git a/cartesi-rollups/node/src/storage/sql/test_helper.rs b/cartesi-rollups/node/src/storage/sql/test_helper.rs index a9edd5573..883b36440 100644 --- a/cartesi-rollups/node/src/storage/sql/test_helper.rs +++ b/cartesi-rollups/node/src/storage/sql/test_helper.rs @@ -4,6 +4,11 @@ use crate::storage::Storage; use cartesi_machine::{ Machine, + cartesi_machine_sys::{ + CM_HTIF_CMD_SHIFT, CM_HTIF_DEV_SHIFT, CM_HTIF_DEV_YIELD, CM_HTIF_REASON_SHIFT, + CM_HTIF_YIELD_CMD_MANUAL, CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED, CM_REG_HTIF_TOHOST, + CM_REG_IFLAGS_Y, + }, config::{ machine::{MachineConfig, RAMConfig}, runtime::RuntimeConfig, @@ -11,7 +16,7 @@ use cartesi_machine::{ }; use tempfile::{TempDir, tempdir}; -/// A fully migrated Storage over a real (tiny) machine image: the +/// A fully initialized Storage over a real (tiny) machine image: the /// production setup path, template snapshot and engine config /// included. Tests need `../../test/programs/linux.bin` present. pub fn setup_storage() -> (TempDir, Storage) { @@ -30,9 +35,16 @@ pub fn setup_storage() -> (TempDir, Storage) { &RuntimeConfig::default(), ) .unwrap(); + // Storage tests exercise persistence, not guest execution. Start their + // template at the canonical awaiting-input boundary required at epoch roll. + let htif_tohost = (u64::from(CM_HTIF_DEV_YIELD) << CM_HTIF_DEV_SHIFT) + | (u64::from(CM_HTIF_YIELD_CMD_MANUAL) << CM_HTIF_CMD_SHIFT) + | (u64::from(CM_HTIF_YIELD_MANUAL_REASON_RX_ACCEPTED) << CM_HTIF_REASON_SHIFT); + machine.write_reg(CM_REG_IFLAGS_Y, 1).unwrap(); + machine.write_reg(CM_REG_HTIF_TOHOST, htif_tohost).unwrap(); machine.store(&machine_path).unwrap(); - let storage = Storage::migrate( + let storage = Storage::initialize( state_dir, &machine_path, 0, diff --git a/cartesi-rollups/node/src/storage/sql/testdata/inject_batch_boundary_failure.sql b/cartesi-rollups/node/src/storage/sql/testdata/inject_batch_boundary_failure.sql new file mode 100644 index 000000000..165c093bd --- /dev/null +++ b/cartesi-rollups/node/src/storage/sql/testdata/inject_batch_boundary_failure.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER fail_batch_boundary +BEFORE INSERT ON epoch_snapshot_info +WHEN NEW.epoch_number = 0 AND NEW.input_number = 3 +BEGIN + SELECT RAISE(ABORT, 'injected boundary failure'); +END; diff --git a/cartesi-rollups/node/src/storage/sql/testdata/obsolete_settlement_schema.sql b/cartesi-rollups/node/src/storage/sql/testdata/obsolete_settlement_schema.sql new file mode 100644 index 000000000..c7456fcc1 --- /dev/null +++ b/cartesi-rollups/node/src/storage/sql/testdata/obsolete_settlement_schema.sql @@ -0,0 +1,4 @@ +CREATE TABLE settlement_info ( + epoch_number INTEGER NOT NULL PRIMARY KEY, + computation_hash BLOB NOT NULL +); diff --git a/cartesi-rollups/node/src/storage/sql/testdata/remove_batch_boundary_failure.sql b/cartesi-rollups/node/src/storage/sql/testdata/remove_batch_boundary_failure.sql new file mode 100644 index 000000000..a0b0176d2 --- /dev/null +++ b/cartesi-rollups/node/src/storage/sql/testdata/remove_batch_boundary_failure.sql @@ -0,0 +1 @@ +DROP TRIGGER fail_batch_boundary; diff --git a/cartesi-rollups/node/src/tournament/reader.rs b/cartesi-rollups/node/src/tournament/reader.rs index c69664d97..91cebc3ee 100644 --- a/cartesi-rollups/node/src/tournament/reader.rs +++ b/cartesi-rollups/node/src/tournament/reader.rs @@ -961,11 +961,10 @@ mod tests { (Chain::new(provider, Vec::new()), asserter, requests) } - fn migrated_storage() -> (tempfile::TempDir, Storage) { + fn initialized_storage() -> (tempfile::TempDir, Storage) { let directory = tempfile::tempdir().unwrap(); - let mut connection = - rusqlite::Connection::open(directory.path().join("db.sqlite3")).unwrap(); - crate::storage::sql::migrations::migrate_to_latest(&mut connection).unwrap(); + let connection = rusqlite::Connection::open(directory.path().join("db.sqlite3")).unwrap(); + crate::storage::sql::schema::initialize(&connection).unwrap(); drop(connection); let storage = Storage::new(directory.path()).unwrap(); (directory, storage) @@ -1214,7 +1213,7 @@ mod tests { let resolved = head(20, 0x20); let after_resolution = head(21, 0x21); let (chain, asserter, requests) = recording_chain(); - let (_directory, storage) = migrated_storage(); + let (_directory, storage) = initialized_storage(); let state_dir = storage.state_dir().to_path_buf(); let mut reader = StateReader::new(chain.clone(), created.number, storage).unwrap(); let log_request_count = || { @@ -1447,7 +1446,7 @@ mod tests { let finalized = head(41, 0x41); let commitment = digest(10); let (chain, asserter, _) = recording_chain(); - let (_directory, storage) = migrated_storage(); + let (_directory, storage) = initialized_storage(); let mut reader = StateReader::new(chain, 40, storage).unwrap(); asserter.push_success(&Some(block(finalized, B256::repeat_byte(0x40)))); @@ -1500,7 +1499,7 @@ mod tests { match_created_log(root, second_latest, 1, second_id, 51), ]; let (chain, asserter, requests) = recording_chain(); - let (_directory, storage) = migrated_storage(); + let (_directory, storage) = initialized_storage(); let mut reader = StateReader::new(chain, 40, storage).unwrap(); asserter.push_success(&Some(block(finalized, B256::repeat_byte(0x40)))); @@ -1554,7 +1553,7 @@ mod tests { commitment_two: two, }; let (chain, asserter, requests) = recording_chain(); - let (_directory, storage) = migrated_storage(); + let (_directory, storage) = initialized_storage(); let mut reader = StateReader::new(chain, 40, storage).unwrap(); asserter.push_success(&Some(block(first_finalized, B256::repeat_byte(0x40)))); @@ -1647,7 +1646,7 @@ mod tests { let child_log = join_log(child, finalized, 4, child_commitment); let (chain, asserter, requests) = recording_chain(); - let (_directory, mut storage) = migrated_storage(); + let (_directory, mut storage) = initialized_storage(); let stored = root_logs.iter().chain([&child_log]).collect::>(); storage .append_tournament_events(root, finalized.number, &stored) @@ -1708,7 +1707,7 @@ mod tests { let orphan = address(2); let finalized = head(10, 0x10); let (chain, asserter, _) = recording_chain(); - let (_directory, mut storage) = migrated_storage(); + let (_directory, mut storage) = initialized_storage(); let root_log = join_log(root, finalized, 0, digest(10)); let orphan_log = join_log(orphan, finalized, 1, digest(20)); storage @@ -1736,7 +1735,7 @@ mod tests { let root = address(1); let finalized = head(41, 0x41); let (chain, asserter, _) = recording_chain(); - let (_directory, storage) = migrated_storage(); + let (_directory, storage) = initialized_storage(); let mut reader = StateReader::new(chain, 40, storage).unwrap(); asserter.push_success(&Some(block(finalized, B256::repeat_byte(0x40)))); diff --git a/cartesi-rollups/node/tests/engine_machine.rs b/cartesi-rollups/node/tests/engine_machine.rs index 710f85c69..709744526 100644 --- a/cartesi-rollups/node/tests/engine_machine.rs +++ b/cartesi-rollups/node/tests/engine_machine.rs @@ -124,17 +124,17 @@ fn prototype_root(image: &Path, level: u64, log2_stride: u64, log2_stride_count: commitment.merkle.root_hash().to_hex() } -/// A real migrated node database in a temp state dir, the echo +/// A real initialized node database in a temp state dir, the echo /// inputs ingested through the production path (payloads live in the /// inputs table; feeders read them there). The guard rides along: /// the state dir must outlive the Storage. -fn migrated_storage(image: &Path) -> (tempfile::TempDir, Storage) { - migrated_storage_with(image, echo_inputs()) +fn initialized_storage(image: &Path) -> (tempfile::TempDir, Storage) { + initialized_storage_with(image, echo_inputs()) } -fn migrated_storage_with(image: &Path, inputs: Vec>) -> (tempfile::TempDir, Storage) { +fn initialized_storage_with(image: &Path, inputs: Vec>) -> (tempfile::TempDir, Storage) { let dir = scratch(); - let mut storage = Storage::migrate(dir.path(), image, 0, Address::ZERO).unwrap(); + let mut storage = Storage::initialize(dir.path(), image, 0, Address::ZERO).unwrap(); let rows: Vec = inputs .into_iter() .enumerate() @@ -167,7 +167,7 @@ fn engine_root_with_inputs( log2_stride: u64, height: u64, ) -> String { - let (state_dir, storage) = migrated_storage_with(image, inputs); + let (state_dir, storage) = initialized_storage_with(image, inputs); let work = scratch(); let mut source = DisputeSource::on_store(storage, 0, work.path().to_path_buf()).unwrap(); let root = source @@ -454,11 +454,11 @@ fn prototype_commitment( .unwrap() } -/// A dispute source over a freshly migrated state dir: the epoch +/// A dispute source over a freshly initialized state dir: the epoch /// start is the only stored boundary, i.e. the template-replay /// behavior - until its own positioning densifies the store. fn machine_source(image: &Path) -> (Vec, DisputeSource) { - let (state_dir, storage) = migrated_storage(image); + let (state_dir, storage) = initialized_storage(image); let work = scratch(); let source = DisputeSource::on_store(storage, 0, work.path().to_path_buf()).unwrap(); (vec![state_dir, work], source) @@ -717,7 +717,7 @@ fn prove_transition_matches_prototype_get_logs() { for (label, meta_cycle) in shapes { // Both sides position independently from the template. // The state dir is a guard: storage lives inside it. - let (_state_dir, storage) = migrated_storage(&image); + let (_state_dir, storage) = initialized_storage(&image); let work = scratch(); let mut source = DisputeSource::on_store(storage, 0, work.path().to_path_buf()).unwrap(); let mut ruler = source.machine_at(meta_cycle).unwrap(); @@ -759,7 +759,7 @@ fn revert_closing_slot_restores_the_checkpoint() { let big_span = U256::from(structure.big_span()); let inputs = yield_inputs(); - let (_state_dir, storage) = migrated_storage_with(&image, inputs.clone()); + let (_state_dir, storage) = initialized_storage_with(&image, inputs.clone()); let work = scratch(); let mut source = DisputeSource::on_store(storage, 0, work.path().to_path_buf()).unwrap(); diff --git a/docs/build-system.md b/docs/build-system.md index 3790a7b5b..26662f5a7 100644 --- a/docs/build-system.md +++ b/docs/build-system.md @@ -86,11 +86,11 @@ module recipes are discoverable aliases. The two contract modules share one parameterized dependency-and-binding checker because their checks are identical apart from paths and labels. -`just doctor` covers build and pre-commit-check readiness. Optional machine -images, the devnet bundle, and retained E2E state belong to -`just doctor-e2e`; `just doctor-all` runs both scopes. A checkout can therefore -be healthy for ordinary development without first constructing every expensive -integration fixture. +`just doctor` covers build and pre-commit-check readiness, including the echo +and yield images consumed by the standard Rust suite. The devnet bundle, +Honeypot image, and retained E2E state belong to `just doctor-e2e`; +`just doctor-all` runs both scopes. A checkout can therefore be healthy for +ordinary development without constructing the expensive E2E fixtures. The devnet receipt is deliberately narrower than the contract worktrees. Its input digest covers production and deployment Solidity, installed production @@ -107,7 +107,7 @@ its semantic stored-machine root. The input digest names one producer script for that image rather than the shared programs Justfile. Editing the Honeypot producer therefore does not invalidate echo, yield, or stress. The v2 receipt format intentionally makes the old shared-recipe receipts stale once; rebuild -the image with the fix printed by `just doctor-e2e`. +the image with the fix printed by the relevant doctor scope. ## Deployment generations @@ -128,6 +128,14 @@ and deployment impact rather than promising equality. Changed bytecode and CREATE2-derived addresses identify a new deployment bundle and must be regenerated together. +The node database follows the same clean-slate policy: `storage/sql/schema.sql` +is the only schema definition and has no upgrade steps. An empty database is +created from that file and atomically stamped with the node package version and +the Keccak hash of the exact schema bytes. An existing database is never given +DDL at startup; its two identity values must match the running binary. A +mismatch requires deleting the state directory and rebuilding it from the +chain and machine image. + ## Open design questions Bindings generation remains open. The resolved emulator policy is retained in @@ -284,13 +292,13 @@ debugging the environment: `just check` is the pre-commit gate: fmt checks (Rust workspace and both contract dirs), luacheck over the Lua client and harness, clippy with warnings denied, the build-tooling regressions, the provider-free contract suites, and -the Rust and Lua unit suites. `just doctor` diagnoses build/check inputs; -`just doctor-e2e` diagnoses machine images, devnet artifacts, and E2E litter; -`just doctor-all` aggregates both. Every failed check prints the command that -fixes it. Run the relevant scope before debugging a mysterious failure, -especially in a fresh worktree. The e2e `test` recipe runs a preflight -with the same spirit: missing artifacts fail with a named fix, not a cryptic jq -or Lua error. +the Rust and Lua unit suites. `just doctor` diagnoses build/check inputs, +including the standard echo and yield images; `just doctor-e2e` diagnoses the +E2E image set, devnet artifacts, and E2E litter; `just doctor-all` aggregates +both. Every failed check prints the command that fixes it. Run the relevant +scope before debugging a mysterious failure, especially in a fresh worktree. +The e2e `test` recipe runs a preflight with the same spirit: missing artifacts +fail with a named fix, not a cryptic jq or Lua error. Formatter versions matter: forge changes wrapping heuristics across releases (observed live: 1.4.3 and 1.5.1-dev disagree about an diff --git a/docs/computation-hash.md b/docs/computation-hash.md index f66853d69..3db5364aa 100644 --- a/docs/computation-hash.md +++ b/docs/computation-hash.md @@ -63,7 +63,7 @@ It rejects a larger counter before parsing its proof or consulting the data provider. The concrete adapter also exposes the `CM_MARCHID` qualified for the pinned Cartesi Machine v0.21 release. Dave pins that value locally until a released solidity-step exports it. Node startup compares the deployed value -with the linked Cartesi Machine library before opening or migrating local +with the linked Cartesi Machine library before opening or initializing local storage. ## The leaf sequence diff --git a/docs/epoch-lifecycle.md b/docs/epoch-lifecycle.md index 6ae7f8799..7ec26014f 100644 --- a/docs/epoch-lifecycle.md +++ b/docs/epoch-lifecycle.md @@ -81,7 +81,9 @@ Three worker threads share one SQLite database (see the batch's current pre-input checkpoint. The batch publishes only its final machine boundary and commits it together with all window roots. Rolling stores the settlement info (computation hash, post-epoch machine state hash, - output merkle, output proof) and the next epoch's initial snapshot. + and the three machine leaf proofs for `iflags_Y`, HTIF tohost, and the first + TX-buffer block) together with the next epoch's initial snapshot. The TX + block itself is the outputs Merkle root. - epoch-manager (`cartesi-rollups/node/src/epoch_manager`): each iteration runs the dispute tick first - for the last sealed epoch, instantiate a `Hero` with the epoch's inputs, leaves, and snapshot, and let it react diff --git a/docs/node-architecture.md b/docs/node-architecture.md index 2281d6cf4..d6517fe11 100644 --- a/docs/node-architecture.md +++ b/docs/node-architecture.md @@ -23,13 +23,14 @@ runtime (`lib.rs run()`), each owning its own SQLite connection: - epoch-manager (async task): db + chain -> settle txs and dispute reactions -Before opening or migrating the database, startup resolves the tournament +Before opening or initializing the database, startup resolves the tournament factory from Dave consensus and reads its level-zero parameters plus its configured state transition. The binary refuses to start unless the deployed root stride equals the node's compiled window-root sampling stride, the root row spans the compiled 92-bit machine coordinate, and the concrete `CartesiStateTransition.CM_MARCHID()` equals the `CM_MARCHID` exported by the -linked Cartesi Machine library. These checks all run before database migration, +linked Cartesi Machine library. These checks all run before database +initialization, so an incompatible deployment cannot create or alter local state. This is a deployment-compatibility assertion over trusted factory configuration, not runtime validation of every tournament row. Deeper geometry continues to come @@ -105,12 +106,25 @@ Dispute positioning remains different: an intermediate boundary only shortens replay, so an unavailable one may fall back to an earlier verified boundary within the epoch. -Main schema (`storage/sql/migrations.sql`): +Schema initialization owns one create-only `storage/sql/schema.sql`; there are +no migrations or ordered schema versions. On an empty database, startup applies +that file once and atomically records the node package version plus the Keccak +hash of the exact schema file. On later launches it executes no DDL: both stored +values must match the running binary, or startup refuses the state directory +before applying schema changes. The raw file fingerprint catches schema changes +between builds that share a package version. It attests which schema created +this node-owned cache; manual database mutation remains unsupported rather than +continuously audited. +Main schema (`storage/sql/schema.sql`): + +- `node_metadata(node_version, schema_fingerprint)` - immutable cache identity - `epochs(epoch_number, input_index_boundary, root_tournament, block_created_number)` - `inputs(epoch_number, input_index_in_epoch, input)` - `latest_processed(block)` - singleton; last finalized block ingested -- `settlement_info(epoch_number, computation_hash, outputs_merkle_root, outputs_merkle_root_proof, final_state)` +- `settlement_info(epoch_number, computation_hash, final_state, data block and + sibling blobs for iflags_Y, HTIF tohost, and the TX buffer)` - the TX data + block is the outputs Merkle root - `machine_state_snapshots(state_hash, file_path)` + `epoch_snapshot_info` (which (epoch, input) has which snapshot) + `template_machine` (pins the genesis snapshot) @@ -124,11 +138,19 @@ Every table belongs to one of four mutation classes - append-only log, write-once cell (equal rewrites absorbed, disagreements fatal), monotonic watermark, prunable derived store - and the schema's trigger layer enforces the taxonomy against any writer, including raw -connections (`sql/migrations.sql`, tested by `sql/discipline.rs`). +connections (`sql/schema.sql`, tested by `sql/discipline.rs`). Snapshot directories are removed only AFTER the transaction that unreferenced their rows commits: a crash may orphan a directory, never dangle a row. +The runner captures all three settlement leaves from one final machine root, +checks their emulator proof metadata, Keccak openings, nonzero `iflags_Y`, and +manual `RX_ACCEPTED` HTIF reason, and verifies that root again when publishing +the next epoch's initial boundary. It intentionally does not interpret the +HTIF response-length field. The boundary row and complete settlement row then +commit in the same SQLite transaction. Reads revalidate every persisted proof +against its final state so corruption fails before transaction staging. + One schema note to know about: - The dispute tables (`sling_config`, `sling_nodes`) live in the main diff --git a/docs/test-harness.md b/docs/test-harness.md index 3ca9f9649..b670750bd 100644 --- a/docs/test-harness.md +++ b/docs/test-harness.md @@ -74,6 +74,9 @@ test, never a source): - Sybil machine material: oracle epoch snapshots. - Tournament winners and settlement: chain state, compared against the oracle commitment. +- Settlement machine validity: `Env.roll_epoch` waits for the next + `EpochSealed`, which can follow only after DaveConsensus accepts the staged + final-state proof and its TX-buffer outputs root. - Node reads (`dave/node.lua`) serve synchronization (wait until the node has progressed) and produce the cross-check subjects. diff --git a/justfile b/justfile index c6fd754fa..85b775c03 100644 --- a/justfile +++ b/justfile @@ -50,24 +50,24 @@ logged log +cmd: exit $status # ------------------------------------------------------------------ -# Setup: one-time preparation. Idempotent; safe to re-run. Does NOT -# clean anything (see the clean recipes for that). +# Setup: preparation. Safe to re-run; rebuild recipes may replace their own +# regenerable artifacts, but setup does not wipe runtime state. # ------------------------------------------------------------------ update-submodules: git submodule update --recursive --init -# Everything the Rust workspace needs to compile. +# Everything the Rust workspace needs to compile and run its standard tests. setup: just machine::setup just prt-contracts::install-deps just rollups-contracts::install-deps + just programs::download-deps + just programs::build-programs # Setup plus everything the e2e tests need, running natively. setup-local: setup just rollups-contracts::build-devnet - just programs::download-deps - just programs::build-programs just programs::build-honeypot-snapshot # requires docker # Setup the Docker build context without first building an unused host archive. @@ -184,9 +184,12 @@ check-rust-workspace: bind # ensure-docker: the kms tests spin testcontainers, and a sleeping # Docker Desktop fails them with noise that reads like a code bug. -# rust workspace unit tests (the kms tests spin docker testcontainers; -# external machine-image and release-corpus gates stay explicit below) +# rust workspace tests (the kms tests spin docker testcontainers; CI and +# setup prepare the echo/yield images used by ordinary tests; +# expensive machine differentials and the release corpus stay explicit below) test-rust-workspace: bind + ./script/machine-image-fingerprint.sh verify echo + ./script/machine-image-fingerprint.sh verify yield ./script/ensure-docker.sh cargo test diff --git a/script/bootstrap-worktree.sh b/script/bootstrap-worktree.sh index a24a088ce..ca6af7b14 100755 --- a/script/bootstrap-worktree.sh +++ b/script/bootstrap-worktree.sh @@ -230,9 +230,15 @@ CDPATH= cd -- "$repo_root" || { exit 2 } -just setup -just prt-contracts::install-deps -just rollups-contracts::install-deps +if [[ -n "$source_root" ]]; then + # Preserve the bootstrap fast path: copied standard images replace the + # image-building portion of root setup. + just machine::setup + just prt-contracts::install-deps + just rollups-contracts::install-deps +else + just setup +fi just bind if [[ -n "$source_root" ]]; then diff --git a/script/doctor.sh b/script/doctor.sh index 6601b83fb..f285b31a2 100755 --- a/script/doctor.sh +++ b/script/doctor.sh @@ -57,6 +57,7 @@ run_component() { check_toolchain() { echo "toolchain (nix users: 'direnv allow' provides all of these)" for tool in git cargo forge lua5.4 luacheck jq sqlite3 \ + cartesi-machine cartesi-machine-stored-hash \ wget curl realpath sha256sum sort; do if command -v "$tool" > /dev/null; then ok "$tool"; else miss "$tool not on PATH" "install it (see README.md requirements)"; fi @@ -117,12 +118,13 @@ if command -v xgenext2fs > /dev/null; then ok "xgenext2fs"; else } check_rust_build_inputs() { -echo "rust build inputs" +echo "rust build and standard-test inputs" run_component "machine" machine ./script/doctor.sh run_component "prt-contracts" prt/contracts \ "$repo_root/script/contracts-doctor.sh" prt run_component "rollups-contracts" cartesi-rollups/contracts \ "$repo_root/script/contracts-doctor.sh" rollups +run_component "programs" test/programs ./script/doctor.sh standard } check_e2e_test_inputs() { diff --git a/test/e2e/rollups/dave/node.lua b/test/e2e/rollups/dave/node.lua index fd710d8f9..3087d4932 100644 --- a/test/e2e/rollups/dave/node.lua +++ b/test/e2e/rollups/dave/node.lua @@ -2,7 +2,7 @@ -- node internals (its SQLite schemas and process lifecycle). Everything -- it reads serves synchronization or produces cross-check subjects; the -- oracle in test_env.lua never consumes node state. When the node's --- schema changes, this file is the complete migration surface. +-- schema changes, this file is the complete update surface. local Hash = require "cryptography.hash" local Machine = require "computation.machine" diff --git a/test/programs/script/doctor.sh b/test/programs/script/doctor.sh index ef7c80b88..03f3c5187 100755 --- a/test/programs/script/doctor.sh +++ b/test/programs/script/doctor.sh @@ -2,6 +2,19 @@ # Not set -e: dependency and image checks must all run. set -u +usage() { + printf 'usage: test/programs/script/doctor.sh [standard|all]\n' >&2 + exit 2 +} + +scope=${1:-all} +[[ "$#" -le 1 ]] || usage +case "$scope" in + standard|all) ;; + *) usage ;; +esac +readonly scope + script_dir="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" || { printf 'programs doctor: cannot resolve its script directory\n' >&2 exit 2 @@ -101,8 +114,10 @@ if [[ ! -x "$fingerprint_checker" ]]; then else check_image echo "just programs::build-echo" check_image yield "just programs::build-yield" - check_image honeypot \ - "ensure the devnet is current with just rollups-contracts::build-devnet, then run: just programs::build-honeypot-snapshot" + if [[ "$scope" == all ]]; then + check_image honeypot \ + "ensure the devnet is current with just rollups-contracts::build-devnet, then run: just programs::build-honeypot-snapshot" + fi fi printf '\n' case "$status" in