perf(l1): cache ECDSA sender recovery in transaction structs#6153
Conversation
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.
🤖 Kimi Code ReviewReview SummaryThe PR adds sender address caching to all transaction types using Issues Found1. Critical: Missing Cache Invalidation (Security Risk)The Location: All transaction types - the cache should be invalidated when any signature field changes. 2. Inconsistent Field Naming
This is acceptable for consistency, but consider if this naming pattern should be unified across all transaction types. 3. Missing Cache Initialization in CloneWhen transactions are cloned, the 4. Potential Performance IssueThe 5. RLP Encoding ConsistencyThe Recommendations
The implementation looks solid for the caching mechanism itself, but the immutability concern should be addressed. Automated review by Kimi (Moonshot AI) · custom prompt |
🤖 Codex Code ReviewPossible stale sender if transactions are mutated after first 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 |
🤖 Claude Code ReviewExcellent. Now let me review the implementation in detail. Let me compile my review: PR Review: Cache ECDSA sender recovery in transaction structs (#6153)SummaryThis PR adds ✅ Strengths
🔍 Detailed AnalysisCode Correctness ✅
Rust Best Practices ✅
Memory Safety ✅
|
Lines of code reportTotal lines added: Detailed view |
Greptile OverviewGreptile SummaryThis PR adds ECDSA sender recovery caching to transaction structs using Changes:
Performance Impact: Confidence Score: 5/5
|
| 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)
There was a problem hiding this comment.
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, likeinner_hash). - Updated
Transaction::sender()to memoize viaget_or_try_initand moved the previous logic intocompute_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.
| pub s: U256, | ||
| #[rkyv(with=rkyv::with::Skip)] | ||
| pub inner_hash: OnceCell<H256>, | ||
| #[rkyv(with=rkyv::with::Skip)] | ||
| pub sender_cache: OnceCell<Address>, |
There was a problem hiding this comment.
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).
| }; | ||
| sender_cache | ||
| .get_or_try_init(|| self.compute_sender()) | ||
| .copied() | ||
| } |
There was a problem hiding this comment.
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.
Benchmark Block Execution Results Comparison Against Main
|
Motivation
eBPF profiling on a live Hoodi node shows secp256k1 at ~11% of total CPU, with
53% from P2P transaction validation.
Transaction::sender()recomputes ECDSApublic 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 thesame pattern as the existing
inner_hash: OnceCell<H256>cache forTransaction::hash(). The firstsender()call computes and caches; subsequentcalls return the cached value.
Changes:
sender_cache: OnceCell<Address>field to all 7 transaction structs(Legacy, EIP2930, EIP1559, EIP4844, EIP7702, PrivilegedL2, FeeToken)
sender()now delegates toget_or_try_init(compute_sender)— zero behavior change#[rkyv(with=rkyv::with::Skip)](same asinner_hash)..Default::default()automatically pick up the new fieldEXPB Benchmark Results
Fast scenario (vs main baselines #253/#258):
Gigablocks scenario (vs main #254):
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