diff --git a/artifacts/program_methods/clock.bin b/artifacts/program_methods/clock.bin new file mode 100644 index 00000000..c3f8af15 Binary files /dev/null and b/artifacts/program_methods/clock.bin differ diff --git a/common/src/transaction.rs b/common/src/transaction.rs index 9563251a..b61d317f 100644 --- a/common/src/transaction.rs +++ b/common/src/transaction.rs @@ -43,6 +43,23 @@ impl NSSATransaction { } } + /// Returns the canonical Block Context Program invocation transaction. + /// Every valid block must end with exactly one occurrence of this transaction. + #[must_use] + pub fn clock_invocation() -> Self { + let message = nssa::public_transaction::Message::try_new( + nssa::program::Program::clock().id(), + vec![nssa::CLOCK_PROGRAM_ACCOUNT_ID], + vec![], + (), + ) + .expect("Clock invocation message should always be constructable"); + Self::Public(nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) + } + // TODO: Introduce type-safe wrapper around checked transaction, e.g. AuthenticatedTransaction pub fn transaction_stateless_check(self) -> Result { // Stateless checks here diff --git a/indexer/core/src/block_store.rs b/indexer/core/src/block_store.rs index f3722b17..16591a0e 100644 --- a/indexer/core/src/block_store.rs +++ b/indexer/core/src/block_store.rs @@ -119,6 +119,30 @@ impl IndexerStore { pub async fn put_block(&self, mut block: Block, l1_header: HeaderId) -> Result<()> { { + let canonical_clock_tx = NSSATransaction::clock_invocation(); + + // 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 == &canonical_clock_tx, + "Last transaction in block must be the canonical clock invocation" + ); + + let clock_count = block + .body + .transactions + .iter() + .filter(|tx| *tx == &canonical_clock_tx) + .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 { @@ -208,11 +232,12 @@ mod tests { 10, &sign_key, ); + let clock_tx = NSSATransaction::clock_invocation(); let next_block = common::test_utils::produce_dummy_block( u64::try_from(i).unwrap(), Some(prev_hash), - vec![tx], + vec![tx, clock_tx], ); prev_hash = next_block.header.hash; diff --git a/nssa/src/lib.rs b/nssa/src/lib.rs index ce958354..b6df2fdd 100644 --- a/nssa/src/lib.rs +++ b/nssa/src/lib.rs @@ -16,7 +16,7 @@ pub use program_deployment_transaction::ProgramDeploymentTransaction; pub use program_methods::PRIVACY_PRESERVING_CIRCUIT_ID; pub use public_transaction::PublicTransaction; pub use signature::{PrivateKey, PublicKey, Signature}; -pub use state::V03State; +pub use state::{CLOCK_PROGRAM_ACCOUNT_ID, V03State}; pub mod encoding; pub mod error; diff --git a/nssa/src/privacy_preserving_transaction/transaction.rs b/nssa/src/privacy_preserving_transaction/transaction.rs index aafbe0cb..dc8670d2 100644 --- a/nssa/src/privacy_preserving_transaction/transaction.rs +++ b/nssa/src/privacy_preserving_transaction/transaction.rs @@ -154,6 +154,17 @@ impl PrivacyPreservingTransaction { .collect() } + /// Returns the post-state the transaction declares for `account_id`, or `None` if the account + /// is not part of this transaction's public execution. + #[must_use] + pub fn public_post_state_for(&self, account_id: &AccountId) -> Option<&Account> { + self.message + .public_account_ids + .iter() + .position(|id| id == account_id) + .map(|i| &self.message.public_post_states[i]) + } + #[must_use] pub fn affected_public_account_ids(&self) -> Vec { let mut acc_set = self diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 70906f68..3c70d1ad 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -16,6 +16,9 @@ use crate::{ pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; +pub const CLOCK_PROGRAM_ACCOUNT_ID: AccountId = + AccountId::new(*b"/LEZ/ClockProgramAccount/0000001"); + #[derive(Clone, BorshSerialize, BorshDeserialize)] #[cfg_attr(test, derive(Debug, PartialEq, Eq))] pub struct CommitmentSet { @@ -148,8 +151,8 @@ impl V03State { this.insert_program(Program::amm()); this.insert_program(Program::clock()); - this.force_insert_account( - AccountId::new(b"/LEZ/ClockProgramAccount/0000001".to_owned()), + this.public_state.insert( + CLOCK_PROGRAM_ACCOUNT_ID, Account { program_owner: Program::clock().id(), data: 0_u64 diff --git a/sequencer/core/src/lib.rs b/sequencer/core/src/lib.rs index 21e63740..0afad05a 100644 --- a/sequencer/core/src/lib.rs +++ b/sequencer/core/src/lib.rs @@ -202,9 +202,27 @@ impl SequencerCore p.message().program_id == clock_program_id, + NSSATransaction::PrivacyPreserving(pp) => pp + .public_post_state_for(&nssa::CLOCK_PROGRAM_ACCOUNT_ID) + .is_some_and(|post| post != &clock_account_pre), + NSSATransaction::ProgramDeployment(_) => false, + }; + if touches_system { + warn!("Dropping transaction from mempool: user transactions may not modify the system clock account"); + continue; + } + // Check if block size exceeds limit let temp_valid_transactions = [valid_transactions.as_slice(), std::slice::from_ref(&tx)].concat(); @@ -249,6 +267,16 @@ impl SequencerCore { + valid_transactions.push(clock_nssa_tx); + } + Err(err) => { + error!("Clock transaction failed execution check: {err:#?}"); + } + } + let hashable_data = HashableBlockData { block_id: new_block_height, transactions: valid_transactions, @@ -368,7 +396,8 @@ mod tests { use base58::ToBase58 as _; use bedrock_client::BackoffConfig; use common::{ - block::AccountInitialData, test_utils::sequencer_sign_key_for_testing, + block::AccountInitialData, + test_utils::sequencer_sign_key_for_testing, transaction::NSSATransaction, }; use logos_blockchain_core::mantle::ops::channel::ChannelId; @@ -694,8 +723,8 @@ mod tests { .unwrap() .unwrap(); - // Only one should be included in the block - assert_eq!(block.body.transactions, vec![tx.clone()]); + // Only one user tx should be included; the clock tx is always appended last. + assert_eq!(block.body.transactions, vec![tx.clone(), NSSATransaction::clock_invocation()]); } #[tokio::test] @@ -721,7 +750,7 @@ mod tests { .get_block_at_id(sequencer.chain_height) .unwrap() .unwrap(); - assert_eq!(block.body.transactions, vec![tx.clone()]); + assert_eq!(block.body.transactions, vec![tx.clone(), NSSATransaction::clock_invocation()]); // Add same transaction should fail mempool_handle.push(tx.clone()).await.unwrap(); @@ -733,7 +762,8 @@ mod tests { .get_block_at_id(sequencer.chain_height) .unwrap() .unwrap(); - assert!(block.body.transactions.is_empty()); + // The replay is rejected, so only the clock tx is in the block. + assert_eq!(block.body.transactions, vec![NSSATransaction::clock_invocation()]); } #[tokio::test] @@ -768,7 +798,7 @@ mod tests { .get_block_at_id(sequencer.chain_height) .unwrap() .unwrap(); - assert_eq!(block.body.transactions, vec![tx.clone()]); + assert_eq!(block.body.transactions, vec![tx.clone(), NSSATransaction::clock_invocation()]); } // Instantiating a new sequencer from the same config. This should load the existing block @@ -898,11 +928,50 @@ mod tests { ); assert_eq!( new_block.body.transactions, - vec![tx], - "New block should contain the submitted transaction" + vec![tx, NSSATransaction::clock_invocation()], + "New block should contain the submitted transaction and the clock invocation" ); } + #[tokio::test] + async fn transactions_touching_clock_account_are_dropped_from_block() { + let (mut sequencer, mempool_handle) = common_setup().await; + + // Canonical clock invocation and a crafted variant with different parameters targeting + // the clock program — both must be dropped since the program_id is the clock program. + let crafted_clock_tx = { + let acc1 = sequencer.sequencer_config.initial_accounts[0].account_id; + let message = nssa::public_transaction::Message::try_new( + nssa::program::Program::clock().id(), + vec![acc1], + vec![], + 42_u64, + ) + .unwrap(); + NSSATransaction::Public(nssa::PublicTransaction::new( + message, + nssa::public_transaction::WitnessSet::from_raw_parts(vec![]), + )) + }; + mempool_handle + .push(NSSATransaction::clock_invocation()) + .await + .unwrap(); + mempool_handle.push(crafted_clock_tx).await.unwrap(); + sequencer + .produce_new_block_with_mempool_transactions() + .unwrap(); + + let block = sequencer + .store + .get_block_at_id(sequencer.chain_height) + .unwrap() + .unwrap(); + + // Both transactions were dropped. Only the system-appended clock tx remains. + assert_eq!(block.body.transactions, vec![NSSATransaction::clock_invocation()]); + } + #[tokio::test] async fn start_from_config_uses_db_height_not_config_genesis() { let mut config = setup_sequencer_config();