Recover each EVM tx sender once on the ingesting validator - #4183
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
PR SummaryMedium Risk Overview RPC: Block execution: New unit tests cover known vs recovered senders, length validation, and parity between checked and unchecked finalize paths; a benchmark documents the parse-path CPU savings. Reviewed by Cursor Bugbot for commit 0b30507. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
The known-sender cache is sound in principle — the remembered sender is bound to the decoded transaction's hash, so it equals what recovery would produce for exactly those bytes, and consensus/admission behaviour is unchanged. No blockers; the notes below are about an unrecorded cross-package invariant, a misattached godoc, and untested lifecycle of the sender map.
Findings: 0 blocking | 4 non-blocking | 3 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- [suggestion] The bounded-memory claim for
checkedSendersrests on two behaviours that no test covers:forgetSendersremoving each executed transaction's entry, andrememberSenderclearing atcheckedSendersCap.TestEVMOnlyApplicationExecutesCheckedTxLikeUncheckedTxonly proves the result is identical, which also holds if entries are never dropped. Both are cheap to assert from inside the package (CheckTx,FinalizeBlock, then checklen(checkedSenders)). - 3 suggestion(s)/nit(s) flagged inline on specific lines.
…e-ecrecover # Conflicts: # giga/evmonly/rpc/send_test.go # giga/evmonly/rpc/server.go # sei-tendermint/internal/rpc/core/mempool.go
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## giga-1 #4183 +/- ##
==========================================
- Coverage 65.55% 65.53% -0.02%
==========================================
Files 2081 2081
Lines 157460 157264 -196
==========================================
- Hits 103222 103065 -157
+ Misses 54097 54058 -39
Partials 141 141
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
shemnon
left a comment
There was a problem hiding this comment.
LGTM
Fairly common optimization when it comes to ecdsa, so lower risk than might be expected.
| Txs [][]byte | ||
| Context BlockContext | ||
| Txs [][]byte | ||
| KnownSender func(common.Hash) (common.Address, bool) |
There was a problem hiding this comment.
every signature would be unique. Would an array matching tx order work better?
unless we are expecting duplicate transactions from tip cut merge.
There was a problem hiding this comment.
A positional array would need the block assembler to know the sender for every slot, but the cache is filled at CheckTx time, before block order exists, and a finalized block mixes txs this node admitted with txs that arrived through other validators' lanes, which it has never seen. Keying by hash lets parsePreparedTx answer "did I already verify these exact bytes" per tx regardless of position, and the miss path is just the normal recovery. If the producer ever passes senders alongside Txs in block order, KnownSender can trivially be backed by that array instead; I kept the func so the caller owns the representation.
There was a problem hiding this comment.
Agreed, done in 0b30507. BlockRequest.Senders is now a []utils.Option[common.Address] aligned with Txs (None = recover as before); the app builds it in one lock pass in FinalizeBlock and drops the entries at the same time, so the callback, the per-tx lock from every parse worker, and the second forgetSenders pass are gone. Within a block hashes are unique anyway (a duplicate would fail the nonce check), and the hash of a raw tx is keccak(raw) for every type, so no decode is needed to build the slice.
There was a problem hiding this comment.
Apologies for the AI nosie; ty for pointing this out @shemnon
On the validator that receives load-generator traffic every EVM transaction is sender-recovered three times: once in
eth_sendRawTransactionto pick a shard proxy, once in the evmonlyappCheckTx, and once more inExecutor.PrepareBlockwhen the block executes. ecrecover is the dominant per-transaction CPU cost on this path, and that node was pinned at ~28 cores while the others sat at 4-7.This change makes the RPC layer recover the sender only when the backend can actually proxy:
BackendandGigaRoutergainEvmProxyEnabled(), which is the validator'sEnableEvmProxyflag (fullnodes always proxy), andSendRawTransactiongoes straight toBroadcastTxwhen it is false. For execution, the evmonlyapp remembers thehash -> senderrecovered byCheckTxin a boundedutils.Mutexmap and, inFinalizeBlock, resolves them in one pass intoBlockRequest.Senders, a[]utils.Option[common.Address]aligned withTxs(the hash of a raw transaction is the keccak of its bytes for every type, so this needs no decoding);parsePreparedTxuses the given sender only when the decoded transaction is protected and carries the block signer's chain ID, and otherwise recovers as before. Entries are removed from the map as they are handed to the executor, and the map is cleared if it grows past 2^18 admitted-but-never-executed entries. The trust argument is "same process, tx already verified by CheckTx": the cache is never fed from anything received over the network, so transactions that arrive through other lanes are still recovered inPrepareBlock, and consensus, admission and block validity are unchanged.BenchmarkParsePreparedTxshows the saving per transaction: decode plus recovery is ~32 µs/op and 29 allocs, decode with a known sender is ~1.9 µs/op and 16 allocs, so for locally admitted transactions execution-side parsing drops by roughly 30 µs of CPU each, on top of the RPC-side recovery that is skipped entirely when proxying is off.