From 76145439ac0dba6a7aa4f73b63d1763c9b51e5b0 Mon Sep 17 00:00:00 2001 From: otherview Date: Tue, 5 May 2026 09:25:05 +0100 Subject: [PATCH 01/20] Add Ethereum Nonce Handling --- runtime/runtime.go | 21 +++++- runtime/runtime_test.go | 119 ++++++++++++++++++++++++++++++++ runtime/statedb/statedb.go | 38 +++++++--- runtime/statedb/statedb_test.go | 9 +-- state/account.go | 12 +++- state/account_test.go | 12 ++-- state/state.go | 28 ++++++++ state/state_test.go | 30 ++++++++ vm/instructions_test.go | 4 +- 9 files changed, 250 insertions(+), 23 deletions(-) diff --git a/runtime/runtime.go b/runtime/runtime.go index 1171125032..7a8584fde4 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -390,7 +390,7 @@ func (rt *Runtime) PrepareClause( txCtx *xenv.TransactionContext, ) (exec func() (output *Output, interrupted bool, err error), interrupt func()) { var ( - stateDB = statedb.New(rt.state) + stateDB = statedb.New(rt.state, txCtx.TxType) evm = rt.newEVM(stateDB, clauseIndex, txCtx) data []byte leftOverGas uint64 @@ -568,6 +568,25 @@ func (rt *Runtime) PrepareTransaction(trx *tx.Transaction) (*TransactionExecutor return nil, err } + // EIP-2: nonce is always consumed for EthereumTx, even if the tx reverts. + // For CALL txs the EVM never touches the nonce, so we always increment here. + // For CREATE txs that succeeded the EVM already incremented the nonce via + // stateDB.SetNonce before the inner snapshot; that survived the tx. + // For CREATE txs that reverted, rt.state.RevertTo(checkpoint) undid the EVM's + // increment, so we must re-apply it here. + if trx.Type() == tx.TypeEthTyped1559 { + isCreate := resolvedTx.Clauses[0].IsCreatingContract() + if !isCreate || reverted { + nonce, err := rt.state.GetNonce(txCtx.Origin) + if err != nil { + return nil, err + } + if err := rt.state.SetNonce(txCtx.Origin, nonce+1); err != nil { + return nil, err + } + } + } + if !thor.IsForked(rt.ctx.Number, rt.forkConfig.GALACTICA) { provedWork, err := trx.ProvedWork(rt.ctx.Number-1, rt.chain.GetBlockID) if err != nil { diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go index ab15e2a84d..6ee131fca5 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -1099,6 +1099,125 @@ func TestCreateAddressDerivation(t *testing.T) { } } +// TestEthTxNonce verifies the nonce increment in Finalize for all four EthereumTx paths +// plus the VeChain isolation guard. +// +// The nonce source differs per path: +// +// CALL success → Finalize (!isCreate = true) +// CALL reverted → Finalize (!isCreate || , EIP-2: nonce consumed evenreverted on revert) +// CREATE success → EVM's create() before snapshot; Finalize condition is false and skips +// CREATE reverted → rt.state.RevertTo undoes EVM's write; Finalize re-applies +// TypeLegacy CREATE → txType guard in StateDB prevents any Ethereum nonce write +func TestEthTxNonce(t *testing.T) { + db := muxdb.NewMem() + g, _ := genesis.NewDevnet() + stater := state.NewStater(db) + b0, _, _, err := g.Build(stater) + assert.Nil(t, err) + repo, _ := chain.NewRepository(db, b0) + + ethChainID := thor.GetEthChainID(b0.Header().ID()) + senderKey := genesis.DevAccounts()[0].PrivateKey + sender := genesis.DevAccounts()[0].Address + + ethBlkCtx := &xenv.BlockContext{ + BaseFee: big.NewInt(thor.InitialBaseFee), + GasLimit: b0.Header().GasLimit(), + } + + newRT := func(blkCtx *xenv.BlockContext) (*runtime.Runtime, *state.State) { + st := stater.NewState(trie.Root{Hash: b0.Header().StateRoot()}) + return runtime.New(repo.NewChain(b0.Header().ID()), st, blkCtx, forkFromStart), st + } + + buildEthTx := func(nonce uint64, to *thor.Address, data []byte) *tx.Transaction { + trx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + ChainID(ethChainID). + Nonce(nonce). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + GasLimit(300_000). + To(to). + Data(data). + Build(senderKey) + assert.Nil(t, err) + return trx + } + + // PUSH1 0 PUSH1 0 RETURN — deploys an empty contract without reverting. + successInitcode := []byte{0x60, 0x00, 0x60, 0x00, 0xf3} + // PUSH1 0 PUSH1 0 REVERT — reverts whether used as initcode or as deployed code. + revertCode := []byte{0x60, 0x00, 0x60, 0x00, 0xfd} + + t.Run("call success: Finalize always increments nonce", func(t *testing.T) { + rt, st := newRT(ethBlkCtx) + recipient := genesis.DevAccounts()[1].Address + receipt, err := rt.ExecuteTransaction(buildEthTx(0, &recipient, nil)) + assert.Nil(t, err) + assert.False(t, receipt.Reverted) + nonce, err := st.GetNonce(sender) + assert.Nil(t, err) + assert.Equal(t, uint64(1), nonce) + }) + + t.Run("call reverted: nonce consumed even on revert", func(t *testing.T) { + rt, st := newRT(ethBlkCtx) + // Pre-load a contract whose runtime code always reverts. + contractAddr := thor.BytesToAddress([]byte("reverter")) + assert.Nil(t, st.SetCode(contractAddr, revertCode)) + receipt, err := rt.ExecuteTransaction(buildEthTx(0, &contractAddr, nil)) + assert.Nil(t, err) + assert.True(t, receipt.Reverted) + nonce, err := st.GetNonce(sender) + assert.Nil(t, err) + assert.Equal(t, uint64(1), nonce) + }) + + t.Run("create success: EVM increments nonce before snapshot; Finalize skips", func(t *testing.T) { + rt, st := newRT(ethBlkCtx) + receipt, err := rt.ExecuteTransaction(buildEthTx(0, nil, successInitcode)) + assert.Nil(t, err) + assert.False(t, receipt.Reverted) + nonce, err := st.GetNonce(sender) + assert.Nil(t, err) + assert.Equal(t, uint64(1), nonce) + }) + + t.Run("create reverted: rt.state.RevertTo undoes EVM's increment; Finalize re-applies", func(t *testing.T) { + rt, st := newRT(ethBlkCtx) + receipt, err := rt.ExecuteTransaction(buildEthTx(0, nil, revertCode)) + assert.Nil(t, err) + assert.True(t, receipt.Reverted) + nonce, err := st.GetNonce(sender) + assert.Nil(t, err) + assert.Equal(t, uint64(1), nonce) + }) + + t.Run("vechain legacy create: Ethereum nonce domain untouched", func(t *testing.T) { + // BaseFee=0 (not nil): avoids nil-deref in EffectivePriorityFeePerGas (GALACTICA + // reward path) while keeping effectiveGasPrice >= baseFee trivially true for TypeLegacy. + vcBlkCtx := &xenv.BlockContext{BaseFee: new(big.Int), GasLimit: b0.Header().GasLimit()} + rt, st := newRT(vcBlkCtx) + vcSender := genesis.DevAccounts()[1] + vcTx := tx.NewBuilder(tx.TypeLegacy). + ChainTag(repo.ChainTag()). + BlockRef(tx.NewBlockRef(0)). + Expiration(100). + GasPriceCoef(255). + Gas(300_000). + Clause(tx.NewClause(nil).WithData(successInitcode)). + Build() + vcTx = tx.MustSign(vcTx, vcSender.PrivateKey) + receipt, err := rt.ExecuteTransaction(vcTx) + assert.Nil(t, err) + assert.False(t, receipt.Reverted) + nonce, err := st.GetNonce(vcSender.Address) + assert.Nil(t, err) + assert.Equal(t, uint64(0), nonce) + }) +} + func getMockTx(repo *chain.Repository, txType tx.Type, t *testing.T) *tx.Transaction { blockRef := tx.NewBlockRef(0) chainTag := repo.ChainTag() diff --git a/runtime/statedb/statedb.go b/runtime/statedb/statedb.go index 4eb5bad058..13ddd4ee28 100644 --- a/runtime/statedb/statedb.go +++ b/runtime/statedb/statedb.go @@ -23,8 +23,9 @@ var codeSizeCache, _ = lru.New(32 * 1024) // StateDB implements evm.StateDB, only adapt to evm. type StateDB struct { - state *state.State - repo *stackedmap.StackedMap + state *state.State + repo *stackedmap.StackedMap + txType tx.Type } type ( @@ -42,7 +43,7 @@ type ( ) // New create a statedb object. -func New(state *state.State) *StateDB { +func New(state *state.State, txType tx.Type) *StateDB { getter := func(k any) (any, bool, error) { switch k.(type) { case suicideFlagKey: @@ -59,8 +60,9 @@ func New(state *state.State) *StateDB { repo := stackedmap.New(getter) return &StateDB{ - state, - repo, + state: state, + repo: repo, + txType: txType, } } @@ -136,11 +138,29 @@ func (s *StateDB) AddBalance(addr common.Address, amount *big.Int) { } } -// GetNonce stub. -func (s *StateDB) GetNonce(_ common.Address) uint64 { return 0 } +// GetNonce returns the Ethereum nonce for the given address. +// Returns 0 for VeChain-native transactions (nonce is not used). +func (s *StateDB) GetNonce(addr common.Address) uint64 { + if s.txType != tx.TypeEthTyped1559 { + return 0 + } + n, err := s.state.GetNonce(thor.Address(addr)) + if err != nil { + panic(err) + } + return n +} -// SetNonce stub. -func (s *StateDB) SetNonce(_ common.Address, _ uint64) {} +// SetNonce sets the Ethereum nonce for the given address. +// No-op for VeChain-native transactions. +func (s *StateDB) SetNonce(addr common.Address, nonce uint64) { + if s.txType != tx.TypeEthTyped1559 { + return + } + if err := s.state.SetNonce(thor.Address(addr), nonce); err != nil { + panic(err) + } +} // GetCodeHash stub. func (s *StateDB) GetCodeHash(addr common.Address) common.Hash { diff --git a/runtime/statedb/statedb_test.go b/runtime/statedb/statedb_test.go index e1fc60fe44..7b794e8564 100644 --- a/runtime/statedb/statedb_test.go +++ b/runtime/statedb/statedb_test.go @@ -25,6 +25,7 @@ import ( "github.com/vechain/thor/v2/muxdb" State "github.com/vechain/thor/v2/state" "github.com/vechain/thor/v2/trie" + "github.com/vechain/thor/v2/tx" ) func TestSnapshotRandom(t *testing.T) { @@ -188,7 +189,7 @@ func (test *snapshotTest) run() bool { var ( db = muxdb.NewMem() state = State.New(db, trie.Root{}) - stateDB = New(state) + stateDB = New(state, tx.TypeLegacy) snapshotRevs = make([]int, len(test.snapshots)) sindex = 0 ) @@ -203,7 +204,7 @@ func (test *snapshotTest) run() bool { // that is equivalent to fresh state with all actions up the snapshot applied. for sindex--; sindex >= 0; sindex-- { state := State.New(db, trie.Root{}) - checkStateDB := New(state) + checkStateDB := New(state, tx.TypeLegacy) for _, action := range test.actions[:test.snapshots[sindex]] { action.fn(action, checkStateDB) } @@ -257,7 +258,7 @@ func TestTransientState(t *testing.T) { db := muxdb.NewMem() state := State.NewStater(db).NewState(trie.Root{}) - stateDB := New(state) + stateDB := New(state, tx.TypeLegacy) val := stateDB.GetTransientState(addr, key) assert.Equal(t, common.Hash{}, val) @@ -281,7 +282,7 @@ func TestTransientState(t *testing.T) { func TestCreateContract(t *testing.T) { addr := common.Address{0x1} - stateDB := New(State.New(muxdb.NewMem(), trie.Root{})) + stateDB := New(State.New(muxdb.NewMem(), trie.Root{}), tx.TypeLegacy) assert.False(t, stateDB.IsNewContract(addr)) stateDB.CreateContract(addr) assert.True(t, stateDB.IsNewContract(addr)) diff --git a/state/account.go b/state/account.go index 69aee14e2f..80946536d3 100644 --- a/state/account.go +++ b/state/account.go @@ -31,6 +31,10 @@ type Account struct { Master []byte // master address CodeHash []byte // hash of code StorageRoot []byte // merkle root of the storage trie + // Nonce is the Ethereum-compatible transaction counter for TypeEthTyped1559 senders. + // Stored as a single-element slice so rlp:"tail" gives backward-compatible encoding: + // zero nonce → nil slice → same 6-field RLP as pre-INTERSTELLAR accounts. + Nonce []uint64 `rlp:"tail"` } // IsEmpty returns if an account is empty. @@ -39,7 +43,8 @@ func (a *Account) IsEmpty() bool { return a.Balance.Sign() == 0 && a.Energy.Sign() == 0 && len(a.Master) == 0 && - len(a.CodeHash) == 0 + len(a.CodeHash) == 0 && + len(a.Nonce) == 0 } var bigE18 = big.NewInt(1e18) @@ -101,6 +106,11 @@ func loadAccount(trie *muxdb.Trie, addr thor.Address) (*Account, *AccountMetadat if err := rlp.DecodeBytes(data, &a); err != nil { return nil, nil, err } + // rlp:"tail" decodes absent elements as an empty (non-nil) slice; + // normalize to nil so zero-nonce accounts compare equal to emptyAccount(). + if len(a.Nonce) == 0 { + a.Nonce = nil + } var am AccountMetadata if len(meta) > 0 { diff --git a/state/account_test.go b/state/account_test.go index 31d651ec39..caa924e8c1 100644 --- a/state/account_test.go +++ b/state/account_test.go @@ -78,12 +78,12 @@ func TestTrie(t *testing.T) { "should load an empty account") acc1 := Account{ - big.NewInt(1), - big.NewInt(0), - 0, - []byte("master"), - []byte("code hash"), - []byte("storage root"), + Balance: big.NewInt(1), + Energy: big.NewInt(0), + BlockTime: 0, + Master: []byte("master"), + CodeHash: []byte("code hash"), + StorageRoot: []byte("storage root"), } meta1 := AccountMetadata{ StorageID: []byte("sid"), diff --git a/state/state.go b/state/state.go index be19eef22a..8966c6babd 100644 --- a/state/state.go +++ b/state/state.go @@ -219,6 +219,34 @@ func (s *State) SetMaster(addr thor.Address, master thor.Address) error { return nil } +// GetNonce returns the Ethereum nonce for the given address. +// Returns 0 for addresses that have never sent an EthereumTx. +func (s *State) GetNonce(addr thor.Address) (uint64, error) { + acc, err := s.getAccount(addr) + if err != nil { + return 0, &Error{err} + } + if len(acc.Nonce) > 0 { + return acc.Nonce[0], nil + } + return 0, nil +} + +// SetNonce sets the Ethereum nonce for the given address. +func (s *State) SetNonce(addr thor.Address, nonce uint64) error { + cpy, err := s.getAccountCopy(addr) + if err != nil { + return &Error{err} + } + if nonce == 0 { + cpy.Nonce = nil + } else { + cpy.Nonce = []uint64{nonce} + } + s.updateAccount(addr, &cpy) + return nil +} + // GetStorage returns storage value for the given address and key. func (s *State) GetStorage(addr thor.Address, key thor.Bytes32) (thor.Bytes32, error) { raw, err := s.GetRawStorage(addr, key) diff --git a/state/state_test.go b/state/state_test.go index aec4efd3d4..bd79edf41b 100644 --- a/state/state_test.go +++ b/state/state_test.go @@ -370,3 +370,33 @@ func TestRLPDecodeErrors(t *testing.T) { }) } } + +func TestNonce(t *testing.T) { + addr := thor.BytesToAddress([]byte("nonce-test")) + + t.Run("default is zero", func(t *testing.T) { + st := New(muxdb.NewMem(), trie.Root{}) + n, err := st.GetNonce(addr) + assert.NoError(t, err) + assert.Equal(t, uint64(0), n) + }) + + t.Run("set and get roundtrip", func(t *testing.T) { + st := New(muxdb.NewMem(), trie.Root{}) + assert.NoError(t, st.SetNonce(addr, 42)) + n, err := st.GetNonce(addr) + assert.NoError(t, err) + assert.Equal(t, uint64(42), n) + }) + + t.Run("zero is stored as nil in account", func(t *testing.T) { + st := New(muxdb.NewMem(), trie.Root{}) + _ = st.SetNonce(addr, 5) + _ = st.SetNonce(addr, 0) + acc, err := st.getAccountCopy(addr) + assert.NoError(t, err) + // nonce=0 must encode as nil so zero-nonce accounts are indistinguishable + // from pre-INTERSTELLAR accounts and IsEmpty() remains correct. + assert.Nil(t, acc.Nonce) + }) +} diff --git a/vm/instructions_test.go b/vm/instructions_test.go index 4f447396e9..0a961a3161 100644 --- a/vm/instructions_test.go +++ b/vm/instructions_test.go @@ -614,7 +614,7 @@ func TestOpTstore(t *testing.T) { var ( db = muxdb.NewMem() state = state.New(db, trie.Root{Hash: thor.Bytes32{}}) - stateDB = statedb.New(state) + stateDB = statedb.New(state, tx.TypeLegacy) env = NewEVM(Context{}, stateDB, &ChainConfig{ChainConfig: *params.TestChainConfig}, Config{}) stack = newstack() mem = NewMemory() @@ -746,7 +746,7 @@ func TestOpSuicide6780(t *testing.T) { tests := []testcase{} newEVMInstance := func(state *state.State) *EVM { - stateDB := statedb.New(state) + stateDB := statedb.New(state, tx.TypeLegacy) evm := NewEVM(Context{ BlockNumber: big.NewInt(1), GasPrice: big.NewInt(1), From 9f6898761e1bbd52e27a45c1b21fcda5f0442ae5 Mon Sep 17 00:00:00 2001 From: otherview Date: Fri, 8 May 2026 12:34:00 +0100 Subject: [PATCH 02/20] Add Ethereum Nonce Handling --- runtime/runtime.go | 4 ++-- runtime/runtime_test.go | 9 +++++---- runtime/statedb/statedb.go | 4 ++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/runtime/runtime.go b/runtime/runtime.go index 7a8584fde4..c5f5a88247 100644 --- a/runtime/runtime.go +++ b/runtime/runtime.go @@ -390,7 +390,7 @@ func (rt *Runtime) PrepareClause( txCtx *xenv.TransactionContext, ) (exec func() (output *Output, interrupted bool, err error), interrupt func()) { var ( - stateDB = statedb.New(rt.state, txCtx.TxType) + stateDB = statedb.New(rt.state, txCtx.Type) evm = rt.newEVM(stateDB, clauseIndex, txCtx) data []byte leftOverGas uint64 @@ -574,7 +574,7 @@ func (rt *Runtime) PrepareTransaction(trx *tx.Transaction) (*TransactionExecutor // stateDB.SetNonce before the inner snapshot; that survived the tx. // For CREATE txs that reverted, rt.state.RevertTo(checkpoint) undid the EVM's // increment, so we must re-apply it here. - if trx.Type() == tx.TypeEthTyped1559 { + if trx.Type() == tx.TypeEthDynamicFee { isCreate := resolvedTx.Clauses[0].IsCreatingContract() if !isCreate || reverted { nonce, err := rt.state.GetNonce(txCtx.Origin) diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go index 6ee131fca5..992e83bf64 100644 --- a/runtime/runtime_test.go +++ b/runtime/runtime_test.go @@ -1117,7 +1117,7 @@ func TestEthTxNonce(t *testing.T) { assert.Nil(t, err) repo, _ := chain.NewRepository(db, b0) - ethChainID := thor.GetEthChainID(b0.Header().ID()) + ethChainID := repo.ChainID() senderKey := genesis.DevAccounts()[0].PrivateKey sender := genesis.DevAccounts()[0].Address @@ -1132,15 +1132,16 @@ func TestEthTxNonce(t *testing.T) { } buildEthTx := func(nonce uint64, to *thor.Address, data []byte) *tx.Transaction { - trx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). ChainID(ethChainID). Nonce(nonce). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). - GasLimit(300_000). + Gas(300_000). To(to). Data(data). - Build(senderKey) + Build() + trx, err := tx.Sign(unsigned, senderKey) assert.Nil(t, err) return trx } diff --git a/runtime/statedb/statedb.go b/runtime/statedb/statedb.go index 13ddd4ee28..23dde375c4 100644 --- a/runtime/statedb/statedb.go +++ b/runtime/statedb/statedb.go @@ -141,7 +141,7 @@ func (s *StateDB) AddBalance(addr common.Address, amount *big.Int) { // GetNonce returns the Ethereum nonce for the given address. // Returns 0 for VeChain-native transactions (nonce is not used). func (s *StateDB) GetNonce(addr common.Address) uint64 { - if s.txType != tx.TypeEthTyped1559 { + if s.txType != tx.TypeEthDynamicFee { return 0 } n, err := s.state.GetNonce(thor.Address(addr)) @@ -154,7 +154,7 @@ func (s *StateDB) GetNonce(addr common.Address) uint64 { // SetNonce sets the Ethereum nonce for the given address. // No-op for VeChain-native transactions. func (s *StateDB) SetNonce(addr common.Address, nonce uint64) { - if s.txType != tx.TypeEthTyped1559 { + if s.txType != tx.TypeEthDynamicFee { return } if err := s.state.SetNonce(thor.Address(addr), nonce); err != nil { From 1de95a4f138a65568d713d926ea6fa5f5eced44a Mon Sep 17 00:00:00 2001 From: otherview Date: Wed, 6 May 2026 17:31:56 +0100 Subject: [PATCH 03/20] Adding rpc package with ethereum json rpc compatible methods + tests --- cmd/thor/flags.go | 7 + cmd/thor/httpserver/eth_rpc_server.go | 100 ++++++ cmd/thor/main.go | 32 ++ cmd/thor/utils.go | 9 + consensus/validator.go | 13 + packer/flow.go | 8 + rpc/accounts/handler.go | 141 ++++++++ rpc/accounts/handler_test.go | 68 ++++ rpc/blocks/handler.go | 80 +++++ rpc/blocks/handler_test.go | 117 ++++++ rpc/chain/handler.go | 56 +++ rpc/chain/handler_test.go | 80 +++++ rpc/dispatcher.go | 36 ++ rpc/eth_types.go | 266 ++++++++++++++ rpc/fees/handler.go | 135 +++++++ rpc/fees/handler_test.go | 65 ++++ rpc/integration_test.go | 123 +++++++ rpc/logs/handler.go | 228 ++++++++++++ rpc/logs/handler_test.go | 55 +++ rpc/server.go | 104 ++++++ rpc/simulation/handler.go | 184 ++++++++++ rpc/simulation/handler_test.go | 75 ++++ rpc/testutil/testutil.go | 191 ++++++++++ rpc/transactions/handler.go | 200 +++++++++++ rpc/transactions/handler_test.go | 167 +++++++++ rpc/types.go | 67 ++++ rpc/utils.go | 265 ++++++++++++++ thorclient/rpc_test.go | 492 ++++++++++++++++++++++++++ txpool/tx_pool.go | 4 + 29 files changed, 3368 insertions(+) create mode 100644 cmd/thor/httpserver/eth_rpc_server.go create mode 100644 rpc/accounts/handler.go create mode 100644 rpc/accounts/handler_test.go create mode 100644 rpc/blocks/handler.go create mode 100644 rpc/blocks/handler_test.go create mode 100644 rpc/chain/handler.go create mode 100644 rpc/chain/handler_test.go create mode 100644 rpc/dispatcher.go create mode 100644 rpc/eth_types.go create mode 100644 rpc/fees/handler.go create mode 100644 rpc/fees/handler_test.go create mode 100644 rpc/integration_test.go create mode 100644 rpc/logs/handler.go create mode 100644 rpc/logs/handler_test.go create mode 100644 rpc/server.go create mode 100644 rpc/simulation/handler.go create mode 100644 rpc/simulation/handler_test.go create mode 100644 rpc/testutil/testutil.go create mode 100644 rpc/transactions/handler.go create mode 100644 rpc/transactions/handler_test.go create mode 100644 rpc/types.go create mode 100644 rpc/utils.go create mode 100644 thorclient/rpc_test.go diff --git a/cmd/thor/flags.go b/cmd/thor/flags.go index c2be138434..873f3b3386 100644 --- a/cmd/thor/flags.go +++ b/cmd/thor/flags.go @@ -57,6 +57,13 @@ var ( Usage: "API service listening address", Sources: envVar("API_ADDR"), } + ethRPCAddrFlag = &cli.StringFlag{ + Name: "eth-rpc-addr", + Local: true, + Value: "localhost:8545", + Usage: "Ethereum JSON-RPC service listening address", + Sources: envVar("ETH_RPC_ADDR"), + } apiCorsFlag = &cli.StringFlag{ Name: "api-cors", Local: true, diff --git a/cmd/thor/httpserver/eth_rpc_server.go b/cmd/thor/httpserver/eth_rpc_server.go new file mode 100644 index 0000000000..0da571895f --- /dev/null +++ b/cmd/thor/httpserver/eth_rpc_server.go @@ -0,0 +1,100 @@ +// 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 httpserver + +import ( + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/handlers" + "github.com/pkg/errors" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/logdb" + "github.com/vechain/thor/v2/rpc" + "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/logs" + "github.com/vechain/thor/v2/rpc/simulation" + "github.com/vechain/thor/v2/rpc/transactions" + "github.com/vechain/thor/v2/state" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/txpool" +) + +// EthRPCConfig holds configuration for the Ethereum JSON-RPC server. +type EthRPCConfig struct { + // AllowedOrigins is the comma-separated list of allowed CORS origins. + // Reuses the same value as the REST API --api-cors flag. + AllowedOrigins string + BacktraceLimit uint32 + CallGasLimit uint64 + ClientVersion string +} + +// StartEthRPCServer starts the Ethereum JSON-RPC server on the given address. +// Returns the listening URL and a closer function. +func StartEthRPCServer( + addr string, + repo *chain.Repository, + stater *state.Stater, + txPool txpool.Pool, + logDB *logdb.LogDB, + forkConfig *thor.ForkConfig, + config EthRPCConfig, +) (string, func(), error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return "", nil, errors.Wrapf(err, "listen Eth RPC addr [%v]", addr) + } + + chainID := thor.GetEthChainID(repo.GenesisBlock().Header().ID()) + + d := rpc.NewDispatcher() + rpcchain.New(repo, chainID, config.ClientVersion).Mount(d) + blocks.New(repo, chainID).Mount(d) + transactions.New(repo, chainID, txPool).Mount(d) + accounts.New(repo, stater).Mount(d) + logs.New(repo, logDB, config.BacktraceLimit).Mount(d) + fees.New(repo, config.BacktraceLimit).Mount(d) + simulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(d) + + origins := strings.Split(strings.TrimSpace(config.AllowedOrigins), ",") + for i, o := range origins { + origins[i] = strings.ToLower(strings.TrimSpace(o)) + } + + srv := rpc.New(d) + + corsHandler := handlers.CORS( + handlers.AllowedOrigins(origins), + handlers.AllowedHeaders([]string{"content-type"}), + handlers.AllowedMethods([]string{"POST", "OPTIONS"}), + )(srv) + + httpSrv := &http.Server{ + Handler: corsHandler, + ReadHeaderTimeout: time.Second, + ReadTimeout: 30 * time.Second, + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + httpSrv.Serve(listener) //nolint:errcheck + }() + + return "http://" + listener.Addr().String(), func() { + httpSrv.Close() + wg.Wait() + }, nil +} diff --git a/cmd/thor/main.go b/cmd/thor/main.go index 7da6d1eb1e..d296f7a09a 100644 --- a/cmd/thor/main.go +++ b/cmd/thor/main.go @@ -98,6 +98,7 @@ func main() { beneficiaryFlag, targetGasLimitFlag, apiAddrFlag, + ethRPCAddrFlag, apiCorsFlag, apiTimeoutFlag, apiCallGasLimitFlag, @@ -143,6 +144,7 @@ func main() { logDbAdditionalIndexesFlag, apiTxpoolFlag, apiAddrFlag, + ethRPCAddrFlag, apiCorsFlag, apiTimeoutFlag, apiCallGasLimitFlag, @@ -329,7 +331,22 @@ func defaultAction(_ context.Context, ctx *cli.Command) error { } defer func() { log.Info("stopping API server..."); srvCloser() }() + ethRPCURL, ethRPCCloser, err := httpserver.StartEthRPCServer( + ctx.String(ethRPCAddrFlag.Name), + repo, + state.NewStater(mainDB), + txPool, + logDB, + forkConfig, + makeEthRPCConfig(ctx, version), + ) + if err != nil { + return err + } + defer func() { log.Info("stopping Eth RPC server..."); ethRPCCloser() }() + printStartupMessage2(gene, apiURL, p2pCommunicator.Enode(), metricsURL, adminURL, false) + log.Info("Eth RPC started", "url", ethRPCURL) if err := p2pCommunicator.Start(); err != nil { return err @@ -525,7 +542,22 @@ func soloAction(_ context.Context, ctx *cli.Command) error { } defer func() { log.Info("stopping API server..."); srvCloser() }() + ethRPCURL, ethRPCCloser, err := httpserver.StartEthRPCServer( + ctx.String(ethRPCAddrFlag.Name), + repo, + stater, + pool, + logDB, + forkConfig, + makeEthRPCConfig(ctx, version), + ) + if err != nil { + return err + } + defer func() { log.Info("stopping Eth RPC server..."); ethRPCCloser() }() + printStartupMessage2(gene, apiURL, "", metricsURL, adminURL, isDevnet) + log.Info("Eth RPC started", "url", ethRPCURL) 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..8fa4984b71 100644 --- a/cmd/thor/utils.go +++ b/cmd/thor/utils.go @@ -281,6 +281,15 @@ func makeAPIConfig(ctx *cli.Command, logAPIRequests *atomic.Bool, soloMode bool) } } +func makeEthRPCConfig(ctx *cli.Command, clientVersion string) httpserver.EthRPCConfig { + return httpserver.EthRPCConfig{ + AllowedOrigins: ctx.String(apiCorsFlag.Name), + BacktraceLimit: uint32(ctx.Uint64(apiBacktraceLimitFlag.Name)), + CallGasLimit: ctx.Uint64(apiCallGasLimitFlag.Name), + ClientVersion: clientVersion, + } +} + func makeConfigDir(ctx *cli.Command) (string, error) { dir := ctx.String(configDirFlag.Name) if dir == "" { diff --git a/consensus/validator.go b/consensus/validator.go index 9a4b201c29..85d47af2a0 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 740c58b720..79c9abd654 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..0b8b63c30d --- /dev/null +++ b/rpc/accounts/handler.go @@ -0,0 +1,141 @@ +// 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" + "fmt" + + "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/state" + "github.com/vechain/thor/v2/thor" +) + +// 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(d *rpc.Dispatcher) { + d.Register("eth_getBalance", h.ethGetBalance) + d.Register("eth_getCode", h.ethGetCode) + d.Register("eth_getStorageAt", h.ethGetStorageAt) + d.Register("eth_getTransactionCount", h.ethGetTransactionCount) +} + +func (h *Handler) ethGetBalance(req rpc.Request) rpc.Response { + addr, tag, err := parseAddrAndTag(req.Params) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + } + st, err := rpc.StateAt(tag, h.repo, h.stater) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + bal, err := st.GetBalance(addr) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + return rpc.OkResponse(req.ID, (*hexutil.Big)(bal)) +} + +func (h *Handler) ethGetCode(req rpc.Request) rpc.Response { + addr, tag, err := parseAddrAndTag(req.Params) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + } + st, err := rpc.StateAt(tag, h.repo, h.stater) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + code, err := st.GetCode(addr) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + return rpc.OkResponse(req.ID, hexutil.Bytes(code)) +} + +func (h *Handler) ethGetStorageAt(req rpc.Request) rpc.Response { + var params [3]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [address, slot, blockTag]") + } + var addrStr, slotStr, tag string + if err := json.Unmarshal(params[0], &addrStr); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address") + } + if err := json.Unmarshal(params[1], &slotStr); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid slot") + } + if err := json.Unmarshal(params[2], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block tag") + } + + addr, err := rpc.ParseThorAddress(addrStr) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address") + } + slot, err := rpc.ParseThorBytes32(slotStr) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid slot") + } + st, err := rpc.StateAt(tag, h.repo, h.stater) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + val, err := st.GetStorage(addr, slot) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + return rpc.OkResponse(req.ID, common.Hash(val)) +} + +func (h *Handler) ethGetTransactionCount(req rpc.Request) rpc.Response { + addr, tag, err := parseAddrAndTag(req.Params) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + } + // NOTE: "pending" returns the confirmed nonce; pool scanning is not implemented. + st, err := rpc.StateAt(tag, h.repo, h.stater) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + nonce, err := st.GetNonce(addr) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + return rpc.OkResponse(req.ID, hexutil.Uint64(nonce)) +} + +func parseAddrAndTag(raw json.RawMessage) (thor.Address, string, error) { + var params [2]json.RawMessage + if err := json.Unmarshal(raw, ¶ms); err != nil { + return thor.Address{}, "", fmt.Errorf("expected [address, blockTag]") + } + var addrStr, tag string + if err := json.Unmarshal(params[0], &addrStr); err != nil { + return thor.Address{}, "", fmt.Errorf("invalid address") + } + if err := json.Unmarshal(params[1], &tag); err != nil { + return thor.Address{}, "", fmt.Errorf("invalid block tag") + } + addr, err := rpc.ParseThorAddress(addrStr) + if err != nil { + return thor.Address{}, "", fmt.Errorf("invalid address: %w", err) + } + return addr, tag, nil +} diff --git a/rpc/accounts/handler_test.go b/rpc/accounts/handler_test.go new file mode 100644 index 0000000000..ed0009c145 --- /dev/null +++ b/rpc/accounts/handler_test.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 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/genesis" + "github.com/vechain/thor/v2/rpc/accounts" + "github.com/vechain/thor/v2/rpc/testutil" +) + +func TestAccountsHandler(t *testing.T) { + fx := testutil.NewChainFixture(t) + ts := testutil.NewMinimalServer(t, accounts.New(fx.Chain.Repo(), fx.Chain.Stater())) + + senderAddr := fx.Sender.Address.String() + + t.Run("eth_getBalance", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getBalance", []any{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{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{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{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)) + }) +} diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go new file mode 100644 index 0000000000..97e55d9037 --- /dev/null +++ b/rpc/blocks/handler.go @@ -0,0 +1,80 @@ +// 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/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" +) + +// Handler implements block query JSON-RPC methods. +type Handler struct { + repo *chain.Repository + chainID uint64 +} + +// New creates a blocks Handler. +func New(repo *chain.Repository, chainID uint64) *Handler { + return &Handler{repo: repo, chainID: chainID} +} + +// Mount registers all block query methods on the dispatcher. +func (h *Handler) Mount(d *rpc.Dispatcher) { + d.Register("eth_getBlockByHash", h.ethGetBlockByHash) + d.Register("eth_getBlockByNumber", h.ethGetBlockByNumber) +} + +func (h *Handler) ethGetBlockByHash(req rpc.Request) rpc.Response { + var params [2]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash, fullTransactions]") + } + var hashStr string + if err := json.Unmarshal(params[0], &hashStr); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") + } + var fullTxs bool + if err := json.Unmarshal(params[1], &fullTxs); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fullTransactions flag") + } + + summary, err := rpc.ResolveBlockTag(hashStr, h.repo) + if err != nil { + return rpc.OkResponse(req.ID, nil) + } + blk, err := rpc.BuildEthBlock(summary.Header, h.repo, h.chainID, fullTxs) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + return rpc.OkResponse(req.ID, blk) +} + +func (h *Handler) ethGetBlockByNumber(req rpc.Request) rpc.Response { + var params [2]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber, fullTransactions]") + } + var tag string + if err := json.Unmarshal(params[0], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") + } + var fullTxs bool + if err := json.Unmarshal(params[1], &fullTxs); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fullTransactions flag") + } + + summary, err := rpc.ResolveBlockTag(tag, h.repo) + if err != nil { + return rpc.OkResponse(req.ID, nil) + } + blk, err := rpc.BuildEthBlock(summary.Header, h.repo, h.chainID, fullTxs) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + return rpc.OkResponse(req.ID, blk) +} diff --git a/rpc/blocks/handler_test.go b/rpc/blocks/handler_test.go new file mode 100644 index 0000000000..0db68deb38 --- /dev/null +++ b/rpc/blocks/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 blocks_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/blocks" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/tx" +) + +func TestBlocksHandler(t *testing.T) { + fx := testutil.NewChainFixture(t) + ts := testutil.NewMinimalServer(t, blocks.New(fx.Chain.Repo(), fx.ChainID)) + + 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.TypeEthTyped1559), 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)) + }) +} diff --git a/rpc/chain/handler.go b/rpc/chain/handler.go new file mode 100644 index 0000000000..f137b3ff05 --- /dev/null +++ b/rpc/chain/handler.go @@ -0,0 +1,56 @@ +// 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/hexutil" + + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" +) + +// Handler implements chain metadata JSON-RPC methods. +type Handler struct { + repo *chain.Repository + chainID uint64 + clientVersion string +} + +// New creates a chain Handler. +func New(repo *chain.Repository, chainID uint64, clientVersion string) *Handler { + return &Handler{repo: repo, chainID: chainID, clientVersion: clientVersion} +} + +// Mount registers all chain metadata methods on the dispatcher. +func (h *Handler) Mount(d *rpc.Dispatcher) { + d.Register("eth_chainId", h.ethChainID) + d.Register("net_version", h.netVersion) + d.Register("web3_clientVersion", h.web3ClientVersion) + d.Register("eth_blockNumber", h.ethBlockNumber) + d.Register("eth_syncing", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) + d.Register("eth_accounts", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, []string{}) }) + d.Register("eth_mining", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) + d.Register("eth_hashrate", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, "0x0") }) +} + +func (h *Handler) ethChainID(req rpc.Request) rpc.Response { + return rpc.OkResponse(req.ID, hexutil.Uint64(h.chainID)) +} + +func (h *Handler) netVersion(req rpc.Request) rpc.Response { + return rpc.OkResponse(req.ID, strconv.FormatUint(h.chainID, 10)) +} + +func (h *Handler) web3ClientVersion(req rpc.Request) rpc.Response { + return rpc.OkResponse(req.ID, "Thor/"+h.clientVersion) +} + +func (h *Handler) ethBlockNumber(req rpc.Request) rpc.Response { + num := h.repo.BestBlockSummary().Header.Number() + return rpc.OkResponse(req.ID, hexutil.Uint64(num)) +} diff --git a/rpc/chain/handler_test.go b/rpc/chain/handler_test.go new file mode 100644 index 0000000000..87928a0912 --- /dev/null +++ b/rpc/chain/handler_test.go @@ -0,0 +1,80 @@ +// 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" +) + +func TestChainHandler(t *testing.T) { + fx := testutil.NewChainFixture(t) + ts := testutil.NewMinimalServer(t, chain.New(fx.Chain.Repo(), fx.ChainID, "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.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) + }) +} diff --git a/rpc/dispatcher.go b/rpc/dispatcher.go new file mode 100644 index 0000000000..d93620ba42 --- /dev/null +++ b/rpc/dispatcher.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 "fmt" + +// Dispatcher routes JSON-RPC requests to registered method handlers. +// Sub-packages call Register via their Mount method; the Server calls dispatch. +type Dispatcher struct { + methods map[string]func(Request) Response +} + +// NewDispatcher creates an empty Dispatcher. +func NewDispatcher() *Dispatcher { + return &Dispatcher{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 (d *Dispatcher) Register(method string, handler func(Request) Response) { + if _, exists := d.methods[method]; exists { + panic("rpc: duplicate method registration: " + method) + } + d.methods[method] = handler +} + +func (d *Dispatcher) dispatch(req Request) Response { + h, ok := d.methods[req.Method] + if !ok { + return ErrResponse(req.ID, CodeMethodNotFound, fmt.Sprintf("method %q not found", req.Method)) + } + return h(req) +} diff --git a/rpc/eth_types.go b/rpc/eth_types.go new file mode 100644 index 0000000000..b19160bdbd --- /dev/null +++ b/rpc/eth_types.go @@ -0,0 +1,266 @@ +// 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 ( + "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/tx" +) + +// EthBlock is the Ethereum JSON representation of a block. +// Only TypeEthTyped1559 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"` + // TODO: compute from ETH tx logs once Phase 1 is complete. + LogsBloom hexutil.Bytes `json:"logsBloom"` + // TODO: compute Merkle root over projected ETH transactions. + TransactionsRoot common.Hash `json:"transactionsRoot"` + StateRoot common.Hash `json:"stateRoot"` + // TODO: compute Merkle root over projected ETH receipts. + 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 TypeEthTyped1559 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"` +} + +// 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") + +// zeroLogsBloom is a 256-byte zero bloom filter returned as a placeholder. +// TODO: compute from ETH tx events once Phase 1 bloom computation is implemented. +var zeroLogsBloom = make(hexutil.Bytes, 256) + +// zeroNonce is an 8-byte zero block nonce — VeChain uses PoA, not PoW. +var zeroNonce = make(hexutil.Bytes, 8) + +// EthTx is the Ethereum JSON representation of a TypeEthTyped1559 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"` +} + +// ToEthTx converts a TypeEthTyped1559 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) *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 + + // effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas) + // Fall back to maxFeePerGas when baseFee is unavailable (pre-GALACTICA blocks). + maxFee := t.MaxFeePerGas() + gasPrice := new(big.Int).Set(maxFee) + if baseFee != nil { + effective := new(big.Int).Add(baseFee, t.MaxPriorityFeePerGas()) + if effective.Cmp(gasPrice) < 0 { + gasPrice = effective + } + } + + num := hexutil.Uint64(blockNum) + idx := hexutil.Uint64(projectedIdx) + bh := blockHash + + return &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.TypeEthTyped1559), + ChainID: (*hexutil.Big)(new(big.Int).SetUint64(chainID)), + V: (*hexutil.Big)(v), + R: (*hexutil.Big)(r), + S: (*hexutil.Big)(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 TypeEthTyped1559 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"` + // TODO: compute bloom filter from ETH tx event logs. + 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"` +} + +// ToEthReceipt builds an Ethereum receipt for a TypeEthTyped1559 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, + chainID uint64, + blockHash common.Hash, + blockNum uint64, + projectedIdx uint64, + cumulativeGas uint64, + logIndexOffset uint64, + baseFee *big.Int, +) *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 + } + + maxFee := t.MaxFeePerGas() + effectiveGasPrice := new(big.Int).Set(maxFee) + if baseFee != nil { + effective := new(big.Int).Add(baseFee, t.MaxPriorityFeePerGas()) + if effective.Cmp(effectiveGasPrice) < 0 { + effectiveGasPrice = effective + } + } + + txHash := common.Hash(t.ID()) + txIdx := hexutil.Uint64(projectedIdx) + + var logs []*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, &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 = []*EthLog{} + } + + return &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: zeroLogsBloom, + Status: status, + Type: hexutil.Uint64(tx.TypeEthTyped1559), + EffectiveGasPrice: (*hexutil.Big)(effectiveGasPrice), + } +} diff --git a/rpc/fees/handler.go b/rpc/fees/handler.go new file mode 100644 index 0000000000..366d04aa22 --- /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/rpc" +) + +// Handler implements fee market JSON-RPC methods. +type Handler struct { + repo *chain.Repository + backtrace uint32 +} + +// New creates a fees Handler. +func New(repo *chain.Repository, backtrace uint32) *Handler { + return &Handler{repo: repo, backtrace: backtrace} +} + +// Mount registers all fee market methods on the dispatcher. +func (h *Handler) Mount(d *rpc.Dispatcher) { + d.Register("eth_gasPrice", h.ethGasPrice) + d.Register("eth_maxPriorityFeePerGas", h.ethMaxPriorityFeePerGas) + d.Register("eth_feeHistory", h.ethFeeHistory) +} + +func (h *Handler) ethGasPrice(req rpc.Request) rpc.Response { + header := h.repo.BestBlockSummary().Header + baseFee := header.BaseFee() + tip := big.NewInt(1e9) // 1 gwei tip suggestion + if baseFee == nil { + return rpc.OkResponse(req.ID, (*hexutil.Big)(tip)) + } + price := new(big.Int).Add(baseFee, tip) + return rpc.OkResponse(req.ID, (*hexutil.Big)(price)) +} + +func (h *Handler) ethMaxPriorityFeePerGas(req rpc.Request) rpc.Response { + // TODO: derive from on-chain params contract once available. + return rpc.OkResponse(req.ID, (*hexutil.Big)(big.NewInt(1e9))) +} + +func (h *Handler) ethFeeHistory(req rpc.Request) rpc.Response { + var params []json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 2 { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockCount, newestBlock, rewardPercentiles]") + } + + blockCountRaw := params[0] + var newestRaw string + if err := json.Unmarshal(params[1], &newestRaw); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid newestBlock") + } + + // Parse block count (may be hex string or integer) + var blockCount uint64 + var s string + if err := json.Unmarshal(blockCountRaw, &s); err == nil { + n, err := rpc.ParseHexUint64(s) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid blockCount") + } + blockCount = n + } else { + if err := json.Unmarshal(blockCountRaw, &blockCount); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid blockCount") + } + } + + if blockCount == 0 { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "blockCount must be > 0") + } + if blockCount > uint64(h.backtrace) { + blockCount = uint64(h.backtrace) + } + + newestSummary, err := rpc.ResolveBlockTag(newestRaw, h.repo) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + + newestNum := uint64(newestSummary.Header.Number()) + if blockCount > newestNum+1 { + blockCount = newestNum + 1 + } + oldestNum := newestNum - blockCount + 1 + + bestChain := h.repo.NewBestChain() + + baseFees := make([]*hexutil.Big, 0, blockCount+1) + gasUsedRatios := make([]float64, 0, blockCount) + + for n := oldestNum; n <= newestNum; n++ { + hdr, err := bestChain.GetBlockHeader(uint32(n)) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.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))) + } + ratio := 0.0 + if hdr.GasLimit() > 0 { + ratio = float64(hdr.GasUsed()) / float64(hdr.GasLimit()) + } + gasUsedRatios = append(gasUsedRatios, ratio) + } + // Include the next block's baseFee (the block after newestBlock). + // We use the newestBlock's baseFee as an approximation since we don't compute the next one. + baseFees = append(baseFees, baseFees[len(baseFees)-1]) + + type feeHistoryResult struct { + OldestBlock hexutil.Uint64 `json:"oldestBlock"` + BaseFeePerGas []*hexutil.Big `json:"baseFeePerGas"` + GasUsedRatio []float64 `json:"gasUsedRatio"` + Reward [][]interface{} `json:"reward"` + } + return rpc.OkResponse(req.ID, feeHistoryResult{ + OldestBlock: hexutil.Uint64(oldestNum), + BaseFeePerGas: baseFees, + GasUsedRatio: gasUsedRatios, + Reward: [][]interface{}{}, + }) +} diff --git a/rpc/fees/handler_test.go b/rpc/fees/handler_test.go new file mode 100644 index 0000000000..01c0874df1 --- /dev/null +++ b/rpc/fees/handler_test.go @@ -0,0 +1,65 @@ +// 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/rpc/fees" + "github.com/vechain/thor/v2/rpc/testutil" +) + +func TestFeesHandler(t *testing.T) { + fx := testutil.NewChainFixture(t) + ts := testutil.NewMinimalServer(t, fees.New(fx.Chain.Repo(), 100)) + + 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) + }) +} diff --git a/rpc/integration_test.go b/rpc/integration_test.go new file mode 100644 index 0000000000..e0a0486328 --- /dev/null +++ b/rpc/integration_test.go @@ -0,0 +1,123 @@ +// 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" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/rpc" + "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/logs" + "github.com/vechain/thor/v2/rpc/simulation" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/rpc/transactions" +) + +// newFullServer assembles all sub-packages onto a single Dispatcher and returns +// an httptest.Server. Used only by integration_test.go for dispatch-level tests +// that need the full method table to be reachable. +func newFullServer(t *testing.T, fx *testutil.ChainFixture) *httptest.Server { + t.Helper() + pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) + d := rpc.NewDispatcher() + rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(d) + blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(d) + transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(d) + accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(d) + logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(d) + fees.New(fx.Chain.Repo(), 100).Mount(d) + simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(d) + ts := httptest.NewServer(rpc.New(d)) + t.Cleanup(ts.Close) + return ts +} + +// 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) { + fx := testutil.NewChainFixture(t) + ts := newFullServer(t, fx) + + t.Run("unknown_method", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_nonExistentMethod", []any{}) + assert.Equal(t, rpc.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 *rpc.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 := 0; i < 11; i++ { + 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 *rpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.NotNil(t, rpcResp.Error) + assert.Equal(t, rpc.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 *rpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.NotNil(t, rpcResp.Error) + assert.Equal(t, rpc.CodeParseError, 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/logs/handler.go b/rpc/logs/handler.go new file mode 100644 index 0000000000..98e6181bc9 --- /dev/null +++ b/rpc/logs/handler.go @@ -0,0 +1,228 @@ +// 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" + + "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/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 +} + +// New creates a logs Handler. +func New(repo *chain.Repository, logDB *logdb.LogDB, backtrace uint32) *Handler { + return &Handler{repo: repo, logDB: logDB, backtrace: backtrace} +} + +// Mount registers all log methods on the dispatcher. +func (h *Handler) Mount(d *rpc.Dispatcher) { + d.Register("eth_getLogs", h.ethGetLogs) +} + +// LogFilter mirrors the Ethereum eth_getLogs filter parameter. +type LogFilter 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 +} + +func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { + var params []LogFilter + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterObject]") + } + f := params[0] + + bestChain := h.repo.NewBestChain() + bestNum := h.repo.BestBlockSummary().Header.Number() + + var fromNum, toNum uint32 + + if f.BlockHash != nil { + // EIP-234: single block identified by hash + summary, err := rpc.ResolveBlockTag(*f.BlockHash, h.repo) + if err != nil { + return rpc.OkResponse(req.ID, []*rpc.EthLog{}) + } + fromNum = summary.Header.Number() + toNum = summary.Header.Number() + } else { + // Default range + toNum = bestNum + + if f.FromBlock != nil && *f.FromBlock != "" { + summary, err := rpc.ResolveBlockTag(*f.FromBlock, h.repo) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fromBlock") + } + fromNum = summary.Header.Number() + } + if f.ToBlock != nil && *f.ToBlock != "" { + summary, err := rpc.ResolveBlockTag(*f.ToBlock, h.repo) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid toBlock") + } + toNum = summary.Header.Number() + } + + if toNum > bestNum { + toNum = bestNum + } + if toNum > fromNum && toNum-fromNum > h.backtrace { + return rpc.ErrResponse(req.ID, rpc.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 := rpc.ParseThorAddress(single) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.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 := rpc.ParseThorAddress(s) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.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: topics: ["A", "B"] means topic0==A AND topic1==B. + // OR semantics within one position (topics: [["A","C"], "B"]) are not yet fully + // supported — only the first alternative is used. + // TODO: full OR-within-position support requires expanding into a cross-product of + // EventCriteria (one per combination of per-position alternatives). + var topicSlot [5]*thor.Bytes32 + topics := f.Topics + if len(topics) > len(topicSlot) { + topics = topics[:len(topicSlot)] + } + 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.ParseThorBytes32(single) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") + } + h32Copy := h32 + topicSlot[i] = &h32Copy + } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { + h32, err := rpc.ParseThorBytes32(multi[0]) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") + } + h32Copy := h32 + topicSlot[i] = &h32Copy + } + } + + // Build criteria set: one EventCriteria per address with all topic positions ANDed. + var criteriaSet []*logdb.EventCriteria + buildCriteria := func(addr *thor.Address) { + criteriaSet = append(criteriaSet, &logdb.EventCriteria{ + Address: addr, + Topics: topicSlot, + }) + } + + if len(addresses) == 0 { + buildCriteria(nil) + } else { + for _, addr := range addresses { + a := addr + buildCriteria(a) + } + } + + filter := &logdb.EventFilter{ + CriteriaSet: criteriaSet, + Range: &logdb.Range{ + From: fromNum, + To: toNum, + }, + Options: &logdb.Options{Limit: 10000}, + Order: logdb.ASC, + } + + events, err := h.logDB.FilterEvents(context.Background(), filter) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + + // Post-filter: only return logs from TypeEthTyped1559 transactions. + // Cache tx type lookups per unique TxID to avoid redundant chain reads. + typeCache := make(map[thor.Bytes32]bool) + isEthTx := func(txID thor.Bytes32) bool { + if v, ok := typeCache[txID]; ok { + return v + } + t, _, err := bestChain.GetTransaction(txID) + ok2 := err == nil && t.Type() == tx.TypeEthTyped1559 + typeCache[txID] = ok2 + return ok2 + } + + var ethLogs []*rpc.EthLog + for _, ev := range events { + if !isEthTx(ev.TxID) { + continue + } + topics := make([]common.Hash, 0, 5) + for _, tp := range ev.Topics { + if tp == nil { + break + } + topics = append(topics, common.Hash(*tp)) + } + ethLogs = append(ethLogs, &rpc.EthLog{ + Address: common.Address(ev.Address), + Topics: topics, + Data: ev.Data, + BlockNumber: hexutil.Uint64(ev.BlockNumber), + TxHash: common.Hash(ev.TxID), + TxIndex: hexutil.Uint64(ev.TxIndex), + BlockHash: common.Hash(ev.BlockID), + LogIndex: hexutil.Uint64(ev.LogIndex), + Removed: false, + }) + } + if ethLogs == nil { + ethLogs = []*rpc.EthLog{} + } + return rpc.OkResponse(req.ID, ethLogs) +} diff --git a/rpc/logs/handler_test.go b/rpc/logs/handler_test.go new file mode 100644 index 0000000000..cec1c3f343 --- /dev/null +++ b/rpc/logs/handler_test.go @@ -0,0 +1,55 @@ +// 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" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/rpc/logs" + "github.com/vechain/thor/v2/rpc/testutil" +) + +func TestLogsHandler(t *testing.T) { + fx := testutil.NewChainFixture(t) + ts := testutil.NewMinimalServer(t, logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100)) + + t.Run("eth_getLogs_empty", func(t *testing.T) { + // The fixture ETH tx is a plain VET transfer — it emits no contract events. + // eth_getLogs therefore returns an empty array. + // + // TODO: extend ChainFixture with a contract-deploy tx that emits events so we + // can assert on non-empty log results (address filter, topic filter, EIP-234 + // blockHash filter, etc.). + 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) + }) +} diff --git a/rpc/server.go b/rpc/server.go new file mode 100644 index 0000000000..2eb45152e6 --- /dev/null +++ b/rpc/server.go @@ -0,0 +1,104 @@ +// 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 ( + "bytes" + "encoding/json" + "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 supports both single and batch requests. +type Server struct { + d *Dispatcher +} + +// New creates a new Server backed by the given Dispatcher. +func New(d *Dispatcher) *Server { + return &Server{d: d} +} + +// ServeHTTP implements http.Handler. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + // CORS preflight handled by the gorilla/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 + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + if err != nil { + 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) + } +} + +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.d.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.d.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/simulation/handler.go b/rpc/simulation/handler.go new file mode 100644 index 0000000000..ec5bd4ca82 --- /dev/null +++ b/rpc/simulation/handler.go @@ -0,0 +1,184 @@ +// 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" + "fmt" + "math/big" + + "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/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(d *rpc.Dispatcher) { + d.Register("eth_call", h.ethCall) + d.Register("eth_estimateGas", h.ethEstimateGas) +} + +// 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"` + Value *hexutil.Big `json:"value"` + Data hexutil.Bytes `json:"data"` +} + +func (h *Handler) ethCall(req rpc.Request) rpc.Response { + args, tag, err := parseCallArgs(req.Params) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + } + + out, _, execErr := h.simulate(args, tag, h.callGasLimit) + if execErr != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, execErr.Error()) + } + if out.VMErr != nil { + return rpc.ErrResponseWithData(req.ID, rpc.CodeServerError, "execution reverted", hexutil.Encode(out.Data)) + } + return rpc.OkResponse(req.ID, hexutil.Bytes(out.Data)) +} + +func (h *Handler) ethEstimateGas(req rpc.Request) rpc.Response { + args, tag, err := parseCallArgs(req.Params) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + } + + limit := h.callGasLimit + if args.Gas != nil && uint64(*args.Gas) < limit { + limit = uint64(*args.Gas) + } + + // Run with full gas limit to determine if the call succeeds at all. + out, _, execErr := h.simulate(args, tag, limit) + if execErr != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, execErr.Error()) + } + if out.VMErr != nil { + return rpc.ErrResponseWithData(req.ID, rpc.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 args.To != nil { + addr := thor.Address(*args.To) + to = &addr + } + intrinsic, err := tx.IntrinsicGas(tx.NewClause(to).WithData(args.Data)) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + + return rpc.OkResponse(req.ID, hexutil.Uint64(evmGasUsed+intrinsic)) +} + +func (h *Handler) simulate(args CallArgs, tag string, gasLimit uint64) (*runtime.Output, *state.State, error) { + summary, err := rpc.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.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, + TxType: tx.TypeEthTyped1559, + } + + exec, _ := rt.PrepareClause(clause, 0, gasLimit, txCtx) + out, _, err := exec() + return out, st, err +} + +func parseCallArgs(raw json.RawMessage) (CallArgs, string, error) { + var params []json.RawMessage + if err := json.Unmarshal(raw, ¶ms); err != nil || len(params) < 1 { + return CallArgs{}, "", fmt.Errorf("expected [callArgs, blockTag?]") + } + var args CallArgs + if err := json.Unmarshal(params[0], &args); err != nil { + return CallArgs{}, "", fmt.Errorf("invalid call arguments: %w", err) + } + tag := "latest" + if len(params) >= 2 { + if err := json.Unmarshal(params[1], &tag); err != nil { + return CallArgs{}, "", fmt.Errorf("invalid block tag") + } + } + return args, tag, nil +} diff --git a/rpc/simulation/handler_test.go b/rpc/simulation/handler_test.go new file mode 100644 index 0000000000..c8d90223d5 --- /dev/null +++ b/rpc/simulation/handler_test.go @@ -0,0 +1,75 @@ +// 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" + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/rpc/simulation" + "github.com/vechain/thor/v2/rpc/testutil" +) + +func TestSimulationHandler(t *testing.T) { + fx := testutil.NewChainFixture(t) + ts := testutil.NewMinimalServer(t, simulation.New( + fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000, + )) + + senderAddr := fx.Sender.Address.String() + recipientAddr := fx.Recipient.Address.String() + + 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": senderAddr, + "to": 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": senderAddr, + "to": 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": senderAddr, + "to": 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)) + }) +} diff --git a/rpc/testutil/testutil.go b/rpc/testutil/testutil.go new file mode 100644 index 0000000000..a8020e8749 --- /dev/null +++ b/rpc/testutil/testutil.go @@ -0,0 +1,191 @@ +// 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" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/vechain/thor/v2/genesis" + "github.com/vechain/thor/v2/rpc" + "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" + "github.com/vechain/thor/v2/txpool" +) + +// ChainFixture holds a fully initialised test chain with one minted block. +// Block layout: position 0 = VeChain TypeLegacy tx, position 1 = Ethereum EIP-1559 tx. +// The EIP-1559 tx has projected ETH index 0 (it is the only ETH tx in the block). +type ChainFixture struct { + Chain *testchain.Chain + Forks thor.ForkConfig + ChainID uint64 + Sender genesis.DevAccount // DevAccounts()[0] — sent both txs, nonce incremented to 1 + Recipient genesis.DevAccount // DevAccounts()[1] — received VET from both txs + EthTx *tx.Transaction // Ethereum EIP-1559 tx at canonical index 1 + VcTx *tx.Transaction // VeChain TypeLegacy tx at canonical index 0 + BlockHash string // 0x-prefixed 66-char hex of block 1 + EthTxHash string // ETH tx ID (= Keccak256 of raw wire bytes) + VcTxHash string // VeChain tx ID +} + +// NewChainFixture creates a standard test chain ready for RPC tests: +// - ForkConfig{} — all forks active from block 0 (GALACTICA, INTERSTELLAR, …) +// - Genesis block + 1 minted block containing one VeChain tx and one ETH tx +func NewChainFixture(t *testing.T) *ChainFixture { + t.Helper() + + // Disable PoS transition so tests don't have to deal with HayabusaTP complexity. + hayabusaTP := uint32(math.MaxUint32) + thor.SetConfig(thor.Config{HayabusaTP: &hayabusaTP}) + + forks := thor.ForkConfig{} + thorChain, err := testchain.NewWithFork(&forks, 180) + require.NoError(t, err) + + chainID := thor.GetEthChainID(thorChain.GenesisBlock().Header().ID()) + + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] + + // VeChain TypeLegacy tx — canonical position 0. + vcTxTo := recipient.Address + vcTx := tx.NewBuilder(tx.TypeLegacy). + ChainTag(thorChain.Repo().ChainTag()). + BlockRef(tx.NewBlockRef(thorChain.Repo().BestBlockSummary().Header.Number())). + Expiration(1000). + GasPriceCoef(255). + Gas(21000). + Nonce(datagen.RandUint64()). + Clause(tx.NewClause(&vcTxTo).WithValue(big.NewInt(1e9))). + Build() + vcTx = tx.MustSign(vcTx, sender.PrivateKey) + + // Ethereum EIP-1559 tx — canonical position 1, projected ETH index 0. + ethTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + ChainID(chainID). + Nonce(0). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + GasLimit(21000). + To(&vcTxTo). + Value(big.NewInt(1e9)). + Build(sender.PrivateKey) + require.NoError(t, err) + + require.NoError(t, thorChain.MintBlock(vcTx, ethTx)) + require.Equal(t, uint32(1), thorChain.Repo().BestBlockSummary().Header.Number()) + + bestBlock, err := thorChain.BestBlock() + require.NoError(t, err) + + return &ChainFixture{ + Chain: thorChain, + Forks: forks, + ChainID: chainID, + Sender: sender, + Recipient: recipient, + EthTx: ethTx, + VcTx: vcTx, + BlockHash: bestBlock.Header().ID().String(), + EthTxHash: ethTx.ID().String(), + VcTxHash: vcTx.ID().String(), + } +} + +// Mounter is satisfied by any sub-package handler that exposes Mount. +type Mounter interface { + Mount(d *rpc.Dispatcher) +} + +// NewMinimalServer 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 NewMinimalServer(t *testing.T, m Mounter) *httptest.Server { + t.Helper() + d := rpc.NewDispatcher() + m.Mount(d) + ts := httptest.NewServer(rpc.New(d)) + t.Cleanup(ts.Close) + return ts +} + +// DefaultPool creates a txpool suitable for testing and registers t.Cleanup(pool.Close). +func DefaultPool(t *testing.T, c *testchain.Chain, forks *thor.ForkConfig) txpool.Pool { + t.Helper() + p := txpool.New(c.Repo(), c.Stater(), txpool.Options{ + Limit: 10000, + LimitPerAccount: 16, + MaxLifetime: 10 * time.Minute, + }, forks) + t.Cleanup(p.Close) + return p +} + +// 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+"/", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error *rpc.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) *rpc.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+"/", "application/json", bytes.NewReader(body)) + require.NoError(t, err) + defer resp.Body.Close() + + var rpcResp struct { + Result json.RawMessage `json:"result"` + Error *rpc.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..22cd342821 --- /dev/null +++ b/rpc/transactions/handler.go @@ -0,0 +1,200 @@ +// 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/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/chain" + "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/txpool" +) + +// Handler implements transaction JSON-RPC methods. +type Handler struct { + repo *chain.Repository + chainID uint64 + txPool txpool.Pool +} + +// New creates a transactions Handler. +func New(repo *chain.Repository, chainID uint64, txPool txpool.Pool) *Handler { + return &Handler{repo: repo, chainID: chainID, txPool: txPool} +} + +// Mount registers all transaction methods on the dispatcher. +func (h *Handler) Mount(d *rpc.Dispatcher) { + d.Register("eth_getTransactionByHash", h.ethGetTransactionByHash) + d.Register("eth_getTransactionByBlockHashAndIndex", h.ethGetTransactionByBlockHashAndIndex) + d.Register("eth_getTransactionByBlockNumberAndIndex", h.ethGetTransactionByBlockNumberAndIndex) + d.Register("eth_getTransactionReceipt", h.ethGetTransactionReceipt) + d.Register("eth_sendRawTransaction", h.ethSendRawTransaction) +} + +func (h *Handler) ethGetTransactionByHash(req rpc.Request) rpc.Response { + var params []string + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [txHash]") + } + id, err := rpc.ParseThorBytes32(params[0]) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") + } + + bestChain := h.repo.NewBestChain() + t, meta, err := bestChain.GetTransaction(id) + if err != nil || t.Type() != tx.TypeEthTyped1559 { + return rpc.OkResponse(req.ID, nil) + } + + header, err := bestChain.GetBlockHeader(meta.BlockNum) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + receipts, err := h.repo.GetBlockReceipts(header.ID()) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + + projIdx := rpc.ProjectedEthIndex(receipts, meta.Index) + return rpc.OkResponse(req.ID, rpc.ToEthTx(t, h.chainID, common.Hash(header.ID()), uint64(header.Number()), projIdx, header.BaseFee())) +} + +func (h *Handler) ethGetTransactionByBlockHashAndIndex(req rpc.Request) rpc.Response { + var params [2]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash, index]") + } + var hashStr string + if err := json.Unmarshal(params[0], &hashStr); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") + } + var idxStr string + if err := json.Unmarshal(params[1], &idxStr); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid index") + } + + summary, err := rpc.ResolveBlockTag(hashStr, h.repo) + if err != nil { + return rpc.OkResponse(req.ID, nil) + } + return h.txByBlockAndEthIndex(req, summary.Header, idxStr) +} + +func (h *Handler) ethGetTransactionByBlockNumberAndIndex(req rpc.Request) rpc.Response { + var params [2]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber, index]") + } + var tag string + if err := json.Unmarshal(params[0], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") + } + var idxStr string + if err := json.Unmarshal(params[1], &idxStr); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid index") + } + + summary, err := rpc.ResolveBlockTag(tag, h.repo) + if err != nil { + return rpc.OkResponse(req.ID, nil) + } + return h.txByBlockAndEthIndex(req, summary.Header, idxStr) +} + +func (h *Handler) txByBlockAndEthIndex(req rpc.Request, header *block.Header, idxStr string) rpc.Response { + ethIdx, err := rpc.ParseHexUint64(idxStr) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid index") + } + + blk, err := h.repo.GetBlock(header.ID()) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + receipts, err := h.repo.GetBlockReceipts(header.ID()) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + + blockHash := common.Hash(header.ID()) + blockNum := uint64(header.Number()) + var projIdx uint64 + + for i, t := range blk.Transactions() { + if t.Type() != tx.TypeEthTyped1559 { + continue + } + if projIdx == ethIdx { + return rpc.OkResponse(req.ID, rpc.ToEthTx(t, h.chainID, blockHash, blockNum, projIdx, header.BaseFee())) + } + _ = receipts[i] // bounds check + projIdx++ + } + return rpc.OkResponse(req.ID, nil) +} + +func (h *Handler) ethGetTransactionReceipt(req rpc.Request) rpc.Response { + var params []string + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [txHash]") + } + id, err := rpc.ParseThorBytes32(params[0]) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") + } + + bestChain := h.repo.NewBestChain() + t, meta, err := bestChain.GetTransaction(id) + if err != nil || t.Type() != tx.TypeEthTyped1559 { + return rpc.OkResponse(req.ID, nil) + } + + header, err := bestChain.GetBlockHeader(meta.BlockNum) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + receipts, err := h.repo.GetBlockReceipts(header.ID()) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + + receipt := receipts[meta.Index] + projIdx := rpc.ProjectedEthIndex(receipts, meta.Index) + cumGas := rpc.CumulativeEthGasUsed(receipts, meta.Index) + logOff := rpc.EthLogOffset(receipts, meta.Index) + + return rpc.OkResponse(req.ID, rpc.ToEthReceipt( + t, receipt, h.chainID, + common.Hash(header.ID()), uint64(header.Number()), + projIdx, cumGas, logOff, header.BaseFee(), + )) +} + +func (h *Handler) ethSendRawTransaction(req rpc.Request) rpc.Response { + var params []string + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [rawTx]") + } + raw, err := hexutil.Decode(params[0]) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid hex encoding") + } + + parsed, err := tx.ParseEthTransaction(raw, h.chainID) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeServerError, err.Error()) + } + if err := h.txPool.AddLocal(parsed); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeServerError, err.Error()) + } + return rpc.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..5f035934a1 --- /dev/null +++ b/rpc/transactions/handler_test.go @@ -0,0 +1,167 @@ +// 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/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/genesis" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/rpc/transactions" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +func TestTransactionsHandler(t *testing.T) { + fx := testutil.NewChainFixture(t) + pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) + ts := testutil.NewMinimalServer(t, transactions.New(fx.Chain.Repo(), fx.ChainID, 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.TypeEthTyped1559), uint64(txType)) + }) + + 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 + + freshTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + ChainID(fx.ChainID). + Nonce(0). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + GasLimit(21000). + To(&freshRecipient). + Value(big.NewInt(1e9)). + Build(freshSender.PrivateKey) + require.NoError(t, err) + + rawBytes, err := freshTx.MarshalBinary() + require.NoError(t, err) + + result := testutil.Call(t, ts, "eth_sendRawTransaction", []any{"0x" + hexBytesToString(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) + }) +} + +func hexBytesToString(b []byte) string { + const hextable = "0123456789abcdef" + buf := make([]byte, len(b)*2) + for i, v := range b { + buf[i*2] = hextable[v>>4] + buf[i*2+1] = hextable[v&0x0f] + } + return string(buf) +} diff --git a/rpc/types.go b/rpc/types.go new file mode 100644 index 0000000000..f628bae8bd --- /dev/null +++ b/rpc/types.go @@ -0,0 +1,67 @@ +// 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" + +// 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 + 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/utils.go b/rpc/utils.go new file mode 100644 index 0000000000..c32fc91102 --- /dev/null +++ b/rpc/utils.go @@ -0,0 +1,265 @@ +// 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 ( + "fmt" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/vechain/thor/v2/block" + "github.com/vechain/thor/v2/chain" + "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. +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 := hexDecode(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 EthBlock from a VeChain block header. +// Only TypeEthTyped1559 transactions are included in the transactions field. +func BuildEthBlock( + header *block.Header, + repo *chain.Repository, + chainID uint64, + fullTxs bool, +) (*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 + var ethTxFull []*EthTx + var ethGasUsed uint64 + + baseFee := header.BaseFee() + + for i, t := range txs { + if t.Type() != tx.TypeEthTyped1559 { + continue + } + projIdx := ProjectedEthIndex(receipts, uint64(i)) + ethGasUsed += receipts[i].GasUsed + if fullTxs { + ethTxFull = append(ethTxFull, ToEthTx(t, chainID, blockHash, blockNum, projIdx, baseFee)) + } else { + ethTxHashes = append(ethTxHashes, common.Hash(t.ID())) + } + } + + var transactions any + if fullTxs { + if ethTxFull == nil { + ethTxFull = []*EthTx{} + } + transactions = ethTxFull + } else { + if ethTxHashes == nil { + ethTxHashes = []common.Hash{} + } + transactions = ethTxHashes + } + + var baseFeePerGas *hexutil.Big + if baseFee != nil { + baseFeePerGas = (*hexutil.Big)(baseFee) + } + + return &EthBlock{ + Number: hexutil.Uint64(blockNum), + Hash: blockHash, + ParentHash: common.Hash(header.ParentID()), + Nonce: zeroNonce, + Sha3Uncles: emptyUncleHash, + LogsBloom: zeroLogsBloom, + TransactionsRoot: common.Hash{}, // TODO: compute Merkle root over projected ETH txs + StateRoot: common.Hash(header.StateRoot()), + ReceiptsRoot: common.Hash{}, // TODO: compute Merkle root over projected ETH receipts + 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 +} + +// ProjectedEthIndex returns the 0-based Ethereum transaction index for a TypeEthTyped1559 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 := uint64(0); i < canonicalIdx; i++ { + if receipts[i].Type == tx.TypeEthTyped1559 { + count++ + } + } + return count +} + +// CumulativeEthGasUsed returns the cumulative gas used by TypeEthTyped1559 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.TypeEthTyped1559 { + total += receipts[i].GasUsed + } + } + return total +} + +// EthLogOffset returns the number of logs emitted by TypeEthTyped1559 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 := uint64(0); i < canonicalIdx; i++ { + if receipts[i].Type == tx.TypeEthTyped1559 && len(receipts[i].Outputs) > 0 { + offset += uint64(len(receipts[i].Outputs[0].Events)) + } + } + return offset +} + +// ParseThorAddress parses a 0x-prefixed Ethereum address string into thor.Address. +func ParseThorAddress(s string) (thor.Address, error) { + if !isHexPrefix(s) || len(s) != 42 { + return thor.Address{}, fmt.Errorf("invalid address %q", s) + } + b, err := hexDecode(s[2:]) + if err != nil { + return thor.Address{}, err + } + var addr thor.Address + copy(addr[:], b) + return addr, nil +} + +// ParseThorBytes32 parses a 0x-prefixed 32-byte hex string. +func ParseThorBytes32(s string) (thor.Bytes32, error) { + if !isHexPrefix(s) { + return thor.Bytes32{}, fmt.Errorf("invalid hex %q", s) + } + b, err := hexDecode(s[2:]) + if err != nil { + return thor.Bytes32{}, err + } + var h32 thor.Bytes32 + copy(h32[32-len(b):], b) + return h32, nil +} + +// ParseHexUint64 parses a 0x-prefixed hex string to uint64. +func ParseHexUint64(s string) (uint64, error) { + if !isHexPrefix(s) { + return 0, fmt.Errorf("invalid hex %q", s) + } + return strconv.ParseUint(s[2:], 16, 64) +} + +func hexDecode(s string) ([]byte, error) { + if len(s)%2 != 0 { + s = "0" + s + } + b := make([]byte, len(s)/2) + for i := range b { + hi, ok1 := fromHexChar(s[2*i]) + lo, ok2 := fromHexChar(s[2*i+1]) + if !ok1 || !ok2 { + return nil, fmt.Errorf("invalid hex character at position %d", 2*i) + } + b[i] = (hi << 4) | lo + } + return b, nil +} + +func fromHexChar(c byte) (byte, bool) { + switch { + case c >= '0' && c <= '9': + return c - '0', true + case c >= 'a' && c <= 'f': + return c - 'a' + 10, true + case c >= 'A' && c <= 'F': + return c - 'A' + 10, true + } + return 0, false +} + +func isHexPrefix(s string) bool { + return len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') +} diff --git a/thorclient/rpc_test.go b/thorclient/rpc_test.go new file mode 100644 index 0000000000..d5ca87cbc4 --- /dev/null +++ b/thorclient/rpc_test.go @@ -0,0 +1,492 @@ +// 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/httptest" + "strconv" + "strings" + "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" + "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/logs" + "github.com/vechain/thor/v2/rpc/simulation" + "github.com/vechain/thor/v2/rpc/testutil" + "github.com/vechain/thor/v2/rpc/transactions" + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/tx" +) + +// ethRPCTestEnv holds the test server and all pre-minted transaction context. +// +// Chain layout: +// +// Block 0: genesis +// Block 1: VcTx (TypeLegacy) + EthTx (EIP-1559, nonce=0, from Sender) +// Block 2: EthTx2 (EIP-1559, nonce=1, from Sender) + EthTx3 (EIP-1559, nonce=0, from DevAccounts[2]) +type ethRPCTestEnv struct { + ts *httptest.Server + fx *testutil.ChainFixture + block2Hash string // block 2 ID (0x-prefixed hex) + ethTx2Hash string // block 2, projected ETH index 0 (Sender, nonce=1) + ethTx3Hash string // block 2, projected ETH index 1 (DevAccounts[2], nonce=0) +} + +// newEthRPCFixture builds the two-block chain and assembles all ETH RPC handlers. +func newEthRPCFixture(t *testing.T) *ethRPCTestEnv { + t.Helper() + + // Block 0 + block 1 come from the shared ChainFixture. + fx := testutil.NewChainFixture(t) + + // Block 2: two ETH txs from different senders so we get non-trivial + // transactionIndex and cumulativeGasUsed on the second receipt. + sender2 := genesis.DevAccounts()[2] + + ethTx2, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + ChainID(fx.ChainID). + Nonce(1). // sender's next nonce: used nonce=0 in block 1 + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + GasLimit(21000). + To(&fx.Recipient.Address). + Value(big.NewInt(1e9)). + Build(fx.Sender.PrivateKey) + require.NoError(t, err) + + ethTx3, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + ChainID(fx.ChainID). + Nonce(0). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + GasLimit(21000). + To(&fx.Recipient.Address). + Value(big.NewInt(1e9)). + Build(sender2.PrivateKey) + require.NoError(t, err) + + require.NoError(t, fx.Chain.MintBlock(ethTx2, ethTx3)) + + block2, err := fx.Chain.BestBlock() + require.NoError(t, err) + + pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) + d := rpc.NewDispatcher() + rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(d) + blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(d) + transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(d) + accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(d) + logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(d) + fees.New(fx.Chain.Repo(), 100).Mount(d) + simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(d) + ts := httptest.NewServer(rpc.New(d)) + t.Cleanup(ts.Close) + + return ðRPCTestEnv{ + ts: ts, + fx: fx, + block2Hash: block2.Header().ID().String(), + ethTx2Hash: ethTx2.ID().String(), + ethTx3Hash: ethTx3.ID().String(), + } +} + +func TestEthRPC(t *testing.T) { + env := newEthRPCFixture(t) + ts, fx := env.ts, env.fx + + // ── Identity ────────────────────────────────────────────────────────────── + + t.Run("eth_chainId", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_chainId", []any{}) + var chainID hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &chainID)) + assert.Equal(t, fx.ChainID, uint64(chainID)) + }) + + t.Run("net_version", func(t *testing.T) { + result := testutil.Call(t, ts, "net_version", []any{}) + var version string + require.NoError(t, json.Unmarshal(result, &version)) + assert.Equal(t, strconv.FormatUint(fx.ChainID, 10), version) + }) + + t.Run("eth_blockNumber", func(t *testing.T) { + // Chain has genesis + 2 minted blocks. + result := testutil.Call(t, ts, "eth_blockNumber", []any{}) + var num hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &num)) + assert.Equal(t, uint64(2), uint64(num)) + }) + + // ── 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, ts, "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(fx.BlockHash, 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(fx.EthTxHash, 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, ts, "eth_getBlockByNumber", []any{"latest", 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(env.ethTx2Hash, txHashes[0])) + assert.True(t, strings.EqualFold(env.ethTx3Hash, 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, ts, "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, ts, "eth_getBlockByHash", []any{fx.BlockHash, 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(fx.BlockHash, hash)) + }) + + // ── Transactions ────────────────────────────────────────────────────────── + + t.Run("eth_getTransactionByHash_eth", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByHash", []any{fx.EthTxHash}) + var ethTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTx)) + require.NotNil(t, ethTx) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(ethTx["type"], &txType)) + assert.Equal(t, uint64(2), uint64(txType)) + + var hash string + require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) + assert.True(t, strings.EqualFold(fx.EthTxHash, hash)) + + var from string + require.NoError(t, json.Unmarshal(ethTx["from"], &from)) + assert.True(t, strings.EqualFold(fx.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, ts, "eth_getTransactionByHash", []any{fx.VcTxHash}) + 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, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x1", "0x0"}) + var ethTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTx)) + require.NotNil(t, ethTx) + + var hash string + require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) + assert.True(t, strings.EqualFold(fx.EthTxHash, hash)) + }) + + t.Run("eth_getTransactionByBlockNumberAndIndex_block2_first", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x0"}) + var ethTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTx)) + require.NotNil(t, ethTx) + + var hash string + require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) + assert.True(t, strings.EqualFold(env.ethTx2Hash, hash)) + }) + + t.Run("eth_getTransactionByBlockNumberAndIndex_block2_second", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x1"}) + var ethTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTx)) + require.NotNil(t, ethTx) + + var hash string + require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) + assert.True(t, strings.EqualFold(env.ethTx3Hash, hash)) + }) + + t.Run("eth_getTransactionByBlockHashAndIndex", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_getTransactionByBlockHashAndIndex", []any{fx.BlockHash, "0x0"}) + var ethTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTx)) + require.NotNil(t, ethTx) + + var hash string + require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) + assert.True(t, strings.EqualFold(fx.EthTxHash, hash)) + }) + + t.Run("eth_getTransactionReceipt_block1", 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)) + 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, ts, "eth_getTransactionReceipt", []any{env.ethTx3Hash}) + 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, ts, "eth_getBalance", []any{fx.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, ts, "eth_getTransactionCount", []any{fx.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, ts, "eth_getCode", []any{fx.Sender.Address.String(), "latest"}) + var code hexutil.Bytes + require.NoError(t, json.Unmarshal(result, &code)) + assert.Empty(t, code) + }) + + // ── Fees ────────────────────────────────────────────────────────────────── + + t.Run("eth_gasPrice", func(t *testing.T) { + 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_two_blocks", func(t *testing.T) { + // blockCount=2, newestBlock="latest" → covers blocks 1 and 2. + result := testutil.Call(t, ts, "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 1. + var oldest hexutil.Uint64 + require.NoError(t, json.Unmarshal(fh["oldestBlock"], &oldest)) + assert.Equal(t, uint64(1), uint64(oldest)) + }) + + // ── Simulation ──────────────────────────────────────────────────────────── + + t.Run("eth_call", func(t *testing.T) { + result := testutil.Call(t, ts, "eth_call", []any{ + map[string]any{ + "from": fx.Sender.Address.String(), + "to": fx.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, ts, "eth_estimateGas", []any{ + map[string]any{ + "from": fx.Sender.Address.String(), + "to": fx.Recipient.Address.String(), + "value": "0x1", + }, + }) + var gas hexutil.Uint64 + require.NoError(t, json.Unmarshal(result, &gas)) + assert.Equal(t, uint64(21000), uint64(gas)) + }) + + // ── Logs ────────────────────────────────────────────────────────────────── + + t.Run("eth_getLogs_empty", func(t *testing.T) { + // All fixture txs are plain VET transfers — no contract events are emitted. + // TODO: extend with a contract-deploy tx that emits events to cover non-empty results, + // address filter, topic filter, and EIP-234 blockHash filter. + 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) + }) + + // ── 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. + newTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + ChainID(fx.ChainID). + Nonce(2). + MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). + MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). + GasLimit(21000). + To(&fx.Recipient.Address). + Value(big.NewInt(1e9)). + Build(fx.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, ts, "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. Mine: seal a block containing the transaction so it becomes queryable. + require.NoError(t, fx.Chain.MintBlock(newTx)) + + // 3. Read transaction: must be visible in the new block. + result = testutil.Call(t, ts, "eth_getTransactionByHash", []any{txHash}) + var ethTx map[string]json.RawMessage + require.NoError(t, json.Unmarshal(result, ðTx)) + require.NotNil(t, ethTx, "transaction should be found after mining") + + var readHash string + require.NoError(t, json.Unmarshal(ethTx["hash"], &readHash)) + assert.True(t, strings.EqualFold(txHash, readHash)) + + var from string + require.NoError(t, json.Unmarshal(ethTx["from"], &from)) + assert.True(t, strings.EqualFold(fx.Sender.Address.String(), from)) + + var txType hexutil.Uint64 + require.NoError(t, json.Unmarshal(ethTx["type"], &txType)) + assert.Equal(t, uint64(2), uint64(txType)) + + // 4. Read receipt: must exist with status=1 (successful transfer). + result = testutil.Call(t, ts, "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)) + }) +} 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) From 35cdcd47e0b0ef5a4d77d83d8e8d416a39da5408 Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 08:44:55 +0100 Subject: [PATCH 04/20] lint --- cmd/thor/httpserver/eth_rpc_server.go | 6 ++---- rpc/fees/handler.go | 10 +++++----- rpc/integration_test.go | 2 +- rpc/utils.go | 4 ++-- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/cmd/thor/httpserver/eth_rpc_server.go b/cmd/thor/httpserver/eth_rpc_server.go index 0da571895f..0178493176 100644 --- a/cmd/thor/httpserver/eth_rpc_server.go +++ b/cmd/thor/httpserver/eth_rpc_server.go @@ -87,11 +87,9 @@ func StartEthRPCServer( } var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { httpSrv.Serve(listener) //nolint:errcheck - }() + }) return "http://" + listener.Addr().String(), func() { httpSrv.Close() diff --git a/rpc/fees/handler.go b/rpc/fees/handler.go index 366d04aa22..4e78f5a2eb 100644 --- a/rpc/fees/handler.go +++ b/rpc/fees/handler.go @@ -121,15 +121,15 @@ func (h *Handler) ethFeeHistory(req rpc.Request) rpc.Response { baseFees = append(baseFees, baseFees[len(baseFees)-1]) type feeHistoryResult struct { - OldestBlock hexutil.Uint64 `json:"oldestBlock"` - BaseFeePerGas []*hexutil.Big `json:"baseFeePerGas"` - GasUsedRatio []float64 `json:"gasUsedRatio"` - Reward [][]interface{} `json:"reward"` + OldestBlock hexutil.Uint64 `json:"oldestBlock"` + BaseFeePerGas []*hexutil.Big `json:"baseFeePerGas"` + GasUsedRatio []float64 `json:"gasUsedRatio"` + Reward [][]any `json:"reward"` } return rpc.OkResponse(req.ID, feeHistoryResult{ OldestBlock: hexutil.Uint64(oldestNum), BaseFeePerGas: baseFees, GasUsedRatio: gasUsedRatios, - Reward: [][]interface{}{}, + Reward: [][]any{}, }) } diff --git a/rpc/integration_test.go b/rpc/integration_test.go index e0a0486328..0917547fe2 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -80,7 +80,7 @@ func TestDispatch(t *testing.T) { t.Run("batch_exceeds_limit", func(t *testing.T) { // Build a batch of 11 requests (maxBatchRequests = 10). var batch []map[string]any - for i := 0; i < 11; i++ { + for i := range 11 { batch = append(batch, map[string]any{ "jsonrpc": "2.0", "id": i + 1, diff --git a/rpc/utils.go b/rpc/utils.go index c32fc91102..14dd6e2efa 100644 --- a/rpc/utils.go +++ b/rpc/utils.go @@ -164,7 +164,7 @@ func BuildEthBlock( // 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 := uint64(0); i < canonicalIdx; i++ { + for i := range canonicalIdx { if receipts[i].Type == tx.TypeEthTyped1559 { count++ } @@ -188,7 +188,7 @@ func CumulativeEthGasUsed(receipts tx.Receipts, canonicalIdx uint64) uint64 { // 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 := uint64(0); i < canonicalIdx; i++ { + for i := range canonicalIdx { if receipts[i].Type == tx.TypeEthTyped1559 && len(receipts[i].Outputs) > 0 { offset += uint64(len(receipts[i].Outputs[0].Events)) } From f3bed10807ffcca5b9ee60e4ce0e3dfadf2d55f1 Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 11:31:08 +0100 Subject: [PATCH 05/20] use same http server --- cmd/thor/flags.go | 7 -- cmd/thor/httpserver/api_server.go | 33 ++++++++- cmd/thor/httpserver/eth_rpc_server.go | 98 --------------------------- cmd/thor/main.go | 40 ++--------- cmd/thor/utils.go | 18 ++--- cmd/thor/utils_test.go | 15 ++-- 6 files changed, 51 insertions(+), 160 deletions(-) delete mode 100644 cmd/thor/httpserver/eth_rpc_server.go diff --git a/cmd/thor/flags.go b/cmd/thor/flags.go index 873f3b3386..c2be138434 100644 --- a/cmd/thor/flags.go +++ b/cmd/thor/flags.go @@ -57,13 +57,6 @@ var ( Usage: "API service listening address", Sources: envVar("API_ADDR"), } - ethRPCAddrFlag = &cli.StringFlag{ - Name: "eth-rpc-addr", - Local: true, - Value: "localhost:8545", - Usage: "Ethereum JSON-RPC service listening address", - Sources: envVar("ETH_RPC_ADDR"), - } apiCorsFlag = &cli.StringFlag{ Name: "api-cors", Local: true, diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index 387b9f046f..96168949ff 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -34,9 +34,18 @@ 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" "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" + rpclogs "github.com/vechain/thor/v2/rpc/logs" + rpcsimulation "github.com/vechain/thor/v2/rpc/simulation" + rpctransactions "github.com/vechain/thor/v2/rpc/transactions" ) var logger = log.WithContext("pkg", "api") @@ -52,6 +61,7 @@ type APIConfig struct { BacktraceLimit uint32 CallGasLimit uint64 BatchDataMaxSize uint64 + ClientVersion string PprofOn bool SkipLogs bool AllowCustomTracer bool @@ -131,6 +141,18 @@ 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 rpc.Server (2 MB via io.LimitReader) + chainID := thor.GetEthChainID(repo.GenesisBlock().Header().ID()) + rpcDisp := rpc.NewDispatcher() + rpcchain.New(repo, chainID, config.ClientVersion).Mount(rpcDisp) + rpcblocks.New(repo, chainID).Mount(rpcDisp) + rpctransactions.New(repo, chainID, txPool).Mount(rpcDisp) + rpcaccounts.New(repo, stater).Mount(rpcDisp) + rpclogs.New(repo, logDB, config.BacktraceLimit).Mount(rpcDisp) + rpcfees.New(repo, config.BacktraceLimit).Mount(rpcDisp) + rpcsimulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(rpcDisp) + router.PathPrefix("/rpc").Handler(rpc.New(rpcDisp)) + if config.PprofOn { router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) router.HandleFunc("/debug/pprof/profile", pprof.Profile) @@ -140,8 +162,15 @@ func StartAPIServer( } // middlewares - // body limit and timeout - router.Use(middleware.HandleRequestBodyLimit(defaultRequestBodyLimit)) + // /rpc enforces its own 2 MB limit internally; all other routes get 200 KB + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/rpc") { + r.Body = http.MaxBytesReader(w, r.Body, defaultRequestBodyLimit) + } + next.ServeHTTP(w, r) + }) + }) if config.Timeout > 0 { router.Use(middleware.HandleAPITimeout(time.Duration(config.Timeout) * time.Millisecond)) } diff --git a/cmd/thor/httpserver/eth_rpc_server.go b/cmd/thor/httpserver/eth_rpc_server.go deleted file mode 100644 index 0178493176..0000000000 --- a/cmd/thor/httpserver/eth_rpc_server.go +++ /dev/null @@ -1,98 +0,0 @@ -// 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 httpserver - -import ( - "net" - "net/http" - "strings" - "sync" - "time" - - "github.com/gorilla/handlers" - "github.com/pkg/errors" - - "github.com/vechain/thor/v2/chain" - "github.com/vechain/thor/v2/logdb" - "github.com/vechain/thor/v2/rpc" - "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/logs" - "github.com/vechain/thor/v2/rpc/simulation" - "github.com/vechain/thor/v2/rpc/transactions" - "github.com/vechain/thor/v2/state" - "github.com/vechain/thor/v2/thor" - "github.com/vechain/thor/v2/txpool" -) - -// EthRPCConfig holds configuration for the Ethereum JSON-RPC server. -type EthRPCConfig struct { - // AllowedOrigins is the comma-separated list of allowed CORS origins. - // Reuses the same value as the REST API --api-cors flag. - AllowedOrigins string - BacktraceLimit uint32 - CallGasLimit uint64 - ClientVersion string -} - -// StartEthRPCServer starts the Ethereum JSON-RPC server on the given address. -// Returns the listening URL and a closer function. -func StartEthRPCServer( - addr string, - repo *chain.Repository, - stater *state.Stater, - txPool txpool.Pool, - logDB *logdb.LogDB, - forkConfig *thor.ForkConfig, - config EthRPCConfig, -) (string, func(), error) { - listener, err := net.Listen("tcp", addr) - if err != nil { - return "", nil, errors.Wrapf(err, "listen Eth RPC addr [%v]", addr) - } - - chainID := thor.GetEthChainID(repo.GenesisBlock().Header().ID()) - - d := rpc.NewDispatcher() - rpcchain.New(repo, chainID, config.ClientVersion).Mount(d) - blocks.New(repo, chainID).Mount(d) - transactions.New(repo, chainID, txPool).Mount(d) - accounts.New(repo, stater).Mount(d) - logs.New(repo, logDB, config.BacktraceLimit).Mount(d) - fees.New(repo, config.BacktraceLimit).Mount(d) - simulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(d) - - origins := strings.Split(strings.TrimSpace(config.AllowedOrigins), ",") - for i, o := range origins { - origins[i] = strings.ToLower(strings.TrimSpace(o)) - } - - srv := rpc.New(d) - - corsHandler := handlers.CORS( - handlers.AllowedOrigins(origins), - handlers.AllowedHeaders([]string{"content-type"}), - handlers.AllowedMethods([]string{"POST", "OPTIONS"}), - )(srv) - - httpSrv := &http.Server{ - Handler: corsHandler, - ReadHeaderTimeout: time.Second, - ReadTimeout: 30 * time.Second, - } - - var wg sync.WaitGroup - wg.Go(func() { - httpSrv.Serve(listener) //nolint:errcheck - }) - - return "http://" + listener.Addr().String(), func() { - httpSrv.Close() - wg.Wait() - }, nil -} diff --git a/cmd/thor/main.go b/cmd/thor/main.go index d296f7a09a..a1320a2ca0 100644 --- a/cmd/thor/main.go +++ b/cmd/thor/main.go @@ -98,7 +98,6 @@ func main() { beneficiaryFlag, targetGasLimitFlag, apiAddrFlag, - ethRPCAddrFlag, apiCorsFlag, apiTimeoutFlag, apiCallGasLimitFlag, @@ -144,7 +143,6 @@ func main() { logDbAdditionalIndexesFlag, apiTxpoolFlag, apiAddrFlag, - ethRPCAddrFlag, apiCorsFlag, apiTimeoutFlag, apiCallGasLimitFlag, @@ -324,29 +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() }() - ethRPCURL, ethRPCCloser, err := httpserver.StartEthRPCServer( - ctx.String(ethRPCAddrFlag.Name), - repo, - state.NewStater(mainDB), - txPool, - logDB, - forkConfig, - makeEthRPCConfig(ctx, version), - ) - if err != nil { - return err - } - defer func() { log.Info("stopping Eth RPC server..."); ethRPCCloser() }() - - printStartupMessage2(gene, apiURL, p2pCommunicator.Enode(), metricsURL, adminURL, false) - log.Info("Eth RPC started", "url", ethRPCURL) + printStartupMessage2(apiURL, apiURL+"rpc", p2pCommunicator.Enode(), metricsURL, adminURL, false) if err := p2pCommunicator.Start(); err != nil { return err @@ -535,29 +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() }() - ethRPCURL, ethRPCCloser, err := httpserver.StartEthRPCServer( - ctx.String(ethRPCAddrFlag.Name), - repo, - stater, - pool, - logDB, - forkConfig, - makeEthRPCConfig(ctx, version), - ) - if err != nil { - return err - } - defer func() { log.Info("stopping Eth RPC server..."); ethRPCCloser() }() - - printStartupMessage2(gene, apiURL, "", metricsURL, adminURL, isDevnet) - log.Info("Eth RPC started", "url", ethRPCURL) + 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 8fa4984b71..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)), @@ -281,15 +282,6 @@ func makeAPIConfig(ctx *cli.Command, logAPIRequests *atomic.Bool, soloMode bool) } } -func makeEthRPCConfig(ctx *cli.Command, clientVersion string) httpserver.EthRPCConfig { - return httpserver.EthRPCConfig{ - AllowedOrigins: ctx.String(apiCorsFlag.Name), - BacktraceLimit: uint32(ctx.Uint64(apiBacktraceLimitFlag.Name)), - CallGasLimit: ctx.Uint64(apiCallGasLimitFlag.Name), - ClientVersion: clientVersion, - } -} - func makeConfigDir(ctx *cli.Command) (string, error) { dir := ctx.String(configDirFlag.Name) if dir == "" { @@ -602,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 "" @@ -620,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) }) } From 3bfdc1b845a9dcb479ebd40c742a1379ebeebecd Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 14:29:06 +0100 Subject: [PATCH 06/20] clean up rpc util --- rpc/accounts/handler.go | 6 +-- rpc/fees/handler.go | 2 +- rpc/logs/handler.go | 8 ++-- rpc/transactions/handler.go | 7 ++-- rpc/utils.go | 76 ++++++++----------------------------- 5 files changed, 28 insertions(+), 71 deletions(-) diff --git a/rpc/accounts/handler.go b/rpc/accounts/handler.go index 0b8b63c30d..2242bf0ef5 100644 --- a/rpc/accounts/handler.go +++ b/rpc/accounts/handler.go @@ -85,11 +85,11 @@ func (h *Handler) ethGetStorageAt(req rpc.Request) rpc.Response { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block tag") } - addr, err := rpc.ParseThorAddress(addrStr) + addr, err := thor.ParseAddress(addrStr) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address") } - slot, err := rpc.ParseThorBytes32(slotStr) + slot, err := rpc.ParseBytes32Compact(slotStr) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid slot") } @@ -133,7 +133,7 @@ func parseAddrAndTag(raw json.RawMessage) (thor.Address, string, error) { if err := json.Unmarshal(params[1], &tag); err != nil { return thor.Address{}, "", fmt.Errorf("invalid block tag") } - addr, err := rpc.ParseThorAddress(addrStr) + addr, err := thor.ParseAddress(addrStr) if err != nil { return thor.Address{}, "", fmt.Errorf("invalid address: %w", err) } diff --git a/rpc/fees/handler.go b/rpc/fees/handler.go index 4e78f5a2eb..5c9de7197f 100644 --- a/rpc/fees/handler.go +++ b/rpc/fees/handler.go @@ -65,7 +65,7 @@ func (h *Handler) ethFeeHistory(req rpc.Request) rpc.Response { var blockCount uint64 var s string if err := json.Unmarshal(blockCountRaw, &s); err == nil { - n, err := rpc.ParseHexUint64(s) + n, err := hexutil.DecodeUint64(s) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid blockCount") } diff --git a/rpc/logs/handler.go b/rpc/logs/handler.go index 98e6181bc9..5358868f8b 100644 --- a/rpc/logs/handler.go +++ b/rpc/logs/handler.go @@ -99,7 +99,7 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { var single string var multi []string if err := json.Unmarshal(f.Address, &single); err == nil { - addr, err := rpc.ParseThorAddress(single) + addr, err := thor.ParseAddress(single) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address in filter") } @@ -107,7 +107,7 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { addresses = append(addresses, &a) } else if err := json.Unmarshal(f.Address, &multi); err == nil { for _, s := range multi { - addr, err := rpc.ParseThorAddress(s) + addr, err := thor.ParseAddress(s) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address in filter") } @@ -135,14 +135,14 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { var single string var multi []string if err := json.Unmarshal(raw, &single); err == nil { - h32, err := rpc.ParseThorBytes32(single) + h32, err := thor.ParseBytes32(single) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") } h32Copy := h32 topicSlot[i] = &h32Copy } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { - h32, err := rpc.ParseThorBytes32(multi[0]) + h32, err := thor.ParseBytes32(multi[0]) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") } diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index 22cd342821..cbfc7ea2aa 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -14,6 +14,7 @@ import ( "github.com/vechain/thor/v2/block" "github.com/vechain/thor/v2/chain" "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/tx" "github.com/vechain/thor/v2/txpool" ) @@ -44,7 +45,7 @@ func (h *Handler) ethGetTransactionByHash(req rpc.Request) rpc.Response { if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [txHash]") } - id, err := rpc.ParseThorBytes32(params[0]) + id, err := thor.ParseBytes32(params[0]) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") } @@ -111,7 +112,7 @@ func (h *Handler) ethGetTransactionByBlockNumberAndIndex(req rpc.Request) rpc.Re } func (h *Handler) txByBlockAndEthIndex(req rpc.Request, header *block.Header, idxStr string) rpc.Response { - ethIdx, err := rpc.ParseHexUint64(idxStr) + ethIdx, err := hexutil.DecodeUint64(idxStr) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid index") } @@ -147,7 +148,7 @@ func (h *Handler) ethGetTransactionReceipt(req rpc.Request) rpc.Response { if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [txHash]") } - id, err := rpc.ParseThorBytes32(params[0]) + id, err := thor.ParseBytes32(params[0]) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") } diff --git a/rpc/utils.go b/rpc/utils.go index 14dd6e2efa..4383b04530 100644 --- a/rpc/utils.go +++ b/rpc/utils.go @@ -6,6 +6,7 @@ package rpc import ( + "encoding/hex" "fmt" "strconv" "strings" @@ -43,7 +44,7 @@ func ResolveBlockTag(tag string, repo *chain.Repository) (*chain.BlockSummary, e // 32-byte hash (0x + 64 hex chars = 66 chars) if strings.HasPrefix(tag, "0x") && len(tag) == 66 { var id thor.Bytes32 - b, err := hexDecode(tag[2:]) + b, err := hex.DecodeString(tag[2:]) if err != nil { return nil, fmt.Errorf("invalid block hash %q: %w", tag, err) } @@ -196,70 +197,25 @@ func EthLogOffset(receipts tx.Receipts, canonicalIdx uint64) uint64 { return offset } -// ParseThorAddress parses a 0x-prefixed Ethereum address string into thor.Address. -func ParseThorAddress(s string) (thor.Address, error) { - if !isHexPrefix(s) || len(s) != 42 { - return thor.Address{}, fmt.Errorf("invalid address %q", s) - } - b, err := hexDecode(s[2:]) - if err != nil { - return thor.Address{}, err - } - var addr thor.Address - copy(addr[:], b) - return addr, nil -} - -// ParseThorBytes32 parses a 0x-prefixed 32-byte hex string. -func ParseThorBytes32(s string) (thor.Bytes32, error) { - if !isHexPrefix(s) { +// 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) } - b, err := hexDecode(s[2:]) + raw := s[2:] + if len(raw)%2 != 0 { + raw = "0" + raw + } + b, err := hex.DecodeString(raw) if err != nil { - return thor.Bytes32{}, err + 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 } - -// ParseHexUint64 parses a 0x-prefixed hex string to uint64. -func ParseHexUint64(s string) (uint64, error) { - if !isHexPrefix(s) { - return 0, fmt.Errorf("invalid hex %q", s) - } - return strconv.ParseUint(s[2:], 16, 64) -} - -func hexDecode(s string) ([]byte, error) { - if len(s)%2 != 0 { - s = "0" + s - } - b := make([]byte, len(s)/2) - for i := range b { - hi, ok1 := fromHexChar(s[2*i]) - lo, ok2 := fromHexChar(s[2*i+1]) - if !ok1 || !ok2 { - return nil, fmt.Errorf("invalid hex character at position %d", 2*i) - } - b[i] = (hi << 4) | lo - } - return b, nil -} - -func fromHexChar(c byte) (byte, bool) { - switch { - case c >= '0' && c <= '9': - return c - '0', true - case c >= 'a' && c <= 'f': - return c - 'a' + 10, true - case c >= 'A' && c <= 'F': - return c - 'A' + 10, true - } - return 0, false -} - -func isHexPrefix(s string) bool { - return len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X') -} From 2748330e4fa648e1dde750578f884a0512d41554 Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 14:47:41 +0100 Subject: [PATCH 07/20] remove the dispatcher --- cmd/thor/httpserver/api_server.go | 18 ++++++++-------- rpc/accounts/handler.go | 10 ++++----- rpc/blocks/handler.go | 6 +++--- rpc/chain/handler.go | 18 ++++++++-------- rpc/dispatcher.go | 36 ------------------------------- rpc/fees/handler.go | 8 +++---- rpc/integration_test.go | 20 ++++++++--------- rpc/logs/handler.go | 4 ++-- rpc/server.go | 32 +++++++++++++++++++++------ rpc/simulation/handler.go | 6 +++--- rpc/testutil/testutil.go | 8 +++---- rpc/transactions/handler.go | 12 +++++------ thorclient/rpc_test.go | 18 ++++++++-------- 13 files changed, 89 insertions(+), 107 deletions(-) delete mode 100644 rpc/dispatcher.go diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index 96168949ff..e354b56afa 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -143,15 +143,15 @@ func StartAPIServer( // Ethereum JSON-RPC at /rpc — body limit enforced internally by rpc.Server (2 MB via io.LimitReader) chainID := thor.GetEthChainID(repo.GenesisBlock().Header().ID()) - rpcDisp := rpc.NewDispatcher() - rpcchain.New(repo, chainID, config.ClientVersion).Mount(rpcDisp) - rpcblocks.New(repo, chainID).Mount(rpcDisp) - rpctransactions.New(repo, chainID, txPool).Mount(rpcDisp) - rpcaccounts.New(repo, stater).Mount(rpcDisp) - rpclogs.New(repo, logDB, config.BacktraceLimit).Mount(rpcDisp) - rpcfees.New(repo, config.BacktraceLimit).Mount(rpcDisp) - rpcsimulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(rpcDisp) - router.PathPrefix("/rpc").Handler(rpc.New(rpcDisp)) + rpcSrv := rpc.NewServer() + rpcchain.New(repo, chainID, config.ClientVersion).Mount(rpcSrv) + rpcblocks.New(repo, chainID).Mount(rpcSrv) + rpctransactions.New(repo, chainID, txPool).Mount(rpcSrv) + rpcaccounts.New(repo, stater).Mount(rpcSrv) + rpclogs.New(repo, logDB, config.BacktraceLimit).Mount(rpcSrv) + rpcfees.New(repo, config.BacktraceLimit).Mount(rpcSrv) + rpcsimulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(rpcSrv) + router.PathPrefix("/rpc").Handler(rpcSrv) if config.PprofOn { router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) diff --git a/rpc/accounts/handler.go b/rpc/accounts/handler.go index 2242bf0ef5..ab3c487895 100644 --- a/rpc/accounts/handler.go +++ b/rpc/accounts/handler.go @@ -30,11 +30,11 @@ func New(repo *chain.Repository, stater *state.Stater) *Handler { } // Mount registers all account state methods on the dispatcher. -func (h *Handler) Mount(d *rpc.Dispatcher) { - d.Register("eth_getBalance", h.ethGetBalance) - d.Register("eth_getCode", h.ethGetCode) - d.Register("eth_getStorageAt", h.ethGetStorageAt) - d.Register("eth_getTransactionCount", h.ethGetTransactionCount) +func (h *Handler) Mount(s *rpc.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 rpc.Request) rpc.Response { diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go index 97e55d9037..68ffdeba00 100644 --- a/rpc/blocks/handler.go +++ b/rpc/blocks/handler.go @@ -24,9 +24,9 @@ func New(repo *chain.Repository, chainID uint64) *Handler { } // Mount registers all block query methods on the dispatcher. -func (h *Handler) Mount(d *rpc.Dispatcher) { - d.Register("eth_getBlockByHash", h.ethGetBlockByHash) - d.Register("eth_getBlockByNumber", h.ethGetBlockByNumber) +func (h *Handler) Mount(s *rpc.Server) { + s.Register("eth_getBlockByHash", h.ethGetBlockByHash) + s.Register("eth_getBlockByNumber", h.ethGetBlockByNumber) } func (h *Handler) ethGetBlockByHash(req rpc.Request) rpc.Response { diff --git a/rpc/chain/handler.go b/rpc/chain/handler.go index f137b3ff05..dba714f086 100644 --- a/rpc/chain/handler.go +++ b/rpc/chain/handler.go @@ -27,15 +27,15 @@ func New(repo *chain.Repository, chainID uint64, clientVersion string) *Handler } // Mount registers all chain metadata methods on the dispatcher. -func (h *Handler) Mount(d *rpc.Dispatcher) { - d.Register("eth_chainId", h.ethChainID) - d.Register("net_version", h.netVersion) - d.Register("web3_clientVersion", h.web3ClientVersion) - d.Register("eth_blockNumber", h.ethBlockNumber) - d.Register("eth_syncing", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) - d.Register("eth_accounts", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, []string{}) }) - d.Register("eth_mining", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) - d.Register("eth_hashrate", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, "0x0") }) +func (h *Handler) Mount(s *rpc.Server) { + s.Register("eth_chainId", h.ethChainID) + s.Register("net_version", h.netVersion) + s.Register("web3_clientVersion", h.web3ClientVersion) + s.Register("eth_blockNumber", h.ethBlockNumber) + s.Register("eth_syncing", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) + s.Register("eth_accounts", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, []string{}) }) + s.Register("eth_mining", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) + s.Register("eth_hashrate", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, "0x0") }) } func (h *Handler) ethChainID(req rpc.Request) rpc.Response { diff --git a/rpc/dispatcher.go b/rpc/dispatcher.go deleted file mode 100644 index d93620ba42..0000000000 --- a/rpc/dispatcher.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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 "fmt" - -// Dispatcher routes JSON-RPC requests to registered method handlers. -// Sub-packages call Register via their Mount method; the Server calls dispatch. -type Dispatcher struct { - methods map[string]func(Request) Response -} - -// NewDispatcher creates an empty Dispatcher. -func NewDispatcher() *Dispatcher { - return &Dispatcher{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 (d *Dispatcher) Register(method string, handler func(Request) Response) { - if _, exists := d.methods[method]; exists { - panic("rpc: duplicate method registration: " + method) - } - d.methods[method] = handler -} - -func (d *Dispatcher) dispatch(req Request) Response { - h, ok := d.methods[req.Method] - if !ok { - return ErrResponse(req.ID, CodeMethodNotFound, fmt.Sprintf("method %q not found", req.Method)) - } - return h(req) -} diff --git a/rpc/fees/handler.go b/rpc/fees/handler.go index 5c9de7197f..76fc104bf5 100644 --- a/rpc/fees/handler.go +++ b/rpc/fees/handler.go @@ -27,10 +27,10 @@ func New(repo *chain.Repository, backtrace uint32) *Handler { } // Mount registers all fee market methods on the dispatcher. -func (h *Handler) Mount(d *rpc.Dispatcher) { - d.Register("eth_gasPrice", h.ethGasPrice) - d.Register("eth_maxPriorityFeePerGas", h.ethMaxPriorityFeePerGas) - d.Register("eth_feeHistory", h.ethFeeHistory) +func (h *Handler) Mount(s *rpc.Server) { + s.Register("eth_gasPrice", h.ethGasPrice) + s.Register("eth_maxPriorityFeePerGas", h.ethMaxPriorityFeePerGas) + s.Register("eth_feeHistory", h.ethFeeHistory) } func (h *Handler) ethGasPrice(req rpc.Request) rpc.Response { diff --git a/rpc/integration_test.go b/rpc/integration_test.go index 0917547fe2..53b292413b 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -26,21 +26,21 @@ import ( "github.com/vechain/thor/v2/rpc/transactions" ) -// newFullServer assembles all sub-packages onto a single Dispatcher and returns +// newFullServer assembles all sub-packages onto a single Server and returns // an httptest.Server. Used only by integration_test.go for dispatch-level tests // that need the full method table to be reachable. func newFullServer(t *testing.T, fx *testutil.ChainFixture) *httptest.Server { t.Helper() pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) - d := rpc.NewDispatcher() - rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(d) - blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(d) - transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(d) - accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(d) - logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(d) - fees.New(fx.Chain.Repo(), 100).Mount(d) - simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(d) - ts := httptest.NewServer(rpc.New(d)) + srv := rpc.NewServer() + rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(srv) + blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(srv) + transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(srv) + accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(srv) + logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(srv) + fees.New(fx.Chain.Repo(), 100).Mount(srv) + simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(srv) + ts := httptest.NewServer(srv) t.Cleanup(ts.Close) return ts } diff --git a/rpc/logs/handler.go b/rpc/logs/handler.go index 5358868f8b..15bf6ac01d 100644 --- a/rpc/logs/handler.go +++ b/rpc/logs/handler.go @@ -33,8 +33,8 @@ func New(repo *chain.Repository, logDB *logdb.LogDB, backtrace uint32) *Handler } // Mount registers all log methods on the dispatcher. -func (h *Handler) Mount(d *rpc.Dispatcher) { - d.Register("eth_getLogs", h.ethGetLogs) +func (h *Handler) Mount(s *rpc.Server) { + s.Register("eth_getLogs", h.ethGetLogs) } // LogFilter mirrors the Ethereum eth_getLogs filter parameter. diff --git a/rpc/server.go b/rpc/server.go index 2eb45152e6..d201737525 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -20,14 +20,24 @@ const maxRequestBodySize = 2 * 1024 * 1024 // 2 MB const maxBatchRequests = 10 // Server is an HTTP handler that implements the Ethereum JSON-RPC protocol. -// It supports both single and batch requests. +// 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 { - d *Dispatcher + methods map[string]func(Request) Response } -// New creates a new Server backed by the given Dispatcher. -func New(d *Dispatcher) *Server { - return &Server{d: d} +// 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. @@ -61,13 +71,21 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +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.d.dispatch(req)) + writeJSON(w, s.dispatch(req)) } func (s *Server) handleBatch(w http.ResponseWriter, body []byte) { @@ -92,7 +110,7 @@ func (s *Server) handleBatch(w http.ResponseWriter, body []byte) { responses[i] = ErrResponse(nil, CodeParseError, "invalid request in batch: "+err.Error()) continue } - responses[i] = s.d.dispatch(req) + responses[i] = s.dispatch(req) } writeJSON(w, responses) } diff --git a/rpc/simulation/handler.go b/rpc/simulation/handler.go index ec5bd4ca82..b92cec15bb 100644 --- a/rpc/simulation/handler.go +++ b/rpc/simulation/handler.go @@ -36,9 +36,9 @@ func New(repo *chain.Repository, stater *state.Stater, forkConfig *thor.ForkConf } // Mount registers all simulation methods on the dispatcher. -func (h *Handler) Mount(d *rpc.Dispatcher) { - d.Register("eth_call", h.ethCall) - d.Register("eth_estimateGas", h.ethEstimateGas) +func (h *Handler) Mount(s *rpc.Server) { + s.Register("eth_call", h.ethCall) + s.Register("eth_estimateGas", h.ethEstimateGas) } // CallArgs mirrors the Ethereum eth_call / eth_estimateGas parameter object. diff --git a/rpc/testutil/testutil.go b/rpc/testutil/testutil.go index a8020e8749..761f910a3b 100644 --- a/rpc/testutil/testutil.go +++ b/rpc/testutil/testutil.go @@ -111,7 +111,7 @@ func NewChainFixture(t *testing.T) *ChainFixture { // Mounter is satisfied by any sub-package handler that exposes Mount. type Mounter interface { - Mount(d *rpc.Dispatcher) + Mount(s *rpc.Server) } // NewMinimalServer creates an httptest.Server with only m's methods registered. @@ -119,9 +119,9 @@ type Mounter interface { // is mounted, so an accidental call to another namespace fails with method-not-found. func NewMinimalServer(t *testing.T, m Mounter) *httptest.Server { t.Helper() - d := rpc.NewDispatcher() - m.Mount(d) - ts := httptest.NewServer(rpc.New(d)) + srv := rpc.NewServer() + m.Mount(srv) + ts := httptest.NewServer(srv) t.Cleanup(ts.Close) return ts } diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index cbfc7ea2aa..0f78552299 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -32,12 +32,12 @@ func New(repo *chain.Repository, chainID uint64, txPool txpool.Pool) *Handler { } // Mount registers all transaction methods on the dispatcher. -func (h *Handler) Mount(d *rpc.Dispatcher) { - d.Register("eth_getTransactionByHash", h.ethGetTransactionByHash) - d.Register("eth_getTransactionByBlockHashAndIndex", h.ethGetTransactionByBlockHashAndIndex) - d.Register("eth_getTransactionByBlockNumberAndIndex", h.ethGetTransactionByBlockNumberAndIndex) - d.Register("eth_getTransactionReceipt", h.ethGetTransactionReceipt) - d.Register("eth_sendRawTransaction", h.ethSendRawTransaction) +func (h *Handler) Mount(s *rpc.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) } func (h *Handler) ethGetTransactionByHash(req rpc.Request) rpc.Response { diff --git a/thorclient/rpc_test.go b/thorclient/rpc_test.go index d5ca87cbc4..0e08149b4e 100644 --- a/thorclient/rpc_test.go +++ b/thorclient/rpc_test.go @@ -85,15 +85,15 @@ func newEthRPCFixture(t *testing.T) *ethRPCTestEnv { require.NoError(t, err) pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) - d := rpc.NewDispatcher() - rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(d) - blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(d) - transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(d) - accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(d) - logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(d) - fees.New(fx.Chain.Repo(), 100).Mount(d) - simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(d) - ts := httptest.NewServer(rpc.New(d)) + srv := rpc.NewServer() + rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(srv) + blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(srv) + transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(srv) + accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(srv) + logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(srv) + fees.New(fx.Chain.Repo(), 100).Mount(srv) + simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(srv) + ts := httptest.NewServer(srv) t.Cleanup(ts.Close) return ðRPCTestEnv{ From da48fd2286dfbe31654fb2d703a0d36191e84ca3 Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 17:11:08 +0100 Subject: [PATCH 08/20] clean up testing surface --- rpc/accounts/handler_test.go | 39 ++++-- rpc/blocks/handler_test.go | 45 ++++++- rpc/chain/handler_test.go | 25 +++- rpc/fees/handler_test.go | 18 ++- rpc/integration_test.go | 56 +++++--- rpc/logs/handler_test.go | 30 ++++- rpc/simulation/handler_test.go | 48 +++++-- rpc/testutil/testutil.go | 110 ++++------------ rpc/transactions/handler_test.go | 65 ++++++++-- test/testchain/chain.go | 5 + test/testnode/node.go | 24 ++++ thorclient/rpc_test.go | 214 +++++++++++++------------------ 12 files changed, 399 insertions(+), 280 deletions(-) diff --git a/rpc/accounts/handler_test.go b/rpc/accounts/handler_test.go index ed0009c145..b4a1e86f39 100644 --- a/rpc/accounts/handler_test.go +++ b/rpc/accounts/handler_test.go @@ -17,16 +17,39 @@ import ( "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" + "github.com/vechain/thor/v2/thor" ) -func TestAccountsHandler(t *testing.T) { - fx := testutil.NewChainFixture(t) - ts := testutil.NewMinimalServer(t, accounts.New(fx.Chain.Repo(), fx.Chain.Stater())) +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 := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + sender := genesis.DevAccounts()[0] + recipient := genesis.DevAccounts()[1] - senderAddr := fx.Sender.Address.String() + 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{senderAddr, "latest"}) + 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") @@ -34,7 +57,7 @@ func TestAccountsHandler(t *testing.T) { t.Run("eth_getCode_eoa", func(t *testing.T) { // EOAs have no code. - result := testutil.Call(t, ts, "eth_getCode", []any{senderAddr, "latest"}) + 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) @@ -42,7 +65,7 @@ func TestAccountsHandler(t *testing.T) { 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{senderAddr, "0x0", "latest"}) + 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) @@ -51,7 +74,7 @@ func TestAccountsHandler(t *testing.T) { 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{senderAddr, "latest"}) + 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)) diff --git a/rpc/blocks/handler_test.go b/rpc/blocks/handler_test.go index 0db68deb38..ad14806be0 100644 --- a/rpc/blocks/handler_test.go +++ b/rpc/blocks/handler_test.go @@ -13,14 +13,47 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "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/thor" "github.com/vechain/thor/v2/tx" ) +type fixture struct { + chain *testchain.Chain + chainID uint64 + ethTxHash string + blockHash string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + 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, + chainID: chainID, + ethTxHash: ethTx.ID().String(), + blockHash: bestBlock.Header().ID().String(), + } +} + func TestBlocksHandler(t *testing.T) { - fx := testutil.NewChainFixture(t) - ts := testutil.NewMinimalServer(t, blocks.New(fx.Chain.Repo(), fx.ChainID)) + fx := newFixture(t) + ts := testutil.NewTestServer(t, blocks.New(fx.chain.Repo(), fx.chainID)) t.Run("eth_getBlockByNumber_latest", func(t *testing.T) { result := testutil.Call(t, ts, "eth_getBlockByNumber", []any{"latest", false}) @@ -35,7 +68,7 @@ func TestBlocksHandler(t *testing.T) { var txHashes []string require.NoError(t, json.Unmarshal(blk["transactions"], &txHashes)) require.Len(t, txHashes, 1) - assert.Equal(t, fx.EthTxHash, txHashes[0]) + assert.Equal(t, fx.ethTxHash, txHashes[0]) // gasUsed counts only the ETH tx. var gasUsed hexutil.Uint64 @@ -83,7 +116,7 @@ func TestBlocksHandler(t *testing.T) { var txHash string require.NoError(t, json.Unmarshal(txObjs[0]["hash"], &txHash)) - assert.Equal(t, fx.EthTxHash, txHash) + assert.Equal(t, fx.ethTxHash, txHash) var txType hexutil.Uint64 require.NoError(t, json.Unmarshal(txObjs[0]["type"], &txType)) @@ -97,7 +130,7 @@ func TestBlocksHandler(t *testing.T) { }) t.Run("eth_getBlockByHash", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getBlockByHash", []any{fx.BlockHash, false}) + result := testutil.Call(t, ts, "eth_getBlockByHash", []any{fx.blockHash, false}) var blk map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, &blk)) @@ -107,7 +140,7 @@ func TestBlocksHandler(t *testing.T) { var gotHash string require.NoError(t, json.Unmarshal(blk["hash"], &gotHash)) - assert.Equal(t, fx.BlockHash, gotHash) + assert.Equal(t, fx.blockHash, gotHash) }) t.Run("eth_getBlockByHash_notfound", func(t *testing.T) { diff --git a/rpc/chain/handler_test.go b/rpc/chain/handler_test.go index 87928a0912..32e2a4f4da 100644 --- a/rpc/chain/handler_test.go +++ b/rpc/chain/handler_test.go @@ -15,17 +15,36 @@ import ( "github.com/vechain/thor/v2/rpc/chain" "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 + chainID uint64 +} + +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, + chainID: thor.GetEthChainID(c.GenesisBlock().Header().ID()), + } +} + func TestChainHandler(t *testing.T) { - fx := testutil.NewChainFixture(t) - ts := testutil.NewMinimalServer(t, chain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0")) + fx := newFixture(t) + ts := testutil.NewTestServer(t, chain.New(fx.chain.Repo(), fx.chainID, "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.ChainID, uint64(got)) + assert.Equal(t, fx.chainID, uint64(got)) }) t.Run("net_version", func(t *testing.T) { diff --git a/rpc/fees/handler_test.go b/rpc/fees/handler_test.go index 01c0874df1..131eaa254e 100644 --- a/rpc/fees/handler_test.go +++ b/rpc/fees/handler_test.go @@ -15,11 +15,25 @@ import ( "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 := testutil.NewChainFixture(t) - ts := testutil.NewMinimalServer(t, fees.New(fx.Chain.Repo(), 100)) + fx := newFixture(t) + ts := testutil.NewTestServer(t, fees.New(fx.chain.Repo(), 100)) t.Run("eth_gasPrice", func(t *testing.T) { // gasPrice = baseFee + 1 gwei tip; must be > 0 after GALACTICA. diff --git a/rpc/integration_test.go b/rpc/integration_test.go index 53b292413b..dc0c762742 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -11,10 +11,16 @@ import ( "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/thor" + "github.com/vechain/thor/v2/txpool" + "github.com/vechain/thor/v2/rpc" "github.com/vechain/thor/v2/rpc/accounts" "github.com/vechain/thor/v2/rpc/blocks" @@ -26,30 +32,38 @@ import ( "github.com/vechain/thor/v2/rpc/transactions" ) -// newFullServer assembles all sub-packages onto a single Server and returns -// an httptest.Server. Used only by integration_test.go for dispatch-level tests -// that need the full method table to be reachable. -func newFullServer(t *testing.T, fx *testutil.ChainFixture) *httptest.Server { - t.Helper() - pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) - srv := rpc.NewServer() - rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(srv) - blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(srv) - transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(srv) - accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(srv) - logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(srv) - fees.New(fx.Chain.Repo(), 100).Mount(srv) - simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(srv) - ts := httptest.NewServer(srv) - t.Cleanup(ts.Close) - return ts -} - // 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) { - fx := testutil.NewChainFixture(t) - ts := newFullServer(t, fx) + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + 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)) + 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 := rpc.NewServer() + rpcchain.New(c.Repo(), chainID, "test/1.0").Mount(srv) + blocks.New(c.Repo(), chainID).Mount(srv) + transactions.New(c.Repo(), chainID, pool).Mount(srv) + accounts.New(c.Repo(), c.Stater()).Mount(srv) + logs.New(c.Repo(), c.LogDB(), 100).Mount(srv) + fees.New(c.Repo(), 100).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{}) diff --git a/rpc/logs/handler_test.go b/rpc/logs/handler_test.go index cec1c3f343..1871cc19ec 100644 --- a/rpc/logs/handler_test.go +++ b/rpc/logs/handler_test.go @@ -14,17 +14,37 @@ import ( "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 := testutil.NewChainFixture(t) - ts := testutil.NewMinimalServer(t, logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100)) + fx := newFixture(t) + ts := testutil.NewTestServer(t, logs.New(fx.chain.Repo(), fx.chain.LogDB(), 100)) t.Run("eth_getLogs_empty", func(t *testing.T) { - // The fixture ETH tx is a plain VET transfer — it emits no contract events. + // The fixture block contains no contract events. // eth_getLogs therefore returns an empty array. // - // TODO: extend ChainFixture with a contract-deploy tx that emits events so we + // TODO: extend with a contract-deploy tx that emits events so we // can assert on non-empty log results (address filter, topic filter, EIP-234 // blockHash filter, etc.). result := testutil.Call(t, ts, "eth_getLogs", []any{ @@ -38,7 +58,7 @@ func TestLogsHandler(t *testing.T) { 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}, + map[string]any{"blockHash": fx.blockHash}, }) var got []any require.NoError(t, json.Unmarshal(result, &got)) diff --git a/rpc/simulation/handler_test.go b/rpc/simulation/handler_test.go index c8d90223d5..dde7843bf3 100644 --- a/rpc/simulation/handler_test.go +++ b/rpc/simulation/handler_test.go @@ -13,25 +13,49 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/vechain/thor/v2/genesis" "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 := testutil.NewChainFixture(t) - ts := testutil.NewMinimalServer(t, simulation.New( - fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000, + fx := newFixture(t) + ts := testutil.NewTestServer(t, simulation.New( + fx.chain.Repo(), fx.chain.Stater(), &fx.forks, 1_000_000, )) - senderAddr := fx.Sender.Address.String() - recipientAddr := fx.Recipient.Address.String() - 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": senderAddr, - "to": recipientAddr, + "from": fx.senderAddr, + "to": fx.recipientAddr, "value": "0x1", }, "latest", @@ -46,8 +70,8 @@ func TestSimulationHandler(t *testing.T) { // 5000 (tx base) + 16000 (per-clause) = 21000 intrinsic, 0 EVM gas. result := testutil.Call(t, ts, "eth_estimateGas", []any{ map[string]any{ - "from": senderAddr, - "to": recipientAddr, + "from": fx.senderAddr, + "to": fx.recipientAddr, "value": "0x1", }, }) @@ -62,8 +86,8 @@ func TestSimulationHandler(t *testing.T) { // Here we pass gas = 21000 which is exactly the estimate. result := testutil.Call(t, ts, "eth_estimateGas", []any{ map[string]any{ - "from": senderAddr, - "to": recipientAddr, + "from": fx.senderAddr, + "to": fx.recipientAddr, "value": "0x1", "gas": "0x5208", // 0x5208 = 21000 }, diff --git a/rpc/testutil/testutil.go b/rpc/testutil/testutil.go index 761f910a3b..a071af3f7e 100644 --- a/rpc/testutil/testutil.go +++ b/rpc/testutil/testutil.go @@ -11,12 +11,10 @@ package testutil import ( "bytes" "encoding/json" - "math" "math/big" "net/http" "net/http/httptest" "testing" - "time" "github.com/stretchr/testify/require" @@ -26,87 +24,37 @@ import ( "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" ) -// ChainFixture holds a fully initialised test chain with one minted block. -// Block layout: position 0 = VeChain TypeLegacy tx, position 1 = Ethereum EIP-1559 tx. -// The EIP-1559 tx has projected ETH index 0 (it is the only ETH tx in the block). -type ChainFixture struct { - Chain *testchain.Chain - Forks thor.ForkConfig - ChainID uint64 - Sender genesis.DevAccount // DevAccounts()[0] — sent both txs, nonce incremented to 1 - Recipient genesis.DevAccount // DevAccounts()[1] — received VET from both txs - EthTx *tx.Transaction // Ethereum EIP-1559 tx at canonical index 1 - VcTx *tx.Transaction // VeChain TypeLegacy tx at canonical index 0 - BlockHash string // 0x-prefixed 66-char hex of block 1 - EthTxHash string // ETH tx ID (= Keccak256 of raw wire bytes) - VcTxHash string // VeChain tx ID -} - -// NewChainFixture creates a standard test chain ready for RPC tests: -// - ForkConfig{} — all forks active from block 0 (GALACTICA, INTERSTELLAR, …) -// - Genesis block + 1 minted block containing one VeChain tx and one ETH tx -func NewChainFixture(t *testing.T) *ChainFixture { +// 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() - - // Disable PoS transition so tests don't have to deal with HayabusaTP complexity. - hayabusaTP := uint32(math.MaxUint32) - thor.SetConfig(thor.Config{HayabusaTP: &hayabusaTP}) - - forks := thor.ForkConfig{} - thorChain, err := testchain.NewWithFork(&forks, 180) - require.NoError(t, err) - - chainID := thor.GetEthChainID(thorChain.GenesisBlock().Header().ID()) - - sender := genesis.DevAccounts()[0] - recipient := genesis.DevAccounts()[1] - - // VeChain TypeLegacy tx — canonical position 0. - vcTxTo := recipient.Address - vcTx := tx.NewBuilder(tx.TypeLegacy). - ChainTag(thorChain.Repo().ChainTag()). - BlockRef(tx.NewBlockRef(thorChain.Repo().BestBlockSummary().Header.Number())). - Expiration(1000). - GasPriceCoef(255). - Gas(21000). - Nonce(datagen.RandUint64()). - Clause(tx.NewClause(&vcTxTo).WithValue(big.NewInt(1e9))). - Build() - vcTx = tx.MustSign(vcTx, sender.PrivateKey) - - // Ethereum EIP-1559 tx — canonical position 1, projected ETH index 0. ethTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). ChainID(chainID). - Nonce(0). + Nonce(nonce). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). GasLimit(21000). - To(&vcTxTo). + To(to). Value(big.NewInt(1e9)). Build(sender.PrivateKey) require.NoError(t, err) + return ethTx +} - require.NoError(t, thorChain.MintBlock(vcTx, ethTx)) - require.Equal(t, uint32(1), thorChain.Repo().BestBlockSummary().Header.Number()) - - bestBlock, err := thorChain.BestBlock() - require.NoError(t, err) - - return &ChainFixture{ - Chain: thorChain, - Forks: forks, - ChainID: chainID, - Sender: sender, - Recipient: recipient, - EthTx: ethTx, - VcTx: vcTx, - BlockHash: bestBlock.Header().ID().String(), - EthTxHash: ethTx.ID().String(), - VcTxHash: vcTx.ID().String(), - } +// 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. @@ -114,10 +62,10 @@ type Mounter interface { Mount(s *rpc.Server) } -// NewMinimalServer creates an httptest.Server with only m's methods registered. +// 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 NewMinimalServer(t *testing.T, m Mounter) *httptest.Server { +func NewTestServer(t *testing.T, m Mounter) *httptest.Server { t.Helper() srv := rpc.NewServer() m.Mount(srv) @@ -126,18 +74,6 @@ func NewMinimalServer(t *testing.T, m Mounter) *httptest.Server { return ts } -// DefaultPool creates a txpool suitable for testing and registers t.Cleanup(pool.Close). -func DefaultPool(t *testing.T, c *testchain.Chain, forks *thor.ForkConfig) txpool.Pool { - t.Helper() - p := txpool.New(c.Repo(), c.Stater(), txpool.Options{ - Limit: 10000, - LimitPerAccount: 16, - MaxLifetime: 10 * time.Minute, - }, forks) - t.Cleanup(p.Close) - return p -} - // 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 { @@ -150,7 +86,7 @@ func Call(t *testing.T, ts *httptest.Server, method string, params any) json.Raw }) require.NoError(t, err) - resp, err := http.Post(ts.URL+"/", "application/json", bytes.NewReader(body)) + resp, err := http.Post(ts.URL+"/rpc", "application/json", bytes.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() @@ -177,7 +113,7 @@ func CallExpectError(t *testing.T, ts *httptest.Server, method string, params an }) require.NoError(t, err) - resp, err := http.Post(ts.URL+"/", "application/json", bytes.NewReader(body)) + resp, err := http.Post(ts.URL+"/rpc", "application/json", bytes.NewReader(body)) require.NoError(t, err) defer resp.Body.Close() diff --git a/rpc/transactions/handler_test.go b/rpc/transactions/handler_test.go index 5f035934a1..182ce4df24 100644 --- a/rpc/transactions/handler_test.go +++ b/rpc/transactions/handler_test.go @@ -9,6 +9,7 @@ import ( "encoding/json" "math/big" "testing" + "time" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/stretchr/testify/assert" @@ -17,25 +18,61 @@ import ( "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 + chainID uint64 + ethTxHash string + vcTxHash string + blockHash string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + c, err := testchain.NewDefault() + require.NoError(t, err) + + chainID := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + 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, + chainID: chainID, + ethTxHash: ethTx.ID().String(), + vcTxHash: vcTx.ID().String(), + blockHash: bestBlock.Header().ID().String(), + } +} + func TestTransactionsHandler(t *testing.T) { - fx := testutil.NewChainFixture(t) - pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) - ts := testutil.NewMinimalServer(t, transactions.New(fx.Chain.Repo(), fx.ChainID, pool)) + 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(), fx.chainID, pool)) // ---- eth_getTransactionByHash ---- t.Run("eth_getTransactionByHash_eth", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getTransactionByHash", []any{fx.EthTxHash}) + 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) + 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 @@ -45,7 +82,7 @@ func TestTransactionsHandler(t *testing.T) { 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}) + result := testutil.Call(t, ts, "eth_getTransactionByHash", []any{fx.vcTxHash}) assert.Equal(t, "null", string(result)) }) @@ -58,17 +95,17 @@ func TestTransactionsHandler(t *testing.T) { 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"}) + 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) + 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"}) + result := testutil.Call(t, ts, "eth_getTransactionByBlockHashAndIndex", []any{fx.blockHash, "0x1"}) assert.Equal(t, "null", string(result)) }) @@ -81,7 +118,7 @@ func TestTransactionsHandler(t *testing.T) { var gotHash string require.NoError(t, json.Unmarshal(txObj["hash"], &gotHash)) - assert.Equal(t, fx.EthTxHash, gotHash) + assert.Equal(t, fx.ethTxHash, gotHash) }) t.Run("eth_getTransactionByBlockNumberAndIndex_outofrange", func(t *testing.T) { @@ -92,13 +129,13 @@ func TestTransactionsHandler(t *testing.T) { // ---- eth_getTransactionReceipt ---- t.Run("eth_getTransactionReceipt_eth", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getTransactionReceipt", []any{fx.EthTxHash}) + 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) + assert.Equal(t, fx.ethTxHash, gotHash) var txIdx hexutil.Uint64 require.NoError(t, json.Unmarshal(receipt["transactionIndex"], &txIdx)) @@ -119,7 +156,7 @@ func TestTransactionsHandler(t *testing.T) { 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}) + result := testutil.Call(t, ts, "eth_getTransactionReceipt", []any{fx.vcTxHash}) assert.Equal(t, "null", string(result)) }) @@ -131,7 +168,7 @@ func TestTransactionsHandler(t *testing.T) { freshRecipient := genesis.DevAccounts()[3].Address freshTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). - ChainID(fx.ChainID). + ChainID(fx.chainID). Nonce(0). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). diff --git a/test/testchain/chain.go b/test/testchain/chain.go index 1895fff980..de50a9e47a 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 thor.GetEthChainID(c.GenesisBlock().Header().ID()) +} diff --git a/test/testnode/node.go b/test/testnode/node.go index 3f29fb7860..3d0189267a 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -11,6 +11,10 @@ import ( "github.com/gorilla/mux" + "github.com/vechain/thor/v2/rpc" + + "github.com/vechain/thor/v2/thor" + "github.com/vechain/thor/v2/api/accounts" "github.com/vechain/thor/v2/api/blocks" "github.com/vechain/thor/v2/api/debug" @@ -26,6 +30,14 @@ 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" + rpclogs "github.com/vechain/thor/v2/rpc/logs" + rpcsimulation "github.com/vechain/thor/v2/rpc/simulation" + rpctransactions "github.com/vechain/thor/v2/rpc/transactions" ) // Node represents a complete test node with chain, API server, and transaction pool capabilities @@ -75,6 +87,7 @@ func (n *node) Start() error { logDB := n.chain.LogDB() forkConfig := n.chain.GetForkConfig() engine := bft.NewMockedEngine(repo.GenesisBlock().Header().ID()) + chainID := thor.GetEthChainID(repo.GenesisBlock().Header().ID()) accounts.New(repo, stater, 40_000_000, 5*1024*1024/2, forkConfig, engine, true).Mount(router, "/accounts") events.New(repo, logDB, 1000, 10).Mount(router, "/logs/event") @@ -96,6 +109,17 @@ func (n *node) Start() error { subs := subscriptions.New(repo, []string{"*"}, 1000, n.txPool, true) subs.Mount(router, "/subscriptions") + // pool := testutil.DefaultPool(t, c, &testchain.DefaultForkConfig) + rpcSrv := rpc.NewServer() + rpcchain.New(repo, chainID, "test/1.0").Mount(rpcSrv) + rpcblocks.New(repo, chainID).Mount(rpcSrv) + rpctransactions.New(repo, chainID, n.txPool).Mount(rpcSrv) + rpcaccounts.New(repo, stater).Mount(rpcSrv) + rpclogs.New(repo, logDB, 100).Mount(rpcSrv) + rpcfees.New(repo, 100).Mount(rpcSrv) + rpcsimulation.New(repo, stater, &testchain.DefaultForkConfig, 1_000_000).Mount(rpcSrv) + router.PathPrefix("/rpc").Handler(rpcSrv) + n.apiServer = httptest.NewServer(router) n.apiServerCloser = func() { subs.Close() diff --git a/thorclient/rpc_test.go b/thorclient/rpc_test.go index 0e08149b4e..b590e9ba7b 100644 --- a/thorclient/rpc_test.go +++ b/thorclient/rpc_test.go @@ -8,7 +8,6 @@ package thorclient import ( "encoding/json" "math/big" - "net/http/httptest" "strconv" "strings" "testing" @@ -17,117 +16,91 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/vechain/thor/v2/test/testchain" + "github.com/vechain/thor/v2/test/testnode" + "github.com/vechain/thor/v2/genesis" - "github.com/vechain/thor/v2/rpc" - "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/logs" - "github.com/vechain/thor/v2/rpc/simulation" "github.com/vechain/thor/v2/rpc/testutil" - "github.com/vechain/thor/v2/rpc/transactions" "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/tx" ) -// ethRPCTestEnv holds the test server and all pre-minted transaction context. -// -// Chain layout: -// -// Block 0: genesis -// Block 1: VcTx (TypeLegacy) + EthTx (EIP-1559, nonce=0, from Sender) -// Block 2: EthTx2 (EIP-1559, nonce=1, from Sender) + EthTx3 (EIP-1559, nonce=0, from DevAccounts[2]) -type ethRPCTestEnv struct { - ts *httptest.Server - fx *testutil.ChainFixture - block2Hash string // block 2 ID (0x-prefixed hex) - ethTx2Hash string // block 2, projected ETH index 0 (Sender, nonce=1) - ethTx3Hash string // block 2, projected ETH index 1 (DevAccounts[2], nonce=0) -} - // newEthRPCFixture builds the two-block chain and assembles all ETH RPC handlers. -func newEthRPCFixture(t *testing.T) *ethRPCTestEnv { +func newEthRPCFixture(t *testing.T) testnode.Node { t.Helper() - // Block 0 + block 1 come from the shared ChainFixture. - fx := testutil.NewChainFixture(t) + 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 := newEthRPCFixture(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()) // Block 2: two ETH txs from different senders so we get non-trivial // transactionIndex and cumulativeGasUsed on the second receipt. sender2 := genesis.DevAccounts()[2] ethTx2, err := tx.NewEthBuilder(tx.TypeEthTyped1559). - ChainID(fx.ChainID). + 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)). GasLimit(21000). - To(&fx.Recipient.Address). + To(&recipient.Address). Value(big.NewInt(1e9)). - Build(fx.Sender.PrivateKey) + Build(sender.PrivateKey) require.NoError(t, err) ethTx3, err := tx.NewEthBuilder(tx.TypeEthTyped1559). - ChainID(fx.ChainID). + ChainID(testNode.Chain().ChainID()). Nonce(0). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). GasLimit(21000). - To(&fx.Recipient.Address). + To(&recipient.Address). Value(big.NewInt(1e9)). Build(sender2.PrivateKey) require.NoError(t, err) - require.NoError(t, fx.Chain.MintBlock(ethTx2, ethTx3)) - - block2, err := fx.Chain.BestBlock() + block2, err := testNode.Chain().BestBlock() require.NoError(t, err) - pool := testutil.DefaultPool(t, fx.Chain, &fx.Forks) - srv := rpc.NewServer() - rpcchain.New(fx.Chain.Repo(), fx.ChainID, "test/1.0").Mount(srv) - blocks.New(fx.Chain.Repo(), fx.ChainID).Mount(srv) - transactions.New(fx.Chain.Repo(), fx.ChainID, pool).Mount(srv) - accounts.New(fx.Chain.Repo(), fx.Chain.Stater()).Mount(srv) - logs.New(fx.Chain.Repo(), fx.Chain.LogDB(), 100).Mount(srv) - fees.New(fx.Chain.Repo(), 100).Mount(srv) - simulation.New(fx.Chain.Repo(), fx.Chain.Stater(), &fx.Forks, 1_000_000).Mount(srv) - ts := httptest.NewServer(srv) - t.Cleanup(ts.Close) - - return ðRPCTestEnv{ - ts: ts, - fx: fx, - block2Hash: block2.Header().ID().String(), - ethTx2Hash: ethTx2.ID().String(), - ethTx3Hash: ethTx3.ID().String(), - } -} - -func TestEthRPC(t *testing.T) { - env := newEthRPCFixture(t) - ts, fx := env.ts, env.fx + require.NoError(t, testNode.Chain().MintBlock(ethTx2, ethTx3)) // ── Identity ────────────────────────────────────────────────────────────── t.Run("eth_chainId", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_chainId", []any{}) + result := testutil.Call(t, testNode.APIServer(), "eth_chainId", []any{}) var chainID hexutil.Uint64 require.NoError(t, json.Unmarshal(result, &chainID)) - assert.Equal(t, fx.ChainID, uint64(chainID)) + assert.Equal(t, testNode.Chain().ChainID(), uint64(chainID)) }) t.Run("net_version", func(t *testing.T) { - result := testutil.Call(t, ts, "net_version", []any{}) + result := testutil.Call(t, testNode.APIServer(), "net_version", []any{}) var version string require.NoError(t, json.Unmarshal(result, &version)) - assert.Equal(t, strconv.FormatUint(fx.ChainID, 10), version) + assert.Equal(t, strconv.FormatUint(testNode.Chain().ChainID(), 10), version) }) t.Run("eth_blockNumber", func(t *testing.T) { // Chain has genesis + 2 minted blocks. - result := testutil.Call(t, ts, "eth_blockNumber", []any{}) + result := testutil.Call(t, testNode.APIServer(), "eth_blockNumber", []any{}) var num hexutil.Uint64 require.NoError(t, json.Unmarshal(result, &num)) assert.Equal(t, uint64(2), uint64(num)) @@ -137,7 +110,7 @@ func TestEthRPC(t *testing.T) { 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, ts, "eth_getBlockByNumber", []any{"0x1", false}) + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockByNumber", []any{"0x1", false}) var block map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, &block)) @@ -147,18 +120,18 @@ func TestEthRPC(t *testing.T) { var hash string require.NoError(t, json.Unmarshal(block["hash"], &hash)) - assert.True(t, strings.EqualFold(fx.BlockHash, hash)) + assert.True(t, strings.EqualFold(block2.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(fx.EthTxHash, txHashes[0])) + 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, ts, "eth_getBlockByNumber", []any{"latest", false}) + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockByNumber", []any{"latest", false}) var block map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, &block)) @@ -169,13 +142,13 @@ func TestEthRPC(t *testing.T) { var txHashes []string require.NoError(t, json.Unmarshal(block["transactions"], &txHashes)) require.Len(t, txHashes, 2) - assert.True(t, strings.EqualFold(env.ethTx2Hash, txHashes[0])) - assert.True(t, strings.EqualFold(env.ethTx3Hash, txHashes[1])) + 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, ts, "eth_getBlockByNumber", []any{"0x1", true}) + // Full-tx mode: transactions array contains EthTx objectestNode.APIServer() 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)) @@ -189,19 +162,19 @@ func TestEthRPC(t *testing.T) { }) t.Run("eth_getBlockByHash", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getBlockByHash", []any{fx.BlockHash, false}) + 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(fx.BlockHash, hash)) + assert.True(t, strings.EqualFold(block2.Header().ID().String(), hash)) }) // ── Transactions ────────────────────────────────────────────────────────── t.Run("eth_getTransactionByHash_eth", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getTransactionByHash", []any{fx.EthTxHash}) + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByHash", []any{ethTx2.ID().String()}) var ethTx map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, ðTx)) require.NotNil(t, ethTx) @@ -212,66 +185,66 @@ func TestEthRPC(t *testing.T) { var hash string require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) - assert.True(t, strings.EqualFold(fx.EthTxHash, hash)) + assert.True(t, strings.EqualFold(ethTx2.ID().String(), hash)) var from string require.NoError(t, json.Unmarshal(ethTx["from"], &from)) - assert.True(t, strings.EqualFold(fx.Sender.Address.String(), 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, ts, "eth_getTransactionByHash", []any{fx.VcTxHash}) + 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, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x1", "0x0"}) - var ethTx map[string]json.RawMessage - require.NoError(t, json.Unmarshal(result, ðTx)) - require.NotNil(t, ethTx) + 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(ethTx["hash"], &hash)) - assert.True(t, strings.EqualFold(fx.EthTxHash, hash)) + 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, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x0"}) + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x0"}) var ethTx map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, ðTx)) require.NotNil(t, ethTx) var hash string require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) - assert.True(t, strings.EqualFold(env.ethTx2Hash, hash)) + assert.True(t, strings.EqualFold(ethTx2.ID().String(), hash)) }) t.Run("eth_getTransactionByBlockNumberAndIndex_block2_second", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x1"}) + result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x1"}) var ethTx map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, ðTx)) require.NotNil(t, ethTx) var hash string require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) - assert.True(t, strings.EqualFold(env.ethTx3Hash, hash)) + assert.True(t, strings.EqualFold(ethTx3.ID().String(), hash)) }) t.Run("eth_getTransactionByBlockHashAndIndex", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getTransactionByBlockHashAndIndex", []any{fx.BlockHash, "0x0"}) - var ethTx map[string]json.RawMessage - require.NoError(t, json.Unmarshal(result, ðTx)) - require.NotNil(t, ethTx) + 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(ethTx["hash"], &hash)) - assert.True(t, strings.EqualFold(fx.EthTxHash, hash)) + require.NoError(t, json.Unmarshal(fetchEthTx["hash"], &hash)) + assert.True(t, strings.EqualFold(ethTx.ID().String(), hash)) }) t.Run("eth_getTransactionReceipt_block1", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getTransactionReceipt", []any{fx.EthTxHash}) + 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) @@ -296,7 +269,7 @@ func TestEthRPC(t *testing.T) { t.Run("eth_getTransactionReceipt_block2_second", func(t *testing.T) { // Second ETH tx in block 2: transactionIndex=1, cumulativeGasUsed=42000. - result := testutil.Call(t, ts, "eth_getTransactionReceipt", []any{env.ethTx3Hash}) + 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) @@ -322,7 +295,7 @@ func TestEthRPC(t *testing.T) { // ── Accounts ────────────────────────────────────────────────────────────── t.Run("eth_getBalance", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_getBalance", []any{fx.Sender.Address.String(), "latest"}) + 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) @@ -330,14 +303,14 @@ func TestEthRPC(t *testing.T) { 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, ts, "eth_getTransactionCount", []any{fx.Sender.Address.String(), "latest"}) + 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, ts, "eth_getCode", []any{fx.Sender.Address.String(), "latest"}) + 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) @@ -346,14 +319,14 @@ func TestEthRPC(t *testing.T) { // ── Fees ────────────────────────────────────────────────────────────────── t.Run("eth_gasPrice", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_gasPrice", []any{}) + 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, ts, "eth_maxPriorityFeePerGas", []any{}) + 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) @@ -361,7 +334,7 @@ func TestEthRPC(t *testing.T) { t.Run("eth_feeHistory_two_blocks", func(t *testing.T) { // blockCount=2, newestBlock="latest" → covers blocks 1 and 2. - result := testutil.Call(t, ts, "eth_feeHistory", []any{2, "latest", []any{}}) + 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)) @@ -384,10 +357,10 @@ func TestEthRPC(t *testing.T) { // ── Simulation ──────────────────────────────────────────────────────────── t.Run("eth_call", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_call", []any{ + result := testutil.Call(t, testNode.APIServer(), "eth_call", []any{ map[string]any{ - "from": fx.Sender.Address.String(), - "to": fx.Recipient.Address.String(), + "from": sender.Address.String(), + "to": recipient.Address.String(), "value": "0x1", }, "latest", @@ -398,10 +371,10 @@ func TestEthRPC(t *testing.T) { }) t.Run("eth_estimateGas", func(t *testing.T) { - result := testutil.Call(t, ts, "eth_estimateGas", []any{ + result := testutil.Call(t, testNode.APIServer(), "eth_estimateGas", []any{ map[string]any{ - "from": fx.Sender.Address.String(), - "to": fx.Recipient.Address.String(), + "from": sender.Address.String(), + "to": recipient.Address.String(), "value": "0x1", }, }) @@ -414,9 +387,9 @@ func TestEthRPC(t *testing.T) { t.Run("eth_getLogs_empty", func(t *testing.T) { // All fixture txs are plain VET transfers — no contract events are emitted. - // TODO: extend with a contract-deploy tx that emits events to cover non-empty results, + // TODO: extend with a contract-deploy tx that emits events to cover non-empty resultestNode.APIServer() // address filter, topic filter, and EIP-234 blockHash filter. - result := testutil.Call(t, ts, "eth_getLogs", []any{ + result := testutil.Call(t, testNode.APIServer(), "eth_getLogs", []any{ map[string]any{"fromBlock": "0x0", "toBlock": "latest"}, }) var got []any @@ -429,32 +402,29 @@ func TestEthRPC(t *testing.T) { t.Run("eth_sendRawTransaction", func(t *testing.T) { // Sender has used nonce=0 (block 1) and nonce=1 (block 2); next nonce is 2. newTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). - ChainID(fx.ChainID). + ChainID(testNode.Chain().ChainID()). Nonce(2). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). GasLimit(21000). - To(&fx.Recipient.Address). + To(&recipient.Address). Value(big.NewInt(1e9)). - Build(fx.Sender.PrivateKey) + Build(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, ts, "eth_sendRawTransaction", []any{ + 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. Mine: seal a block containing the transaction so it becomes queryable. - require.NoError(t, fx.Chain.MintBlock(newTx)) - - // 3. Read transaction: must be visible in the new block. - result = testutil.Call(t, ts, "eth_getTransactionByHash", []any{txHash}) + // 2. Read transaction: must be visible in the new block. + result = testutil.Call(t, testNode.APIServer(), "eth_getTransactionByHash", []any{txHash}) var ethTx map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, ðTx)) require.NotNil(t, ethTx, "transaction should be found after mining") @@ -465,14 +435,14 @@ func TestEthRPC(t *testing.T) { var from string require.NoError(t, json.Unmarshal(ethTx["from"], &from)) - assert.True(t, strings.EqualFold(fx.Sender.Address.String(), from)) + assert.True(t, strings.EqualFold(sender.Address.String(), from)) var txType hexutil.Uint64 require.NoError(t, json.Unmarshal(ethTx["type"], &txType)) assert.Equal(t, uint64(2), uint64(txType)) - // 4. Read receipt: must exist with status=1 (successful transfer). - result = testutil.Call(t, ts, "eth_getTransactionReceipt", []any{txHash}) + // 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") From 9a967a935d0d97b54f29ac14f9afb1aba71d722d Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 21:11:11 +0100 Subject: [PATCH 09/20] reuse existingMiddleware --- api/middleware/middleware.go | 12 ++++++++++-- api/middleware/middleware_test.go | 29 +++++++++++++++++++++++++++++ cmd/thor/httpserver/api_server.go | 9 +-------- 3 files changed, 40 insertions(+), 10 deletions(-) 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/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index e354b56afa..c43dd09e8c 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -163,14 +163,7 @@ func StartAPIServer( // middlewares // /rpc enforces its own 2 MB limit internally; all other routes get 200 KB - router.Use(func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.URL.Path, "/rpc") { - r.Body = http.MaxBytesReader(w, r.Body, defaultRequestBodyLimit) - } - next.ServeHTTP(w, r) - }) - }) + router.Use(middleware.HandleRequestBodyLimit(defaultRequestBodyLimit, "/rpc")) if config.Timeout > 0 { router.Use(middleware.HandleAPITimeout(time.Duration(config.Timeout) * time.Millisecond)) } From 683ab5e489ca805804a8ca764d17926011e80687 Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 22:16:32 +0100 Subject: [PATCH 10/20] minor refactor --- cmd/thor/httpserver/api_server.go | 2 +- rpc/blocks/handler.go | 20 +++------ rpc/integration_test.go | 18 ++++++++ rpc/server.go | 12 ++++-- rpc/transactions/handler.go | 72 ++++++++++++++++++------------- rpc/types.go | 1 + 6 files changed, 79 insertions(+), 46 deletions(-) diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index c43dd09e8c..ed97b431c0 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -162,7 +162,7 @@ func StartAPIServer( } // middlewares - // /rpc enforces its own 2 MB limit internally; all other routes get 200 KB + // /rpc owns its body limit inside rpc.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)) diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go index 68ffdeba00..2f88be14df 100644 --- a/rpc/blocks/handler.go +++ b/rpc/blocks/handler.go @@ -42,16 +42,7 @@ func (h *Handler) ethGetBlockByHash(req rpc.Request) rpc.Response { if err := json.Unmarshal(params[1], &fullTxs); err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fullTransactions flag") } - - summary, err := rpc.ResolveBlockTag(hashStr, h.repo) - if err != nil { - return rpc.OkResponse(req.ID, nil) - } - blk, err := rpc.BuildEthBlock(summary.Header, h.repo, h.chainID, fullTxs) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) - } - return rpc.OkResponse(req.ID, blk) + return h.getBlockByTag(req.ID, hashStr, fullTxs) } func (h *Handler) ethGetBlockByNumber(req rpc.Request) rpc.Response { @@ -67,14 +58,17 @@ func (h *Handler) ethGetBlockByNumber(req rpc.Request) rpc.Response { if err := json.Unmarshal(params[1], &fullTxs); err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fullTransactions flag") } + return h.getBlockByTag(req.ID, tag, fullTxs) +} +func (h *Handler) getBlockByTag(id json.RawMessage, tag string, fullTxs bool) rpc.Response { summary, err := rpc.ResolveBlockTag(tag, h.repo) if err != nil { - return rpc.OkResponse(req.ID, nil) + return rpc.OkResponse(id, nil) } blk, err := rpc.BuildEthBlock(summary.Header, h.repo, h.chainID, fullTxs) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) } - return rpc.OkResponse(req.ID, blk) + return rpc.OkResponse(id, blk) } diff --git a/rpc/integration_test.go b/rpc/integration_test.go index dc0c762742..30737d4b35 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -128,6 +128,24 @@ func TestDispatch(t *testing.T) { assert.Equal(t, rpc.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 *rpc.RPCError `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) + require.NotNil(t, rpcResp.Error) + assert.Equal(t, rpc.CodeInvalidRequest, rpcResp.Error.Code) + }) + t.Run("wrong_http_method", func(t *testing.T) { resp, err := http.Get(ts.URL + "/") require.NoError(t, err) diff --git a/rpc/server.go b/rpc/server.go index d201737525..1396889c32 100644 --- a/rpc/server.go +++ b/rpc/server.go @@ -8,6 +8,7 @@ package rpc import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -43,7 +44,7 @@ func (s *Server) Register(method string, handler func(Request) Response) { // ServeHTTP implements http.Handler. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { - // CORS preflight handled by the gorilla/handlers CORS middleware applied externally. + // CORS preflight handled by the handlers CORS middleware applied externally. w.WriteHeader(http.StatusOK) return } @@ -52,9 +53,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBodySize)) + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + body, err := io.ReadAll(r.Body) if err != nil { - writeJSON(w, ErrResponse(nil, CodeParseError, "failed to read request body")) + 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 } diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index 0f78552299..27bbd55ed4 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -40,6 +40,31 @@ func (h *Handler) Mount(s *rpc.Server) { 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 thor.Bytes32) (*ethTxContext, error) { + t, meta, err := bestChain.GetTransaction(id) + if err != nil || t.Type() != tx.TypeEthTyped1559 { + 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 rpc.Request) rpc.Response { var params []string if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { @@ -50,23 +75,19 @@ func (h *Handler) ethGetTransactionByHash(req rpc.Request) rpc.Response { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") } - bestChain := h.repo.NewBestChain() - t, meta, err := bestChain.GetTransaction(id) - if err != nil || t.Type() != tx.TypeEthTyped1559 { - return rpc.OkResponse(req.ID, nil) - } - - header, err := bestChain.GetBlockHeader(meta.BlockNum) + ctx, err := h.fetchEthTxContext(h.repo.NewBestChain(), id) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) } - receipts, err := h.repo.GetBlockReceipts(header.ID()) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + if ctx == nil { + return rpc.OkResponse(req.ID, nil) } - projIdx := rpc.ProjectedEthIndex(receipts, meta.Index) - return rpc.OkResponse(req.ID, rpc.ToEthTx(t, h.chainID, common.Hash(header.ID()), uint64(header.Number()), projIdx, header.BaseFee())) + projIdx := rpc.ProjectedEthIndex(ctx.receipts, ctx.meta.Index) + return rpc.OkResponse( + req.ID, + rpc.ToEthTx(ctx.transaction, h.chainID, common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), projIdx, ctx.header.BaseFee()), + ) } func (h *Handler) ethGetTransactionByBlockHashAndIndex(req rpc.Request) rpc.Response { @@ -153,30 +174,23 @@ func (h *Handler) ethGetTransactionReceipt(req rpc.Request) rpc.Response { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") } - bestChain := h.repo.NewBestChain() - t, meta, err := bestChain.GetTransaction(id) - if err != nil || t.Type() != tx.TypeEthTyped1559 { - return rpc.OkResponse(req.ID, nil) - } - - header, err := bestChain.GetBlockHeader(meta.BlockNum) + ctx, err := h.fetchEthTxContext(h.repo.NewBestChain(), id) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) } - receipts, err := h.repo.GetBlockReceipts(header.ID()) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + if ctx == nil { + return rpc.OkResponse(req.ID, nil) } - receipt := receipts[meta.Index] - projIdx := rpc.ProjectedEthIndex(receipts, meta.Index) - cumGas := rpc.CumulativeEthGasUsed(receipts, meta.Index) - logOff := rpc.EthLogOffset(receipts, meta.Index) + receipt := ctx.receipts[ctx.meta.Index] + projIdx := rpc.ProjectedEthIndex(ctx.receipts, ctx.meta.Index) + cumGas := rpc.CumulativeEthGasUsed(ctx.receipts, ctx.meta.Index) + logOff := rpc.EthLogOffset(ctx.receipts, ctx.meta.Index) return rpc.OkResponse(req.ID, rpc.ToEthReceipt( - t, receipt, h.chainID, - common.Hash(header.ID()), uint64(header.Number()), - projIdx, cumGas, logOff, header.BaseFee(), + ctx.transaction, receipt, h.chainID, + common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), + projIdx, cumGas, logOff, ctx.header.BaseFee(), )) } diff --git a/rpc/types.go b/rpc/types.go index f628bae8bd..b0f7efda23 100644 --- a/rpc/types.go +++ b/rpc/types.go @@ -32,6 +32,7 @@ type RPCError struct { const ( CodeParseError = -32700 + CodeInvalidRequest = -32600 CodeMethodNotFound = -32601 CodeInvalidParams = -32602 CodeInternalError = -32603 From a9b2b75c120f38b5b1d4e035cf1030c8b712ecae Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 7 May 2026 22:30:32 +0100 Subject: [PATCH 11/20] fix windows test --- .github/workflows/test.yaml | 2 +- api/middleware/request_logger_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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, From 47892f7505e78b8a302cba3a653bdf809bd7576a Mon Sep 17 00:00:00 2001 From: otherview Date: Fri, 8 May 2026 14:18:31 +0100 Subject: [PATCH 12/20] rebase eth-equivalence --- cmd/thor/httpserver/api_server.go | 2 +- rpc/accounts/handler_test.go | 3 +-- rpc/blocks/handler_test.go | 5 ++--- rpc/chain/handler_test.go | 3 +-- rpc/eth_types.go | 4 ++-- rpc/integration_test.go | 3 +-- rpc/logs/handler.go | 2 +- rpc/simulation/handler.go | 2 +- rpc/testutil/testutil.go | 7 ++++--- rpc/transactions/handler.go | 10 +++++----- rpc/transactions/handler_test.go | 11 ++++++----- rpc/utils.go | 8 ++++---- test/testchain/chain.go | 2 +- test/testnode/node.go | 4 +--- thorclient/rpc_test.go | 21 ++++++++++++--------- 15 files changed, 43 insertions(+), 44 deletions(-) diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index ed97b431c0..051d20c04a 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -142,7 +142,7 @@ func StartAPIServer( subs.Mount(router, "/subscriptions") // Ethereum JSON-RPC at /rpc — body limit enforced internally by rpc.Server (2 MB via io.LimitReader) - chainID := thor.GetEthChainID(repo.GenesisBlock().Header().ID()) + chainID := repo.ChainID() rpcSrv := rpc.NewServer() rpcchain.New(repo, chainID, config.ClientVersion).Mount(rpcSrv) rpcblocks.New(repo, chainID).Mount(rpcSrv) diff --git a/rpc/accounts/handler_test.go b/rpc/accounts/handler_test.go index b4a1e86f39..52297c8749 100644 --- a/rpc/accounts/handler_test.go +++ b/rpc/accounts/handler_test.go @@ -18,7 +18,6 @@ import ( "github.com/vechain/thor/v2/rpc/accounts" "github.com/vechain/thor/v2/rpc/testutil" "github.com/vechain/thor/v2/test/testchain" - "github.com/vechain/thor/v2/thor" ) type fixture struct { @@ -31,7 +30,7 @@ func newFixture(t *testing.T) *fixture { c, err := testchain.NewDefault() require.NoError(t, err) - chainID := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + chainID := c.Repo().ChainID() sender := genesis.DevAccounts()[0] recipient := genesis.DevAccounts()[1] diff --git a/rpc/blocks/handler_test.go b/rpc/blocks/handler_test.go index ad14806be0..76d130031e 100644 --- a/rpc/blocks/handler_test.go +++ b/rpc/blocks/handler_test.go @@ -17,7 +17,6 @@ import ( "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/thor" "github.com/vechain/thor/v2/tx" ) @@ -33,7 +32,7 @@ func newFixture(t *testing.T) *fixture { c, err := testchain.NewDefault() require.NoError(t, err) - chainID := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + chainID := c.Repo().ChainID() sender := genesis.DevAccounts()[0] recipient := genesis.DevAccounts()[1] @@ -120,7 +119,7 @@ func TestBlocksHandler(t *testing.T) { var txType hexutil.Uint64 require.NoError(t, json.Unmarshal(txObjs[0]["type"], &txType)) - assert.Equal(t, uint64(tx.TypeEthTyped1559), uint64(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. diff --git a/rpc/chain/handler_test.go b/rpc/chain/handler_test.go index 32e2a4f4da..a67552aec8 100644 --- a/rpc/chain/handler_test.go +++ b/rpc/chain/handler_test.go @@ -16,7 +16,6 @@ import ( "github.com/vechain/thor/v2/rpc/chain" "github.com/vechain/thor/v2/rpc/testutil" "github.com/vechain/thor/v2/test/testchain" - "github.com/vechain/thor/v2/thor" ) type fixture struct { @@ -32,7 +31,7 @@ func newFixture(t *testing.T) *fixture { require.NoError(t, c.MintBlock()) return &fixture{ chain: c, - chainID: thor.GetEthChainID(c.GenesisBlock().Header().ID()), + chainID: c.Repo().ChainID(), } } diff --git a/rpc/eth_types.go b/rpc/eth_types.go index b19160bdbd..735d2e134a 100644 --- a/rpc/eth_types.go +++ b/rpc/eth_types.go @@ -130,7 +130,7 @@ func ToEthTx(t *tx.Transaction, chainID uint64, blockHash common.Hash, blockNum To: to, TransactionIndex: &idx, Value: (*hexutil.Big)(new(big.Int).Set(clauses[0].Value())), - Type: hexutil.Uint64(tx.TypeEthTyped1559), + Type: hexutil.Uint64(tx.TypeEthDynamicFee), ChainID: (*hexutil.Big)(new(big.Int).SetUint64(chainID)), V: (*hexutil.Big)(v), R: (*hexutil.Big)(r), @@ -260,7 +260,7 @@ func ToEthReceipt( Logs: logs, LogsBloom: zeroLogsBloom, Status: status, - Type: hexutil.Uint64(tx.TypeEthTyped1559), + Type: hexutil.Uint64(tx.TypeEthDynamicFee), EffectiveGasPrice: (*hexutil.Big)(effectiveGasPrice), } } diff --git a/rpc/integration_test.go b/rpc/integration_test.go index 30737d4b35..2a39f6cf7a 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -18,7 +18,6 @@ import ( "github.com/vechain/thor/v2/genesis" "github.com/vechain/thor/v2/test/testchain" - "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/txpool" "github.com/vechain/thor/v2/rpc" @@ -38,7 +37,7 @@ func TestDispatch(t *testing.T) { c, err := testchain.NewDefault() require.NoError(t, err) - chainID := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + chainID := c.Repo().ChainID() sender := genesis.DevAccounts()[0] recipient := genesis.DevAccounts()[1] diff --git a/rpc/logs/handler.go b/rpc/logs/handler.go index 15bf6ac01d..c789a733fe 100644 --- a/rpc/logs/handler.go +++ b/rpc/logs/handler.go @@ -192,7 +192,7 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { return v } t, _, err := bestChain.GetTransaction(txID) - ok2 := err == nil && t.Type() == tx.TypeEthTyped1559 + ok2 := err == nil && t.Type() == tx.TypeEthDynamicFee typeCache[txID] = ok2 return ok2 } diff --git a/rpc/simulation/handler.go b/rpc/simulation/handler.go index b92cec15bb..be8fc30447 100644 --- a/rpc/simulation/handler.go +++ b/rpc/simulation/handler.go @@ -157,7 +157,7 @@ func (h *Handler) simulate(args CallArgs, tag string, gasLimit uint64) (*runtime Origin: origin, GasPrice: gasPrice, ClauseCount: 1, - TxType: tx.TypeEthTyped1559, + Type: tx.TypeEthDynamicFee, } exec, _ := rt.PrepareClause(clause, 0, gasLimit, txCtx) diff --git a/rpc/testutil/testutil.go b/rpc/testutil/testutil.go index a071af3f7e..2d540cb8ab 100644 --- a/rpc/testutil/testutil.go +++ b/rpc/testutil/testutil.go @@ -29,15 +29,16 @@ import ( // 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() - ethTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). ChainID(chainID). Nonce(nonce). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). - GasLimit(21000). + Gas(21000). To(to). Value(big.NewInt(1e9)). - Build(sender.PrivateKey) + Build() + ethTx, err := tx.Sign(unsigned, sender.PrivateKey) require.NoError(t, err) return ethTx } diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index 27bbd55ed4..51d7ed57e2 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -51,7 +51,7 @@ type ethTxContext struct { // Returns nil, nil when the tx does not exist or is not an ETH-typed transaction. func (h *Handler) fetchEthTxContext(bestChain *chain.Chain, id thor.Bytes32) (*ethTxContext, error) { t, meta, err := bestChain.GetTransaction(id) - if err != nil || t.Type() != tx.TypeEthTyped1559 { + if err != nil || t.Type() != tx.TypeEthDynamicFee { return nil, nil } header, err := bestChain.GetBlockHeader(meta.BlockNum) @@ -152,7 +152,7 @@ func (h *Handler) txByBlockAndEthIndex(req rpc.Request, header *block.Header, id var projIdx uint64 for i, t := range blk.Transactions() { - if t.Type() != tx.TypeEthTyped1559 { + if t.Type() != tx.TypeEthDynamicFee { continue } if projIdx == ethIdx { @@ -204,9 +204,9 @@ func (h *Handler) ethSendRawTransaction(req rpc.Request) rpc.Response { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid hex encoding") } - parsed, err := tx.ParseEthTransaction(raw, h.chainID) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeServerError, err.Error()) + parsed := new(tx.Transaction) + if err := parsed.UnmarshalBinary(raw); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) } if err := h.txPool.AddLocal(parsed); err != nil { return rpc.ErrResponse(req.ID, rpc.CodeServerError, err.Error()) diff --git a/rpc/transactions/handler_test.go b/rpc/transactions/handler_test.go index 182ce4df24..e239c3ab93 100644 --- a/rpc/transactions/handler_test.go +++ b/rpc/transactions/handler_test.go @@ -37,7 +37,7 @@ func newFixture(t *testing.T) *fixture { c, err := testchain.NewDefault() require.NoError(t, err) - chainID := thor.GetEthChainID(c.GenesisBlock().Header().ID()) + chainID := c.Repo().ChainID() sender := genesis.DevAccounts()[0] recipient := genesis.DevAccounts()[1] vcTx := testutil.BuildVcTx(t, c, sender, &recipient.Address) @@ -151,7 +151,7 @@ func TestTransactionsHandler(t *testing.T) { var txType hexutil.Uint64 require.NoError(t, json.Unmarshal(receipt["type"], &txType)) - assert.Equal(t, uint64(tx.TypeEthTyped1559), uint64(txType)) + assert.Equal(t, uint64(tx.TypeEthDynamicFee), uint64(txType)) }) t.Run("eth_getTransactionReceipt_vechain", func(t *testing.T) { @@ -167,15 +167,16 @@ func TestTransactionsHandler(t *testing.T) { freshSender := genesis.DevAccounts()[2] freshRecipient := genesis.DevAccounts()[3].Address - freshTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). ChainID(fx.chainID). Nonce(0). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). - GasLimit(21000). + Gas(21000). To(&freshRecipient). Value(big.NewInt(1e9)). - Build(freshSender.PrivateKey) + Build() + freshTx, err := tx.Sign(unsigned, freshSender.PrivateKey) require.NoError(t, err) rawBytes, err := freshTx.MarshalBinary() diff --git a/rpc/utils.go b/rpc/utils.go index 4383b04530..4d15e4dd38 100644 --- a/rpc/utils.go +++ b/rpc/utils.go @@ -109,7 +109,7 @@ func BuildEthBlock( baseFee := header.BaseFee() for i, t := range txs { - if t.Type() != tx.TypeEthTyped1559 { + if t.Type() != tx.TypeEthDynamicFee { continue } projIdx := ProjectedEthIndex(receipts, uint64(i)) @@ -166,7 +166,7 @@ func BuildEthBlock( func ProjectedEthIndex(receipts tx.Receipts, canonicalIdx uint64) uint64 { var count uint64 for i := range canonicalIdx { - if receipts[i].Type == tx.TypeEthTyped1559 { + if receipts[i].Type == tx.TypeEthDynamicFee { count++ } } @@ -178,7 +178,7 @@ func ProjectedEthIndex(receipts tx.Receipts, canonicalIdx uint64) uint64 { func CumulativeEthGasUsed(receipts tx.Receipts, canonicalIdx uint64) uint64 { var total uint64 for i := uint64(0); i <= canonicalIdx; i++ { - if receipts[i].Type == tx.TypeEthTyped1559 { + if receipts[i].Type == tx.TypeEthDynamicFee { total += receipts[i].GasUsed } } @@ -190,7 +190,7 @@ func CumulativeEthGasUsed(receipts tx.Receipts, canonicalIdx uint64) uint64 { func EthLogOffset(receipts tx.Receipts, canonicalIdx uint64) uint64 { var offset uint64 for i := range canonicalIdx { - if receipts[i].Type == tx.TypeEthTyped1559 && len(receipts[i].Outputs) > 0 { + if receipts[i].Type == tx.TypeEthDynamicFee && len(receipts[i].Outputs) > 0 { offset += uint64(len(receipts[i].Outputs[0].Events)) } } diff --git a/test/testchain/chain.go b/test/testchain/chain.go index de50a9e47a..c42982c837 100644 --- a/test/testchain/chain.go +++ b/test/testchain/chain.go @@ -248,5 +248,5 @@ func (c *Chain) AddValidator(validator genesis.DevAccount) { // ChainID returns the current genesis chain id. func (c *Chain) ChainID() uint64 { - return thor.GetEthChainID(c.GenesisBlock().Header().ID()) + return c.Repo().ChainID() } diff --git a/test/testnode/node.go b/test/testnode/node.go index 3d0189267a..c6e5456eed 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -13,8 +13,6 @@ import ( "github.com/vechain/thor/v2/rpc" - "github.com/vechain/thor/v2/thor" - "github.com/vechain/thor/v2/api/accounts" "github.com/vechain/thor/v2/api/blocks" "github.com/vechain/thor/v2/api/debug" @@ -87,7 +85,7 @@ func (n *node) Start() error { logDB := n.chain.LogDB() forkConfig := n.chain.GetForkConfig() engine := bft.NewMockedEngine(repo.GenesisBlock().Header().ID()) - chainID := thor.GetEthChainID(repo.GenesisBlock().Header().ID()) + chainID := repo.ChainID() accounts.New(repo, stater, 40_000_000, 5*1024*1024/2, forkConfig, engine, true).Mount(router, "/accounts") events.New(repo, logDB, 1000, 10).Mount(router, "/logs/event") diff --git a/thorclient/rpc_test.go b/thorclient/rpc_test.go index b590e9ba7b..7b7a641839 100644 --- a/thorclient/rpc_test.go +++ b/thorclient/rpc_test.go @@ -55,26 +55,28 @@ func TestEthRPC(t *testing.T) { // transactionIndex and cumulativeGasUsed on the second receipt. sender2 := genesis.DevAccounts()[2] - ethTx2, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + 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)). - GasLimit(21000). + Gas(21000). To(&recipient.Address). Value(big.NewInt(1e9)). - Build(sender.PrivateKey) + Build() + ethTx2, err := tx.Sign(unsigned2, sender.PrivateKey) require.NoError(t, err) - ethTx3, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + unsigned3 := tx.NewBuilder(tx.TypeEthDynamicFee). ChainID(testNode.Chain().ChainID()). Nonce(0). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). - GasLimit(21000). + Gas(21000). To(&recipient.Address). Value(big.NewInt(1e9)). - Build(sender2.PrivateKey) + Build() + ethTx3, err := tx.Sign(unsigned3, sender2.PrivateKey) require.NoError(t, err) block2, err := testNode.Chain().BestBlock() @@ -401,15 +403,16 @@ func TestEthRPC(t *testing.T) { t.Run("eth_sendRawTransaction", func(t *testing.T) { // Sender has used nonce=0 (block 1) and nonce=1 (block 2); next nonce is 2. - newTx, err := tx.NewEthBuilder(tx.TypeEthTyped1559). + unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). ChainID(testNode.Chain().ChainID()). Nonce(2). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). - GasLimit(21000). + Gas(21000). To(&recipient.Address). Value(big.NewInt(1e9)). - Build(sender.PrivateKey) + Build() + newTx, err := tx.Sign(unsigned, sender.PrivateKey) require.NoError(t, err) rawBytes, err := newTx.MarshalBinary() From 9e50fec3fc13dfb83abb3da592b9ba6dfe7c2473 Mon Sep 17 00:00:00 2001 From: otherview Date: Fri, 8 May 2026 15:06:00 +0100 Subject: [PATCH 13/20] add eth block and net missing actions --- cmd/thor/httpserver/api_server.go | 2 +- rpc/blocks/handler.go | 128 ++++++++++++++++++++++++++++++ rpc/blocks/handler_test.go | 75 +++++++++++++++++ rpc/chain/handler.go | 8 ++ rpc/chain/handler_test.go | 21 +++++ 5 files changed, 233 insertions(+), 1 deletion(-) diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index 051d20c04a..c4dc7fa7ce 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -141,7 +141,7 @@ 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 rpc.Server (2 MB via io.LimitReader) + // Ethereum JSON-RPC at /rpc — body limit enforced internally by rpc.Server (2 MB via MaxBytesReader) chainID := repo.ChainID() rpcSrv := rpc.NewServer() rpcchain.New(repo, chainID, config.ClientVersion).Mount(rpcSrv) diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go index 2f88be14df..472781fd5f 100644 --- a/rpc/blocks/handler.go +++ b/rpc/blocks/handler.go @@ -8,8 +8,12 @@ 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/tx" ) // Handler implements block query JSON-RPC methods. @@ -27,6 +31,13 @@ func New(repo *chain.Repository, chainID uint64) *Handler { func (h *Handler) Mount(s *rpc.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 rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, nil) }) + s.Register("eth_getUncleByBlockNumberAndIndex", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, nil) }) } func (h *Handler) ethGetBlockByHash(req rpc.Request) rpc.Response { @@ -72,3 +83,120 @@ func (h *Handler) getBlockByTag(id json.RawMessage, tag string, fullTxs bool) rp } return rpc.OkResponse(id, blk) } + +func (h *Handler) ethGetBlockTransactionCountByHash(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash]") + } + var tag string + if err := json.Unmarshal(params[0], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") + } + return h.txCountByTag(req.ID, tag) +} + +func (h *Handler) ethGetBlockTransactionCountByNumber(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber]") + } + var tag string + if err := json.Unmarshal(params[0], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") + } + return h.txCountByTag(req.ID, tag) +} + +func (h *Handler) txCountByTag(id json.RawMessage, tag string) rpc.Response { + summary, err := rpc.ResolveBlockTag(tag, h.repo) + if err != nil { + return rpc.OkResponse(id, nil) + } + blk, err := h.repo.GetBlock(summary.Header.ID()) + if err != nil { + return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + } + var count uint64 + for _, t := range blk.Transactions() { + if t.Type() == tx.TypeEthDynamicFee { + count++ + } + } + return rpc.OkResponse(id, hexutil.Uint64(count)) +} + +func (h *Handler) ethGetBlockReceipts(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockTag]") + } + var tag string + if err := json.Unmarshal(params[0], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block tag") + } + + summary, err := rpc.ResolveBlockTag(tag, h.repo) + if err != nil { + return rpc.OkResponse(req.ID, nil) + } + blk, err := h.repo.GetBlock(summary.Header.ID()) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + receipts, err := h.repo.GetBlockReceipts(summary.Header.ID()) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + } + + blockHash := common.Hash(summary.Header.ID()) + blockNum := uint64(summary.Header.Number()) + baseFee := summary.Header.BaseFee() + + ethReceipts := make([]*rpc.EthReceipt, 0) + for i, t := range blk.Transactions() { + if t.Type() != tx.TypeEthDynamicFee { + continue + } + projIdx := rpc.ProjectedEthIndex(receipts, uint64(i)) + cumGas := rpc.CumulativeEthGasUsed(receipts, uint64(i)) + logOff := rpc.EthLogOffset(receipts, uint64(i)) + ethReceipts = append(ethReceipts, rpc.ToEthReceipt( + t, receipts[i], h.chainID, + blockHash, blockNum, + projIdx, cumGas, logOff, baseFee, + )) + } + return rpc.OkResponse(req.ID, ethReceipts) +} + +func (h *Handler) ethGetUncleCountByBlockHash(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash]") + } + var tag string + if err := json.Unmarshal(params[0], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") + } + return h.uncleCountByTag(req.ID, tag) +} + +func (h *Handler) ethGetUncleCountByBlockNumber(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber]") + } + var tag string + if err := json.Unmarshal(params[0], &tag); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") + } + return h.uncleCountByTag(req.ID, tag) +} + +func (h *Handler) uncleCountByTag(id json.RawMessage, tag string) rpc.Response { + if _, err := rpc.ResolveBlockTag(tag, h.repo); err != nil { + return rpc.OkResponse(id, nil) + } + return rpc.OkResponse(id, hexutil.Uint64(0)) +} diff --git a/rpc/blocks/handler_test.go b/rpc/blocks/handler_test.go index 76d130031e..1b2ec302ea 100644 --- a/rpc/blocks/handler_test.go +++ b/rpc/blocks/handler_test.go @@ -146,4 +146,79 @@ func TestBlocksHandler(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)) + }) } diff --git a/rpc/chain/handler.go b/rpc/chain/handler.go index dba714f086..09a8be590a 100644 --- a/rpc/chain/handler.go +++ b/rpc/chain/handler.go @@ -8,6 +8,7 @@ package chain import ( "strconv" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/vechain/thor/v2/chain" @@ -30,8 +31,11 @@ func New(repo *chain.Repository, chainID uint64, clientVersion string) *Handler func (h *Handler) Mount(s *rpc.Server) { s.Register("eth_chainId", h.ethChainID) s.Register("net_version", h.netVersion) + s.Register("net_listening", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, true) }) + s.Register("net_peerCount", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, hexutil.Uint64(0)) }) // TODO do we want to hook this up ? s.Register("web3_clientVersion", h.web3ClientVersion) s.Register("eth_blockNumber", h.ethBlockNumber) + s.Register("eth_coinbase", h.ethCoinbase) s.Register("eth_syncing", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) s.Register("eth_accounts", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, []string{}) }) s.Register("eth_mining", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) @@ -54,3 +58,7 @@ func (h *Handler) ethBlockNumber(req rpc.Request) rpc.Response { num := h.repo.BestBlockSummary().Header.Number() return rpc.OkResponse(req.ID, hexutil.Uint64(num)) } + +func (h *Handler) ethCoinbase(req rpc.Request) rpc.Response { + return rpc.OkResponse(req.ID, common.Address{}) +} diff --git a/rpc/chain/handler_test.go b/rpc/chain/handler_test.go index a67552aec8..df00c0cd8d 100644 --- a/rpc/chain/handler_test.go +++ b/rpc/chain/handler_test.go @@ -95,4 +95,25 @@ func TestChainHandler(t *testing.T) { 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) + }) } From 9d9d25facb8afd4478eaf0027494905c93abd11b Mon Sep 17 00:00:00 2001 From: otherview Date: Fri, 8 May 2026 17:12:28 +0100 Subject: [PATCH 14/20] Adding logs --- cmd/thor/httpserver/api_server.go | 2 +- rpc/blocks/handler_test.go | 71 +++++ rpc/eth_trie_test.go | 97 +++++++ rpc/eth_types.go | 43 +++- rpc/integration_test.go | 2 +- rpc/logs/handler.go | 111 ++++++-- rpc/logs/handler_test.go | 412 +++++++++++++++++++++++++++++- rpc/testutil/testutil.go | 33 +++ rpc/transactions/handler.go | 7 +- rpc/transactions/handler_test.go | 61 ++++- rpc/utils.go | 116 ++++++++- test/testnode/node.go | 2 +- 12 files changed, 889 insertions(+), 68 deletions(-) create mode 100644 rpc/eth_trie_test.go diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index c4dc7fa7ce..2f0e06d7a2 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -148,7 +148,7 @@ func StartAPIServer( rpcblocks.New(repo, chainID).Mount(rpcSrv) rpctransactions.New(repo, chainID, txPool).Mount(rpcSrv) rpcaccounts.New(repo, stater).Mount(rpcSrv) - rpclogs.New(repo, logDB, config.BacktraceLimit).Mount(rpcSrv) + rpclogs.New(repo, logDB, config.BacktraceLimit, config.LogsLimit).Mount(rpcSrv) rpcfees.New(repo, config.BacktraceLimit).Mount(rpcSrv) rpcsimulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(rpcSrv) router.PathPrefix("/rpc").Handler(rpcSrv) diff --git a/rpc/blocks/handler_test.go b/rpc/blocks/handler_test.go index 1b2ec302ea..64f4b0b5ee 100644 --- a/rpc/blocks/handler_test.go +++ b/rpc/blocks/handler_test.go @@ -7,17 +7,23 @@ 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 { @@ -222,3 +228,68 @@ func TestBlocksHandler(t *testing.T) { 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(), chainID)) + + 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/eth_trie_test.go b/rpc/eth_trie_test.go new file mode 100644 index 0000000000..a953868107 --- /dev/null +++ b/rpc/eth_trie_test.go @@ -0,0 +1,97 @@ +// 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/ethereum/go-ethereum/common" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + ethtypes "github.com/ethereum/go-ethereum/core/types" +) + +func TestEthLogsBloom_empty(t *testing.T) { + bloom := ethLogsBloom(nil) + require.Len(t, bloom, 256) + assert.Equal(t, make([]byte, 256), []byte(bloom)) + + bloom2 := ethLogsBloom([]*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 := &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([]*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 := &EthReceipt{ + Status: 1, + CumulativeGasUsed: 21000, + LogsBloom: bloom, + Logs: []*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/eth_types.go b/rpc/eth_types.go index 735d2e134a..e894c62574 100644 --- a/rpc/eth_types.go +++ b/rpc/eth_types.go @@ -25,12 +25,12 @@ type EthBlock struct { Nonce hexutil.Bytes `json:"nonce"` // Sha3Uncles is the empty uncle hash — VeChain has no uncles. Sha3Uncles common.Hash `json:"sha3Uncles"` - // TODO: compute from ETH tx logs once Phase 1 is complete. + // LogsBloom is the OR of all receipt blooms for ETH-typed transactions in this block. LogsBloom hexutil.Bytes `json:"logsBloom"` - // TODO: compute Merkle root over projected ETH transactions. + // TransactionsRoot is the Keccak256 MPT root over the projected ETH transaction list. TransactionsRoot common.Hash `json:"transactionsRoot"` StateRoot common.Hash `json:"stateRoot"` - // TODO: compute Merkle root over projected ETH receipts. + // 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"` @@ -53,13 +53,38 @@ type EthBlock struct { // there are no uncle blocks (always the case for VeChain). var emptyUncleHash = common.HexToHash("0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347") -// zeroLogsBloom is a 256-byte zero bloom filter returned as a placeholder. -// TODO: compute from ETH tx events once Phase 1 bloom computation is implemented. -var zeroLogsBloom = make(hexutil.Bytes, 256) - // 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 []*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 +} + // EthTx is the Ethereum JSON representation of a TypeEthTyped1559 transaction. type EthTx struct { BlockHash *common.Hash `json:"blockHash"` @@ -163,7 +188,7 @@ type EthReceipt struct { CumulativeGasUsed hexutil.Uint64 `json:"cumulativeGasUsed"` ContractAddress *common.Address `json:"contractAddress"` Logs []*EthLog `json:"logs"` - // TODO: compute bloom filter from ETH tx event 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"` @@ -258,7 +283,7 @@ func ToEthReceipt( CumulativeGasUsed: hexutil.Uint64(cumulativeGas), ContractAddress: contractAddress, Logs: logs, - LogsBloom: zeroLogsBloom, + LogsBloom: ethLogsBloom(logs), Status: status, Type: hexutil.Uint64(tx.TypeEthDynamicFee), EffectiveGasPrice: (*hexutil.Big)(effectiveGasPrice), diff --git a/rpc/integration_test.go b/rpc/integration_test.go index 2a39f6cf7a..ddf5a52280 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -57,7 +57,7 @@ func TestDispatch(t *testing.T) { blocks.New(c.Repo(), chainID).Mount(srv) transactions.New(c.Repo(), chainID, pool).Mount(srv) accounts.New(c.Repo(), c.Stater()).Mount(srv) - logs.New(c.Repo(), c.LogDB(), 100).Mount(srv) + logs.New(c.Repo(), c.LogDB(), 100, 1000).Mount(srv) fees.New(c.Repo(), 100).Mount(srv) simulation.New(c.Repo(), c.Stater(), &testchain.DefaultForkConfig, 1_000_000).Mount(srv) diff --git a/rpc/logs/handler.go b/rpc/logs/handler.go index c789a733fe..179ad7518b 100644 --- a/rpc/logs/handler.go +++ b/rpc/logs/handler.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "fmt" + "math" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -25,11 +26,12 @@ 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) *Handler { - return &Handler{repo: repo, logDB: logDB, backtrace: backtrace} +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. @@ -53,21 +55,27 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { } f := params[0] - bestChain := h.repo.NewBestChain() - bestNum := h.repo.BestBlockSummary().Header.Number() + // 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: single block identified by hash + // EIP-234: blockHash is mutually exclusive with fromBlock/toBlock. + if (f.FromBlock != nil && *f.FromBlock != "") || (f.ToBlock != nil && *f.ToBlock != "") { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "can't specify fromBlock/toBlock with blockHash") + } summary, err := rpc.ResolveBlockTag(*f.BlockHash, h.repo) if err != nil { - return rpc.OkResponse(req.ID, []*rpc.EthLog{}) + return rpc.ErrResponse(req.ID, rpc.CodeServerError, "unknown block") } fromNum = summary.Header.Number() toNum = summary.Header.Number() } else { - // Default range + // Per Ethereum spec, absent fromBlock and toBlock both default to "latest". + fromNum = bestNum toNum = bestNum if f.FromBlock != nil && *f.FromBlock != "" { @@ -88,7 +96,10 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { if toNum > bestNum { toNum = bestNum } - if toNum > fromNum && toNum-fromNum > h.backtrace { + if fromNum > toNum { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block range params") + } + if toNum-fromNum > h.backtrace { return rpc.ErrResponse(req.ID, rpc.CodeServerError, fmt.Sprintf("block range exceeds backtrace limit of %d", h.backtrace)) } } @@ -169,13 +180,19 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { } } + // 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: 10000}, + Options: &logdb.Options{Limit: queryLimit}, Order: logdb.ASC, } @@ -183,41 +200,83 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) } + if uint64(len(events)) > h.logsLimit { + return rpc.ErrResponse(req.ID, rpc.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) - // Post-filter: only return logs from TypeEthTyped1559 transactions. - // Cache tx type lookups per unique TxID to avoid redundant chain reads. - typeCache := make(map[thor.Bytes32]bool) - isEthTx := func(txID thor.Bytes32) bool { - if v, ok := typeCache[txID]; ok { - return v - } - t, _, err := bestChain.GetTransaction(txID) - ok2 := err == nil && t.Type() == tx.TypeEthDynamicFee - typeCache[txID] = ok2 - return ok2 + 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 for _, ev := range events { - if !isEthTx(ev.TxID) { + blockTxs, err := getBlockTxs(ev.BlockNumber) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.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 } - topics := make([]common.Hash, 0, 5) + + // 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 } - topics = append(topics, common.Hash(*tp)) + evTopics = append(evTopics, common.Hash(*tp)) } + ethLogs = append(ethLogs, &rpc.EthLog{ Address: common.Address(ev.Address), - Topics: topics, + Topics: evTopics, Data: ev.Data, BlockNumber: hexutil.Uint64(ev.BlockNumber), TxHash: common.Hash(ev.TxID), - TxIndex: hexutil.Uint64(ev.TxIndex), + TxIndex: hexutil.Uint64(projTxIdx), BlockHash: common.Hash(ev.BlockID), - LogIndex: hexutil.Uint64(ev.LogIndex), + LogIndex: hexutil.Uint64(logIdx), Removed: false, }) } diff --git a/rpc/logs/handler_test.go b/rpc/logs/handler_test.go index 1871cc19ec..bfab57324c 100644 --- a/rpc/logs/handler_test.go +++ b/rpc/logs/handler_test.go @@ -7,11 +7,16 @@ 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" @@ -38,15 +43,10 @@ func newFixture(t *testing.T) *fixture { func TestLogsHandler(t *testing.T) { fx := newFixture(t) - ts := testutil.NewTestServer(t, logs.New(fx.chain.Repo(), fx.chain.LogDB(), 100)) + 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 contract events. - // eth_getLogs therefore returns an empty array. - // - // TODO: extend with a contract-deploy tx that emits events so we - // can assert on non-empty log results (address filter, topic filter, EIP-234 - // blockHash filter, etc.). + // 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"}, }) @@ -73,3 +73,401 @@ func TestLogsHandler(t *testing.T) { 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)") +} diff --git a/rpc/testutil/testutil.go b/rpc/testutil/testutil.go index 2d540cb8ab..9a5d41a626 100644 --- a/rpc/testutil/testutil.go +++ b/rpc/testutil/testutil.go @@ -43,6 +43,39 @@ func BuildEthTx(t *testing.T, chainID uint64, sender genesis.DevAccount, nonce u 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() diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index 51d7ed57e2..8df21b49f9 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -142,23 +142,18 @@ func (h *Handler) txByBlockAndEthIndex(req rpc.Request, header *block.Header, id if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) } - receipts, err := h.repo.GetBlockReceipts(header.ID()) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) - } blockHash := common.Hash(header.ID()) blockNum := uint64(header.Number()) var projIdx uint64 - for i, t := range blk.Transactions() { + for _, t := range blk.Transactions() { if t.Type() != tx.TypeEthDynamicFee { continue } if projIdx == ethIdx { return rpc.OkResponse(req.ID, rpc.ToEthTx(t, h.chainID, blockHash, blockNum, projIdx, header.BaseFee())) } - _ = receipts[i] // bounds check projIdx++ } return rpc.OkResponse(req.ID, nil) diff --git a/rpc/transactions/handler_test.go b/rpc/transactions/handler_test.go index e239c3ab93..1fc24b9328 100644 --- a/rpc/transactions/handler_test.go +++ b/rpc/transactions/handler_test.go @@ -6,15 +6,19 @@ 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" @@ -152,6 +156,12 @@ func TestTransactionsHandler(t *testing.T) { 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) { @@ -182,7 +192,7 @@ func TestTransactionsHandler(t *testing.T) { rawBytes, err := freshTx.MarshalBinary() require.NoError(t, err) - result := testutil.Call(t, ts, "eth_sendRawTransaction", []any{"0x" + hexBytesToString(rawBytes)}) + 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) @@ -194,12 +204,45 @@ func TestTransactionsHandler(t *testing.T) { }) } -func hexBytesToString(b []byte) string { - const hextable = "0123456789abcdef" - buf := make([]byte, len(b)*2) - for i, v := range b { - buf[i*2] = hextable[v>>4] - buf[i*2+1] = hextable[v&0x0f] - } - return string(buf) +// 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(), chainID, 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/utils.go b/rpc/utils.go index 4d15e4dd38..0a3e3e619c 100644 --- a/rpc/utils.go +++ b/rpc/utils.go @@ -6,6 +6,7 @@ package rpc import ( + "bytes" "encoding/hex" "fmt" "strconv" @@ -13,12 +14,15 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rlp" "github.com/vechain/thor/v2/block" "github.com/vechain/thor/v2/chain" "github.com/vechain/thor/v2/state" "github.com/vechain/thor/v2/thor" "github.com/vechain/thor/v2/tx" + + ethtypes "github.com/ethereum/go-ethereum/core/types" ) // ResolveBlockTag maps an Ethereum block tag, hex block number, or block hash to @@ -102,9 +106,16 @@ func BuildEthBlock( blockHash := common.Hash(header.ID()) blockNum := uint64(header.Number()) - var ethTxHashes []common.Hash - var ethTxFull []*EthTx - var ethGasUsed uint64 + var ( + ethTxHashes []common.Hash + ethTxFull []*EthTx + ethGasUsed uint64 + ethProjIdx uint64 + logOffset uint64 + ethTxsForRoot []*tx.Transaction + ethRecsForRoot []*EthReceipt + blockBloom [256]byte + ) baseFee := header.BaseFee() @@ -112,13 +123,25 @@ func BuildEthBlock( if t.Type() != tx.TypeEthDynamicFee { continue } - projIdx := ProjectedEthIndex(receipts, uint64(i)) ethGasUsed += receipts[i].GasUsed + + rec := ToEthReceipt(t, receipts[i], chainID, 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, chainID, blockHash, blockNum, projIdx, baseFee)) + ethTxFull = append(ethTxFull, ToEthTx(t, chainID, blockHash, blockNum, ethProjIdx, baseFee)) } else { ethTxHashes = append(ethTxHashes, common.Hash(t.ID())) } + ethProjIdx++ } var transactions any @@ -145,10 +168,10 @@ func BuildEthBlock( ParentHash: common.Hash(header.ParentID()), Nonce: zeroNonce, Sha3Uncles: emptyUncleHash, - LogsBloom: zeroLogsBloom, - TransactionsRoot: common.Hash{}, // TODO: compute Merkle root over projected ETH txs + LogsBloom: blockBloom[:], + TransactionsRoot: ethTransactionsRoot(ethTxsForRoot), StateRoot: common.Hash(header.StateRoot()), - ReceiptsRoot: common.Hash{}, // TODO: compute Merkle root over projected ETH receipts + ReceiptsRoot: ethReceiptsRoot(ethRecsForRoot), Miner: common.Address(header.Beneficiary()), ExtraData: []byte{}, Size: hexutil.Uint64(blk.Size()), @@ -161,6 +184,83 @@ func BuildEthBlock( }, 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 EthReceipt as the EIP-2718 type-2 consensus bytes: +// 0x02 || RLP(status, cumulativeGasUsed, bloom, logs). +func ethReceiptWireBytes(rec *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 []*EthReceipt for use with ethtypes.DeriveSha. +type ethReceiptDerivableList []*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 []*EthReceipt) common.Hash { + return ethtypes.DeriveSha(ethReceiptDerivableList(recs)) +} + // ProjectedEthIndex returns the 0-based Ethereum transaction index for a TypeEthTyped1559 tx. // canonicalIdx is the tx's position counting all tx types in the block. func ProjectedEthIndex(receipts tx.Receipts, canonicalIdx uint64) uint64 { diff --git a/test/testnode/node.go b/test/testnode/node.go index c6e5456eed..58b18eca08 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -113,7 +113,7 @@ func (n *node) Start() error { rpcblocks.New(repo, chainID).Mount(rpcSrv) rpctransactions.New(repo, chainID, n.txPool).Mount(rpcSrv) rpcaccounts.New(repo, stater).Mount(rpcSrv) - rpclogs.New(repo, logDB, 100).Mount(rpcSrv) + rpclogs.New(repo, logDB, 100, 1000).Mount(rpcSrv) rpcfees.New(repo, 100).Mount(rpcSrv) rpcsimulation.New(repo, stater, &testchain.DefaultForkConfig, 1_000_000).Mount(rpcSrv) router.PathPrefix("/rpc").Handler(rpcSrv) From 6b67d2756a953c26c4f55dee035545ae1fef3913 Mon Sep 17 00:00:00 2001 From: otherview Date: Mon, 11 May 2026 15:30:21 +0100 Subject: [PATCH 15/20] Adding filters --- cmd/thor/httpserver/api_server.go | 4 + rpc/accounts/handler_test.go | 11 + rpc/filters/handler.go | 529 ++++++++++++++++++++++++++++++ rpc/filters/handler_test.go | 523 +++++++++++++++++++++++++++++ rpc/filters/ttl_test.go | 72 ++++ rpc/logs/handler.go | 15 +- rpc/simulation/handler.go | 3 + rpc/simulation/handler_test.go | 43 +++ rpc/utils.go | 10 + rpc/utils_test.go | 66 ++++ test/testnode/node.go | 4 + thorclient/rpc_test.go | 481 ++++++++++++++++++++++++--- 12 files changed, 1711 insertions(+), 50 deletions(-) create mode 100644 rpc/filters/handler.go create mode 100644 rpc/filters/handler_test.go create mode 100644 rpc/filters/ttl_test.go create mode 100644 rpc/utils_test.go diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index 2f0e06d7a2..63e01ac471 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -43,6 +43,7 @@ import ( 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" @@ -151,6 +152,8 @@ func StartAPIServer( rpclogs.New(repo, logDB, config.BacktraceLimit, config.LogsLimit).Mount(rpcSrv) rpcfees.New(repo, config.BacktraceLimit).Mount(rpcSrv) rpcsimulation.New(repo, stater, forkConfig, config.CallGasLimit).Mount(rpcSrv) + rpcFilters := rpcfilters.New(repo, txPool, config.BacktraceLimit) + rpcFilters.Mount(rpcSrv) router.PathPrefix("/rpc").Handler(rpcSrv) if config.PprofOn { @@ -193,6 +196,7 @@ func StartAPIServer( return "http://" + listener.Addr().String() + "/", func() { srv.Close() subs.Close() + rpcFilters.Close() goes.Wait() }, nil } diff --git a/rpc/accounts/handler_test.go b/rpc/accounts/handler_test.go index 52297c8749..e5b34711fb 100644 --- a/rpc/accounts/handler_test.go +++ b/rpc/accounts/handler_test.go @@ -14,6 +14,7 @@ import ( "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" @@ -87,4 +88,14 @@ func TestAccountsHandler(t *testing.T) { 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") + }) } diff --git a/rpc/filters/handler.go b/rpc/filters/handler.go new file mode 100644 index 0000000000..26d3453fbe --- /dev/null +++ b/rpc/filters/handler.go @@ -0,0 +1,529 @@ +// 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/thor" + "github.com/vechain/thor/v2/tx" + "github.com/vechain/thor/v2/txpool" +) + +const ( + filterTTL = 5 * time.Minute + ttlCheckInterval = time.Minute + pendingTxBufSize = 128 +) + +type filterKind int8 + +const ( + kindLog filterKind = iota + kindBlock + kindPendingTx +) + +// logCriteria is the parsed form of a log filter for fast per-event matching +// during incremental block scanning in eth_getFilterChanges. +// Only ETH-typed (TypeEthDynamicFee) transaction events are matched. +type logCriteria struct { + addresses []thor.Address + topics [5]*thor.Bytes32 +} + +func (c *logCriteria) matchesEvent(e *tx.Event) bool { + if len(c.addresses) > 0 { + found := false + for _, a := range c.addresses { + if a == e.Address { + found = true + break + } + } + if !found { + return false + } + } + for i, want := range c.topics { + if want == nil { + continue // wildcard + } + if i >= len(e.Topics) || e.Topics[i] != *want { + return false + } + } + return true +} + +type entry struct { + kind filterKind + lastPoll time.Time + + // 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.LogFilter + criteria 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 *rpc.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 rpc.Request) rpc.Response { + var params []rpc.LogFilter + if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterObject]") + } + f := params[0] + criteria, err := parseCriteria(f) + if err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + } + id := h.newID() + h.mu.Lock() + h.entries[id] = &entry{ + kind: kindLog, + lastPoll: time.Now(), + reader: h.repo.NewBlockReader(h.repo.BestBlockSummary().Header.ID()), + logFilter: f, + criteria: criteria, + } + h.mu.Unlock() + return rpc.OkResponse(req.ID, id) +} + +func (h *Handler) ethNewBlockFilter(req rpc.Request) rpc.Response { + id := h.newID() + h.mu.Lock() + h.entries[id] = &entry{ + kind: kindBlock, + lastPoll: time.Now(), + reader: h.repo.NewBlockReader(h.repo.BestBlockSummary().Header.ID()), + } + h.mu.Unlock() + return rpc.OkResponse(req.ID, id) +} + +func (h *Handler) ethNewPendingTransactionFilter(req rpc.Request) rpc.Response { + txCh := make(chan *txpool.TxEvent, pendingTxBufSize) + sub := h.txPool.SubscribeTxEvent(txCh) + id := h.newID() + h.mu.Lock() + h.entries[id] = &entry{ + kind: kindPendingTx, + lastPoll: time.Now(), + txCh: txCh, + txSub: sub, + } + h.mu.Unlock() + return rpc.OkResponse(req.ID, id) +} + +func (h *Handler) ethGetFilterChanges(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterId]") + } + var id string + if err := json.Unmarshal(params[0], &id); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid filter id") + } + + h.mu.Lock() + e, ok := h.entries[id] + if ok { + e.lastPoll = time.Now() + } + h.mu.Unlock() + + if !ok { + return rpc.ErrResponse(req.ID, rpc.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) rpc.Response { + // 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 rpc.ErrResponse(id, rpc.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 rpc.OkResponse(id, hashes) +} + +func (h *Handler) changesLog(id json.RawMessage, e *entry) rpc.Response { + // 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 rpc.ErrResponse(id, rpc.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 rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + } + logs := collectMatchingLogs(&e.criteria, blk.Transactions(), receipts, + common.Hash(blk.Header().ID()), uint64(blk.Header().Number())) + ethLogs = append(ethLogs, logs...) + } + } + return rpc.OkResponse(id, ethLogs) +} + +func (h *Handler) changesPendingTx(id json.RawMessage, e *entry) rpc.Response { + 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 rpc.OkResponse(id, hashes) +} + +func (h *Handler) ethGetFilterLogs(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterId]") + } + var id string + if err := json.Unmarshal(params[0], &id); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid filter id") + } + + h.mu.Lock() + e, ok := h.entries[id] + if ok { + e.lastPoll = time.Now() + } + h.mu.Unlock() + + if !ok { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "filter not found") + } + if e.kind != kindLog { + return rpc.ErrResponse(req.ID, rpc.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) rpc.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 := rpc.ResolveBlockTag(*f.FromBlock, h.repo) + if err != nil { + return rpc.ErrResponse(id, rpc.CodeInvalidParams, "invalid fromBlock") + } + fromNum = summary.Header.Number() + } + if f.ToBlock != nil && *f.ToBlock != "" { + summary, err := rpc.ResolveBlockTag(*f.ToBlock, h.repo) + if err != nil { + return rpc.ErrResponse(id, rpc.CodeInvalidParams, "invalid toBlock") + } + toNum = summary.Header.Number() + } + if toNum > bestNum { + toNum = bestNum + } + if fromNum > toNum { + return rpc.ErrResponse(id, rpc.CodeInvalidParams, "invalid block range") + } + if toNum-fromNum > h.backtrace { + return rpc.ErrResponse(id, rpc.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 rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + } + receipts, err := h.repo.GetBlockReceipts(blk.Header().ID()) + if err != nil { + return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + } + logs := collectMatchingLogs(&e.criteria, blk.Transactions(), receipts, + common.Hash(blk.Header().ID()), uint64(blk.Header().Number())) + ethLogs = append(ethLogs, logs...) + } + if ethLogs == nil { + ethLogs = []*rpc.EthLog{} + } + return rpc.OkResponse(id, ethLogs) +} + +func (h *Handler) ethUninstallFilter(req rpc.Request) rpc.Response { + var params [1]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterId]") + } + var id string + if err := json.Unmarshal(params[0], &id); err != nil { + return rpc.ErrResponse(req.ID, rpc.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 rpc.OkResponse(req.ID, ok) +} + +// collectMatchingLogs scans ETH-typed transactions in a single block and returns EthLog +// entries matching the criteria. Projected transactionIndex and logIndex are relative to +// ETH-typed transactions only, consistent with eth_getTransactionByHash etc. +func collectMatchingLogs(criteria *logCriteria, txs tx.Transactions, receipts tx.Receipts, blockHash common.Hash, blockNum uint64) []*rpc.EthLog { + var logs []*rpc.EthLog + var projEthIdx uint64 // running ETH tx index within the block + var projLogIdx uint64 // running ETH log index within the block (all ETH events, not just matching) + + 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: false, + }) + } + } + projLogIdx += uint64(len(receipt.Outputs[0].Events)) + } + projEthIdx++ + } + return logs +} + +// parseCriteria parses the address and topic fields from a LogFilter into a logCriteria. +// OR semantics within a single topic position are not fully supported — only the first +// alternative is used (e.g., [["A","B"], "C"] treats position 0 as matching only "A"). +func parseCriteria(f rpc.LogFilter) (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) + } + h32Copy := h32 + c.topics[i] = &h32Copy + } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { + h32, err := rpc.ParseBytes32Compact(multi[0]) + if err != nil { + return c, fmt.Errorf("invalid topic: %w", err) + } + h32Copy := h32 + c.topics[i] = &h32Copy + } + } + return c, nil +} diff --git a/rpc/filters/handler_test.go b/rpc/filters/handler_test.go new file mode 100644 index 0000000000..29718d5b49 --- /dev/null +++ b/rpc/filters/handler_test.go @@ -0,0 +1,523 @@ +// 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" + "github.com/vechain/thor/v2/rpc/filters" + "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, rpc.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, rpc.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) + + // Add an ETH tx to the pool. SubscribeTxEvent fires synchronously before + // AddLocal returns, so the hash is immediately available for polling. + ethTx := testutil.BuildEthTx(t, fx.chainID, sender, 10, &recipient.Address) + require.NoError(t, fx.pool.AddLocal(ethTx)) + + result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) + require.NoError(t, json.Unmarshal(result, &hashes)) + require.Len(t, hashes, 1) + assert.Equal(t, common.Hash(ethTx.ID()), hashes[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, rpc.CodeInvalidParams, rpcErr.Code) + }) + + t.Run("eth_getFilterLogs_unknown", func(t *testing.T) { + rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterLogs", []any{"0x9999"}) + assert.Equal(t, rpc.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, rpc.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) + }) +} 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/logs/handler.go b/rpc/logs/handler.go index 179ad7518b..aa3f3892a9 100644 --- a/rpc/logs/handler.go +++ b/rpc/logs/handler.go @@ -39,17 +39,8 @@ func (h *Handler) Mount(s *rpc.Server) { s.Register("eth_getLogs", h.ethGetLogs) } -// LogFilter mirrors the Ethereum eth_getLogs filter parameter. -type LogFilter 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 -} - func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { - var params []LogFilter + var params []rpc.LogFilter if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterObject]") } @@ -146,14 +137,14 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { var single string var multi []string if err := json.Unmarshal(raw, &single); err == nil { - h32, err := thor.ParseBytes32(single) + h32, err := rpc.ParseBytes32Compact(single) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") } h32Copy := h32 topicSlot[i] = &h32Copy } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { - h32, err := thor.ParseBytes32(multi[0]) + h32, err := rpc.ParseBytes32Compact(multi[0]) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") } diff --git a/rpc/simulation/handler.go b/rpc/simulation/handler.go index be8fc30447..2b86fda4f4 100644 --- a/rpc/simulation/handler.go +++ b/rpc/simulation/handler.go @@ -101,6 +101,9 @@ func (h *Handler) ethEstimateGas(req rpc.Request) rpc.Response { return rpc.ErrResponse(req.ID, rpc.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 rpc.OkResponse(req.ID, hexutil.Uint64(evmGasUsed+intrinsic)) } diff --git a/rpc/simulation/handler_test.go b/rpc/simulation/handler_test.go index dde7843bf3..8f11708d3f 100644 --- a/rpc/simulation/handler_test.go +++ b/rpc/simulation/handler_test.go @@ -7,13 +7,16 @@ 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" "github.com/vechain/thor/v2/rpc/simulation" "github.com/vechain/thor/v2/rpc/testutil" "github.com/vechain/thor/v2/test/testchain" @@ -96,4 +99,44 @@ func TestSimulationHandler(t *testing.T) { 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, rpc.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, rpc.CodeServerError, rpcErr.Code) + }) } diff --git a/rpc/utils.go b/rpc/utils.go index 0a3e3e619c..713a0c2dad 100644 --- a/rpc/utils.go +++ b/rpc/utils.go @@ -8,6 +8,7 @@ package rpc import ( "bytes" "encoding/hex" + "encoding/json" "fmt" "strconv" "strings" @@ -297,6 +298,15 @@ func EthLogOffset(receipts tx.Receipts, canonicalIdx uint64) uint64 { return offset } +// LogFilter mirrors the Ethereum eth_getLogs / eth_newFilter parameter object. +type LogFilter 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 +} + // 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. diff --git a/rpc/utils_test.go b/rpc/utils_test.go new file mode 100644 index 0000000000..f9a425dc11 --- /dev/null +++ b/rpc/utils_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/test/testnode/node.go b/test/testnode/node.go index 58b18eca08..aee03c81a2 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -33,6 +33,7 @@ import ( 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" @@ -116,11 +117,14 @@ func (n *node) Start() error { rpclogs.New(repo, logDB, 100, 1000).Mount(rpcSrv) rpcfees.New(repo, 100).Mount(rpcSrv) rpcsimulation.New(repo, stater, &testchain.DefaultForkConfig, 1_000_000).Mount(rpcSrv) + rpcFilters := rpcfilters.New(repo, n.txPool, 100) + rpcFilters.Mount(rpcSrv) router.PathPrefix("/rpc").Handler(rpcSrv) n.apiServer = httptest.NewServer(router) n.apiServerCloser = func() { subs.Close() + rpcFilters.Close() n.apiServer.Close() } return nil diff --git a/thorclient/rpc_test.go b/thorclient/rpc_test.go index 7b7a641839..e4114c01f4 100644 --- a/thorclient/rpc_test.go +++ b/thorclient/rpc_test.go @@ -12,15 +12,16 @@ import ( "strings" "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/test/testchain" - "github.com/vechain/thor/v2/test/testnode" - + "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" ) @@ -51,6 +52,9 @@ func TestEthRPC(t *testing.T) { 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] @@ -79,10 +83,28 @@ func TestEthRPC(t *testing.T) { 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) - require.NoError(t, testNode.Chain().MintBlock(ethTx2, ethTx3)) + // 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 ────────────────────────────────────────────────────────────── @@ -101,11 +123,69 @@ func TestEthRPC(t *testing.T) { }) t.Run("eth_blockNumber", func(t *testing.T) { - // Chain has genesis + 2 minted blocks. + // 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(2), uint64(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 ──────────────────────────────────────────────────────────────── @@ -122,7 +202,7 @@ func TestEthRPC(t *testing.T) { var hash string require.NoError(t, json.Unmarshal(block["hash"], &hash)) - assert.True(t, strings.EqualFold(block2.Header().ID().String(), 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 @@ -133,7 +213,7 @@ func TestEthRPC(t *testing.T) { 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{"latest", false}) + result := testutil.Call(t, testNode.APIServer(), "eth_getBlockByNumber", []any{"0x2", false}) var block map[string]json.RawMessage require.NoError(t, json.Unmarshal(result, &block)) @@ -149,7 +229,7 @@ func TestEthRPC(t *testing.T) { }) t.Run("eth_getBlockByNumber_full_tx", func(t *testing.T) { - // Full-tx mode: transactions array contains EthTx objectestNode.APIServer() not just hashes. + // 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)) @@ -173,24 +253,96 @@ func TestEthRPC(t *testing.T) { 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 ethTx map[string]json.RawMessage - require.NoError(t, json.Unmarshal(result, ðTx)) - require.NotNil(t, ethTx) + 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(ethTx["type"], &txType)) + require.NoError(t, json.Unmarshal(ethTxObj["type"], &txType)) assert.Equal(t, uint64(2), uint64(txType)) var hash string - require.NoError(t, json.Unmarshal(ethTx["hash"], &hash)) + 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(ethTx["from"], &from)) + require.NoError(t, json.Unmarshal(ethTxObj["from"], &from)) assert.True(t, strings.EqualFold(sender.Address.String(), from)) }) @@ -214,23 +366,23 @@ func TestEthRPC(t *testing.T) { t.Run("eth_getTransactionByBlockNumberAndIndex_block2_first", func(t *testing.T) { result := testutil.Call(t, testNode.APIServer(), "eth_getTransactionByBlockNumberAndIndex", []any{"0x2", "0x0"}) - var ethTx map[string]json.RawMessage - require.NoError(t, json.Unmarshal(result, ðTx)) - require.NotNil(t, ethTx) + 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(ethTx["hash"], &hash)) + 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 ethTx map[string]json.RawMessage - require.NoError(t, json.Unmarshal(result, ðTx)) - require.NotNil(t, ethTx) + 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(ethTx["hash"], &hash)) + require.NoError(t, json.Unmarshal(ethTxObj["hash"], &hash)) assert.True(t, strings.EqualFold(ethTx3.ID().String(), hash)) }) @@ -242,7 +394,7 @@ func TestEthRPC(t *testing.T) { var hash string require.NoError(t, json.Unmarshal(fetchEthTx["hash"], &hash)) - assert.True(t, strings.EqualFold(ethTx.ID().String(), hash)) + assert.True(t, strings.EqualFold(ethTx2.ID().String(), hash)) }) t.Run("eth_getTransactionReceipt_block1", func(t *testing.T) { @@ -318,6 +470,22 @@ func TestEthRPC(t *testing.T) { 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) { @@ -335,7 +503,7 @@ func TestEthRPC(t *testing.T) { }) t.Run("eth_feeHistory_two_blocks", func(t *testing.T) { - // blockCount=2, newestBlock="latest" → covers blocks 1 and 2. + // 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)) @@ -350,10 +518,10 @@ func TestEthRPC(t *testing.T) { require.NoError(t, json.Unmarshal(fh["gasUsedRatio"], &gasRatios)) assert.Len(t, gasRatios, 2) - // oldestBlock is block 1. + // 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(1), uint64(oldest)) + assert.Equal(t, uint64(2), uint64(oldest)) }) // ── Simulation ──────────────────────────────────────────────────────────── @@ -385,20 +553,119 @@ func TestEthRPC(t *testing.T) { 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_empty", func(t *testing.T) { - // All fixture txs are plain VET transfers — no contract events are emitted. - // TODO: extend with a contract-deploy tx that emits events to cover non-empty resultestNode.APIServer() - // address filter, topic filter, and EIP-234 blockHash filter. + 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"}, + 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) { @@ -428,20 +695,20 @@ func TestEthRPC(t *testing.T) { // 2. Read transaction: must be visible in the new block. result = testutil.Call(t, testNode.APIServer(), "eth_getTransactionByHash", []any{txHash}) - var ethTx map[string]json.RawMessage - require.NoError(t, json.Unmarshal(result, ðTx)) - require.NotNil(t, ethTx, "transaction should be found after mining") + 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(ethTx["hash"], &readHash)) + require.NoError(t, json.Unmarshal(ethTxObj["hash"], &readHash)) assert.True(t, strings.EqualFold(txHash, readHash)) var from string - require.NoError(t, json.Unmarshal(ethTx["from"], &from)) + 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(ethTx["type"], &txType)) + 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). @@ -462,4 +729,142 @@ func TestEthRPC(t *testing.T) { 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) + }) } From 2cb06c6dbbb9f887566bdaa9a34fa3ee201de433 Mon Sep 17 00:00:00 2001 From: otherview Date: Tue, 12 May 2026 17:22:42 +0100 Subject: [PATCH 16/20] updates --- cmd/thor/httpserver/api_server.go | 7 +++---- rpc/blocks/handler.go | 11 +++++------ rpc/blocks/handler_test.go | 9 +++------ rpc/chain/handler.go | 9 ++++----- rpc/chain/handler_test.go | 10 ++++------ rpc/integration_test.go | 9 ++++----- rpc/transactions/handler.go | 15 +++++++-------- rpc/transactions/handler_test.go | 8 +++----- test/testnode/node.go | 8 +++----- thorclient/rpc_test.go | 6 +++--- 10 files changed, 39 insertions(+), 53 deletions(-) diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index 63e01ac471..a79c103aa8 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -143,11 +143,10 @@ func StartAPIServer( subs.Mount(router, "/subscriptions") // Ethereum JSON-RPC at /rpc — body limit enforced internally by rpc.Server (2 MB via MaxBytesReader) - chainID := repo.ChainID() rpcSrv := rpc.NewServer() - rpcchain.New(repo, chainID, config.ClientVersion).Mount(rpcSrv) - rpcblocks.New(repo, chainID).Mount(rpcSrv) - rpctransactions.New(repo, chainID, txPool).Mount(rpcSrv) + 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).Mount(rpcSrv) diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go index 472781fd5f..96408cec7a 100644 --- a/rpc/blocks/handler.go +++ b/rpc/blocks/handler.go @@ -18,13 +18,12 @@ import ( // Handler implements block query JSON-RPC methods. type Handler struct { - repo *chain.Repository - chainID uint64 + repo *chain.Repository } // New creates a blocks Handler. -func New(repo *chain.Repository, chainID uint64) *Handler { - return &Handler{repo: repo, chainID: chainID} +func New(repo *chain.Repository) *Handler { + return &Handler{repo: repo} } // Mount registers all block query methods on the dispatcher. @@ -77,7 +76,7 @@ func (h *Handler) getBlockByTag(id json.RawMessage, tag string, fullTxs bool) rp if err != nil { return rpc.OkResponse(id, nil) } - blk, err := rpc.BuildEthBlock(summary.Header, h.repo, h.chainID, fullTxs) + blk, err := rpc.BuildEthBlock(summary.Header, h.repo, h.repo.ChainID(), fullTxs) if err != nil { return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) } @@ -162,7 +161,7 @@ func (h *Handler) ethGetBlockReceipts(req rpc.Request) rpc.Response { cumGas := rpc.CumulativeEthGasUsed(receipts, uint64(i)) logOff := rpc.EthLogOffset(receipts, uint64(i)) ethReceipts = append(ethReceipts, rpc.ToEthReceipt( - t, receipts[i], h.chainID, + t, receipts[i], h.repo.ChainID(), blockHash, blockNum, projIdx, cumGas, logOff, baseFee, )) diff --git a/rpc/blocks/handler_test.go b/rpc/blocks/handler_test.go index 64f4b0b5ee..e0837d4ca0 100644 --- a/rpc/blocks/handler_test.go +++ b/rpc/blocks/handler_test.go @@ -28,7 +28,6 @@ import ( type fixture struct { chain *testchain.Chain - chainID uint64 ethTxHash string blockHash string } @@ -38,19 +37,17 @@ func newFixture(t *testing.T) *fixture { 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) + 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, - chainID: chainID, ethTxHash: ethTx.ID().String(), blockHash: bestBlock.Header().ID().String(), } @@ -58,7 +55,7 @@ func newFixture(t *testing.T) *fixture { func TestBlocksHandler(t *testing.T) { fx := newFixture(t) - ts := testutil.NewTestServer(t, blocks.New(fx.chain.Repo(), fx.chainID)) + 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}) @@ -248,7 +245,7 @@ func TestBlocksBloomAndRoots(t *testing.T) { 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(), chainID)) + 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}) diff --git a/rpc/chain/handler.go b/rpc/chain/handler.go index 09a8be590a..1a15e22751 100644 --- a/rpc/chain/handler.go +++ b/rpc/chain/handler.go @@ -18,13 +18,12 @@ import ( // Handler implements chain metadata JSON-RPC methods. type Handler struct { repo *chain.Repository - chainID uint64 clientVersion string } // New creates a chain Handler. -func New(repo *chain.Repository, chainID uint64, clientVersion string) *Handler { - return &Handler{repo: repo, chainID: chainID, clientVersion: clientVersion} +func New(repo *chain.Repository, clientVersion string) *Handler { + return &Handler{repo: repo, clientVersion: clientVersion} } // Mount registers all chain metadata methods on the dispatcher. @@ -43,11 +42,11 @@ func (h *Handler) Mount(s *rpc.Server) { } func (h *Handler) ethChainID(req rpc.Request) rpc.Response { - return rpc.OkResponse(req.ID, hexutil.Uint64(h.chainID)) + return rpc.OkResponse(req.ID, hexutil.Uint64(h.repo.ChainID())) } func (h *Handler) netVersion(req rpc.Request) rpc.Response { - return rpc.OkResponse(req.ID, strconv.FormatUint(h.chainID, 10)) + return rpc.OkResponse(req.ID, strconv.FormatUint(h.repo.ChainID(), 10)) } func (h *Handler) web3ClientVersion(req rpc.Request) rpc.Response { diff --git a/rpc/chain/handler_test.go b/rpc/chain/handler_test.go index df00c0cd8d..ea4b1ae440 100644 --- a/rpc/chain/handler_test.go +++ b/rpc/chain/handler_test.go @@ -19,8 +19,7 @@ import ( ) type fixture struct { - chain *testchain.Chain - chainID uint64 + chain *testchain.Chain } func newFixture(t *testing.T) *fixture { @@ -30,20 +29,19 @@ func newFixture(t *testing.T) *fixture { require.NoError(t, c.MintBlock()) return &fixture{ - chain: c, - chainID: c.Repo().ChainID(), + chain: c, } } func TestChainHandler(t *testing.T) { fx := newFixture(t) - ts := testutil.NewTestServer(t, chain.New(fx.chain.Repo(), fx.chainID, "test/1.0")) + 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.chainID, uint64(got)) + assert.Equal(t, fx.chain.ChainID(), uint64(got)) }) t.Run("net_version", func(t *testing.T) { diff --git a/rpc/integration_test.go b/rpc/integration_test.go index ddf5a52280..7c9cf8ffb6 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -37,12 +37,11 @@ func TestDispatch(t *testing.T) { 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) + 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()) @@ -53,9 +52,9 @@ func TestDispatch(t *testing.T) { MaxLifetime: 10 * time.Minute, }, &testchain.DefaultForkConfig) srv := rpc.NewServer() - rpcchain.New(c.Repo(), chainID, "test/1.0").Mount(srv) - blocks.New(c.Repo(), chainID).Mount(srv) - transactions.New(c.Repo(), chainID, pool).Mount(srv) + 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).Mount(srv) diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index 8df21b49f9..51ce7e3698 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -21,14 +21,13 @@ import ( // Handler implements transaction JSON-RPC methods. type Handler struct { - repo *chain.Repository - chainID uint64 - txPool txpool.Pool + repo *chain.Repository + txPool txpool.Pool } // New creates a transactions Handler. -func New(repo *chain.Repository, chainID uint64, txPool txpool.Pool) *Handler { - return &Handler{repo: repo, chainID: chainID, txPool: txPool} +func New(repo *chain.Repository, txPool txpool.Pool) *Handler { + return &Handler{repo: repo, txPool: txPool} } // Mount registers all transaction methods on the dispatcher. @@ -86,7 +85,7 @@ func (h *Handler) ethGetTransactionByHash(req rpc.Request) rpc.Response { projIdx := rpc.ProjectedEthIndex(ctx.receipts, ctx.meta.Index) return rpc.OkResponse( req.ID, - rpc.ToEthTx(ctx.transaction, h.chainID, common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), projIdx, ctx.header.BaseFee()), + rpc.ToEthTx(ctx.transaction, h.repo.ChainID(), common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), projIdx, ctx.header.BaseFee()), ) } @@ -152,7 +151,7 @@ func (h *Handler) txByBlockAndEthIndex(req rpc.Request, header *block.Header, id continue } if projIdx == ethIdx { - return rpc.OkResponse(req.ID, rpc.ToEthTx(t, h.chainID, blockHash, blockNum, projIdx, header.BaseFee())) + return rpc.OkResponse(req.ID, rpc.ToEthTx(t, h.repo.ChainID(), blockHash, blockNum, projIdx, header.BaseFee())) } projIdx++ } @@ -183,7 +182,7 @@ func (h *Handler) ethGetTransactionReceipt(req rpc.Request) rpc.Response { logOff := rpc.EthLogOffset(ctx.receipts, ctx.meta.Index) return rpc.OkResponse(req.ID, rpc.ToEthReceipt( - ctx.transaction, receipt, h.chainID, + ctx.transaction, receipt, h.repo.ChainID(), common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), projIdx, cumGas, logOff, ctx.header.BaseFee(), )) diff --git a/rpc/transactions/handler_test.go b/rpc/transactions/handler_test.go index 1fc24b9328..33801464b7 100644 --- a/rpc/transactions/handler_test.go +++ b/rpc/transactions/handler_test.go @@ -30,7 +30,6 @@ import ( type fixture struct { chain *testchain.Chain - chainID uint64 ethTxHash string vcTxHash string blockHash string @@ -51,7 +50,6 @@ func newFixture(t *testing.T) *fixture { require.NoError(t, err) return &fixture{ chain: c, - chainID: chainID, ethTxHash: ethTx.ID().String(), vcTxHash: vcTx.ID().String(), blockHash: bestBlock.Header().ID().String(), @@ -65,7 +63,7 @@ func TestTransactionsHandler(t *testing.T) { LimitPerAccount: 16, MaxLifetime: 10 * time.Minute, }, &testchain.DefaultForkConfig) - ts := testutil.NewTestServer(t, transactions.New(fx.chain.Repo(), fx.chainID, pool)) + ts := testutil.NewTestServer(t, transactions.New(fx.chain.Repo(), pool)) // ---- eth_getTransactionByHash ---- @@ -178,7 +176,7 @@ func TestTransactionsHandler(t *testing.T) { freshRecipient := genesis.DevAccounts()[3].Address unsigned := tx.NewBuilder(tx.TypeEthDynamicFee). - ChainID(fx.chainID). + ChainID(fx.chain.ChainID()). Nonce(0). MaxPriorityFeePerGas(big.NewInt(thor.InitialBaseFee)). MaxFeePerGas(big.NewInt(2 * thor.InitialBaseFee)). @@ -228,7 +226,7 @@ func TestTransactionReceiptBloom(t *testing.T) { LimitPerAccount: 16, MaxLifetime: 10 * time.Minute, }, &testchain.DefaultForkConfig) - ts := testutil.NewTestServer(t, transactions.New(c.Repo(), chainID, pool)) + 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 diff --git a/test/testnode/node.go b/test/testnode/node.go index aee03c81a2..a95a8c707f 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -86,7 +86,6 @@ func (n *node) Start() error { logDB := n.chain.LogDB() forkConfig := n.chain.GetForkConfig() engine := bft.NewMockedEngine(repo.GenesisBlock().Header().ID()) - chainID := repo.ChainID() accounts.New(repo, stater, 40_000_000, 5*1024*1024/2, forkConfig, engine, true).Mount(router, "/accounts") events.New(repo, logDB, 1000, 10).Mount(router, "/logs/event") @@ -108,11 +107,10 @@ func (n *node) Start() error { subs := subscriptions.New(repo, []string{"*"}, 1000, n.txPool, true) subs.Mount(router, "/subscriptions") - // pool := testutil.DefaultPool(t, c, &testchain.DefaultForkConfig) rpcSrv := rpc.NewServer() - rpcchain.New(repo, chainID, "test/1.0").Mount(rpcSrv) - rpcblocks.New(repo, chainID).Mount(rpcSrv) - rpctransactions.New(repo, chainID, n.txPool).Mount(rpcSrv) + 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).Mount(rpcSrv) diff --git a/thorclient/rpc_test.go b/thorclient/rpc_test.go index e4114c01f4..34ae97c76a 100644 --- a/thorclient/rpc_test.go +++ b/thorclient/rpc_test.go @@ -26,8 +26,8 @@ import ( "github.com/vechain/thor/v2/tx" ) -// newEthRPCFixture builds the two-block chain and assembles all ETH RPC handlers. -func newEthRPCFixture(t *testing.T) testnode.Node { +// 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() @@ -41,7 +41,7 @@ func newEthRPCFixture(t *testing.T) testnode.Node { } func TestEthRPC(t *testing.T) { - testNode := newEthRPCFixture(t) + testNode := setupEthTestNode(t) sender := genesis.DevAccounts()[0] recipient := genesis.DevAccounts()[1] From 6a2931fb141eef7afb3630653e98d8ade04d39de Mon Sep 17 00:00:00 2001 From: otherview Date: Wed, 13 May 2026 16:04:58 +0100 Subject: [PATCH 17/20] rpc: remove chainID from BuildEthBlock/ToEthReceipt; add TODOs chainID is now sourced from repo.ChainID() internally in BuildEthBlock and is not needed in ToEthReceipt (receipts carry no chainId field). Also adds TODO comments flagging known gaps in gasUsedRatio, eth_getLogs performance, topic OR-filtering, and ethSendRawTransaction guarantees. Co-Authored-By: Claude Sonnet 4.6 --- rpc/blocks/handler.go | 4 ++-- rpc/eth_types.go | 1 - rpc/fees/handler.go | 2 ++ rpc/logs/handler.go | 2 ++ rpc/transactions/handler.go | 4 +++- rpc/utils.go | 7 ++++--- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go index 96408cec7a..c9ae8c4747 100644 --- a/rpc/blocks/handler.go +++ b/rpc/blocks/handler.go @@ -76,7 +76,7 @@ func (h *Handler) getBlockByTag(id json.RawMessage, tag string, fullTxs bool) rp if err != nil { return rpc.OkResponse(id, nil) } - blk, err := rpc.BuildEthBlock(summary.Header, h.repo, h.repo.ChainID(), fullTxs) + blk, err := rpc.BuildEthBlock(summary.Header, h.repo, fullTxs) if err != nil { return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) } @@ -161,7 +161,7 @@ func (h *Handler) ethGetBlockReceipts(req rpc.Request) rpc.Response { cumGas := rpc.CumulativeEthGasUsed(receipts, uint64(i)) logOff := rpc.EthLogOffset(receipts, uint64(i)) ethReceipts = append(ethReceipts, rpc.ToEthReceipt( - t, receipts[i], h.repo.ChainID(), + t, receipts[i], blockHash, blockNum, projIdx, cumGas, logOff, baseFee, )) diff --git a/rpc/eth_types.go b/rpc/eth_types.go index e894c62574..f3fdf833ad 100644 --- a/rpc/eth_types.go +++ b/rpc/eth_types.go @@ -206,7 +206,6 @@ type EthReceipt struct { func ToEthReceipt( t *tx.Transaction, receipt *tx.Receipt, - chainID uint64, blockHash common.Hash, blockNum uint64, projectedIdx uint64, diff --git a/rpc/fees/handler.go b/rpc/fees/handler.go index 76fc104bf5..bb09776f06 100644 --- a/rpc/fees/handler.go +++ b/rpc/fees/handler.go @@ -100,6 +100,8 @@ func (h *Handler) ethFeeHistory(req rpc.Request) rpc.Response { gasUsedRatios := make([]float64, 0, blockCount) for n := oldestNum; n <= newestNum; n++ { + // TODO need a decision here: hdr.GasUsed() includes all transaction types (VeChain Legacy + EthDynamicFee). + // TODO For ETH equivalence, gasUsedRatio should only count gas from TypeEth transactions. hdr, err := bestChain.GetBlockHeader(uint32(n)) if err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) diff --git a/rpc/logs/handler.go b/rpc/logs/handler.go index aa3f3892a9..547182b300 100644 --- a/rpc/logs/handler.go +++ b/rpc/logs/handler.go @@ -222,6 +222,8 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { } var ethLogs []*rpc.EthLog + // TODO the log scanning loop iterates blocks and calls GetBlock() for each. A large block range with many blocks could block the RPC connection + // Is this perfomant at all ? for _, ev := range events { blockTxs, err := getBlockTxs(ev.BlockNumber) if err != nil { diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index 51ce7e3698..8b3476aee2 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -182,7 +182,7 @@ func (h *Handler) ethGetTransactionReceipt(req rpc.Request) rpc.Response { logOff := rpc.EthLogOffset(ctx.receipts, ctx.meta.Index) return rpc.OkResponse(req.ID, rpc.ToEthReceipt( - ctx.transaction, receipt, h.repo.ChainID(), + ctx.transaction, receipt, common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), projIdx, cumGas, logOff, ctx.header.BaseFee(), )) @@ -202,8 +202,10 @@ func (h *Handler) ethSendRawTransaction(req rpc.Request) rpc.Response { if err := parsed.UnmarshalBinary(raw); err != nil { return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) } + // TODO is Adding to the pool enough guarantee for ethereum styled txs ? if err := h.txPool.AddLocal(parsed); err != nil { return rpc.ErrResponse(req.ID, rpc.CodeServerError, err.Error()) } + return rpc.OkResponse(req.ID, common.Hash(parsed.ID()).Hex()) } diff --git a/rpc/utils.go b/rpc/utils.go index 713a0c2dad..91bf765c36 100644 --- a/rpc/utils.go +++ b/rpc/utils.go @@ -36,6 +36,7 @@ import ( // Hash strings (66 chars, "0x" + 64 hex digits): resolved directly by hash. // // "pending", "safe", and "finalized" are treated as "latest" in Phase 1. +// TODO decide if should this return error or not found (not as an error) func ResolveBlockTag(tag string, repo *chain.Repository) (*chain.BlockSummary, error) { switch strings.ToLower(tag) { case "", "latest", "pending", "safe", "finalized": @@ -91,7 +92,6 @@ func StateAt(tag string, repo *chain.Repository, stater *state.Stater) (*state.S func BuildEthBlock( header *block.Header, repo *chain.Repository, - chainID uint64, fullTxs bool, ) (*EthBlock, error) { blk, err := repo.GetBlock(header.ID()) @@ -126,7 +126,8 @@ func BuildEthBlock( } ethGasUsed += receipts[i].GasUsed - rec := ToEthReceipt(t, receipts[i], chainID, blockHash, blockNum, ethProjIdx, ethGasUsed, logOffset, baseFee) + // TODO we might no need to call this if fullTxs=true or false + 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. @@ -138,7 +139,7 @@ func BuildEthBlock( ethRecsForRoot = append(ethRecsForRoot, rec) if fullTxs { - ethTxFull = append(ethTxFull, ToEthTx(t, chainID, blockHash, blockNum, ethProjIdx, baseFee)) + ethTxFull = append(ethTxFull, ToEthTx(t, repo.ChainID(), blockHash, blockNum, ethProjIdx, baseFee)) } else { ethTxHashes = append(ethTxHashes, common.Hash(t.ID())) } From 580e32bcc38bf3f2ed98cafede79c21a06abf6db Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 14 May 2026 21:38:15 +0100 Subject: [PATCH 18/20] reworked rpc/ to be type only + added websockets --- cmd/thor/httpserver/api_server.go | 18 +- rpc/accounts/handler.go | 116 +++----- rpc/accounts/handler_test.go | 8 + rpc/accounts_types.go | 84 ++++++ rpc/blocks/handler.go | 146 ++++------ rpc/blocks_types.go | 48 ++++ rpc/chain/handler.go | 39 +-- rpc/eth_types.go | 197 +------------ rpc/{ => ethconvert}/eth_trie_test.go | 14 +- rpc/ethconvert/eth_types.go | 186 ++++++++++++ rpc/ethconvert/log_criteria.go | 157 +++++++++++ rpc/{ => ethconvert}/utils.go | 85 +++--- rpc/fees/handler.go | 116 ++++---- rpc/fees/handler_test.go | 38 ++- rpc/fees_types.go | 58 ++++ rpc/filters/handler.go | 238 ++++------------ rpc/filters/handler_test.go | 73 ++++- rpc/integration_test.go | 26 +- rpc/{ => jsonrpc}/server.go | 14 +- rpc/{ => jsonrpc}/types.go | 2 +- rpc/logs/handler.go | 134 +++++---- rpc/logs/handler_test.go | 55 ++++ rpc/logs_types.go | 17 ++ rpc/parse.go | 36 +++ rpc/{utils_test.go => parse_test.go} | 0 rpc/simulation/handler.go | 101 +++---- rpc/simulation/handler_test.go | 24 +- rpc/simulation_types.go | 50 ++++ rpc/testutil/testutil.go | 16 +- rpc/transactions/handler.go | 143 ++++------ rpc/transactions_types.go | 87 ++++++ rpc/ws/conn.go | 324 +++++++++++++++++++++ rpc/ws/handler.go | 106 +++++++ rpc/ws/handler_test.go | 392 ++++++++++++++++++++++++++ rpc/ws/subscriptions.go | 124 ++++++++ test/testnode/node.go | 11 +- thorclient/rpc_test.go | 102 +++++++ 37 files changed, 2471 insertions(+), 914 deletions(-) create mode 100644 rpc/accounts_types.go create mode 100644 rpc/blocks_types.go rename rpc/{ => ethconvert}/eth_trie_test.go (93%) create mode 100644 rpc/ethconvert/eth_types.go create mode 100644 rpc/ethconvert/log_criteria.go rename rpc/{ => ethconvert}/utils.go (80%) create mode 100644 rpc/fees_types.go rename rpc/{ => jsonrpc}/server.go (86%) rename rpc/{ => jsonrpc}/types.go (99%) create mode 100644 rpc/logs_types.go create mode 100644 rpc/parse.go rename rpc/{utils_test.go => parse_test.go} (100%) create mode 100644 rpc/simulation_types.go create mode 100644 rpc/transactions_types.go create mode 100644 rpc/ws/conn.go create mode 100644 rpc/ws/handler.go create mode 100644 rpc/ws/handler_test.go create mode 100644 rpc/ws/subscriptions.go diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index a79c103aa8..268e650a49 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -34,7 +34,7 @@ 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" + "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" @@ -47,6 +47,7 @@ import ( 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") @@ -142,18 +143,22 @@ 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 rpc.Server (2 MB via MaxBytesReader) - rpcSrv := rpc.NewServer() + // 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).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) - router.PathPrefix("/rpc").Handler(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, config.BacktraceLimit, origins, rpcSrv) + router.PathPrefix("/rpc").Handler(rpcWs) if config.PprofOn { router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) @@ -164,7 +169,7 @@ func StartAPIServer( } // middlewares - // /rpc owns its body limit inside rpc.Server; skip the REST 200 KB cap for that path. + // /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)) @@ -196,6 +201,7 @@ func StartAPIServer( srv.Close() subs.Close() rpcFilters.Close() + rpcWs.Close() goes.Wait() }, nil } diff --git a/rpc/accounts/handler.go b/rpc/accounts/handler.go index ab3c487895..e534b36fbe 100644 --- a/rpc/accounts/handler.go +++ b/rpc/accounts/handler.go @@ -7,15 +7,15 @@ package accounts import ( "encoding/json" - "fmt" "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" - "github.com/vechain/thor/v2/thor" ) // Handler implements account state JSON-RPC methods. @@ -30,112 +30,74 @@ func New(repo *chain.Repository, stater *state.Stater) *Handler { } // Mount registers all account state methods on the dispatcher. -func (h *Handler) Mount(s *rpc.Server) { +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 rpc.Request) rpc.Response { - addr, tag, err := parseAddrAndTag(req.Params) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) +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 := rpc.StateAt(tag, h.repo, h.stater) + st, err := ethconvert.StateAt(params.Tag, h.repo, h.stater) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - bal, err := st.GetBalance(addr) + bal, err := st.GetBalance(params.Address) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - return rpc.OkResponse(req.ID, (*hexutil.Big)(bal)) + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(bal)) } -func (h *Handler) ethGetCode(req rpc.Request) rpc.Response { - addr, tag, err := parseAddrAndTag(req.Params) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) +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 := rpc.StateAt(tag, h.repo, h.stater) + st, err := ethconvert.StateAt(params.Tag, h.repo, h.stater) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - code, err := st.GetCode(addr) + code, err := st.GetCode(params.Address) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - return rpc.OkResponse(req.ID, hexutil.Bytes(code)) + return jsonrpc.OkResponse(req.ID, hexutil.Bytes(code)) } -func (h *Handler) ethGetStorageAt(req rpc.Request) rpc.Response { - var params [3]json.RawMessage +func (h *Handler) ethGetStorageAt(req jsonrpc.Request) jsonrpc.Response { + var params rpc.StorageAtParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [address, slot, blockTag]") - } - var addrStr, slotStr, tag string - if err := json.Unmarshal(params[0], &addrStr); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address") - } - if err := json.Unmarshal(params[1], &slotStr); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid slot") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - if err := json.Unmarshal(params[2], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block tag") - } - - addr, err := thor.ParseAddress(addrStr) + st, err := ethconvert.StateAt(params.Tag, h.repo, h.stater) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - slot, err := rpc.ParseBytes32Compact(slotStr) + val, err := st.GetStorage(params.Address, params.Slot) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid slot") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - st, err := rpc.StateAt(tag, h.repo, h.stater) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) - } - val, err := st.GetStorage(addr, slot) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) - } - return rpc.OkResponse(req.ID, common.Hash(val)) + return jsonrpc.OkResponse(req.ID, common.Hash(val)) } -func (h *Handler) ethGetTransactionCount(req rpc.Request) rpc.Response { - addr, tag, err := parseAddrAndTag(req.Params) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) - } - // NOTE: "pending" returns the confirmed nonce; pool scanning is not implemented. - st, err := rpc.StateAt(tag, h.repo, h.stater) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) +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()) } - nonce, err := st.GetNonce(addr) + // 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 rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) - } - return rpc.OkResponse(req.ID, hexutil.Uint64(nonce)) -} - -func parseAddrAndTag(raw json.RawMessage) (thor.Address, string, error) { - var params [2]json.RawMessage - if err := json.Unmarshal(raw, ¶ms); err != nil { - return thor.Address{}, "", fmt.Errorf("expected [address, blockTag]") - } - var addrStr, tag string - if err := json.Unmarshal(params[0], &addrStr); err != nil { - return thor.Address{}, "", fmt.Errorf("invalid address") - } - if err := json.Unmarshal(params[1], &tag); err != nil { - return thor.Address{}, "", fmt.Errorf("invalid block tag") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - addr, err := thor.ParseAddress(addrStr) + nonce, err := st.GetNonce(params.Address) if err != nil { - return thor.Address{}, "", fmt.Errorf("invalid address: %w", err) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } - return addr, tag, nil + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(nonce)) } diff --git a/rpc/accounts/handler_test.go b/rpc/accounts/handler_test.go index e5b34711fb..21af573ccf 100644 --- a/rpc/accounts/handler_test.go +++ b/rpc/accounts/handler_test.go @@ -98,4 +98,12 @@ func TestAccountsHandler(t *testing.T) { 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 index c9ae8c4747..e326cc7de0 100644 --- a/rpc/blocks/handler.go +++ b/rpc/blocks/handler.go @@ -13,6 +13,8 @@ import ( "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" ) @@ -27,7 +29,7 @@ func New(repo *chain.Repository) *Handler { } // Mount registers all block query methods on the dispatcher. -func (h *Handler) Mount(s *rpc.Server) { +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) @@ -35,86 +37,62 @@ func (h *Handler) Mount(s *rpc.Server) { s.Register("eth_getBlockReceipts", h.ethGetBlockReceipts) s.Register("eth_getUncleCountByBlockHash", h.ethGetUncleCountByBlockHash) s.Register("eth_getUncleCountByBlockNumber", h.ethGetUncleCountByBlockNumber) - s.Register("eth_getUncleByBlockHashAndIndex", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, nil) }) - s.Register("eth_getUncleByBlockNumberAndIndex", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, nil) }) + 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 rpc.Request) rpc.Response { - var params [2]json.RawMessage +func (h *Handler) ethGetBlockByHash(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockQueryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash, fullTransactions]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - var hashStr string - if err := json.Unmarshal(params[0], &hashStr); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") - } - var fullTxs bool - if err := json.Unmarshal(params[1], &fullTxs); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fullTransactions flag") - } - return h.getBlockByTag(req.ID, hashStr, fullTxs) + return h.getBlockByTag(req.ID, params.Tag, params.FullTxs) } -func (h *Handler) ethGetBlockByNumber(req rpc.Request) rpc.Response { - var params [2]json.RawMessage +func (h *Handler) ethGetBlockByNumber(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockQueryParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber, fullTransactions]") - } - var tag string - if err := json.Unmarshal(params[0], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - var fullTxs bool - if err := json.Unmarshal(params[1], &fullTxs); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fullTransactions flag") - } - return h.getBlockByTag(req.ID, tag, fullTxs) + return h.getBlockByTag(req.ID, params.Tag, params.FullTxs) } -func (h *Handler) getBlockByTag(id json.RawMessage, tag string, fullTxs bool) rpc.Response { - summary, err := rpc.ResolveBlockTag(tag, h.repo) +func (h *Handler) getBlockByTag(id json.RawMessage, tag string, fullTxs bool) jsonrpc.Response { + summary, err := ethconvert.ResolveBlockTag(tag, h.repo) if err != nil { - return rpc.OkResponse(id, nil) + return jsonrpc.OkResponse(id, nil) } - blk, err := rpc.BuildEthBlock(summary.Header, h.repo, fullTxs) + blk, err := ethconvert.BuildEthBlock(summary.Header, h.repo, fullTxs) if err != nil { - return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) } - return rpc.OkResponse(id, blk) + return jsonrpc.OkResponse(id, blk) } -func (h *Handler) ethGetBlockTransactionCountByHash(req rpc.Request) rpc.Response { - var params [1]json.RawMessage +func (h *Handler) ethGetBlockTransactionCountByHash(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash]") - } - var tag string - if err := json.Unmarshal(params[0], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - return h.txCountByTag(req.ID, tag) + return h.txCountByTag(req.ID, params.Tag) } -func (h *Handler) ethGetBlockTransactionCountByNumber(req rpc.Request) rpc.Response { - var params [1]json.RawMessage +func (h *Handler) ethGetBlockTransactionCountByNumber(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - var tag string - if err := json.Unmarshal(params[0], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") - } - return h.txCountByTag(req.ID, tag) + return h.txCountByTag(req.ID, params.Tag) } -func (h *Handler) txCountByTag(id json.RawMessage, tag string) rpc.Response { - summary, err := rpc.ResolveBlockTag(tag, h.repo) +func (h *Handler) txCountByTag(id json.RawMessage, tag string) jsonrpc.Response { + summary, err := ethconvert.ResolveBlockTag(tag, h.repo) if err != nil { - return rpc.OkResponse(id, nil) + return jsonrpc.OkResponse(id, nil) } blk, err := h.repo.GetBlock(summary.Header.ID()) if err != nil { - return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) } var count uint64 for _, t := range blk.Transactions() { @@ -122,30 +100,26 @@ func (h *Handler) txCountByTag(id json.RawMessage, tag string) rpc.Response { count++ } } - return rpc.OkResponse(id, hexutil.Uint64(count)) + return jsonrpc.OkResponse(id, hexutil.Uint64(count)) } -func (h *Handler) ethGetBlockReceipts(req rpc.Request) rpc.Response { - var params [1]json.RawMessage +func (h *Handler) ethGetBlockReceipts(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockTag]") - } - var tag string - if err := json.Unmarshal(params[0], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block tag") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - summary, err := rpc.ResolveBlockTag(tag, h.repo) + summary, err := ethconvert.ResolveBlockTag(params.Tag, h.repo) if err != nil { - return rpc.OkResponse(req.ID, nil) + return jsonrpc.OkResponse(req.ID, nil) } blk, err := h.repo.GetBlock(summary.Header.ID()) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } receipts, err := h.repo.GetBlockReceipts(summary.Header.ID()) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } blockHash := common.Hash(summary.Header.ID()) @@ -157,45 +131,37 @@ func (h *Handler) ethGetBlockReceipts(req rpc.Request) rpc.Response { if t.Type() != tx.TypeEthDynamicFee { continue } - projIdx := rpc.ProjectedEthIndex(receipts, uint64(i)) - cumGas := rpc.CumulativeEthGasUsed(receipts, uint64(i)) - logOff := rpc.EthLogOffset(receipts, uint64(i)) - ethReceipts = append(ethReceipts, rpc.ToEthReceipt( + projIdx := ethconvert.ProjectedEthIndex(receipts, uint64(i)) + cumGas := ethconvert.CumulativeEthGasUsed(receipts, uint64(i)) + logOff := ethconvert.EthLogOffset(receipts, uint64(i)) + ethReceipts = append(ethReceipts, ethconvert.ToEthReceipt( t, receipts[i], blockHash, blockNum, projIdx, cumGas, logOff, baseFee, )) } - return rpc.OkResponse(req.ID, ethReceipts) + return jsonrpc.OkResponse(req.ID, ethReceipts) } -func (h *Handler) ethGetUncleCountByBlockHash(req rpc.Request) rpc.Response { - var params [1]json.RawMessage +func (h *Handler) ethGetUncleCountByBlockHash(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - var tag string - if err := json.Unmarshal(params[0], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") - } - return h.uncleCountByTag(req.ID, tag) + return h.uncleCountByTag(req.ID, params.Tag) } -func (h *Handler) ethGetUncleCountByBlockNumber(req rpc.Request) rpc.Response { - var params [1]json.RawMessage +func (h *Handler) ethGetUncleCountByBlockNumber(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber]") - } - var tag string - if err := json.Unmarshal(params[0], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - return h.uncleCountByTag(req.ID, tag) + return h.uncleCountByTag(req.ID, params.Tag) } -func (h *Handler) uncleCountByTag(id json.RawMessage, tag string) rpc.Response { - if _, err := rpc.ResolveBlockTag(tag, h.repo); err != nil { - return rpc.OkResponse(id, nil) +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 rpc.OkResponse(id, hexutil.Uint64(0)) + return jsonrpc.OkResponse(id, hexutil.Uint64(0)) } 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 index 1a15e22751..41188ae5a6 100644 --- a/rpc/chain/handler.go +++ b/rpc/chain/handler.go @@ -12,7 +12,7 @@ import ( "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/jsonrpc" ) // Handler implements chain metadata JSON-RPC methods. @@ -27,37 +27,40 @@ func New(repo *chain.Repository, clientVersion string) *Handler { } // Mount registers all chain metadata methods on the dispatcher. -func (h *Handler) Mount(s *rpc.Server) { +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 rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, true) }) - s.Register("net_peerCount", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, hexutil.Uint64(0)) }) // TODO do we want to hook this up ? + 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 rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) - s.Register("eth_accounts", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, []string{}) }) - s.Register("eth_mining", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, false) }) - s.Register("eth_hashrate", func(req rpc.Request) rpc.Response { return rpc.OkResponse(req.ID, "0x0") }) + 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 rpc.Request) rpc.Response { - return rpc.OkResponse(req.ID, hexutil.Uint64(h.repo.ChainID())) +func (h *Handler) ethChainID(req jsonrpc.Request) jsonrpc.Response { + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(h.repo.ChainID())) } -func (h *Handler) netVersion(req rpc.Request) rpc.Response { - return rpc.OkResponse(req.ID, strconv.FormatUint(h.repo.ChainID(), 10)) +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 rpc.Request) rpc.Response { - return rpc.OkResponse(req.ID, "Thor/"+h.clientVersion) +func (h *Handler) web3ClientVersion(req jsonrpc.Request) jsonrpc.Response { + return jsonrpc.OkResponse(req.ID, "Thor/"+h.clientVersion) } -func (h *Handler) ethBlockNumber(req rpc.Request) rpc.Response { +func (h *Handler) ethBlockNumber(req jsonrpc.Request) jsonrpc.Response { num := h.repo.BestBlockSummary().Header.Number() - return rpc.OkResponse(req.ID, hexutil.Uint64(num)) + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(num)) } -func (h *Handler) ethCoinbase(req rpc.Request) rpc.Response { - return rpc.OkResponse(req.ID, common.Address{}) +func (h *Handler) ethCoinbase(req jsonrpc.Request) jsonrpc.Response { + return jsonrpc.OkResponse(req.ID, common.Address{}) } diff --git a/rpc/eth_types.go b/rpc/eth_types.go index f3fdf833ad..5dfbce3ce4 100644 --- a/rpc/eth_types.go +++ b/rpc/eth_types.go @@ -6,17 +6,12 @@ package rpc 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/tx" ) // EthBlock is the Ethereum JSON representation of a block. -// Only TypeEthTyped1559 transactions are included in the transactions field. +// Only TypeEthDynamicFee transactions are included in the transactions field. type EthBlock struct { Number hexutil.Uint64 `json:"number"` Hash common.Hash `json:"hash"` @@ -39,7 +34,7 @@ type EthBlock struct { ExtraData hexutil.Bytes `json:"extraData"` Size hexutil.Uint64 `json:"size"` GasLimit hexutil.Uint64 `json:"gasLimit"` - // GasUsed is the sum of gas used by TypeEthTyped1559 transactions only. + // 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). @@ -49,43 +44,7 @@ type EthBlock struct { Uncles []common.Hash `json:"uncles"` } -// 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 []*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 -} - -// EthTx is the Ethereum JSON representation of a TypeEthTyped1559 transaction. +// EthTx is the Ethereum JSON representation of a TypeEthDynamicFee transaction. type EthTx struct { BlockHash *common.Hash `json:"blockHash"` BlockNumber *hexutil.Uint64 `json:"blockNumber"` @@ -107,62 +66,6 @@ type EthTx struct { S *hexutil.Big `json:"s"` } -// ToEthTx converts a TypeEthTyped1559 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) *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 - - // effectiveGasPrice = min(maxFeePerGas, baseFee + maxPriorityFeePerGas) - // Fall back to maxFeePerGas when baseFee is unavailable (pre-GALACTICA blocks). - maxFee := t.MaxFeePerGas() - gasPrice := new(big.Int).Set(maxFee) - if baseFee != nil { - effective := new(big.Int).Add(baseFee, t.MaxPriorityFeePerGas()) - if effective.Cmp(gasPrice) < 0 { - gasPrice = effective - } - } - - num := hexutil.Uint64(blockNum) - idx := hexutil.Uint64(projectedIdx) - bh := blockHash - - return &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), - } -} - // EthLog is the Ethereum JSON representation of a contract event log. type EthLog struct { Address common.Address `json:"address"` @@ -176,7 +79,7 @@ type EthLog struct { Removed bool `json:"removed"` } -// EthReceipt is the Ethereum JSON representation of a TypeEthTyped1559 transaction receipt. +// EthReceipt is the Ethereum JSON representation of a TypeEthDynamicFee transaction receipt. type EthReceipt struct { TransactionHash common.Hash `json:"transactionHash"` TransactionIndex hexutil.Uint64 `json:"transactionIndex"` @@ -196,95 +99,3 @@ type EthReceipt struct { Type hexutil.Uint64 `json:"type"` EffectiveGasPrice *hexutil.Big `json:"effectiveGasPrice"` } - -// ToEthReceipt builds an Ethereum receipt for a TypeEthTyped1559 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, -) *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 - } - - maxFee := t.MaxFeePerGas() - effectiveGasPrice := new(big.Int).Set(maxFee) - if baseFee != nil { - effective := new(big.Int).Add(baseFee, t.MaxPriorityFeePerGas()) - if effective.Cmp(effectiveGasPrice) < 0 { - effectiveGasPrice = effective - } - } - - txHash := common.Hash(t.ID()) - txIdx := hexutil.Uint64(projectedIdx) - - var logs []*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, &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 = []*EthLog{} - } - - return &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/eth_trie_test.go b/rpc/ethconvert/eth_trie_test.go similarity index 93% rename from rpc/eth_trie_test.go rename to rpc/ethconvert/eth_trie_test.go index a953868107..095223cb61 100644 --- a/rpc/eth_trie_test.go +++ b/rpc/ethconvert/eth_trie_test.go @@ -3,7 +3,7 @@ // Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying // file LICENSE or -package rpc +package ethconvert import ( "testing" @@ -14,6 +14,8 @@ import ( "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) { @@ -21,7 +23,7 @@ func TestEthLogsBloom_empty(t *testing.T) { require.Len(t, bloom, 256) assert.Equal(t, make([]byte, 256), []byte(bloom)) - bloom2 := ethLogsBloom([]*EthLog{}) + bloom2 := ethLogsBloom([]*rpc.EthLog{}) assert.Equal(t, make([]byte, 256), []byte(bloom2)) } @@ -36,7 +38,7 @@ func TestEthLogsBloom_crossCheck(t *testing.T) { fromTopic := common.BytesToHash(fromAddr.Bytes()) toTopic := common.BytesToHash(toAddr.Bytes()) - ethLog := &EthLog{ + ethLog := &rpc.EthLog{ Address: contractAddr, Topics: []common.Hash{transferTopic, fromTopic, toTopic}, Data: []byte{}, @@ -53,7 +55,7 @@ func TestEthLogsBloom_crossCheck(t *testing.T) { b := gethBin.Bytes() copy(expected[256-len(b):], b) - got := ethLogsBloom([]*EthLog{ethLog}) + 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. @@ -78,11 +80,11 @@ func TestEthReceiptWireBytes(t *testing.T) { bloom := make([]byte, 256) bloom[255] = 0x01 // one non-zero bit - rec := &EthReceipt{ + rec := &rpc.EthReceipt{ Status: 1, CumulativeGasUsed: 21000, LogsBloom: bloom, - Logs: []*EthLog{}, + Logs: []*rpc.EthLog{}, } b := ethReceiptWireBytes(rec) 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..ad28f5be7f --- /dev/null +++ b/rpc/ethconvert/log_criteria.go @@ -0,0 +1,157 @@ +// 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" + + "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 { + found := false + for _, a := range c.Addresses { + if a == e.Address { + found = true + break + } + } + if !found { + return false + } + } + for i, alts := range c.Topics { + if len(alts) == 0 { + continue // wildcard + } + if i >= len(e.Topics) { + return false + } + matched := false + for _, want := range alts { + if e.Topics[i] == want { + matched = true + break + } + } + if !matched { + 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/utils.go b/rpc/ethconvert/utils.go similarity index 80% rename from rpc/utils.go rename to rpc/ethconvert/utils.go index 91bf765c36..d6b29dad3b 100644 --- a/rpc/utils.go +++ b/rpc/ethconvert/utils.go @@ -3,13 +3,16 @@ // Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying // file LICENSE or -package rpc +// 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" - "encoding/json" "fmt" + "math/big" "strconv" "strings" @@ -17,13 +20,14 @@ import ( "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" - - ethtypes "github.com/ethereum/go-ethereum/core/types" ) // ResolveBlockTag maps an Ethereum block tag, hex block number, or block hash to @@ -36,7 +40,8 @@ import ( // Hash strings (66 chars, "0x" + 64 hex digits): resolved directly by hash. // // "pending", "safe", and "finalized" are treated as "latest" in Phase 1. -// TODO decide if should this return error or not found (not as an error) +// 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": @@ -87,13 +92,13 @@ func StateAt(tag string, repo *chain.Repository, stater *state.Stater) (*state.S return stater.NewState(summary.Root()), nil } -// BuildEthBlock constructs an EthBlock from a VeChain block header. -// Only TypeEthTyped1559 transactions are included in the transactions field. +// 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, -) (*EthBlock, error) { +) (*rpc.EthBlock, error) { blk, err := repo.GetBlock(header.ID()) if err != nil { return nil, err @@ -109,12 +114,12 @@ func BuildEthBlock( var ( ethTxHashes []common.Hash - ethTxFull []*EthTx + ethTxFull []*rpc.EthTx ethGasUsed uint64 ethProjIdx uint64 logOffset uint64 ethTxsForRoot []*tx.Transaction - ethRecsForRoot []*EthReceipt + ethRecsForRoot []*rpc.EthReceipt blockBloom [256]byte ) @@ -126,7 +131,6 @@ func BuildEthBlock( } ethGasUsed += receipts[i].GasUsed - // TODO we might no need to call this if fullTxs=true or false rec := ToEthReceipt(t, receipts[i], blockHash, blockNum, ethProjIdx, ethGasUsed, logOffset, baseFee) logOffset += uint64(len(rec.Logs)) @@ -149,7 +153,7 @@ func BuildEthBlock( var transactions any if fullTxs { if ethTxFull == nil { - ethTxFull = []*EthTx{} + ethTxFull = []*rpc.EthTx{} } transactions = ethTxFull } else { @@ -164,7 +168,7 @@ func BuildEthBlock( baseFeePerGas = (*hexutil.Big)(baseFee) } - return &EthBlock{ + return &rpc.EthBlock{ Number: hexutil.Uint64(blockNum), Hash: blockHash, ParentHash: common.Hash(header.ParentID()), @@ -201,9 +205,9 @@ type rlpReceiptBody struct { Logs []rlpLogEntry } -// ethReceiptWireBytes encodes an EthReceipt as the EIP-2718 type-2 consensus bytes: +// ethReceiptWireBytes encodes an rpc.EthReceipt as the EIP-2718 type-2 consensus bytes: // 0x02 || RLP(status, cumulativeGasUsed, bloom, logs). -func ethReceiptWireBytes(rec *EthReceipt) []byte { +func ethReceiptWireBytes(rec *rpc.EthReceipt) []byte { status := []byte{0x01} if rec.Status == 0 { status = []byte{} @@ -245,8 +249,8 @@ func (l ethTxDerivableList) GetRlp(i int) []byte { return b } -// ethReceiptDerivableList wraps []*EthReceipt for use with ethtypes.DeriveSha. -type ethReceiptDerivableList []*EthReceipt +// 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]) } @@ -259,11 +263,11 @@ func ethTransactionsRoot(txs []*tx.Transaction) common.Hash { // ethReceiptsRoot computes the Ethereum Keccak256 MPT root over the EIP-1559 // encoded consensus bytes of the given ETH receipts. -func ethReceiptsRoot(recs []*EthReceipt) common.Hash { +func ethReceiptsRoot(recs []*rpc.EthReceipt) common.Hash { return ethtypes.DeriveSha(ethReceiptDerivableList(recs)) } -// ProjectedEthIndex returns the 0-based Ethereum transaction index for a TypeEthTyped1559 tx. +// 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 @@ -275,7 +279,7 @@ func ProjectedEthIndex(receipts tx.Receipts, canonicalIdx uint64) uint64 { return count } -// CumulativeEthGasUsed returns the cumulative gas used by TypeEthTyped1559 transactions +// 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 @@ -287,7 +291,7 @@ func CumulativeEthGasUsed(receipts tx.Receipts, canonicalIdx uint64) uint64 { return total } -// EthLogOffset returns the number of logs emitted by TypeEthTyped1559 transactions +// 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 @@ -299,34 +303,17 @@ func EthLogOffset(receipts tx.Receipts, canonicalIdx uint64) uint64 { return offset } -// LogFilter mirrors the Ethereum eth_getLogs / eth_newFilter parameter object. -type LogFilter 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 -} - -// 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) +// 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) } - if len(b) > 32 { - return thor.Bytes32{}, fmt.Errorf("hex value too long for bytes32 %q", s) + effective := new(big.Int).Add(baseFee, maxPriority) + if effective.Cmp(maxFee) < 0 { + return effective } - var h32 thor.Bytes32 - copy(h32[32-len(b):], b) - return h32, nil + return new(big.Int).Set(maxFee) } diff --git a/rpc/fees/handler.go b/rpc/fees/handler.go index bb09776f06..8414a808cd 100644 --- a/rpc/fees/handler.go +++ b/rpc/fees/handler.go @@ -12,99 +12,87 @@ import ( "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 + repo *chain.Repository + backtrace uint32 + forkConfig *thor.ForkConfig } // New creates a fees Handler. -func New(repo *chain.Repository, backtrace uint32) *Handler { - return &Handler{repo: repo, backtrace: backtrace} +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 *rpc.Server) { +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 rpc.Request) rpc.Response { +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 rpc.OkResponse(req.ID, (*hexutil.Big)(tip)) + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(tip)) } price := new(big.Int).Add(baseFee, tip) - return rpc.OkResponse(req.ID, (*hexutil.Big)(price)) + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(price)) } -func (h *Handler) ethMaxPriorityFeePerGas(req rpc.Request) rpc.Response { +func (h *Handler) ethMaxPriorityFeePerGas(req jsonrpc.Request) jsonrpc.Response { // TODO: derive from on-chain params contract once available. - return rpc.OkResponse(req.ID, (*hexutil.Big)(big.NewInt(1e9))) + return jsonrpc.OkResponse(req.ID, (*hexutil.Big)(big.NewInt(1e9))) } -func (h *Handler) ethFeeHistory(req rpc.Request) rpc.Response { - var params []json.RawMessage - if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 2 { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockCount, newestBlock, rewardPercentiles]") +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()) } - blockCountRaw := params[0] - var newestRaw string - if err := json.Unmarshal(params[1], &newestRaw); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid newestBlock") + // Reward percentiles are not yet supported. + if len(params.RewardPercentiles) > 0 { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, "reward percentiles are not yet supported") } - // Parse block count (may be hex string or integer) - var blockCount uint64 - var s string - if err := json.Unmarshal(blockCountRaw, &s); err == nil { - n, err := hexutil.DecodeUint64(s) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid blockCount") - } - blockCount = n - } else { - if err := json.Unmarshal(blockCountRaw, &blockCount); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid blockCount") - } + if params.BlockCount == 0 { + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "blockCount must be > 0") } - - if blockCount == 0 { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "blockCount must be > 0") - } - if blockCount > uint64(h.backtrace) { - blockCount = uint64(h.backtrace) + if params.BlockCount > uint64(h.backtrace) { + params.BlockCount = uint64(h.backtrace) } - newestSummary, err := rpc.ResolveBlockTag(newestRaw, h.repo) + newestSummary, err := ethconvert.ResolveBlockTag(params.NewestBlock, h.repo) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } newestNum := uint64(newestSummary.Header.Number()) - if blockCount > newestNum+1 { - blockCount = newestNum + 1 + if params.BlockCount > newestNum+1 { + params.BlockCount = newestNum + 1 } - oldestNum := newestNum - blockCount + 1 + oldestNum := newestNum - params.BlockCount + 1 bestChain := h.repo.NewBestChain() - baseFees := make([]*hexutil.Big, 0, blockCount+1) - gasUsedRatios := make([]float64, 0, blockCount) + baseFees := make([]*hexutil.Big, 0, params.BlockCount+1) + gasUsedRatios := make([]float64, 0, params.BlockCount) for n := oldestNum; n <= newestNum; n++ { - // TODO need a decision here: hdr.GasUsed() includes all transaction types (VeChain Legacy + EthDynamicFee). - // TODO For ETH equivalence, gasUsedRatio should only count gas from TypeEth transactions. hdr, err := bestChain.GetBlockHeader(uint32(n)) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } bf := hdr.BaseFee() if bf == nil { @@ -112,26 +100,36 @@ func (h *Handler) ethFeeHistory(req rpc.Request) rpc.Response { } 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(hdr.GasUsed()) / float64(hdr.GasLimit()) + ratio = float64(ethGasUsed) / float64(hdr.GasLimit()) } gasUsedRatios = append(gasUsedRatios, ratio) } - // Include the next block's baseFee (the block after newestBlock). - // We use the newestBlock's baseFee as an approximation since we don't compute the next one. - baseFees = append(baseFees, baseFees[len(baseFees)-1]) - - type feeHistoryResult struct { - OldestBlock hexutil.Uint64 `json:"oldestBlock"` - BaseFeePerGas []*hexutil.Big `json:"baseFeePerGas"` - GasUsedRatio []float64 `json:"gasUsedRatio"` - Reward [][]any `json:"reward"` + + // Compute the true next-block baseFee using the consensus formula. + nextBaseFee := galactica.CalcBaseFee(newestSummary.Header, h.forkConfig) + if nextBaseFee == nil { + nextBaseFee = new(big.Int) } - return rpc.OkResponse(req.ID, feeHistoryResult{ + baseFees = append(baseFees, (*hexutil.Big)(nextBaseFee)) + + return jsonrpc.OkResponse(req.ID, rpc.FeeHistoryResult{ OldestBlock: hexutil.Uint64(oldestNum), BaseFeePerGas: baseFees, GasUsedRatio: gasUsedRatios, - Reward: [][]any{}, }) } diff --git a/rpc/fees/handler_test.go b/rpc/fees/handler_test.go index 131eaa254e..6dd72efe5f 100644 --- a/rpc/fees/handler_test.go +++ b/rpc/fees/handler_test.go @@ -13,6 +13,7 @@ import ( "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" @@ -33,7 +34,7 @@ func newFixture(t *testing.T) *fixture { func TestFeesHandler(t *testing.T) { fx := newFixture(t) - ts := testutil.NewTestServer(t, fees.New(fx.chain.Repo(), 100)) + 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. @@ -76,4 +77,39 @@ func TestFeesHandler(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 index 26d3453fbe..c418a5a1bf 100644 --- a/rpc/filters/handler.go +++ b/rpc/filters/handler.go @@ -18,7 +18,8 @@ import ( "github.com/vechain/thor/v2/chain" "github.com/vechain/thor/v2/rpc" - "github.com/vechain/thor/v2/thor" + "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" ) @@ -37,41 +38,13 @@ const ( kindPendingTx ) -// logCriteria is the parsed form of a log filter for fast per-event matching -// during incremental block scanning in eth_getFilterChanges. -// Only ETH-typed (TypeEthDynamicFee) transaction events are matched. -type logCriteria struct { - addresses []thor.Address - topics [5]*thor.Bytes32 -} - -func (c *logCriteria) matchesEvent(e *tx.Event) bool { - if len(c.addresses) > 0 { - found := false - for _, a := range c.addresses { - if a == e.Address { - found = true - break - } - } - if !found { - return false - } - } - for i, want := range c.topics { - if want == nil { - continue // wildcard - } - if i >= len(e.Topics) || e.Topics[i] != *want { - return false - } - } - return true -} - 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. @@ -85,8 +58,8 @@ type entry struct { // 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.LogFilter - criteria logCriteria + logFilter rpc.EthLogFilter + criteria ethconvert.LogCriteria // kindPendingTx only. // Only executable ETH-typed transactions are reported; see eth_newPendingTransactionFilter. @@ -134,7 +107,7 @@ func (h *Handler) Close() { } // Mount registers all filter methods on the dispatcher. -func (h *Handler) Mount(s *rpc.Server) { +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) @@ -174,15 +147,15 @@ func (h *Handler) evictExpired() { } } -func (h *Handler) ethNewFilter(req rpc.Request) rpc.Response { - var params []rpc.LogFilter +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 rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterObject]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterObject]") } f := params[0] - criteria, err := parseCriteria(f) + criteria, err := ethconvert.ParseLogCriteria(f) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } id := h.newID() h.mu.Lock() @@ -194,10 +167,10 @@ func (h *Handler) ethNewFilter(req rpc.Request) rpc.Response { criteria: criteria, } h.mu.Unlock() - return rpc.OkResponse(req.ID, id) + return jsonrpc.OkResponse(req.ID, id) } -func (h *Handler) ethNewBlockFilter(req rpc.Request) rpc.Response { +func (h *Handler) ethNewBlockFilter(req jsonrpc.Request) jsonrpc.Response { id := h.newID() h.mu.Lock() h.entries[id] = &entry{ @@ -206,10 +179,10 @@ func (h *Handler) ethNewBlockFilter(req rpc.Request) rpc.Response { reader: h.repo.NewBlockReader(h.repo.BestBlockSummary().Header.ID()), } h.mu.Unlock() - return rpc.OkResponse(req.ID, id) + return jsonrpc.OkResponse(req.ID, id) } -func (h *Handler) ethNewPendingTransactionFilter(req rpc.Request) rpc.Response { +func (h *Handler) ethNewPendingTransactionFilter(req jsonrpc.Request) jsonrpc.Response { txCh := make(chan *txpool.TxEvent, pendingTxBufSize) sub := h.txPool.SubscribeTxEvent(txCh) id := h.newID() @@ -221,17 +194,17 @@ func (h *Handler) ethNewPendingTransactionFilter(req rpc.Request) rpc.Response { txSub: sub, } h.mu.Unlock() - return rpc.OkResponse(req.ID, id) + return jsonrpc.OkResponse(req.ID, id) } -func (h *Handler) ethGetFilterChanges(req rpc.Request) rpc.Response { +func (h *Handler) ethGetFilterChanges(req jsonrpc.Request) jsonrpc.Response { var params [1]json.RawMessage if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterId]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterId]") } var id string if err := json.Unmarshal(params[0], &id); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid filter id") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid filter id") } h.mu.Lock() @@ -242,7 +215,7 @@ func (h *Handler) ethGetFilterChanges(req rpc.Request) rpc.Response { h.mu.Unlock() if !ok { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "filter not found") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "filter not found") } switch e.kind { @@ -255,13 +228,15 @@ func (h *Handler) ethGetFilterChanges(req rpc.Request) rpc.Response { } } -func (h *Handler) changesBlock(id json.RawMessage, e *entry) rpc.Response { +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 rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) } if len(blocks) == 0 { break @@ -273,16 +248,18 @@ func (h *Handler) changesBlock(id json.RawMessage, e *entry) rpc.Response { hashes = append(hashes, common.Hash(blk.Header().ID())) } } - return rpc.OkResponse(id, hashes) + return jsonrpc.OkResponse(id, hashes) } -func (h *Handler) changesLog(id json.RawMessage, e *entry) rpc.Response { +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 rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) } if len(blocks) == 0 { break @@ -293,17 +270,19 @@ func (h *Handler) changesLog(id json.RawMessage, e *entry) rpc.Response { } receipts, err := h.repo.GetBlockReceipts(blk.Header().ID()) if err != nil { - return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) } - logs := collectMatchingLogs(&e.criteria, blk.Transactions(), receipts, - common.Hash(blk.Header().ID()), uint64(blk.Header().Number())) + logs := ethconvert.CollectMatchingLogs(&e.criteria, blk.Transactions(), receipts, + common.Hash(blk.Header().ID()), uint64(blk.Header().Number()), false) ethLogs = append(ethLogs, logs...) } } - return rpc.OkResponse(id, ethLogs) + return jsonrpc.OkResponse(id, ethLogs) } -func (h *Handler) changesPendingTx(id json.RawMessage, e *entry) rpc.Response { +func (h *Handler) changesPendingTx(id json.RawMessage, e *entry) jsonrpc.Response { + e.mu.Lock() + defer e.mu.Unlock() var hashes []common.Hash drain: for { @@ -319,17 +298,17 @@ drain: if hashes == nil { hashes = []common.Hash{} } - return rpc.OkResponse(id, hashes) + return jsonrpc.OkResponse(id, hashes) } -func (h *Handler) ethGetFilterLogs(req rpc.Request) rpc.Response { +func (h *Handler) ethGetFilterLogs(req jsonrpc.Request) jsonrpc.Response { var params [1]json.RawMessage if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterId]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterId]") } var id string if err := json.Unmarshal(params[0], &id); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid filter id") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid filter id") } h.mu.Lock() @@ -340,10 +319,10 @@ func (h *Handler) ethGetFilterLogs(req rpc.Request) rpc.Response { h.mu.Unlock() if !ok { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "filter not found") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "filter not found") } if e.kind != kindLog { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "eth_getFilterLogs is only valid for log filters") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "eth_getFilterLogs is only valid for log filters") } return h.queryFilterLogs(req.ID, e) } @@ -356,7 +335,7 @@ func (h *Handler) ethGetFilterLogs(req rpc.Request) rpc.Response { // // 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) rpc.Response { +func (h *Handler) queryFilterLogs(id json.RawMessage, e *entry) jsonrpc.Response { f := e.logFilter bestNum := h.repo.BestBlockSummary().Header.Number() bestChain := h.repo.NewBestChain() @@ -366,16 +345,16 @@ func (h *Handler) queryFilterLogs(id json.RawMessage, e *entry) rpc.Response { toNum := bestNum if f.FromBlock != nil && *f.FromBlock != "" { - summary, err := rpc.ResolveBlockTag(*f.FromBlock, h.repo) + summary, err := ethconvert.ResolveBlockTag(*f.FromBlock, h.repo) if err != nil { - return rpc.ErrResponse(id, rpc.CodeInvalidParams, "invalid fromBlock") + return jsonrpc.ErrResponse(id, jsonrpc.CodeInvalidParams, "invalid fromBlock") } fromNum = summary.Header.Number() } if f.ToBlock != nil && *f.ToBlock != "" { - summary, err := rpc.ResolveBlockTag(*f.ToBlock, h.repo) + summary, err := ethconvert.ResolveBlockTag(*f.ToBlock, h.repo) if err != nil { - return rpc.ErrResponse(id, rpc.CodeInvalidParams, "invalid toBlock") + return jsonrpc.ErrResponse(id, jsonrpc.CodeInvalidParams, "invalid toBlock") } toNum = summary.Header.Number() } @@ -383,10 +362,10 @@ func (h *Handler) queryFilterLogs(id json.RawMessage, e *entry) rpc.Response { toNum = bestNum } if fromNum > toNum { - return rpc.ErrResponse(id, rpc.CodeInvalidParams, "invalid block range") + return jsonrpc.ErrResponse(id, jsonrpc.CodeInvalidParams, "invalid block range") } if toNum-fromNum > h.backtrace { - return rpc.ErrResponse(id, rpc.CodeServerError, + return jsonrpc.ErrResponse(id, jsonrpc.CodeServerError, fmt.Sprintf("block range exceeds backtrace limit of %d", h.backtrace)) } @@ -394,30 +373,30 @@ func (h *Handler) queryFilterLogs(id json.RawMessage, e *entry) rpc.Response { for num := uint64(fromNum); num <= uint64(toNum); num++ { blk, err := bestChain.GetBlock(uint32(num)) if err != nil { - return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) } receipts, err := h.repo.GetBlockReceipts(blk.Header().ID()) if err != nil { - return rpc.ErrResponse(id, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(id, jsonrpc.CodeInternalError, err.Error()) } - logs := collectMatchingLogs(&e.criteria, blk.Transactions(), receipts, - common.Hash(blk.Header().ID()), uint64(blk.Header().Number())) + 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 rpc.OkResponse(id, ethLogs) + return jsonrpc.OkResponse(id, ethLogs) } -func (h *Handler) ethUninstallFilter(req rpc.Request) rpc.Response { +func (h *Handler) ethUninstallFilter(req jsonrpc.Request) jsonrpc.Response { var params [1]json.RawMessage if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterId]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterId]") } var id string if err := json.Unmarshal(params[0], &id); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid filter id") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid filter id") } h.mu.Lock() @@ -430,100 +409,5 @@ func (h *Handler) ethUninstallFilter(req rpc.Request) rpc.Response { if ok && e.kind == kindPendingTx { e.txSub.Unsubscribe() } - return rpc.OkResponse(req.ID, ok) -} - -// collectMatchingLogs scans ETH-typed transactions in a single block and returns EthLog -// entries matching the criteria. Projected transactionIndex and logIndex are relative to -// ETH-typed transactions only, consistent with eth_getTransactionByHash etc. -func collectMatchingLogs(criteria *logCriteria, txs tx.Transactions, receipts tx.Receipts, blockHash common.Hash, blockNum uint64) []*rpc.EthLog { - var logs []*rpc.EthLog - var projEthIdx uint64 // running ETH tx index within the block - var projLogIdx uint64 // running ETH log index within the block (all ETH events, not just matching) - - 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: false, - }) - } - } - projLogIdx += uint64(len(receipt.Outputs[0].Events)) - } - projEthIdx++ - } - return logs -} - -// parseCriteria parses the address and topic fields from a LogFilter into a logCriteria. -// OR semantics within a single topic position are not fully supported — only the first -// alternative is used (e.g., [["A","B"], "C"] treats position 0 as matching only "A"). -func parseCriteria(f rpc.LogFilter) (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) - } - h32Copy := h32 - c.topics[i] = &h32Copy - } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { - h32, err := rpc.ParseBytes32Compact(multi[0]) - if err != nil { - return c, fmt.Errorf("invalid topic: %w", err) - } - h32Copy := h32 - c.topics[i] = &h32Copy - } - } - return c, nil + return jsonrpc.OkResponse(req.ID, ok) } diff --git a/rpc/filters/handler_test.go b/rpc/filters/handler_test.go index 29718d5b49..de2cf9915d 100644 --- a/rpc/filters/handler_test.go +++ b/rpc/filters/handler_test.go @@ -17,8 +17,8 @@ import ( "github.com/vechain/thor/v2/builtin" "github.com/vechain/thor/v2/genesis" - "github.com/vechain/thor/v2/rpc" "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" @@ -106,7 +106,7 @@ func TestFiltersHandler(t *testing.T) { rpcErr := testutil.CallExpectError(t, ts, "eth_newFilter", []any{ map[string]any{"address": "not-a-valid-address"}, }) - assert.Equal(t, rpc.CodeInvalidParams, rpcErr.Code) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcErr.Code) }) t.Run("eth_getFilterLogs", func(t *testing.T) { @@ -129,7 +129,7 @@ func TestFiltersHandler(t *testing.T) { require.NoError(t, json.Unmarshal(idResult, &filterID)) rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterLogs", []any{filterID}) - assert.Equal(t, rpc.CodeInvalidParams, rpcErr.Code) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcErr.Code) }) t.Run("pending_tx_filter", func(t *testing.T) { @@ -179,12 +179,12 @@ func TestFiltersHandler(t *testing.T) { t.Run("eth_getFilterChanges_unknown", func(t *testing.T) { rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterChanges", []any{"0x9999"}) - assert.Equal(t, rpc.CodeInvalidParams, rpcErr.Code) + 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, rpc.CodeInvalidParams, rpcErr.Code) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcErr.Code) }) } @@ -381,7 +381,7 @@ func TestFiltersHandlerGetFilterLogsBacktraceLimit(t *testing.T) { require.NoError(t, json.Unmarshal(idResult, &filterID)) rpcErr := testutil.CallExpectError(t, ts, "eth_getFilterLogs", []any{filterID}) - assert.Equal(t, rpc.CodeServerError, rpcErr.Code) + assert.Equal(t, jsonrpc.CodeServerError, rpcErr.Code) } // TestFiltersHandlerVcTxExcluded verifies that events from TypeLegacy VeChain @@ -521,3 +521,64 @@ func TestFiltersHandlerCompactTopicFilter(t *testing.T) { 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/integration_test.go b/rpc/integration_test.go index 7c9cf8ffb6..2546e6e859 100644 --- a/rpc/integration_test.go +++ b/rpc/integration_test.go @@ -20,11 +20,11 @@ import ( "github.com/vechain/thor/v2/test/testchain" "github.com/vechain/thor/v2/txpool" - "github.com/vechain/thor/v2/rpc" "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" @@ -51,13 +51,13 @@ func TestDispatch(t *testing.T) { LimitPerAccount: 16, MaxLifetime: 10 * time.Minute, }, &testchain.DefaultForkConfig) - srv := rpc.NewServer() + 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).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) @@ -65,7 +65,7 @@ func TestDispatch(t *testing.T) { t.Run("unknown_method", func(t *testing.T) { rpcErr := testutil.CallExpectError(t, ts, "eth_nonExistentMethod", []any{}) - assert.Equal(t, rpc.CodeMethodNotFound, rpcErr.Code) + assert.Equal(t, jsonrpc.CodeMethodNotFound, rpcErr.Code) }) t.Run("batch", func(t *testing.T) { @@ -78,9 +78,9 @@ func TestDispatch(t *testing.T) { defer resp.Body.Close() var responses []struct { - ID json.RawMessage `json:"id"` - Result json.RawMessage `json:"result"` - Error *rpc.RPCError `json:"error"` + 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) @@ -106,11 +106,11 @@ func TestDispatch(t *testing.T) { defer resp.Body.Close() var rpcResp struct { - Error *rpc.RPCError `json:"error"` + Error *jsonrpc.RPCError `json:"error"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) require.NotNil(t, rpcResp.Error) - assert.Equal(t, rpc.CodeInvalidParams, rpcResp.Error.Code) + assert.Equal(t, jsonrpc.CodeInvalidParams, rpcResp.Error.Code) }) t.Run("invalid_json", func(t *testing.T) { @@ -119,11 +119,11 @@ func TestDispatch(t *testing.T) { defer resp.Body.Close() var rpcResp struct { - Error *rpc.RPCError `json:"error"` + Error *jsonrpc.RPCError `json:"error"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) require.NotNil(t, rpcResp.Error) - assert.Equal(t, rpc.CodeParseError, rpcResp.Error.Code) + assert.Equal(t, jsonrpc.CodeParseError, rpcResp.Error.Code) }) t.Run("body_too_large", func(t *testing.T) { @@ -137,11 +137,11 @@ func TestDispatch(t *testing.T) { defer resp.Body.Close() var rpcResp struct { - Error *rpc.RPCError `json:"error"` + Error *jsonrpc.RPCError `json:"error"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) require.NotNil(t, rpcResp.Error) - assert.Equal(t, rpc.CodeInvalidRequest, rpcResp.Error.Code) + assert.Equal(t, jsonrpc.CodeInvalidRequest, rpcResp.Error.Code) }) t.Run("wrong_http_method", func(t *testing.T) { diff --git a/rpc/server.go b/rpc/jsonrpc/server.go similarity index 86% rename from rpc/server.go rename to rpc/jsonrpc/server.go index 1396889c32..ef9115595b 100644 --- a/rpc/server.go +++ b/rpc/jsonrpc/server.go @@ -3,7 +3,10 @@ // Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying // file LICENSE or -package rpc +// 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" @@ -77,7 +80,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -func (s *Server) dispatch(req Request) Response { +// 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)) @@ -91,7 +97,7 @@ func (s *Server) handleSingle(w http.ResponseWriter, body []byte) { writeJSON(w, ErrResponse(nil, CodeParseError, "invalid JSON: "+err.Error())) return } - writeJSON(w, s.dispatch(req)) + writeJSON(w, s.Dispatch(req)) } func (s *Server) handleBatch(w http.ResponseWriter, body []byte) { @@ -116,7 +122,7 @@ func (s *Server) handleBatch(w http.ResponseWriter, body []byte) { responses[i] = ErrResponse(nil, CodeParseError, "invalid request in batch: "+err.Error()) continue } - responses[i] = s.dispatch(req) + responses[i] = s.Dispatch(req) } writeJSON(w, responses) } diff --git a/rpc/types.go b/rpc/jsonrpc/types.go similarity index 99% rename from rpc/types.go rename to rpc/jsonrpc/types.go index b0f7efda23..b5c68b413b 100644 --- a/rpc/types.go +++ b/rpc/jsonrpc/types.go @@ -3,7 +3,7 @@ // Distributed under the GNU Lesser General Public License v3.0 software license, see the accompanying // file LICENSE or -package rpc +package jsonrpc import "encoding/json" diff --git a/rpc/logs/handler.go b/rpc/logs/handler.go index 547182b300..554b266339 100644 --- a/rpc/logs/handler.go +++ b/rpc/logs/handler.go @@ -17,6 +17,8 @@ import ( "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" ) @@ -35,14 +37,14 @@ func New(repo *chain.Repository, logDB *logdb.LogDB, backtrace uint32, logsLimit } // Mount registers all log methods on the dispatcher. -func (h *Handler) Mount(s *rpc.Server) { +func (h *Handler) Mount(s *jsonrpc.Server) { s.Register("eth_getLogs", h.ethGetLogs) } -func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { - var params []rpc.LogFilter +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 rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [filterObject]") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "expected [filterObject]") } f := params[0] @@ -56,11 +58,11 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { 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 rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "can't specify fromBlock/toBlock with blockHash") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "can't specify fromBlock/toBlock with blockHash") } - summary, err := rpc.ResolveBlockTag(*f.BlockHash, h.repo) + summary, err := ethconvert.ResolveBlockTag(*f.BlockHash, h.repo) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeServerError, "unknown block") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, "unknown block") } fromNum = summary.Header.Number() toNum = summary.Header.Number() @@ -70,16 +72,16 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { toNum = bestNum if f.FromBlock != nil && *f.FromBlock != "" { - summary, err := rpc.ResolveBlockTag(*f.FromBlock, h.repo) + summary, err := ethconvert.ResolveBlockTag(*f.FromBlock, h.repo) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid fromBlock") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid fromBlock") } fromNum = summary.Header.Number() } if f.ToBlock != nil && *f.ToBlock != "" { - summary, err := rpc.ResolveBlockTag(*f.ToBlock, h.repo) + summary, err := ethconvert.ResolveBlockTag(*f.ToBlock, h.repo) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid toBlock") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid toBlock") } toNum = summary.Header.Number() } @@ -88,10 +90,10 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { toNum = bestNum } if fromNum > toNum { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block range params") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid block range params") } if toNum-fromNum > h.backtrace { - return rpc.ErrResponse(req.ID, rpc.CodeServerError, fmt.Sprintf("block range exceeds backtrace limit of %d", h.backtrace)) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, fmt.Sprintf("block range exceeds backtrace limit of %d", h.backtrace)) } } @@ -103,7 +105,7 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { if err := json.Unmarshal(f.Address, &single); err == nil { addr, err := thor.ParseAddress(single) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address in filter") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid address in filter") } a := addr addresses = append(addresses, &a) @@ -111,7 +113,7 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { for _, s := range multi { addr, err := thor.ParseAddress(s) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid address in filter") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid address in filter") } a := addr addresses = append(addresses, &a) @@ -120,15 +122,12 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { } // Parse topic filters — up to 5 positions (topic0…topic4), each null | hex | []hex. - // Adjacent positions are ANDed: topics: ["A", "B"] means topic0==A AND topic1==B. - // OR semantics within one position (topics: [["A","C"], "B"]) are not yet fully - // supported — only the first alternative is used. - // TODO: full OR-within-position support requires expanding into a cross-product of - // EventCriteria (one per combination of per-position alternatives). - var topicSlot [5]*thor.Bytes32 + // 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(topicSlot) { - topics = topics[:len(topicSlot)] + if len(topics) > len(topicAlts) { + topics = topics[:len(topicAlts)] } for i, raw := range topics { if raw == nil || string(raw) == "null" { @@ -139,37 +138,26 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { if err := json.Unmarshal(raw, &single); err == nil { h32, err := rpc.ParseBytes32Compact(single) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, "invalid topic") } - h32Copy := h32 - topicSlot[i] = &h32Copy + topicAlts[i] = []thor.Bytes32{h32} } else if err := json.Unmarshal(raw, &multi); err == nil && len(multi) > 0 { - h32, err := rpc.ParseBytes32Compact(multi[0]) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid topic") + 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) } - h32Copy := h32 - topicSlot[i] = &h32Copy + topicAlts[i] = alts } } - // Build criteria set: one EventCriteria per address with all topic positions ANDed. - var criteriaSet []*logdb.EventCriteria - buildCriteria := func(addr *thor.Address) { - criteriaSet = append(criteriaSet, &logdb.EventCriteria{ - Address: addr, - Topics: topicSlot, - }) - } - - if len(addresses) == 0 { - buildCriteria(nil) - } else { - for _, addr := range addresses { - a := addr - buildCriteria(a) - } - } + // 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. @@ -189,10 +177,10 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { events, err := h.logDB.FilterEvents(context.Background(), filter) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } if uint64(len(events)) > h.logsLimit { - return rpc.ErrResponse(req.ID, rpc.CodeServerError, + 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)) } @@ -222,12 +210,12 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { } var ethLogs []*rpc.EthLog - // TODO the log scanning loop iterates blocks and calls GetBlock() for each. A large block range with many blocks could block the RPC connection - // Is this perfomant at all ? + // 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 rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } // Bounds-check and ID-verify before using the canonical index. @@ -276,5 +264,43 @@ func (h *Handler) ethGetLogs(req rpc.Request) rpc.Response { if ethLogs == nil { ethLogs = []*rpc.EthLog{} } - return rpc.OkResponse(req.ID, ethLogs) + 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 index bfab57324c..f54c7924bb 100644 --- a/rpc/logs/handler_test.go +++ b/rpc/logs/handler_test.go @@ -471,3 +471,58 @@ func TestLogsHandlerReversedRange(t *testing.T) { }) 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/utils_test.go b/rpc/parse_test.go similarity index 100% rename from rpc/utils_test.go rename to rpc/parse_test.go diff --git a/rpc/simulation/handler.go b/rpc/simulation/handler.go index 2b86fda4f4..a634d944af 100644 --- a/rpc/simulation/handler.go +++ b/rpc/simulation/handler.go @@ -7,14 +7,14 @@ package simulation import ( "encoding/json" - "fmt" "math/big" - "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/runtime" "github.com/vechain/thor/v2/state" "github.com/vechain/thor/v2/thor" @@ -36,55 +36,50 @@ func New(repo *chain.Repository, stater *state.Stater, forkConfig *thor.ForkConf } // Mount registers all simulation methods on the dispatcher. -func (h *Handler) Mount(s *rpc.Server) { +func (h *Handler) Mount(s *jsonrpc.Server) { s.Register("eth_call", h.ethCall) s.Register("eth_estimateGas", h.ethEstimateGas) } -// 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"` - Value *hexutil.Big `json:"value"` - Data hexutil.Bytes `json:"data"` -} - -func (h *Handler) ethCall(req rpc.Request) rpc.Response { - args, tag, err := parseCallArgs(req.Params) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) +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(args, tag, h.callGasLimit) + out, _, execErr := h.simulate(params.Args, params.Tag, h.callGasLimit) if execErr != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, execErr.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, execErr.Error()) } if out.VMErr != nil { - return rpc.ErrResponseWithData(req.ID, rpc.CodeServerError, "execution reverted", hexutil.Encode(out.Data)) + return jsonrpc.ErrResponseWithData(req.ID, jsonrpc.CodeServerError, "execution reverted", hexutil.Encode(out.Data)) } - return rpc.OkResponse(req.ID, hexutil.Bytes(out.Data)) + return jsonrpc.OkResponse(req.ID, hexutil.Bytes(out.Data)) } -func (h *Handler) ethEstimateGas(req rpc.Request) rpc.Response { - args, tag, err := parseCallArgs(req.Params) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) +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 args.Gas != nil && uint64(*args.Gas) < limit { - limit = uint64(*args.Gas) + if params.Args.Gas != nil && uint64(*params.Args.Gas) < limit { + limit = uint64(*params.Args.Gas) } - // Run with full gas limit to determine if the call succeeds at all. - out, _, execErr := h.simulate(args, tag, limit) + // 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 rpc.ErrResponse(req.ID, rpc.CodeInternalError, execErr.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, execErr.Error()) } if out.VMErr != nil { - return rpc.ErrResponseWithData(req.ID, rpc.CodeServerError, "execution reverted", hexutil.Encode(out.Data)) + return jsonrpc.ErrResponseWithData(req.ID, jsonrpc.CodeServerError, "execution reverted", hexutil.Encode(out.Data)) } evmGasUsed := limit - out.LeftOverGas @@ -92,23 +87,23 @@ func (h *Handler) ethEstimateGas(req rpc.Request) rpc.Response { // 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 args.To != nil { - addr := thor.Address(*args.To) + if params.Args.To != nil { + addr := thor.Address(*params.Args.To) to = &addr } - intrinsic, err := tx.IntrinsicGas(tx.NewClause(to).WithData(args.Data)) + intrinsic, err := tx.IntrinsicGas(tx.NewClause(to).WithData(params.Args.Data)) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + 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 rpc.OkResponse(req.ID, hexutil.Uint64(evmGasUsed+intrinsic)) + return jsonrpc.OkResponse(req.ID, hexutil.Uint64(evmGasUsed+intrinsic)) } -func (h *Handler) simulate(args CallArgs, tag string, gasLimit uint64) (*runtime.Output, *state.State, error) { - summary, err := rpc.ResolveBlockTag(tag, h.repo) +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 } @@ -137,7 +132,17 @@ func (h *Handler) simulate(args CallArgs, tag string, gasLimit uint64) (*runtime origin = thor.Address(*args.From) } var gasPrice *big.Int - if args.GasPrice != nil { + 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) @@ -167,21 +172,3 @@ func (h *Handler) simulate(args CallArgs, tag string, gasLimit uint64) (*runtime out, _, err := exec() return out, st, err } - -func parseCallArgs(raw json.RawMessage) (CallArgs, string, error) { - var params []json.RawMessage - if err := json.Unmarshal(raw, ¶ms); err != nil || len(params) < 1 { - return CallArgs{}, "", fmt.Errorf("expected [callArgs, blockTag?]") - } - var args CallArgs - if err := json.Unmarshal(params[0], &args); err != nil { - return CallArgs{}, "", fmt.Errorf("invalid call arguments: %w", err) - } - tag := "latest" - if len(params) >= 2 { - if err := json.Unmarshal(params[1], &tag); err != nil { - return CallArgs{}, "", fmt.Errorf("invalid block tag") - } - } - return args, tag, nil -} diff --git a/rpc/simulation/handler_test.go b/rpc/simulation/handler_test.go index 8f11708d3f..1b6a6d1086 100644 --- a/rpc/simulation/handler_test.go +++ b/rpc/simulation/handler_test.go @@ -16,7 +16,7 @@ import ( "github.com/vechain/thor/v2/builtin" "github.com/vechain/thor/v2/genesis" - "github.com/vechain/thor/v2/rpc" + "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" @@ -117,7 +117,7 @@ func TestSimulationHandler(t *testing.T) { }, "latest", }) - assert.Equal(t, rpc.CodeServerError, rpcErr.Code) + assert.Equal(t, jsonrpc.CodeServerError, rpcErr.Code) assert.Equal(t, "execution reverted", rpcErr.Message) }) @@ -137,6 +137,24 @@ func TestSimulationHandler(t *testing.T) { "data": hexutil.Encode(callData), }, }) - assert.Equal(t, rpc.CodeServerError, rpcErr.Code) + 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 index 9a5d41a626..3a24013d9c 100644 --- a/rpc/testutil/testutil.go +++ b/rpc/testutil/testutil.go @@ -19,7 +19,7 @@ import ( "github.com/stretchr/testify/require" "github.com/vechain/thor/v2/genesis" - "github.com/vechain/thor/v2/rpc" + "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" @@ -93,7 +93,7 @@ func BuildVcTx(t *testing.T, c *testchain.Chain, sender genesis.DevAccount, to * // Mounter is satisfied by any sub-package handler that exposes Mount. type Mounter interface { - Mount(s *rpc.Server) + Mount(s *jsonrpc.Server) } // NewTestServer creates an httptest.Server with only m's methods registered. @@ -101,7 +101,7 @@ type Mounter interface { // 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 := rpc.NewServer() + srv := jsonrpc.NewServer() m.Mount(srv) ts := httptest.NewServer(srv) t.Cleanup(ts.Close) @@ -125,8 +125,8 @@ func Call(t *testing.T, ts *httptest.Server, method string, params any) json.Raw defer resp.Body.Close() var rpcResp struct { - Result json.RawMessage `json:"result"` - Error *rpc.RPCError `json:"error"` + Result json.RawMessage `json:"result"` + Error *jsonrpc.RPCError `json:"error"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&rpcResp)) if rpcResp.Error != nil { @@ -137,7 +137,7 @@ func Call(t *testing.T, ts *httptest.Server, method string, params any) json.Raw // 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) *rpc.RPCError { +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", @@ -152,8 +152,8 @@ func CallExpectError(t *testing.T, ts *httptest.Server, method string, params an defer resp.Body.Close() var rpcResp struct { - Result json.RawMessage `json:"result"` - Error *rpc.RPCError `json:"error"` + 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) diff --git a/rpc/transactions/handler.go b/rpc/transactions/handler.go index 8b3476aee2..77b39654b1 100644 --- a/rpc/transactions/handler.go +++ b/rpc/transactions/handler.go @@ -9,12 +9,12 @@ import ( "encoding/json" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/vechain/thor/v2/block" "github.com/vechain/thor/v2/chain" "github.com/vechain/thor/v2/rpc" - "github.com/vechain/thor/v2/thor" + "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" ) @@ -31,7 +31,7 @@ func New(repo *chain.Repository, txPool txpool.Pool) *Handler { } // Mount registers all transaction methods on the dispatcher. -func (h *Handler) Mount(s *rpc.Server) { +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) @@ -48,7 +48,7 @@ type ethTxContext struct { // 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 thor.Bytes32) (*ethTxContext, error) { +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 @@ -64,82 +64,56 @@ func (h *Handler) fetchEthTxContext(bestChain *chain.Chain, id thor.Bytes32) (*e return ðTxContext{transaction: t, meta: meta, header: header, receipts: receipts}, nil } -func (h *Handler) ethGetTransactionByHash(req rpc.Request) rpc.Response { - var params []string - if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [txHash]") - } - id, err := thor.ParseBytes32(params[0]) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") +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(), id) + ctx, err := h.fetchEthTxContext(h.repo.NewBestChain(), params.Hash) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } if ctx == nil { - return rpc.OkResponse(req.ID, nil) + return jsonrpc.OkResponse(req.ID, nil) } - projIdx := rpc.ProjectedEthIndex(ctx.receipts, ctx.meta.Index) - return rpc.OkResponse( - req.ID, - rpc.ToEthTx(ctx.transaction, h.repo.ChainID(), common.Hash(ctx.header.ID()), uint64(ctx.header.Number()), projIdx, ctx.header.BaseFee()), - ) + 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 rpc.Request) rpc.Response { - var params [2]json.RawMessage +func (h *Handler) ethGetTransactionByBlockHashAndIndex(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagAndIndexParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockHash, index]") - } - var hashStr string - if err := json.Unmarshal(params[0], &hashStr); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block hash") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - var idxStr string - if err := json.Unmarshal(params[1], &idxStr); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid index") - } - - summary, err := rpc.ResolveBlockTag(hashStr, h.repo) + summary, err := ethconvert.ResolveBlockTag(params.Tag, h.repo) if err != nil { - return rpc.OkResponse(req.ID, nil) + return jsonrpc.OkResponse(req.ID, nil) } - return h.txByBlockAndEthIndex(req, summary.Header, idxStr) + return h.txByBlockAndEthIndex(req, summary.Header, params.Index) } -func (h *Handler) ethGetTransactionByBlockNumberAndIndex(req rpc.Request) rpc.Response { - var params [2]json.RawMessage +func (h *Handler) ethGetTransactionByBlockNumberAndIndex(req jsonrpc.Request) jsonrpc.Response { + var params rpc.BlockTagAndIndexParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [blockNumber, index]") - } - var tag string - if err := json.Unmarshal(params[0], &tag); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid block number or tag") + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInvalidParams, err.Error()) } - var idxStr string - if err := json.Unmarshal(params[1], &idxStr); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid index") - } - - summary, err := rpc.ResolveBlockTag(tag, h.repo) + summary, err := ethconvert.ResolveBlockTag(params.Tag, h.repo) if err != nil { - return rpc.OkResponse(req.ID, nil) + return jsonrpc.OkResponse(req.ID, nil) } - return h.txByBlockAndEthIndex(req, summary.Header, idxStr) + return h.txByBlockAndEthIndex(req, summary.Header, params.Index) } -func (h *Handler) txByBlockAndEthIndex(req rpc.Request, header *block.Header, idxStr string) rpc.Response { - ethIdx, err := hexutil.DecodeUint64(idxStr) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid 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 rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } blockHash := common.Hash(header.ID()) @@ -151,61 +125,54 @@ func (h *Handler) txByBlockAndEthIndex(req rpc.Request, header *block.Header, id continue } if projIdx == ethIdx { - return rpc.OkResponse(req.ID, rpc.ToEthTx(t, h.repo.ChainID(), blockHash, blockNum, projIdx, header.BaseFee())) + return jsonrpc.OkResponse(req.ID, ethconvert.ToEthTx(t, h.repo.ChainID(), blockHash, blockNum, projIdx, header.BaseFee())) } projIdx++ } - return rpc.OkResponse(req.ID, nil) + return jsonrpc.OkResponse(req.ID, nil) } -func (h *Handler) ethGetTransactionReceipt(req rpc.Request) rpc.Response { - var params []string - if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [txHash]") - } - id, err := thor.ParseBytes32(params[0]) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid tx hash") +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(), id) + ctx, err := h.fetchEthTxContext(h.repo.NewBestChain(), params.Hash) if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInternalError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeInternalError, err.Error()) } if ctx == nil { - return rpc.OkResponse(req.ID, nil) + return jsonrpc.OkResponse(req.ID, nil) } receipt := ctx.receipts[ctx.meta.Index] - projIdx := rpc.ProjectedEthIndex(ctx.receipts, ctx.meta.Index) - cumGas := rpc.CumulativeEthGasUsed(ctx.receipts, ctx.meta.Index) - logOff := rpc.EthLogOffset(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 rpc.OkResponse(req.ID, rpc.ToEthReceipt( + 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 rpc.Request) rpc.Response { - var params []string - if err := json.Unmarshal(req.Params, ¶ms); err != nil || len(params) < 1 { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "expected [rawTx]") - } - raw, err := hexutil.Decode(params[0]) - if err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, "invalid hex encoding") +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(raw); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeInvalidParams, err.Error()) + 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") } - // TODO is Adding to the pool enough guarantee for ethereum styled txs ? if err := h.txPool.AddLocal(parsed); err != nil { - return rpc.ErrResponse(req.ID, rpc.CodeServerError, err.Error()) + return jsonrpc.ErrResponse(req.ID, jsonrpc.CodeServerError, err.Error()) } - - return rpc.OkResponse(req.ID, common.Hash(parsed.ID()).Hex()) + return jsonrpc.OkResponse(req.ID, common.Hash(parsed.ID()).Hex()) } 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..fbfa18ab39 --- /dev/null +++ b/rpc/ws/conn.go @@ -0,0 +1,324 @@ +// 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.Add(1) + go func() { + defer writeWg.Done() + 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. + 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. +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.Add(1) + go func() { + defer c.subWg.Done() + 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..10a9d1495d --- /dev/null +++ b/rpc/ws/handler.go @@ -0,0 +1,106 @@ +// 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 + backtrace uint32 + 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, backtrace uint32, allowedOrigins []string, rpcSrv *jsonrpc.Server) *Handler { + ctx, cancel := context.WithCancel(context.Background()) + return &Handler{ + repo: repo, + txPool: txPool, + backtrace: backtrace, + 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.Add(1) + go func() { + defer h.wg.Done() + 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..f94626e037 --- /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, 100, []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/testnode/node.go b/test/testnode/node.go index a95a8c707f..78c42a7f8c 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -11,7 +11,7 @@ import ( "github.com/gorilla/mux" - "github.com/vechain/thor/v2/rpc" + "github.com/vechain/thor/v2/rpc/jsonrpc" "github.com/vechain/thor/v2/api/accounts" "github.com/vechain/thor/v2/api/blocks" @@ -37,6 +37,7 @@ import ( 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 @@ -107,22 +108,24 @@ func (n *node) Start() error { subs := subscriptions.New(repo, []string{"*"}, 1000, n.txPool, true) subs.Mount(router, "/subscriptions") - rpcSrv := rpc.NewServer() + 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).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) - router.PathPrefix("/rpc").Handler(rpcSrv) + rpcWs := rpcws.New(repo, n.txPool, 100, []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 index 34ae97c76a..41a76c9867 100644 --- a/thorclient/rpc_test.go +++ b/thorclient/rpc_test.go @@ -8,12 +8,16 @@ 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" @@ -867,4 +871,102 @@ func TestEthRPC(t *testing.T) { 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) + }) } From cf682164372286c836df7ed617bf3a7d88c1c007 Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 14 May 2026 22:30:28 +0100 Subject: [PATCH 19/20] remove backtrace + lint --- cmd/thor/httpserver/api_server.go | 2 +- rpc/ethconvert/log_criteria.go | 23 ++++------------------- rpc/filters/handler_test.go | 15 +++++++++------ rpc/ws/conn.go | 12 ++++-------- rpc/ws/handler.go | 24 ++++++++++-------------- rpc/ws/handler_test.go | 2 +- test/testnode/node.go | 2 +- 7 files changed, 30 insertions(+), 50 deletions(-) diff --git a/cmd/thor/httpserver/api_server.go b/cmd/thor/httpserver/api_server.go index 268e650a49..833bd97481 100644 --- a/cmd/thor/httpserver/api_server.go +++ b/cmd/thor/httpserver/api_server.go @@ -157,7 +157,7 @@ func StartAPIServer( // 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, config.BacktraceLimit, origins, rpcSrv) + rpcWs := rpcws.New(repo, txPool, origins, rpcSrv) router.PathPrefix("/rpc").Handler(rpcWs) if config.PprofOn { diff --git a/rpc/ethconvert/log_criteria.go b/rpc/ethconvert/log_criteria.go index ad28f5be7f..97be62f47c 100644 --- a/rpc/ethconvert/log_criteria.go +++ b/rpc/ethconvert/log_criteria.go @@ -8,6 +8,7 @@ package ethconvert import ( "encoding/json" "fmt" + "slices" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" @@ -27,17 +28,8 @@ type LogCriteria struct { } func (c *LogCriteria) matchesEvent(e *tx.Event) bool { - if len(c.Addresses) > 0 { - found := false - for _, a := range c.Addresses { - if a == e.Address { - found = true - break - } - } - if !found { - return false - } + if len(c.Addresses) > 0 && !slices.Contains(c.Addresses, e.Address) { + return false } for i, alts := range c.Topics { if len(alts) == 0 { @@ -46,14 +38,7 @@ func (c *LogCriteria) matchesEvent(e *tx.Event) bool { if i >= len(e.Topics) { return false } - matched := false - for _, want := range alts { - if e.Topics[i] == want { - matched = true - break - } - } - if !matched { + if !slices.Contains(alts, e.Topics[i]) { return false } } diff --git a/rpc/filters/handler_test.go b/rpc/filters/handler_test.go index de2cf9915d..6237b3799c 100644 --- a/rpc/filters/handler_test.go +++ b/rpc/filters/handler_test.go @@ -143,15 +143,18 @@ func TestFiltersHandler(t *testing.T) { require.NoError(t, json.Unmarshal(result, &hashes)) assert.Empty(t, hashes) - // Add an ETH tx to the pool. SubscribeTxEvent fires synchronously before - // AddLocal returns, so the hash is immediately available for polling. + // The pool dispatches the subscription event asynchronously (p.goes.Go), + // so poll until the hash arrives rather than reading once immediately. ethTx := testutil.BuildEthTx(t, fx.chainID, sender, 10, &recipient.Address) require.NoError(t, fx.pool.AddLocal(ethTx)) - result = testutil.Call(t, ts, "eth_getFilterChanges", []any{filterID}) - require.NoError(t, json.Unmarshal(result, &hashes)) - require.Len(t, hashes, 1) - assert.Equal(t, common.Hash(ethTx.ID()), hashes[0]) + 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}) diff --git a/rpc/ws/conn.go b/rpc/ws/conn.go index fbfa18ab39..17e1a7b7c7 100644 --- a/rpc/ws/conn.go +++ b/rpc/ws/conn.go @@ -100,11 +100,9 @@ func (c *wsConn) serve() { }() var writeWg sync.WaitGroup - writeWg.Add(1) - go func() { - defer writeWg.Done() + writeWg.Go(func() { c.writeLoop() - }() + }) c.readLoop() // blocks until conn.Close() or read error c.connCancel() // stop write loop and all subscription goroutines @@ -216,9 +214,7 @@ func (c *wsConn) startSub(subID string, fn func(context.Context)) { c.subs[subID] = cancel c.subsMu.Unlock() - c.subWg.Add(1) - go func() { - defer c.subWg.Done() + c.subWg.Go(func() { defer func() { c.subsMu.Lock() delete(c.subs, subID) @@ -226,7 +222,7 @@ func (c *wsConn) startSub(subID string, fn func(context.Context)) { cancel() }() fn(ctx) - }() + }) } // unsubscribe handles eth_unsubscribe: cancels the subscription goroutine. diff --git a/rpc/ws/handler.go b/rpc/ws/handler.go index 10a9d1495d..e790d7c0e3 100644 --- a/rpc/ws/handler.go +++ b/rpc/ws/handler.go @@ -33,11 +33,10 @@ const ( // connections gain eth_subscribe / eth_unsubscribe in addition to all registered // methods. type Handler struct { - repo *chain.Repository - txPool txpool.Pool - backtrace uint32 - rpcSrv *jsonrpc.Server - upgrader *websocket.Upgrader + repo *chain.Repository + txPool txpool.Pool + rpcSrv *jsonrpc.Server + upgrader *websocket.Upgrader ctx context.Context cancel context.CancelFunc @@ -46,13 +45,12 @@ type Handler struct { // 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, backtrace uint32, allowedOrigins []string, rpcSrv *jsonrpc.Server) *Handler { +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, - backtrace: backtrace, - rpcSrv: rpcSrv, + repo: repo, + txPool: txPool, + rpcSrv: rpcSrv, upgrader: &websocket.Upgrader{ EnableCompression: true, CheckOrigin: func(r *http.Request) bool { @@ -97,10 +95,8 @@ func (h *Handler) serveWS(w http.ResponseWriter, r *http.Request) { return } - h.wg.Add(1) - go func() { - defer h.wg.Done() + 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 index f94626e037..6afa268492 100644 --- a/rpc/ws/handler_test.go +++ b/rpc/ws/handler_test.go @@ -56,7 +56,7 @@ func newFixture(t *testing.T) *fixture { rpcchain.New(c.Repo(), "test/1.0").Mount(rpcSrv) rpcblocks.New(c.Repo()).Mount(rpcSrv) - h := rpcws.New(c.Repo(), pool, 100, []string{"*"}, rpcSrv) + h := rpcws.New(c.Repo(), pool, []string{"*"}, rpcSrv) t.Cleanup(h.Close) srv := httptest.NewServer(h) diff --git a/test/testnode/node.go b/test/testnode/node.go index 78c42a7f8c..1d17918b76 100644 --- a/test/testnode/node.go +++ b/test/testnode/node.go @@ -118,7 +118,7 @@ func (n *node) Start() error { 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, 100, []string{"*"}, rpcSrv) + rpcWs := rpcws.New(repo, n.txPool, []string{"*"}, rpcSrv) router.PathPrefix("/rpc").Handler(rpcWs) n.apiServer = httptest.NewServer(router) From 60e6a5a5d1d09374a2dd022abbfe0032b7e12c24 Mon Sep 17 00:00:00 2001 From: otherview Date: Thu, 14 May 2026 23:02:37 +0100 Subject: [PATCH 20/20] adding temp max filters + faster receipts --- rpc/blocks/handler.go | 14 ++++++-------- rpc/filters/handler.go | 30 +++++++++++++++++++++++++++--- rpc/ws/conn.go | 6 ++++++ 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/rpc/blocks/handler.go b/rpc/blocks/handler.go index e326cc7de0..11673c7cc4 100644 --- a/rpc/blocks/handler.go +++ b/rpc/blocks/handler.go @@ -127,18 +127,16 @@ func (h *Handler) ethGetBlockReceipts(req jsonrpc.Request) jsonrpc.Response { 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 } - projIdx := ethconvert.ProjectedEthIndex(receipts, uint64(i)) - cumGas := ethconvert.CumulativeEthGasUsed(receipts, uint64(i)) - logOff := ethconvert.EthLogOffset(receipts, uint64(i)) - ethReceipts = append(ethReceipts, ethconvert.ToEthReceipt( - t, receipts[i], - blockHash, blockNum, - projIdx, cumGas, logOff, baseFee, - )) + 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) } diff --git a/rpc/filters/handler.go b/rpc/filters/handler.go index c418a5a1bf..61b64bdca7 100644 --- a/rpc/filters/handler.go +++ b/rpc/filters/handler.go @@ -28,6 +28,20 @@ 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 @@ -159,6 +173,10 @@ func (h *Handler) ethNewFilter(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: kindLog, lastPoll: time.Now(), @@ -166,19 +184,21 @@ func (h *Handler) ethNewFilter(req jsonrpc.Request) jsonrpc.Response { logFilter: f, criteria: criteria, } - h.mu.Unlock() 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()), } - h.mu.Unlock() return jsonrpc.OkResponse(req.ID, id) } @@ -187,13 +207,17 @@ func (h *Handler) ethNewPendingTransactionFilter(req jsonrpc.Request) jsonrpc.Re 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, } - h.mu.Unlock() return jsonrpc.OkResponse(req.ID, id) } diff --git a/rpc/ws/conn.go b/rpc/ws/conn.go index 17e1a7b7c7..f7313698a8 100644 --- a/rpc/ws/conn.go +++ b/rpc/ws/conn.go @@ -131,6 +131,9 @@ func (c *wsConn) dispatch(msg []byte) { 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()))) @@ -208,6 +211,9 @@ func (c *wsConn) subscribe(req jsonrpc.Request) jsonrpc.Response { // 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()