add validated state diff

This commit is contained in:
Sergio Chouhy 2026-04-02 01:09:58 -03:00
parent fa2fd857a9
commit 40c7b308a9
8 changed files with 102 additions and 133 deletions

View File

@ -61,28 +61,6 @@ impl NSSATransaction {
))
}
/// Returns `true` if this transaction is a user invocation of the clock program.
///
/// For public transactions: checks whether the program ID matches the clock program.
/// For privacy-preserving transactions: checks whether any clock account has a modified
/// post-state (i.e. `post != pre`), using the provided pre-state snapshot.
/// Pass an empty slice when only the public case is relevant (e.g. in committed blocks where
/// PP clock-touching transactions are already filtered out by the sequencer).
#[must_use]
pub fn is_invocation_of_clock_program(
&self,
clock_pre_states: &[(nssa::AccountId, nssa::Account)],
) -> bool {
let clock_program_id = nssa::program::Program::clock().id();
match self {
Self::Public(tx) => tx.message().program_id == clock_program_id,
Self::PrivacyPreserving(pp) => clock_pre_states
.iter()
.any(|(id, pre)| pp.public_post_state_for(id).is_some_and(|post| post != pre)),
Self::ProgramDeployment(_) => false,
}
}
// TODO: Introduce type-safe wrapper around checked transaction, e.g. AuthenticatedTransaction
pub fn transaction_stateless_check(self) -> Result<Self, TransactionMalformationError> {
// Stateless checks here

View File

@ -120,29 +120,6 @@ impl IndexerStore {
pub async fn put_block(&self, mut block: Block, l1_header: HeaderId) -> Result<()> {
{
let expected_clock_tx = NSSATransaction::clock_invocation(block.header.timestamp);
// Validate block structure: the last transaction must be the sole clock invocation.
let last_tx =
block.body.transactions.last().ok_or_else(|| {
anyhow::anyhow!("Block must contain at least one transaction")
})?;
anyhow::ensure!(
last_tx == &expected_clock_tx,
"Last transaction in block must be the canonical clock invocation"
);
let clock_count = block
.body
.transactions
.iter()
.filter(|tx| tx.is_invocation_of_clock_program(&[]))
.count();
anyhow::ensure!(
clock_count == 1,
"Block must contain exactly one Block Context Program invocation"
);
let mut state_guard = self.current_state.write().await;
for transaction in &block.body.transactions {

View File

@ -55,7 +55,7 @@ pub type NullifierSecretKey = [u8; 32];
#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
#[cfg_attr(
any(feature = "host", test),
derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)
derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)
)]
pub struct Nullifier(pub(super) [u8; 32]);

View File

@ -20,6 +20,7 @@ pub use state::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS, V03State,
};
pub use state_diff::ValidatedStateDiff;
pub mod encoding;
pub mod error;
@ -30,6 +31,7 @@ pub mod program_deployment_transaction;
pub mod public_transaction;
mod signature;
mod state;
mod state_diff;
pub mod program_methods {
include!(concat!(env!("OUT_DIR"), "/program_methods/mod.rs"));

View File

@ -1,6 +1,5 @@
use std::{
collections::{HashMap, HashSet},
hash::Hash,
collections::HashSet, hash::Hash,
};
use borsh::{BorshDeserialize, BorshSerialize};
@ -13,6 +12,7 @@ use sha2::{Digest as _, digest::FixedOutput as _};
use super::{message::Message, witness_set::WitnessSet};
use crate::{
AccountId, V03State, error::NssaError, privacy_preserving_transaction::circuit::Proof,
state_diff::ValidatedStateDiff,
};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
@ -30,12 +30,12 @@ impl PrivacyPreservingTransaction {
}
}
pub(crate) fn validate_and_produce_public_state_diff(
pub fn validate_and_produce_public_state_diff(
&self,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<HashMap<AccountId, Account>, NssaError> {
) -> Result<ValidatedStateDiff, NssaError> {
let message = &self.message;
let witness_set = &self.witness_set;
@ -124,12 +124,24 @@ impl PrivacyPreservingTransaction {
// 6. Nullifier uniqueness
state.check_nullifiers_are_valid(&message.new_nullifiers)?;
Ok(message
let public_diff = message
.public_account_ids
.iter()
.copied()
.zip(message.public_post_states.clone())
.collect())
.collect();
let new_nullifiers = message
.new_nullifiers
.iter()
.cloned()
.map(|(nullifier, _)| nullifier)
.collect();
Ok(ValidatedStateDiff::new(
self.signer_account_ids(),
public_diff,
message.new_commitments.clone(),
new_nullifiers,
))
}
#[must_use]

View File

@ -14,6 +14,7 @@ use crate::{
error::NssaError,
public_transaction::{Message, WitnessSet},
state::MAX_NUMBER_CHAINED_CALLS,
state_diff::ValidatedStateDiff,
};
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
@ -68,12 +69,12 @@ impl PublicTransaction {
hasher.finalize_fixed().into()
}
pub(crate) fn validate_and_produce_public_state_diff(
pub fn validate_and_produce_public_state_diff(
&self,
state: &V03State,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<HashMap<AccountId, Account>, NssaError> {
) -> Result<ValidatedStateDiff, NssaError> {
let message = self.message();
let witness_set = self.witness_set();
@ -270,7 +271,7 @@ impl PublicTransaction {
);
}
Ok(state_diff)
Ok(ValidatedStateDiff::new(signer_account_ids, state_diff, vec![], vec![]))
}
}

View File

@ -19,6 +19,7 @@ use crate::{
privacy_preserving_transaction::PrivacyPreservingTransaction, program::Program,
program_deployment_transaction::ProgramDeploymentTransaction,
public_transaction::PublicTransaction,
state_diff::ValidatedStateDiff,
};
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
@ -79,7 +80,7 @@ impl NullifierSet {
Self(BTreeSet::new())
}
fn extend(&mut self, new_nullifiers: Vec<Nullifier>) {
fn extend(&mut self, new_nullifiers: &[Nullifier]) {
self.0.extend(new_nullifiers);
}
@ -184,29 +185,29 @@ impl V03State {
self.programs.insert(program.id(), program);
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, account) in diff.public_diff {
*self.get_account_by_id_mut(account_id) = account;
}
for account_id in diff.signer_account_ids {
self.get_account_by_id_mut(account_id).nonce.public_account_nonce_increment();
}
self.private_state.0.extend(&diff.new_commitments);
self.private_state.1.extend(&diff.new_nullifiers);
}
pub fn transition_from_public_transaction(
&mut self,
tx: &PublicTransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), NssaError> {
let state_diff = tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?;
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, post) in state_diff {
let current_account = self.get_account_by_id_mut(account_id);
*current_account = post;
}
for account_id in tx.signer_account_ids() {
let current_account = self.get_account_by_id_mut(account_id);
current_account.nonce.public_account_nonce_increment();
}
let diff = tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?;
self.apply_state_diff(diff);
Ok(())
}
@ -216,40 +217,8 @@ impl V03State {
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), NssaError> {
// 1. Verify the transaction satisfies acceptance criteria
let public_state_diff =
tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?;
let message = tx.message();
// 2. Add new commitments
self.private_state.0.extend(&message.new_commitments);
// 3. Add new nullifiers
let new_nullifiers = message
.new_nullifiers
.iter()
.cloned()
.map(|(nullifier, _)| nullifier)
.collect::<Vec<Nullifier>>();
self.private_state.1.extend(new_nullifiers);
// 4. Update public accounts
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, post) in public_state_diff {
let current_account = self.get_account_by_id_mut(account_id);
*current_account = post;
}
// 5. Increment nonces for public signers
for account_id in tx.signer_account_ids() {
let current_account = self.get_account_by_id_mut(account_id);
current_account.nonce.public_account_nonce_increment();
}
let diff = tx.validate_and_produce_public_state_diff(self, block_id, timestamp)?;
self.apply_state_diff(diff);
Ok(())
}

View File

@ -15,7 +15,7 @@ use logos_blockchain_key_management_system_service::keys::{ED25519_SECRET_KEY_SI
use mempool::{MemPool, MemPoolHandle};
#[cfg(feature = "mock")]
pub use mock::SequencerCoreWithMockClients;
use nssa::V03State;
use nssa::{V03State, ValidatedStateDiff};
use nssa_core::{BlockId, Timestamp};
pub use storage::error::DbError;
use testnet_initial_state::initial_state;
@ -204,6 +204,23 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
Ok(self.chain_height)
}
fn validate_transaction_and_produce_state_diff(
&self,
transaction: &NSSATransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Option<Result<ValidatedStateDiff, nssa::error::NssaError>> {
match transaction {
NSSATransaction::Public(public_transaction) => Some(
public_transaction.validate_and_produce_public_state_diff(&self.state, block_id, timestamp),
),
NSSATransaction::PrivacyPreserving(privacy_preserving_transaction) => Some(
privacy_preserving_transaction.validate_and_produce_public_state_diff(&self.state, block_id, timestamp),
),
NSSATransaction::ProgramDeployment(_) => None,
}
}
/// Produces new block from transactions in mempool and packs it into a `SignedMantleTx`.
pub fn produce_new_block_with_mempool_transactions(
&mut self,
@ -235,16 +252,27 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
while let Some(tx) = self.mempool.pop() {
let tx_hash = tx.hash();
// The Block Context Program is system-only. Reject:
// - any public tx that invokes the clock program, and
// - any PP tx that declares a modified post-state for a clock account.
let touches_system = tx.is_invocation_of_clock_program(&clock_accounts_pre);
if touches_system {
warn!(
"Dropping transaction from mempool: user transactions may not modify the system clock account"
);
continue;
}
let validated_diff = match self.validate_transaction_and_produce_state_diff(&tx, new_block_height, new_block_timestamp) {
Some(Ok(diff)) => {
let touches_system = clock_accounts_pre
.iter()
.any(|(id, pre)| diff.public_diff().get(id).is_some_and(|post| post != pre));
if touches_system {
warn!(
"Dropping transaction from mempool: user transactions may not modify the system clock account"
);
continue;
}
Some(diff)
}
Some(Err(err)) => {
error!(
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
);
continue;
}
None => None,
};
// Check if block size exceeds limit
let temp_valid_transactions =
@ -271,23 +299,25 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
break;
}
match self.execute_check_transaction_on_state(tx, new_block_height, new_block_timestamp)
{
Ok(valid_tx) => {
valid_transactions.push(valid_tx);
info!("Validated transaction with hash {tx_hash}, including it in block");
if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block {
break;
match validated_diff {
Some(diff) => self.state.apply_state_diff(diff),
None => {
if let NSSATransaction::ProgramDeployment(deploy_tx) = &tx {
if let Err(err) = self.state.transition_from_program_deployment_transaction(deploy_tx) {
error!(
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
);
// TODO: Probably need to handle unsuccessful transaction execution?
continue;
}
}
}
Err(err) => {
error!(
"Transaction with hash {tx_hash} failed execution check with error: {err:#?}, skipping it",
);
// TODO: Probably need to handle unsuccessful transaction execution?
}
}
valid_transactions.push(tx);
info!("Validated transaction with hash {tx_hash}, including it in block");
if valid_transactions.len() >= self.sequencer_config.max_num_tx_in_block {
break;
}
}