Skip to content

fix(flashblocks-rpc): fix eth_getTransactionCount to expose txpool nonce awareness for pending tag#256

Merged
louisliu2048 merged 2 commits intomainfrom
niven/fix-eth-tx-count
Apr 10, 2026
Merged

fix(flashblocks-rpc): fix eth_getTransactionCount to expose txpool nonce awareness for pending tag#256
louisliu2048 merged 2 commits intomainfrom
niven/fix-eth-tx-count

Conversation

@sieniven
Copy link
Copy Markdown
Contributor

@sieniven sieniven commented Apr 10, 2026

Summary

  • The flashblocks RPC overlay's eth_getTransactionCount("pending") returned only the flashblock execution state nonce without consulting the txpool. This caused replacement transaction underpriced errors when clients queried the pending nonce right after submitting transactions.
  • Fix mirrors reth's LoadState::transaction_count — for the pending tag, checks pool().get_highest_consecutive_transaction_by_sender() and returns max(state_nonce, pool_nonce + 1).
  • All flashblocks state API handlers (eth_call, eth_estimateGas, eth_getBalance, eth_getTransactionCount, eth_getCode, eth_getStorageAt) were executing state reads directly on the async runtime. When MemoryOverlayStateProvider falls through to MDBX for accounts not in the overlay, this is a blocking disk I/O read. Wrapped all handlers in spawn_blocking_io_fut with acquire_owned_blocking_io semaphore for eth_call, matching reth's standard patterns.

Test plan

  • just check passes (clippy + fmt + tests)
  • Devnet e2e: enable flashblocks RPC, run make erc20 benchmark — no more replacement transaction underpriced

…nCount

The flashblocks RPC overlay for eth_getTransactionCount("pending")
returned only the execution state nonce from the flashblock cache,
without consulting the transaction pool. This caused "replacement
transaction underpriced" errors when clients queried pending nonce
immediately after submitting transactions, as the returned nonce
was stale (only reflecting flashblock-executed txs).

Mirrors reth's LoadState::transaction_count behavior by checking
pool().get_highest_consecutive_transaction_by_sender() for the
pending tag and returning max(state_nonce, pool_nonce + 1).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sieniven
Copy link
Copy Markdown
Contributor Author

@claude review this PR

Copy link
Copy Markdown

@XLayer-Bot XLayer-Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix is correct and well-implemented.

Logic analysis

The handler for the pending tag now returns , which is the right formula. A few things I verified:

  • Using the flashblock execution state nonce (not the canonical state nonce) as the argument to is correct. Pool transactions with nonce < flashblock_state_nonce are already included in the flashblock execution, so the flashblock nonce is the right floor. The pool internally guards with on_chain_nonce.max(pool_state_nonce), so no double-counting occurs.
  • The nonce.max(next_pool_tx_nonce) guard is a faithful copy of reth's own defensive pattern (labelled "guard against drifts in the pool" upstream). It's redundant in the common path but harmless and intentional.
  • BlockId::pending() is BlockId::Number(BlockNumberOrTag::Pending). There is no BlockId::Tag variant in alloy, and "pending" always deserialises to BlockId::Number(BlockNumberOrTag::Pending), so the equality check is exhaustive.
  • Skipping txpool for None/Latest is correct — Latest is a snapshot query and txpool lookahead is only meaningful for Pending.
  • No stale-nonce risk: MemoryOverlayStateProvider always falls back to the canonical historical layer, so the flashblock state nonce is never behind canonical.

Cargo changesreth-transaction-pool dep addition is minimal and correct.

workflow run

The flashblocks RPC overlay was executing state reads (account_nonce,
account_balance, account_code, storage, EVM transact) directly on the
async runtime. When the MemoryOverlayStateProvider falls through to
the canonical MDBX state provider for accounts not in the overlay,
this becomes a blocking disk I/O read that stalls the tokio runtime.

Wrap all flashblocks state API handlers in spawn_blocking_io_fut to
match reth's standard EthState/EthCall patterns:
- eth_call: acquire_owned_blocking_io + spawn_blocking_io_fut
- eth_estimateGas: spawn_blocking_io_fut
- eth_getBalance: spawn_blocking_io_fut
- eth_getTransactionCount: spawn_blocking_io_fut
- eth_getCode: spawn_blocking_io_fut
- eth_getStorageAt: spawn_blocking_io_fut

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sieniven sieniven requested a review from XLayer-Bot April 10, 2026 10:56
@sieniven
Copy link
Copy Markdown
Contributor Author

@claude please review again

Copy link
Copy Markdown

@XLayer-Bot XLayer-Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two fixes are both correct and well-implemented.

txpool nonce awareness ( / pending)
The logic mirrors reth's exactly: reads from the flashblock overlay state, then calls , adds 1, and returns . The is a harmless safety net; since the pool query uses the state nonce as its base, will always be ≥ . Overflow is handled with . Correct.

spawn_blocking_io_fut wrapping
All six flashblock state handlers are now off the async runtime. The acquired by in is a RAII guard: Rust evaluates the full (including the ) before dropping locals, so the semaphore slot is held for the entire duration of the spawned work. The asymmetry — takes the extra permit while the lighter read-only handlers (, , , , ) do not — is intentional and consistent with reth's pattern. is a synchronous function, so calling it without inside the blocking closure is correct.

LGTM.

workflow run

Copy link
Copy Markdown

@XLayer-Bot XLayer-Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two fixes are both correct and well-implemented.

txpool nonce awareness (eth_getTransactionCount / pending)
The logic mirrors reth's LoadState::transaction_count exactly: reads on_chain_account_nonce from the flashblock overlay state, then calls get_highest_consecutive_transaction_by_sender(address, on_chain_account_nonce), adds 1, and returns max(state_nonce, pool_nonce + 1). The max() is a harmless safety net — since the pool query uses the state nonce as its base, pool_nonce + 1 will always be ≥ state_nonce. Overflow is handled with checked_add(1).ok_or_else(...). Correct.

spawn_blocking_io_fut wrapping
All six flashblock state handlers are now off the async runtime. The _permit acquired by acquire_owned_blocking_io in eth_call is a RAII guard: Rust evaluates the full return expression (including the .await) before dropping locals, so the semaphore slot is held for the entire duration of the spawned work. The asymmetry — eth_call takes the extra permit while the lighter read-only handlers (balance, code, storage, transaction_count, estimate_gas) do not — is intentional and consistent with reth's EthCall pattern. estimate_gas_with is a synchronous function, so calling it without .await inside the blocking closure is correct.

LGTM.

workflow run

@louisliu2048 louisliu2048 merged commit 32404c3 into main Apr 10, 2026
@XLayer-Bot
Copy link
Copy Markdown

🔧 CI running — workflow run

@XLayer-Bot
Copy link
Copy Markdown

XLayer Reth CI passed for commit 32404c3432cf87bba2ff4d461aa068a0d522782f

Step Result
format-check ✅ success
compile-check ✅ success
clippy ✅ success
tests ✅ success

View run

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants