Skip to content

feat(evmonly): add eth_call RPC to the EVM-only executor - #4194

Merged
shemnon merged 7 commits into
giga-1from
shemnon/giga-evmonly-call
Sep 16, 2026
Merged

shemnon merged 7 commits into
giga-1from
shemnon/giga-evmonly-call

Conversation

@shemnon

@shemnon shemnon commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Adds a read-only eth_call JSON-RPC method to the Autobahn EVM-only executor's RPC server, on top of the eth_getBalance/eth_getTransactionCount/eth_blockNumber/eth_chainId work already on giga-1 (#4192).

  • new Executor.Call (giga/evmonly/call.go) runs a synthetic message against a state snapshot, reusing the same vm.NewEVM/buildBlockContext/customPrecompileMap machinery executeBlockSequential already builds for real transactions
  • new eth_call RPC method (giga/evmonly/rpc/call.go), decoding args via go-ethereum's export.TransactionArgs (no Cosmos/keeper dependency) and reusing the existing requireCurrentState gate
  • Proxy.EvmCall type-asserts against a package-local evmCaller capability interface rather than adding EvmCall to the shared abci.Application interface, since only the EVM-only app can meaningfully implement it and the real cosmos App already has its own eth_call via evmrpc
  • extends persisted app state with lastBlockTime, threaded through the same choke point as appHash/parentHash, so the block context used for a call (time, prev-randao) reflects the real last-committed block instead of a placeholder; documents the two remaining gaps (coinbase is provably always the zero address, blockhash(current-1) is unavailable)
  • gas cap defaults to 10,000,000, matching evmrpc's existing simulation_gas_limit precedent
  • eth_estimateGas is explicitly out of scope for this PR

This is exploratory/sizing work (opened as a draft) — not intended to merge as-is.

Testing performed to validate your change

  • go build ./...
  • go test -count=1 ./giga/evmonly/... ./sei-tendermint/internal/evmonlyapp/... ./sei-tendermint/internal/proxy/... ./sei-tendermint/internal/rpc/core/...
  • State-isolation coverage: TestExecutorCallDoesNotMutateCommittedState (in-memory store) and TestEVMOnlyApplicationEvmCallDoesNotMutateCommittedState (real disk-backed FlatKV store through a full FinalizeBlock+Commit cycle) both assert committed state is unchanged after a call that would otherwise SSTORE
  • Revert-reason ABI round-trip, gas-cap override/preserve, historical-block-tag rejection, nonexistent-contract call, capability-check both directions on Proxy
  • golangci-lint run v2.13.2 scoped to all touched packages: 0 issues
  • gofmt -s / goimports clean

🤖 Generated with Claude Code

Adds a read-only eth_call to the Autobahn giga/evmonly RPC surface, reusing
the executor's existing EVM-running machinery through a new Executor.Call
that opens a store snapshot, builds a vm.EVM, and discards the overlay after
execution without ever committing it.

- giga/evmonly: Executor.Call runs a core.Message against current state via
  a fresh store snapshot; nothing it does is ever persisted.
- evmonlyapp: evmOnlyApplication.EvmCall builds the call's block context from
  currently tracked state, extended with a persisted lastBlockTime (mirroring
  appHash/parentHash) so Time/PrevRandao can be reproduced for a call.
- proxy: Proxy.EvmCall reaches the application through a narrow evmCaller
  capability check instead of abci.Application, since only the EVM-only
  application can run a call.
- rpc: new callAPI registers eth_call, reusing go-ethereum's
  export.TransactionArgs for arg decoding/gas-cap defaulting and a local
  revertError matching evmrpc's JSON-RPC revert shape.
- docs: update the autobahn README's RPC surface and known-gaps lists.
@github-actions

github-actions Bot commented Sep 16, 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, 12:35 PM

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.41176% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.55%. Comparing base (7e3ca24) to head (cda82e7).
⚠️ Report is 14 commits behind head on giga-1.

Files with missing lines Patch % Lines
giga/evmonly/call.go 85.71% 4 Missing ⚠️
giga/evmonly/rpc/call.go 90.90% 2 Missing ⚠️
giga/evmonly/rpc/server.go 50.00% 1 Missing ⚠️
sei-tendermint/internal/evmonlyapp/app.go 96.29% 1 Missing ⚠️
sei-tendermint/internal/rpc/core/mempool.go 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff            @@
##           giga-1    #4194    +/-   ##
========================================
  Coverage   65.55%   65.55%            
========================================
  Files        2081     2083     +2     
  Lines      157460   157280   -180     
========================================
- Hits       103222   103106   -116     
+ Misses      54097    54033    -64     
  Partials      141      141            
Flag Coverage Δ
sei-chain-pr 75.68% <89.41%> (?)
sei-db ?

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

Files with missing lines Coverage Δ
sei-tendermint/internal/proxy/proxy.go 88.46% <100.00%> (-2.65%) ⬇️
giga/evmonly/rpc/server.go 21.95% <50.00%> (-20.16%) ⬇️
sei-tendermint/internal/evmonlyapp/app.go 82.31% <96.29%> (+0.53%) ⬆️
sei-tendermint/internal/rpc/core/mempool.go 49.18% <0.00%> (-2.23%) ⬇️
giga/evmonly/rpc/call.go 90.90% <90.90%> (ø)
giga/evmonly/call.go 85.71% <85.71%> (ø)

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

…vm's minimum

- Tighten the eth_call path's godocs (giga/evmonly, evmonlyapp, proxy) to state
  what a function does rather than why/how; surviving rationale moves inline
  at the code that needs it.
- eth_call's TransactionArgs base fee now defaults to x/evm's
  DefaultMinFeePerGas instead of a hardcoded zero, matching the floor a fresh
  chain executes at.
@shemnon
shemnon marked this pull request as ready for review September 16, 2026 09:32
@cursor

cursor Bot commented Sep 16, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New RPC executes arbitrary EVM bytecode against committed state with timeout/gas caps, but mis-timed calls during pending commit are rejected and snapshot isolation is critical to avoid accidental state writes.

Overview
Adds eth_call to the Autobahn EVM-only JSON-RPC surface so clients can run read-only contract queries against current committed state (e.g. cast call / balanceOf).

Execution path: new Executor.Call runs ApplyMessage on a state snapshot (no persistence), with NoBaseFee, a 60s wall-clock EVM cancel (beyond gas limits), and the same EVM/precompile setup as block execution. The EVM-only app implements EvmCall with block context from the committed cursor (including lastBlockTime / PrevRandao after Commit), and rejects calls while a finalized block is still uncommitted. Proxy.EvmCall delegates via a local evmCaller interface (not ABCI); core RPC Environment.EvmCall forwards to the app.

RPC layer: decodes export.TransactionArgs, applies a 10M gas cap (default/fill/cap), allows only latest/safe/finalized/pending tags, and returns JSON-RPC code 3 revert errors with ABI-decoded reasons when possible. Autobahn README documents eth_call and notes coinbase zero and no historical blockhash semantics.

Reviewed by Cursor Bugbot for commit cda82e7. 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.

Adds a read-only eth_call to the EVM-only executor RPC, reusing the existing block-execution machinery and the requireCurrentState gate, with good test coverage (including two state-isolation tests). No blockers; two consistency issues worth addressing are the base fee used to build the message and the possible skew between the state a call reads and the block context it runs under.

Findings: 0 blocking | 3 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] eth_call here has neither an execution timeout nor a concurrency limit, unlike evmrpc's SimulationAPI.Call (which uses RPCEVMTimeout and a requestLimiter). Executor.Call only checks ctx.Err() before execution and core.ApplyMessage is not cancellable, so a request that hits the 10M gas cap runs to completion and holds a state view open regardless of client disconnect. The listener binds 0.0.0.0:8545; consider evm.Cancel() on a timeout, or at least documenting the exposure for the load-test deployment.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread giga/evmonly/rpc/call.go Outdated
if err := requireCurrentState(block); err != nil {
return nil, err
}
baseFee := evmtypes.DefaultMinFeePerGas.TruncateInt().BigInt()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The base fee used to build the message disagrees with the base fee the call actually executes under. evmOnlyBaseFee() (sei-tendermint/internal/evmonlyapp/app.go:30) returns 0, and EvmCall sets BlockContext.BaseFee from it, so the BASEFEE opcode inside the call sees 0. Here the args are converted against DefaultMinFeePerGas (1 gwei) — the Cosmos EVM module's minimum fee parameter, which is unrelated to this application's block base fee.

For a caller that supplies EIP-1559 fields, ToMessage derives gasPrice = min(gasTipCap + baseFee, gasFeeCap); e.g. maxFeePerGas = 2 gwei, maxPriorityFeePerGas = 1 gwei yields GASPRICE == 2 gwei where a real transaction in a block would pay 1 gwei. The buyGas deduction uses that same value. Callers that omit fee fields are unaffected (both defaults land on 0), so the impact is narrow, but the two should not be able to diverge.

