Skip to content

perf(l1): cache ECDSA sender recovery in transaction structs#6153

Merged
ilitteri merged 3 commits into
mainfrom
perf/cache-tx-sender
Feb 9, 2026
Merged

perf(l1): cache ECDSA sender recovery in transaction structs#6153
ilitteri merged 3 commits into
mainfrom
perf/cache-tx-sender

Conversation

@ilitteri

@ilitteri ilitteri commented Feb 6, 2026

Copy link
Copy Markdown
Collaborator

Motivation

eBPF profiling on a live Hoodi node shows secp256k1 at ~11% of total CPU, with
53% from P2P transaction validation. Transaction::sender() recomputes ECDSA
public key recovery on every call — when the same transaction has sender()
called multiple times (mempool checks, block building), each triggers a fresh
recovery.

Description

Add sender_cache: OnceCell<Address> to all transaction types, following the
same pattern as the existing inner_hash: OnceCell<H256> cache for
Transaction::hash(). The first sender() call computes and caches; subsequent
calls return the cached value.

Changes:

  • Added sender_cache: OnceCell<Address> field to all 7 transaction structs
    (Legacy, EIP2930, EIP1559, EIP4844, EIP7702, PrivilegedL2, FeeToken)
  • sender() now delegates to get_or_try_init(compute_sender) — zero behavior change
  • Field uses #[rkyv(with=rkyv::with::Skip)] (same as inner_hash)
  • All construction sites updated (RLPDecode impls + l1_watcher explicit construction)
  • Sites using ..Default::default() automatically pick up the new field

EXPB Benchmark Results

