bug(txpool): transaction validation does not retain a state provider across a batch
Description
Arc does not pin a transaction batch to one canonical snapshot. Each transaction that reaches stateful validation acquires a fresh provider.
After preliminary checks, each transaction uses one provider for its blocklist, denylist, and Ethereum account checks. Those checks share a snapshot within that transaction. The consistency problem arises between different transactions in the same batch, because each transaction acquires its own provider.
Reth's upstream EthTransactionValidator retains one provider for the whole batch after its first successful acquisition. Arc does not preserve that behavior. The canonical head can advance while a batch is being validated. If the state at the new head includes a blocklist update, transactions that acquired a provider before the update can be judged against the old blocklist, while later transactions acquire a provider for the new state and are judged against the updated blocklist.
Consider a batch of three transactions that each transfer a nonzero amount to the same recipient. Assume all checks other than the recipient blocklist check pass:
- Snapshot A blocklists that recipient. A batch pinned to A rejects all three with
BlocklistedError.
- Snapshot B does not blocklist it. A batch pinned to B accepts all three.
- The provider returns A for the first two
latest() calls and B afterwards.
With this provider sequence, Arc can return [Blocklisted, Blocklisted, Valid]. That result matches neither the all-rejected result at A nor the all-valid result at B.
Examined versions: Arc 2a3e8ab10c0ac97bf1a2628a325eb98d4a468b1a, with Reth v2.2.0 (88505c7fcbfdebfd3b56d88c86b62e950043c6c4).
Root cause
Arc uses the defaults provided by Reth's TransactionValidator trait. Those defaults route each item through Arc's validate_transaction, which calls validate_one. That method passes a fresh &mut None to validate_one_with_state, so no provider cache is retained across the batch.
Additionally, validate_one_with_state never reads the supplied state: it acquires a new provider even when the caller retains an existing one. It writes the provider back only after Ethereum stateful validation; blocklist/denylist rejections and storage-read errors return before that assignment. Consequently, moving the optional state outside the batch loop alone would not fix the issue. The helper must also reuse the cached provider and retain it across early stateful returns.
Expected behavior
All provider-backed checks within a batch should use the same state snapshot, even if the canonical head changes during validation. A head change should not cause the same recipient to be treated as blocklisted for some transactions in the batch and not blocklisted for others.
Test
The following fixed-state test checks the helper's provider-reuse contract. Paste it into the existing mod tests in crates/execution-txpool/src/validator.rs; it uses that module's imports and create_arc_validator_for_test helper.
It is a fix-acceptance test for retaining the acquired provider. It does not simulate a head transition or independently verify the batch entry points; those paths are identified in the source analysis above.
// Paste inside the existing `mod tests` in execution-txpool/src/validator.rs.
#[tokio::test]
async fn validate_one_with_state_reuses_cached_provider() {
let tx = MockTransaction::legacy()
.with_gas_limit(21_000)
.with_gas_price(1_000_000_000)
.with_value(U256::from(1));
let provider = MockEthProvider::default();
provider.add_account(tx.sender(), ExtendedAccount::new(0, U256::MAX));
let validator = create_arc_validator_for_test(provider);
let mut state = None;
let first = validator
.validate_one_with_state(TransactionOrigin::External, tx.clone(), &mut state)
.await;
assert!(first.is_valid(), "{first:?}");
let first_provider = std::ptr::from_ref(
state
.as_deref()
.expect("first validation must cache its provider"),
)
.cast::<()>();
let second = validator
.validate_one_with_state(TransactionOrigin::External, tx, &mut state)
.await;
assert!(second.is_valid(), "{second:?}");
let second_provider = std::ptr::from_ref(
state
.as_deref()
.expect("second validation must retain the provider"),
)
.cast::<()>();
assert_eq!(
first_provider, second_provider,
"cached provider must be reused"
);
}
Suggested fix
- Override
validate_transactions and validate_transactions_with_origin to retain one shared provider cache per batch and pass it to validate_one_with_state.
- In
validate_one_with_state, reuse the cached provider. Call latest() only when stateful checks need a provider and the cache is empty, and cache it before running those checks so that an individual rejection does not discard it.
bug(txpool): transaction validation does not retain a state provider across a batch
Description
Arc does not pin a transaction batch to one canonical snapshot. Each transaction that reaches stateful validation acquires a fresh provider.
After preliminary checks, each transaction uses one provider for its blocklist, denylist, and Ethereum account checks. Those checks share a snapshot within that transaction. The consistency problem arises between different transactions in the same batch, because each transaction acquires its own provider.
Reth's upstream
EthTransactionValidatorretains one provider for the whole batch after its first successful acquisition. Arc does not preserve that behavior. The canonical head can advance while a batch is being validated. If the state at the new head includes a blocklist update, transactions that acquired a provider before the update can be judged against the old blocklist, while later transactions acquire a provider for the new state and are judged against the updated blocklist.Consider a batch of three transactions that each transfer a nonzero amount to the same recipient. Assume all checks other than the recipient blocklist check pass:
BlocklistedError.latest()calls and B afterwards.With this provider sequence, Arc can return
[Blocklisted, Blocklisted, Valid]. That result matches neither the all-rejected result at A nor the all-valid result at B.Examined versions: Arc
2a3e8ab10c0ac97bf1a2628a325eb98d4a468b1a, with Reth v2.2.0 (88505c7fcbfdebfd3b56d88c86b62e950043c6c4).Root cause
Arc uses the defaults provided by Reth's
TransactionValidatortrait. Those defaults route each item through Arc'svalidate_transaction, which callsvalidate_one. That method passes a fresh&mut Nonetovalidate_one_with_state, so no provider cache is retained across the batch.Additionally,
validate_one_with_statenever reads the suppliedstate: it acquires a new provider even when the caller retains an existing one. It writes the provider back only after Ethereum stateful validation; blocklist/denylist rejections and storage-read errors return before that assignment. Consequently, moving the optional state outside the batch loop alone would not fix the issue. The helper must also reuse the cached provider and retain it across early stateful returns.Expected behavior
All provider-backed checks within a batch should use the same state snapshot, even if the canonical head changes during validation. A head change should not cause the same recipient to be treated as blocklisted for some transactions in the batch and not blocklisted for others.
Test
The following fixed-state test checks the helper's provider-reuse contract. Paste it into the existing
mod testsincrates/execution-txpool/src/validator.rs; it uses that module's imports andcreate_arc_validator_for_testhelper.It is a fix-acceptance test for retaining the acquired provider. It does not simulate a head transition or independently verify the batch entry points; those paths are identified in the source analysis above.
Suggested fix
validate_transactionsandvalidate_transactions_with_originto retain one shared provider cache per batch and pass it tovalidate_one_with_state.validate_one_with_state, reuse the cached provider. Calllatest()only when stateful checks need a provider and the cache is empty, and cache it before running those checks so that an individual rejection does not discard it.