Skip to content

Commit 4b0c85a

Browse files
authored
Backport EVM-only RPC methods from giga-1 (CON-427, CON-428, CON-429, CON-430) (#4289)
## Summary Backport the Autobahn EVM-only JSON-RPC surface from `giga-1` onto `main`, one commit per original merge, in apply order: - **CON-427** / giga-1 #4192 — `eth_getTransactionCount`, `eth_blockNumber`, `eth_chainId`, and reorganize `giga/evmonly/rpc` by JSON-RPC namespace - **CON-428** / giga-1 #4194 — `eth_call` (`Executor.Call` + read-only RPC) - **CON-429** / giga-1 #4205 — `eth_getTransactionByHash` - **CON-430** / giga-1 #4208 — `eth_getBlockByNumber` / `eth_getBlockByHash` `giga-1`'s durable execution cursor (#4190 / #4231) is not part of this backport. `EvmCall` and `EvmGasLimit` read main's in-memory `evmOnlyState` the same way giga-1 HEAD reads committed height/time/gas/hash and refuses while a finalized block is pending Commit. ## Test plan - [x] `go test -count=1 ./giga/evmonly/ ./giga/evmonly/rpc/ ./sei-tendermint/internal/proxy/` - [x] `scripts/ramtest.sh ./sei-tendermint/internal/evmonlyapp/ -count=1` - [ ] seidroid review - [ ] CI Coverage / Go Test on this PR
1 parent a71b871 commit 4b0c85a

33 files changed

Lines changed: 3469 additions & 365 deletions

app/abci.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ func (app *App) EvmBalance(evmAddr common.Address, seiAddrBz []byte) uint256.Int
188188
return bigIntToUint256(mempoolBalanceFloor(balance))
189189
}
190190

