Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<S as GasSpec>::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
Expand Down
10 changes: 5 additions & 5 deletions crates/bench/sp1-microbenches/guest-storage/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -873,8 +872,15 @@ fn tx_set_relayer_config(relayer: &TestUser<TestSpec>) -> 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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<S: Spec> Uniqueness<S> {
pub(crate) fn check_generation_uniqueness(
&self,
Expand All @@ -18,14 +46,23 @@ impl<S: Spec> Uniqueness<S> {

// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,27 @@ impl Window {
// Otherwise, check the bitmap
self.bits.get_bit(offset)
}

fn highest_seen_nonce(&self) -> Option<u64> {
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)]
Expand Down Expand Up @@ -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<S: Spec> Uniqueness<S> {
pub(crate) fn check_window_uniqueness(
&self,
Expand All @@ -137,6 +181,10 @@ impl<S: Spec> Uniqueness<S> {
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}");
Expand Down
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading