Skip to content

Commit d6cde08

Browse files
kariyclaude
andcommitted
test(pool): add end-to-end pool-to-executor nonce drift test
Adds a test that reproduces the production error through the full pool → executor pipeline, mirroring the actual node setup: 1. Tx submitted to pool via add_transaction (through TxValidator) 2. Tx picked up from pool via pending_transactions (like block producer) 3. Tx executed by blockifier executor 4. Executor rejects with InvalidNonce error The test simulates pool_nonces drift by directly setting the drifted value, then verifies the tx passes pool validation, enters the pool, gets picked up, and is rejected by the executor with the exact error format seen in production: "Invalid transaction nonce of contract at address 0x... Account nonce: 0x0; got: 0x3." Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 85abda8 commit d6cde08

File tree

1 file changed

+154
-8
lines changed

1 file changed

+154
-8
lines changed

crates/pool/pool/src/validation/stateful.rs

Lines changed: 154 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,9 @@ mod tests {
371371
use katana_primitives::chain::ChainId;
372372
use katana_primitives::contract::{ContractAddress, Nonce};
373373
use katana_primitives::env::BlockEnv;
374-
use katana_primitives::transaction::{ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV1};
374+
use katana_primitives::transaction::{
375+
ExecutableTx, ExecutableTxWithHash, InvokeTx, InvokeTxV1,
376+
};
375377
use katana_primitives::Felt;
376378
use katana_provider::api::block::BlockWriter;
377379
use katana_provider::api::state::{StateFactoryProvider, StateProvider};
@@ -389,9 +391,7 @@ mod tests {
389391
status: FinalityStatus::AcceptedOnL2,
390392
block: Block::default().seal_with_hash(Felt::ZERO),
391393
};
392-
provider_mut
393-
.insert_block_with_states_and_receipts(block, states, vec![], vec![])
394-
.unwrap();
394+
provider_mut.insert_block_with_states_and_receipts(block, states, vec![], vec![]).unwrap();
395395
provider_mut.commit().unwrap();
396396
provider_factory.provider().latest().unwrap()
397397
}
@@ -442,8 +442,7 @@ mod tests {
442442
let sender = *chain_spec.genesis().accounts().next().unwrap().0;
443443

444444
let state = create_test_state(&chain_spec);
445-
let execution_flags =
446-
ExecutionFlags::new().with_account_validation(false).with_fee(false);
445+
let execution_flags = ExecutionFlags::new().with_account_validation(false).with_fee(false);
447446
let block_env = BlockEnv::default();
448447
let permit = Arc::new(Mutex::new(()));
449448

@@ -487,8 +486,155 @@ mod tests {
487486

488487
assert!(
489488
matches!(result, Ok(ValidationOutcome::Dependent { .. })),
490-
"After update(), tx with nonce 3 should be Dependent (state nonce is 0), \
491-
but stale pool_nonces caused it to be accepted as Valid. Got: {result:?}"
489+
"After update(), tx with nonce 3 should be Dependent (state nonce is 0), but stale \
490+
pool_nonces caused it to be accepted as Valid. Got: {result:?}"
492491
);
493492
}
493+
494+
/// End-to-end test reproducing the production error through the full
495+
/// pool → executor pipeline, mirroring the actual node setup.
496+
///
497+
/// When pool_nonces drifts ahead of the actual state nonce (the bug this
498+
/// fix addresses), a transaction whose nonce matches the drifted value
499+
/// passes the pool validator's checks and enters the pool. The block
500+
/// producer then picks it up from the pool and feeds it to the executor.
501+
/// The executor uses strict nonce checking and rejects it with:
502+
///
503+
/// "Invalid transaction nonce of contract at address 0x...
504+
/// Account nonce: 0x0; got: 0x3."
505+
///
506+
/// This is the same error observed in production:
507+
/// "Invalid transaction nonce of contract at address 0x4250...bccf.
508+
/// Account nonce: 0x27ed; got: 0x2bbf."
509+
///
510+
/// The test exercises the full flow:
511+
/// 1. Tx submitted to pool via `add_transaction` (goes through TxValidator)
512+
/// 2. Tx picked up from pool via `pending_transactions` (like block producer)
513+
/// 3. Tx executed by blockifier executor
514+
/// 4. Executor rejects with InvalidNonce
515+
#[tokio::test]
516+
async fn pool_to_executor_nonce_drift_produces_invalid_nonce_error() {
517+
use futures::StreamExt;
518+
use katana_executor::blockifier::BlockifierFactory;
519+
use katana_executor::{ExecutionResult, ExecutorFactory};
520+
use katana_pool_api::TransactionPool;
521+
use katana_primitives::env::VersionedConstantsOverrides;
522+
523+
use crate::ordering::FiFo;
524+
use crate::pool::Pool;
525+
526+
let _ = ClassCacheBuilder::new().build_global();
527+
528+
let chain_spec = Arc::new(ChainSpec::dev());
529+
let chain_id = chain_spec.id();
530+
let sender = *chain_spec.genesis().accounts().next().unwrap().0;
531+
532+
// -- Set up pool with TxValidator (same as real node) --
533+
let state = create_test_state(&chain_spec);
534+
let execution_flags = ExecutionFlags::new().with_account_validation(false).with_fee(false);
535+
let block_env = BlockEnv::default();
536+
let permit = Arc::new(Mutex::new(()));
537+
538+
let validator = TxValidator::new(
539+
state,
540+
execution_flags.clone(),
541+
None,
542+
block_env.clone(),
543+
permit,
544+
chain_spec.clone(),
545+
);
546+
547+
let pool = Pool::new(validator.clone(), FiFo::new());
548+
549+
// -- Set up executor factory (same config as block producer) --
550+
let executor_factory = BlockifierFactory::new(
551+
Some(VersionedConstantsOverrides {
552+
validate_max_n_steps: Some(u32::MAX),
553+
invoke_tx_max_n_steps: Some(u32::MAX),
554+
max_recursion_depth: Some(usize::MAX),
555+
}),
556+
// Executor uses default flags: strict nonce check enabled
557+
execution_flags,
558+
katana_executor::BlockLimits::default(),
559+
katana_executor::blockifier::cache::ClassCache::global(),
560+
chain_spec.clone(),
561+
);
562+
563+
// -- Simulate pool_nonces drift (pre-fix bug) --
564+
//
565+
// In production, pool_nonces drifts because update() didn't clear it.
566+
// After many blocks, pool_nonces[sender] could be far ahead of state.
567+
// We simulate this by directly setting pool_nonces to a drifted value.
568+
let drifted_nonce = Felt::THREE;
569+
{
570+
let mut inner = validator.inner.lock();
571+
inner.pool_nonces.insert(sender, drifted_nonce);
572+
}
573+
574+
// -- Step 1: Submit tx to pool via add_transaction --
575+
//
576+
// The validator sees pool_nonces[sender] = 3, tx_nonce = 3.
577+
// Since tx_nonce == current_nonce, it falls through to blockifier.
578+
// Blockifier (non-strict in validator): state_nonce(0) <= tx_nonce(3) → passes.
579+
// Tx enters the pool as Valid.
580+
let tx = create_invoke_tx(sender, chain_id, drifted_nonce);
581+
let tx_hash = tx.hash;
582+
pool.add_transaction(tx).await.expect("tx should pass validation and enter pool");
583+
584+
assert!(pool.contains(tx_hash), "tx should be in the pool");
585+
586+
// -- Step 2: Pull tx from pool (like block producer does) --
587+
let mut pending = pool.pending_transactions();
588+
let pending_tx = pending.next().await.expect("should have a pending tx");
589+
let picked_tx = pending_tx.tx.as_ref().clone();
590+
assert_eq!(picked_tx.hash, tx_hash);
591+
592+
// -- Step 3: Execute with executor (like block producer does) --
593+
//
594+
// Executor is created with fresh state (nonce = 0) and strict nonce check.
595+
// This is what happens in the real node: the executor's state reflects
596+
// committed blocks, not the pool's speculative nonce tracking.
597+
let executor_state = create_test_state(&chain_spec);
598+
let mut executor = executor_factory.executor(executor_state, block_env);
599+
600+
let (executed, _) = executor.execute_transactions(vec![picked_tx]).unwrap();
601+
assert_eq!(executed, 1, "executor should process the tx (even if it fails)");
602+
603+
// -- Step 4: Verify the exact production error --
604+
let (_, result) = &executor.transactions()[0];
605+
match result {
606+
ExecutionResult::Failed { error } => {
607+
let error_msg = error.to_string();
608+
// This is the exact error format from production logs:
609+
// "Invalid transaction nonce of contract at address {addr}.
610+
// Account nonce: {current_nonce:#x}; got: {tx_nonce:#x}."
611+
assert!(
612+
error_msg.contains("Invalid transaction nonce"),
613+
"Expected InvalidNonce error, got: {error_msg}"
614+
);
615+
assert!(
616+
error_msg.contains(&format!("{sender}")),
617+
"Error should reference the sender address, got: {error_msg}"
618+
);
619+
assert!(
620+
error_msg.contains("Account nonce: 0x0"),
621+
"Error should show state nonce 0x0, got: {error_msg}"
622+
);
623+
assert!(
624+
error_msg.contains("got: 0x3"),
625+
"Error should show tx nonce 0x3, got: {error_msg}"
626+
);
627+
}
628+
ExecutionResult::Success { .. } => {
629+
panic!(
630+
"Executor should reject tx with nonce 3 when state nonce is 0, but it \
631+
succeeded"
632+
);
633+
}
634+
}
635+
636+
// -- Cleanup: remove from pool (like block producer does post-execution) --
637+
pool.remove_transactions(&[tx_hash]);
638+
assert!(!pool.contains(tx_hash));
639+
}
494640
}

0 commit comments

Comments
 (0)