191+
// EvmChainID returns the EVM chain ID configured for this network.
192+
func (app *App) EvmChainID() uint64 {
193+
return app.EvmKeeper.ChainID(app.GetCheckCtx()).Uint64()
194+
}
195+
191196
func bigIntToUint256(x *big.Int) uint256.Int {
192197
if x == nil {
193198
return uint256.Int{}

giga/evmonly/call.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package evmonly
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"time"
8+
9+
"github.com/ethereum/go-ethereum/core"
10+
"github.com/ethereum/go-ethereum/core/vm"
11+
)
12+
13+
// callTimeout bounds how long a Call may run before its EVM is cancelled,
14+
// matching evmrpc's simulation_evm_timeout default. A var, not a const, so
15+
// tests can shrink it rather than run for the full timeout.
16+
var callTimeout = 60 * time.Second
17+
18+
// Call executes msg as a read-only EVM message call against the current
19+
// committed state and returns the execution result. It persists no state
20+
// change.
21+
func (e *Executor) Call(ctx context.Context, blockCtx BlockContext, msg *core.Message) (*core.ExecutionResult, error) {
22+
chainConfig := e.chainConfig(blockCtx)
23+
if err := validateBlockContext(chainConfig, blockCtx); err != nil {
24+
return nil, err
25+
}
26+
if e.stateStore == nil {
27+
return nil, errMissingStateStore
28+
}
29+
if err := ctx.Err(); err != nil {
30+
return nil, err
31+
}
32+
33+
snapshot := e.stateStore.OpenView()
34+
if snapshot == nil {
35+
return nil, errors.New("giga store returned a nil snapshot")
36+
}
37+
defer snapshot.Close()
38+
39+
stateDB := e.acquireStateDB(gigaSnapshotStateReader{snapshot: snapshot, missingState: e.missingState})
40+
defer e.releaseStateDB(stateDB)
41+
42+
// NoBaseFee matches go-ethereum's eth_call: zero fee fields skip the fee-cap check.
43+
evm := vm.NewEVM(buildBlockContext(blockCtx), stateDB, chainConfig, vm.Config{NoBaseFee: true}, customPrecompileMap(e.cfg.CustomPrecompiles))
44+
stateDB.SetEVM(evm)
45+
evm.SetTxContext(core.NewEVMTxContext(msg))
46+
47+
// core.ApplyMessage does not itself respect ctx, so bound it with a timer
48+
// that cancels the EVM directly; gas pricing alone cannot cap wall-clock
49+
// cost (e.g. modexp with adversarial inputs).
50+
callCtx, cancel := context.WithTimeout(ctx, callTimeout)
51+
defer cancel()
52+
go func() {
53+
<-callCtx.Done()
54+
evm.Cancel()
55+
}()
56+
57+
gasPool := new(core.GasPool).AddGas(msg.GasLimit)
58+
result, err := core.ApplyMessage(evm, msg, gasPool)
59+
if evm.Cancelled() {
60+
return nil, fmt.Errorf("EVM-only call exceeded %s execution timeout", callTimeout)
61+
}
62+
if stateErr := stateDB.Error(); stateErr != nil {
63+
return nil, stateErr
64+
}
65+
return result, err
66+
}

giga/evmonly/call_test.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package evmonly
2+
3+
import (
4+
"context"
5+
"math/big"
6+
"testing"
7+
"time"
8+
9+
"github.com/ethereum/go-ethereum/accounts/abi"
10+
"github.com/ethereum/go-ethereum/common"
11+
"github.com/ethereum/go-ethereum/core"
12+
"github.com/ethereum/go-ethereum/core/vm"
13+
"github.com/ethereum/go-ethereum/crypto"
14+
"github.com/stretchr/testify/require"
15+
16+
"github.com/sei-protocol/sei-chain/sei-db/proto"
17+
)
18+
19+
func callMessage(from common.Address, to *common.Address) *core.Message {
20+
return &core.Message{
21+
From: from,
22+
To: to,
23+
GasLimit: 200_000,
24+
GasPrice: new(big.Int),
25+
GasFeeCap: new(big.Int),
26+
GasTipCap: new(big.Int),
27+
Value: new(big.Int),
28+
SkipNonceChecks: true,
29+
SkipFromEOACheck: true,
30+
}
31+
}
32+
33+
// sloadReturnCode returns runtime bytecode that reads storage slot key and
34+
// returns its 32-byte value, mirroring a view function such as ERC20
35+
// balanceOf.
36+
func sloadReturnCode(key common.Hash) []byte {
37+
code := []byte{0x7f} // PUSH32 key
38+
code = append(code, key.Bytes()...)
39+
code = append(code, 0x54) // SLOAD
40+
code = append(code, 0x60, 0x00, 0x52) // PUSH1 0, MSTORE
41+
code = append(code, 0x60, 0x20, 0x60, 0x00, 0xf3) // PUSH1 32, PUSH1 0, RETURN
42+
return code
43+
}
44+
45+
// revertReasonRuntime returns runtime bytecode that always reverts with the
46+
// ABI-encoded Error(string) selector and reason, matching a Solidity
47+
// `require(false, reason)`.
48+
func revertReasonRuntime(reason string) []byte {
49+
selector := crypto.Keccak256([]byte("Error(string)"))[:4]
50+
payload := append(append([]byte{}, selector...), abiEncodeString(reason)...)
51+
return revertCodeForPayload(payload)
52+
}
53+
54+
func abiEncodeString(s string) []byte {
55+
data := []byte(s)
56+
offset := make([]byte, 32)
57+
offset[31] = 32
58+
length := make([]byte, 32)
59+
new(big.Int).SetUint64(uint64(len(data))).FillBytes(length)
60+
padded := make([]byte, ((len(data)+31)/32)*32)
61+
copy(padded, data)
62+
out := append(append([]byte{}, offset...), length...)
63+
return append(out, padded...)
64+
}
65+
66+
// revertCodeForPayload returns runtime bytecode that copies payload out of its
67+
// own code (via CODECOPY) and REVERTs with it.
68+
func revertCodeForPayload(payload []byte) []byte {
69+
const preambleLen = 14
70+
if len(payload) > 0xffff {
71+
panic("payload too large for test helper")
72+
}
73+
hi := byte(len(payload) >> 8) //nolint:gosec // bounded by the check above.
74+
lo := byte(len(payload) & 0xff) //nolint:gosec // bounded by the check above.
75+
code := []byte{
76+
0x61, hi, lo, // PUSH2 len(payload)
77+
0x60, preambleLen, // PUSH1 offset (start of payload in this code)
78+
0x60, 0x00, // PUSH1 0 (destination memory offset)
79+
0x39, // CODECOPY
80+
0x61, hi, lo, // PUSH2 len(payload)
81+
0x60, 0x00, // PUSH1 0
82+
0xfd, // REVERT
83+
}
84+
if len(code) != preambleLen {
85+
panic("preamble length mismatch")
86+
}
87+
return append(code, payload...)
88+
}
89+
90+
func TestExecutorCallReturnsViewFunctionResult(t *testing.T) {
91+
chainID := big.NewInt(testChainID)
92+
key, err := crypto.GenerateKey()
93+
require.NoError(t, err)
94+
sender := crypto.PubkeyToAddress(key.PublicKey)
95+
slot := testHash(0x11)
96+
value := testHash(0x22)
97+
readRuntime := sloadReturnCode(slot)
98+
contractAddr := crypto.CreateAddress(sender, 0)
99+
100+
state := NewMemoryState()
101+
state.SetBalance(sender, big.NewInt(2_000_000_000_000_000))
102+
store := NewMemoryStore(state)
103+
executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet))
104+
105+
deployRead := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(readRuntime), 300_000)
106+
_, err = executor.ExecuteBlock(t.Context(), BlockRequest{
107+
Context: blockContext(chainID),
108+
Txs: [][]byte{deployRead},
109+
})
110+
require.NoError(t, err)
111+
// Seed the slot directly, standing in for a prior committed transaction's
112+
// SSTORE; the view function under test only reads it back.
113+
state.SetState(contractAddr, slot, value)
114+
115+
result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &contractAddr))
116+
117+
require.NoError(t, err)
118+
require.False(t, result.Failed())
119+
require.Equal(t, value.Bytes(), result.Return())
120+
}
121+
122+
func TestExecutorCallSurfacesRevertReason(t *testing.T) {
123+
chainID := big.NewInt(testChainID)
124+
key, err := crypto.GenerateKey()
125+
require.NoError(t, err)
126+
sender := crypto.PubkeyToAddress(key.PublicKey)
127+
runtime := revertReasonRuntime("insufficient balance")
128+
contractAddr := crypto.CreateAddress(sender, 0)
129+
130+
state := NewMemoryState()
131+
state.SetBalance(sender, big.NewInt(2_000_000_000_000_000))
132+
store := NewMemoryStore(state)
133+
executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet))
134+
135+
deploy := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(runtime), 300_000)
136+
_, err = executor.ExecuteBlock(t.Context(), BlockRequest{
137+
Context: blockContext(chainID),
138+
Txs: [][]byte{deploy},
139+
})
140+
require.NoError(t, err)
141+
142+
result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &contractAddr))
143+
144+
require.NoError(t, err)
145+
require.ErrorIs(t, result.Err, vm.ErrExecutionReverted)
146+
reason, unpackErr := abi.UnpackRevert(result.Revert())
147+
require.NoError(t, unpackErr)
148+
require.Equal(t, "insufficient balance", reason)
149+
}
150+
151+
func TestExecutorCallToNonexistentContractSucceedsWithEmptyReturnData(t *testing.T) {
152+
chainID := big.NewInt(testChainID)
153+
sender := testAddress(0xa1)
154+
target := testAddress(0xb2)
155+
156+
state := NewMemoryState()
157+
state.SetBalance(sender, big.NewInt(2_000_000_000_000_000))
158+
store := NewMemoryStore(state)
159+
executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet))
160+
161+
result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &target))
162+
163+
require.NoError(t, err)
164+
require.False(t, result.Failed())
165+
require.Empty(t, result.Return())
166+
}
167+
168+
func TestExecutorCallDoesNotMutateCommittedState(t *testing.T) {
169+
chainID := big.NewInt(testChainID)
170+
key, err := crypto.GenerateKey()
171+
require.NoError(t, err)
172+
sender := crypto.PubkeyToAddress(key.PublicKey)
173+
slot := testHash(0x44)
174+
writtenValue := testHash(0x55)
175+
// This contract unconditionally SSTOREs on every invocation; a call must
176+
// never let that write reach committed state.
177+
runtime := storeCode(slot, writtenValue)
178+
contractAddr := crypto.CreateAddress(sender, 0)
179+
180+
state := NewMemoryState()
181+
state.SetBalance(sender, big.NewInt(2_000_000_000_000_000))
182+
store := NewMemoryStore(state)
183+
executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet))
184+
185+
deploy := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(runtime), 300_000)
186+
_, err = executor.ExecuteBlock(t.Context(), BlockRequest{
187+
Context: blockContext(chainID),
188+
Txs: [][]byte{deploy},
189+
})
190+
require.NoError(t, err)
191+
192+
beforeView := store.OpenView()
193+
before := beforeView.GetStorage(contractAddr, slot)
194+
beforeView.Close()
195+
require.Equal(t, common.Hash{}, before)
196+
197+
result, err := executor.Call(t.Context(), blockContext(chainID), callMessage(sender, &contractAddr))
198+
require.NoError(t, err)
199+
require.False(t, result.Failed())
200+
201+
afterView := store.OpenView()
202+
defer afterView.Close()
203+
require.Equal(t, common.Hash{}, afterView.GetStorage(contractAddr, slot),
204+
"eth_call-style execution must never persist a state change")
205+
}
206+
207+
// infiniteLoopCode returns runtime bytecode that loops forever
208+
// (JUMPDEST, PUSH1 0, JUMP), for a call whose gas alone would never stop it.
209+
func infiniteLoopCode() []byte {
210+
return []byte{0x5b, 0x60, 0x00, 0x56}
211+
}
212+
213+
func TestExecutorCallTimesOutOnUnboundedExecution(t *testing.T) {
214+
original := callTimeout
215+
callTimeout = 20 * time.Millisecond
216+
defer func() { callTimeout = original }()
217+
218+
chainID := big.NewInt(testChainID)
219+
key, err := crypto.GenerateKey()
220+
require.NoError(t, err)
221+
sender := crypto.PubkeyToAddress(key.PublicKey)
222+
contractAddr := crypto.CreateAddress(sender, 0)
223+
224+
state := NewMemoryState()
225+
state.SetBalance(sender, big.NewInt(2_000_000_000_000_000))
226+
store := NewMemoryStore(state)
227+
executor := NewExecutor(Config{}, withTestStores(store, NewMemoryReceiptStore(), store.EncodeChangeSet))
228+
229+
deploy := signLegacyTxWithGas(t, key, chainID, 0, nil, big.NewInt(0), initCode(infiniteLoopCode()), 300_000)
230+
_, err = executor.ExecuteBlock(t.Context(), BlockRequest{
231+
Context: blockContext(chainID),
232+
Txs: [][]byte{deploy},
233+
})
234+
require.NoError(t, err)
235+
236+
msg := callMessage(sender, &contractAddr)
237+
msg.GasLimit = 1_000_000_000_000 // far more gas than the shrunk timeout allows spending
238+
239+
_, err = executor.Call(t.Context(), blockContext(chainID), msg)
240+
241+
require.ErrorContains(t, err, "timeout")
242+
}
243+
244+
func TestExecutorCallRejectsMissingStateStore(t *testing.T) {
245+
executor := NewExecutor(Config{})
246+
247+
_, err := executor.Call(t.Context(), blockContext(big.NewInt(testChainID)), callMessage(testAddress(0x01), nil))
248+
249+
require.ErrorIs(t, err, errMissingStateStore)
250+
}
251+
252+
func TestExecutorCallHonorsCanceledContext(t *testing.T) {
253+
executor := NewExecutor(Config{}, withTestState(NewMemoryState()))
254+
ctx, cancel := context.WithCancel(t.Context())
255+
cancel()
256+
257+
// Test: Call with an already-canceled context.
258+
_, err := executor.Call(ctx, blockContext(big.NewInt(testChainID)), callMessage(testAddress(0x01), nil))
259+
260+
// Verify: the canceled context is returned before a snapshot is opened.
261+
require.ErrorIs(t, err, context.Canceled)
262+
}
263+
264+
func TestExecutorCallRejectsNilSnapshot(t *testing.T) {
265+
executor := NewExecutor(Config{}, withTestStores(&recordingGigaStore{}, NewMemoryReceiptStore(), func(StateChangeSet) ([]*proto.NamedChangeSet, error) {
266+
return nil, nil
267+
}))
268+
269+
// Test: the store's OpenView returns nil.
270+
_, err := executor.Call(t.Context(), blockContext(big.NewInt(testChainID)), callMessage(testAddress(0x01), nil))
271+
272+
// Verify: that is an error, not a panic on the snapshot.
273+
require.EqualError(t, err, "giga store returned a nil snapshot")
274+
}
275+
276+
func TestExecutorCallRejectsZeroGasLimit(t *testing.T) {
277+
executor := NewExecutor(Config{}, withTestState(NewMemoryState()))
278+
ctx := blockContext(big.NewInt(testChainID))
279+
ctx.GasLimit = 0
280+
281+
// Test: Call with a zero block gas limit.
282+
_, err := executor.Call(t.Context(), ctx, callMessage(testAddress(0x01), nil))
283+
284+
// Verify: rejected before a snapshot is opened.
285+
require.ErrorIs(t, err, errInvalidBlockGasLimit)
286+
}

0 commit comments

Comments
 (0)