diff --git a/constants.toml b/constants.toml index b1342c5a82..184c9a880c 100644 --- a/constants.toml +++ b/constants.toml @@ -203,6 +203,7 @@ SESSION_REGISTRY_DISCRIMINANT = 19 BLACKLIST_DISCRIMINANT = 20 # Test modules +EMPTY_VALUE_SET_MODULE_DISCRIMINANT = 226 EVENT_EMITTER_MODULE_DISCRIMINANT = 227 TEST_DEX_DISCRIMINANT = 228 SEQUENCING_DATA_TESTER_DISCRIMINANT = 229 diff --git a/crates/module-system/sov-modules-api/tests/integration/state_tests/namespaces.rs b/crates/module-system/sov-modules-api/tests/integration/state_tests/namespaces.rs index 1d8e36146a..c1146e36ba 100644 --- a/crates/module-system/sov-modules-api/tests/integration/state_tests/namespaces.rs +++ b/crates/module-system/sov-modules-api/tests/integration/state_tests/namespaces.rs @@ -232,3 +232,145 @@ where Ok(()) } + +// NOMT requires both user and kernel namespaces to be written together in each +// commit; a user-only write triggers a harness panic. Mirrors the helper in +// `structs.rs`. +fn write_kernel_marker(state: &mut StateCheckpoint) { + let mut kernel_val: KernelStateValue = + KernelStateValue::with_codec(Prefix::new(255, 0), BorshCodec); + kernel_val.set(&0u8, state).unwrap(); +} + +// The following tests probe the claim that the state backend silently drops +// zero-byte values on commit, i.e. `StateMap<_, ()>` and `StateValue<()>` appear +// present in the same session but return `None` after a commit/readback cycle. + +#[test] +fn test_jmt_state_map_unit_value_persists_across_commit() -> Result<(), Infallible> { + let mut storage_manager = SimpleJmtStorageManager::new(); + storage_manager.genesis(); + test_state_map_unit_value_persists::(storage_manager) +} + +#[test] +fn test_nomt_state_map_unit_value_persists_across_commit() -> Result<(), Infallible> { + let storage_manager = SimpleStorageManager::new(); + test_state_map_unit_value_persists::(storage_manager) +} + +fn test_state_map_unit_value_persists(mut storage_manager: Sm) -> Result<(), Infallible> +where + S: Spec, + Sm: ForklessStorageManager, +{ + let (storage, prev_root) = storage_manager.create_storage_with_root(); + let mut state_map = StateMap::::with_codec(Prefix::new(0, 0), BorshCodec); + let mut kernel = MockKernel::::default(); + + let mut state: StateCheckpoint = StateCheckpoint::new(storage.clone(), &kernel, None); + state_map.set(&0x12345678u32, &(), &mut state)?; + assert_eq!( + state_map.get(&0x12345678u32, &mut state)?, + Some(()), + "StateMap<_, ()> should return Some(()) after set in the same session", + ); + write_kernel_marker(&mut state); + commit_to_storage(state, storage, &mut kernel, &mut storage_manager, prev_root); + + let (storage, _) = storage_manager.create_storage_with_root(); + let mut state: StateCheckpoint = StateCheckpoint::new(storage, &kernel, None); + assert_eq!( + state_map.get(&0x12345678u32, &mut state)?, + Some(()), + "StateMap<_, ()> should still return Some(()) after commit + fresh session", + ); + + Ok(()) +} + +#[test] +fn test_jmt_state_map_bool_false_persists_across_commit() -> Result<(), Infallible> { + let mut storage_manager = SimpleJmtStorageManager::new(); + storage_manager.genesis(); + test_state_map_bool_false_persists::(storage_manager) +} + +#[test] +fn test_nomt_state_map_bool_false_persists_across_commit() -> Result<(), Infallible> { + let storage_manager = SimpleStorageManager::new(); + test_state_map_bool_false_persists::(storage_manager) +} + +fn test_state_map_bool_false_persists(mut storage_manager: Sm) -> Result<(), Infallible> +where + S: Spec, + Sm: ForklessStorageManager, +{ + let (storage, prev_root) = storage_manager.create_storage_with_root(); + let mut state_map = StateMap::::with_codec(Prefix::new(0, 0), BorshCodec); + let mut kernel = MockKernel::::default(); + + let mut state: StateCheckpoint = StateCheckpoint::new(storage.clone(), &kernel, None); + state_map.set(&0x12345678u32, &false, &mut state)?; + assert_eq!( + state_map.get(&0x12345678u32, &mut state)?, + Some(false), + "StateMap<_, bool> should return Some(false) after set in the same session", + ); + write_kernel_marker(&mut state); + commit_to_storage(state, storage, &mut kernel, &mut storage_manager, prev_root); + + let (storage, _) = storage_manager.create_storage_with_root(); + let mut state: StateCheckpoint = StateCheckpoint::new(storage, &kernel, None); + assert_eq!( + state_map.get(&0x12345678u32, &mut state)?, + Some(false), + "StateMap<_, bool> control: Some(false) must persist across commit (harness sanity)", + ); + + Ok(()) +} + +#[test] +fn test_jmt_state_value_unit_persists_across_commit() -> Result<(), Infallible> { + let mut storage_manager = SimpleJmtStorageManager::new(); + storage_manager.genesis(); + test_state_value_unit_persists::(storage_manager) +} + +#[test] +fn test_nomt_state_value_unit_persists_across_commit() -> Result<(), Infallible> { + let storage_manager = SimpleStorageManager::new(); + test_state_value_unit_persists::(storage_manager) +} + +fn test_state_value_unit_persists(mut storage_manager: Sm) -> Result<(), Infallible> +where + S: Spec, + Sm: ForklessStorageManager, +{ + let (storage, prev_root) = storage_manager.create_storage_with_root(); + let mut state_value = StateValue::<()>::with_codec(Prefix::new(0, 0), BorshCodec); + let mut kernel = MockKernel::::default(); + + let mut state: StateCheckpoint = StateCheckpoint::new(storage.clone(), &kernel, None); + state_value.set(&(), &mut state)?; + assert_eq!( + state_value.get(&mut state)?, + Some(()), + "StateValue<()> should return Some(()) after set in the same session", + ); + write_kernel_marker(&mut state); + commit_to_storage(state, storage, &mut kernel, &mut storage_manager, prev_root); + + let (storage, _) = storage_manager.create_storage_with_root(); + let mut state: StateCheckpoint = StateCheckpoint::new(storage, &kernel, None); + assert_eq!( + state_value.get(&mut state)?, + Some(()), + "StateValue<()> should still return Some(()) after commit + fresh session", + ); + + Ok(()) +} diff --git a/crates/utils/sov-test-utils/tests/integration/empty_value_set.rs b/crates/utils/sov-test-utils/tests/integration/empty_value_set.rs new file mode 100644 index 0000000000..3039151fef --- /dev/null +++ b/crates/utils/sov-test-utils/tests/integration/empty_value_set.rs @@ -0,0 +1,182 @@ +//! End-to-end reproduction of "zero-byte values missing after query_visible_state". +//! +//! The SDK-layer tests in `sov-modules-api/tests/integration/state_tests/namespaces.rs` +//! write via a bare `StateCheckpoint` and read back via another bare `StateCheckpoint`. +//! That proves the raw `StateMap` + storage round-trip works for `V = ()`, but skips +//! every layer that sits between a real rollup's `Module::genesis` and an RPC-style +//! `ApiStateAccessor` read. This file fills that gap. +//! +//! Each test builds a minimal runtime that includes `EmptyValueSetModule` alongside the +//! usual test runtime modules, writes a fixed set of keys in the module's `genesis`, +//! and then queries the same module via `TestRunner::query_visible_state` — the path +//! that was observed to return `None` in production. + +use schemars::JsonSchema; +use sov_modules_api::macros::{serialize, UniversalWallet}; +use sov_modules_api::prelude::UnwrapInfallible; +use sov_modules_api::{ + Context, DaSpec, GenesisState, Module, ModuleId, ModuleInfo, Spec, StateMap, TxState, +}; +use sov_paymaster::{Paymaster, PaymasterConfig, SafeVec}; +use sov_test_utils::generate_optimistic_runtime; +use sov_test_utils::runtime::genesis::optimistic::HighLevelOptimisticGenesisConfig; +use sov_test_utils::runtime::TestRunner; +use sov_test_utils::TestSpec; +use sov_value_setter::{ValueSetter, ValueSetterConfig}; + +/// A test module that holds a `StateMap` and a `StateMap`. Both +/// are populated at genesis from the module's config. The `bool` map is a control — +/// if the `()` map loses entries across commit but the `bool` map keeps them, the +/// difference is specifically about zero-byte values. +#[derive(Clone, ModuleInfo)] +pub struct EmptyValueSetModule { + #[id] + pub id: ModuleId, + + /// Map with the zero-byte value — the subject of the investigation. + #[state] + pub unit_keys: StateMap, + + /// Control map whose value type serializes to a single non-zero byte. + #[state] + pub bool_keys: StateMap, + + #[phantom] + _phantom: std::marker::PhantomData, +} + +#[derive( + Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, JsonSchema, +)] +#[serde(deny_unknown_fields)] +pub struct EmptyValueSetConfig { + /// Keys to insert into `unit_keys` at genesis. + pub unit_keys: Vec, + /// Keys to insert into `bool_keys` at genesis (each is set to `false`). + pub bool_keys: Vec, +} + +#[derive(Debug, PartialEq, Clone, JsonSchema)] +#[serialize(Borsh, Serde)] +pub enum EmptyValueSetEvent { + /// Placeholder — the test module never emits events. + Noop, +} + +#[derive(Debug, PartialEq, Eq, Clone, JsonSchema, UniversalWallet)] +#[serialize(Borsh, Serde)] +pub enum EmptyValueSetCall { + /// Placeholder — the test module accepts no call messages. + Noop, +} + +impl Module for EmptyValueSetModule { + type Spec = S; + type Config = EmptyValueSetConfig; + type CallMessage = EmptyValueSetCall; + type Event = EmptyValueSetEvent; + type Error = anyhow::Error; + + fn genesis( + &mut self, + _genesis_rollup_header: &<::Da as DaSpec>::BlockHeader, + config: &Self::Config, + state: &mut impl GenesisState, + ) -> anyhow::Result<()> { + for key in &config.unit_keys { + self.unit_keys.set(key, &(), state)?; + } + for key in &config.bool_keys { + self.bool_keys.set(key, &false, state)?; + } + Ok(()) + } + + fn call( + &mut self, + _msg: Self::CallMessage, + _context: &Context, + _state: &mut impl TxState, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +generate_optimistic_runtime!(EmptyValueSetRuntime <= + value_setter: ValueSetter, + paymaster: Paymaster, + empty_value_set: EmptyValueSetModule +); + +type S = TestSpec; +type RT = EmptyValueSetRuntime; + +fn setup(unit_keys: Vec, bool_keys: Vec) -> TestRunner { + let genesis_config = + HighLevelOptimisticGenesisConfig::generate().add_accounts_with_default_balance(1); + + let admin = genesis_config + .additional_accounts() + .first() + .unwrap() + .clone(); + + let value_setter_config = ValueSetterConfig { + admin: admin.address(), + }; + let paymaster_config = PaymasterConfig { + payers: SafeVec::new(), + }; + let empty_value_set_config = EmptyValueSetConfig { + unit_keys, + bool_keys, + }; + + let genesis = GenesisConfig::from_minimal_config( + genesis_config.into(), + value_setter_config, + paymaster_config, + empty_value_set_config, + ); + + TestRunner::new_with_genesis(genesis.into_genesis_params(), RT::default()) +} + +#[test] +fn test_unit_value_survives_genesis_commit_and_is_visible_via_api_accessor() { + let unit_keys = vec![0x12345678u32, 42, 100]; + let runner = setup(unit_keys.clone(), vec![]); + + runner.query_visible_state(|state| { + let module = EmptyValueSetModule::::default(); + for key in &unit_keys { + assert_eq!( + module.unit_keys.get(key, state).unwrap_infallible(), + Some(()), + "genesis-written unit key {key} should be visible via query_visible_state", + ); + } + assert_eq!( + module.unit_keys.get(&999u32, state).unwrap_infallible(), + None, + "an unwritten key should be absent — sanity check", + ); + }); +} + +#[test] +fn test_bool_control_value_survives_genesis_commit_and_is_visible_via_api_accessor() { + let bool_keys = vec![0x12345678u32, 42, 100]; + let runner = setup(vec![], bool_keys.clone()); + + runner.query_visible_state(|state| { + let module = EmptyValueSetModule::::default(); + for key in &bool_keys { + assert_eq!( + module.bool_keys.get(key, state).unwrap_infallible(), + Some(false), + "control: bool-valued key {key} should be visible via query_visible_state", + ); + } + }); +} diff --git a/crates/utils/sov-test-utils/tests/integration/main.rs b/crates/utils/sov-test-utils/tests/integration/main.rs index dc741cf899..33d69a9c9a 100644 --- a/crates/utils/sov-test-utils/tests/integration/main.rs +++ b/crates/utils/sov-test-utils/tests/integration/main.rs @@ -1,4 +1,5 @@ mod archival_queries; +mod empty_value_set; mod execution; mod helpers; mod queries;