diff --git a/CHANGELOG.md b/CHANGELOG.md index 449fcec406..6984804301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,7 @@ Temporary section for maintaining breaking changes from individual PRs, which wi Receipt `effectiveGasPrice` and projected `gasUsed` are now unconditionally derived from the actual metered fee. Forks that had set this to a non-zero future activation height must migrate; the new behavior is mandatory. - #2934 **Breaking Change**: Removes the `CHANGE_GAS_LIMIT_AFTER_HEIGHT` gas-limit fork. The `INITIAL_GAS_LIMIT` and `UPDATED_GAS_LIMIT` constants are replaced by a single `BLOCK_GAS_LIMIT` constant (test override env var `SOV_TEST_CONST_OVERRIDE_INITIAL_GAS_LIMIT` → `SOV_TEST_CONST_OVERRIDE_BLOCK_GAS_LIMIT`). Removes the `sequencer.height_for_gas_limit_computation` rate-limiter config field and the `ChainStateCapability::block_gas_limit` / `ChainState::block_gas_limit{,_at}` methods (use `::block_gas_limit()` instead). The block gas limit is now constant for all heights; the per-block sequencer safeguards (preferred-sequencer pre-exec escrow, zero-bond preferred-sequencer penalization, and the slot-gas-limit pre-check) remain unconditionally enabled. +- #3032 Generation and windowed-nonce based uniqueness checks now reject transactions that provide a value 2x greater than the last observed value (except for very low numbers where the limit is more lenient). This provides some protection against user error accidentally making an account unusable by submitting a transaction with a very high value, irreversibly ratcheting the generation or nonce window to an unergonomic value or even u64::MAX. - #2965 sov-migrations: the v1 (state_version 0->1) migration is now idempotent. Re-running it against an already-migrated DB (state_version >= 1) logs a message and exits Ok instead of erroring, so a restarted rollup that re-invokes the migration binary no longer fails. # 2026-04-01 diff --git a/crates/bench/sp1-microbenches/guest-storage/Cargo.lock b/crates/bench/sp1-microbenches/guest-storage/Cargo.lock index 30e9d8b8dd..b3bc118dd1 100644 --- a/crates/bench/sp1-microbenches/guest-storage/Cargo.lock +++ b/crates/bench/sp1-microbenches/guest-storage/Cargo.lock @@ -1184,9 +1184,9 @@ dependencies = [ [[package]] name = "nomt-core" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c29af216422c69aeef84d7c20bb7638d442ec5016d93df53d1fb5152f7e4dc7" +checksum = "19b43245e3ed2b32eaf8c062506ec16a4ff3f08e0373c29c2b0b202e8c325409" dependencies = [ "arrayvec", "bitvec", @@ -2096,7 +2096,7 @@ dependencies = [ [[package]] name = "sov-universal-wallet" -version = "0.4.0" +version = "0.4.1" dependencies = [ "alloy-primitives", "arrayvec", @@ -2116,7 +2116,7 @@ dependencies = [ [[package]] name = "sov-universal-wallet-macro-helpers" -version = "0.4.0" +version = "0.4.1" dependencies = [ "bech32", "borsh", @@ -2130,7 +2130,7 @@ dependencies = [ [[package]] name = "sov-universal-wallet-macros" -version = "0.4.0" +version = "0.4.1" dependencies = [ "sov-universal-wallet-macro-helpers", "syn 2.0.117", diff --git a/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs b/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs index aba74ebbca..98d43df5e7 100644 --- a/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs +++ b/crates/full-node/sov-sequencer/tests/integration/preferred_end_to_end.rs @@ -892,8 +892,9 @@ impl Default for TestState { Self { value_by_slot_number: Default::default(), _current_slot_number: Default::default(), - // initialize to a higher generation so that "invalid generation" actions are always possible - next_generation: config_value!("PAST_TRANSACTION_GENERATIONS") + 10, + // Start at the pruning boundary so that "too old" generation actions + // are possible without exceeding the forward-generation cap during setup. + next_generation: config_value!("PAST_TRANSACTION_GENERATIONS"), current_value: Default::default(), } } diff --git a/crates/module-system/hyperlane/tests/integration/with_agent/mod.rs b/crates/module-system/hyperlane/tests/integration/with_agent/mod.rs index e3f35f5959..64770820ad 100644 --- a/crates/module-system/hyperlane/tests/integration/with_agent/mod.rs +++ b/crates/module-system/hyperlane/tests/integration/with_agent/mod.rs @@ -14,7 +14,6 @@ use std::collections::HashMap; use std::io::Read; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::Result; use base64::prelude::BASE64_STANDARD; @@ -873,8 +872,15 @@ fn tx_set_relayer_config(relayer: &TestUser) -> RawTx { } fn generation() -> u64 { - static GENERATION: AtomicU64 = AtomicU64::new(0); - GENERATION.fetch_add(1, Ordering::Relaxed) + // The dockerized Hyperlane agent uses Unix milliseconds for generations. + // Keep test-created transactions in the same range for shared credentials. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after the Unix epoch"); + + now.as_secs() + .saturating_mul(1_000) + .saturating_add(u64::from(now.subsec_millis())) } async fn anvil_rpc_value(port: u16, method: &str, params: Value) -> Value { diff --git a/crates/module-system/module-implementations/sov-uniqueness/README.md b/crates/module-system/module-implementations/sov-uniqueness/README.md index d5096836b8..b5e1e65f89 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/README.md +++ b/crates/module-system/module-implementations/sov-uniqueness/README.md @@ -2,8 +2,12 @@ The `sov-uniqueness` module is responsible for ensuring transaction deduplication on the rollup. -The module does not expose any `CallMessage` therefore, its state can't be directly modified by the users of the rollup. Instead the nonces/generation buckets are modified via the rollup's capabilities. +The module does not expose any `CallMessage`, so users cannot directly modify its state. Instead, nonce, generation, and windowed nonce state is updated through rollup capabilities during transaction processing. -Transaction deduplication can be done in two ways: -- Nonce deduplication: Each transaction sent by a given `sov_rollup_interface::crypto::CredentialId` has a unique nonce. This is a simple way to deduplicate transaction, this mechanism is similar to what is used by blockchains such as Ethereum. It is not possible to send a transaction with the same nonce twice, and the nonce is incremented by one for each transaction. -- Generation deduplication: Each transaction sent by a given `sov_rollup_interface::crypto::CredentialId` has an associated generation number. Each generation is mapped to a bucket of transactions that deduplicate transactions by their hash. Each credential can store at most `MAX_STORED_TX_HASHES_PER_CREDENTIAL` in `PAST_TRANSACTION_GENERATIONS` generations. When a transaction land with a generation number that is higher than the highest known generation, the buckets older than `new_generation - PAST_TRANSACTION_GENERATIONS` are pruned. This mechanism allows fast and (somewhat) stateless transaction deduplication, for usage by market makers for example. Transaction buckets can be mapped to an increasing timestamp with second granularity for instance. \ No newline at end of file +Transaction deduplication can be done in three ways: + +- Nonce deduplication: Each transaction sent by a given `sov_rollup_interface::crypto::CredentialId` has a unique sequential nonce. This is similar to nonce handling in blockchains such as Ethereum. It is not possible to send a transaction with the same nonce twice, and the nonce is incremented by one for each transaction. +- Generation deduplication: Each transaction sent by a given credential has an associated generation number. Each generation is mapped to a bucket of transaction hashes. Each credential can store at most `MAX_STORED_TX_HASHES_PER_CREDENTIAL` hashes across `PAST_TRANSACTION_GENERATIONS` generations. When a transaction lands with a generation higher than the highest known generation, buckets older than `new_generation - PAST_TRANSACTION_GENERATIONS` are pruned. This mechanism allows fast and somewhat stateless deduplication; for example, transaction buckets can be mapped to an increasing timestamp with second granularity. +- Windowed nonce deduplication: Each transaction sent by a given credential has a nonce that must not have been seen before, but nonces do not need to be consecutive. The module stores a bitmap over the most recent `PAST_TRANSACTIONS_WINDOW` nonce range. Nonces below the current window are treated as already consumed, while unseen nonces inside or ahead of the window can be accepted. + +For generation and windowed nonce deduplication, fresh credentials with no stored state may submit any initial value. After that, generation transactions may not increase the latest known generation above `max(latest * 2, latest + PAST_TRANSACTION_GENERATIONS + 1)`, which still permits the minimum jump needed to prune existing buckets. Windowed nonce transactions may not increase the highest seen nonce above `max(highest * 2, PAST_TRANSACTIONS_WINDOW)`. These limits prevent accidental jumps such as submitting millisecond timestamps where second timestamps were intended, or submitting `u64::MAX`, while still allowing intentional larger migrations through a short series of intermediate transactions. Values cannot be lowered again without reintroducing replay risks. diff --git a/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs b/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs index 543f4ca06a..da5286e1b0 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/src/generations.rs @@ -3,6 +3,34 @@ use sov_modules_api::{CredentialId, Spec, StateAccessor, StateReader, TxHash}; use sov_state::User; use crate::Uniqueness; + +fn ensure_generation_is_not_too_far_ahead( + credential_id: &CredentialId, + latest_generation: u64, + past_transaction_generations: u64, + transaction_generation: u64, +) -> anyhow::Result<()> { + // A jump to `latest_generation + PAST_TRANSACTION_GENERATIONS + 1` is the + // minimum increase that guarantees the current pruning rule can drop every + // existing bucket if the stored transaction hashes are at capacity. + let minimum_pruning_generation = latest_generation + .saturating_add(past_transaction_generations) + .saturating_add(1); + // Always allow at minimum a large enough increase to prune, in case the buckets are currently + // at capacity. This avoids an account deadlock at low generation numbers (where a doubling + // wouldn't be enough to prune). + let max_allowed_generation = latest_generation + .saturating_mul(2) + .max(minimum_pruning_generation); + + anyhow::ensure!( + transaction_generation <= max_allowed_generation, + "Bad generation for credential id: {credential_id}, latest known value is: {latest_generation}, provided value {transaction_generation} exceeds the maximum allowed value {max_allowed_generation}", + ); + + Ok(()) +} + impl Uniqueness { pub(crate) fn check_generation_uniqueness( &self, @@ -18,14 +46,23 @@ impl Uniqueness { // The "currently active" generations is the range containing the latest seen generation // and the previous PAST_TRANSACTION_GENERATIONS - let latest_generation = senders_buckets - .last_key_value() - .map_or(transaction_generation, |(k, _)| *k); + let latest_generation = senders_buckets.last_key_value().map(|(k, _)| *k); let past_transaction_generations: u64 = config_value!("PAST_TRANSACTION_GENERATIONS"); let transaction_generation_cutoff: u64 = past_transaction_generations.checked_sub(1) .ok_or( anyhow::anyhow!("PAST_TRANSACTION_GENERATIONS should be greater than 0. Please ensure you have set this value correctly"))?; + if let Some(latest_generation) = latest_generation { + ensure_generation_is_not_too_far_ahead( + credential_id, + latest_generation, + past_transaction_generations, + transaction_generation, + )?; + } + + let latest_generation = latest_generation.unwrap_or(transaction_generation); + // If we're below the current generation range, always fail // Note about the arithmetic: for a given PAST_TRANSACTION_GENERATIONS, the correct // comparison is diff --git a/crates/module-system/module-implementations/sov-uniqueness/src/window.rs b/crates/module-system/module-implementations/sov-uniqueness/src/window.rs index 761fd19240..58bd1f845a 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/src/window.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/src/window.rs @@ -94,6 +94,27 @@ impl Window { // Otherwise, check the bitmap self.bits.get_bit(offset) } + + fn highest_seen_nonce(&self) -> Option { + self.bits + .0 + .iter() + .enumerate() + .rev() + .find_map(|(byte_index, byte)| { + if *byte == 0 { + None + } else { + let highest_set_bit = 7 - byte.leading_zeros() as u64; + let offset = byte_index as u64 * 8 + highest_set_bit; + Some( + self.start_nonce + .checked_add(offset) + .expect("highest seen nonce cannot overflow. This is a bug."), + ) + } + }) + } } #[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize, Default, Serialize)] @@ -127,6 +148,29 @@ impl BitMap { } use crate::Uniqueness; + +fn ensure_window_nonce_is_not_too_far_ahead( + credential_id: &CredentialId, + highest_seen_nonce: u64, + nonce: u64, +) -> anyhow::Result<()> { + // The `max()` term gives a margin for comfortable nonce increases at very low nonce numbers + // for new accounts, where the raw `mul(2)` limit would be very restrictive. + // The choice of PAST_TRANSACTIONS_WINDOW is somewhat arbitrary here, but has the nice property + // that it allows any nonce within the initial window to be submitted for a new account, which + // naturally conforms to the window semantics. + let max_allowed_nonce = highest_seen_nonce + .saturating_mul(2) + .max(PAST_TRANSACTIONS_WINDOW); + + anyhow::ensure!( + nonce <= max_allowed_nonce, + "Bad window nonce for credential id: {credential_id}, latest known value is: {highest_seen_nonce}, provided value {nonce} exceeds the maximum allowed value {max_allowed_nonce}", + ); + + Ok(()) +} + impl Uniqueness { pub(crate) fn check_window_uniqueness( &self, @@ -137,6 +181,10 @@ impl Uniqueness { let window = self.window.get(credential_id, state)?.unwrap_or_default(); let start = window.start_nonce; + if let Some(highest_seen_nonce) = window.highest_seen_nonce() { + ensure_window_nonce_is_not_too_far_ahead(credential_id, highest_seen_nonce, nonce)?; + } + anyhow::ensure!( nonce >= start, "Tx outdated for credential id: {credential_id}, expected at least: {start}, but found: {nonce}"); diff --git a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs index 867c0865f4..1b405f6c6d 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/call_tests.rs @@ -1,10 +1,11 @@ +use sov_modules_api::capabilities::UniquenessData; use sov_modules_api::macros::config_value; use sov_modules_api::{CredentialId, TxEffect}; use sov_test_utils::{BatchType, SlotInput, TransactionTestCase, TxProcessingError}; use sov_uniqueness::Uniqueness; use crate::runtime::S; -use crate::utils::{generate_value_setter_tx, setup}; +use crate::utils::{generate_value_setter_tx, generate_value_setter_uniqueness_tx, setup}; /// This test verifies that the `MAX_STORED_TX_HASHES_PER_CREDENTIAL` limit is respected and that authentication succeeds when that limit is not exceeded. #[test] @@ -32,6 +33,83 @@ fn test_max_stored_tx_hashes_per_credential() { do_max_stored_tx_hashes_per_credential_test() } +#[test] +fn generation_allows_pruning_floor_then_rejects_too_far_ahead() { + let (admin, mut runner, _) = setup(); + let past_transaction_generations: u64 = config_value!("PAST_TRANSACTION_GENERATIONS"); + let pruning_floor = past_transaction_generations.checked_add(1).unwrap(); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_tx(0, 0, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_tx(pruning_floor, 1, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_tx(u64::MAX, 2, &admin), + assert: Box::new(move |ctx, _state| { + let TxEffect::Skipped(skipped) = ctx.tx_receipt else { + panic!("Transaction should be skipped"); + }; + match skipped.error { + TxProcessingError::CheckUniquenessFailed(reason) => { + assert!(reason.contains("exceeds the maximum allowed value")); + } + _ => { + panic!( + "Transaction should be rejected because its generation is too far ahead" + ); + } + } + }), + }); +} + +#[test] +fn window_allows_window_floor_then_rejects_too_far_ahead() { + let (admin, mut runner, _) = setup(); + let window_floor: u64 = config_value!("PAST_TRANSACTIONS_WINDOW"); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_uniqueness_tx(UniquenessData::Window(0), 0, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_uniqueness_tx(UniquenessData::Window(window_floor), 1, &admin), + assert: Box::new(move |ctx, _state| { + assert!(ctx.tx_receipt.is_successful()); + }), + }); + + runner.execute_transaction(TransactionTestCase { + input: generate_value_setter_uniqueness_tx(UniquenessData::Window(u64::MAX), 2, &admin), + assert: Box::new(move |ctx, _state| { + let TxEffect::Skipped(skipped) = ctx.tx_receipt else { + panic!("Transaction should be skipped"); + }; + match skipped.error { + TxProcessingError::CheckUniquenessFailed(reason) => { + assert!(reason.contains("exceeds the maximum allowed value")); + } + _ => { + panic!("Transaction should be rejected because its nonce is too far ahead"); + } + } + }), + }); +} + /// This function generates a number of transactions that will fill up the "bucket" of stored transaction hashes /// for a given account. Then it tries to send one more transaction with a current generation number and verifies that /// the tx is rejected because the bucket is full. Finally, it updates the generation number and verifies that the diff --git a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs index 842968e966..2b827536c8 100644 --- a/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs +++ b/crates/module-system/module-implementations/sov-uniqueness/tests/integration/hooks_tests.rs @@ -286,6 +286,11 @@ fn send_tx_works_window_zero_start_nonce() { fn send_tx_works_window() { let (admin, mut runner, evm_account) = setup(); let admin_credential_id: CredentialId = admin.credential_id(); + let window = config_value!("PAST_TRANSACTIONS_WINDOW"); + let first_nonce = window; + let second_nonce = window - 2; + let third_nonce = first_nonce + window - 1; + let fourth_nonce = third_nonce + 1; runner.query_visible_state(|state| { assert_eq!( @@ -298,62 +303,56 @@ fn send_tx_works_window() { }); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx(UniquenessData::Window(42), &admin, &evm_account), + input: generate_default_tx(UniquenessData::Window(first_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); + let mut dst = vec![0; window as usize / 8]; + dst[window as usize / 8 - 1] = 1; assert_eq!( Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple(( - 0, - vec![0, 0, 0, 0, 0, 1 << 2] - ))), + Some(Window::test_only_from_tuple((8, dst))), "A bit in the window should be set", ); }), }); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx(UniquenessData::Window(40), &admin, &evm_account), + input: generate_default_tx(UniquenessData::Window(second_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); + let mut dst = vec![0; window as usize / 8]; + dst[window as usize / 8 - 2] = 1 << 6; + dst[window as usize / 8 - 1] = 1; assert_eq!( Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple(( - 0, - vec![0, 0, 0, 0, 0, (1 << 2) | (1 << 0)] - ))), + Some(Window::test_only_from_tuple((8, dst))), "Two bits in the window should be set." ); }), }); - let window = config_value!("PAST_TRANSACTIONS_WINDOW"); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx( - UniquenessData::Window(40 + window - 1), - &admin, - &evm_account, - ), + input: generate_default_tx(UniquenessData::Window(third_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); let mut dst = vec![0; window as usize / 8]; - dst[0] = (1 << 2) | (1 << 0); + dst[0] = 1; dst[window as usize / 8 - 1] = 1 << 7; assert_eq!( Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple((40, dst))), + Some(Window::test_only_from_tuple((first_nonce, dst))), "Dropping bits in the window" ); }), }); runner.execute_transaction(TransactionTestCase { - input: generate_default_tx(UniquenessData::Window(40 + window), &admin, &evm_account), + input: generate_default_tx(UniquenessData::Window(fourth_nonce), &admin, &evm_account), assert: Box::new(move |ctx, state| { assert!(ctx.tx_receipt.is_successful()); let mut dst = vec![0; window as usize / 8]; @@ -363,7 +362,7 @@ fn send_tx_works_window() { Uniqueness::::default() .window(&admin_credential_id, state) .unwrap_infallible(), - Some(Window::test_only_from_tuple((48, dst))), + Some(Window::test_only_from_tuple((first_nonce + 8, dst))), "Dropping bits in the window" ); }), diff --git a/examples/demo-rollup/tests/resync/data/mock_da.sqlite b/examples/demo-rollup/tests/resync/data/mock_da.sqlite index 4f16677614..06f2f1d7fc 100644 Binary files a/examples/demo-rollup/tests/resync/data/mock_da.sqlite and b/examples/demo-rollup/tests/resync/data/mock_da.sqlite differ diff --git a/examples/demo-rollup/tests/resync/mod.rs b/examples/demo-rollup/tests/resync/mod.rs index 631b6715c5..10f5b45931 100644 --- a/examples/demo-rollup/tests/resync/mod.rs +++ b/examples/demo-rollup/tests/resync/mod.rs @@ -260,7 +260,7 @@ async fn test_rollup_resync() -> anyhow::Result<()> { { let _span = tracing::info_span!("sync-1").entered(); - sync_rollup_with_path(rollup_storage_path.clone(), 10_000) + sync_rollup_with_path(rollup_storage_path.clone(), 200) .await .context("Sync 1")?; } @@ -306,7 +306,7 @@ async fn test_rollup_resync() -> anyhow::Result<()> { ); { - sync_rollup_with_path(rollup_storage_path, 20_000) + sync_rollup_with_path(rollup_storage_path, 300) .await .context("Sync 2")?; }