Skip to content

Recover each EVM tx sender once on the ingesting validator - #4183

Merged
masih merged 6 commits into
giga-1from
masih/1789479448-one-ecrecover
Sep 16, 2026
Merged

masih merged 6 commits into
giga-1from
masih/1789479448-one-ecrecover

Conversation

@masih

@masih masih commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

On the validator that receives load-generator traffic every EVM transaction is sender-recovered three times: once in eth_sendRawTransaction to pick a shard proxy, once in the evmonlyapp CheckTx, and once more in Executor.PrepareBlock when 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: Backend and GigaRouter gain EvmProxyEnabled(), which is the validator's EnableEvmProxy flag (fullnodes always proxy), and SendRawTransaction goes straight to BroadcastTx when it is false. For execution, the evmonlyapp remembers the hash -> sender recovered by CheckTx in a bounded utils.Mutex map and, in FinalizeBlock, resolves them in one pass into BlockRequest.Senders, a []utils.Option[common.Address] aligned with Txs (the hash of a raw transaction is the keccak of its bytes for every type, so this needs no decoding); parsePreparedTx uses 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 in PrepareBlock, and consensus, admission and block validity are unchanged.

BenchmarkParsePreparedTx shows 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.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@github-actions

github-actions Bot commented Sep 15, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 16, 2026, 11:56 AM

@masih
masih marked this pull request as ready for review September 15, 2026 15:44
@cursor

cursor Bot commented Sep 15, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes how senders are resolved on the hot admission/RPC/execution path; correctness depends on the in-process CheckTx cache and chain-ID guards, though tests assert deterministic execution matches the prior recovery-only behavior.

Overview
Reduces duplicate EVM sender recovery (ecrecover) on validators that ingest traffic, where the same tx was recovered in eth_sendRawTransaction, CheckTx, and PrepareBlock.

RPC: SendRawTransaction only recovers the sender to pick a shard proxy when EvmProxyEnabled() is true (validators with EnableEvmProxy; fullnodes always proxy). Otherwise it broadcasts locally without recovery. Backend / GigaRouter expose EvmProxyEnabled().

Block execution: BlockRequest gains an optional Senders slice aligned with Txs. parsePreparedTx skips recovery when a known sender is supplied for a protected tx on the executor’s chain ID; otherwise it recovers as before. The evmonly app stores tx hash → sender from CheckTx in a bounded map, passes entries into FinalizeBlock via takeSenders, and drops them as txs execute.

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.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 checkedSenders rests on two behaviours that no test covers: forgetSenders removing each executed transaction's entry, and rememberSender clearing at checkedSendersCap. TestEVMOnlyApplicationExecutesCheckedTxLikeUncheckedTx only 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 check len(checkedSenders)).
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/parser.go Outdated
Comment thread sei-tendermint/internal/p2p/giga_router_fullnode.go
Comment thread giga/evmonly/parser.go Outdated
…e-ecrecover

# Conflicts:
#	giga/evmonly/rpc/send_test.go
#	giga/evmonly/rpc/server.go
#	sei-tendermint/internal/rpc/core/mempool.go
@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.71429% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.53%. Comparing base (7e3ca24) to head (0b30507).
⚠️ Report is 10 commits behind head on giga-1.

Files with missing lines Patch % Lines
sei-tendermint/internal/rpc/core/mempool.go 0.00% 3 Missing ⚠️
giga/evmonly/rpc/send.go 80.00% 2 Missing ⚠️
sei-tendermint/internal/evmonlyapp/app.go 93.75% 1 Missing ⚠️
...ei-tendermint/internal/p2p/giga_router_fullnode.go 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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              
Flag Coverage Δ
sei-chain-pr 78.01% <85.71%> (?)
sei-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
giga/evmonly/executor.go 91.35% <100.00%> (+0.14%) ⬆️
giga/evmonly/parser.go 98.03% <100.00%> (+2.38%) ⬆️
giga/evmonly/rpc/server.go 20.51% <ø> (-21.60%) ⬇️
giga/evmonly/types.go 90.00% <ø> (ø)
sei-tendermint/internal/p2p/giga_router.go 100.00% <ø> (ø)
...i-tendermint/internal/p2p/giga_router_validator.go 93.33% <100.00%> (+0.09%) ⬆️
sei-tendermint/internal/evmonlyapp/app.go 80.87% <93.75%> (-0.90%) ⬇️
...ei-tendermint/internal/p2p/giga_router_fullnode.go 36.36% <0.00%> (-0.68%) ⬇️
giga/evmonly/rpc/send.go 81.48% <80.00%> (ø)
sei-tendermint/internal/rpc/core/mempool.go 44.05% <0.00%> (-7.36%) ⬇️

... and 9 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@masih
masih enabled auto-merge September 16, 2026 11:43

@shemnon shemnon 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.

LGTM
Fairly common optimization when it comes to ecdsa, so lower risk than might be expected.

Comment thread giga/evmonly/types.go Outdated
Txs [][]byte
Context BlockContext
Txs [][]byte
KnownSender func(common.Hash) (common.Address, bool)

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.

every signature would be unique. Would an array matching tx order work better?
unless we are expecting duplicate transactions from tip cut merge.

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.

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.

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.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Apologies for the AI nosie; ty for pointing this out @shemnon

@masih
masih disabled auto-merge September 16, 2026 11:48
@masih
masih enabled auto-merge September 16, 2026 11:56
@masih
masih added this pull request to the merge queue Sep 16, 2026
Merged via the queue into giga-1 with commit 00610c4 Sep 16, 2026
81 checks passed
@masih
masih deleted the masih/1789479448-one-ecrecover branch September 16, 2026 12:16
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.

2 participants