Fast scenario (vs main baselines #253/#258):

Metric Main cache-tx-sender Change
latency_avg 26.23 ms 25.69 ms -2.1%
mgas_avg 775.78 795.35 +2.5%

Gigablocks scenario (vs main #254):

Metric Main cache-tx-sender Change
latency_avg 754.51 ms 723.45 ms -4.1%
mgas_avg 1547.14 1616.66 +4.5%

Fast shows ~2% improvement (noise range). Gigablocks shows a meaningful 4.5%
mgas improvement
— more transactions per block means more cache hits.

How to Test

cargo test -p ethrex-common -p ethrex-blockchain -p ethrex-p2p

Transaction::sender() performs secp256k1 public key recovery on every call.
When the same transaction is received from multiple peers, each triggers a
fresh ECDSA recovery. This adds a sender_cache field (OnceCell<Address>) to
all transaction types, following the same pattern as the existing inner_hash
cache for Transaction::hash(). The first call computes and caches; subsequent
calls return the cached value.

eBPF profiling shows secp256k1 at ~11% of total CPU on a live Hoodi node,
with 53% from P2P transaction validation.
Copilot AI review requested due to automatic review settings February 6, 2026 20:37
@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

Review Summary

The PR adds sender address caching to all transaction types using OnceCell<Address>. This is a good optimization to avoid repeated ECDSA recoveries, but there are several issues to address.

Issues Found

1. Critical: Missing Cache Invalidation (Security Risk)

The sender_cache is never invalidated when transaction fields change. If any field that affects the sender computation is modified after decoding, the cached sender will be incorrect.

Location: All transaction types - the cache should be invalidated when any signature field changes.

2. Inconsistent Field Naming

  • LegacyTransaction uses r/s for signature fields
  • All other types use signature_r/signature_s
  • But the cache field is consistently named sender_cache across all types

This is acceptable for consistency, but consider if this naming pattern should be unified across all transaction types.

3. Missing Cache Initialization in Clone

When transactions are cloned, the OnceCell will be re-initialized. This is correct behavior since OnceCell doesn't implement Clone, but verify this is intentional.

4. Potential Performance Issue

The compute_sender() method is now private, which is good, but consider if there are any edge cases where the cache might need to be recomputed (e.g., after transaction validation failures).

5. RLP Encoding Consistency

The sender_cache is correctly marked with #[rkyv(with=rkyv::with::Skip)] for serialization, but verify that this field is also excluded from RLP encoding. The current RLP encode implementations don't include this field, which is correct.

Recommendations

  1. Add cache invalidation methods or ensure transaction fields are immutable after construction
  2. Consider adding a test to verify the cache works correctly across transaction types
  3. Document the caching behavior in the sender() method's documentation

The implementation looks solid for the caching mechanism itself, but the immutability concern should be addressed.


Automated review by Kimi (Moonshot AI) · custom prompt

@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

Possible stale sender if transactions are mutated after first sender() call. All transaction structs expose public fields, and sender() now caches the derived address in sender_cache without any invalidation. If any code mutates signature fields (or from for PrivilegedL2Transaction) after the first call, the cached sender will be wrong and could affect validation/execution. This is especially sensitive for security-critical logic that relies on sender identity. Consider making transaction fields immutable (or at least limiting mutation), or adding a clear/invalidate path for sender_cache on mutation, or moving sender caching behind private fields/builders. crates/common/types/transaction.rs:1071 and crates/common/types/transaction.rs:1086; struct field additions at crates/common/types/transaction.rs:196, :219, :242, :270, :295, :314, :1573.

No other correctness/security/perf issues found in this diff.

If you want, I can also scan for any call sites that mutate transaction fields after creation to see if this could be exercised.


Automated review by OpenAI Codex · custom prompt

@ilitteri ilitteri changed the title Cache ECDSA sender recovery in transaction structs perf(l1): cache ECDSA sender recovery in transaction structs Feb 6, 2026
@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Excellent. Now let me review the implementation in detail. Let me compile my review:

PR Review: Cache ECDSA sender recovery in transaction structs (#6153)

Summary

This PR adds sender_cache: OnceCell<Address> to all transaction types to avoid redundant ECDSA public key recovery computations when sender() is called multiple times on the same transaction. The implementation mirrors the existing inner_hash caching pattern.


Strengths

  1. Well-motivated optimization: eBPF profiling data showing 11% CPU in secp256k1 operations justifies this change. Benchmark results show 4.5% throughput improvement in gigablocks scenario.

  2. Consistent pattern: Follows the existing inner_hash: OnceCell<H256> caching pattern, maintaining consistency across the codebase.

  3. Proper serialization handling: Uses #[rkyv(with=rkyv::with::Skip)] correctly to exclude the cache from serialization (consistent with inner_hash).

  4. Comprehensive coverage: All 7 transaction types are updated (Legacy, EIP2930, EIP1559, EIP4844, EIP7702, PrivilegedL2, FeeToken).

  5. Clean refactoring: The compute_sender() method separation makes the logic clear and testable.


🔍 Detailed Analysis

Code Correctness ✅

  • Thread safety: OnceCell from once_cell::sync is correctly used for interior mutability with &self receiver
  • Error handling: get_or_try_init properly propagates EcdsaError without changing API semantics
  • Behavior preservation: No functional changes - caching is transparent to callers
  • RLPDecode consistency: All decode implementations properly initialize sender_cache: OnceCell::new()

Rust Best Practices ✅

  • Zero-cost abstraction: OnceCell::get_or_try_init + .copied() compiles to efficient code
  • No breaking changes: Public API surface is unchanged
  • Derived Clone works correctly: OnceCell implements Clone by creating a new empty cell (intentional and correct for cache fields)

Memory Safety ✅

  • OnceCell provides safe interior mutability
  • No unsafe code introduced
  • .copied() on &Address is safe and efficient (20 bytes)

⚠️ Potential Issues

1. Clone semantics - cache is NOT preserved (Minor - Intentional Design)

Location: All transaction structs

When a transaction is cloned, the sender_cache is not preserved (OnceCell clones as empty). This means:

let tx1 = Transaction::LegacyTransaction(/* ... */);
let _ = tx1.sender(); // computes and caches sender
let tx2 = tx1.clone(); // sender_cache is now empty
let _ = tx2.sender(); // recomputes sender again

Analysis: This is consistent with the existing inner_hash field behavior and is actually intentional - caches should not serialize/deserialize. However, if transactions are frequently cloned in hot paths, this might reduce the effectiveness of the optimization.

Recommendation:

  • Document this behavior in code comments if cloning is common
  • Monitor if clone patterns in the codebase negate benefits (though benchmarks suggest overall win)
  • Consider if Arc<OnceCell<Address>> might be beneficial if clone-heavy paths exist (but adds complexity)

2. PrivilegedL2Transaction caches what's already known (Minor Inefficiency)

Location: crates/common/types/transaction.rs:1199

Transaction::PrivilegedL2Transaction(tx) => Ok(tx.from),

PrivilegedL2Transaction stores from: Address directly and compute_sender() simply returns it. The cache adds 20 bytes overhead with no benefit.

Recommendation:

  • Consider special-casing PrivilegedL2Transaction to avoid the cache field
  • OR document this as accepted overhead for code uniformity
  • Not critical - 20 bytes is negligible compared to other fields

📊 Performance Review

Memory Impact ✅

  • Added overhead per transaction: 20 bytes per transaction (OnceCell)
  • Typical savings: Eliminates ~50-100 repeated ECDSA recoveries per transaction across validation/mempool/block building
  • Net impact: Strongly positive - CPU reduction far outweighs memory cost

Benchmark Validation ✅

  • Fast scenario: 2% improvement (within noise but positive trend)
  • Gigablocks: 4.5% mgas improvement (statistically significant)
  • Results align with expected benefits (more cache hits in high-transaction scenarios)

🔒 Security Considerations

No Security Issues Found ✅

  1. ECDSA recovery unchanged: compute_sender() preserves exact original logic
  2. No signature validation bypass: Cache only stores result, doesn't affect validation
  3. Thread-safe: OnceCell::sync provides correct synchronization
  4. No cache poisoning: get_or_try_init ensures cache is only set on successful recovery
  5. Serialization safety: Cache excluded from RLP encoding/archival serialization

📝 Minor Suggestions

  1. Add doc comment on sender() method:
/// Returns the transaction sender's address, recovering it from the signature if needed.
/// The result is cached after first computation for performance.
pub fn sender(&self) -> Result<Address, EcdsaError> {
  1. Consider adding a test to verify cache behavior:
#[test]
fn test_sender_cache() {
    let tx = /* construct transaction */;
    let sender1 = tx.sender().unwrap();
    let sender2 = tx.sender().unwrap(); // should return cached value
    assert_eq!(sender1, sender2);
}
  1. Document clone behavior if it matters:
/// Note: sender_cache is not preserved across clones (consistent with inner_hash)
#[rkyv(with=rkyv::with::Skip)]
pub sender_cache: OnceCell<Address>,

Verdict

APPROVED - This is a solid optimization with clear performance benefits and no correctness issues.

Summary:

  • ✅ Correct implementation following established patterns
  • ✅ Meaningful performance improvement (4.5% in realistic scenarios)
  • ✅ No security vulnerabilities
  • ✅ Maintains API compatibility
  • ⚠️ Minor: Clone doesn't preserve cache (intentional, acceptable)
  • ℹ️ Minor: PrivilegedL2Transaction cache is redundant but harmless

The code is production-ready. The minor observations above are for consideration but don't block merging.


Automated review by Claude (Anthropic) · custom prompt

@github-actions github-actions Bot added L1 Ethereum client performance Block execution throughput and performance in general labels Feb 6, 2026
@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 43
Total lines removed: 0
Total lines changed: 43

Detailed view
+-------------------------------------------+-------+------+
| File                                      | Lines | Diff |
+-------------------------------------------+-------+------+
| ethrex/crates/common/types/transaction.rs | 3325  | +42  |
+-------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/l1_watcher.rs  | 534   | +1   |
+-------------------------------------------+-------+------+

@greptile-apps

greptile-apps Bot commented Feb 6, 2026

Copy link
Copy Markdown

Greptile Overview

Greptile Summary

This PR adds ECDSA sender recovery caching to transaction structs using OnceCell<Address>, following the same pattern as the existing inner_hash cache. The implementation adds a sender_cache field to all 7 transaction types and refactors the sender() method to use get_or_try_init(), computing the sender only on the first call and returning the cached value on subsequent calls.

Changes:

  • Added sender_cache: OnceCell<Address> field to all transaction structs (Legacy, EIP2930, EIP1559, EIP4844, EIP7702, PrivilegedL2, FeeToken)
  • Refactored sender() to delegate to get_or_try_init(compute_sender) with caching logic
  • Extracted sender computation logic into private compute_sender() method
  • Updated all RLPDecode implementations to initialize sender_cache as OnceCell::new()
  • Updated explicit PrivilegedL2Transaction construction in l1_watcher.rs
  • Field uses #[rkyv(with=rkyv::with::Skip)] for serialization (same as inner_hash)

Performance Impact:
Benchmark results show meaningful improvements, especially in scenarios with high transaction throughput (Gigablocks: +4.5% mgas, -4.1% latency). The optimization targets the 11% CPU usage from secp256k1 operations observed in production profiling.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The implementation follows established patterns (mirrors inner_hash caching), makes no behavioral changes, uses thread-safe OnceCell::sync, and all construction sites are properly updated. The refactoring is mechanical and well-tested by existing test suites.
  • No files require special attention

Important Files Changed

Filename Overview
crates/common/types/transaction.rs Added sender_cache: OnceCell<Address> to all 7 transaction types, refactored sender() to use caching pattern matching existing inner_hash implementation
crates/l2/sequencer/l1_watcher.rs Added sender_cache: Default::default() to PrivilegedL2Transaction construction to accommodate new required field

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Transaction
    participant OnceCell
    participant compute_sender

    Note over Caller,compute_sender: First sender() call
    Caller->>Transaction: sender()
    Transaction->>Transaction: match to get sender_cache ref
    Transaction->>OnceCell: get_or_try_init(compute_sender)
    OnceCell->>OnceCell: Check if value exists
    Note over OnceCell: Cache empty
    OnceCell->>compute_sender: Call closure
    compute_sender->>compute_sender: ECDSA recovery (expensive)
    compute_sender-->>OnceCell: Return Address
    OnceCell->>OnceCell: Store Address
    OnceCell-->>Transaction: Return &Address
    Transaction->>Transaction: .copied()
    Transaction-->>Caller: Return Address

    Note over Caller,compute_sender: Subsequent sender() calls
    Caller->>Transaction: sender()
    Transaction->>Transaction: match to get sender_cache ref
    Transaction->>OnceCell: get_or_try_init(compute_sender)
    OnceCell->>OnceCell: Check if value exists
    Note over OnceCell: Cache hit!
    OnceCell-->>Transaction: Return cached &Address
    Transaction->>Transaction: .copied()
    Transaction-->>Caller: Return Address (no ECDSA)
Loading

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR reduces repeated secp256k1 ECDSA recovery work by caching the recovered sender address inside each transaction struct, similar to the existing cached transaction hash, to speed up hot paths like P2P validation and block building.

Changes:

  • Added sender_cache: OnceCell<Address> to all transaction variants (skipped for rkyv archival, like inner_hash).
  • Updated Transaction::sender() to memoize via get_or_try_init and moved the previous logic into compute_sender().
  • Updated explicit transaction construction in the L1 watcher to initialize the new cache field.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
crates/common/types/transaction.rs Adds per-transaction sender memoization and wires it into Transaction::sender(); updates decoders and transaction structs to carry the cache.
crates/l2/sequencer/l1_watcher.rs Updates explicit PrivilegedL2Transaction construction to include the new sender_cache field.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 196 to +200
pub s: U256,
#[rkyv(with=rkyv::with::Skip)]
pub inner_hash: OnceCell<H256>,
#[rkyv(with=rkyv::with::Skip)]
pub sender_cache: OnceCell<Address>,

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

sender_cache is part of the derived PartialEq/Eq for each transaction struct. Because Transaction::sender() now populates this OnceCell, calling sender() becomes an observable side effect: two otherwise-identical transactions (or MempoolTransactions that wrap them) can become != depending on whether sender() has been called. If equality is intended to be based only on the signed tx fields, consider implementing custom PartialEq/Eq that ignores cache fields (both inner_hash and sender_cache).

Copilot uses AI. Check for mistakes.
Comment on lines +1080 to +1084
};
sender_cache
.get_or_try_init(|| self.compute_sender())
.copied()
}

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

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

The new sender-caching logic in Transaction::sender() doesn’t appear to be covered by tests. Since this is performance-motivated and adds new stateful behavior (initially empty cache, populated on first call), it would be good to add a unit test that calls sender() twice and asserts (1) the returned address is stable and (2) the corresponding sender_cache is initialized after the first call.

Copilot uses AI. Check for mistakes.
@ilitteri ilitteri requested a review from a team as a code owner February 6, 2026 21:07
@github-actions

github-actions Bot commented Feb 6, 2026

Copy link
Copy Markdown

Benchmark Block Execution Results Comparison Against Main

Command Mean [s] Min [s] Max [s] Relative
base 64.909 ± 0.209 64.648 65.250 1.03 ± 0.01
head 62.868 ± 0.265 62.540 63.290 1.00

@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Feb 9, 2026
@ilitteri ilitteri added this pull request to the merge queue Feb 9, 2026
Merged via the queue into main with commit 05d9e09 Feb 9, 2026
75 of 77 checks passed
@ilitteri ilitteri deleted the perf/cache-tx-sender branch February 9, 2026 20:56
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Feb 9, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in ethrex_performance Feb 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-code-assisted L1 Ethereum client performance Block execution throughput and performance in general

Projects

Status: Done
Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants