Skip to content

bug(txpool): transaction validation does not retain a state provider across a batch #463

Description

@syang-ng

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

  1. Override validate_transactions and validate_transactions_with_origin to retain one shared provider cache per batch and pass it to validate_one_with_state.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    tracked internallyThis issue is already tracked internally by the Arc team.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions