diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 01c67a0811..7d626667b2 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,7 +31,7 @@ jobs: - name: Make Test id: unit-test - run: make test + run: make clean test test_coverage: runs-on: ubuntu-latest diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 3a64b3b651..ec30e72930 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "runtime/debug" + "strings" "time" "github.com/vechain/thor/v2/api/doc" @@ -60,10 +61,17 @@ func HandleAPITimeout(timeout time.Duration) func(http.Handler) http.Handler { } } -// middleware to limit request body size. -func HandleRequestBodyLimit(maxBodySize int64) func(next http.Handler) http.Handler { +// HandleRequestBodyLimit limits request body size. +// Paths whose prefix matches any entry in exceptions bypass the limit. +func HandleRequestBodyLimit(maxBodySize int64, exceptions ...string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, exc := range exceptions { + if strings.HasPrefix(r.URL.Path, exc) { + next.ServeHTTP(w, r) + return + } + } r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) next.ServeHTTP(w, r) }) diff --git a/api/middleware/middleware_test.go b/api/middleware/middleware_test.go index 189dbe1beb..5579430d3b 100644 --- a/api/middleware/middleware_test.go +++ b/api/middleware/middleware_test.go @@ -394,6 +394,35 @@ func TestHandleRequestBodyLimitExceeded(t *testing.T) { assert.Contains(t, rr.Body.String(), "http: request body too large") } +func TestHandleRequestBodyLimitException(t *testing.T) { + largeBody := strings.Repeat("x", 200) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusRequestEntityTooLarge) + return + } + w.WriteHeader(http.StatusOK) + }) + + mw := HandleRequestBodyLimit(10, "/rpc") + + t.Run("exception_path_bypasses_limit", func(t *testing.T) { + req := httptest.NewRequest("POST", "/rpc", strings.NewReader(largeBody)) + rr := httptest.NewRecorder() + mw(handler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("non_exception_path_is_limited", func(t *testing.T) { + req := httptest.NewRequest("POST", "/accounts", strings.NewReader(largeBody)) + rr := httptest.NewRecorder() + mw(handler).ServeHTTP(rr, req) + assert.Equal(t, http.StatusRequestEntityTooLarge, rr.Code) + }) +} + func TestBodyLimitWithRequestLogger(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := io.ReadAll(r.Body) diff --git a/api/middleware/request_logger_test.go b/api/middleware/request_logger_test.go index 716b8769d8..e9390ea5b8 100644 --- a/api/middleware/request_logger_test.go +++ b/api/middleware/request_logger_test.go @@ -119,7 +119,7 @@ func TestRequestLoggerHandler(t *testing.T) { w.Write([]byte("OK")) }, enabled: false, - slowQueriesThreshold: 20 * time.Millisecond, + slowQueriesThreshold: time.Second, log5xxErrors: false, expectedStatusCode: http.StatusOK, shouldLog: false, diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index 387b9f046f..833bd97481 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -34,9 +34,20 @@ import ( "github.com/vechain/thor/v2/chain" "github.com/vechain/thor/v2/log" "github.com/vechain/thor/v2/logdb" + "github.com/vechain/thor/v2/rpc/jsonrpc" "github.com/vechain/thor/v2/state" "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/txpool" + + rpcaccounts "github.com/vechain/thor/v2/rpc/accounts" + rpcblocks "github.com/vechain/thor/v2/rpc/blocks" + rpcchain "github.com/vechain/thor/v2/rpc/chain" + rpcfees "github.com/vechain/thor/v2/rpc/fees" + rpcfilters "github.com/vechain/thor/v2/rpc/filters" + rpclogs "github.com/vechain/thor/v2/rpc/logs" + rpcsimulation "github.com/vechain/thor/v2/rpc/simulation" + rpctransactions "github.com/vechain/thor/v2/rpc/transactions" + rpcws "github.com/vechain/thor/v2/rpc/ws" ) var logger = log.WithContext("pkg", "api") @@ -52,6 +63,7 @@ type APIConfig struct { BacktraceLimit uint32 CallGasLimit uint64 BatchDataMaxSize uint64 + ClientVersion string PprofOn bool SkipLogs bool AllowCustomTracer bool @@ -131,6 +143,23 @@ func StartAPIServer( subs := subscriptions.New(repo, origins, config.BacktraceLimit, txPool, config.EnableDeprecated) subs.Mount(router, "/subscriptions") + // Ethereum JSON-RPC at /rpc — body limit enforced internally by jsonrpc.Server (2 MB via MaxBytesReader) + rpcSrv := jsonrpc.NewServer() + rpcchain.New(repo, config.ClientVersion).Mount(rpcSrv) + rpcblocks.New(repo).Mount(rpcSrv) + rpctransactions.New(repo, txPool).Mount(rpcSrv) + rpcaccounts.New(repo, stater).Mount(rpcSrv) + rpclogs.New(repo, logDB, config.BacktraceLimit, config.LogsLimit).Mount(rpcSrv) + rpcfees.New(repo, config.BacktraceLimit, forkConfig).Mount(rpcSrv) + rpcsimulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(rpcSrv) + rpcFilters := rpcfilters.New(repo, txPool, config.BacktraceLimit) + rpcFilters.Mount(rpcSrv) + + // Wrap rpcSrv with the WebSocket handler: plain HTTP POST goes to rpcSrv, + // WebSocket upgrade requests gain eth_subscribe / eth_unsubscribe. + rpcWs := rpcws.New(repo, txPool, origins, rpcSrv) + router.PathPrefix("/rpc").Handler(rpcWs) + if config.PprofOn { router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) router.HandleFunc("/debug/pprof/profile", pprof.Profile) @@ -140,8 +169,8 @@ func StartAPIServer( } // middlewares - // body limit and timeout - router.Use(middleware.HandleRequestBodyLimit(defaultRequestBodyLimit)) + // /rpc owns its body limit inside jsonrpc.Server; skip the REST 200 KB cap for that path. + router.Use(middleware.HandleRequestBodyLimit(defaultRequestBodyLimit, "/rpc")) if config.Timeout > 0 { router.Use(middleware.HandleAPITimeout(time.Duration(config.Timeout) * time.Millisecond)) } @@ -171,6 +200,8 @@ func StartAPIServer( return "http://" + listener.Addr().String() + "/", func() { srv.Close() subs.Close() + rpcFilters.Close() + rpcWs.Close() goes.Wait() }, nil } diff --git a/cmd/thor/main.go b/cmd/thor/main.go index 7da6d1eb1e..a1320a2ca0 100644 --- a/cmd/thor/main.go +++ b/cmd/thor/main.go @@ -322,14 +322,14 @@ func defaultAction(_ context.Context, ctx *cli.Command) error { bftEngine, p2pCommunicator.Communicator(), forkConfig, - makeAPIConfig(ctx, logAPIRequests, false), + makeAPIConfig(ctx, logAPIRequests, false, version), ) if err != nil { return err } defer func() { log.Info("stopping API server..."); srvCloser() }() - printStartupMessage2(gene, apiURL, p2pCommunicator.Enode(), metricsURL, adminURL, false) + printStartupMessage2(apiURL, apiURL+"rpc", p2pCommunicator.Enode(), metricsURL, adminURL, false) if err := p2pCommunicator.Start(); err != nil { return err @@ -518,14 +518,14 @@ func soloAction(_ context.Context, ctx *cli.Command) error { bftEngine, &solo.Communicator{}, forkConfig, - makeAPIConfig(ctx, logAPIRequests, true), + makeAPIConfig(ctx, logAPIRequests, true, version), ) if err != nil { return err } defer func() { log.Info("stopping API server..."); srvCloser() }() - printStartupMessage2(gene, apiURL, "", metricsURL, adminURL, isDevnet) + printStartupMessage2(apiURL, apiURL+"rpc", "", metricsURL, adminURL, isDevnet) if !ctx.Bool(disablePrunerFlag.Name) { pruner := pruner.New(mainDB, repo, bftEngine, *forkConfig) diff --git a/cmd/thor/utils.go b/cmd/thor/utils.go index be5ed36acd..800441e9d7 100644 --- a/cmd/thor/utils.go +++ b/cmd/thor/utils.go @@ -257,12 +257,13 @@ func parseGenesisFile(uri string) (*genesis.Genesis, *thor.ForkConfig, error) { return customGen, &forkConfig, nil } -func makeAPIConfig(ctx *cli.Command, logAPIRequests *atomic.Bool, soloMode bool) httpserver.APIConfig { +func makeAPIConfig(ctx *cli.Command, logAPIRequests *atomic.Bool, soloMode bool, clientVersion string) httpserver.APIConfig { return httpserver.APIConfig{ AllowedOrigins: ctx.String(apiCorsFlag.Name), BacktraceLimit: uint32(ctx.Uint64(apiBacktraceLimitFlag.Name)), CallGasLimit: ctx.Uint64(apiCallGasLimitFlag.Name), BatchDataMaxSize: ctx.Uint64(apiBatchDataMaxSizeFlag.Name), + ClientVersion: clientVersion, PprofOn: ctx.Bool(pprofFlag.Name), SkipLogs: ctx.Bool(skipLogsFlag.Name), APIBacktraceLimit: int(ctx.Uint64(apiBacktraceLimitFlag.Name)), @@ -593,14 +594,15 @@ func printStartupMessage1( } func printStartupMessage2( - gene *genesis.Genesis, apiURL string, + ethRPCURL string, nodeID string, metricsURL string, adminURL string, isDevnet bool, ) { - message := fmt.Sprintf(`%v API portal [ %v ]%v%v%v`, + message := fmt.Sprintf(`%v API portal [ %v ] + Ethereum RPC [ %v ]%v%v%v`, func() string { // node ID if nodeID == "" { return "" @@ -611,6 +613,7 @@ func printStartupMessage2( } }(), apiURL, + ethRPCURL, func() string { // metrics URL if metricsURL == "" { return "" diff --git a/cmd/thor/utils_test.go b/cmd/thor/utils_test.go index c63a263093..eadf57816d 100644 --- a/cmd/thor/utils_test.go +++ b/cmd/thor/utils_test.go @@ -45,18 +45,23 @@ func TestPrintStartupMessage1(t *testing.T) { } func TestPrintStartupMessage2(t *testing.T) { - gene, _ := genesis.NewDevnet() - t.Run("all fields", func(t *testing.T) { - printStartupMessage2(gene, "http://localhost:8669", "enode://abc@127.0.0.1:11235", "http://localhost:2112", "http://localhost:2113", false) + printStartupMessage2( + "http://localhost:8669/", + "http://localhost:8669/rpc", + "enode://abc@127.0.0.1:11235", + "http://localhost:2112", + "http://localhost:2113", + false, + ) }) t.Run("minimal fields", func(t *testing.T) { - printStartupMessage2(gene, "http://localhost:8669", "", "", "", false) + printStartupMessage2("http://localhost:8669/", "http://localhost:8669/rpc", "", "", "", false) }) t.Run("devnet", func(t *testing.T) { - printStartupMessage2(gene, "http://localhost:8669", "", "", "", true) + printStartupMessage2("http://localhost:8669/", "http://localhost:8669/rpc", "", "", "", true) }) } diff --git a/consensus/validator.go b/consensus/validator.go index a70f61c4e9..b28ebd05a2 100644 --- a/consensus/validator.go +++ b/consensus/validator.go @@ -221,6 +221,19 @@ func (c *Consensus) validateBlockBody(blk *block.Block) error { return consensusError(fmt.Sprintf("tx Ethereum chain ID %v does not match network chain ID %d", cid, c.repo.ChainID())) } + // Field-range validation (maxFeePerGas > 0, maxPriority ≤ maxFee, etc.) is + // intentionally absent here, consistent with TypeDynamicFee. Both types rely + // on execution-time checks in ResolveTransaction and BuyGas — a block whose + // tx has invalid fields will fail verifyBlock when BuyGas returns an error. + // validateBlockBody is a fast-path structural check; semantic validation is + // deferred to execution. + // + // TODO: non-empty access lists are rejected by the pool (ParseEthTransaction / + // validateEth1559Fields) but are not checked here or in eth1559TxData.decode(). + // A block built outside the pool can carry ETH txs with access lists — the + // entries are stored but not used for EIP-2929 warm/cold gas accounting (not + // yet implemented). Add an explicit consensus-level rejection once access list + // support is complete, or reject them here in the interim. } if err := tr.TestFeatures(header.TxsFeatures()); err != nil { diff --git a/packer/flow.go b/packer/flow.go index 209235e6e3..1c5df5ccbb 100644 --- a/packer/flow.go +++ b/packer/flow.go @@ -100,6 +100,11 @@ func (f *Flow) hasTx(txid thor.Bytes32, txBlockRef uint32) (bool, error) { } func (f *Flow) txFitsBlockSize(t *tx.Transaction) bool { + // TODO: f.blockSize is the sum of individual tx sizes; blk.Size() in consensus + // measures the full RLP-encoded block (header + tx list framing). The 2 KB buffer + // covers expected header overhead, but the two accounting methods can diverge under + // RLP framing edge cases. Consider a property test that feeds blk.Size() back from + // Pack() to verify the packer never produces a block that fails the consensus check. return f.blockSize+uint64(t.Size()) < thor.MaxRLPBlockSize-blockSizeBufferZone } @@ -174,6 +179,9 @@ func (f *Flow) Adopt(t *tx.Transaction) error { return errGasLimitReached } + // Block size enforcement is introduced with INTERSTELLAR; pre-INTERSTELLAR blocks + // have no packer-side size cap — only the gas limit bounds block size indirectly. + // This preserves pre-existing behaviour for all blocks before the fork. if thor.IsForked(f.Number(), f.packer.forkConfig.INTERSTELLAR) && !f.txFitsBlockSize(t) { return errBlockSizeLimitReached } diff --git a/rpc/accounts/handler.go b/rpc/accounts/handler.go new file mode 100644 index 0000000000..e534b36fbe --- /dev/null +++ b/rpc/accounts/handler.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package accounts + +import ( + "encoding/json" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/state" +) + +// Handler implements account state JSON-RPC methods. +type Handler struct { + repo *chain.Repository + stater *state.Stater +} + +// New creates an accounts Handler. +func New(repo *chain.Repository, stater *state.Stater) *Handler { + return &Handler{repo: repo, stater: stater} +} + +// Mount registers all account state methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_getBalance", h.ethGetBalance) + s.Register("eth_getCode", h.ethGetCode) + s.Register("eth_getStorageAt", h.ethGetStorageAt) + s.Register("eth_getTransactionCount", h.ethGetTransactionCount) +} + +func (h *Handler) ethGetBalance(req jsonrpc.Request) jsonrpc.Response { + var params rpc.AddressAndTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + st, err := ethconvert.StateAt(params.Tag, h.repo, h.stater) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + bal, err := st.GetBalance(params.Address) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(bal)) +} + +func (h *Handler) ethGetCode(req jsonrpc.Request) jsonrpc.Response { + var params rpc.AddressAndTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + st, err := ethconvert.StateAt(params.Tag, h.repo, h.stater) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + code, err := st.GetCode(params.Address) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + return jsonrpc.OkResponse(req.ID, hexutil.Bytes(code)) +} + +func (h *Handler) ethGetStorageAt(req jsonrpc.Request) jsonrpc.Response { + var params rpc.StorageAtParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + st, err := ethconvert.StateAt(params.Tag, h.repo, h.stater) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + val, err := st.GetStorage(params.Address, params.Slot) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + return jsonrpc.OkResponse(req.ID, common.Hash(val)) +} + +func (h *Handler) ethGetTransactionCount(req jsonrpc.Request) jsonrpc.Response { + var params rpc.AddressAndTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + // TODO "pending" returns the confirmed nonce; pool scanning is not implemented. + st, err := ethconvert.StateAt(params.Tag, h.repo, h.stater) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + nonce, err := st.GetNonce(params.Address) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(nonce)) +} diff --git a/rpc/accounts/handler_test.go b/rpc/accounts/handler_test.go new file mode 100644 index 0000000000..21af573ccf --- /dev/null +++ b/rpc/accounts/handler_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package accounts_test + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/accounts" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" +) + +type fixture struct { + chain *testchain.Chain + senderAddr string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + ethTx := testutil.BuildEthTx(t, chainID, sender, 0, &recipient.Address) + require.NoError(t, c.MintBlock(ethTx)) + + return &fixture{ + chain: c, + senderAddr: sender.Address.String(), + } +} + +func TestAccountsHandler(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, accounts.New(fx.chain.Repo(), fx.chain.Stater())) + + t.Run("eth_getBalance", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBalance", []any{fx.senderAddr, "latest"}) + var bal hexutil.Big + require.NoError(t, json.Unmarshal(result, &bal)) + assert.True(t, bal.ToInt().Sign() > 0, "funded dev account should have non-zero balance") + }) + + t.Run("eth_getCode_eoa", func(t *testing.T) { + // EOAs have no code. + result := testutil.Call(t, ts, "eth_getCode", []any{fx.senderAddr, "latest"}) + var code hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &code)) + assert.Empty(t, code) + }) + + t.Run("eth_getStorageAt_zero_slot", func(t *testing.T) { + // Slot 0 of an EOA is always zero. + result := testutil.Call(t, ts, "eth_getStorageAt", []any{fx.senderAddr, "0x0", "latest"}) + var slot common.Hash + require.NoError(t, json.Unmarshal(result, &slot)) + assert.Equal(t, common.Hash{}, slot) + }) + + t.Run("eth_getTransactionCount_after_eth_tx", func(t *testing.T) { + // The fixture sender sent one ETH tx with nonce 0; the runtime increments + // the nonce to 1 and persists it in the committed trie. + result := testutil.Call(t, ts, "eth_getTransactionCount", []any{fx.senderAddr, "latest"}) + var nonce hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &nonce)) + assert.Equal(t, uint64(1), uint64(nonce)) + }) + + t.Run("eth_getTransactionCount_fresh_account", func(t *testing.T) { + // An account that has never sent an ETH tx has nonce 0. + freshAddr := genesis.DevAccounts()[5].Address.String() + result := testutil.Call(t, ts, "eth_getTransactionCount", []any{freshAddr, "latest"}) + var nonce hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &nonce)) + assert.Equal(t, uint64(0), uint64(nonce)) + }) + + t.Run("eth_getCode_contract", func(t *testing.T) { + // The Energy built-in is a deployed contract — its code must be non-empty. + result := testutil.Call(t, ts, "eth_getCode", []any{ + builtin.Energy.Address.String(), "latest", + }) + var code hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &code)) + assert.NotEmpty(t, code, "Energy built-in contract should have non-empty code") + }) + + t.Run("eth_getBalance_no_block_tag", func(t *testing.T) { + // Block tag is optional per Ethereum convention; omitting it defaults to "latest". + result := testutil.Call(t, ts, "eth_getBalance", []any{fx.senderAddr}) + var bal hexutil.Big + require.NoError(t, json.Unmarshal(result, &bal)) + assert.True(t, bal.ToInt().Sign() > 0, "funded dev account should have non-zero balance without explicit block tag") + }) +} diff --git a/rpc/accounts_types.go b/rpc/accounts_types.go new file mode 100644 index 0000000000..0b705b193b --- /dev/null +++ b/rpc/accounts_types.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "encoding/json" + "fmt" + + "github.com/vechain/thor/v2/thor" +) + +// AddressAndTagParams holds an account address and an optional block tag, +// used by eth_getBalance, eth_getCode, and eth_getTransactionCount. +// Tag defaults to "latest" when omitted or null. +type AddressAndTagParams struct { + Address thor.Address + Tag string +} + +func (p *AddressAndTagParams) UnmarshalJSON(data []byte) error { + var raws []json.RawMessage + if err := json.Unmarshal(data, &raws); err != nil || len(raws) < 1 { + return fmt.Errorf("expected [address, blockTag?]") + } + var addrStr string + if err := json.Unmarshal(raws[0], &addrStr); err != nil { + return fmt.Errorf("invalid address") + } + addr, err := thor.ParseAddress(addrStr) + if err != nil { + return fmt.Errorf("invalid address: %w", err) + } + p.Address = addr + p.Tag = "latest" + if len(raws) >= 2 && string(raws[1]) != "null" { + if err := json.Unmarshal(raws[1], &p.Tag); err != nil { + return fmt.Errorf("invalid block tag") + } + } + return nil +} + +// StorageAtParams holds an account address, a storage slot, and an optional block tag, +// used by eth_getStorageAt. Tag defaults to "latest" when omitted or null. +type StorageAtParams struct { + Address thor.Address + Slot thor.Bytes32 + Tag string +} + +func (p *StorageAtParams) UnmarshalJSON(data []byte) error { + var raws []json.RawMessage + if err := json.Unmarshal(data, &raws); err != nil || len(raws) < 2 { + return fmt.Errorf("expected [address, slot, blockTag?]") + } + var addrStr string + if err := json.Unmarshal(raws[0], &addrStr); err != nil { + return fmt.Errorf("invalid address") + } + addr, err := thor.ParseAddress(addrStr) + if err != nil { + return fmt.Errorf("invalid address: %w", err) + } + p.Address = addr + var slotStr string + if err := json.Unmarshal(raws[1], &slotStr); err != nil { + return fmt.Errorf("invalid slot") + } + slot, err := ParseBytes32Compact(slotStr) + if err != nil { + return fmt.Errorf("invalid slot: %w", err) + } + p.Slot = slot + p.Tag = "latest" + if len(raws) >= 3 && string(raws[2]) != "null" { + if err := json.Unmarshal(raws[2], &p.Tag); err != nil { + return fmt.Errorf("invalid block tag") + } + } + return nil +} diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go new file mode 100644 index 0000000000..11673c7cc4 --- /dev/null +++ b/rpc/blocks/handler.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package blocks + +import ( + "encoding/json" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/tx" +) + +// Handler implements block query JSON-RPC methods. +type Handler struct { + repo *chain.Repository +} + +// New creates a blocks Handler. +func New(repo *chain.Repository) *Handler { + return &Handler{repo: repo} +} + +// Mount registers all block query methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_getBlockByHash", h.ethGetBlockByHash) + s.Register("eth_getBlockByNumber", h.ethGetBlockByNumber) + s.Register("eth_getBlockTransactionCountByHash", h.ethGetBlockTransactionCountByHash) + s.Register("eth_getBlockTransactionCountByNumber", h.ethGetBlockTransactionCountByNumber) + s.Register("eth_getBlockReceipts", h.ethGetBlockReceipts) + s.Register("eth_getUncleCountByBlockHash", h.ethGetUncleCountByBlockHash) + s.Register("eth_getUncleCountByBlockNumber", h.ethGetUncleCountByBlockNumber) + s.Register("eth_getUncleByBlockHashAndIndex", func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, nil) }) + s.Register("eth_getUncleByBlockNumberAndIndex", func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, nil) }) +} + +func (h *Handler) ethGetBlockByHash(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockQueryParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + return h.getBlockByTag(req.ID, params.Tag, params.FullTxs) +} + +func (h *Handler) ethGetBlockByNumber(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockQueryParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + return h.getBlockByTag(req.ID, params.Tag, params.FullTxs) +} + +func (h *Handler) getBlockByTag(id json.RawMessage, tag string, fullTxs bool) jsonrpc.Response { + summary, err := ethconvert.ResolveBlockTag(tag, h.repo) + if err != nil { + return jsonrpc.OkResponse(id, nil) + } + blk, err := ethconvert.BuildEthBlock(summary.Header, h.repo, fullTxs) + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) + } + return jsonrpc.OkResponse(id, blk) +} + +func (h *Handler) ethGetBlockTransactionCountByHash(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + return h.txCountByTag(req.ID, params.Tag) +} + +func (h *Handler) ethGetBlockTransactionCountByNumber(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + return h.txCountByTag(req.ID, params.Tag) +} + +func (h *Handler) txCountByTag(id json.RawMessage, tag string) jsonrpc.Response { + summary, err := ethconvert.ResolveBlockTag(tag, h.repo) + if err != nil { + return jsonrpc.OkResponse(id, nil) + } + blk, err := h.repo.GetBlock(summary.Header.ID()) + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) + } + var count uint64 + for _, t := range blk.Transactions() { + if t.Type() == tx.TypeEthDynamicFee { + count++ + } + } + return jsonrpc.OkResponse(id, hexutil.Uint64(count)) +} + +func (h *Handler) ethGetBlockReceipts(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + + summary, err := ethconvert.ResolveBlockTag(params.Tag, h.repo) + if err != nil { + return jsonrpc.OkResponse(req.ID, nil) + } + blk, err := h.repo.GetBlock(summary.Header.ID()) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + receipts, err := h.repo.GetBlockReceipts(summary.Header.ID()) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + + blockHash := common.Hash(summary.Header.ID()) + blockNum := uint64(summary.Header.Number()) + baseFee := summary.Header.BaseFee() + + ethReceipts := make([]*rpc.EthReceipt, 0) + var projIdx, cumGas, logOff uint64 + for i, t := range blk.Transactions() { + if t.Type() != tx.TypeEthDynamicFee { + continue + } + cumGas += receipts[i].GasUsed + rec := ethconvert.ToEthReceipt(t, receipts[i], blockHash, blockNum, projIdx, cumGas, logOff, baseFee) + ethReceipts = append(ethReceipts, rec) + logOff += uint64(len(rec.Logs)) + projIdx++ + } + return jsonrpc.OkResponse(req.ID, ethReceipts) +} + +func (h *Handler) ethGetUncleCountByBlockHash(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + return h.uncleCountByTag(req.ID, params.Tag) +} + +func (h *Handler) ethGetUncleCountByBlockNumber(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + return h.uncleCountByTag(req.ID, params.Tag) +} + +func (h *Handler) uncleCountByTag(id json.RawMessage, tag string) jsonrpc.Response { + if _, err := ethconvert.ResolveBlockTag(tag, h.repo); err != nil { + return jsonrpc.OkResponse(id, nil) + } + return jsonrpc.OkResponse(id, hexutil.Uint64(0)) +} diff --git a/rpc/blocks/handler_test.go b/rpc/blocks/handler_test.go new file mode 100644 index 0000000000..e0837d4ca0 --- /dev/null +++ b/rpc/blocks/handler_test.go @@ -0,0 +1,292 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package blocks_test + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/blocks" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/tx" + + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +type fixture struct { + chain *testchain.Chain + ethTxHash string + blockHash string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + vcTx := testutil.BuildVcTx(t, c, sender, &recipient.Address) + ethTx := testutil.BuildEthTx(t, c.Repo().ChainID(), sender, 0, &recipient.Address) + require.NoError(t, c.MintBlock(vcTx, ethTx)) + + bestBlock, err := c.BestBlock() + require.NoError(t, err) + return &fixture{ + chain: c, + ethTxHash: ethTx.ID().String(), + blockHash: bestBlock.Header().ID().String(), + } +} + +func TestBlocksHandler(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, blocks.New(fx.chain.Repo())) + + t.Run("eth_getBlockByNumber_latest", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"latest", false}) + var blk map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &blk)) + + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(blk["number"], &num)) + assert.Equal(t, uint64(1), uint64(num)) + + // Only the ETH tx hash is present; the VeChain legacy tx is excluded. + var txHashes []string + require.NoError(t, json.Unmarshal(blk["transactions"], &txHashes)) + require.Len(t, txHashes, 1) + assert.Equal(t, fx.ethTxHash, txHashes[0]) + + // gasUsed counts only the ETH tx. + var gasUsed hexutil.Uint64 + require.NoError(t, json.Unmarshal(blk["gasUsed"], &gasUsed)) + assert.Greater(t, uint64(gasUsed), uint64(0)) + + // baseFeePerGas is present because GALACTICA is active from block 0. + _, hasBF := blk["baseFeePerGas"] + assert.True(t, hasBF, "baseFeePerGas should be present for a GALACTICA block") + }) + + t.Run("eth_getBlockByNumber_earliest", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"earliest", false}) + var blk map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &blk)) + + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(blk["number"], &num)) + assert.Equal(t, uint64(0), uint64(num)) + }) + + t.Run("eth_getBlockByNumber_hex", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"0x1", false}) + var blk map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &blk)) + + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(blk["number"], &num)) + assert.Equal(t, uint64(1), uint64(num)) + }) + + t.Run("eth_getBlockByNumber_notfound", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"0xffff", false}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getBlockByNumber_fullTxs", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"latest", true}) + var blk map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &blk)) + + var txObjs []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(blk["transactions"], &txObjs)) + require.Len(t, txObjs, 1) + + var txHash string + require.NoError(t, json.Unmarshal(txObjs[0]["hash"], &txHash)) + assert.Equal(t, fx.ethTxHash, txHash) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(txObjs[0]["type"], &txType)) + assert.Equal(t, uint64(tx.TypeEthDynamicFee), uint64(txType)) + + // Projected ETH index: the ETH tx is at canonical position 1 but it is + // the first (and only) ETH tx, so its projected index is 0. + var txIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(txObjs[0]["transactionIndex"], &txIdx)) + assert.Equal(t, uint64(0), uint64(txIdx)) + }) + + t.Run("eth_getBlockByHash", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByHash", []any{fx.blockHash, false}) + var blk map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &blk)) + + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(blk["number"], &num)) + assert.Equal(t, uint64(1), uint64(num)) + + var gotHash string + require.NoError(t, json.Unmarshal(blk["hash"], &gotHash)) + assert.Equal(t, fx.blockHash, gotHash) + }) + + t.Run("eth_getBlockByHash_notfound", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByHash", []any{"0x0000000000000000000000000000000000000000000000000000000000000001", false}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getBlockTransactionCountByNumber", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockTransactionCountByNumber", []any{"latest"}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(1), uint64(got)) // one ETH tx in the block + }) + + t.Run("eth_getBlockTransactionCountByNumber_notfound", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockTransactionCountByNumber", []any{"0xffff"}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getBlockTransactionCountByHash", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockTransactionCountByHash", []any{fx.blockHash}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(1), uint64(got)) + }) + + t.Run("eth_getBlockTransactionCountByHash_notfound", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockTransactionCountByHash", []any{"0x0000000000000000000000000000000000000000000000000000000000000001"}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getBlockReceipts", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockReceipts", []any{"latest"}) + var receipts []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &receipts)) + require.Len(t, receipts, 1) // one ETH tx receipt + var txHash string + require.NoError(t, json.Unmarshal(receipts[0]["transactionHash"], &txHash)) + assert.Equal(t, fx.ethTxHash, txHash) + }) + + t.Run("eth_getBlockReceipts_notfound", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockReceipts", []any{"0xffff"}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getBlockReceipts_empty", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockReceipts", []any{"earliest"}) + var receipts []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &receipts)) + assert.Empty(t, receipts) + }) + + t.Run("eth_getUncleCountByBlockNumber", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getUncleCountByBlockNumber", []any{"latest"}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(0), uint64(got)) + }) + + t.Run("eth_getUncleCountByBlockNumber_notfound", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getUncleCountByBlockNumber", []any{"0xffff"}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getUncleCountByBlockHash", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getUncleCountByBlockHash", []any{fx.blockHash}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(0), uint64(got)) + }) + + t.Run("eth_getUncleByBlockHashAndIndex", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getUncleByBlockHashAndIndex", []any{fx.blockHash, "0x0"}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getUncleByBlockNumberAndIndex", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getUncleByBlockNumberAndIndex", []any{"latest", "0x0"}) + assert.Equal(t, "null", string(result)) + }) +} + +// TestBlocksBloomAndRoots verifies that LogsBloom, TransactionsRoot and ReceiptsRoot +// are correctly populated for blocks containing ETH typed transactions. +func TestBlocksBloomAndRoots(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + // Block 1: ETH call to the Energy (VTHO) contract, which emits a Transfer event. + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + ts := testutil.NewTestServer(t, blocks.New(c.Repo())) + + t.Run("genesis_has_empty_trie_roots_and_zero_bloom", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"0x0", false}) + var blk map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &blk)) + + var txRoot, recRoot common.Hash + require.NoError(t, json.Unmarshal(blk["transactionsRoot"], &txRoot)) + require.NoError(t, json.Unmarshal(blk["receiptsRoot"], &recRoot)) + assert.Equal(t, ethtypes.EmptyRootHash, txRoot, "genesis transactionsRoot should be Ethereum empty trie root") + assert.Equal(t, ethtypes.EmptyRootHash, recRoot, "genesis receiptsRoot should be Ethereum empty trie root") + + var logsBloom hexutil.Bytes + require.NoError(t, json.Unmarshal(blk["logsBloom"], &logsBloom)) + assert.Equal(t, make([]byte, 256), []byte(logsBloom), "genesis logsBloom should be all zeros") + }) + + t.Run("event_block_bloom_contains_energy_address", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"latest", false}) + var blk map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &blk)) + + // logsBloom must be non-zero: Energy.transfer emits a Transfer event. + var logsBloom hexutil.Bytes + require.NoError(t, json.Unmarshal(blk["logsBloom"], &logsBloom)) + require.Len(t, logsBloom, 256) + assert.NotEqual(t, make([]byte, 256), []byte(logsBloom), "logsBloom should be non-zero for a block with ETH event logs") + + // The bloom must contain the Energy contract address. + var bloom256 [256]byte + copy(bloom256[:], logsBloom) + ethBloom := ethtypes.BytesToBloom(bloom256[:]) + assert.True(t, ethtypes.BloomLookup(ethBloom, common.Address(builtin.Energy.Address)), "block bloom should contain Energy contract address") + + // transactionsRoot and receiptsRoot must be non-zero and not the empty trie root. + var txRoot, recRoot common.Hash + require.NoError(t, json.Unmarshal(blk["transactionsRoot"], &txRoot)) + require.NoError(t, json.Unmarshal(blk["receiptsRoot"], &recRoot)) + assert.NotEqual(t, (common.Hash{}), txRoot) + assert.NotEqual(t, ethtypes.EmptyRootHash, txRoot, "transactionsRoot should not be empty trie root when block has ETH txs") + assert.NotEqual(t, (common.Hash{}), recRoot) + assert.NotEqual(t, ethtypes.EmptyRootHash, recRoot, "receiptsRoot should not be empty trie root when block has ETH txs") + }) +} diff --git a/rpc/blocks_types.go b/rpc/blocks_types.go new file mode 100644 index 0000000000..447e02ca85 --- /dev/null +++ b/rpc/blocks_types.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "encoding/json" + "fmt" +) + +// BlockQueryParams holds the parameters for eth_getBlockByHash and eth_getBlockByNumber. +type BlockQueryParams struct { + Tag string + FullTxs bool +} + +func (p *BlockQueryParams) UnmarshalJSON(data []byte) error { + var raw [2]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("expected [blockTag, fullTransactions]") + } + if err := json.Unmarshal(raw[0], &p.Tag); err != nil { + return fmt.Errorf("invalid block tag") + } + if err := json.Unmarshal(raw[1], &p.FullTxs); err != nil { + return fmt.Errorf("invalid fullTransactions flag") + } + return nil +} + +// BlockTagParams holds a single block tag parameter, used by methods that accept +// only a block identifier (hash, number, or tag such as "latest"). +type BlockTagParams struct { + Tag string +} + +func (p *BlockTagParams) UnmarshalJSON(data []byte) error { + var raw [1]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("expected [blockTag]") + } + if err := json.Unmarshal(raw[0], &p.Tag); err != nil { + return fmt.Errorf("invalid block tag") + } + return nil +} diff --git a/rpc/chain/handler.go b/rpc/chain/handler.go new file mode 100644 index 0000000000..41188ae5a6 --- /dev/null +++ b/rpc/chain/handler.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package chain + +import ( + "strconv" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc/jsonrpc" +) + +// Handler implements chain metadata JSON-RPC methods. +type Handler struct { + repo *chain.Repository + clientVersion string +} + +// New creates a chain Handler. +func New(repo *chain.Repository, clientVersion string) *Handler { + return &Handler{repo: repo, clientVersion: clientVersion} +} + +// Mount registers all chain metadata methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_chainId", h.ethChainID) + s.Register("net_version", h.netVersion) + s.Register("net_listening", func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, true) }) + s.Register( + "net_peerCount", + func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, hexutil.Uint64(0)) }, + ) // VeChain PoA has no mining peers + s.Register("web3_clientVersion", h.web3ClientVersion) + s.Register("eth_blockNumber", h.ethBlockNumber) + s.Register("eth_coinbase", h.ethCoinbase) + s.Register("eth_syncing", func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, false) }) + s.Register("eth_accounts", func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, []string{}) }) + s.Register("eth_mining", func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, false) }) + s.Register("eth_hashrate", func(req jsonrpc.Request) jsonrpc.Response { return jsonrpc.OkResponse(req.ID, hexutil.Uint64(0)) }) +} + +func (h *Handler) ethChainID(req jsonrpc.Request) jsonrpc.Response { + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(h.repo.ChainID())) +} + +func (h *Handler) netVersion(req jsonrpc.Request) jsonrpc.Response { + return jsonrpc.OkResponse(req.ID, strconv.FormatUint(h.repo.ChainID(), 10)) +} + +func (h *Handler) web3ClientVersion(req jsonrpc.Request) jsonrpc.Response { + return jsonrpc.OkResponse(req.ID, "Thor/"+h.clientVersion) +} + +func (h *Handler) ethBlockNumber(req jsonrpc.Request) jsonrpc.Response { + num := h.repo.BestBlockSummary().Header.Number() + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(num)) +} + +func (h *Handler) ethCoinbase(req jsonrpc.Request) jsonrpc.Response { + return jsonrpc.OkResponse(req.ID, common.Address{}) +} diff --git a/rpc/chain/handler_test.go b/rpc/chain/handler_test.go new file mode 100644 index 0000000000..ea4b1ae440 --- /dev/null +++ b/rpc/chain/handler_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package chain_test + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/rpc/chain" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" +) + +type fixture struct { + chain *testchain.Chain +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + require.NoError(t, c.MintBlock()) + return &fixture{ + chain: c, + } +} + +func TestChainHandler(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, chain.New(fx.chain.Repo(), "test/1.0")) + + t.Run("eth_chainId", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_chainId", []any{}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, fx.chain.ChainID(), uint64(got)) + }) + + t.Run("net_version", func(t *testing.T) { + // net_version returns the chain ID as a decimal string. + result := testutil.Call(t, ts, "net_version", []any{}) + var got string + require.NoError(t, json.Unmarshal(result, &got)) + assert.NotEmpty(t, got) + }) + + t.Run("web3_clientVersion", func(t *testing.T) { + result := testutil.Call(t, ts, "web3_clientVersion", []any{}) + var got string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, "Thor/test/1.0", got) + }) + + t.Run("eth_blockNumber", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_blockNumber", []any{}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(1), uint64(got)) + }) + + t.Run("eth_syncing", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_syncing", []any{}) + var got bool + require.NoError(t, json.Unmarshal(result, &got)) + assert.False(t, got) + }) + + t.Run("eth_accounts", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_accounts", []any{}) + var got []string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got) + }) + + t.Run("eth_mining", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_mining", []any{}) + var got bool + require.NoError(t, json.Unmarshal(result, &got)) + assert.False(t, got) + }) + + t.Run("eth_hashrate", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_hashrate", []any{}) + var got string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, "0x0", got) + }) + + t.Run("net_listening", func(t *testing.T) { + result := testutil.Call(t, ts, "net_listening", []any{}) + var got bool + require.NoError(t, json.Unmarshal(result, &got)) + assert.True(t, got) + }) + + t.Run("net_peerCount", func(t *testing.T) { + result := testutil.Call(t, ts, "net_peerCount", []any{}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(0), uint64(got)) + }) + + t.Run("eth_coinbase", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_coinbase", []any{}) + var got string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, "0x0000000000000000000000000000000000000000", got) + }) +} diff --git a/rpc/eth_types.go b/rpc/eth_types.go new file mode 100644 index 0000000000..5dfbce3ce4 --- /dev/null +++ b/rpc/eth_types.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// EthBlock is the Ethereum JSON representation of a block. +// Only TypeEthDynamicFee transactions are included in the transactions field. +type EthBlock struct { + Number hexutil.Uint64 `json:"number"` + Hash common.Hash `json:"hash"` + ParentHash common.Hash `json:"parentHash"` + // Nonce is always zero — VeChain uses PoA, not PoW. + Nonce hexutil.Bytes `json:"nonce"` + // Sha3Uncles is the empty uncle hash — VeChain has no uncles. + Sha3Uncles common.Hash `json:"sha3Uncles"` + // LogsBloom is the OR of all receipt blooms for ETH-typed transactions in this block. + LogsBloom hexutil.Bytes `json:"logsBloom"` + // TransactionsRoot is the Keccak256 MPT root over the projected ETH transaction list. + TransactionsRoot common.Hash `json:"transactionsRoot"` + StateRoot common.Hash `json:"stateRoot"` + // ReceiptsRoot is the Keccak256 MPT root over the projected ETH receipt list. + ReceiptsRoot common.Hash `json:"receiptsRoot"` + // Miner is the block beneficiary declared in the VeChain block header. + Miner common.Address `json:"miner"` + Difficulty hexutil.Big `json:"difficulty"` // always zero (PoA) + TotalDifficulty hexutil.Big `json:"totalDifficulty"` // always zero (PoA) + ExtraData hexutil.Bytes `json:"extraData"` + Size hexutil.Uint64 `json:"size"` + GasLimit hexutil.Uint64 `json:"gasLimit"` + // GasUsed is the sum of gas used by TypeEthDynamicFee transactions only. + GasUsed hexutil.Uint64 `json:"gasUsed"` + Timestamp hexutil.Uint64 `json:"timestamp"` + // BaseFeePerGas is omitted for pre-GALACTICA blocks (nil BaseFee on header). + BaseFeePerGas *hexutil.Big `json:"baseFeePerGas,omitempty"` + // Transactions is either []common.Hash (fullTx=false) or []*EthTx (fullTx=true). + Transactions any `json:"transactions"` + Uncles []common.Hash `json:"uncles"` +} + +// EthTx is the Ethereum JSON representation of a TypeEthDynamicFee transaction. +type EthTx struct { + BlockHash *common.Hash `json:"blockHash"` + BlockNumber *hexutil.Uint64 `json:"blockNumber"` + From common.Address `json:"from"` + Gas hexutil.Uint64 `json:"gas"` + GasPrice *hexutil.Big `json:"gasPrice"` + MaxFeePerGas *hexutil.Big `json:"maxFeePerGas"` + MaxPriorityFeePerGas *hexutil.Big `json:"maxPriorityFeePerGas"` + Hash common.Hash `json:"hash"` + Input hexutil.Bytes `json:"input"` + Nonce hexutil.Uint64 `json:"nonce"` + To *common.Address `json:"to"` + TransactionIndex *hexutil.Uint64 `json:"transactionIndex"` + Value *hexutil.Big `json:"value"` + Type hexutil.Uint64 `json:"type"` + ChainID *hexutil.Big `json:"chainId"` + V *hexutil.Big `json:"v"` + R *hexutil.Big `json:"r"` + S *hexutil.Big `json:"s"` +} + +// EthLog is the Ethereum JSON representation of a contract event log. +type EthLog struct { + Address common.Address `json:"address"` + Topics []common.Hash `json:"topics"` + Data hexutil.Bytes `json:"data"` + BlockNumber hexutil.Uint64 `json:"blockNumber"` + TxHash common.Hash `json:"transactionHash"` + TxIndex hexutil.Uint64 `json:"transactionIndex"` + BlockHash common.Hash `json:"blockHash"` + LogIndex hexutil.Uint64 `json:"logIndex"` + Removed bool `json:"removed"` +} + +// EthReceipt is the Ethereum JSON representation of a TypeEthDynamicFee transaction receipt. +type EthReceipt struct { + TransactionHash common.Hash `json:"transactionHash"` + TransactionIndex hexutil.Uint64 `json:"transactionIndex"` + BlockHash common.Hash `json:"blockHash"` + BlockNumber hexutil.Uint64 `json:"blockNumber"` + From common.Address `json:"from"` + To *common.Address `json:"to"` + GasUsed hexutil.Uint64 `json:"gasUsed"` + CumulativeGasUsed hexutil.Uint64 `json:"cumulativeGasUsed"` + ContractAddress *common.Address `json:"contractAddress"` + Logs []*EthLog `json:"logs"` + // LogsBloom is computed from the ETH-typed transaction's event logs (bloom9 over address and topics). + LogsBloom hexutil.Bytes `json:"logsBloom"` + // Status: 1 = success, 0 = reverted. + Status hexutil.Uint64 `json:"status"` + // Type is always 2 (EIP-1559). + Type hexutil.Uint64 `json:"type"` + EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` +} diff --git a/rpc/ethconvert/eth_trie_test.go b/rpc/ethconvert/eth_trie_test.go new file mode 100644 index 0000000000..095223cb61 --- /dev/null +++ b/rpc/ethconvert/eth_trie_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package ethconvert + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/vechain/thor/v2/rpc" +) + +func TestEthLogsBloom_empty(t *testing.T) { + bloom := ethLogsBloom(nil) + require.Len(t, bloom, 256) + assert.Equal(t, make([]byte, 256), []byte(bloom)) + + bloom2 := ethLogsBloom([]*rpc.EthLog{}) + assert.Equal(t, make([]byte, 256), []byte(bloom2)) +} + +// TestEthLogsBloom_crossCheck verifies our bloom9 implementation matches +// go-ethereum's types.LogsBloom for the same log entries. +func TestEthLogsBloom_crossCheck(t *testing.T) { + // ERC-20 Transfer(address indexed from, address indexed to, uint256 value) + transferTopic := common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef") + contractAddr := common.HexToAddress("0x0000000000000000000000000000000000000abc") + fromAddr := common.HexToAddress("0x1111111111111111111111111111111111111111") + toAddr := common.HexToAddress("0x2222222222222222222222222222222222222222") + fromTopic := common.BytesToHash(fromAddr.Bytes()) + toTopic := common.BytesToHash(toAddr.Bytes()) + + ethLog := &rpc.EthLog{ + Address: contractAddr, + Topics: []common.Hash{transferTopic, fromTopic, toTopic}, + Data: []byte{}, + } + + // Reference: go-ethereum types.LogsBloom + gethLog := ðtypes.Log{ + Address: contractAddr, + Topics: []common.Hash{transferTopic, fromTopic, toTopic}, + Data: []byte{}, + } + gethBin := ethtypes.LogsBloom([]*ethtypes.Log{gethLog}) + expected := make([]byte, 256) + b := gethBin.Bytes() + copy(expected[256-len(b):], b) + + got := ethLogsBloom([]*rpc.EthLog{ethLog}) + assert.Equal(t, expected, []byte(got), "bloom must match go-ethereum reference") + + // Verify the bloom contains the expected entries via BloomLookup. + var bloom256 [256]byte + copy(bloom256[:], got) + ethBloom := ethtypes.BytesToBloom(bloom256[:]) + assert.True(t, ethtypes.BloomLookup(ethBloom, contractAddr), "bloom should contain contract address") + assert.True(t, ethtypes.BloomLookup(ethBloom, transferTopic), "bloom should contain Transfer topic") +} + +func TestEthTransactionsRoot_empty(t *testing.T) { + root := ethTransactionsRoot(nil) + assert.Equal(t, ethtypes.EmptyRootHash, root, "empty tx list must produce Ethereum empty trie root") +} + +func TestEthReceiptsRoot_empty(t *testing.T) { + root := ethReceiptsRoot(nil) + assert.Equal(t, ethtypes.EmptyRootHash, root, "empty receipt list must produce Ethereum empty trie root") +} + +func TestEthReceiptWireBytes(t *testing.T) { + bloom := make([]byte, 256) + bloom[255] = 0x01 // one non-zero bit + + rec := &rpc.EthReceipt{ + Status: 1, + CumulativeGasUsed: 21000, + LogsBloom: bloom, + Logs: []*rpc.EthLog{}, + } + + b := ethReceiptWireBytes(rec) + require.Greater(t, len(b), 1) + assert.Equal(t, byte(0x02), b[0], "first byte must be the EIP-1559 receipt type 0x02") + + // Status 0 (reverted) must produce a different encoding. + rec.Status = 0 + bReverted := ethReceiptWireBytes(rec) + assert.Equal(t, byte(0x02), bReverted[0]) + assert.NotEqual(t, b, bReverted, "success and reverted receipts must encode differently") +} diff --git a/rpc/ethconvert/eth_types.go b/rpc/ethconvert/eth_types.go new file mode 100644 index 0000000000..2db6e268fa --- /dev/null +++ b/rpc/ethconvert/eth_types.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package ethconvert + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/tx" +) + +// emptyUncleHash is the Keccak256 hash of an empty RLP list, used as sha3Uncles when +// there are no uncle blocks (always the case for VeChain). +var emptyUncleHash = common.HexToHash("0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347") + +// zeroNonce is an 8-byte zero block nonce — VeChain uses PoA, not PoW. +var zeroNonce = make(hexutil.Bytes, 8) + +// ethBloom9 sets 3 bits in a 2048-bit (256-byte) Bloom filter for the given byte slice, +// following the Ethereum Yellow Paper Appendix H algorithm (EIP-2981). +func ethBloom9(b []byte) *big.Int { + b = crypto.Keccak256(b) + r := new(big.Int) + for i := 0; i < 6; i += 2 { + t := big.NewInt(1) + bit := (uint(b[i+1]) + (uint(b[i]) << 8)) & 2047 + r.Or(r, t.Lsh(t, bit)) + } + return r +} + +// ethLogsBloom computes the 256-byte Ethereum bloom filter for a slice of logs. +// It ORs the bloom contribution of each log's address and topics. +func ethLogsBloom(logs []*rpc.EthLog) hexutil.Bytes { + bin := new(big.Int) + for _, log := range logs { + bin.Or(bin, ethBloom9(log.Address.Bytes())) + for _, topic := range log.Topics { + bin.Or(bin, ethBloom9(topic[:])) + } + } + bloom := make(hexutil.Bytes, 256) + b := bin.Bytes() + copy(bloom[256-len(b):], b) + return bloom +} + +// ToEthTx converts a TypeEthDynamicFee transaction to the Ethereum JSON representation. +// projectedIdx is the 0-based index within the ETH-only transaction subsequence of the block. +// baseFee is the block base fee used to compute effectiveGasPrice; nil is allowed (pre-GALACTICA). +func ToEthTx(t *tx.Transaction, chainID uint64, blockHash common.Hash, blockNum uint64, projectedIdx uint64, baseFee *big.Int) *rpc.EthTx { + origin, _ := t.Origin() + clauses := t.Clauses() + + var to *common.Address + if clauses[0].To() != nil { + addr := common.Address(*clauses[0].To()) + to = &addr + } + + // EIP-1559 signature layout: [R(32) || S(32) || yParity(1)] + sig := t.Signature() + r := new(big.Int).SetBytes(sig[0:32]) + s := new(big.Int).SetBytes(sig[32:64]) + v := new(big.Int).SetUint64(uint64(sig[64])) // yParity: 0 or 1 + + maxFee := t.MaxFeePerGas() + gasPrice := CalcEffectiveGasPrice(maxFee, t.MaxPriorityFeePerGas(), baseFee) + + num := hexutil.Uint64(blockNum) + idx := hexutil.Uint64(projectedIdx) + bh := blockHash + + return &rpc.EthTx{ + BlockHash: &bh, + BlockNumber: &num, + From: common.Address(origin), + Gas: hexutil.Uint64(t.Gas()), + GasPrice: (*hexutil.Big)(gasPrice), + MaxFeePerGas: (*hexutil.Big)(maxFee), + MaxPriorityFeePerGas: (*hexutil.Big)(t.MaxPriorityFeePerGas()), + Hash: common.Hash(t.ID()), + Input: clauses[0].Data(), + Nonce: hexutil.Uint64(t.Nonce()), + To: to, + TransactionIndex: &idx, + Value: (*hexutil.Big)(new(big.Int).Set(clauses[0].Value())), + Type: hexutil.Uint64(tx.TypeEthDynamicFee), + ChainID: (*hexutil.Big)(new(big.Int).SetUint64(chainID)), + V: (*hexutil.Big)(v), + R: (*hexutil.Big)(r), + S: (*hexutil.Big)(s), + } +} + +// ToEthReceipt builds an Ethereum receipt for a TypeEthDynamicFee transaction. +// +// projectedIdx — 0-based index within the ETH-only transaction subsequence of the block. +// cumulativeGas — cumulative gas used by ETH txs in this block up to and including this tx. +// logIndexOffset — number of logs emitted by ETH txs before this tx in the block. +// baseFee — block base fee; nil is allowed (pre-GALACTICA). +func ToEthReceipt( + t *tx.Transaction, + receipt *tx.Receipt, + blockHash common.Hash, + blockNum uint64, + projectedIdx uint64, + cumulativeGas uint64, + logIndexOffset uint64, + baseFee *big.Int, +) *rpc.EthReceipt { + origin, _ := t.Origin() + clauses := t.Clauses() + + var to *common.Address + if clauses[0].To() != nil { + addr := common.Address(*clauses[0].To()) + to = &addr + } + + // contractAddress is re-derived for CREATE transactions (To == nil). + // EIP-1559 CREATE always uses crypto.CreateAddress(sender, nonce). + var contractAddress *common.Address + if to == nil { + addr := crypto.CreateAddress(common.Address(origin), t.Nonce()) + contractAddress = &addr + } + + status := hexutil.Uint64(1) + if receipt.Reverted { + status = 0 + } + + effectiveGasPrice := CalcEffectiveGasPrice(t.MaxFeePerGas(), t.MaxPriorityFeePerGas(), baseFee) + + txHash := common.Hash(t.ID()) + txIdx := hexutil.Uint64(projectedIdx) + + var logs []*rpc.EthLog + if len(receipt.Outputs) > 0 { + for i, event := range receipt.Outputs[0].Events { + topics := make([]common.Hash, len(event.Topics)) + for j, tp := range event.Topics { + topics[j] = common.Hash(tp) + } + logs = append(logs, &rpc.EthLog{ + Address: common.Address(event.Address), + Topics: topics, + Data: event.Data, + BlockNumber: hexutil.Uint64(blockNum), + TxHash: txHash, + TxIndex: txIdx, + BlockHash: blockHash, + LogIndex: hexutil.Uint64(logIndexOffset + uint64(i)), + Removed: false, + }) + } + } + if logs == nil { + logs = []*rpc.EthLog{} + } + + return &rpc.EthReceipt{ + TransactionHash: txHash, + TransactionIndex: txIdx, + BlockHash: blockHash, + BlockNumber: hexutil.Uint64(blockNum), + From: common.Address(origin), + To: to, + GasUsed: hexutil.Uint64(receipt.GasUsed), + CumulativeGasUsed: hexutil.Uint64(cumulativeGas), + ContractAddress: contractAddress, + Logs: logs, + LogsBloom: ethLogsBloom(logs), + Status: status, + Type: hexutil.Uint64(tx.TypeEthDynamicFee), + EffectiveGasPrice: (*hexutil.Big)(effectiveGasPrice), + } +} diff --git a/rpc/ethconvert/log_criteria.go b/rpc/ethconvert/log_criteria.go new file mode 100644 index 0000000000..97be62f47c --- /dev/null +++ b/rpc/ethconvert/log_criteria.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package ethconvert + +import ( + "encoding/json" + "fmt" + "slices" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// LogCriteria is the parsed form of a log filter for fast per-event matching +// during incremental block scanning. Only TypeEthDynamicFee transaction events +// are matched. topics[i] holds all accepted values for position i; empty means +// wildcard. Adjacent positions are ANDed; alternatives within one position are ORed. +type LogCriteria struct { + Addresses []thor.Address + Topics [5][]thor.Bytes32 +} + +func (c *LogCriteria) matchesEvent(e *tx.Event) bool { + if len(c.Addresses) > 0 && !slices.Contains(c.Addresses, e.Address) { + return false + } + for i, alts := range c.Topics { + if len(alts) == 0 { + continue // wildcard + } + if i >= len(e.Topics) { + return false + } + if !slices.Contains(alts, e.Topics[i]) { + return false + } + } + return true +} + +// ParseLogCriteria parses the address and topic fields from an EthLogFilter into a LogCriteria. +func ParseLogCriteria(f rpc.EthLogFilter) (LogCriteria, error) { + var c LogCriteria + + if len(f.Address) > 0 && string(f.Address) != "null" { + var single string + var multi []string + if err := json.Unmarshal(f.Address, &single); err == nil { + addr, err := thor.ParseAddress(single) + if err != nil { + return c, fmt.Errorf("invalid address: %w", err) + } + c.Addresses = append(c.Addresses, addr) + } else if err := json.Unmarshal(f.Address, &multi); err == nil { + for _, s := range multi { + addr, err := thor.ParseAddress(s) + if err != nil { + return c, fmt.Errorf("invalid address: %w", err) + } + c.Addresses = append(c.Addresses, addr) + } + } + } + + topics := f.Topics + if len(topics) > len(c.Topics) { + topics = topics[:len(c.Topics)] + } + for i, raw := range topics { + if raw == nil || string(raw) == "null" { + continue + } + var single string + var multi []string + if err := json.Unmarshal(raw, &single); err == nil { + h32, err := rpc.ParseBytes32Compact(single) + if err != nil { + return c, fmt.Errorf("invalid topic: %w", err) + } + c.Topics[i] = []thor.Bytes32{h32} + } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { + alts := make([]thor.Bytes32, 0, len(multi)) + for _, s := range multi { + h32, err := rpc.ParseBytes32Compact(s) + if err != nil { + return c, fmt.Errorf("invalid topic: %w", err) + } + alts = append(alts, h32) + } + c.Topics[i] = alts + } + } + return c, nil +} + +// CollectMatchingLogs scans ETH-typed transactions in a single block and returns rpc.EthLog +// entries matching the criteria. Projected transactionIndex and logIndex are relative to +// ETH-typed transactions only, consistent with eth_getTransactionByHash etc. +// Pass removed=true for blocks from a reorg (Obsolete=true) to set Removed on each log. +func CollectMatchingLogs(criteria *LogCriteria, txs tx.Transactions, receipts tx.Receipts, blockHash common.Hash, blockNum uint64, removed bool) []*rpc.EthLog { + var logs []*rpc.EthLog + var projEthIdx uint64 + var projLogIdx uint64 + + for i, t := range txs { + if t.Type() != tx.TypeEthDynamicFee { + continue + } + receipt := receipts[i] + if len(receipt.Outputs) > 0 { + for j, event := range receipt.Outputs[0].Events { + if criteria.matchesEvent(event) { + topics := make([]common.Hash, len(event.Topics)) + for k, tp := range event.Topics { + topics[k] = common.Hash(tp) + } + logs = append(logs, &rpc.EthLog{ + Address: common.Address(event.Address), + Topics: topics, + Data: event.Data, + BlockNumber: hexutil.Uint64(blockNum), + TxHash: common.Hash(t.ID()), + TxIndex: hexutil.Uint64(projEthIdx), + BlockHash: blockHash, + LogIndex: hexutil.Uint64(projLogIdx + uint64(j)), + Removed: removed, + }) + } + } + projLogIdx += uint64(len(receipt.Outputs[0].Events)) + } + projEthIdx++ + } + return logs +} diff --git a/rpc/ethconvert/utils.go b/rpc/ethconvert/utils.go new file mode 100644 index 0000000000..d6b29dad3b --- /dev/null +++ b/rpc/ethconvert/utils.go @@ -0,0 +1,319 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +// Package ethconvert provides functions that convert VeChain-internal types to their +// Ethereum JSON-RPC equivalents. It is the shared conversion layer used by all +// rpc sub-package handlers — analogous to api/restutil in the REST API. +package ethconvert + +import ( + "bytes" + "encoding/hex" + "fmt" + "math/big" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rlp" + + ethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/state" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// ResolveBlockTag maps an Ethereum block tag, hex block number, or block hash to +// a block summary in the canonical chain. The returned summary carries the +// versioned trie.Root needed for correct state access — always use summary.Root() +// rather than trie.Root{Hash: header.StateRoot()} when opening a state. +// +// Supported tags: "latest", "earliest", "pending", "safe", "finalized". +// Numeric strings: "0x1" → block number 1. +// Hash strings (66 chars, "0x" + 64 hex digits): resolved directly by hash. +// +// "pending", "safe", and "finalized" are treated as "latest" in Phase 1. +// Returns an error for unrecognised or invalid tags. Callers map errors to a +// null JSON response, matching Ethereum's "block not found → null" convention. +func ResolveBlockTag(tag string, repo *chain.Repository) (*chain.BlockSummary, error) { + switch strings.ToLower(tag) { + case "", "latest", "pending", "safe", "finalized": + // NOTE: "pending" returns confirmed state. Full pool scanning is not implemented. + return repo.BestBlockSummary(), nil + case "earliest": + id := repo.GenesisBlock().Header().ID() + return repo.GetBlockSummary(id) + } + + // 32-byte hash (0x + 64 hex chars = 66 chars) + if strings.HasPrefix(tag, "0x") && len(tag) == 66 { + var id thor.Bytes32 + b, err := hex.DecodeString(tag[2:]) + if err != nil { + return nil, fmt.Errorf("invalid block hash %q: %w", tag, err) + } + copy(id[:], b) + summary, err := repo.GetBlockSummary(id) + if err != nil { + return nil, fmt.Errorf("block not found: %w", err) + } + return summary, nil + } + + // Hex block number + if strings.HasPrefix(tag, "0x") { + n, err := strconv.ParseUint(tag[2:], 16, 32) + if err != nil { + return nil, fmt.Errorf("invalid block number %q: %w", tag, err) + } + summary, err := repo.NewBestChain().GetBlockSummary(uint32(n)) + if err != nil { + return nil, fmt.Errorf("block %d not found: %w", n, err) + } + return summary, nil + } + + return nil, fmt.Errorf("unsupported block tag %q", tag) +} + +// StateAt opens the state at the block identified by tag. +func StateAt(tag string, repo *chain.Repository, stater *state.Stater) (*state.State, error) { + summary, err := ResolveBlockTag(tag, repo) + if err != nil { + return nil, err + } + return stater.NewState(summary.Root()), nil +} + +// BuildEthBlock constructs an rpc.EthBlock from a VeChain block header. +// Only TypeEthDynamicFee transactions are included in the transactions field. +func BuildEthBlock( + header *block.Header, + repo *chain.Repository, + fullTxs bool, +) (*rpc.EthBlock, error) { + blk, err := repo.GetBlock(header.ID()) + if err != nil { + return nil, err + } + receipts, err := repo.GetBlockReceipts(header.ID()) + if err != nil { + return nil, err + } + + txs := blk.Transactions() + blockHash := common.Hash(header.ID()) + blockNum := uint64(header.Number()) + + var ( + ethTxHashes []common.Hash + ethTxFull []*rpc.EthTx + ethGasUsed uint64 + ethProjIdx uint64 + logOffset uint64 + ethTxsForRoot []*tx.Transaction + ethRecsForRoot []*rpc.EthReceipt + blockBloom [256]byte + ) + + baseFee := header.BaseFee() + + for i, t := range txs { + if t.Type() != tx.TypeEthDynamicFee { + continue + } + ethGasUsed += receipts[i].GasUsed + + rec := ToEthReceipt(t, receipts[i], blockHash, blockNum, ethProjIdx, ethGasUsed, logOffset, baseFee) + logOffset += uint64(len(rec.Logs)) + + // OR this receipt's bloom into the block-level bloom. + for j, b := range rec.LogsBloom { + blockBloom[j] |= b + } + + ethTxsForRoot = append(ethTxsForRoot, t) + ethRecsForRoot = append(ethRecsForRoot, rec) + + if fullTxs { + ethTxFull = append(ethTxFull, ToEthTx(t, repo.ChainID(), blockHash, blockNum, ethProjIdx, baseFee)) + } else { + ethTxHashes = append(ethTxHashes, common.Hash(t.ID())) + } + ethProjIdx++ + } + + var transactions any + if fullTxs { + if ethTxFull == nil { + ethTxFull = []*rpc.EthTx{} + } + transactions = ethTxFull + } else { + if ethTxHashes == nil { + ethTxHashes = []common.Hash{} + } + transactions = ethTxHashes + } + + var baseFeePerGas *hexutil.Big + if baseFee != nil { + baseFeePerGas = (*hexutil.Big)(baseFee) + } + + return &rpc.EthBlock{ + Number: hexutil.Uint64(blockNum), + Hash: blockHash, + ParentHash: common.Hash(header.ParentID()), + Nonce: zeroNonce, + Sha3Uncles: emptyUncleHash, + LogsBloom: blockBloom[:], + TransactionsRoot: ethTransactionsRoot(ethTxsForRoot), + StateRoot: common.Hash(header.StateRoot()), + ReceiptsRoot: ethReceiptsRoot(ethRecsForRoot), + Miner: common.Address(header.Beneficiary()), + ExtraData: []byte{}, + Size: hexutil.Uint64(blk.Size()), + GasLimit: hexutil.Uint64(header.GasLimit()), + GasUsed: hexutil.Uint64(ethGasUsed), + Timestamp: hexutil.Uint64(header.Timestamp()), + BaseFeePerGas: baseFeePerGas, + Transactions: transactions, + Uncles: []common.Hash{}, + }, nil +} + +// rlpLogEntry is the consensus RLP encoding of an event log: only address, topics, data. +type rlpLogEntry struct { + Address common.Address + Topics []common.Hash + Data []byte +} + +// rlpReceiptBody is the consensus encoding of an EIP-1559 (type 2) receipt. +type rlpReceiptBody struct { + PostStateOrStatus []byte + CumulativeGasUsed uint64 + Bloom [256]byte + Logs []rlpLogEntry +} + +// ethReceiptWireBytes encodes an rpc.EthReceipt as the EIP-2718 type-2 consensus bytes: +// 0x02 || RLP(status, cumulativeGasUsed, bloom, logs). +func ethReceiptWireBytes(rec *rpc.EthReceipt) []byte { + status := []byte{0x01} + if rec.Status == 0 { + status = []byte{} + } + + var bloom [256]byte + copy(bloom[:], rec.LogsBloom) + + logs := make([]rlpLogEntry, len(rec.Logs)) + for i, log := range rec.Logs { + logs[i] = rlpLogEntry{Address: log.Address, Topics: log.Topics, Data: log.Data} + } + + body := rlpReceiptBody{ + PostStateOrStatus: status, + CumulativeGasUsed: uint64(rec.CumulativeGasUsed), + Bloom: bloom, + Logs: logs, + } + + var buf bytes.Buffer + buf.WriteByte(0x02) // EIP-1559 receipt type byte + if err := rlp.Encode(&buf, body); err != nil { + panic(err) // only fails on unencodable types, which rlpReceiptBody is not + } + return buf.Bytes() +} + +// ethTxDerivableList wraps []*tx.Transaction for use with ethtypes.DeriveSha. +// GetRlp returns the EIP-2718 wire bytes (0x02 || RLP body) for each tx. +type ethTxDerivableList []*tx.Transaction + +func (l ethTxDerivableList) Len() int { return len(l) } +func (l ethTxDerivableList) GetRlp(i int) []byte { + b, err := l[i].MarshalBinary() + if err != nil { + panic(err) + } + return b +} + +// ethReceiptDerivableList wraps []*rpc.EthReceipt for use with ethtypes.DeriveSha. +type ethReceiptDerivableList []*rpc.EthReceipt + +func (l ethReceiptDerivableList) Len() int { return len(l) } +func (l ethReceiptDerivableList) GetRlp(i int) []byte { return ethReceiptWireBytes(l[i]) } + +// ethTransactionsRoot computes the Ethereum Keccak256 MPT root over the EIP-1559 +// encoded wire bytes of the given ETH transactions (projected tx index as trie key). +func ethTransactionsRoot(txs []*tx.Transaction) common.Hash { + return ethtypes.DeriveSha(ethTxDerivableList(txs)) +} + +// ethReceiptsRoot computes the Ethereum Keccak256 MPT root over the EIP-1559 +// encoded consensus bytes of the given ETH receipts. +func ethReceiptsRoot(recs []*rpc.EthReceipt) common.Hash { + return ethtypes.DeriveSha(ethReceiptDerivableList(recs)) +} + +// ProjectedEthIndex returns the 0-based Ethereum transaction index for a TypeEthDynamicFee tx. +// canonicalIdx is the tx's position counting all tx types in the block. +func ProjectedEthIndex(receipts tx.Receipts, canonicalIdx uint64) uint64 { + var count uint64 + for i := range canonicalIdx { + if receipts[i].Type == tx.TypeEthDynamicFee { + count++ + } + } + return count +} + +// CumulativeEthGasUsed returns the cumulative gas used by TypeEthDynamicFee transactions +// up to and including the tx at canonicalIdx. +func CumulativeEthGasUsed(receipts tx.Receipts, canonicalIdx uint64) uint64 { + var total uint64 + for i := uint64(0); i <= canonicalIdx; i++ { + if receipts[i].Type == tx.TypeEthDynamicFee { + total += receipts[i].GasUsed + } + } + return total +} + +// EthLogOffset returns the number of logs emitted by TypeEthDynamicFee transactions +// strictly before canonicalIdx (used as the starting logIndex for a tx's logs). +func EthLogOffset(receipts tx.Receipts, canonicalIdx uint64) uint64 { + var offset uint64 + for i := range canonicalIdx { + if receipts[i].Type == tx.TypeEthDynamicFee && len(receipts[i].Outputs) > 0 { + offset += uint64(len(receipts[i].Outputs[0].Events)) + } + } + return offset +} + +// CalcEffectiveGasPrice returns the EIP-1559 effective gas price: +// min(maxFeePerGas, baseFee + maxPriorityFeePerGas). +// When baseFee is nil (pre-GALACTICA blocks), maxFeePerGas is returned. +// maxFee and maxPriority must not be nil. +func CalcEffectiveGasPrice(maxFee, maxPriority, baseFee *big.Int) *big.Int { + if baseFee == nil { + return new(big.Int).Set(maxFee) + } + effective := new(big.Int).Add(baseFee, maxPriority) + if effective.Cmp(maxFee) < 0 { + return effective + } + return new(big.Int).Set(maxFee) +} diff --git a/rpc/fees/handler.go b/rpc/fees/handler.go new file mode 100644 index 0000000000..8414a808cd --- /dev/null +++ b/rpc/fees/handler.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package fees + +import ( + "encoding/json" + "math/big" + + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/consensus/upgrade/galactica" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// Handler implements fee market JSON-RPC methods. +type Handler struct { + repo *chain.Repository + backtrace uint32 + forkConfig *thor.ForkConfig +} + +// New creates a fees Handler. +func New(repo *chain.Repository, backtrace uint32, forkConfig *thor.ForkConfig) *Handler { + return &Handler{repo: repo, backtrace: backtrace, forkConfig: forkConfig} +} + +// Mount registers all fee market methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_gasPrice", h.ethGasPrice) + s.Register("eth_maxPriorityFeePerGas", h.ethMaxPriorityFeePerGas) + s.Register("eth_feeHistory", h.ethFeeHistory) +} + +func (h *Handler) ethGasPrice(req jsonrpc.Request) jsonrpc.Response { + header := h.repo.BestBlockSummary().Header + baseFee := header.BaseFee() + tip := big.NewInt(1e9) // 1 gwei tip suggestion + if baseFee == nil { + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(tip)) + } + price := new(big.Int).Add(baseFee, tip) + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(price)) +} + +func (h *Handler) ethMaxPriorityFeePerGas(req jsonrpc.Request) jsonrpc.Response { + // TODO: derive from on-chain params contract once available. + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(big.NewInt(1e9))) +} + +func (h *Handler) ethFeeHistory(req jsonrpc.Request) jsonrpc.Response { + var params rpc.FeeHistoryParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + + // Reward percentiles are not yet supported. + if len(params.RewardPercentiles) > 0 { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, "reward percentiles are not yet supported") + } + + if params.BlockCount == 0 { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "blockCount must be > 0") + } + if params.BlockCount > uint64(h.backtrace) { + params.BlockCount = uint64(h.backtrace) + } + + newestSummary, err := ethconvert.ResolveBlockTag(params.NewestBlock, h.repo) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + + newestNum := uint64(newestSummary.Header.Number()) + if params.BlockCount > newestNum+1 { + params.BlockCount = newestNum + 1 + } + oldestNum := newestNum - params.BlockCount + 1 + + bestChain := h.repo.NewBestChain() + + baseFees := make([]*hexutil.Big, 0, params.BlockCount+1) + gasUsedRatios := make([]float64, 0, params.BlockCount) + + for n := oldestNum; n <= newestNum; n++ { + hdr, err := bestChain.GetBlockHeader(uint32(n)) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + bf := hdr.BaseFee() + if bf == nil { + baseFees = append(baseFees, (*hexutil.Big)(new(big.Int))) + } else { + baseFees = append(baseFees, (*hexutil.Big)(new(big.Int).Set(bf))) + } + + // gasUsedRatio counts only TypeEthDynamicFee gas so Ethereum tooling sees + // ETH-typed block utilisation, not VeChain legacy tx activity. + receipts, err := h.repo.GetBlockReceipts(hdr.ID()) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + var ethGasUsed uint64 + for _, r := range receipts { + if r.Type == tx.TypeEthDynamicFee { + ethGasUsed += r.GasUsed + } + } + ratio := 0.0 + if hdr.GasLimit() > 0 { + ratio = float64(ethGasUsed) / float64(hdr.GasLimit()) + } + gasUsedRatios = append(gasUsedRatios, ratio) + } + + // Compute the true next-block baseFee using the consensus formula. + nextBaseFee := galactica.CalcBaseFee(newestSummary.Header, h.forkConfig) + if nextBaseFee == nil { + nextBaseFee = new(big.Int) + } + baseFees = append(baseFees, (*hexutil.Big)(nextBaseFee)) + + return jsonrpc.OkResponse(req.ID, rpc.FeeHistoryResult{ + OldestBlock: hexutil.Uint64(oldestNum), + BaseFeePerGas: baseFees, + GasUsedRatio: gasUsedRatios, + }) +} diff --git a/rpc/fees/handler_test.go b/rpc/fees/handler_test.go new file mode 100644 index 0000000000..6dd72efe5f --- /dev/null +++ b/rpc/fees/handler_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package fees_test + +import ( + "encoding/json" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/fees" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" +) + +type fixture struct { + chain *testchain.Chain +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + require.NoError(t, c.MintBlock()) + return &fixture{chain: c} +} + +func TestFeesHandler(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, fees.New(fx.chain.Repo(), 100, &testchain.DefaultForkConfig)) + + t.Run("eth_gasPrice", func(t *testing.T) { + // gasPrice = baseFee + 1 gwei tip; must be > 0 after GALACTICA. + result := testutil.Call(t, ts, "eth_gasPrice", []any{}) + var price hexutil.Big + require.NoError(t, json.Unmarshal(result, &price)) + assert.True(t, price.ToInt().Sign() > 0) + }) + + t.Run("eth_maxPriorityFeePerGas", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_maxPriorityFeePerGas", []any{}) + var tip hexutil.Big + require.NoError(t, json.Unmarshal(result, &tip)) + assert.True(t, tip.ToInt().Sign() > 0) + }) + + t.Run("eth_feeHistory_single_block", func(t *testing.T) { + // blockCount=1, newestBlock="latest" + result := testutil.Call(t, ts, "eth_feeHistory", []any{1, "latest", []any{}}) + var fh map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &fh)) + + // baseFeePerGas has length blockCount+1 = 2 (includes next-block estimate). + var baseFees []*hexutil.Big + require.NoError(t, json.Unmarshal(fh["baseFeePerGas"], &baseFees)) + assert.Len(t, baseFees, 2) + + // gasUsedRatio has length blockCount = 1. + var gasRatios []float64 + require.NoError(t, json.Unmarshal(fh["gasUsedRatio"], &gasRatios)) + assert.Len(t, gasRatios, 1) + + // oldestBlock is the first block in the range. + var oldest hexutil.Uint64 + require.NoError(t, json.Unmarshal(fh["oldestBlock"], &oldest)) + assert.Equal(t, uint64(1), uint64(oldest)) + }) + + t.Run("eth_feeHistory_zero_blockCount", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_feeHistory", []any{0, "latest", []any{}}) + assert.NotNil(t, rpcErr) + }) + + t.Run("eth_feeHistory_reward_percentiles_unsupported", func(t *testing.T) { + // Non-empty rewardPercentiles must return a server error rather than silently + // returning an empty reward array. + rpcErr := testutil.CallExpectError(t, ts, "eth_feeHistory", []any{1, "latest", []float64{25, 50, 75}}) + assert.NotNil(t, rpcErr) + assert.Contains(t, rpcErr.Message, "reward percentiles are not yet supported") + }) +} + +// TestFeeHistoryGasUsedRatioEthOnly verifies that gasUsedRatio counts only +// TypeEthDynamicFee gas, not VeChain legacy tx gas. +func TestFeeHistoryGasUsedRatioEthOnly(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + // Mint a block containing only a VeChain legacy tx. + // It consumes gas (GasUsed > 0 at the block level) but is not ETH-typed. + vcTx := testutil.BuildVcTx(t, c, sender, &recipient.Address) + require.NoError(t, c.MintBlock(vcTx)) + + ts := testutil.NewTestServer(t, fees.New(c.Repo(), 100, &testchain.DefaultForkConfig)) + + result := testutil.Call(t, ts, "eth_feeHistory", []any{1, "latest", []any{}}) + var fh map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &fh)) + + var gasRatios []float64 + require.NoError(t, json.Unmarshal(fh["gasUsedRatio"], &gasRatios)) + require.Len(t, gasRatios, 1) + assert.Equal(t, 0.0, gasRatios[0], + "VeChain legacy tx gas must not contribute to gasUsedRatio") +} diff --git a/rpc/fees_types.go b/rpc/fees_types.go new file mode 100644 index 0000000000..14f192a989 --- /dev/null +++ b/rpc/fees_types.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// FeeHistoryParams holds the parameters for eth_feeHistory. +// RewardPercentiles is optional and defaults to nil when omitted or null. +type FeeHistoryParams struct { + BlockCount uint64 + NewestBlock string + RewardPercentiles []float64 +} + +func (p *FeeHistoryParams) UnmarshalJSON(data []byte) error { + var raws []json.RawMessage + if err := json.Unmarshal(data, &raws); err != nil || len(raws) < 2 { + return fmt.Errorf("expected [blockCount, newestBlock, rewardPercentiles?]") + } + + // blockCount may arrive as a hex string ("0xa") or a plain integer (10). + var hexStr string + if err := json.Unmarshal(raws[0], &hexStr); err == nil { + n, err := hexutil.DecodeUint64(hexStr) + if err != nil { + return fmt.Errorf("invalid blockCount") + } + p.BlockCount = n + } else if err := json.Unmarshal(raws[0], &p.BlockCount); err != nil { + return fmt.Errorf("invalid blockCount") + } + + if err := json.Unmarshal(raws[1], &p.NewestBlock); err != nil { + return fmt.Errorf("invalid newestBlock") + } + + if len(raws) >= 3 && string(raws[2]) != "null" { + if err := json.Unmarshal(raws[2], &p.RewardPercentiles); err != nil { + return fmt.Errorf("invalid rewardPercentiles") + } + } + return nil +} + +// FeeHistoryResult is the response type for eth_feeHistory. +type FeeHistoryResult struct { + OldestBlock hexutil.Uint64 `json:"oldestBlock"` + BaseFeePerGas []*hexutil.Big `json:"baseFeePerGas"` + GasUsedRatio []float64 `json:"gasUsedRatio"` +} diff --git a/rpc/filters/handler.go b/rpc/filters/handler.go new file mode 100644 index 0000000000..61b64bdca7 --- /dev/null +++ b/rpc/filters/handler.go @@ -0,0 +1,437 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package filters + +import ( + "encoding/json" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/event" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/txpool" +) + +const ( + filterTTL = 5 * time.Minute + ttlCheckInterval = time.Minute + pendingTxBufSize = 128 + + // maxActiveFilters caps the total number of live filter objects across all clients. + // Each kindPendingTx filter holds a live txpool subscription; without this cap a + // single client can exhaust goroutines and channels by calling eth_newPendingTransactionFilter + // in a tight loop. The TTL evicts idle filters after filterTTL, but the check interval is + // ttlCheckInterval, so up to maxActiveFilters entries can accumulate before eviction fires. + // + // TODO: decide the broader approach for stateful filter endpoints: + // (a) keep as-is with this global cap + TTL and document that sticky sessions are required + // in multi-node / load-balanced deployments (filter state is node-local), or + // (b) add a node-operator flag to disable these endpoints for clustered setups. + // Modern tooling (ethers v6, viem, wagmi) uses eth_subscribe over WebSocket instead; + // these filter endpoints mainly serve legacy clients (web3.js v1, older Hardhat plugins). + maxActiveFilters = 1000 +) + +type filterKind int8 + +const ( + kindLog filterKind = iota + kindBlock + kindPendingTx +) + +type entry struct { + kind filterKind + lastPoll time.Time + // mu serialises concurrent ethGetFilterChanges calls on the same filter entry. + // reader and txCh are stateful (capture position/buffer) and must not be read + // by two goroutines at once. The TTL goroutine only holds h.mu, never e.mu. + mu sync.Mutex + + // kindLog + kindBlock: tracks the chain cursor for incremental polling. + // Positioned at the best block when the filter was created; advances on each poll. + reader chain.BlockReader + + // kindLog only: the original filter object and its parsed criteria. + // + // eth_getFilterChanges uses criteria for fast per-event matching while + // scanning new blocks via reader. It ignores LogFilter.FromBlock/ToBlock. + // + // eth_getFilterLogs re-evaluates LogFilter.FromBlock/ToBlock against the + // current best chain at query time, so "latest" resolves to the current + // head — not the block at filter creation. + logFilter rpc.EthLogFilter + criteria ethconvert.LogCriteria + + // kindPendingTx only. + // Only executable ETH-typed transactions are reported; see eth_newPendingTransactionFilter. + txCh chan *txpool.TxEvent + txSub event.Subscription +} + +// Handler implements the Ethereum filter poll API. +type Handler struct { + repo *chain.Repository + txPool txpool.Pool + backtrace uint32 + + mu sync.Mutex + entries map[string]*entry + nextID atomic.Uint64 + done chan struct{} + wg sync.WaitGroup +} + +// New creates a filter Handler and starts the background TTL cleanup goroutine. +func New(repo *chain.Repository, txPool txpool.Pool, backtrace uint32) *Handler { + h := &Handler{ + repo: repo, + txPool: txPool, + backtrace: backtrace, + entries: make(map[string]*entry), + done: make(chan struct{}), + } + h.wg.Go(h.runTTL) + return h +} + +// Close stops the TTL goroutine and unsubscribes all pending-tx filter subscriptions. +func (h *Handler) Close() { + close(h.done) + h.wg.Wait() + h.mu.Lock() + defer h.mu.Unlock() + for _, e := range h.entries { + if e.kind == kindPendingTx { + e.txSub.Unsubscribe() + } + } +} + +// Mount registers all filter methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_newFilter", h.ethNewFilter) + s.Register("eth_newBlockFilter", h.ethNewBlockFilter) + s.Register("eth_newPendingTransactionFilter", h.ethNewPendingTransactionFilter) + s.Register("eth_getFilterChanges", h.ethGetFilterChanges) + s.Register("eth_getFilterLogs", h.ethGetFilterLogs) + s.Register("eth_uninstallFilter", h.ethUninstallFilter) +} + +func (h *Handler) newID() string { + return hexutil.EncodeUint64(h.nextID.Add(1)) +} + +func (h *Handler) runTTL() { + ticker := time.NewTicker(ttlCheckInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + h.evictExpired() + case <-h.done: + return + } + } +} + +func (h *Handler) evictExpired() { + h.mu.Lock() + defer h.mu.Unlock() + now := time.Now() + for id, e := range h.entries { + if now.Sub(e.lastPoll) > filterTTL { + if e.kind == kindPendingTx { + e.txSub.Unsubscribe() + } + delete(h.entries, id) + } + } +} + +func (h *Handler) ethNewFilter(req jsonrpc.Request) jsonrpc.Response { + var params []rpc.EthLogFilter + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterObject]") + } + f := params[0] + criteria, err := ethconvert.ParseLogCriteria(f) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + id := h.newID() + h.mu.Lock() + defer h.mu.Unlock() + if len(h.entries) >= maxActiveFilters { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, "too many active filters") + } + h.entries[id] = &entry{ + kind: kindLog, + lastPoll: time.Now(), + reader: h.repo.NewBlockReader(h.repo.BestBlockSummary().Header.ID()), + logFilter: f, + criteria: criteria, + } + return jsonrpc.OkResponse(req.ID, id) +} + +func (h *Handler) ethNewBlockFilter(req jsonrpc.Request) jsonrpc.Response { + id := h.newID() + h.mu.Lock() + defer h.mu.Unlock() + if len(h.entries) >= maxActiveFilters { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, "too many active filters") + } + h.entries[id] = &entry{ + kind: kindBlock, + lastPoll: time.Now(), + reader: h.repo.NewBlockReader(h.repo.BestBlockSummary().Header.ID()), + } + return jsonrpc.OkResponse(req.ID, id) +} + +func (h *Handler) ethNewPendingTransactionFilter(req jsonrpc.Request) jsonrpc.Response { + txCh := make(chan *txpool.TxEvent, pendingTxBufSize) + sub := h.txPool.SubscribeTxEvent(txCh) + id := h.newID() + h.mu.Lock() + defer h.mu.Unlock() + if len(h.entries) >= maxActiveFilters { + sub.Unsubscribe() + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, "too many active filters") + } + h.entries[id] = &entry{ + kind: kindPendingTx, + lastPoll: time.Now(), + txCh: txCh, + txSub: sub, + } + return jsonrpc.OkResponse(req.ID, id) +} + +func (h *Handler) ethGetFilterChanges(req jsonrpc.Request) jsonrpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterId]") + } + var id string + if err := json.Unmarshal(params[0], &id); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid filter id") + } + + h.mu.Lock() + e, ok := h.entries[id] + if ok { + e.lastPoll = time.Now() + } + h.mu.Unlock() + + if !ok { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "filter not found") + } + + switch e.kind { + case kindBlock: + return h.changesBlock(req.ID, e) + case kindLog: + return h.changesLog(req.ID, e) + default: // kindPendingTx + return h.changesPendingTx(req.ID, e) + } +} + +func (h *Handler) changesBlock(id json.RawMessage, e *entry) jsonrpc.Response { + e.mu.Lock() + defer e.mu.Unlock() + // BlockReader.Read() advances by one block per call — loop until caught up. + hashes := make([]common.Hash, 0) + for { + blocks, err := e.reader.Read() + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) + } + if len(blocks) == 0 { + break + } + for _, blk := range blocks { + if blk.Obsolete { + continue // skip fork/reorg blocks; only canonical new heads + } + hashes = append(hashes, common.Hash(blk.Header().ID())) + } + } + return jsonrpc.OkResponse(id, hashes) +} + +func (h *Handler) changesLog(id json.RawMessage, e *entry) jsonrpc.Response { + e.mu.Lock() + defer e.mu.Unlock() + // BlockReader.Read() advances by one block per call — loop until caught up. + ethLogs := make([]*rpc.EthLog, 0) + for { + blocks, err := e.reader.Read() + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) + } + if len(blocks) == 0 { + break + } + for _, blk := range blocks { + if blk.Obsolete { + continue // skip fork/reorg blocks + } + receipts, err := h.repo.GetBlockReceipts(blk.Header().ID()) + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) + } + logs := ethconvert.CollectMatchingLogs(&e.criteria, blk.Transactions(), receipts, + common.Hash(blk.Header().ID()), uint64(blk.Header().Number()), false) + ethLogs = append(ethLogs, logs...) + } + } + return jsonrpc.OkResponse(id, ethLogs) +} + +func (h *Handler) changesPendingTx(id json.RawMessage, e *entry) jsonrpc.Response { + e.mu.Lock() + defer e.mu.Unlock() + var hashes []common.Hash +drain: + for { + select { + case ev := <-e.txCh: + if ev.Executable != nil && *ev.Executable && ev.Tx.Type() == tx.TypeEthDynamicFee { + hashes = append(hashes, common.Hash(ev.Tx.ID())) + } + default: + break drain + } + } + if hashes == nil { + hashes = []common.Hash{} + } + return jsonrpc.OkResponse(id, hashes) +} + +func (h *Handler) ethGetFilterLogs(req jsonrpc.Request) jsonrpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterId]") + } + var id string + if err := json.Unmarshal(params[0], &id); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid filter id") + } + + h.mu.Lock() + e, ok := h.entries[id] + if ok { + e.lastPoll = time.Now() + } + h.mu.Unlock() + + if !ok { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "filter not found") + } + if e.kind != kindLog { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "eth_getFilterLogs is only valid for log filters") + } + return h.queryFilterLogs(req.ID, e) +} + +// queryFilterLogs re-runs a full range query for a log filter using block receipt scanning. +// +// FromBlock/ToBlock from the stored LogFilter are re-resolved against the current best chain +// at call time: "latest" always means the current head, not the block at filter creation. +// Use eth_getFilterChanges for incremental changes from the creation cursor. +// +// Scanning is receipt-based rather than using the logDB index, so it is bounded by the +// backtrace limit. For large historical range queries, prefer eth_getLogs instead. +func (h *Handler) queryFilterLogs(id json.RawMessage, e *entry) jsonrpc.Response { + f := e.logFilter + bestNum := h.repo.BestBlockSummary().Header.Number() + bestChain := h.repo.NewBestChain() + + // Default both fromBlock and toBlock to "latest" when absent. + fromNum := bestNum + toNum := bestNum + + if f.FromBlock != nil && *f.FromBlock != "" { + summary, err := ethconvert.ResolveBlockTag(*f.FromBlock, h.repo) + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInvalidParams, "invalid fromBlock") + } + fromNum = summary.Header.Number() + } + if f.ToBlock != nil && *f.ToBlock != "" { + summary, err := ethconvert.ResolveBlockTag(*f.ToBlock, h.repo) + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInvalidParams, "invalid toBlock") + } + toNum = summary.Header.Number() + } + if toNum > bestNum { + toNum = bestNum + } + if fromNum > toNum { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInvalidParams, "invalid block range") + } + if toNum-fromNum > h.backtrace { + return jsonrpc.ErrResponse(id, jsonrpc.CodeServerError, + fmt.Sprintf("block range exceeds backtrace limit of %d", h.backtrace)) + } + + var ethLogs []*rpc.EthLog + for num := uint64(fromNum); num <= uint64(toNum); num++ { + blk, err := bestChain.GetBlock(uint32(num)) + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) + } + receipts, err := h.repo.GetBlockReceipts(blk.Header().ID()) + if err != nil { + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) + } + logs := ethconvert.CollectMatchingLogs(&e.criteria, blk.Transactions(), receipts, + common.Hash(blk.Header().ID()), uint64(blk.Header().Number()), false) + ethLogs = append(ethLogs, logs...) + } + if ethLogs == nil { + ethLogs = []*rpc.EthLog{} + } + return jsonrpc.OkResponse(id, ethLogs) +} + +func (h *Handler) ethUninstallFilter(req jsonrpc.Request) jsonrpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterId]") + } + var id string + if err := json.Unmarshal(params[0], &id); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid filter id") + } + + h.mu.Lock() + e, ok := h.entries[id] + if ok { + delete(h.entries, id) + } + h.mu.Unlock() + + if ok && e.kind == kindPendingTx { + e.txSub.Unsubscribe() + } + return jsonrpc.OkResponse(req.ID, ok) +} diff --git a/rpc/filters/handler_test.go b/rpc/filters/handler_test.go new file mode 100644 index 0000000000..8bc81b975d --- /dev/null +++ b/rpc/filters/handler_test.go @@ -0,0 +1,589 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package filters_test + +import ( + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/filters" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/txpool" +) + +type fixture struct { + chain *testchain.Chain + chainID uint64 + pool *txpool.TxPool +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + pool := txpool.New(c.Repo(), c.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, &testchain.DefaultForkConfig) + t.Cleanup(pool.Close) + + return &fixture{ + chain: c, + chainID: c.Repo().ChainID(), + pool: pool, + } +} + +func TestFiltersHandler(t *testing.T) { + fx := newFixture(t) + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + h := filters.New(fx.chain.Repo(), fx.pool, 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + t.Run("block_filter_empty_then_new_block", func(t *testing.T) { + idResult := testutil.Call(t, ts, "eth_newBlockFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + assert.Regexp(t, `^0x[0-9a-f]+$`, filterID) + + // No new blocks yet — returns empty array (not null). + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var hashes []common.Hash + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Empty(t, hashes) + + // Mint a block, then poll — returns the new block hash. + require.NoError(t, fx.chain.MintBlock()) + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + require.NoError(t, json.Unmarshal(result, &hashes)) + require.Len(t, hashes, 1) + + best, err := fx.chain.BestBlock() + require.NoError(t, err) + assert.Equal(t, common.Hash(best.Header().ID()), hashes[0]) + + // Second poll with no new blocks — empty again. + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Empty(t, hashes) + }) + + t.Run("log_filter_no_events", func(t *testing.T) { + // Mint a block with a plain ETH transfer (no contract events). + ethTx := testutil.BuildEthTx(t, fx.chainID, sender, 0, &recipient.Address) + require.NoError(t, fx.chain.MintBlock(ethTx)) + + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{}}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // No events in a plain transfer — returns empty array. + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Empty(t, logs) + }) + + t.Run("log_filter_invalid_criteria", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_newFilter", []any{ + map[string]any{"address": "not-a-valid-address"}, + }) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcErr.Code) + }) + + t.Run("eth_getFilterLogs", func(t *testing.T) { + idResult := testutil.Call(t, ts, "eth_newFilter", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, + }) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // No contract events in the chain — returns empty array (not null). + result := testutil.Call(t, ts, "eth_getFilterLogs", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Empty(t, logs) + }) + + t.Run("eth_getFilterLogs_block_filter_error", func(t *testing.T) { + idResult := testutil.Call(t, ts, "eth_newBlockFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterLogs", []any{filterID}) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcErr.Code) + }) + + t.Run("pending_tx_filter", func(t *testing.T) { + idResult := testutil.Call(t, ts, "eth_newPendingTransactionFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // No pending txs yet — returns empty array. + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var hashes []common.Hash + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Empty(t, hashes) + + // The pool dispatches the subscription event asynchronously (p.goes.Go), + // so poll until the hash arrives rather than reading once immediately. + // Use DevAccounts()[2] (nonce=0) so this subtest is independent of prior ones. + pendingSender := genesis.DevAccounts()[2] + ethTx := testutil.BuildEthTx(t, fx.chainID, pendingSender, 0, &recipient.Address) + require.NoError(t, fx.pool.AddLocal(ethTx)) + + var gotHashes []common.Hash + require.Eventually(t, func() bool { + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + return json.Unmarshal(result, &gotHashes) == nil && len(gotHashes) > 0 + }, 3*time.Second, 10*time.Millisecond) + require.Len(t, gotHashes, 1) + assert.Equal(t, common.Hash(ethTx.ID()), gotHashes[0]) + + // Drained — second poll is empty. + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Empty(t, hashes) + }) + + t.Run("eth_uninstallFilter_existing", func(t *testing.T) { + idResult := testutil.Call(t, ts, "eth_newBlockFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + result := testutil.Call(t, ts, "eth_uninstallFilter", []any{filterID}) + var ok bool + require.NoError(t, json.Unmarshal(result, &ok)) + assert.True(t, ok) + }) + + t.Run("eth_uninstallFilter_unknown", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_uninstallFilter", []any{"0x9999"}) + var ok bool + require.NoError(t, json.Unmarshal(result, &ok)) + assert.False(t, ok) + }) + + t.Run("eth_getFilterChanges_unknown", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterChanges", []any{"0x9999"}) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcErr.Code) + }) + + t.Run("eth_getFilterLogs_unknown", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterLogs", []any{"0x9999"}) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcErr.Code) + }) +} + +// newTestPool creates a txpool and registers its cleanup. +func newTestPool(t *testing.T, c *testchain.Chain) *txpool.TxPool { + t.Helper() + pool := txpool.New(c.Repo(), c.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, &testchain.DefaultForkConfig) + t.Cleanup(pool.Close) + return pool +} + +// TestFiltersHandlerLogChangesWithEvents verifies that eth_getFilterChanges for a log +// filter returns actual ETH-typed transaction events and drains on a second poll. +func TestFiltersHandlerLogChangesWithEvents(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + transferEvent, ok := builtin.Energy.ABI.EventByName("Transfer") + require.True(t, ok) + transferTopic := common.Hash(transferEvent.ID()).Hex() + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + // Create a log filter at genesis — no criteria matches all events. + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{}}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // No new blocks yet — empty. + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var initial []any + require.NoError(t, json.Unmarshal(result, &initial)) + assert.Empty(t, initial) + + // Mint a block with an ETH contract call that emits a Transfer event. + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + // Poll — should return the Transfer event. + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &logs)) + require.Len(t, logs, 1) + + var addr string + require.NoError(t, json.Unmarshal(logs[0]["address"], &addr)) + assert.Equal(t, energyAddr.String(), addr) + + var topics []string + require.NoError(t, json.Unmarshal(logs[0]["topics"], &topics)) + require.NotEmpty(t, topics) + assert.Equal(t, transferTopic, topics[0]) + + // Second poll with no new blocks — empty (changes drain). + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var drained []any + require.NoError(t, json.Unmarshal(result, &drained)) + assert.Empty(t, drained) +} + +// TestFiltersHandlerLogChangesAddressFilter verifies that address criteria in a log +// filter correctly include matching events and exclude non-matching ones. +func TestFiltersHandlerLogChangesAddressFilter(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + t.Run("matching_address_returns_event", func(t *testing.T) { + sender := genesis.DevAccounts()[0] + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "address": energyAddr.String(), + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Len(t, logs, 1) + }) + + t.Run("non_matching_address_returns_empty", func(t *testing.T) { + // Use a different sender so nonce is 0 regardless of prior subtest. + sender := genesis.DevAccounts()[2] + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "address": "0x0000000000000000000000000000000000000001", + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // Energy.transfer emits from energyAddr, not 0x0001 → filter should not match. + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Empty(t, logs) + }) +} + +// TestFiltersHandlerGetFilterLogsWithEvents verifies that eth_getFilterLogs returns +// actual events from the stored block range for a log filter. +func TestFiltersHandlerGetFilterLogsWithEvents(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + // Mint the event block first; eth_getFilterLogs re-evaluates the range at query time. + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + result := testutil.Call(t, ts, "eth_getFilterLogs", []any{filterID}) + var logs []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &logs)) + require.Len(t, logs, 1) + + var addr string + require.NoError(t, json.Unmarshal(logs[0]["address"], &addr)) + assert.Equal(t, energyAddr.String(), addr) +} + +// TestFiltersHandlerGetFilterLogsBacktraceLimit verifies that eth_getFilterLogs rejects +// a block range that exceeds the backtrace limit with a server error. +func TestFiltersHandlerGetFilterLogsBacktraceLimit(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + // Build a chain deeper than backtrace=100: fromBlock=0x0, toBlock=latest → range 102 > 100. + for range 102 { + require.NoError(t, c.MintBlock()) + } + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterLogs", []any{filterID}) + assert.Equal(t, jsonrpc.CodeServerError, rpcErr.Code) +} + +// TestFiltersHandlerVcTxExcluded verifies that events from TypeLegacy VeChain +// transactions do not appear in log filter changes. +func TestFiltersHandlerVcTxExcluded(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{}}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // Mint a block with a TypeLegacy VeChain tx — emits a Transfer event but is not ETH-typed. + vcCallTx := testutil.BuildVcCallTx(t, c, sender, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(vcCallTx)) + + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Empty(t, logs, "TypeLegacy VeChain tx events must not appear in log filter changes") +} + +// TestFiltersHandlerBlockFilterMultipleBlocks verifies that polling a block filter +// after minting several blocks returns all new hashes in a single call. +func TestFiltersHandlerBlockFilterMultipleBlocks(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + idResult := testutil.Call(t, ts, "eth_newBlockFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + require.NoError(t, c.MintBlock()) + require.NoError(t, c.MintBlock()) + require.NoError(t, c.MintBlock()) + + // Single poll returns all 3 hashes. + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var hashes []common.Hash + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Len(t, hashes, 3) + + // Second poll with no new blocks — empty. + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Empty(t, hashes) +} + +// TestFiltersHandlerCompactTopicFilter verifies that compact topic hex like "0x0" +// is accepted by eth_newFilter and correctly matches events. +func TestFiltersHandlerCompactTopicFilter(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + transferEvent, ok := builtin.Energy.ABI.EventByName("Transfer") + require.True(t, ok) + transferTopic := common.Hash(transferEvent.ID()).Hex() + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + t.Run("compact_zero_topic_creates_filter", func(t *testing.T) { + // "0x0" is compact hex for zero-topic — should not reject the filter. + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "topics": []any{"0x0"}, + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + assert.NotEmpty(t, filterID) + }) + + t.Run("compact_topic_matches_correctly", func(t *testing.T) { + // Create filter with a zero-topic filter — uses compact "0x0" form. + // This verifies that compact hex is parsed without error. + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "topics": []any{"0x0"}, + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // Mint a block with the Transfer event (topic[0] is the event signature, not zero). + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + // Should not match because the event topic[0] is the Transfer event ID, not zero. + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Empty(t, logs) + }) + + t.Run("full_topic_compact_prefix_matches", func(t *testing.T) { + // Different sender so nonce 0 is fresh. + sender := genesis.DevAccounts()[2] + + // Use the full topic hex — ParseBytes32Compact handles both compact and full. + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "topics": []any{transferTopic}, + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Len(t, logs, 1) + }) +} + +// TestFiltersHandlerORTopicFilter verifies that an array at a topic position is treated as +// OR: topics: [["A","B"]] matches logs whose topic0 equals A OR B. +func TestFiltersHandlerORTopicFilter(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + transferEvent, ok := builtin.Energy.ABI.EventByName("Transfer") + require.True(t, ok) + transferTopic := common.Hash(transferEvent.ID()).Hex() + noMatchTopic := "0x0000000000000000000000000000000000000000000000000000000000000001" + + h := filters.New(c.Repo(), newTestPool(t, c), 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + t.Run("OR_includes_matching_topic", func(t *testing.T) { + // [transferTopic, noMatchTopic] at position 0 — should match the Transfer event. + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "topics": []any{[]any{transferTopic, noMatchTopic}}, + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Len(t, logs, 1, "OR filter including transferTopic should return the event") + }) + + t.Run("OR_no_matching_topic", func(t *testing.T) { + // Two non-matching topics OR-ed at position 0 — should return nothing. + idResult := testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{ + "topics": []any{[]any{noMatchTopic, "0x0000000000000000000000000000000000000000000000000000000000000002"}}, + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + // Mint a block with the Transfer event; the filter should not match it. + ethCallTx := testutil.BuildEthCallTx(t, chainID, genesis.DevAccounts()[2], 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + result := testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + var logs []any + require.NoError(t, json.Unmarshal(result, &logs)) + assert.Empty(t, logs, "OR filter with no matching topics should return empty") + }) +} diff --git a/rpc/filters/ttl_test.go b/rpc/filters/ttl_test.go new file mode 100644 index 0000000000..29a4ee3b44 --- /dev/null +++ b/rpc/filters/ttl_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package filters + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/txpool" +) + +// TestTTLExpiration verifies that filter entries are cleaned up after the TTL +// period expires via evictExpired. +func TestTTLExpiration(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + pool := txpool.New(c.Repo(), c.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, &testchain.DefaultForkConfig) + defer pool.Close() + + // Save the original TTL constant and restore after test. + // Since filterTTL is a const we can't modify it, so we create + // the handler and manually trigger evictExpired after sleeping. + h := New(c.Repo(), pool, 100) + t.Cleanup(h.Close) + ts := testutil.NewTestServer(t, h) + + // Create filters of each type. + idResult := testutil.Call(t, ts, "eth_newBlockFilter", []any{}) + var blockFilterID string + require.NoError(t, json.Unmarshal(idResult, &blockFilterID)) + + idResult = testutil.Call(t, ts, "eth_newFilter", []any{map[string]any{}}) + var logFilterID string + require.NoError(t, json.Unmarshal(idResult, &logFilterID)) + + idResult = testutil.Call(t, ts, "eth_newPendingTransactionFilter", []any{}) + var pendingFilterID string + require.NoError(t, json.Unmarshal(idResult, &pendingFilterID)) + + // All three should exist in the entries map. + require.Contains(t, h.entries, blockFilterID) + require.Contains(t, h.entries, logFilterID) + require.Contains(t, h.entries, pendingFilterID) + + // Manually age the entries by backdating lastPoll to exceed the TTL. + h.mu.Lock() + for _, e := range h.entries { + e.lastPoll = time.Now().Add(-(filterTTL + time.Second)) + } + h.mu.Unlock() + + // Trigger eviction directly (avoids waiting for runTTL's ticker). + h.evictExpired() + + // All three should have been removed. + require.NotContains(t, h.entries, blockFilterID) + require.NotContains(t, h.entries, logFilterID) + require.NotContains(t, h.entries, pendingFilterID) +} diff --git a/rpc/integration_test.go b/rpc/integration_test.go new file mode 100644 index 0000000000..2546e6e859 --- /dev/null +++ b/rpc/integration_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/txpool" + + "github.com/vechain/thor/v2/rpc/accounts" + "github.com/vechain/thor/v2/rpc/blocks" + rpcchain "github.com/vechain/thor/v2/rpc/chain" + "github.com/vechain/thor/v2/rpc/fees" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/rpc/logs" + "github.com/vechain/thor/v2/rpc/simulation" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/rpc/transactions" +) + +// TestDispatch covers server- and dispatcher-level behaviour that is independent +// of any individual method namespace. Per-method tests live in each sub-package. +func TestDispatch(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + vcTx := testutil.BuildVcTx(t, c, sender, &recipient.Address) + ethTx := testutil.BuildEthTx(t, c.Repo().ChainID(), sender, 0, &recipient.Address) + + require.NoError(t, c.MintBlock(vcTx, ethTx)) + require.Equal(t, uint32(1), c.Repo().BestBlockSummary().Header.Number()) + + pool := txpool.New(c.Repo(), c.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, &testchain.DefaultForkConfig) + srv := jsonrpc.NewServer() + rpcchain.New(c.Repo(), "test/1.0").Mount(srv) + blocks.New(c.Repo()).Mount(srv) + transactions.New(c.Repo(), pool).Mount(srv) + accounts.New(c.Repo(), c.Stater()).Mount(srv) + logs.New(c.Repo(), c.LogDB(), 100, 1000).Mount(srv) + fees.New(c.Repo(), 100, &testchain.DefaultForkConfig).Mount(srv) + simulation.New(c.Repo(), c.Stater(), &testchain.DefaultForkConfig, 1_000_000).Mount(srv) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + t.Run("unknown_method", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_nonExistentMethod", []any{}) + assert.Equal(t, jsonrpc.CodeMethodNotFound, rpcErr.Code) + }) + + t.Run("batch", func(t *testing.T) { + batchBody := `[ + {"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}, + {"jsonrpc":"2.0","id":2,"method":"eth_syncing","params":[]} + ]` + resp, err := http.Post(ts.URL+"/", "application/json", bytes.NewReader([]byte(batchBody))) + require.NoError(t, err) + defer resp.Body.Close() + + var responses []struct { + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result"` + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&responses)) + assert.Len(t, responses, 2) + for _, r := range responses { + assert.Nil(t, r.Error, "batch element should not have an error") + } + }) + + t.Run("batch_exceeds_limit", func(t *testing.T) { + // Build a batch of 11 requests (maxBatchRequests = 10). + var batch []map[string]any + for i := range 11 { + batch = append(batch, map[string]any{ + "jsonrpc": "2.0", + "id": i + 1, + "method": "eth_blockNumber", + "params": []any{}, + }) + } + batchBody, _ := json.Marshal(batch) + resp, err := http.Post(ts.URL+"/", "application/json", bytes.NewReader(batchBody)) + require.NoError(t, err) + defer resp.Body.Close() + + var rpcResp struct { + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.NotNil(t, rpcResp.Error) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcResp.Error.Code) + }) + + t.Run("invalid_json", func(t *testing.T) { + resp, err := http.Post(ts.URL+"/", "application/json", bytes.NewReader([]byte("{invalid"))) + require.NoError(t, err) + defer resp.Body.Close() + + var rpcResp struct { + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.NotNil(t, rpcResp.Error) + assert.Equal(t, jsonrpc.CodeParseError, rpcResp.Error.Code) + }) + + t.Run("body_too_large", func(t *testing.T) { + // Send a body that exceeds the 2 MB server limit. + oversized := make([]byte, 2*1024*1024+1) + for i := range oversized { + oversized[i] = 'x' + } + resp, err := http.Post(ts.URL+"/", "application/json", bytes.NewReader(oversized)) + require.NoError(t, err) + defer resp.Body.Close() + + var rpcResp struct { + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.NotNil(t, rpcResp.Error) + assert.Equal(t, jsonrpc.CodeInvalidRequest, rpcResp.Error.Code) + }) + + t.Run("wrong_http_method", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) + }) +} diff --git a/rpc/jsonrpc/server.go b/rpc/jsonrpc/server.go new file mode 100644 index 0000000000..ef9115595b --- /dev/null +++ b/rpc/jsonrpc/server.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +// Package jsonrpc provides the JSON-RPC 2.0 server and protocol types used by +// the Ethereum-compatible RPC layer. It mirrors the role gorilla/mux plays in +// the REST API: it is the dispatch layer, not a domain-logic package. +package jsonrpc + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +const maxRequestBodySize = 2 * 1024 * 1024 // 2 MB + +// TODO: revisit this limit — 10 is conservative; raise once the performance +// profile of synchronous batch processing is better understood. +const maxBatchRequests = 10 + +// Server is an HTTP handler that implements the Ethereum JSON-RPC protocol. +// It acts as both the method registry (via Register) and the HTTP handler, +// mirroring the role mux.Router plays in the REST API. +type Server struct { + methods map[string]func(Request) Response +} + +// NewServer creates a new Server. +func NewServer() *Server { + return &Server{methods: make(map[string]func(Request) Response)} +} + +// Register adds a handler for the given JSON-RPC method name. +// Panics if the method name is already registered — catches wiring mistakes at startup. +func (s *Server) Register(method string, handler func(Request) Response) { + if _, exists := s.methods[method]; exists { + panic("rpc: duplicate method registration: " + method) + } + s.methods[method] = handler +} + +// ServeHTTP implements http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + // CORS preflight handled by the handlers CORS middleware applied externally. + w.WriteHeader(http.StatusOK) + return + } + if r.Method != http.MethodPost { + http.Error(w, "only POST requests are accepted", http.StatusMethodNotAllowed) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + body, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + writeJSON(w, ErrResponse(nil, CodeInvalidRequest, "request body too large")) + } else { + writeJSON(w, ErrResponse(nil, CodeParseError, "failed to read request body")) + } + return + } + + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + writeJSON(w, ErrResponse(nil, CodeParseError, "empty request body")) + return + } + + if trimmed[0] == '[' { + s.handleBatch(w, trimmed) + } else { + s.handleSingle(w, trimmed) + } +} + +// Dispatch routes a parsed JSON-RPC request to its registered handler. +// It is exported so that the WebSocket handler can proxy non-subscribe +// methods (eth_call, eth_blockNumber, etc.) over a WS connection. +func (s *Server) Dispatch(req Request) Response { + h, ok := s.methods[req.Method] + if !ok { + return ErrResponse(req.ID, CodeMethodNotFound, fmt.Sprintf("method %q not found", req.Method)) + } + return h(req) +} + +func (s *Server) handleSingle(w http.ResponseWriter, body []byte) { + var req Request + if err := json.Unmarshal(body, &req); err != nil { + writeJSON(w, ErrResponse(nil, CodeParseError, "invalid JSON: "+err.Error())) + return + } + writeJSON(w, s.Dispatch(req)) +} + +func (s *Server) handleBatch(w http.ResponseWriter, body []byte) { + var raws []json.RawMessage + if err := json.Unmarshal(body, &raws); err != nil { + writeJSON(w, ErrResponse(nil, CodeParseError, "invalid JSON array: "+err.Error())) + return + } + if len(raws) == 0 { + writeJSON(w, ErrResponse(nil, CodeInvalidParams, "empty batch")) + return + } + if len(raws) > maxBatchRequests { + writeJSON(w, ErrResponse(nil, CodeInvalidParams, fmt.Sprintf("batch size %d exceeds maximum of %d", len(raws), maxBatchRequests))) + return + } + + responses := make([]Response, len(raws)) + for i, raw := range raws { + var req Request + if err := json.Unmarshal(raw, &req); err != nil { + responses[i] = ErrResponse(nil, CodeParseError, "invalid request in batch: "+err.Error()) + continue + } + responses[i] = s.Dispatch(req) + } + writeJSON(w, responses) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/rpc/jsonrpc/types.go b/rpc/jsonrpc/types.go new file mode 100644 index 0000000000..b5c68b413b --- /dev/null +++ b/rpc/jsonrpc/types.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package jsonrpc + +import "encoding/json" + +// Request is a JSON-RPC 2.0 request object. +type Request struct { + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + ID json.RawMessage `json:"id"` +} + +// Response is a JSON-RPC 2.0 response object. +type Response struct { + Jsonrpc string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +// RPCError is a JSON-RPC 2.0 error object. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data string `json:"data,omitempty"` +} + +const ( + CodeParseError = -32700 + CodeInvalidRequest = -32600 + CodeMethodNotFound = -32601 + CodeInvalidParams = -32602 + CodeInternalError = -32603 + CodeServerError = -32000 // execution error, revert, etc. +) + +// ErrResponse constructs a JSON-RPC error response. +func ErrResponse(id json.RawMessage, code int, msg string) Response { + return Response{ + Jsonrpc: "2.0", + ID: id, + Error: &RPCError{Code: code, Message: msg}, + } +} + +// ErrResponseWithData constructs a JSON-RPC error response with an extra data field. +func ErrResponseWithData(id json.RawMessage, code int, msg, data string) Response { + return Response{ + Jsonrpc: "2.0", + ID: id, + Error: &RPCError{Code: code, Message: msg, Data: data}, + } +} + +// OkResponse constructs a successful JSON-RPC response. +func OkResponse(id json.RawMessage, result any) Response { + data, _ := json.Marshal(result) + return Response{ + Jsonrpc: "2.0", + ID: id, + Result: data, + } +} diff --git a/rpc/logs/handler.go b/rpc/logs/handler.go new file mode 100644 index 0000000000..554b266339 --- /dev/null +++ b/rpc/logs/handler.go @@ -0,0 +1,306 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package logs + +import ( + "context" + "encoding/json" + "fmt" + "math" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/logdb" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// Handler implements the eth_getLogs JSON-RPC method. +type Handler struct { + repo *chain.Repository + logDB *logdb.LogDB + backtrace uint32 + logsLimit uint64 +} + +// New creates a logs Handler. +func New(repo *chain.Repository, logDB *logdb.LogDB, backtrace uint32, logsLimit uint64) *Handler { + return &Handler{repo: repo, logDB: logDB, backtrace: backtrace, logsLimit: logsLimit} +} + +// Mount registers all log methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_getLogs", h.ethGetLogs) +} + +func (h *Handler) ethGetLogs(req jsonrpc.Request) jsonrpc.Response { + var params []rpc.EthLogFilter + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterObject]") + } + f := params[0] + + // Single BestBlockSummary read so bestChain and bestNum are always consistent. + bestSummary := h.repo.BestBlockSummary() + bestChain := h.repo.NewChain(bestSummary.Header.ID()) + bestNum := bestSummary.Header.Number() + + var fromNum, toNum uint32 + + if f.BlockHash != nil { + // EIP-234: blockHash is mutually exclusive with fromBlock/toBlock. + if (f.FromBlock != nil && *f.FromBlock != "") || (f.ToBlock != nil && *f.ToBlock != "") { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "can't specify fromBlock/toBlock with blockHash") + } + summary, err := ethconvert.ResolveBlockTag(*f.BlockHash, h.repo) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, "unknown block") + } + fromNum = summary.Header.Number() + toNum = summary.Header.Number() + } else { + // Per Ethereum spec, absent fromBlock and toBlock both default to "latest". + fromNum = bestNum + toNum = bestNum + + if f.FromBlock != nil && *f.FromBlock != "" { + summary, err := ethconvert.ResolveBlockTag(*f.FromBlock, h.repo) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid fromBlock") + } + fromNum = summary.Header.Number() + } + if f.ToBlock != nil && *f.ToBlock != "" { + summary, err := ethconvert.ResolveBlockTag(*f.ToBlock, h.repo) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid toBlock") + } + toNum = summary.Header.Number() + } + + if toNum > bestNum { + toNum = bestNum + } + if fromNum > toNum { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid block range params") + } + if toNum-fromNum > h.backtrace { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, fmt.Sprintf("block range exceeds backtrace limit of %d", h.backtrace)) + } + } + + // Parse address filter + var addresses []*thor.Address + if len(f.Address) > 0 { + var single string + var multi []string + if err := json.Unmarshal(f.Address, &single); err == nil { + addr, err := thor.ParseAddress(single) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid address in filter") + } + a := addr + addresses = append(addresses, &a) + } else if err := json.Unmarshal(f.Address, &multi); err == nil { + for _, s := range multi { + addr, err := thor.ParseAddress(s) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid address in filter") + } + a := addr + addresses = append(addresses, &a) + } + } + } + + // Parse topic filters — up to 5 positions (topic0…topic4), each null | hex | []hex. + // Adjacent positions are ANDed; alternatives within one position are ORed. + // topicAlts[i] holds all accepted values for position i; empty means wildcard. + var topicAlts [5][]thor.Bytes32 + topics := f.Topics + if len(topics) > len(topicAlts) { + topics = topics[:len(topicAlts)] + } + for i, raw := range topics { + if raw == nil || string(raw) == "null" { + continue // nil = wildcard for this position + } + var single string + var multi []string + if err := json.Unmarshal(raw, &single); err == nil { + h32, err := rpc.ParseBytes32Compact(single) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid topic") + } + topicAlts[i] = []thor.Bytes32{h32} + } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { + alts := make([]thor.Bytes32, 0, len(multi)) + for _, s := range multi { + h32, err := rpc.ParseBytes32Compact(s) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid topic") + } + alts = append(alts, h32) + } + topicAlts[i] = alts + } + } + + // Build criteria set via cross-product of addresses × topic alternatives. + // Criteria count grows as the product of per-position alternative counts and + // address count; typical usage is small and no hard cap is enforced. + criteriaSet := buildCriteriaSet(addresses, topicAlts) + + // Fetch one extra result to detect truncation: if the logdb returns more than + // logsLimit rows, return an error instead of a silently incomplete result. + queryLimit := h.logsLimit + if queryLimit < math.MaxUint64 { + queryLimit++ + } + filter := &logdb.EventFilter{ + CriteriaSet: criteriaSet, + Range: &logdb.Range{ + From: fromNum, + To: toNum, + }, + Options: &logdb.Options{Limit: queryLimit}, + Order: logdb.ASC, + } + + events, err := h.logDB.FilterEvents(context.Background(), filter) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + if uint64(len(events)) > h.logsLimit { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, + fmt.Sprintf("query returned more than %d results, use a smaller block range or a more specific filter", h.logsLimit)) + } + + // Post-filter: only return logs from TypeEthDynamicFee transactions. + // Projected transactionIndex and logIndex are computed relative to ETH-typed txs only, + // so that they remain consistent with eth_getTransactionByHash etc. in mixed blocks. + // + // blockTxsByNum caches the full tx list per block (one GetBlock call per unique block). + // ethProjTxIdx caches canonical-position → projected-ETH-index per tx (avoids recount + // when the same tx emits multiple events). + // ethLogIdxByBlock counts ETH events seen so far per block (becomes the projected logIndex). + blockTxsByNum := make(map[uint32][]*tx.Transaction) + ethProjTxIdx := make(map[thor.Bytes32]uint32) + ethLogIdxByBlock := make(map[thor.Bytes32]uint32) + + getBlockTxs := func(blockNum uint32) ([]*tx.Transaction, error) { + if txs, ok := blockTxsByNum[blockNum]; ok { + return txs, nil + } + blk, err := bestChain.GetBlock(blockNum) + if err != nil { + return nil, err + } + txs := blk.Transactions() + blockTxsByNum[blockNum] = txs + return txs, nil + } + + var ethLogs []*rpc.EthLog + // blockTxsByNum caches block transactions per block number so GetBlock is called + // at most once per unique block in the result set, not once per event. + for _, ev := range events { + blockTxs, err := getBlockTxs(ev.BlockNumber) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + + // Bounds-check and ID-verify before using the canonical index. + if int(ev.TxIndex) >= len(blockTxs) || blockTxs[ev.TxIndex].ID() != ev.TxID { + continue + } + if blockTxs[ev.TxIndex].Type() != tx.TypeEthDynamicFee { + continue + } + + // Projected ETH tx index: number of TypeEthDynamicFee txs at canonical positions < ev.TxIndex. + projTxIdx, ok := ethProjTxIdx[ev.TxID] + if !ok { + for i := uint32(0); i < ev.TxIndex; i++ { + if blockTxs[i].Type() == tx.TypeEthDynamicFee { + projTxIdx++ + } + } + ethProjTxIdx[ev.TxID] = projTxIdx + } + + // Projected ETH log index: running count of ETH events in this block so far. + logIdx := ethLogIdxByBlock[ev.BlockID] + ethLogIdxByBlock[ev.BlockID]++ + + evTopics := make([]common.Hash, 0, 5) + for _, tp := range ev.Topics { + if tp == nil { + break + } + evTopics = append(evTopics, common.Hash(*tp)) + } + + ethLogs = append(ethLogs, &rpc.EthLog{ + Address: common.Address(ev.Address), + Topics: evTopics, + Data: ev.Data, + BlockNumber: hexutil.Uint64(ev.BlockNumber), + TxHash: common.Hash(ev.TxID), + TxIndex: hexutil.Uint64(projTxIdx), + BlockHash: common.Hash(ev.BlockID), + LogIndex: hexutil.Uint64(logIdx), + Removed: false, + }) + } + if ethLogs == nil { + ethLogs = []*rpc.EthLog{} + } + return jsonrpc.OkResponse(req.ID, ethLogs) +} + +// buildCriteriaSet returns the EventCriteria cross-product for the given addresses +// and per-slot topic alternatives. Positions with no alternatives are wildcards (Topics[i] == nil). +func buildCriteriaSet(addresses []*thor.Address, topicAlts [5][]thor.Bytes32) []*logdb.EventCriteria { + type topicCombo [5]*thor.Bytes32 + combos := []topicCombo{{}} + for i, alts := range topicAlts { + if len(alts) == 0 { + continue + } + expanded := make([]topicCombo, 0, len(combos)*len(alts)) + for _, c := range combos { + for _, alt := range alts { + newCombo := c + altCopy := alt + newCombo[i] = &altCopy + expanded = append(expanded, newCombo) + } + } + combos = expanded + } + var criteria []*logdb.EventCriteria + if len(addresses) == 0 { + for _, c := range combos { + topics := c + criteria = append(criteria, &logdb.EventCriteria{Topics: topics}) + } + } else { + for _, addr := range addresses { + for _, c := range combos { + addrCopy := *addr + topics := c + criteria = append(criteria, &logdb.EventCriteria{Address: &addrCopy, Topics: topics}) + } + } + } + return criteria +} diff --git a/rpc/logs/handler_test.go b/rpc/logs/handler_test.go new file mode 100644 index 0000000000..f54c7924bb --- /dev/null +++ b/rpc/logs/handler_test.go @@ -0,0 +1,528 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package logs_test + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/logs" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" +) + +type fixture struct { + chain *testchain.Chain + blockHash string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + require.NoError(t, c.MintBlock()) + bestBlock, err := c.BestBlock() + require.NoError(t, err) + return &fixture{ + chain: c, + blockHash: bestBlock.Header().ID().String(), + } +} + +func TestLogsHandler(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, logs.New(fx.chain.Repo(), fx.chain.LogDB(), 100, 1000)) + + t.Run("eth_getLogs_empty", func(t *testing.T) { + // The fixture block contains no ETH typed transactions → no events. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got) + }) + + t.Run("eth_getLogs_blockHash_filter", func(t *testing.T) { + // EIP-234: single-block query via blockHash. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"blockHash": fx.blockHash}, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got) + }) + + t.Run("eth_getLogs_range_exceeds_backtrace", func(t *testing.T) { + // A range wider than the backtrace limit (100) must be rejected. + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "0x65"}, // 0x65 = 101 + }) + assert.NotNil(t, rpcErr) + }) +} + +// TestLogsHandlerWithEvents verifies that eth_getLogs returns events emitted by +// ETH typed transactions — including address and topic filters. +func TestLogsHandlerWithEvents(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + // Mint a block with an ETH call to Energy.transfer, which emits a Transfer event. + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + bestBlock, err := c.BestBlock() + require.NoError(t, err) + blockHash := bestBlock.Header().ID().String() + txHash := ethCallTx.ID().String() + + ts := testutil.NewTestServer(t, logs.New(c.Repo(), c.LogDB(), 100, 1000)) + + transferEvent, ok := builtin.Energy.ABI.EventByName("Transfer") + require.True(t, ok) + transferTopic := common.Hash(transferEvent.ID()).Hex() + + t.Run("eth_getLogs_range_returns_event", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, + }) + var got []map[string]any + require.NoError(t, json.Unmarshal(result, &got)) + require.Len(t, got, 1, "Energy.transfer emits one Transfer event") + + addr, _ := got[0]["address"].(string) + assert.Equal(t, energyAddr.String(), addr) + + topics, _ := got[0]["topics"].([]any) + require.NotEmpty(t, topics) + assert.Equal(t, transferTopic, topics[0]) + }) + + t.Run("eth_getLogs_log_fields", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, + }) + var got []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &got)) + require.Len(t, got, 1) + log := got[0] + + var blockNum hexutil.Uint64 + require.NoError(t, json.Unmarshal(log["blockNumber"], &blockNum)) + assert.Equal(t, uint64(1), uint64(blockNum)) + + var gotTxHash common.Hash + require.NoError(t, json.Unmarshal(log["transactionHash"], &gotTxHash)) + assert.Equal(t, txHash, gotTxHash.Hex()) + + var txIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(log["transactionIndex"], &txIdx)) + assert.Equal(t, uint64(0), uint64(txIdx)) + + var gotBlockHash common.Hash + require.NoError(t, json.Unmarshal(log["blockHash"], &gotBlockHash)) + assert.Equal(t, blockHash, gotBlockHash.Hex()) + + var logIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(log["logIndex"], &logIdx)) + assert.Equal(t, uint64(0), uint64(logIdx)) + + var removed bool + require.NoError(t, json.Unmarshal(log["removed"], &removed)) + assert.False(t, removed) + + var data hexutil.Bytes + require.NoError(t, json.Unmarshal(log["data"], &data)) + assert.Greater(t, len(data), 0, "ABI-encoded transfer amount should be non-empty") + }) + + t.Run("eth_getLogs_blockHash_with_events", func(t *testing.T) { + // EIP-234: query by blockHash on a block that contains events. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"blockHash": blockHash}, + }) + var got []map[string]any + require.NoError(t, json.Unmarshal(result, &got)) + require.Len(t, got, 1) + addr, _ := got[0]["address"].(string) + assert.Equal(t, energyAddr.String(), addr) + }) + + t.Run("eth_getLogs_address_filter", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "address": energyAddr.String(), + }, + }) + var got []map[string]any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1) + }) + + t.Run("eth_getLogs_multi_address_filter", func(t *testing.T) { + // Array-of-addresses form: one matching address, one non-matching. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "address": []string{energyAddr.String(), "0x0000000000000000000000000000000000000001"}, + }, + }) + var got []map[string]any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1) + }) + + t.Run("eth_getLogs_topic_filter", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "topics": []any{transferTopic}, + }, + }) + var got []map[string]any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1) + }) + + t.Run("eth_getLogs_topic_null_wildcard", func(t *testing.T) { + // Per the Ethereum spec, null at a topic position is a wildcard. + // The ERC-20 Transfer event has topic1 = the from address (indexed). + // Filtering [null, senderTopic] means: any topic0, topic1 must match sender. + senderTopic := common.BytesToHash(sender.Address[:]).Hex() + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "topics": []any{nil, senderTopic}, + }, + }) + var got []map[string]any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1, "null wildcard at topic0 should still match the Transfer event") + }) + + t.Run("eth_getLogs_address_mismatch_returns_empty", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "address": "0x0000000000000000000000000000000000000001", + }, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got) + }) +} + +// TestLogsHandlerVcTxsExcluded verifies that events from TypeLegacy VeChain +// transactions are not returned by eth_getLogs, even though they are stored in logdb. +func TestLogsHandlerVcTxsExcluded(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + // Build a TypeLegacy VeChain tx that calls Energy.transfer (emits a Transfer event). + vcCallTx := testutil.BuildVcCallTx(t, c, sender, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(vcCallTx)) + + ts := testutil.NewTestServer(t, logs.New(c.Repo(), c.LogDB(), 100, 1000)) + + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got, "events from TypeLegacy VeChain txs must not appear in eth_getLogs") +} + +// TestLogsHandlerInvalidParams verifies that malformed filter fields produce RPC errors. +func TestLogsHandlerInvalidParams(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, logs.New(fx.chain.Repo(), fx.chain.LogDB(), 100, 1000)) + + t.Run("eth_getLogs_invalid_address", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "address": "0xinvalid", + }, + }) + assert.NotNil(t, rpcErr) + }) + + t.Run("eth_getLogs_invalid_topic", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "topics": []any{"0xinvalid"}, + }, + }) + assert.NotNil(t, rpcErr) + }) +} + +// TestLogsHandlerProjectedIndices verifies that transactionIndex and logIndex in +// eth_getLogs responses are projected relative to ETH-typed transactions only, not +// the canonical VeChain block position. In a block with [vcTx, ethTx], the ethTx's +// event must have transactionIndex=0 and logIndex=0, not 1 and 1. +func TestLogsHandlerProjectedIndices(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + // Mint a mixed block: VeChain tx first (canonical idx 0), ETH tx second (canonical idx 1). + // The VeChain tx emits a Transfer event (stored in logdb as txIndex=0, logIndex=0). + // The ETH tx emits a Transfer event (stored in logdb as txIndex=1, logIndex=1). + vcCallTx := testutil.BuildVcCallTx(t, c, sender, &energyAddr, callData, 200_000) + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(vcCallTx, ethCallTx)) + + ts := testutil.NewTestServer(t, logs.New(c.Repo(), c.LogDB(), 100, 1000)) + + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, + }) + var got []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &got)) + require.Len(t, got, 1, "only the ETH tx's event should be returned") + + var txIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(got[0]["transactionIndex"], &txIdx)) + assert.Equal(t, uint64(0), uint64(txIdx), + "transactionIndex must be the projected ETH index (0), not the canonical VeChain index (1)") + + var logIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(got[0]["logIndex"], &logIdx)) + assert.Equal(t, uint64(0), uint64(logIdx), + "logIndex must be the projected ETH log index (0), not the VeChain block-wide count (1)") +} + +// TestLogsHandlerFromBlockDefault verifies that an absent fromBlock defaults to +// "latest" per the Ethereum spec, not to block 0 (genesis). +func TestLogsHandlerFromBlockDefault(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + // Block 1: contains an ETH event. + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + // Block 2: empty — no transactions, no events. + require.NoError(t, c.MintBlock()) + + ts := testutil.NewTestServer(t, logs.New(c.Repo(), c.LogDB(), 100, 1000)) + + t.Run("absent_fromBlock_defaults_to_latest", func(t *testing.T) { + // No fromBlock → defaults to latest (block 2). Block 2 has no events. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"toBlock": "latest"}, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got, "absent fromBlock should default to latest (block 2), which has no events") + }) + + t.Run("explicit_fromBlock_reaches_earlier_event", func(t *testing.T) { + // Explicit fromBlock=1 includes block 1 where the ETH event lives. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x1", "toBlock": "latest"}, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1, "explicit fromBlock=1 should reach the event in block 1") + }) +} + +// TestLogsHandlerLogsLimit verifies that when logdb returns more rows than logsLimit, +// eth_getLogs returns an explicit error instead of silently truncating the result. +func TestLogsHandlerLogsLimit(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + // Mint two separate ETH call txs so there are 2 events in logdb. + ethCallTx1 := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + ethCallTx2 := testutil.BuildEthCallTx(t, chainID, genesis.DevAccounts()[2], 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx1, ethCallTx2)) + + // logsLimit=1: querying 2 events exceeds the limit. + ts := testutil.NewTestServer(t, logs.New(c.Repo(), c.LogDB(), 100, 1)) + + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, + }) + assert.NotNil(t, rpcErr, "should return an error when result exceeds logsLimit") +} + +// TestLogsHandlerEIP234MutualExclusion verifies that passing blockHash together with +// fromBlock or toBlock is rejected with an InvalidParams error, per EIP-234. +func TestLogsHandlerEIP234MutualExclusion(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, logs.New(fx.chain.Repo(), fx.chain.LogDB(), 100, 1000)) + + t.Run("blockHash_with_fromBlock", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{ + "blockHash": fx.blockHash, + "fromBlock": "0x0", + }, + }) + assert.Equal(t, -32602, rpcErr.Code, "should be InvalidParams") + }) + + t.Run("blockHash_with_toBlock", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{ + "blockHash": fx.blockHash, + "toBlock": "latest", + }, + }) + assert.Equal(t, -32602, rpcErr.Code, "should be InvalidParams") + }) +} + +// TestLogsHandlerUnknownBlockHash verifies that an unresolvable blockHash returns a +// server error (matching go-ethereum semantics), not a silent empty result. +func TestLogsHandlerUnknownBlockHash(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, logs.New(fx.chain.Repo(), fx.chain.LogDB(), 100, 1000)) + + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{"blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001"}, + }) + assert.Equal(t, -32000, rpcErr.Code, "unknown blockHash should return server error (-32000)") +} + +// TestLogsHandlerReversedRange verifies that fromBlock > toBlock returns an InvalidParams +// error (matching go-ethereum semantics), not a silent empty or DB over-scan. +func TestLogsHandlerReversedRange(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + require.NoError(t, c.MintBlock()) // advance to block 1 + ts := testutil.NewTestServer(t, logs.New(c.Repo(), c.LogDB(), 100, 1000)) + + rpcErr := testutil.CallExpectError(t, ts, "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x1", "toBlock": "0x0"}, + }) + assert.Equal(t, -32602, rpcErr.Code, "fromBlock > toBlock should return InvalidParams (-32602)") +} + +// TestLogsHandlerORTopicFilter verifies that an array at a topic position is treated as +// OR: topics: [["A","B"]] matches logs whose topic0 equals A OR B. +func TestLogsHandlerORTopicFilter(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + ts := testutil.NewTestServer(t, logs.New(c.Repo(), c.LogDB(), 100, 1000)) + + transferEvent, ok := builtin.Energy.ABI.EventByName("Transfer") + require.True(t, ok) + transferTopic := common.Hash(transferEvent.ID()).Hex() + noMatchTopic := "0x0000000000000000000000000000000000000000000000000000000000000001" + + t.Run("OR_includes_matching_topic", func(t *testing.T) { + // [transferTopic, noMatchTopic] at position 0 — should match the Transfer event. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "topics": []any{[]any{transferTopic, noMatchTopic}}, + }, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1, "OR filter including transferTopic should return the event") + }) + + t.Run("OR_no_matching_topic", func(t *testing.T) { + // Two non-matching topics OR-ed at position 0 — should return nothing. + result := testutil.Call(t, ts, "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "topics": []any{[]any{noMatchTopic, "0x0000000000000000000000000000000000000000000000000000000000000002"}}, + }, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got, "OR filter with no matching topics should return empty") + }) +} diff --git a/rpc/logs_types.go b/rpc/logs_types.go new file mode 100644 index 0000000000..5fa9ac6e6e --- /dev/null +++ b/rpc/logs_types.go @@ -0,0 +1,17 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import "encoding/json" + +// EthLogFilter mirrors the Ethereum eth_getLogs / eth_newFilter parameter object. +type EthLogFilter struct { + FromBlock *string `json:"fromBlock"` + ToBlock *string `json:"toBlock"` + Address json.RawMessage `json:"address"` // string | []string | null + Topics []json.RawMessage `json:"topics"` // each: null | string | []string + BlockHash *string `json:"blockHash"` // EIP-234: mutually exclusive with from/toBlock +} diff --git a/rpc/parse.go b/rpc/parse.go new file mode 100644 index 0000000000..95d2d41eb0 --- /dev/null +++ b/rpc/parse.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "encoding/hex" + "fmt" + + "github.com/vechain/thor/v2/thor" +) + +// ParseBytes32Compact parses a 0x-prefixed hex string of variable length into a +// right-aligned Bytes32. Unlike thor.ParseBytes32, it accepts compact Ethereum +// encoding such as "0x0" for storage slot 0. +func ParseBytes32Compact(s string) (thor.Bytes32, error) { + if len(s) < 2 || s[0] != '0' || (s[1] != 'x' && s[1] != 'X') { + return thor.Bytes32{}, fmt.Errorf("invalid hex %q", s) + } + raw := s[2:] + if len(raw)%2 != 0 { + raw = "0" + raw + } + b, err := hex.DecodeString(raw) + if err != nil { + return thor.Bytes32{}, fmt.Errorf("invalid hex %q: %w", s, err) + } + if len(b) > 32 { + return thor.Bytes32{}, fmt.Errorf("hex value too long for bytes32 %q", s) + } + var h32 thor.Bytes32 + copy(h32[32-len(b):], b) + return h32, nil +} diff --git a/rpc/parse_test.go b/rpc/parse_test.go new file mode 100644 index 0000000000..f9a425dc11 --- /dev/null +++ b/rpc/parse_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/vechain/thor/v2/thor" +) + +func TestParseBytes32Compact(t *testing.T) { + tests := []struct { + name string + input string + want thor.Bytes32 + wantErr bool + }{ + { + name: "compact zero", + input: "0x0", + want: thor.Bytes32{}, + }, + { + name: "compact odd hex", + input: "0xa", + want: thor.Bytes32{31: 0x0a}, + }, + { + name: "compact two bytes", + input: "0x1234", + want: thor.Bytes32{30: 0x12, 31: 0x34}, + }, + { + name: "full 64 hex chars with prefix", + input: "0x" + "11223344556677889900aabbccddeeff" + "11223344556677889900aabbccddeeff", + want: thor.Bytes32{ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0x00, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, + 0x99, 0x00, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, + }, + }, + { + name: "missing 0x prefix", + input: "11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseBytes32Compact(tt.input) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} diff --git a/rpc/simulation/handler.go b/rpc/simulation/handler.go new file mode 100644 index 0000000000..a634d944af --- /dev/null +++ b/rpc/simulation/handler.go @@ -0,0 +1,174 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package simulation + +import ( + "encoding/json" + "math/big" + + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/runtime" + "github.com/vechain/thor/v2/state" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/xenv" +) + +// Handler implements eth_call and eth_estimateGas JSON-RPC methods. +type Handler struct { + repo *chain.Repository + stater *state.Stater + forkConfig *thor.ForkConfig + callGasLimit uint64 +} + +// New creates a simulation Handler. +func New(repo *chain.Repository, stater *state.Stater, forkConfig *thor.ForkConfig, callGasLimit uint64) *Handler { + return &Handler{repo: repo, stater: stater, forkConfig: forkConfig, callGasLimit: callGasLimit} +} + +// Mount registers all simulation methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_call", h.ethCall) + s.Register("eth_estimateGas", h.ethEstimateGas) +} + +func (h *Handler) ethCall(req jsonrpc.Request) jsonrpc.Response { + var params rpc.CallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + + out, _, execErr := h.simulate(params.Args, params.Tag, h.callGasLimit) + if execErr != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, execErr.Error()) + } + if out.VMErr != nil { + return jsonrpc.ErrResponseWithData(req.ID, jsonrpc.CodeServerError, "execution reverted", hexutil.Encode(out.Data)) + } + return jsonrpc.OkResponse(req.ID, hexutil.Bytes(out.Data)) +} + +func (h *Handler) ethEstimateGas(req jsonrpc.Request) jsonrpc.Response { + var params rpc.CallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + + limit := h.callGasLimit + if params.Args.Gas != nil && uint64(*params.Args.Gas) < limit { + limit = uint64(*params.Args.Gas) + } + + // Single-pass estimate: run at the full gas limit to check for revert, then return + // gasUsed + intrinsic. This over-estimates for contracts whose behaviour changes based + // on available gas (e.g. EIP-1283 stipend checks). A binary search (hi=limit, lo=gasUsed) + // would find the true minimum, but adds latency and is not required for correctness — + // wallets and SDKs typically add a 20–25% buffer on top of estimates anyway. + // TODO: implement binary search if gas-sensitive contracts become common on VeChain. + out, _, execErr := h.simulate(params.Args, params.Tag, limit) + if execErr != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, execErr.Error()) + } + if out.VMErr != nil { + return jsonrpc.ErrResponseWithData(req.ID, jsonrpc.CodeServerError, "execution reverted", hexutil.Encode(out.Data)) + } + + evmGasUsed := limit - out.LeftOverGas + + // PrepareClause does not charge intrinsic gas (tx base + per-clause overhead). + // Add it explicitly so the estimate matches what the network will deduct. + var to *thor.Address + if params.Args.To != nil { + addr := thor.Address(*params.Args.To) + to = &addr + } + intrinsic, err := tx.IntrinsicGas(tx.NewClause(to).WithData(params.Args.Data)) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + + // Edge case: if the call uses exactly gasLimit (leftover == 0), this returns + // callGasLimit + intrinsic — the absolute maximum. The estimate may still be too + // low for the actual tx, but returning the ceiling is acceptable. + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(evmGasUsed+intrinsic)) +} + +func (h *Handler) simulate(args rpc.CallArgs, tag string, gasLimit uint64) (*runtime.Output, *state.State, error) { + summary, err := ethconvert.ResolveBlockTag(tag, h.repo) + if err != nil { + return nil, nil, err + } + header := summary.Header + + st := h.stater.NewState(summary.Root()) + signer, _ := header.Signer() + + rt := runtime.New( + h.repo.NewChain(header.ParentID()), + st, + &xenv.BlockContext{ + Beneficiary: header.Beneficiary(), + Signer: signer, + Number: header.Number(), + Time: header.Timestamp(), + GasLimit: header.GasLimit(), + TotalScore: header.TotalScore(), + BaseFee: header.BaseFee(), + }, + h.forkConfig, + ) + + var origin thor.Address + if args.From != nil { + origin = thor.Address(*args.From) + } + var gasPrice *big.Int + if args.MaxFeePerGas != nil || args.MaxPriorityFeePerGas != nil { + maxFee := new(big.Int) + if args.MaxFeePerGas != nil { + maxFee = (*big.Int)(args.MaxFeePerGas) + } + maxPriority := new(big.Int) + if args.MaxPriorityFeePerGas != nil { + maxPriority = (*big.Int)(args.MaxPriorityFeePerGas) + } + gasPrice = ethconvert.CalcEffectiveGasPrice(maxFee, maxPriority, header.BaseFee()) + } else if args.GasPrice != nil { + gasPrice = (*big.Int)(args.GasPrice) + } else { + gasPrice = new(big.Int) + } + var value *big.Int + if args.Value != nil { + value = (*big.Int)(args.Value) + } else { + value = new(big.Int) + } + + var to *thor.Address + if args.To != nil { + addr := thor.Address(*args.To) + to = &addr + } + + clause := tx.NewClause(to).WithData(args.Data).WithValue(value) + txCtx := &xenv.TransactionContext{ + Origin: origin, + GasPrice: gasPrice, + ClauseCount: 1, + Type: tx.TypeEthDynamicFee, + } + + exec, _ := rt.PrepareClause(clause, 0, gasLimit, txCtx) + out, _, err := exec() + return out, st, err +} diff --git a/rpc/simulation/handler_test.go b/rpc/simulation/handler_test.go new file mode 100644 index 0000000000..1b6a6d1086 --- /dev/null +++ b/rpc/simulation/handler_test.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package simulation_test + +import ( + "encoding/json" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/rpc/simulation" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/thor" +) + +type fixture struct { + chain *testchain.Chain + forks thor.ForkConfig + senderAddr string + recipientAddr string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + // No block minted — genesis dev accounts are funded and simulation runs + // against the latest state directly. + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + return &fixture{ + chain: c, + forks: testchain.DefaultForkConfig, + senderAddr: sender.Address.String(), + recipientAddr: recipient.Address.String(), + } +} + +func TestSimulationHandler(t *testing.T) { + fx := newFixture(t) + ts := testutil.NewTestServer(t, simulation.New( + fx.chain.Repo(), fx.chain.Stater(), &fx.forks, 1_000_000, + )) + + t.Run("eth_call_transfer", func(t *testing.T) { + // A plain VET transfer returns empty output data. + result := testutil.Call(t, ts, "eth_call", []any{ + map[string]any{ + "from": fx.senderAddr, + "to": fx.recipientAddr, + "value": "0x1", + }, + "latest", + }) + var data hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &data)) + assert.Empty(t, data) + }) + + t.Run("eth_estimateGas_transfer", func(t *testing.T) { + // A simple EOA-to-EOA transfer costs exactly 21000 gas: + // 5000 (tx base) + 16000 (per-clause) = 21000 intrinsic, 0 EVM gas. + result := testutil.Call(t, ts, "eth_estimateGas", []any{ + map[string]any{ + "from": fx.senderAddr, + "to": fx.recipientAddr, + "value": "0x1", + }, + }) + var gasEst hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &gasEst)) + assert.Equal(t, uint64(21000), uint64(gasEst)) + }) + + t.Run("eth_estimateGas_respects_gas_cap", func(t *testing.T) { + // Providing a gas cap lower than the intrinsic cost should still succeed + // for a zero-opcode call (EVM gas used = 0, only intrinsic matters). + // Here we pass gas = 21000 which is exactly the estimate. + result := testutil.Call(t, ts, "eth_estimateGas", []any{ + map[string]any{ + "from": fx.senderAddr, + "to": fx.recipientAddr, + "value": "0x1", + "gas": "0x5208", // 0x5208 = 21000 + }, + }) + var gasEst hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &gasEst)) + assert.Equal(t, uint64(21000), uint64(gasEst)) + }) + + t.Run("eth_call_revert", func(t *testing.T) { + // Call Energy.transfer from a zero-VTHO address — the balance check reverts. + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput( + genesis.DevAccounts()[1].Address, big.NewInt(1), + ) + require.NoError(t, err) + + rpcErr := testutil.CallExpectError(t, ts, "eth_call", []any{ + map[string]any{ + "from": "0x000000000000000000000000000000000000000a", // no VTHO + "to": builtin.Energy.Address.String(), + "data": hexutil.Encode(callData), + }, + "latest", + }) + assert.Equal(t, jsonrpc.CodeServerError, rpcErr.Code) + assert.Equal(t, "execution reverted", rpcErr.Message) + }) + + t.Run("eth_estimateGas_revert", func(t *testing.T) { + // Same call via eth_estimateGas — must also return a server error. + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput( + genesis.DevAccounts()[1].Address, big.NewInt(1), + ) + require.NoError(t, err) + + rpcErr := testutil.CallExpectError(t, ts, "eth_estimateGas", []any{ + map[string]any{ + "from": "0x000000000000000000000000000000000000000a", + "to": builtin.Energy.Address.String(), + "data": hexutil.Encode(callData), + }, + }) + assert.Equal(t, jsonrpc.CodeServerError, rpcErr.Code) + }) + + t.Run("eth_call_eip1559_gas_price_fields", func(t *testing.T) { + // EIP-1559 callers supply maxFeePerGas + maxPriorityFeePerGas instead of gasPrice. + // A plain transfer with these fields must succeed and return empty output data. + result := testutil.Call(t, ts, "eth_call", []any{ + map[string]any{ + "from": fx.senderAddr, + "to": fx.recipientAddr, + "value": "0x1", + "maxFeePerGas": "0x3B9ACA00", // 1 gwei + "maxPriorityFeePerGas": "0x3B9ACA00", // 1 gwei + }, + "latest", + }) + var data hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &data)) + assert.Empty(t, data) + }) +} diff --git a/rpc/simulation_types.go b/rpc/simulation_types.go new file mode 100644 index 0000000000..915973b0ca --- /dev/null +++ b/rpc/simulation_types.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +// CallArgs mirrors the Ethereum eth_call / eth_estimateGas parameter object. +type CallArgs struct { + From *common.Address `json:"from"` + To *common.Address `json:"to"` + Gas *hexutil.Uint64 `json:"gas"` + GasPrice *hexutil.Big `json:"gasPrice"` + MaxFeePerGas *hexutil.Big `json:"maxFeePerGas"` + MaxPriorityFeePerGas *hexutil.Big `json:"maxPriorityFeePerGas"` + Value *hexutil.Big `json:"value"` + Data hexutil.Bytes `json:"data"` +} + +// CallParams holds the arguments for eth_call and eth_estimateGas. +// Tag is optional and defaults to "latest" when omitted. +type CallParams struct { + Args CallArgs + Tag string +} + +func (p *CallParams) UnmarshalJSON(data []byte) error { + var raws []json.RawMessage + if err := json.Unmarshal(data, &raws); err != nil || len(raws) < 1 { + return fmt.Errorf("expected [callArgs, blockTag?]") + } + if err := json.Unmarshal(raws[0], &p.Args); err != nil { + return fmt.Errorf("invalid call arguments: %w", err) + } + p.Tag = "latest" + if len(raws) >= 2 { + if err := json.Unmarshal(raws[1], &p.Tag); err != nil { + return fmt.Errorf("invalid block tag") + } + } + return nil +} diff --git a/rpc/testutil/testutil.go b/rpc/testutil/testutil.go new file mode 100644 index 0000000000..3a24013d9c --- /dev/null +++ b/rpc/testutil/testutil.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +// Package testutil provides test helpers for the rpc package and its sub-packages. +// It deliberately does NOT import any rpc sub-package so that sub-package tests +// can import testutil without creating a circular dependency. +package testutil + +import ( + "bytes" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/test/datagen" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// BuildEthTx creates a signed EIP-1559 tx from sender (at the given nonce) to to. +func BuildEthTx(t *testing.T, chainID uint64, sender genesis.DevAccount, nonce uint64, to *thor.Address) *tx.Transaction { + t.Helper() + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(chainID). + Nonce(nonce). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(to). + Value(big.NewInt(1e9)). + Build() + ethTx, err := tx.Sign(unsigned, sender.PrivateKey) + require.NoError(t, err) + return ethTx +} + +// BuildEthCallTx creates a signed EIP-1559 contract-call tx (no VET value, arbitrary data). +func BuildEthCallTx(t *testing.T, chainID uint64, sender genesis.DevAccount, nonce uint64, to *thor.Address, data []byte, gas uint64) *tx.Transaction { + t.Helper() + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(chainID). + Nonce(nonce). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(gas). + To(to). + Value(big.NewInt(0)). + Data(data). + Build() + ethTx, err := tx.Sign(unsigned, sender.PrivateKey) + require.NoError(t, err) + return ethTx +} + +// BuildVcCallTx creates a signed TypeLegacy VeChain tx with a contract-call clause (no VET value, arbitrary data). +func BuildVcCallTx(t *testing.T, c *testchain.Chain, sender genesis.DevAccount, to *thor.Address, data []byte, gas uint64) *tx.Transaction { + t.Helper() + vcTx := tx.NewBuilder(tx.TypeLegacy). + ChainTag(c.Repo().ChainTag()). + BlockRef(tx.NewBlockRef(c.Repo().BestBlockSummary().Header.Number())). + Expiration(1000). + GasPriceCoef(255). + Gas(gas). + Nonce(datagen.RandUint64()). + Clause(tx.NewClause(to).WithData(data)). + Build() + return tx.MustSign(vcTx, sender.PrivateKey) +} + +// BuildVcTx creates a signed TypeLegacy VeChain tx from sender to to. +func BuildVcTx(t *testing.T, c *testchain.Chain, sender genesis.DevAccount, to *thor.Address) *tx.Transaction { + t.Helper() + vcTx := tx.NewBuilder(tx.TypeLegacy). + ChainTag(c.Repo().ChainTag()). + BlockRef(tx.NewBlockRef(c.Repo().BestBlockSummary().Header.Number())). + Expiration(1000). + GasPriceCoef(255). + Gas(21000). + Nonce(datagen.RandUint64()). + Clause(tx.NewClause(to).WithValue(big.NewInt(1e9))). + Build() + return tx.MustSign(vcTx, sender.PrivateKey) +} + +// Mounter is satisfied by any sub-package handler that exposes Mount. +type Mounter interface { + Mount(s *jsonrpc.Server) +} + +// NewTestServer creates an httptest.Server with only m's methods registered. +// Sub-package tests use this for focused isolation — only the handler under test +// is mounted, so an accidental call to another namespace fails with method-not-found. +func NewTestServer(t *testing.T, m Mounter) *httptest.Server { + t.Helper() + srv := jsonrpc.NewServer() + m.Mount(srv) + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + return ts +} + +// Call posts a JSON-RPC 2.0 request and returns the result field. +// The test fails immediately if the server returns an RPC error. +func Call(t *testing.T, ts *httptest.Server, method string, params any) json.RawMessage { + t.Helper() + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }) + require.NoError(t, err) + + resp, err := http.Post(ts.URL+"/rpc", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + if rpcResp.Error != nil { + t.Fatalf("unexpected RPC error for %s: code=%d msg=%s", method, rpcResp.Error.Code, rpcResp.Error.Message) + } + return rpcResp.Result +} + +// CallExpectError posts a JSON-RPC 2.0 request and returns the RPC error. +// The test fails if no error is returned. +func CallExpectError(t *testing.T, ts *httptest.Server, method string, params any) *jsonrpc.RPCError { + t.Helper() + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }) + require.NoError(t, err) + + resp, err := http.Post(ts.URL+"/rpc", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.NotNil(t, rpcResp.Error, "expected RPC error for method %s but got result: %s", method, rpcResp.Result) + return rpcResp.Error +} diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go new file mode 100644 index 0000000000..77b39654b1 --- /dev/null +++ b/rpc/transactions/handler.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package transactions + +import ( + "encoding/json" + + "github.com/ethereum/go-ethereum/common" + + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/txpool" +) + +// Handler implements transaction JSON-RPC methods. +type Handler struct { + repo *chain.Repository + txPool txpool.Pool +} + +// New creates a transactions Handler. +func New(repo *chain.Repository, txPool txpool.Pool) *Handler { + return &Handler{repo: repo, txPool: txPool} +} + +// Mount registers all transaction methods on the dispatcher. +func (h *Handler) Mount(s *jsonrpc.Server) { + s.Register("eth_getTransactionByHash", h.ethGetTransactionByHash) + s.Register("eth_getTransactionByBlockHashAndIndex", h.ethGetTransactionByBlockHashAndIndex) + s.Register("eth_getTransactionByBlockNumberAndIndex", h.ethGetTransactionByBlockNumberAndIndex) + s.Register("eth_getTransactionReceipt", h.ethGetTransactionReceipt) + s.Register("eth_sendRawTransaction", h.ethSendRawTransaction) +} + +type ethTxContext struct { + transaction *tx.Transaction + meta *chain.TxMeta + header *block.Header + receipts tx.Receipts +} + +// fetchEthTxContext looks up an ETH-typed tx by hash and loads its block header and receipts. +// Returns nil, nil when the tx does not exist or is not an ETH-typed transaction. +func (h *Handler) fetchEthTxContext(bestChain *chain.Chain, id [32]byte) (*ethTxContext, error) { + t, meta, err := bestChain.GetTransaction(id) + if err != nil || t.Type() != tx.TypeEthDynamicFee { + return nil, nil + } + header, err := bestChain.GetBlockHeader(meta.BlockNum) + if err != nil { + return nil, err + } + receipts, err := h.repo.GetBlockReceipts(header.ID()) + if err != nil { + return nil, err + } + return ðTxContext{transaction: t, meta: meta, header: header, receipts: receipts}, nil +} + +func (h *Handler) ethGetTransactionByHash(req jsonrpc.Request) jsonrpc.Response { + var params rpc.TxHashParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + + ctx, err := h.fetchEthTxContext(h.repo.NewBestChain(), params.Hash) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + if ctx == nil { + return jsonrpc.OkResponse(req.ID, nil) + } + + projIdx := ethconvert.ProjectedEthIndex(ctx.receipts, ctx.meta.Index) + return jsonrpc.OkResponse(req.ID, ethconvert.ToEthTx( + ctx.transaction, h.repo.ChainID(), + common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), + projIdx, ctx.header.BaseFee(), + )) +} + +func (h *Handler) ethGetTransactionByBlockHashAndIndex(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagAndIndexParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + summary, err := ethconvert.ResolveBlockTag(params.Tag, h.repo) + if err != nil { + return jsonrpc.OkResponse(req.ID, nil) + } + return h.txByBlockAndEthIndex(req, summary.Header, params.Index) +} + +func (h *Handler) ethGetTransactionByBlockNumberAndIndex(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagAndIndexParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + summary, err := ethconvert.ResolveBlockTag(params.Tag, h.repo) + if err != nil { + return jsonrpc.OkResponse(req.ID, nil) + } + return h.txByBlockAndEthIndex(req, summary.Header, params.Index) +} + +func (h *Handler) txByBlockAndEthIndex(req jsonrpc.Request, header *block.Header, ethIdx uint64) jsonrpc.Response { + blk, err := h.repo.GetBlock(header.ID()) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + + blockHash := common.Hash(header.ID()) + blockNum := uint64(header.Number()) + var projIdx uint64 + + for _, t := range blk.Transactions() { + if t.Type() != tx.TypeEthDynamicFee { + continue + } + if projIdx == ethIdx { + return jsonrpc.OkResponse(req.ID, ethconvert.ToEthTx(t, h.repo.ChainID(), blockHash, blockNum, projIdx, header.BaseFee())) + } + projIdx++ + } + return jsonrpc.OkResponse(req.ID, nil) +} + +func (h *Handler) ethGetTransactionReceipt(req jsonrpc.Request) jsonrpc.Response { + var params rpc.TxHashParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + + ctx, err := h.fetchEthTxContext(h.repo.NewBestChain(), params.Hash) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) + } + if ctx == nil { + return jsonrpc.OkResponse(req.ID, nil) + } + + receipt := ctx.receipts[ctx.meta.Index] + projIdx := ethconvert.ProjectedEthIndex(ctx.receipts, ctx.meta.Index) + cumGas := ethconvert.CumulativeEthGasUsed(ctx.receipts, ctx.meta.Index) + logOff := ethconvert.EthLogOffset(ctx.receipts, ctx.meta.Index) + + return jsonrpc.OkResponse(req.ID, ethconvert.ToEthReceipt( + ctx.transaction, receipt, + common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), + projIdx, cumGas, logOff, ctx.header.BaseFee(), + )) +} + +func (h *Handler) ethSendRawTransaction(req jsonrpc.Request) jsonrpc.Response { + var params rpc.RawTxParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + + parsed := new(tx.Transaction) + if err := parsed.UnmarshalBinary(params.Raw); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + if parsed.Type() != tx.TypeEthDynamicFee { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "only EIP-1559 (type 2) transactions are accepted") + } + if err := h.txPool.AddLocal(parsed); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, err.Error()) + } + return jsonrpc.OkResponse(req.ID, common.Hash(parsed.ID()).Hex()) +} diff --git a/rpc/transactions/handler_test.go b/rpc/transactions/handler_test.go new file mode 100644 index 0000000000..33801464b7 --- /dev/null +++ b/rpc/transactions/handler_test.go @@ -0,0 +1,246 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package transactions_test + +import ( + "encoding/hex" + "encoding/json" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/rpc/transactions" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/txpool" +) + +type fixture struct { + chain *testchain.Chain + ethTxHash string + vcTxHash string + blockHash string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + vcTx := testutil.BuildVcTx(t, c, sender, &recipient.Address) + ethTx := testutil.BuildEthTx(t, chainID, sender, 0, &recipient.Address) + require.NoError(t, c.MintBlock(vcTx, ethTx)) + bestBlock, err := c.BestBlock() + require.NoError(t, err) + return &fixture{ + chain: c, + ethTxHash: ethTx.ID().String(), + vcTxHash: vcTx.ID().String(), + blockHash: bestBlock.Header().ID().String(), + } +} + +func TestTransactionsHandler(t *testing.T) { + fx := newFixture(t) + pool := txpool.New(fx.chain.Repo(), fx.chain.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, &testchain.DefaultForkConfig) + ts := testutil.NewTestServer(t, transactions.New(fx.chain.Repo(), pool)) + + // ---- eth_getTransactionByHash ---- + + t.Run("eth_getTransactionByHash_eth", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByHash", []any{fx.ethTxHash}) + var txObj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &txObj)) + + var gotHash string + require.NoError(t, json.Unmarshal(txObj["hash"], &gotHash)) + assert.Equal(t, fx.ethTxHash, gotHash) + + // The ETH tx sits at canonical index 1 but is the only ETH tx → projected index 0. + var txIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(txObj["transactionIndex"], &txIdx)) + assert.Equal(t, uint64(0), uint64(txIdx)) + }) + + t.Run("eth_getTransactionByHash_vechain", func(t *testing.T) { + // VeChain legacy txs are invisible from the ETH endpoint. + result := testutil.Call(t, ts, "eth_getTransactionByHash", []any{fx.vcTxHash}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getTransactionByHash_unknown", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByHash", []any{"0x0000000000000000000000000000000000000000000000000000000000000001"}) + assert.Equal(t, "null", string(result)) + }) + + // ---- eth_getTransactionByBlockHashAndIndex ---- + + t.Run("eth_getTransactionByBlockHashAndIndex", func(t *testing.T) { + // Projected ETH index 0x0 = first (and only) ETH tx in the block. + result := testutil.Call(t, ts, "eth_getTransactionByBlockHashAndIndex", []any{fx.blockHash, "0x0"}) + var txObj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &txObj)) + + var gotHash string + require.NoError(t, json.Unmarshal(txObj["hash"], &gotHash)) + assert.Equal(t, fx.ethTxHash, gotHash) + }) + + t.Run("eth_getTransactionByBlockHashAndIndex_outofrange", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByBlockHashAndIndex", []any{fx.blockHash, "0x1"}) + assert.Equal(t, "null", string(result)) + }) + + // ---- eth_getTransactionByBlockNumberAndIndex ---- + + t.Run("eth_getTransactionByBlockNumberAndIndex", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x1", "0x0"}) + var txObj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &txObj)) + + var gotHash string + require.NoError(t, json.Unmarshal(txObj["hash"], &gotHash)) + assert.Equal(t, fx.ethTxHash, gotHash) + }) + + t.Run("eth_getTransactionByBlockNumberAndIndex_outofrange", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x1", "0x1"}) + assert.Equal(t, "null", string(result)) + }) + + // ---- eth_getTransactionReceipt ---- + + t.Run("eth_getTransactionReceipt_eth", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionReceipt", []any{fx.ethTxHash}) + var receipt map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &receipt)) + + var gotHash string + require.NoError(t, json.Unmarshal(receipt["transactionHash"], &gotHash)) + assert.Equal(t, fx.ethTxHash, gotHash) + + var txIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["transactionIndex"], &txIdx)) + assert.Equal(t, uint64(0), uint64(txIdx)) + + var status hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["status"], &status)) + assert.Equal(t, uint64(1), uint64(status), "transfer should succeed") + + var gasUsed hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["gasUsed"], &gasUsed)) + assert.Greater(t, uint64(gasUsed), uint64(0)) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["type"], &txType)) + assert.Equal(t, uint64(tx.TypeEthDynamicFee), uint64(txType)) + + // Simple value transfer emits no events → logsBloom must be all zeros. + var logsBloom hexutil.Bytes + require.NoError(t, json.Unmarshal(receipt["logsBloom"], &logsBloom)) + require.Len(t, logsBloom, 256) + assert.Equal(t, make([]byte, 256), []byte(logsBloom)) + }) + + t.Run("eth_getTransactionReceipt_vechain", func(t *testing.T) { + // VeChain txs have no ETH receipt. + result := testutil.Call(t, ts, "eth_getTransactionReceipt", []any{fx.vcTxHash}) + assert.Equal(t, "null", string(result)) + }) + + // ---- eth_sendRawTransaction ---- + + t.Run("eth_sendRawTransaction_valid", func(t *testing.T) { + // Use a fresh account (index 2) that hasn't sent any ETH tx → nonce 0. + freshSender := genesis.DevAccounts()[2] + freshRecipient := genesis.DevAccounts()[3].Address + + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(fx.chain.ChainID()). + Nonce(0). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(&freshRecipient). + Value(big.NewInt(1e9)). + Build() + freshTx, err := tx.Sign(unsigned, freshSender.PrivateKey) + require.NoError(t, err) + + rawBytes, err := freshTx.MarshalBinary() + require.NoError(t, err) + + result := testutil.Call(t, ts, "eth_sendRawTransaction", []any{"0x" + hex.EncodeToString(rawBytes)}) + var gotHash string + require.NoError(t, json.Unmarshal(result, &gotHash)) + assert.NotEmpty(t, gotHash) + }) + + t.Run("eth_sendRawTransaction_invalid", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_sendRawTransaction", []any{"0xdeadbeef"}) + assert.NotEqual(t, 0, rpcErr.Code) + }) +} + +// TestTransactionReceiptBloom verifies that eth_getTransactionReceipt populates +// logsBloom correctly for an ETH typed tx that emits contract events. +func TestTransactionReceiptBloom(t *testing.T) { + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := c.Repo().ChainID() + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + // Mint a block with an ETH call to Energy.transfer, which emits a Transfer event. + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + ethCallTx := testutil.BuildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, c.MintBlock(ethCallTx)) + + pool := txpool.New(c.Repo(), c.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, &testchain.DefaultForkConfig) + ts := testutil.NewTestServer(t, transactions.New(c.Repo(), pool)) + + result := testutil.Call(t, ts, "eth_getTransactionReceipt", []any{ethCallTx.ID().String()}) + var receipt map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &receipt)) + + // logsBloom must be non-zero: Energy.transfer emits a Transfer event. + var logsBloom hexutil.Bytes + require.NoError(t, json.Unmarshal(receipt["logsBloom"], &logsBloom)) + require.Len(t, logsBloom, 256) + assert.NotEqual(t, make([]byte, 256), []byte(logsBloom), "logsBloom should be non-zero for receipt with events") + + // The bloom must contain the Energy contract address. + var bloom256 [256]byte + copy(bloom256[:], logsBloom) + ethBloom := ethtypes.BytesToBloom(bloom256[:]) + assert.True(t, ethtypes.BloomLookup(ethBloom, common.Address(builtin.Energy.Address)), "receipt bloom should contain Energy contract address") +} diff --git a/rpc/transactions_types.go b/rpc/transactions_types.go new file mode 100644 index 0000000000..5034c83c41 --- /dev/null +++ b/rpc/transactions_types.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package rpc + +import ( + "encoding/json" + "fmt" + + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/thor" +) + +// TxHashParams holds a single transaction hash parameter. +type TxHashParams struct { + Hash thor.Bytes32 +} + +func (p *TxHashParams) UnmarshalJSON(data []byte) error { + var raw [1]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("expected [txHash]") + } + var hashStr string + if err := json.Unmarshal(raw[0], &hashStr); err != nil { + return fmt.Errorf("invalid tx hash") + } + hash, err := thor.ParseBytes32(hashStr) + if err != nil { + return fmt.Errorf("invalid tx hash: %w", err) + } + p.Hash = hash + return nil +} + +// BlockTagAndIndexParams holds a block identifier and a hex-encoded transaction index, +// used by eth_getTransactionByBlockHashAndIndex and eth_getTransactionByBlockNumberAndIndex. +type BlockTagAndIndexParams struct { + Tag string + Index uint64 +} + +func (p *BlockTagAndIndexParams) UnmarshalJSON(data []byte) error { + var raw [2]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("expected [blockTag, index]") + } + if err := json.Unmarshal(raw[0], &p.Tag); err != nil { + return fmt.Errorf("invalid block tag") + } + var idxStr string + if err := json.Unmarshal(raw[1], &idxStr); err != nil { + return fmt.Errorf("invalid index") + } + idx, err := hexutil.DecodeUint64(idxStr) + if err != nil { + return fmt.Errorf("invalid index: %w", err) + } + p.Index = idx + return nil +} + +// RawTxParams holds the hex-decoded bytes of a raw signed transaction, +// used by eth_sendRawTransaction. +type RawTxParams struct { + Raw []byte +} + +func (p *RawTxParams) UnmarshalJSON(data []byte) error { + var raw [1]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("expected [rawTx]") + } + var hexStr string + if err := json.Unmarshal(raw[0], &hexStr); err != nil { + return fmt.Errorf("invalid raw transaction") + } + decoded, err := hexutil.Decode(hexStr) + if err != nil { + return fmt.Errorf("invalid hex encoding: %w", err) + } + p.Raw = decoded + return nil +} diff --git a/rpc/ws/conn.go b/rpc/ws/conn.go new file mode 100644 index 0000000000..f7313698a8 --- /dev/null +++ b/rpc/ws/conn.go @@ -0,0 +1,326 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package ws + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/gorilla/websocket" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/txpool" +) + +// notification is the JSON-RPC push envelope sent for each subscription event. +// It has no "id" field, distinguishing it from a response to a request. +type notification struct { + Jsonrpc string `json:"jsonrpc"` + Method string `json:"method"` + Params notificationParams `json:"params"` +} + +type notificationParams struct { + Subscription string `json:"subscription"` + Result json.RawMessage `json:"result"` +} + +// wsConn manages the lifecycle of a single WebSocket connection: one read loop, +// one write loop, and N subscription goroutines (one per active eth_subscribe call). +type wsConn struct { + conn *websocket.Conn + writeCh chan []byte // pre-serialised JSON frames; closed only after all writers exit + + // connCtx is cancelled on client disconnect or server shutdown. + connCtx context.Context + connCancel context.CancelFunc + + repo *chain.Repository + txPool txpool.Pool + rpcSrv *jsonrpc.Server + + subsMu sync.Mutex + subs map[string]context.CancelFunc // subID → cancel for that sub's goroutine + nextSub atomic.Uint64 + subWg sync.WaitGroup +} + +func newWSConn(conn *websocket.Conn, parentCtx context.Context, repo *chain.Repository, txPool txpool.Pool, rpcSrv *jsonrpc.Server) *wsConn { + ctx, cancel := context.WithCancel(parentCtx) + return &wsConn{ + conn: conn, + writeCh: make(chan []byte, writeBufSize), + connCtx: ctx, + connCancel: cancel, + repo: repo, + txPool: txPool, + rpcSrv: rpcSrv, + subs: make(map[string]context.CancelFunc), + } +} + +// serve runs the read and write loops, blocking until the connection closes. +func (c *wsConn) serve() { + defer func() { + // connCancel stops subscription goroutines; subWg.Wait ensures none + // outlive serve(), so that the caller's wg.Done fires only after full cleanup. + c.connCancel() + c.subWg.Wait() + }() + + c.conn.SetReadLimit(100 * 1024) // 100 KB per frame + + // Pong handler: reset the read deadline each time the peer responds to a ping, + // keeping the connection alive as long as the client is reachable. + if err := c.conn.SetReadDeadline(time.Now().Add(pongWait * time.Second)); err != nil { + return + } + c.conn.SetPongHandler(func(string) error { + return c.conn.SetReadDeadline(time.Now().Add(pongWait * time.Second)) + }) + + // Close the underlying connection when connCtx is cancelled (server shutdown + // or explicit client teardown). This unblocks the blocking ReadMessage call + // in readLoop so serve() can return promptly without goroutine leaks. + go func() { + <-c.connCtx.Done() + c.conn.Close() + }() + + var writeWg sync.WaitGroup + writeWg.Go(func() { + c.writeLoop() + }) + + c.readLoop() // blocks until conn.Close() or read error + c.connCancel() // stop write loop and all subscription goroutines + writeWg.Wait() // wait for write loop to drain and exit +} + +// readLoop reads JSON-RPC frames from the client and dispatches them. +// It exits when the connection is closed or a read error occurs (including +// the pongWait deadline expiring after a missed pong). +func (c *wsConn) readLoop() { + for { + _, msg, err := c.conn.ReadMessage() + if err != nil { + return + } + c.dispatch(msg) + } +} + +// dispatch parses one frame (single or batch) and routes it. +func (c *wsConn) dispatch(msg []byte) { + trimmed := bytes.TrimSpace(msg) + if len(trimmed) == 0 { + return + } + + if trimmed[0] == '[' { + // Batch request. + // TODO: enforce a batch size cap here (the HTTP path uses jsonrpc.maxBatchRequests=10). + // WS batch requests currently have no size limit — a single frame can carry thousands + // of requests, all dispatched synchronously in the read goroutine. + var raws []json.RawMessage + if err := json.Unmarshal(trimmed, &raws); err != nil { + c.send(mustMarshal(jsonrpc.ErrResponse(nil, jsonrpc.CodeParseError, "invalid JSON array: "+err.Error()))) + return + } + responses := make([]jsonrpc.Response, len(raws)) + for i, raw := range raws { + responses[i] = c.dispatchOne(raw) + } + c.send(mustMarshal(responses)) + } else { + resp := c.dispatchOne(trimmed) + c.send(mustMarshal(resp)) + } +} + +func (c *wsConn) dispatchOne(raw []byte) jsonrpc.Response { + var req jsonrpc.Request + if err := json.Unmarshal(raw, &req); err != nil { + return jsonrpc.ErrResponse(nil, jsonrpc.CodeParseError, "invalid JSON: "+err.Error()) + } + switch req.Method { + case "eth_subscribe": + return c.subscribe(req) + case "eth_unsubscribe": + return c.unsubscribe(req) + default: + return c.rpcSrv.Dispatch(req) + } +} + +// subscribe handles eth_subscribe: spawns the appropriate subscription goroutine +// and returns the subscription ID. +func (c *wsConn) subscribe(req jsonrpc.Request) jsonrpc.Response { + var params []json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) == 0 { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [subscriptionType, ...]") + } + var subType string + if err := json.Unmarshal(params[0], &subType); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid subscription type") + } + + subID := hexutil.EncodeUint64(c.nextSub.Add(1)) + + switch subType { + case "newHeads": + c.startSub(subID, func(ctx context.Context) { + runNewHeads(ctx, c, subID) + }) + case "logs": + var filter rpc.EthLogFilter + if len(params) > 1 { + if err := json.Unmarshal(params[1], &filter); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid logs filter: "+err.Error()) + } + } + criteria, err := ethconvert.ParseLogCriteria(filter) + if err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) + } + c.startSub(subID, func(ctx context.Context) { + runLogs(ctx, c, subID, criteria) + }) + case "newPendingTransactions": + c.startSub(subID, func(ctx context.Context) { + runNewPendingTransactions(ctx, c, subID) + }) + default: + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, fmt.Sprintf("unsupported subscription type %q", subType)) + } + + return jsonrpc.OkResponse(req.ID, subID) +} + +// startSub registers a subscription and runs fn in a goroutine. +// The goroutine is tracked in subWg so serve() can wait for all of them. +// TODO: add a per-connection subscription cap to prevent goroutine exhaustion. +// A client can call eth_subscribe unlimited times; each call spawns a goroutine that +// lives until the connection closes. Decide the right cap value before implementing. +func (c *wsConn) startSub(subID string, fn func(context.Context)) { + ctx, cancel := context.WithCancel(c.connCtx) + c.subsMu.Lock() + c.subs[subID] = cancel + c.subsMu.Unlock() + + c.subWg.Go(func() { + defer func() { + c.subsMu.Lock() + delete(c.subs, subID) + c.subsMu.Unlock() + cancel() + }() + fn(ctx) + }) +} + +// unsubscribe handles eth_unsubscribe: cancels the subscription goroutine. +func (c *wsConn) unsubscribe(req jsonrpc.Request) jsonrpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [subscriptionId]") + } + var subID string + if err := json.Unmarshal(params[0], &subID); err != nil { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid subscription id") + } + + c.subsMu.Lock() + cancel, ok := c.subs[subID] + if ok { + delete(c.subs, subID) + } + c.subsMu.Unlock() + if ok { + cancel() + } + return jsonrpc.OkResponse(req.ID, ok) +} + +// writeLoop drains writeCh and sends frames to the client. It also sends +// periodic pings so the pong handler can reset the read deadline on the other +// side, keeping the connection alive through idle periods. +// +// A per-write deadline enforces the disconnect-on-slow-client policy: if the +// client is not consuming frames fast enough the write times out and +// connCancel() closes the connection. +func (c *wsConn) writeLoop() { + pingTicker := time.NewTicker(pingPeriod * time.Second) + defer pingTicker.Stop() + + for { + select { + case data := <-c.writeCh: + if err := c.conn.SetWriteDeadline(time.Now().Add(writeTimeout * time.Second)); err != nil { + return + } + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return + } + case <-pingTicker.C: + if err := c.conn.WriteControl( + websocket.PingMessage, + nil, + time.Now().Add(writeTimeout*time.Second), + ); err != nil { + return + } + case <-c.connCtx.Done(): + return + } + } +} + +// send queues a pre-serialised frame for the write loop. If the buffer is full +// the connection is disconnected: the client is not reading fast enough. +func (c *wsConn) send(data []byte) { + select { + case c.writeCh <- data: + case <-c.connCtx.Done(): + default: + // Buffer full — disconnect the slow client. + c.connCancel() + } +} + +// notify builds and queues a subscription notification frame. +func (c *wsConn) notify(subID string, result any) { + resultBytes, err := json.Marshal(result) + if err != nil { + return + } + data, err := json.Marshal(notification{ + Jsonrpc: "2.0", + Method: "eth_subscription", + Params: notificationParams{Subscription: subID, Result: resultBytes}, + }) + if err != nil { + return + } + c.send(data) +} + +func mustMarshal(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + panic("ws: json.Marshal failed: " + err.Error()) + } + return b +} diff --git a/rpc/ws/handler.go b/rpc/ws/handler.go new file mode 100644 index 0000000000..e790d7c0e3 --- /dev/null +++ b/rpc/ws/handler.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +// Package ws implements Ethereum eth_subscribe / eth_unsubscribe over WebSocket. +// The Handler wraps an existing jsonrpc.Server: plain HTTP POST requests are +// forwarded to it unchanged; WebSocket upgrade requests are served here with +// push-based subscriptions multiplexed on the same connection. +package ws + +import ( + "context" + "net/http" + "sync" + + "github.com/gorilla/websocket" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/txpool" +) + +const ( + pongWait = 60 // seconds — read deadline after each pong + pingPeriod = 42 // seconds — ping interval (7/10 of pongWait) + writeTimeout = 10 // seconds — per-write deadline for data frames; connection is closed on expiry + writeBufSize = 256 // per-connection notification buffer; full buffer triggers disconnect +) + +// Handler is an http.Handler that serves JSON-RPC over both HTTP and WebSocket +// at the same endpoint. HTTP POST requests are forwarded to rpcSrv; WebSocket +// connections gain eth_subscribe / eth_unsubscribe in addition to all registered +// methods. +type Handler struct { + repo *chain.Repository + txPool txpool.Pool + rpcSrv *jsonrpc.Server + upgrader *websocket.Upgrader + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// New creates a Handler. allowedOrigins controls the WebSocket CORS check; +// pass the same slice used for the REST API. +func New(repo *chain.Repository, txPool txpool.Pool, allowedOrigins []string, rpcSrv *jsonrpc.Server) *Handler { + ctx, cancel := context.WithCancel(context.Background()) + return &Handler{ + repo: repo, + txPool: txPool, + rpcSrv: rpcSrv, + upgrader: &websocket.Upgrader{ + EnableCompression: true, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + for _, allowed := range allowedOrigins { + if allowed == origin || allowed == "*" { + return true + } + } + return false + }, + }, + ctx: ctx, + cancel: cancel, + } +} + +// ServeHTTP dispatches WebSocket upgrade requests to the subscription handler +// and all other requests to the underlying jsonrpc.Server. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if websocket.IsWebSocketUpgrade(r) { + h.serveWS(w, r) + return + } + h.rpcSrv.ServeHTTP(w, r) +} + +// Close stops all active WebSocket connections gracefully and waits for them +// to finish. It should be called during server shutdown. +func (h *Handler) Close() { + h.cancel() + h.wg.Wait() +} + +func (h *Handler) serveWS(w http.ResponseWriter, r *http.Request) { + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + // Upgrade writes the error response; nothing more to do. + return + } + + h.wg.Go(func() { + c := newWSConn(conn, h.ctx, h.repo, h.txPool, h.rpcSrv) + c.serve() + }) +} diff --git a/rpc/ws/handler_test.go b/rpc/ws/handler_test.go new file mode 100644 index 0000000000..6afa268492 --- /dev/null +++ b/rpc/ws/handler_test.go @@ -0,0 +1,392 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package ws_test + +import ( + "bytes" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + rpcblocks "github.com/vechain/thor/v2/rpc/blocks" + rpcchain "github.com/vechain/thor/v2/rpc/chain" + "github.com/vechain/thor/v2/rpc/jsonrpc" + rpcws "github.com/vechain/thor/v2/rpc/ws" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/txpool" +) + +type fixture struct { + chain *testchain.Chain + pool *txpool.TxPool + srv *httptest.Server + handler *rpcws.Handler +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + pool := txpool.New(c.Repo(), c.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, &testchain.DefaultForkConfig) + t.Cleanup(pool.Close) + + rpcSrv := jsonrpc.NewServer() + rpcchain.New(c.Repo(), "test/1.0").Mount(rpcSrv) + rpcblocks.New(c.Repo()).Mount(rpcSrv) + + h := rpcws.New(c.Repo(), pool, []string{"*"}, rpcSrv) + t.Cleanup(h.Close) + + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + + return &fixture{chain: c, pool: pool, srv: srv, handler: h} +} + +// wsURL converts the test server's http:// URL to ws://. +func wsURL(srv *httptest.Server) string { + return "ws" + strings.TrimPrefix(srv.URL, "http") + "/rpc" +} + +// dial opens a WebSocket connection to the test server. +func dial(t *testing.T, srv *httptest.Server) *websocket.Conn { + t.Helper() + u := url.URL{Scheme: "ws", Host: strings.TrimPrefix(srv.URL, "http://"), Path: "/rpc"} + conn, resp, err := websocket.DefaultDialer.Dial(u.String(), nil) + require.NoError(t, err) + assert.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + t.Cleanup(func() { conn.Close() }) + return conn +} + +// rpcCall sends a JSON-RPC request over WS and reads the response. +func rpcCall(t *testing.T, conn *websocket.Conn, id int, method string, params any) json.RawMessage { + t.Helper() + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + require.NoError(t, err) + require.NoError(t, conn.WriteMessage(websocket.TextMessage, body)) + + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + + var resp struct { + Result json.RawMessage `json:"result"` + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.Unmarshal(msg, &resp)) + require.Nil(t, resp.Error, "unexpected RPC error: %v", resp.Error) + return resp.Result +} + +// readNotification reads the next eth_subscription notification from the connection. +func readNotification(t *testing.T, conn *websocket.Conn, timeout time.Duration) (subID string, result json.RawMessage) { + t.Helper() + conn.SetReadDeadline(time.Now().Add(timeout)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + + var notif struct { + Method string `json:"method"` + Params struct { + Subscription string `json:"subscription"` + Result json.RawMessage `json:"result"` + } `json:"params"` + } + require.NoError(t, json.Unmarshal(msg, ¬if)) + require.Equal(t, "eth_subscription", notif.Method) + return notif.Params.Subscription, notif.Params.Result +} + +// TestHTTPPassthrough verifies that plain HTTP POST requests still work after +// wrapping jsonrpc.Server with the WebSocket handler. +func TestHTTPPassthrough(t *testing.T) { + fx := newFixture(t) + + body := `{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}` + resp, err := http.Post(fx.srv.URL+"/rpc", "application/json", bytes.NewReader([]byte(body))) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.Nil(t, rpcResp.Error) + require.NotEmpty(t, rpcResp.Result) +} + +// TestNonSubscribeOverWS verifies that regular methods (eth_blockNumber) work +// over a WebSocket connection alongside subscriptions. +func TestNonSubscribeOverWS(t *testing.T) { + fx := newFixture(t) + conn := dial(t, fx.srv) + + result := rpcCall(t, conn, 1, "eth_blockNumber", []any{}) + var blockNum string + require.NoError(t, json.Unmarshal(result, &blockNum)) + assert.Equal(t, "0x0", blockNum) +} + +// TestBatchOverWS verifies that batch JSON-RPC requests work over WebSocket. +func TestBatchOverWS(t *testing.T) { + fx := newFixture(t) + conn := dial(t, fx.srv) + + batch := `[ + {"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}, + {"jsonrpc":"2.0","id":2,"method":"eth_chainId","params":[]} + ]` + require.NoError(t, conn.WriteMessage(websocket.TextMessage, []byte(batch))) + + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + + var responses []struct { + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result"` + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.Unmarshal(msg, &responses)) + require.Len(t, responses, 2) + for _, r := range responses { + assert.Nil(t, r.Error) + } +} + +// TestUnsubscribe verifies that eth_unsubscribe stops notifications. +func TestUnsubscribe(t *testing.T) { + fx := newFixture(t) + conn := dial(t, fx.srv) + + // Subscribe to newHeads. + subResult := rpcCall(t, conn, 1, "eth_subscribe", []any{"newHeads"}) + var subID string + require.NoError(t, json.Unmarshal(subResult, &subID)) + assert.Regexp(t, `^0x[0-9a-f]+$`, subID) + + // Unsubscribe. + unsubResult := rpcCall(t, conn, 2, "eth_unsubscribe", []any{subID}) + var ok bool + require.NoError(t, json.Unmarshal(unsubResult, &ok)) + assert.True(t, ok) + + // Unsubscribing again returns false. + unsubResult2 := rpcCall(t, conn, 3, "eth_unsubscribe", []any{subID}) + var ok2 bool + require.NoError(t, json.Unmarshal(unsubResult2, &ok2)) + assert.False(t, ok2) +} + +// TestNewHeadsSubscription verifies that a newHeads subscription delivers a +// notification containing the new block's hash after a block is minted. +func TestNewHeadsSubscription(t *testing.T) { + fx := newFixture(t) + conn := dial(t, fx.srv) + + subResult := rpcCall(t, conn, 1, "eth_subscribe", []any{"newHeads"}) + var subID string + require.NoError(t, json.Unmarshal(subResult, &subID)) + + require.NoError(t, fx.chain.MintBlock()) + + gotSubID, result := readNotification(t, conn, 3*time.Second) + assert.Equal(t, subID, gotSubID) + + var block struct { + Number string `json:"number"` + Hash string `json:"hash"` + } + require.NoError(t, json.Unmarshal(result, &block)) + assert.Equal(t, "0x1", block.Number) + assert.Regexp(t, `^0x[0-9a-f]{64}$`, block.Hash) +} + +// TestLogsSubscriptionNoEvents verifies that a plain ETH transfer (no events) +// does not trigger a notification on a logs subscription. +func TestLogsSubscriptionNoEvents(t *testing.T) { + fx := newFixture(t) + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + conn := dial(t, fx.srv) + + subResult := rpcCall(t, conn, 1, "eth_subscribe", []any{"logs", map[string]any{}}) + var subID string + require.NoError(t, json.Unmarshal(subResult, &subID)) + + chainID := fx.chain.Repo().ChainID() + ethTx := buildEthTx(t, chainID, sender, 0, &recipient.Address) + require.NoError(t, fx.chain.MintBlock(ethTx)) + + conn.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + _, _, err := conn.ReadMessage() + assert.Error(t, err, "expected read timeout — plain transfer emits no logs") +} + +// TestLogsSubscriptionWithEvents verifies that a logs subscription delivers a +// notification when an ETH-typed transaction emits a matching event. +// Uses Energy.transfer() which emits a Transfer(address,address,uint256) event. +func TestLogsSubscriptionWithEvents(t *testing.T) { + fx := newFixture(t) + sender := genesis.DevAccounts()[3] + + conn := dial(t, fx.srv) + + energyAddr := builtin.Energy.Address + transferEvent, ok := builtin.Energy.ABI.EventByName("Transfer") + require.True(t, ok) + transferTopic := common.Hash(transferEvent.ID()) + + // Subscribe filtering specifically for the Energy contract address. + subResult := rpcCall(t, conn, 1, "eth_subscribe", []any{"logs", map[string]any{ + "address": energyAddr.String(), + }}) + var subID string + require.NoError(t, json.Unmarshal(subResult, &subID)) + + // Build and mint a block containing an Energy.transfer call. + recipient := genesis.DevAccounts()[1] + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + chainID := fx.chain.Repo().ChainID() + ethCallTx := buildEthCallTx(t, chainID, sender, 0, &energyAddr, callData, 200_000) + require.NoError(t, fx.chain.MintBlock(ethCallTx)) + + // Expect a notification carrying the Transfer event log. + gotSubID, result := readNotification(t, conn, 3*time.Second) + assert.Equal(t, subID, gotSubID) + + var log struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Removed bool `json:"removed"` + } + require.NoError(t, json.Unmarshal(result, &log)) + assert.True(t, strings.EqualFold(energyAddr.String(), log.Address)) + require.NotEmpty(t, log.Topics) + assert.True(t, strings.EqualFold(transferTopic.Hex(), log.Topics[0])) + assert.False(t, log.Removed) +} + +// TestNewPendingTransactionsSubscription verifies that a newPendingTransactions +// subscription delivers the hash of an ETH-typed tx when it enters the pool. +func TestNewPendingTransactionsSubscription(t *testing.T) { + fx := newFixture(t) + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + conn := dial(t, fx.srv) + + subResult := rpcCall(t, conn, 1, "eth_subscribe", []any{"newPendingTransactions"}) + var subID string + require.NoError(t, json.Unmarshal(subResult, &subID)) + + chainID := fx.chain.Repo().ChainID() + ethTx := buildEthTx(t, chainID, sender, 0, &recipient.Address) + require.NoError(t, fx.pool.Add(ethTx)) + + gotSubID, result := readNotification(t, conn, 3*time.Second) + assert.Equal(t, subID, gotSubID) + + var txHash string + require.NoError(t, json.Unmarshal(result, &txHash)) + assert.Equal(t, "0x"+strings.ToLower(ethTx.ID().String()[2:]), strings.ToLower(txHash)) +} + +// TestUnsupportedSubscriptionType verifies that an unknown subscription type +// returns a JSON-RPC error. +func TestUnsupportedSubscriptionType(t *testing.T) { + fx := newFixture(t) + conn := dial(t, fx.srv) + + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_subscribe", + "params": []any{"syncing"}, + }) + require.NoError(t, conn.WriteMessage(websocket.TextMessage, body)) + + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + + var resp struct { + Error *jsonrpc.RPCError `json:"error"` + } + require.NoError(t, json.Unmarshal(msg, &resp)) + require.NotNil(t, resp.Error) + assert.Equal(t, jsonrpc.CodeInvalidParams, resp.Error.Code) +} + +// TestWSURL is a quick smoke test confirming the wsURL helper produces the right scheme. +func TestWSURL(t *testing.T) { + assert.Equal(t, "ws://example.com/rpc", wsURL(&httptest.Server{URL: "http://example.com"})) +} + +// buildEthTx creates a minimal signed EIP-1559 transaction for testing. +func buildEthTx(t *testing.T, chainID uint64, sender genesis.DevAccount, nonce uint64, to *thor.Address) *tx.Transaction { + t.Helper() + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(chainID). + Nonce(nonce). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(to). + Value(big.NewInt(1e9)). + Build() + ethTx, err := tx.Sign(unsigned, sender.PrivateKey) + require.NoError(t, err) + return ethTx +} + +// buildEthCallTx creates a signed EIP-1559 contract call transaction for testing. +func buildEthCallTx(t *testing.T, chainID uint64, sender genesis.DevAccount, nonce uint64, to *thor.Address, data []byte, gas uint64) *tx.Transaction { + t.Helper() + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(chainID). + Nonce(nonce). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(gas). + To(to). + Data(data). + Build() + ethTx, err := tx.Sign(unsigned, sender.PrivateKey) + require.NoError(t, err) + return ethTx +} diff --git a/rpc/ws/subscriptions.go b/rpc/ws/subscriptions.go new file mode 100644 index 0000000000..804255a192 --- /dev/null +++ b/rpc/ws/subscriptions.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package ws + +import ( + "context" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/vechain/thor/v2/rpc/ethconvert" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/txpool" +) + +const pendingTxBufSize = 128 + +// runNewHeads pushes an EthBlock notification (fullTxs=false) for every new +// canonical block while ctx is alive. Obsolete (reorg) blocks are skipped +// because newHeads delivers only the canonical chain tip. +func runNewHeads(ctx context.Context, c *wsConn, subID string) { + reader := c.repo.NewBlockReader(c.repo.BestBlockSummary().Header.ID()) + ticker := c.repo.NewTicker() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C(): + } + for { + blocks, err := reader.Read() + if err != nil || len(blocks) == 0 { + break + } + for _, blk := range blocks { + if blk.Obsolete { + continue // deliver canonical tips only + } + ethBlock, err := ethconvert.BuildEthBlock(blk.Header(), c.repo, false) + if err != nil { + continue + } + c.notify(subID, ethBlock) + } + } + } +} + +// runLogs pushes EthLog notifications for every new block while ctx is alive. +// For canonical (non-obsolete) blocks, logs are pushed with Removed=false. +// For obsolete blocks (reorg), the same logs are re-emitted with Removed=true +// so subscribers can roll back their state — per the Ethereum eth_subscribe spec. +func runLogs(ctx context.Context, c *wsConn, subID string, criteria ethconvert.LogCriteria) { + reader := c.repo.NewBlockReader(c.repo.BestBlockSummary().Header.ID()) + ticker := c.repo.NewTicker() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C(): + } + for { + blocks, err := reader.Read() + if err != nil || len(blocks) == 0 { + break + } + for _, blk := range blocks { + receipts, err := c.repo.GetBlockReceipts(blk.Header().ID()) + if err != nil { + continue + } + // Obsolete=true means this block was part of a fork that got replaced. + // We re-emit its logs with removed=true so subscribers can undo their state. + removed := blk.Obsolete + logs := ethconvert.CollectMatchingLogs( + &criteria, + blk.Transactions(), + receipts, + common.Hash(blk.Header().ID()), + uint64(blk.Header().Number()), + removed, + ) + for _, log := range logs { + c.notify(subID, log) + } + } + } + } +} + +// runNewPendingTransactions pushes the transaction hash for every executable +// ETH-typed (TypeEthDynamicFee) transaction that enters the pool while ctx is alive. +// VeChain-native transactions are intentionally excluded: Ethereum tooling cannot +// decode or display them and their IDs do not match any Ethereum tx format. +func runNewPendingTransactions(ctx context.Context, c *wsConn, subID string) { + txCh := make(chan *txpool.TxEvent, pendingTxBufSize) + sub := c.txPool.SubscribeTxEvent(txCh) + defer sub.Unsubscribe() + + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-txCh: + if !ok { + return + } + // Only report executable ETH-typed transactions. + if ev.Executable == nil || !*ev.Executable { + continue + } + if ev.Tx.Type() != tx.TypeEthDynamicFee { + continue + } + c.notify(subID, common.Hash(ev.Tx.ID())) + case <-time.After(pongWait * time.Second): + // Safety valve: if txCh produces nothing for a full pong cycle, + // loop back so connCtx.Done() is checked. + } + } +} diff --git a/test/testchain/chain.go b/test/testchain/chain.go index 1895fff980..c42982c837 100644 --- a/test/testchain/chain.go +++ b/test/testchain/chain.go @@ -245,3 +245,8 @@ func (c *Chain) RemoveValidator(address thor.Address) { func (c *Chain) AddValidator(validator genesis.DevAccount) { c.validators = append(c.validators, validator) } + +// ChainID returns the current genesis chain id. +func (c *Chain) ChainID() uint64 { + return c.Repo().ChainID() +} diff --git a/test/testnode/node.go b/test/testnode/node.go index 3f29fb7860..1d17918b76 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -11,6 +11,8 @@ import ( "github.com/gorilla/mux" + "github.com/vechain/thor/v2/rpc/jsonrpc" + "github.com/vechain/thor/v2/api/accounts" "github.com/vechain/thor/v2/api/blocks" "github.com/vechain/thor/v2/api/debug" @@ -26,6 +28,16 @@ import ( "github.com/vechain/thor/v2/test/testchain" "github.com/vechain/thor/v2/tx" "github.com/vechain/thor/v2/txpool" + + rpcaccounts "github.com/vechain/thor/v2/rpc/accounts" + rpcblocks "github.com/vechain/thor/v2/rpc/blocks" + rpcchain "github.com/vechain/thor/v2/rpc/chain" + rpcfees "github.com/vechain/thor/v2/rpc/fees" + rpcfilters "github.com/vechain/thor/v2/rpc/filters" + rpclogs "github.com/vechain/thor/v2/rpc/logs" + rpcsimulation "github.com/vechain/thor/v2/rpc/simulation" + rpctransactions "github.com/vechain/thor/v2/rpc/transactions" + rpcws "github.com/vechain/thor/v2/rpc/ws" ) // Node represents a complete test node with chain, API server, and transaction pool capabilities @@ -96,9 +108,24 @@ func (n *node) Start() error { subs := subscriptions.New(repo, []string{"*"}, 1000, n.txPool, true) subs.Mount(router, "/subscriptions") + rpcSrv := jsonrpc.NewServer() + rpcchain.New(repo, "test/1.0").Mount(rpcSrv) + rpcblocks.New(repo).Mount(rpcSrv) + rpctransactions.New(repo, n.txPool).Mount(rpcSrv) + rpcaccounts.New(repo, stater).Mount(rpcSrv) + rpclogs.New(repo, logDB, 100, 1000).Mount(rpcSrv) + rpcfees.New(repo, 100, forkConfig).Mount(rpcSrv) + rpcsimulation.New(repo, stater, &testchain.DefaultForkConfig, 1_000_000).Mount(rpcSrv) + rpcFilters := rpcfilters.New(repo, n.txPool, 100) + rpcFilters.Mount(rpcSrv) + rpcWs := rpcws.New(repo, n.txPool, []string{"*"}, rpcSrv) + router.PathPrefix("/rpc").Handler(rpcWs) + n.apiServer = httptest.NewServer(router) n.apiServerCloser = func() { subs.Close() + rpcFilters.Close() + rpcWs.Close() n.apiServer.Close() } return nil diff --git a/thorclient/rpc_test.go b/thorclient/rpc_test.go new file mode 100644 index 0000000000..41a76c9867 --- /dev/null +++ b/thorclient/rpc_test.go @@ -0,0 +1,972 @@ +// Copyright (c) 2026 The VeChainThor developers +// +// Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying +// file LICENSE or + +package thorclient + +import ( + "encoding/json" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/builtin" + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/test/testnode" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// setupEthTestNode builds the two-block chain and assembles all ETH RPC handlers. +func setupEthTestNode(t *testing.T) testnode.Node { + t.Helper() + + c, err := testchain.NewDefault() + require.NoError(t, err) + + testNode, err := testnode.NewNodeBuilder().WithChain(c).Build() + require.NoError(t, err) + require.NoError(t, testNode.Start()) + + return testNode +} + +func TestEthRPC(t *testing.T) { + testNode := setupEthTestNode(t) + + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + vcTx := testutil.BuildVcTx(t, testNode.Chain(), sender, &recipient.Address) + ethTx := testutil.BuildEthTx(t, testNode.Chain().ChainID(), sender, 0, &recipient.Address) + + require.NoError(t, testNode.Chain().MintBlock(vcTx, ethTx)) + require.Equal(t, uint32(1), testNode.Chain().Repo().BestBlockSummary().Header.Number()) + + block1, err := testNode.Chain().BestBlock() + require.NoError(t, err) + + // Block 2: two ETH txs from different senders so we get non-trivial + // transactionIndex and cumulativeGasUsed on the second receipt. + sender2 := genesis.DevAccounts()[2] + + unsigned2 := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(testNode.Chain().ChainID()). + Nonce(1). // sender's next nonce: used nonce=0 in block 1 + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(&recipient.Address). + Value(big.NewInt(1e9)). + Build() + ethTx2, err := tx.Sign(unsigned2, sender.PrivateKey) + require.NoError(t, err) + + unsigned3 := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(testNode.Chain().ChainID()). + Nonce(0). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(&recipient.Address). + Value(big.NewInt(1e9)). + Build() + ethTx3, err := tx.Sign(unsigned3, sender2.PrivateKey) + require.NoError(t, err) + + require.NoError(t, testNode.Chain().MintBlock(ethTx2, ethTx3)) + + block2, err := testNode.Chain().BestBlock() + require.NoError(t, err) + + // Block 3: ETH call to Energy.transfer — emits a Transfer event. + // Used for eth_getLogs and filter tests. + sender3 := genesis.DevAccounts()[3] + transferMethod, ok := builtin.Energy.ABI.MethodByName("transfer") + require.True(t, ok) + callData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + energyAddr := builtin.Energy.Address + ethCallTx := testutil.BuildEthCallTx(t, testNode.Chain().ChainID(), sender3, 0, &energyAddr, callData, 200_000) + require.NoError(t, testNode.Chain().MintBlock(ethCallTx)) + + blockWithEvents, err := testNode.Chain().BestBlock() + require.NoError(t, err) + + transferEvent, ok := builtin.Energy.ABI.EventByName("Transfer") + require.True(t, ok) + transferTopic := common.Hash(transferEvent.ID()).Hex() + + // ── Identity ────────────────────────────────────────────────────────────── + + t.Run("eth_chainId", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_chainId", []any{}) + var chainID hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &chainID)) + assert.Equal(t, testNode.Chain().ChainID(), uint64(chainID)) + }) + + t.Run("net_version", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "net_version", []any{}) + var version string + require.NoError(t, json.Unmarshal(result, &version)) + assert.Equal(t, strconv.FormatUint(testNode.Chain().ChainID(), 10), version) + }) + + t.Run("eth_blockNumber", func(t *testing.T) { + // Chain has genesis + 3 minted blocks. + result := testutil.Call(t, testNode.APIServer(), "eth_blockNumber", []any{}) + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &num)) + assert.Equal(t, uint64(3), uint64(num)) + }) + + // ── Simple stubs ────────────────────────────────────────────────────────── + + t.Run("net_listening", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "net_listening", []any{}) + var got bool + require.NoError(t, json.Unmarshal(result, &got)) + assert.True(t, got) + }) + + t.Run("net_peerCount", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "net_peerCount", []any{}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(0), uint64(got)) + }) + + t.Run("eth_coinbase", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_coinbase", []any{}) + var got string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, "0x0000000000000000000000000000000000000000", got) + }) + + t.Run("eth_syncing", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_syncing", []any{}) + var got bool + require.NoError(t, json.Unmarshal(result, &got)) + assert.False(t, got) + }) + + t.Run("eth_accounts", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_accounts", []any{}) + var got []string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got) + }) + + t.Run("eth_mining", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_mining", []any{}) + var got bool + require.NoError(t, json.Unmarshal(result, &got)) + assert.False(t, got) + }) + + t.Run("eth_hashrate", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_hashrate", []any{}) + var got string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, "0x0", got) + }) + + t.Run("web3_clientVersion", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "web3_clientVersion", []any{}) + var got string + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, "Thor/test/1.0", got) + }) + + // ── Blocks ──────────────────────────────────────────────────────────────── + + t.Run("eth_getBlockByNumber_block1_hashes", func(t *testing.T) { + // Block 1 contains one VeChain tx (invisible) and one ETH tx. + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockByNumber", []any{"0x1", false}) + var block map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &block)) + + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(block["number"], &num)) + assert.Equal(t, uint64(1), uint64(num)) + + var hash string + require.NoError(t, json.Unmarshal(block["hash"], &hash)) + assert.True(t, strings.EqualFold(block1.Header().ID().String(), hash)) + + // Only the EIP-1559 tx is visible; the VeChain-native tx is filtered out. + var txHashes []string + require.NoError(t, json.Unmarshal(block["transactions"], &txHashes)) + require.Len(t, txHashes, 1) + assert.True(t, strings.EqualFold(ethTx.ID().String(), txHashes[0])) + }) + + t.Run("eth_getBlockByNumber_block2_hashes", func(t *testing.T) { + // Block 2 contains two ETH txs from different senders. + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockByNumber", []any{"0x2", false}) + var block map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &block)) + + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(block["number"], &num)) + assert.Equal(t, uint64(2), uint64(num)) + + var txHashes []string + require.NoError(t, json.Unmarshal(block["transactions"], &txHashes)) + require.Len(t, txHashes, 2) + assert.True(t, strings.EqualFold(ethTx2.ID().String(), txHashes[0])) + assert.True(t, strings.EqualFold(ethTx3.ID().String(), txHashes[1])) + }) + + t.Run("eth_getBlockByNumber_full_tx", func(t *testing.T) { + // Full-tx mode: transactions array contains EthTx objects, not just hashes. + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockByNumber", []any{"0x1", true}) + var block map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &block)) + + var txObjects []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(block["transactions"], &txObjects)) + require.Len(t, txObjects, 1) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(txObjects[0]["type"], &txType)) + assert.Equal(t, uint64(2), uint64(txType)) + }) + + t.Run("eth_getBlockByHash", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockByHash", []any{block2.Header().ID().String(), false}) + var block map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &block)) + + var hash string + require.NoError(t, json.Unmarshal(block["hash"], &hash)) + assert.True(t, strings.EqualFold(block2.Header().ID().String(), hash)) + }) + + t.Run("eth_getBlockTransactionCountByNumber", func(t *testing.T) { + // Block 1: 1 ETH tx; block 2: 2 ETH txs; block 3: 1 ETH call tx. + for _, tc := range []struct { + tag string + expected uint64 + }{ + {"0x1", 1}, + {"0x2", 2}, + {"0x3", 1}, + } { + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockTransactionCountByNumber", []any{tc.tag}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, tc.expected, uint64(got), "block %s", tc.tag) + } + }) + + t.Run("eth_getBlockTransactionCountByHash", func(t *testing.T) { + // Block 2 has two ETH txs. + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockTransactionCountByHash", []any{block2.Header().ID().String()}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(2), uint64(got)) + }) + + t.Run("eth_getBlockReceipts", func(t *testing.T) { + // Genesis has no ETH txs → empty receipts array. + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockReceipts", []any{"0x0"}) + var empty []json.RawMessage + require.NoError(t, json.Unmarshal(result, &empty)) + assert.Empty(t, empty) + + // Block 1: 1 ETH tx receipt. + result = testutil.Call(t, testNode.APIServer(), "eth_getBlockReceipts", []any{"0x1"}) + var recs1 []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &recs1)) + require.Len(t, recs1, 1) + var hash string + require.NoError(t, json.Unmarshal(recs1[0]["transactionHash"], &hash)) + assert.True(t, strings.EqualFold(ethTx.ID().String(), hash)) + + // Block 2: 2 ETH tx receipts. + result = testutil.Call(t, testNode.APIServer(), "eth_getBlockReceipts", []any{"0x2"}) + var recs2 []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &recs2)) + assert.Len(t, recs2, 2) + }) + + t.Run("eth_getUncleCountByBlockNumber", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getUncleCountByBlockNumber", []any{"latest"}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(0), uint64(got)) + }) + + t.Run("eth_getUncleCountByBlockHash", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getUncleCountByBlockHash", []any{block2.Header().ID().String()}) + var got hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &got)) + assert.Equal(t, uint64(0), uint64(got)) + }) + + t.Run("eth_getUncleByBlockHashAndIndex", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getUncleByBlockHashAndIndex", []any{block2.Header().ID().String(), "0x0"}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getUncleByBlockNumberAndIndex", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getUncleByBlockNumberAndIndex", []any{"latest", "0x0"}) + assert.Equal(t, "null", string(result)) + }) + + // ── Transactions ────────────────────────────────────────────────────────── + + t.Run("eth_getTransactionByHash_eth", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByHash", []any{ethTx2.ID().String()}) + var ethTxObj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTxObj)) + require.NotNil(t, ethTxObj) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(ethTxObj["type"], &txType)) + assert.Equal(t, uint64(2), uint64(txType)) + + var hash string + require.NoError(t, json.Unmarshal(ethTxObj["hash"], &hash)) + assert.True(t, strings.EqualFold(ethTx2.ID().String(), hash)) + + var from string + require.NoError(t, json.Unmarshal(ethTxObj["from"], &from)) + assert.True(t, strings.EqualFold(sender.Address.String(), from)) + }) + + t.Run("eth_getTransactionByHash_vechain_invisible", func(t *testing.T) { + // VeChain-native txs must not be visible through the ETH RPC. + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByHash", []any{vcTx.ID().String()}) + assert.Equal(t, "null", string(result)) + }) + + t.Run("eth_getTransactionByBlockNumberAndIndex_block1", func(t *testing.T) { + // Block 1, projected ETH index 0 = the only EIP-1559 tx in that block. + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByBlockNumberAndIndex", []any{"0x1", "0x0"}) + var fetchEthTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &fetchEthTx)) + require.NotNil(t, fetchEthTx) + + var hash string + require.NoError(t, json.Unmarshal(fetchEthTx["hash"], &hash)) + assert.True(t, strings.EqualFold(ethTx.ID().String(), hash)) + }) + + t.Run("eth_getTransactionByBlockNumberAndIndex_block2_first", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x0"}) + var ethTxObj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTxObj)) + require.NotNil(t, ethTxObj) + + var hash string + require.NoError(t, json.Unmarshal(ethTxObj["hash"], &hash)) + assert.True(t, strings.EqualFold(ethTx2.ID().String(), hash)) + }) + + t.Run("eth_getTransactionByBlockNumberAndIndex_block2_second", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x1"}) + var ethTxObj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTxObj)) + require.NotNil(t, ethTxObj) + + var hash string + require.NoError(t, json.Unmarshal(ethTxObj["hash"], &hash)) + assert.True(t, strings.EqualFold(ethTx3.ID().String(), hash)) + }) + + t.Run("eth_getTransactionByBlockHashAndIndex", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByBlockHashAndIndex", []any{block2.Header().ID().String(), "0x0"}) + var fetchEthTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &fetchEthTx)) + require.NotNil(t, fetchEthTx) + + var hash string + require.NoError(t, json.Unmarshal(fetchEthTx["hash"], &hash)) + assert.True(t, strings.EqualFold(ethTx2.ID().String(), hash)) + }) + + t.Run("eth_getTransactionReceipt_block1", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionReceipt", []any{ethTx2.ID().String()}) + var receipt map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &receipt)) + require.NotNil(t, receipt) + + var status hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["status"], &status)) + assert.Equal(t, uint64(1), uint64(status)) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["type"], &txType)) + assert.Equal(t, uint64(2), uint64(txType)) + + // Only tx in block 1: transactionIndex=0, cumulativeGasUsed=gasUsed=21000. + var txIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["transactionIndex"], &txIdx)) + assert.Equal(t, uint64(0), uint64(txIdx)) + + var cumGas hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["cumulativeGasUsed"], &cumGas)) + assert.Equal(t, uint64(21000), uint64(cumGas)) + }) + + t.Run("eth_getTransactionReceipt_block2_second", func(t *testing.T) { + // Second ETH tx in block 2: transactionIndex=1, cumulativeGasUsed=42000. + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionReceipt", []any{ethTx3.ID().String()}) + var receipt map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &receipt)) + require.NotNil(t, receipt) + + var status hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["status"], &status)) + assert.Equal(t, uint64(1), uint64(status)) + + var txIdx hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["transactionIndex"], &txIdx)) + assert.Equal(t, uint64(1), uint64(txIdx)) + + var gasUsed hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["gasUsed"], &gasUsed)) + assert.Equal(t, uint64(21000), uint64(gasUsed)) + + // Cumulative = gas from both ETH txs in the block. + var cumGas hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["cumulativeGasUsed"], &cumGas)) + assert.Equal(t, uint64(42000), uint64(cumGas)) + }) + + // ── Accounts ────────────────────────────────────────────────────────────── + + t.Run("eth_getBalance", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getBalance", []any{sender.Address.String(), "latest"}) + var bal hexutil.Big + require.NoError(t, json.Unmarshal(result, &bal)) + assert.True(t, bal.ToInt().Sign() > 0) + }) + + t.Run("eth_getTransactionCount", func(t *testing.T) { + // Sender executed 2 ETH txs (nonce=0 in block 1, nonce=1 in block 2). + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionCount", []any{sender.Address.String(), "latest"}) + var count hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &count)) + assert.Equal(t, uint64(2), uint64(count)) + }) + + t.Run("eth_getCode_eoa", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getCode", []any{sender.Address.String(), "latest"}) + var code hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &code)) + assert.Empty(t, code) + }) + + t.Run("eth_getCode_contract", func(t *testing.T) { + // The Energy built-in is a deployed contract — its code must be non-empty. + result := testutil.Call(t, testNode.APIServer(), "eth_getCode", []any{energyAddr.String(), "latest"}) + var code hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &code)) + assert.NotEmpty(t, code) + }) + + t.Run("eth_getStorageAt", func(t *testing.T) { + // Slot 0 of an EOA is always zero. + result := testutil.Call(t, testNode.APIServer(), "eth_getStorageAt", []any{sender.Address.String(), "0x0", "latest"}) + var slot common.Hash + require.NoError(t, json.Unmarshal(result, &slot)) + assert.Equal(t, common.Hash{}, slot) + }) + + // ── Fees ────────────────────────────────────────────────────────────────── + + t.Run("eth_gasPrice", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_gasPrice", []any{}) + var price hexutil.Big + require.NoError(t, json.Unmarshal(result, &price)) + assert.True(t, price.ToInt().Sign() > 0) + }) + + t.Run("eth_maxPriorityFeePerGas", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_maxPriorityFeePerGas", []any{}) + var tip hexutil.Big + require.NoError(t, json.Unmarshal(result, &tip)) + assert.True(t, tip.ToInt().Sign() > 0) + }) + + t.Run("eth_feeHistory_two_blocks", func(t *testing.T) { + // blockCount=2, newestBlock="latest" (block 3) → covers blocks 2 and 3. + result := testutil.Call(t, testNode.APIServer(), "eth_feeHistory", []any{2, "latest", []any{}}) + var fh map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &fh)) + + // baseFeePerGas has length blockCount+1 = 3. + var baseFees []*hexutil.Big + require.NoError(t, json.Unmarshal(fh["baseFeePerGas"], &baseFees)) + assert.Len(t, baseFees, 3) + + // gasUsedRatio has length blockCount = 2. + var gasRatios []float64 + require.NoError(t, json.Unmarshal(fh["gasUsedRatio"], &gasRatios)) + assert.Len(t, gasRatios, 2) + + // oldestBlock is block 2 (newestBlock=3, blockCount=2 → 3-2+1=2). + var oldest hexutil.Uint64 + require.NoError(t, json.Unmarshal(fh["oldestBlock"], &oldest)) + assert.Equal(t, uint64(2), uint64(oldest)) + }) + + // ── Simulation ──────────────────────────────────────────────────────────── + + t.Run("eth_call", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_call", []any{ + map[string]any{ + "from": sender.Address.String(), + "to": recipient.Address.String(), + "value": "0x1", + }, + "latest", + }) + var data hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &data)) + assert.Empty(t, data) // plain VET transfer returns no output data + }) + + t.Run("eth_estimateGas", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_estimateGas", []any{ + map[string]any{ + "from": sender.Address.String(), + "to": recipient.Address.String(), + "value": "0x1", + }, + }) + var gas hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &gas)) + assert.Equal(t, uint64(21000), uint64(gas)) + }) + + t.Run("eth_call_contract", func(t *testing.T) { + // Call Energy.totalSupply() — a pure view function, returns non-zero ABI-encoded uint256. + totalSupplyMethod, ok := builtin.Energy.ABI.MethodByName("totalSupply") + require.True(t, ok) + tsCallData, err := totalSupplyMethod.EncodeInput() + require.NoError(t, err) + + result := testutil.Call(t, testNode.APIServer(), "eth_call", []any{ + map[string]any{ + "to": energyAddr.String(), + "data": hexutil.Encode(tsCallData), + }, + "latest", + }) + var data hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &data)) + assert.Len(t, data, 32, "ABI-encoded uint256 is 32 bytes") + }) + + t.Run("eth_estimateGas_contract", func(t *testing.T) { + // Estimate gas for Energy.transfer — sender has VTHO so it succeeds; gas > 21000. + result := testutil.Call(t, testNode.APIServer(), "eth_estimateGas", []any{ + map[string]any{ + "from": sender.Address.String(), + "to": energyAddr.String(), + "data": hexutil.Encode(callData), + }, + }) + var gas hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &gas)) + assert.Greater(t, uint64(gas), uint64(21000)) + }) + + // ── Logs ────────────────────────────────────────────────────────────────── + + t.Run("eth_getLogs_pre_event_range", func(t *testing.T) { + // Blocks 0–2 contain only plain VET transfers — no contract events. + result := testutil.Call(t, testNode.APIServer(), "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x0", "toBlock": "0x2"}, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got) + }) + + t.Run("eth_getLogs_range_with_event", func(t *testing.T) { + // Block 3 contains an Energy.transfer → 1 Transfer event. + result := testutil.Call(t, testNode.APIServer(), "eth_getLogs", []any{ + map[string]any{"fromBlock": "0x3", "toBlock": "0x3"}, + }) + var got []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &got)) + require.Len(t, got, 1) + + var addr string + require.NoError(t, json.Unmarshal(got[0]["address"], &addr)) + assert.True(t, strings.EqualFold(energyAddr.String(), addr)) + + var topics []string + require.NoError(t, json.Unmarshal(got[0]["topics"], &topics)) + require.NotEmpty(t, topics) + assert.True(t, strings.EqualFold(transferTopic, topics[0])) + }) + + t.Run("eth_getLogs_address_filter", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "address": energyAddr.String(), + }, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1) + }) + + t.Run("eth_getLogs_wrong_address", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "address": "0x0000000000000000000000000000000000000001", + }, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Empty(t, got) + }) + + t.Run("eth_getLogs_topic_filter", func(t *testing.T) { + result := testutil.Call(t, testNode.APIServer(), "eth_getLogs", []any{ + map[string]any{ + "fromBlock": "0x0", + "toBlock": "latest", + "topics": []any{transferTopic}, + }, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1) + }) + + t.Run("eth_getLogs_blockHash_filter", func(t *testing.T) { + // EIP-234: single-block query by blockHash for the block that has the event. + result := testutil.Call(t, testNode.APIServer(), "eth_getLogs", []any{ + map[string]any{"blockHash": blockWithEvents.Header().ID().String()}, + }) + var got []any + require.NoError(t, json.Unmarshal(result, &got)) + assert.Len(t, got, 1) + }) + + // ── Send ────────────────────────────────────────────────────────────────── + + t.Run("eth_sendRawTransaction", func(t *testing.T) { + // Sender has used nonce=0 (block 1) and nonce=1 (block 2); next nonce is 2. + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(testNode.Chain().ChainID()). + Nonce(2). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(&recipient.Address). + Value(big.NewInt(1e9)). + Build() + newTx, err := tx.Sign(unsigned, sender.PrivateKey) + require.NoError(t, err) + + rawBytes, err := newTx.MarshalBinary() + require.NoError(t, err) + + // 1. Send: the endpoint validates, adds to pool, and returns the tx hash. + result := testutil.Call(t, testNode.APIServer(), "eth_sendRawTransaction", []any{ + hexutil.Encode(rawBytes), + }) + var txHash string + require.NoError(t, json.Unmarshal(result, &txHash)) + assert.True(t, strings.EqualFold(newTx.ID().String(), txHash)) + + // 2. Read transaction: must be visible in the new block. + result = testutil.Call(t, testNode.APIServer(), "eth_getTransactionByHash", []any{txHash}) + var ethTxObj map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTxObj)) + require.NotNil(t, ethTxObj, "transaction should be found after mining") + + var readHash string + require.NoError(t, json.Unmarshal(ethTxObj["hash"], &readHash)) + assert.True(t, strings.EqualFold(txHash, readHash)) + + var from string + require.NoError(t, json.Unmarshal(ethTxObj["from"], &from)) + assert.True(t, strings.EqualFold(sender.Address.String(), from)) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(ethTxObj["type"], &txType)) + assert.Equal(t, uint64(2), uint64(txType)) + + // 3. Read receipt: must exist with status=1 (successful transfer). + result = testutil.Call(t, testNode.APIServer(), "eth_getTransactionReceipt", []any{txHash}) + var receipt map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &receipt)) + require.NotNil(t, receipt, "receipt should be found after mining") + + var status hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["status"], &status)) + assert.Equal(t, uint64(1), uint64(status)) + + var receiptType hexutil.Uint64 + require.NoError(t, json.Unmarshal(receipt["type"], &receiptType)) + assert.Equal(t, uint64(2), uint64(receiptType)) + + var receiptHash string + require.NoError(t, json.Unmarshal(receipt["transactionHash"], &receiptHash)) + assert.True(t, strings.EqualFold(txHash, receiptHash)) + }) + + // ── Filters ─────────────────────────────────────────────────────────────── + // The instantMintPool auto-mines a block on every AddLocal, so eth_sendRawTransaction + // both fires the txFeed (for pending filters) and advances the chain (for block filters) + // before the HTTP response returns — no sleeps or retries needed. + + t.Run("eth_newBlockFilter", func(t *testing.T) { + // Create filter, then send a plain VET transfer — auto-mines a new block. + idResult := testutil.Call(t, testNode.APIServer(), "eth_newBlockFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + assert.Regexp(t, `^0x[0-9a-f]+$`, filterID) + + filterSender := genesis.DevAccounts()[4] + vtxUnsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(testNode.Chain().ChainID()). + Nonce(0). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(&recipient.Address). + Value(big.NewInt(1e9)). + Build() + vtx, err := tx.Sign(vtxUnsigned, filterSender.PrivateKey) + require.NoError(t, err) + rawBytes, err := vtx.MarshalBinary() + require.NoError(t, err) + testutil.Call(t, testNode.APIServer(), "eth_sendRawTransaction", []any{hexutil.Encode(rawBytes)}) + + result := testutil.Call(t, testNode.APIServer(), "eth_getFilterChanges", []any{filterID}) + var hashes []common.Hash + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Len(t, hashes, 1) + + // Second poll — no new blocks — empty. + result = testutil.Call(t, testNode.APIServer(), "eth_getFilterChanges", []any{filterID}) + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Empty(t, hashes) + }) + + t.Run("eth_newPendingTransactionFilter", func(t *testing.T) { + // txFeed fires synchronously inside AddLocal before MintBlock returns, + // so the hash is in the filter channel when eth_sendRawTransaction responds. + idResult := testutil.Call(t, testNode.APIServer(), "eth_newPendingTransactionFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + filterSender := genesis.DevAccounts()[5] + vtxUnsigned := tx.NewBuilder(tx.TypeEthDynamicFee). + ChainID(testNode.Chain().ChainID()). + Nonce(0). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + Gas(21000). + To(&recipient.Address). + Value(big.NewInt(1e9)). + Build() + vtx, err := tx.Sign(vtxUnsigned, filterSender.PrivateKey) + require.NoError(t, err) + rawBytes, err := vtx.MarshalBinary() + require.NoError(t, err) + testutil.Call(t, testNode.APIServer(), "eth_sendRawTransaction", []any{hexutil.Encode(rawBytes)}) + + result := testutil.Call(t, testNode.APIServer(), "eth_getFilterChanges", []any{filterID}) + var hashes []common.Hash + require.NoError(t, json.Unmarshal(result, &hashes)) + require.Len(t, hashes, 1) + assert.True(t, strings.EqualFold(vtx.ID().String(), hashes[0].Hex())) + + // Second poll — drained — empty. + result = testutil.Call(t, testNode.APIServer(), "eth_getFilterChanges", []any{filterID}) + require.NoError(t, json.Unmarshal(result, &hashes)) + assert.Empty(t, hashes) + }) + + t.Run("eth_newFilter_getFilterLogs", func(t *testing.T) { + // Log filter covering block 3 — eth_getFilterLogs returns the Transfer event. + idResult := testutil.Call(t, testNode.APIServer(), "eth_newFilter", []any{map[string]any{ + "fromBlock": "0x3", + "toBlock": "0x3", + }}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + result := testutil.Call(t, testNode.APIServer(), "eth_getFilterLogs", []any{filterID}) + var logs []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &logs)) + require.Len(t, logs, 1) + + var addr string + require.NoError(t, json.Unmarshal(logs[0]["address"], &addr)) + assert.True(t, strings.EqualFold(energyAddr.String(), addr)) + }) + + t.Run("eth_newFilter_getFilterChanges", func(t *testing.T) { + // Create log filter, then send Energy.transfer (auto-mines) → poll returns 1 event. + idResult := testutil.Call(t, testNode.APIServer(), "eth_newFilter", []any{map[string]any{}}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + filterSender := genesis.DevAccounts()[6] + callDataForFilter, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + filterCallTx := testutil.BuildEthCallTx(t, testNode.Chain().ChainID(), filterSender, 0, &energyAddr, callDataForFilter, 200_000) + rawBytes, err := filterCallTx.MarshalBinary() + require.NoError(t, err) + testutil.Call(t, testNode.APIServer(), "eth_sendRawTransaction", []any{hexutil.Encode(rawBytes)}) + + result := testutil.Call(t, testNode.APIServer(), "eth_getFilterChanges", []any{filterID}) + var logs []map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, &logs)) + require.Len(t, logs, 1) + + var addr string + require.NoError(t, json.Unmarshal(logs[0]["address"], &addr)) + assert.True(t, strings.EqualFold(energyAddr.String(), addr)) + + // Second poll — no new blocks — empty. + result = testutil.Call(t, testNode.APIServer(), "eth_getFilterChanges", []any{filterID}) + var empty []any + require.NoError(t, json.Unmarshal(result, &empty)) + assert.Empty(t, empty) + }) + + t.Run("eth_uninstallFilter", func(t *testing.T) { + idResult := testutil.Call(t, testNode.APIServer(), "eth_newBlockFilter", []any{}) + var filterID string + require.NoError(t, json.Unmarshal(idResult, &filterID)) + + result := testutil.Call(t, testNode.APIServer(), "eth_uninstallFilter", []any{filterID}) + var ok bool + require.NoError(t, json.Unmarshal(result, &ok)) + assert.True(t, ok) + + result = testutil.Call(t, testNode.APIServer(), "eth_uninstallFilter", []any{"0x9999"}) + require.NoError(t, json.Unmarshal(result, &ok)) + assert.False(t, ok) + }) + + t.Run("eth_subscribe_newHeads", func(t *testing.T) { + u := url.URL{Scheme: "ws", Host: strings.TrimPrefix(testNode.APIServer().URL, "http://"), Path: "/rpc"} + conn, resp, err := websocket.DefaultDialer.Dial(u.String(), nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + t.Cleanup(func() { conn.Close() }) + + // Subscribe to newHeads. + body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "eth_subscribe", "params": []any{"newHeads"}}) + require.NoError(t, conn.WriteMessage(websocket.TextMessage, body)) + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var subResp struct { + Result string `json:"result"` + } + require.NoError(t, json.Unmarshal(msg, &subResp)) + subID := subResp.Result + assert.Regexp(t, `^0x[0-9a-f]+$`, subID) + + // Mint a new block and expect a notification. + require.NoError(t, testNode.Chain().MintBlock()) + + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, notifMsg, err := conn.ReadMessage() + require.NoError(t, err) + + var notif struct { + Method string `json:"method"` + Params struct { + Subscription string `json:"subscription"` + Result struct { + Number string `json:"number"` + Hash string `json:"hash"` + } `json:"result"` + } `json:"params"` + } + require.NoError(t, json.Unmarshal(notifMsg, ¬if)) + assert.Equal(t, "eth_subscription", notif.Method) + assert.Equal(t, subID, notif.Params.Subscription) + assert.Regexp(t, `^0x[0-9a-f]{64}$`, notif.Params.Result.Hash) + assert.NotEmpty(t, notif.Params.Result.Number) + }) + + t.Run("eth_subscribe_logs", func(t *testing.T) { + u := url.URL{Scheme: "ws", Host: strings.TrimPrefix(testNode.APIServer().URL, "http://"), Path: "/rpc"} + conn, resp, err := websocket.DefaultDialer.Dial(u.String(), nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + t.Cleanup(func() { conn.Close() }) + + // Subscribe to logs from the Energy contract. + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "eth_subscribe", + "params": []any{"logs", map[string]any{"address": energyAddr.String()}}, + }) + require.NoError(t, conn.WriteMessage(websocket.TextMessage, body)) + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, msg, err := conn.ReadMessage() + require.NoError(t, err) + var subResp struct { + Result string `json:"result"` + } + require.NoError(t, json.Unmarshal(msg, &subResp)) + subID := subResp.Result + assert.Regexp(t, `^0x[0-9a-f]+$`, subID) + + // Mint a block containing an Energy.transfer call. + wsSender := genesis.DevAccounts()[9] + wsCallData, err := transferMethod.EncodeInput(recipient.Address, big.NewInt(1e9)) + require.NoError(t, err) + wsCallTx := testutil.BuildEthCallTx(t, testNode.Chain().ChainID(), wsSender, 0, &energyAddr, wsCallData, 200_000) + require.NoError(t, testNode.Chain().MintBlock(wsCallTx)) + + // Expect a notification with the Transfer log. + conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, notifMsg, err := conn.ReadMessage() + require.NoError(t, err) + + var notif struct { + Method string `json:"method"` + Params struct { + Subscription string `json:"subscription"` + Result struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Removed bool `json:"removed"` + } `json:"result"` + } `json:"params"` + } + require.NoError(t, json.Unmarshal(notifMsg, ¬if)) + assert.Equal(t, "eth_subscription", notif.Method) + assert.Equal(t, subID, notif.Params.Subscription) + assert.True(t, strings.EqualFold(energyAddr.String(), notif.Params.Result.Address)) + require.NotEmpty(t, notif.Params.Result.Topics) + assert.False(t, notif.Params.Result.Removed) + }) +} diff --git a/txpool/tx_pool.go b/txpool/tx_pool.go index a6df4507b1..8d9220ae0e 100644 --- a/txpool/tx_pool.go +++ b/txpool/tx_pool.go @@ -275,6 +275,8 @@ func (p *TxPool) add(newTx *tx.Transaction, rejectNonExecutable bool, localSubmi txTypeString := "Legacy" if newTx.Type() == tx.TypeDynamicFee { txTypeString = "DynamicFee" + } else if newTx.Type() == tx.TypeEthDynamicFee { + txTypeString = "EthDynamicFee" } metricTxPoolGauge().AddWithLabel(1, map[string]string{"source": source, "type": txTypeString}) @@ -454,6 +456,8 @@ func (p *TxPool) Remove(txHash thor.Bytes32, txID thor.Bytes32) bool { txTypeString = "Legacy" } else if removedTransaction.Type() == tx.TypeDynamicFee { txTypeString = "DynamicFee" + } else if removedTransaction.Type() == tx.TypeEthDynamicFee { + txTypeString = "EthDynamicFee" } metricTxPoolGauge().AddWithLabel(-1, map[string]string{"source": "n/a", "type": txTypeString}) logger.Debug("tx removed", "id", txID)