Passing a zero base fee here — or plumbing the executor's base fee through Backend so there is a single source — makes the message consistent with the block context it runs against and drops the x/evm/types coupling for this value.

PrevRandao: evmOnlyPrevRandao(state.lastBlockTime),
}
}
return executor.Call(ctx, blockCtx, msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The block context is captured under a.state.Lock() but Executor.Call opens the state view after the lock is released, so the two can describe different heights.

The deterministic case is the window between FinalizeBlock and Commit: ExecuteBlock already runs CommitStateChanges for block N inside FinalizeBlock (giga/evmonly/giga_store.go:97), while committedHeight and lastBlockTime are only advanced in Commit. A call landing in that window reads block N's storage but runs with NUMBER = N-1, TIMESTAMP and PrevRandao from block N-1. A view function mixing storage with block.number/block.timestamp returns an inconsistent pair.

Options: refuse the call while state.pending.IsPresent(), or advance committedHeight/lastBlockTime at the point the store write happens rather than at Commit. Either way it is worth a test that pins the behaviour in the post-FinalizeBlock, pre-Commit window — the existing tests all call after Commit.

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

Stale Bugbot comment from a previous run.

Comment thread giga/evmonly/rpc/call.go
Comment thread sei-tendermint/internal/evmonlyapp/app.go
… window

- Executor.Call now runs with vm.Config.NoBaseFee, matching go-ethereum's own
  eth_call semantics so a caller leaving fee fields at zero isn't rejected by
  the fee-cap-vs-basefee check.
- rpc/call.go's message-building base fee now matches the zero base fee the
  call actually executes under, instead of an unrelated x/evm minimum-fee
  constant.
- EvmCall now refuses while a finalized block is pending Commit, since the
  store already reflects the new block while tracked NUMBER/TIMESTAMP/
  PrevRandao still describe the previous one in that window.
Condense the NoBaseFee, base fee, and pending-commit comments to one line
each, stating what/why without restating the surrounding code.
@shemnon
shemnon enabled auto-merge September 16, 2026 09:53
@shemnon
shemnon added this pull request to the merge queue Sep 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Sep 16, 2026
@shemnon
shemnon enabled auto-merge September 16, 2026 11:57
…y-call

# Conflicts:
#	sei-tendermint/internal/evmonlyapp/app.go
@shemnon
shemnon disabled auto-merge September 16, 2026 12:08

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

Stale Bugbot comment from a previous run.

gasLimit: block.GasLimit,
}
state.pending = utils.Some(next)
state.pendingBlockTime = block.Time

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Block time not persisted across restart

Medium Severity

lastBlockTime is only kept in memory. The cursor changeset still encodes height, hashes, and gas limit, so a restart reloads those fields but leaves Time and PrevRandao at zero until the next Commit. eth_call then runs against the restored height with a genesis timestamp rather than the last committed block’s real time.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 80c24fb. Configure here.

core.ApplyMessage doesn't respect ctx, so a call priced under the 10M gas
cap but CPU-expensive (e.g. modexp with adversarial inputs) could hold a
state view open indefinitely. Executor.Call now cancels its EVM after
callTimeout (60s, matching evmrpc's simulation_evm_timeout default) via
evm.Cancel(), same as go-ethereum's own eth_call. Addresses a non-blocking
review finding that was missed on the initial pass.
…y-call

# Conflicts:
#	giga/evmonly/rpc/setup_test.go
@shemnon
shemnon enabled auto-merge September 16, 2026 12:37

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit cda82e7. Configure here.

PrevRandao: evmOnlyPrevRandao(state.lastBlockTime),
}
}
return executor.Call(ctx, blockCtx, msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Call races past pending check

Medium Severity

EvmCall rejects calls only while pending is set, then releases the cursor lock before OpenView. A FinalizeBlock can commit the next block's writes in that window, so the call executes against newer store state while still using the previous block's number, timestamp, and PrevRandao.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cda82e7. Configure here.

@shemnon
shemnon added this pull request to the merge queue Sep 16, 2026
Merged via the queue into giga-1 with commit b119735 Sep 16, 2026
68 checks passed
@shemnon
shemnon deleted the shemnon/giga-evmonly-call branch September 16, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants