Skip to content

Commit beb96e2

Browse files
Merge pull request #34 from SeismicSystems/h/851-missing-block-validation-check
fix: enforce tx expiration at block execution time
2 parents aef8ee6 + 2a7e3a6 commit beb96e2

2 files changed

Lines changed: 158 additions & 19 deletions

File tree

crates/evm/src/block/error.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use alloc::{
44
string::{String, ToString},
55
};
66
use alloy_primitives::B256;
7-
use seismic_alloy_consensus::InputDecryptionElementsError;
7+
use seismic_alloy_consensus::{InputDecryptionElementsError, SeismicValidationError};
88

99
/// Block validation error.
1010
#[derive(Debug, thiserror::Error)]
@@ -152,6 +152,9 @@ pub enum InternalBlockExecutionError {
152152
/// Unable to decrypt calldata of seismic tx
153153
#[error("Failed to decrypt seismic tx: {0}")]
154154
FailedToDecryptSeismicTx(InputDecryptionElementsError),
155+
/// Seismic transaction failed block-level validation (expiration, recent block hash)
156+
#[error("Seismic tx validation failed: {0}")]
157+
SeismicValidationFailed(SeismicValidationError),
155158
/// Arbitrary Block Executor Errors
156159
#[error(transparent)]
157160
Other(Box<dyn core::error::Error + Send + Sync + 'static>),

crates/seismic-evm/src/block/mod.rs

Lines changed: 154 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ use alloy_evm::{
2626
block::{CommitChanges, ExecutableTx, InternalBlockExecutionError},
2727
FromTxWithEncoded, RecoveredTx,
2828
};
29-
use revm::context::result::ExecutionResult;
30-
use seismic_alloy_consensus::InputDecryptionElements;
29+
use revm::context::{result::ExecutionResult, BlockEnv};
30+
use seismic_alloy_consensus::{InputDecryptionElements, SeismicValidationError};
3131

3232
type SeismicBlockExecutionCtx<'a> = EthBlockExecutionCtx<'a>;
3333

@@ -65,6 +65,27 @@ where
6565
}
6666
}
6767

68+
/// Rejects Seismic transactions whose `expires_at_block` is before the current block.
69+
/// Non-Seismic transaction types are passed through without validation.
70+
fn validate_expiration(
71+
tx: &impl InputDecryptionElements,
72+
block: &BlockEnv,
73+
) -> Result<(), BlockExecutionError> {
74+
if let Ok(elements) = tx.get_decryption_elements() {
75+
let current_block: u64 = block.number.saturating_to();
76+
if current_block > elements.expires_at_block {
77+
return Err(InternalBlockExecutionError::SeismicValidationFailed(
78+
SeismicValidationError::TransactionExpired {
79+
current_block,
80+
expires_at_block: elements.expires_at_block,
81+
},
82+
)
83+
.into());
84+
}
85+
}
86+
Ok(())
87+
}
88+
6889
impl<'db, DB, E, Spec, R> BlockExecutor for SeismicBlockExecutor<'_, E, Spec, R>
6990
where
7091
DB: Database + 'db,
@@ -91,16 +112,14 @@ where
91112
tx: impl ExecutableTx<Self>,
92113
f: impl FnOnce(&ExecutionResult<<Self::Evm as Evm>::HaltReason>) -> CommitChanges,
93114
) -> Result<Option<u64>, BlockExecutionError> {
94-
// Convert from ExecutableTx<Self> to R::Transaction,
95-
// which has the InputDecryptionElements bound
96115
let receipt_tx: &<R as ReceiptBuilder>::Transaction = RecoveredTx::tx(&tx);
116+
validate_expiration(receipt_tx, self.evm().block())?;
97117

98118
let signer = RecoveredTx::signer(&tx);
99119
let plaintext_base = receipt_tx
100120
.plaintext_copy(&self.purpose_keys.tx_io_sk, *signer)
101121
.map_err(|e| InternalBlockExecutionError::FailedToDecryptSeismicTx(e))?;
102122

103-
// call inner
104123
let recovered = Recovered::new_unchecked(plaintext_base, *signer);
105124
self.inner.execute_transaction_with_commit_condition(&recovered, f)
106125
}
@@ -110,16 +129,14 @@ where
110129
tx: impl ExecutableTx<Self>,
111130
f: impl FnOnce(&ExecutionResult<<Self::Evm as Evm>::HaltReason>),
112131
) -> Result<u64, BlockExecutionError> {
113-
// Convert from ExecutableTx<Self> to R::Transaction,
114-
// which has the InputDecryptionElements bound
115132
let receipt_tx: &<R as ReceiptBuilder>::Transaction = RecoveredTx::tx(&tx);
133+
validate_expiration(receipt_tx, self.evm().block())?;
116134

117135
let signer = RecoveredTx::signer(&tx);
118136
let plaintext_base = receipt_tx
119137
.plaintext_copy(&self.purpose_keys.tx_io_sk, *signer)
120138
.map_err(|e| InternalBlockExecutionError::FailedToDecryptSeismicTx(e))?;
121139

122-
// call inner
123140
let recovered = Recovered::new_unchecked(plaintext_base, *signer);
124141
self.inner.execute_transaction_with_result_closure(&recovered, f)
125142
}
@@ -339,16 +356,11 @@ mod tests {
339356
return tx_envelope;
340357
}
341358

342-
fn sample_seismic_tx<'a>(setup: &SetupTest<'a>, plaintext: &str) -> TxSeismic {
343-
let seismic_elements = TxSeismicElements {
344-
encryption_pubkey: setup.encryption_pubkey,
345-
encryption_nonce: U96::from_be_slice(&setup.encryption_nonce.0),
346-
message_version: 0,
347-
recent_block_hash: alloy_primitives::B256::from_slice(&[1u8; 32]),
348-
expires_at_block: 1000000,
349-
signed_read: false,
350-
};
351-
359+
fn sample_seismic_tx_with_elements<'a>(
360+
setup: &SetupTest<'a>,
361+
plaintext: &str,
362+
seismic_elements: TxSeismicElements,
363+
) -> TxSeismic {
352364
let pt_bytes = Bytes::from(plaintext.as_bytes().to_vec());
353365

354366
let tx_metadata = TxSeismicMetadata {
@@ -378,6 +390,18 @@ mod tests {
378390
}
379391
}
380392

393+
fn sample_seismic_tx<'a>(setup: &SetupTest<'a>, plaintext: &str) -> TxSeismic {
394+
let seismic_elements = TxSeismicElements {
395+
encryption_pubkey: setup.encryption_pubkey,
396+
encryption_nonce: U96::from_be_slice(&setup.encryption_nonce.0),
397+
message_version: 0,
398+
recent_block_hash: alloy_primitives::B256::from_slice(&[1u8; 32]),
399+
expires_at_block: 1000000,
400+
signed_read: false,
401+
};
402+
sample_seismic_tx_with_elements(setup, plaintext, seismic_elements)
403+
}
404+
381405
#[test]
382406
fn test_transaction_decryption_in_executor() {
383407
let db = InMemoryDB::default();
@@ -425,4 +449,116 @@ mod tests {
425449
let result = executor.execute_transaction(recovered);
426450
assert!(result.is_err(), "expected transaction to fail, but got: {:?}", result);
427451
}
452+
453+
#[test]
454+
fn test_expired_tx_rejected() {
455+
let db = InMemoryDB::default();
456+
let mut state = StateBuilder::new_with_database(db).build();
457+
458+
let setup = setup_test(&mut state);
459+
460+
// Set block number to 100 so the tx with expires_at_block=50 is expired
461+
let mut block_env = BlockEnv::default();
462+
block_env.number = U256::from(100);
463+
464+
let evm = setup.evm_factory.create_evm(
465+
&mut state,
466+
EvmEnv::new(CfgEnv::new_with_spec(SeismicSpecId::MERCURY), block_env),
467+
);
468+
let mut executor = setup.executor_factory.create_executor(evm, setup.ctx.clone());
469+
470+
let plaintext = "hello world";
471+
let seismic_elements = TxSeismicElements {
472+
encryption_pubkey: setup.encryption_pubkey,
473+
encryption_nonce: U96::from_be_slice(&setup.encryption_nonce.0),
474+
message_version: 0,
475+
recent_block_hash: alloy_primitives::B256::from_slice(&[1u8; 32]),
476+
expires_at_block: 50,
477+
signed_read: false,
478+
};
479+
let tx_seismic = sample_seismic_tx_with_elements(&setup, plaintext, seismic_elements);
480+
let tx_envelope = get_tx_envelope(&setup, tx_seismic);
481+
let recovered = Recovered::new_unchecked(&tx_envelope, setup.signer);
482+
483+
let result = executor.execute_transaction(recovered);
484+
assert!(
485+
result.is_err(),
486+
"expired transaction should be rejected, but it was accepted"
487+
);
488+
}
489+
490+
#[test]
491+
fn test_tx_at_exact_expiry_block_accepted() {
492+
let db = InMemoryDB::default();
493+
let mut state = StateBuilder::new_with_database(db).build();
494+
495+
let setup = setup_test(&mut state);
496+
497+
// Set block number exactly equal to expires_at_block (should still be valid)
498+
let mut block_env = BlockEnv::default();
499+
block_env.number = U256::from(100);
500+
501+
let evm = setup.evm_factory.create_evm(
502+
&mut state,
503+
EvmEnv::new(CfgEnv::new_with_spec(SeismicSpecId::MERCURY), block_env),
504+
);
505+
let mut executor = setup.executor_factory.create_executor(evm, setup.ctx.clone());
506+
507+
let plaintext = "hello world";
508+
let seismic_elements = TxSeismicElements {
509+
encryption_pubkey: setup.encryption_pubkey,
510+
encryption_nonce: U96::from_be_slice(&setup.encryption_nonce.0),
511+
message_version: 0,
512+
recent_block_hash: alloy_primitives::B256::from_slice(&[1u8; 32]),
513+
expires_at_block: 100,
514+
signed_read: false,
515+
};
516+
let tx_seismic = sample_seismic_tx_with_elements(&setup, plaintext, seismic_elements);
517+
let tx_envelope = get_tx_envelope(&setup, tx_seismic);
518+
let recovered = Recovered::new_unchecked(&tx_envelope, setup.signer);
519+
520+
let result = executor.execute_transaction(recovered);
521+
assert!(
522+
result.is_ok(),
523+
"transaction at exact expiry block should be accepted, got: {:?}",
524+
result
525+
);
526+
}
527+
528+
#[test]
529+
fn test_tx_one_block_past_expiry_rejected() {
530+
let db = InMemoryDB::default();
531+
let mut state = StateBuilder::new_with_database(db).build();
532+
533+
let setup = setup_test(&mut state);
534+
535+
// Set block number to one past expires_at_block
536+
let mut block_env = BlockEnv::default();
537+
block_env.number = U256::from(101);
538+
539+
let evm = setup.evm_factory.create_evm(
540+
&mut state,
541+
EvmEnv::new(CfgEnv::new_with_spec(SeismicSpecId::MERCURY), block_env),
542+
);
543+
let mut executor = setup.executor_factory.create_executor(evm, setup.ctx.clone());
544+
545+
let plaintext = "hello world";
546+
let seismic_elements = TxSeismicElements {
547+
encryption_pubkey: setup.encryption_pubkey,
548+
encryption_nonce: U96::from_be_slice(&setup.encryption_nonce.0),
549+
message_version: 0,
550+
recent_block_hash: alloy_primitives::B256::from_slice(&[1u8; 32]),
551+
expires_at_block: 100,
552+
signed_read: false,
553+
};
554+
let tx_seismic = sample_seismic_tx_with_elements(&setup, plaintext, seismic_elements);
555+
let tx_envelope = get_tx_envelope(&setup, tx_seismic);
556+
let recovered = Recovered::new_unchecked(&tx_envelope, setup.signer);
557+
558+
let result = executor.execute_transaction(recovered);
559+
assert!(
560+
result.is_err(),
561+
"transaction one block past expiry should be rejected, but it was accepted"
562+
);
563+
}
428564
}

0 commit comments

Comments
 (0)