add sequencer checks

This commit is contained in:
Sergio Chouhy 2026-03-25 20:01:53 -03:00
parent 90f20a7040
commit 3324bcf391
7 changed files with 137 additions and 12 deletions

Binary file not shown.

View File

@ -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 // TODO: Introduce type-safe wrapper around checked transaction, e.g. AuthenticatedTransaction
pub fn transaction_stateless_check(self) -> Result<Self, TransactionMalformationError> { pub fn transaction_stateless_check(self) -> Result<Self, TransactionMalformationError> {
// Stateless checks here // Stateless checks here

View File

@ -119,6 +119,30 @@ impl IndexerStore {
pub async fn put_block(&self, mut block: Block, l1_header: HeaderId) -> Result<()> { 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; let mut state_guard = self.current_state.write().await;
for transaction in &block.body.transactions { for transaction in &block.body.transactions {
@ -208,11 +232,12 @@ mod tests {
10, 10,
&sign_key, &sign_key,
); );
let clock_tx = NSSATransaction::clock_invocation();
let next_block = common::test_utils::produce_dummy_block( let next_block = common::test_utils::produce_dummy_block(
u64::try_from(i).unwrap(), u64::try_from(i).unwrap(),
Some(prev_hash), Some(prev_hash),
vec![tx], vec![tx, clock_tx],
); );
prev_hash = next_block.header.hash; prev_hash = next_block.header.hash;

View File

@ -16,7 +16,7 @@ pub use program_deployment_transaction::ProgramDeploymentTransaction;
pub use program_methods::PRIVACY_PRESERVING_CIRCUIT_ID; pub use program_methods::PRIVACY_PRESERVING_CIRCUIT_ID;
pub use public_transaction::PublicTransaction; pub use public_transaction::PublicTransaction;
pub use signature::{PrivateKey, PublicKey, Signature}; pub use signature::{PrivateKey, PublicKey, Signature};
pub use state::V03State; pub use state::{CLOCK_PROGRAM_ACCOUNT_ID, V03State};
pub mod encoding; pub mod encoding;
pub mod error; pub mod error;

View File

@ -154,6 +154,17 @@ impl PrivacyPreservingTransaction {
.collect() .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] #[must_use]
pub fn affected_public_account_ids(&self) -> Vec<AccountId> { pub fn affected_public_account_ids(&self) -> Vec<AccountId> {
let mut acc_set = self let mut acc_set = self

View File

@ -16,6 +16,9 @@ use crate::{
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10; 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)] #[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))] #[cfg_attr(test, derive(Debug, PartialEq, Eq))]
pub struct CommitmentSet { pub struct CommitmentSet {
@ -148,8 +151,8 @@ impl V03State {
this.insert_program(Program::amm()); this.insert_program(Program::amm());
this.insert_program(Program::clock()); this.insert_program(Program::clock());
this.force_insert_account( this.public_state.insert(
AccountId::new(b"/LEZ/ClockProgramAccount/0000001".to_owned()), CLOCK_PROGRAM_ACCOUNT_ID,
Account { Account {
program_owner: Program::clock().id(), program_owner: Program::clock().id(),
data: 0_u64 data: 0_u64

View File

@ -202,9 +202,27 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
let curr_time = u64::try_from(chrono::Utc::now().timestamp_millis()) let curr_time = u64::try_from(chrono::Utc::now().timestamp_millis())
.expect("Timestamp must be positive"); .expect("Timestamp must be positive");
let clock_program_id = nssa::program::Program::clock().id();
let clock_account_pre = self.state.get_account_by_id(nssa::CLOCK_PROGRAM_ACCOUNT_ID);
while let Some(tx) = self.mempool.pop() { while let Some(tx) = self.mempool.pop() {
let tx_hash = tx.hash(); let tx_hash = tx.hash();
// The Block Context Program is system-only. Reject:
// - any public tx that invokes the clock program ID, and
// - any PP tx that declares a modified post-state for the clock account.
let touches_system = match &tx {
NSSATransaction::Public(p) => 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 // Check if block size exceeds limit
let temp_valid_transactions = let temp_valid_transactions =
[valid_transactions.as_slice(), std::slice::from_ref(&tx)].concat(); [valid_transactions.as_slice(), std::slice::from_ref(&tx)].concat();
@ -249,6 +267,16 @@ impl<BC: BlockSettlementClientTrait, IC: IndexerClientTrait> SequencerCore<BC, I
} }
} }
// Append the Block Context Program invocation as the mandatory last transaction.
match self.execute_check_transaction_on_state(NSSATransaction::clock_invocation()) {
Ok(clock_nssa_tx) => {
valid_transactions.push(clock_nssa_tx);
}
Err(err) => {
error!("Clock transaction failed execution check: {err:#?}");
}
}
let hashable_data = HashableBlockData { let hashable_data = HashableBlockData {
block_id: new_block_height, block_id: new_block_height,
transactions: valid_transactions, transactions: valid_transactions,
@ -368,7 +396,8 @@ mod tests {
use base58::ToBase58 as _; use base58::ToBase58 as _;
use bedrock_client::BackoffConfig; use bedrock_client::BackoffConfig;
use common::{ use common::{
block::AccountInitialData, test_utils::sequencer_sign_key_for_testing, block::AccountInitialData,
test_utils::sequencer_sign_key_for_testing,
transaction::NSSATransaction, transaction::NSSATransaction,
}; };
use logos_blockchain_core::mantle::ops::channel::ChannelId; use logos_blockchain_core::mantle::ops::channel::ChannelId;
@ -694,8 +723,8 @@ mod tests {
.unwrap() .unwrap()
.unwrap(); .unwrap();
// Only one should be included in the block // Only one user tx should be included; the clock tx is always appended last.
assert_eq!(block.body.transactions, vec![tx.clone()]); assert_eq!(block.body.transactions, vec![tx.clone(), NSSATransaction::clock_invocation()]);
} }
#[tokio::test] #[tokio::test]
@ -721,7 +750,7 @@ mod tests {
.get_block_at_id(sequencer.chain_height) .get_block_at_id(sequencer.chain_height)
.unwrap() .unwrap()
.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 // Add same transaction should fail
mempool_handle.push(tx.clone()).await.unwrap(); mempool_handle.push(tx.clone()).await.unwrap();
@ -733,7 +762,8 @@ mod tests {
.get_block_at_id(sequencer.chain_height) .get_block_at_id(sequencer.chain_height)
.unwrap() .unwrap()
.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] #[tokio::test]
@ -768,7 +798,7 @@ mod tests {
.get_block_at_id(sequencer.chain_height) .get_block_at_id(sequencer.chain_height)
.unwrap() .unwrap()
.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 // Instantiating a new sequencer from the same config. This should load the existing block
@ -898,11 +928,50 @@ mod tests {
); );
assert_eq!( assert_eq!(
new_block.body.transactions, new_block.body.transactions,
vec![tx], vec![tx, NSSATransaction::clock_invocation()],
"New block should contain the submitted transaction" "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] #[tokio::test]
async fn start_from_config_uses_db_height_not_config_genesis() { async fn start_from_config_uses_db_height_not_config_genesis() {
let mut config = setup_sequencer_config(); let mut config = setup_sequencer_config();