diff --git a/eth/catalyst/api_benchmark_test.go b/eth/catalyst/api_benchmark_test.go index 6d6ad59f7f98..0d3fc1d29bc2 100644 --- a/eth/catalyst/api_benchmark_test.go +++ b/eth/catalyst/api_benchmark_test.go @@ -23,12 +23,15 @@ import ( "fmt" "io" "math/big" + "net/http" + "net/http/httptest" "strings" "testing" "time" "github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -736,3 +739,159 @@ func BenchmarkGetBlobsV3RPCServerOnly(b *testing.B) { b.StopTimer() b.ReportMetric(float64(b.Elapsed().Milliseconds())/float64(b.N), "ms/op") } + +// benchTxDataSizes is a spread of calldata sizes approximating the mix in a +// mainnet block, where cheap transfers sit alongside large contract calls. +// Cycling these gives a mean transaction of roughly 700 bytes, which is what +// mainnet blocks of 30 to 40 million gas have been carrying. +var benchTxDataSizes = []int{0, 68, 132, 356, 900, 2500} + +// makeBenchNewPayload builds a payload holding numTx transactions, along with +// the other arguments engine_newPayloadV4 takes. +func makeBenchNewPayload(b *testing.B, numTx int) (engine.ExecutableData, []common.Hash, *common.Hash) { + config := params.MergedTestChainConfig + signer := types.LatestSigner(config) + + txs := make([][]byte, numTx) + for i := range txs { + size := benchTxDataSizes[i%len(benchTxDataSizes)] + data := make([]byte, size) + for j := range data { + data[j] = byte(i + j) + } + tx := types.MustSignNewTx(testKey, signer, &types.DynamicFeeTx{ + ChainID: config.ChainID, + Nonce: uint64(i), + GasTipCap: big.NewInt(params.GWei), + GasFeeCap: big.NewInt(10 * params.GWei), + Gas: 21000 + uint64(size)*16, + To: &testAddr, + Value: big.NewInt(1), + Data: data, + }) + enc, err := tx.MarshalBinary() + if err != nil { + b.Fatalf("encoding transaction %d failed: %v", i, err) + } + txs[i] = enc + } + + withdrawals := make([]*types.Withdrawal, 16) + for i := range withdrawals { + withdrawals[i] = &types.Withdrawal{ + Index: uint64(i), + Validator: uint64(i), + Address: testAddr, + Amount: 1e9, + } + } + + beaconRoot := common.Hash{0x42} + data := engine.ExecutableData{ + ParentHash: common.Hash{0x01}, + FeeRecipient: testAddr, + StateRoot: common.Hash{0x02}, + ReceiptsRoot: common.Hash{0x03}, + LogsBloom: make([]byte, 256), + Random: common.Hash{0x04}, + Number: 1, + GasLimit: 60_000_000, + GasUsed: 30_000_000, + Timestamp: 1700000000, + ExtraData: []byte("benchmark"), + BaseFeePerGas: big.NewInt(params.GWei), + Transactions: txs, + Withdrawals: withdrawals, + BlobGasUsed: new(uint64), + ExcessBlobGas: new(uint64), + } + vhashes := []common.Hash{} + block, err := engine.ExecutableDataToBlockNoHash(data, vhashes, &beaconRoot, [][]byte{}) + if err != nil { + b.Fatalf("assembling the payload failed: %v", err) + } + data.BlockHash = block.Hash() + return data, vhashes, &beaconRoot +} + +// makeBenchNewPayloadRequest serializes a payload the way a consensus client +// would send it. +func makeBenchNewPayloadRequest(b *testing.B, numTx int) []byte { + data, vhashes, beaconRoot := makeBenchNewPayload(b, numTx) + payload, err := json.Marshal(data) + if err != nil { + b.Fatalf("marshaling the payload failed: %v", err) + } + hashes, err := json.Marshal(vhashes) + if err != nil { + b.Fatalf("marshaling the versioned hashes failed: %v", err) + } + root, err := json.Marshal(beaconRoot) + if err != nil { + b.Fatalf("marshaling the beacon root failed: %v", err) + } + return []byte(fmt.Sprintf( + `{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[%s,%s,%s,[]]}`, + payload, hashes, root)) +} + +// newPayloadDecodeStub answers engine_newPayloadV4 with no chain behind it. With +// toBlock set it also assembles the block. +type newPayloadDecodeStub struct { + toBlock bool + err error +} + +func (s *newPayloadDecodeStub) NewPayloadV4(ctx context.Context, params engine.ExecutableData, versionedHashes []common.Hash, beaconRoot *common.Hash, executionRequests []hexutil.Bytes) (engine.PayloadStatusV1, error) { + if s.toBlock { + requests := make([][]byte, len(executionRequests)) + for i, r := range executionRequests { + requests[i] = r + } + if _, err := engine.ExecutableDataToBlock(params, versionedHashes, beaconRoot, requests); err != nil { + s.err = err + } + } + return engine.PayloadStatusV1{Status: engine.VALID}, nil +} + +// BenchmarkNewPayloadDecode measures what an engine_newPayloadV4 request costs +// the server before the block reaches the chain. It arrives over HTTP, as it does +// from a consensus client. The decode variant stops once the arguments are +// decoded, decode+block also assembles and hash checks the block. +func BenchmarkNewPayloadDecode(b *testing.B) { + for _, numTx := range []int{64, 192, 384} { + req := makeBenchNewPayloadRequest(b, numTx) + for _, variant := range []struct { + label string + toBlock bool + }{{"decode", false}, {"decode+block", true}} { + b.Run(fmt.Sprintf("txs=%d/kb=%d/%s", numTx, len(req)/1024, variant.label), func(b *testing.B) { + stub := &newPayloadDecodeStub{toBlock: variant.toBlock} + srv := rpc.NewServer() + if err := srv.RegisterName("engine", stub); err != nil { + b.Fatalf("registering the stub failed: %v", err) + } + defer srv.Stop() + + body := string(req) + b.ReportAllocs() + b.SetBytes(int64(len(req))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + r.Header.Set("content-type", "application/json") + w := httptest.NewRecorder() + srv.ServeHTTP(w, r) + if w.Code != http.StatusOK { + b.Fatalf("status %d: %s", w.Code, w.Body.String()) + } + } + b.StopTimer() + if stub.err != nil { + b.Fatalf("assembling the block failed: %v", stub.err) + } + }) + } + } +} diff --git a/rpc/http.go b/rpc/http.go index 6340175736db..8f4f1bc92ebb 100644 --- a/rpc/http.go +++ b/rpc/http.go @@ -284,10 +284,45 @@ func (s *Server) newHTTPServerConn(r *http.Request, w http.ResponseWriter) Serve return httpWrite(ctx, w, buf, isError) } - dec := json.NewDecoder(conn) - dec.UseNumber() + // The body holds one message, so it can be read in one go and checked once. + readFrame := func() ([]byte, error) { + hint := 0 + if r.ContentLength > 0 && r.ContentLength <= int64(s.httpBodyLimit) { + hint = int(r.ContentLength) + } + frame, err := readAllBody(body, hint) + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(frame)) == 0 { + // An empty body carries no message, which is not an error. The + // decoder used to report this as EOF and callers rely on that. + return nil, io.EOF + } + return frame, nil + } + return newFuncCodec(conn, encodeMsg, encodeBatch, nil, readFrame) +} - return NewFuncCodec(conn, encodeMsg, encodeBatch, dec.Decode) +// readAllBody reads r to the end, sizing the buffer from the hint when there is +// one so that a large body does not have to be grown into. +func readAllBody(r io.Reader, hint int) ([]byte, error) { + // A byte past the hint, so a body of exactly hint bytes sees EOF without + // the buffer doubling right at the end. + buf := make([]byte, 0, max(hint+1, 512)) + for { + if len(buf) == cap(buf) { + buf = append(buf, 0)[:len(buf)] + } + n, err := r.Read(buf[len(buf):cap(buf)]) + buf = buf[:len(buf)+n] + if err != nil { + if err == io.EOF { + return buf, nil + } + return buf, err + } + } } // httpWrite writes pre-encoded response data over HTTP. diff --git a/rpc/http_test.go b/rpc/http_test.go index 15ddd59bd0a9..2cee4abf544b 100644 --- a/rpc/http_test.go +++ b/rpc/http_test.go @@ -17,8 +17,11 @@ package rpc import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "strings" @@ -263,3 +266,202 @@ func TestNewContextWithHeaders(t *testing.T) { t.Error("call failed:", err) } } + +// TestHTTPRequestFraming covers what the server answers for the shapes of body +// that reach it, including the ones that are not valid JSON. +func TestHTTPRequestFraming(t *testing.T) { + tests := []struct { + name string + body string + want string // substring the response must contain, empty means no response + }{ + { + name: "call", + body: `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}`, + want: `"result"`, + }, + { + name: "call with surrounding space", + body: " \n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test_echo\",\"params\":[\"x\",3]}\n ", + want: `"result"`, + }, + { + name: "batch", + body: `[{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}]`, + want: `"result"`, + }, + { + name: "empty body", + body: ``, + want: ``, + }, + { + name: "whitespace only", + body: " \n\t ", + want: ``, + }, + { + name: "truncated object", + body: `{"jsonrpc":"2.0","id":1,"method":"test_echo"`, + want: `parse error`, + }, + { + name: "not json", + body: `hello`, + want: `parse error`, + }, + { + name: "unbalanced bracket", + body: `[{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}`, + want: `parse error`, + }, + { + name: "control character in string", + body: "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test_\x01echo\",\"params\":[]}", + want: `parse error`, + }, + { + // A body holding more than one value is rejected. The decoder this + // replaced stopped after the first value and ignored the rest. + name: "trailing second value", + body: `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}{"a":1}`, + want: `parse error`, + }, + { + name: "trailing garbage", + body: `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]} oops`, + want: `parse error`, + }, + } + + srv := newTestServer() + defer srv.Stop() + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(tc.body)) + req.Header.Set("content-type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + body := rec.Body.String() + if tc.want == "" { + if strings.TrimSpace(body) != "" { + t.Fatalf("want no response, got %q", body) + } + return + } + if !strings.Contains(body, tc.want) { + t.Fatalf("want response containing %q, got %q", tc.want, body) + } + }) + } +} + +// TestHTTPRequestFramingChunked checks a body with no content length, which is +// the case the size hint cannot help with. +func TestHTTPRequestFramingChunked(t *testing.T) { + srv := newTestServer() + defer srv.Stop() + + body := `{"jsonrpc":"2.0","id":1,"method":"test_echo","params":["x",3]}` + req := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(strings.NewReader(body))) + req.Header.Set("content-type", "application/json") + req.ContentLength = -1 + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if got := rec.Body.String(); !strings.Contains(got, `"result"`) { + t.Fatalf("want a result, got %q", got) + } +} + +// TestReadAllBody checks the body reader against io.ReadAll for both a helpful +// and an unhelpful size hint. +func TestReadAllBody(t *testing.T) { + for _, size := range []int{0, 1, 511, 512, 513, 4096, 100000} { + want := bytes.Repeat([]byte("ab"), size/2) + for _, hint := range []int{0, 1, size, size + 1, size * 2} { + got, err := readAllBody(bytes.NewReader(want), hint) + if err != nil { + t.Fatalf("size %d hint %d: %v", size, hint, err) + } + if !bytes.Equal(got, want) { + t.Fatalf("size %d hint %d: got %d bytes, want %d", size, hint, len(got), len(want)) + } + // A hint covering the body must be enough, reading to EOF must + // not grow the buffer past it. + if hint >= len(want) && cap(got) > max(hint+1, 512) { + t.Fatalf("size %d hint %d: buffer grew to %d", size, hint, cap(got)) + } + } + } +} + +// TestReadAllBodyError checks that a read failure is reported rather than +// treated as the end of the body. +func TestReadAllBodyError(t *testing.T) { + r := io.MultiReader(strings.NewReader(`{"a":`), &errReader{}) + if _, err := readAllBody(r, 0); err == nil { + t.Fatal("want an error") + } +} + +type errReader struct{} + +func (*errReader) Read([]byte) (int, error) { return 0, io.ErrClosedPipe } + +// TestHTTPBatchRequestFraming checks a batch whose items each carry a sizeable +// argument. Every message in a batch points into the same buffer, so this would +// catch one item's arguments bleeding into another's. +func TestHTTPBatchRequestFraming(t *testing.T) { + srv := newTestServer() + defer srv.Stop() + + const items = 12 + var body strings.Builder + body.WriteByte('[') + for i := 0; i < items; i++ { + if i > 0 { + body.WriteByte(',') + } + // A distinct payload per item, large enough to span several reads. + pad := strings.Repeat(string(rune('a'+i)), 4096) + fmt.Fprintf(&body, `{"jsonrpc":"2.0","id":%d,"method":"test_echo","params":["%s",%d,{"S":"%s"}]}`, + i, pad, i, pad) + } + body.WriteByte(']') + + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body.String())) + req.Header.Set("content-type", "application/json") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + confirmStatusCode(t, rec.Code, http.StatusOK) + + var resps []struct { + ID int `json:"id"` + Result struct { + String string + Int int + Args *echoArgs + } `json:"result"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resps); err != nil { + t.Fatalf("decoding the batch response failed: %v", err) + } + if len(resps) != items { + t.Fatalf("got %d responses, want %d", len(resps), items) + } + for _, r := range resps { + want := strings.Repeat(string(rune('a'+r.ID)), 4096) + if r.Result.Int != r.ID { + t.Errorf("id %d: Int = %d", r.ID, r.Result.Int) + } + if r.Result.String != want { + t.Errorf("id %d: String is not its own argument", r.ID) + } + if r.Result.Args == nil || r.Result.Args.S != want { + t.Errorf("id %d: Args is not its own argument", r.ID) + } + } +} diff --git a/rpc/json.go b/rpc/json.go index f0e82afb5c4d..41c7fe7ab7f4 100644 --- a/rpc/json.go +++ b/rpc/json.go @@ -199,6 +199,7 @@ type jsonCodec struct { closer sync.Once // close closed channel once closeCh chan interface{} // closed on Close decode decodeFunc // decoder to allow multiple transports + readFrame readFrameFunc // set when the transport delimits messages itself encMu sync.Mutex // guards the encoder encodeMsg encodeMsgFunc // single-message encoder encodeBatch encodeBatchFunc // batch encoder @@ -211,15 +212,28 @@ type encodeBatchFunc = func(ctx context.Context, msgs []*jsonrpcMessage, isError type decodeFunc = func(v interface{}) error +// readFrameFunc returns the bytes of the next message. Only transports that +// delimit messages themselves have one. The bytes must not be reused on the next +// call, the message points into them. +type readFrameFunc = func() ([]byte, error) + // NewFuncCodec creates a codec which uses the given functions to read and write. If conn // implements ConnRemoteAddr, log messages will use it to include the remote address of -// the connection. +// the connection. The decode function must reject invalid JSON, reading a message +// relies on it. func NewFuncCodec(conn deadlineCloser, encodeMsg encodeMsgFunc, encodeBatch encodeBatchFunc, decode decodeFunc) ServerCodec { + return newFuncCodec(conn, encodeMsg, encodeBatch, decode, nil) +} + +// newFuncCodec is NewFuncCodec with the frame reader the built in transports use. +// A transport with a frame reader never calls decode, so it may be nil. +func newFuncCodec(conn deadlineCloser, encodeMsg encodeMsgFunc, encodeBatch encodeBatchFunc, decode decodeFunc, readFrame readFrameFunc) *jsonCodec { codec := &jsonCodec{ closeCh: make(chan interface{}), encodeMsg: encodeMsg, encodeBatch: encodeBatch, decode: decode, + readFrame: readFrame, conn: conn, } if ra, ok := conn.(ConnRemoteAddr); ok { @@ -299,10 +313,8 @@ func (c *jsonCodec) remoteAddr() string { } func (c *jsonCodec) readBatch() (messages []*jsonrpcMessage, batch bool, err error) { - // Decode the next JSON object in the input stream. - // This verifies basic syntax, etc. - var rawmsg json.RawMessage - if err := c.decode(&rawmsg); err != nil { + rawmsg, err := c.readMessage() + if err != nil { return nil, false, err } messages, batch = parseMessage(rawmsg) @@ -316,6 +328,38 @@ func (c *jsonCodec) readBatch() (messages []*jsonrpcMessage, batch bool, err err return messages, batch, nil } +// readMessage returns the bytes of the next message, checked to be valid JSON. +func (c *jsonCodec) readMessage() (json.RawMessage, error) { + // A stream has no framing, so the decoder finds the message end and checks it. + if c.readFrame == nil { + // Decode the next JSON object in the input stream. + // This verifies basic syntax, etc. + var rawmsg json.RawMessage + if err := c.decode(&rawmsg); err != nil { + return nil, err + } + return rawmsg, nil + } + // The transport delimits the message, so one read and one check will do. + // Decoding into a json.RawMessage would scan twice and copy. + frame, err := c.readFrame() + if err != nil { + return nil, err + } + if !json.Valid(frame) { + // Decode the broken message to report where it went wrong. Unmarshal + // checks syntax the same way Valid does, so it fails here too. The + // fallback only guards against the two ever disagreeing. + var rawmsg json.RawMessage + err := json.Unmarshal(frame, &rawmsg) + if err == nil { + err = errors.New("invalid JSON request") + } + return nil, err + } + return frame, nil +} + func (c *jsonCodec) writeJSON(ctx context.Context, msg *jsonrpcMessage, isError bool) error { c.encMu.Lock() defer c.encMu.Unlock() @@ -358,20 +402,54 @@ func (c *jsonCodec) closed() <-chan interface{} { // jsonrpcMessage. func parseMessage(raw json.RawMessage) ([]*jsonrpcMessage, bool) { if !isBatch(raw) { + // readBatch rejects a nil message, which is what null must become. + if isJSONNull(raw) { + return []*jsonrpcMessage{nil}, false + } msgs := []*jsonrpcMessage{{}} - json.Unmarshal(raw, &msgs[0]) + fillMessage(raw, msgs[0]) return msgs, false } - dec := json.NewDecoder(bytes.NewReader(raw)) - dec.Token() // skip '[' var msgs []*jsonrpcMessage - for dec.More() { - msgs = append(msgs, new(jsonrpcMessage)) - dec.Decode(&msgs[len(msgs)-1]) - } + forEachJSONElement(raw, func(elem []byte) { + // readBatch rejects a nil message, which is what null must become. + if isJSONNull(elem) { + msgs = append(msgs, nil) + return + } + msg := new(jsonrpcMessage) + fillMessage(elem, msg) + msgs = append(msgs, msg) + }) return msgs, true } +// fillMessage picks a message apart into msg. Input that does not hold an object +// leaves msg zero, and the handler rejects it later. +func fillMessage(input []byte, msg *jsonrpcMessage) { + // The raw fields point into input rather than being copied out of it, which + // matters because params is nearly all of a large request. + forEachJSONField(input, func(key, value []byte) { + switch string(key) { + case "jsonrpc": + // The string fields go through encoding/json to unescape them. + json.Unmarshal(value, &msg.Version) + case "id": + msg.ID = value + case "method": + json.Unmarshal(value, &msg.Method) + case "params": + msg.Params = value + case "error": + msg.Error = value + case "result": + msg.Result = value + default: + // ignore unknown fields + } + }) +} + // isBatch returns true when the first non-whitespace characters is '[' func isBatch(raw json.RawMessage) bool { for _, c := range raw { @@ -388,18 +466,15 @@ func isBatch(raw json.RawMessage) bool { // given types. It returns the parsed values or an error when the args could not be // parsed. Missing optional arguments are returned as reflect.Zero values. func parsePositionalArguments(rawArgs json.RawMessage, types []reflect.Type) ([]reflect.Value, error) { - dec := json.NewDecoder(bytes.NewReader(rawArgs)) var args []reflect.Value - tok, err := dec.Token() switch { - case err == io.EOF || tok == nil && err == nil: + case len(bytes.TrimSpace(rawArgs)) == 0 || isJSONNull(rawArgs): // "params" is optional and may be empty. Also allow "params":null even though it's // not in the spec because our own client used to send it. - case err != nil: - return nil, err - case tok == json.Delim('['): + case isBatch(rawArgs): // Read argument array. - if args, err = parseArgumentArray(dec, types); err != nil { + var err error + if args, err = parseArgumentArray(rawArgs, types); err != nil { return nil, err } default: @@ -415,24 +490,43 @@ func parsePositionalArguments(rawArgs json.RawMessage, types []reflect.Type) ([] return args, nil } -func parseArgumentArray(dec *json.Decoder, types []reflect.Type) ([]reflect.Value, error) { +// parseArgumentArray decodes an already syntax-checked argument array. +func parseArgumentArray(rawArgs json.RawMessage, types []reflect.Type) ([]reflect.Value, error) { + // Cutting the array into elements first means each argument is decoded once. + // A json.Decoder would walk every argument twice, once to find where it ends. args := make([]reflect.Value, 0, len(types)) - for i := 0; dec.More(); i++ { + var scanErr error + forEachJSONElement(rawArgs, func(elem []byte) { + if scanErr != nil { + return + } + i := len(args) if i >= len(types) { - return args, fmt.Errorf("too many arguments, want at most %d", len(types)) + scanErr = fmt.Errorf("too many arguments, want at most %d", len(types)) + return } argval := reflect.New(types[i]) - if err := dec.Decode(argval.Interface()); err != nil { - return args, fmt.Errorf("invalid argument %d: %v", i, err) + if err := decodeArgument(elem, argval.Interface()); err != nil { + scanErr = fmt.Errorf("invalid argument %d: %v", i, err) + return } if argval.IsNil() && types[i].Kind() != reflect.Pointer { - return args, fmt.Errorf("missing value for required argument %d", i) + scanErr = fmt.Errorf("missing value for required argument %d", i) + return } args = append(args, argval.Elem()) + }) + return args, scanErr +} + +// decodeArgument decodes one already syntax-checked argument value. +func decodeArgument(elem []byte, arg any) error { + // A type that unmarshals itself is called directly, which skips the + // validation pass json.Unmarshal runs first. + if u, ok := arg.(json.Unmarshaler); ok && !isJSONNull(elem) { + return u.UnmarshalJSON(elem) } - // Read end of args array. - _, err := dec.Token() - return args, err + return json.Unmarshal(elem, arg) } // parseSubscriptionName extracts the subscription name from an encoded argument array. diff --git a/rpc/jsonscan.go b/rpc/jsonscan.go new file mode 100644 index 000000000000..650495427b43 --- /dev/null +++ b/rpc/jsonscan.go @@ -0,0 +1,166 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package rpc + +import "bytes" + +// Helpers for finding the bounds of JSON values without parsing them, so a +// request is not walked by encoding/json once per layer. They require input +// encoding/json has already accepted, and hand out sub-slices of it which are +// still decoded afterwards. Do not use these on unchecked input. + +func isJSONSpace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +// skipJSONSpace returns the offset of the first byte at or after i that is not +// insignificant whitespace. +func skipJSONSpace(data []byte, i int) int { + for i < len(data) && isJSONSpace(data[i]) { + i++ + } + return i +} + +// scanJSONString returns the offset just past the string beginning at data[i], +// which must be its opening quote. +func scanJSONString(data []byte, i int) int { + i++ // opening quote + for i < len(data) { + j := bytes.IndexByte(data[i:], '"') + if j < 0 { + return len(data) + } + // The quote ends the string unless an odd number of backslashes run up + // to it, in which case it is escaped. + k, n := i+j-1, 0 + for k >= i && data[k] == '\\' { + n++ + k-- + } + i += j + 1 + if n%2 == 0 { + return i + } + } + return len(data) +} + +// scanJSONValue returns the offset just past the JSON value beginning at +// data[i], skipping any whitespace in front of it. +func scanJSONValue(data []byte, i int) int { + i = skipJSONSpace(data, i) + if i >= len(data) { + return len(data) + } + switch data[i] { + case '"': + return scanJSONString(data, i) + case '{', '[': + depth := 0 + for i < len(data) { + switch data[i] { + case '"': + i = scanJSONString(data, i) + continue + case '{', '[': + depth++ + case '}', ']': + depth-- + if depth == 0 { + return i + 1 + } + } + i++ + } + return len(data) + default: + // A number, true, false or null, ending at the next structural byte. + for ; i < len(data); i++ { + c := data[i] + if c == ',' || c == '}' || c == ']' || isJSONSpace(c) { + return i + } + } + return len(data) + } +} + +// forEachJSONField calls fn with the key and raw value of every member of the +// JSON object in data. Nothing is called if data does not hold an object. +func forEachJSONField(data []byte, fn func(key, value []byte)) { + i := skipJSONSpace(data, 0) + if i >= len(data) || data[i] != '{' { + return + } + i++ + for { + i = skipJSONSpace(data, i) + if i >= len(data) || data[i] == '}' { + return + } + if data[i] == ',' { + i++ + continue + } + if data[i] != '"' { + return + } + keyStart := i + keyEnd := scanJSONString(data, i) + i = skipJSONSpace(data, keyEnd) + if i >= len(data) || data[i] != ':' { + return + } + valStart := skipJSONSpace(data, i+1) + valEnd := scanJSONValue(data, valStart) + i = valEnd + if keyEnd-1 <= keyStart { + return + } + fn(data[keyStart+1:keyEnd-1], data[valStart:valEnd]) + } +} + +// forEachJSONElement calls fn with the raw value of every element of the JSON +// array in data. Nothing is called if data does not hold an array. +func forEachJSONElement(data []byte, fn func(value []byte)) { + i := skipJSONSpace(data, 0) + if i >= len(data) || data[i] != '[' { + return + } + i++ + for { + i = skipJSONSpace(data, i) + if i >= len(data) || data[i] == ']' { + return + } + if data[i] == ',' { + i++ + continue + } + start := i + i = scanJSONValue(data, i) + fn(data[start:i]) + } +} + +// isJSONNull reports whether data holds the JSON null literal. +func isJSONNull(data []byte) bool { + i := skipJSONSpace(data, 0) + return bytes.Equal(bytes.TrimRight(data[i:], " \t\n\r"), []byte("null")) +} diff --git a/rpc/jsonscan_test.go b/rpc/jsonscan_test.go new file mode 100644 index 000000000000..5392522c41b2 --- /dev/null +++ b/rpc/jsonscan_test.go @@ -0,0 +1,505 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package rpc + +import ( + "bytes" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" +) + +// messageCorpus seeds the fuzz targets below. Every entry is valid JSON, which +// is the state of a message by the time parseMessage sees it. +var messageCorpus = []string{ + `{}`, + `{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`, + `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{"to":"0x00"},"latest"]}`, + `{"id":null}`, + `{"result":null}`, + `{"params":null}`, + `{"jsonrpc":"2.0","id":1,"result":null}`, + `{"id":"str-id","method":"m"}`, + `{"id":1.5e3,"method":"m"}`, + `{"id":true,"method":"m"}`, + // escapes in the string fields + `{"method":"a\"b","id":1}`, + `{"method":"a\\b","id":1}`, + `{"method":"hello","id":1}`, + `{"method":"tab\there","id":1}`, + `{"method":"back\\\\slash","id":1}`, + // structural bytes inside strings must not confuse the scan + `{"method":"m","params":["{[,:}]"],"id":1}`, + `{"method":"m","params":["a\"},{\"b"],"id":1}`, + `{"method":"m","params":["ends with backslash\\"],"id":1}`, + // whitespace + "{ \"method\" : \"m\" , \"id\" : 1 }", + "\n\t{\"method\":\"m\",\"id\":2}\r\n", + `{"params": [ 1 , 2 ] ,"id":3}`, + // duplicate keys, last one wins + `{"method":"first","method":"second","id":1}`, + `{"id":1,"id":2}`, + // keys in other spellings are unknown fields, only the exact names match + `{"METHOD":"m","ID":1}`, + `{"Method":"m","Id":1,"Jsonrpc":"2.0"}`, + "{\"metho\\u0064\":\"m\",\"i\\u0064\":7}", + `{"metho\\u0064":"not the method key"}`, + // unknown fields are ignored + `{"method":"m","id":1,"extra":{"a":[1,2,3]},"more":"x"}`, + // nested params + `{"method":"m","id":1,"params":[[[[1]]]],"x":1}`, + `{"method":"m","id":1,"params":[{"a":{"b":{"c":[]}}}]}`, + // empty and odd values + `{"method":"","id":1}`, + `{"":1,"method":"m"}`, + // not an object at all + `1`, + `"str"`, + `null`, + `true`, + // batches + `[]`, + `[{"method":"a","id":1}]`, + `[{"method":"a","id":1},{"method":"b","id":2}]`, + `[null]`, + `[{"method":"a","id":1},null,{"method":"b","id":2}]`, + `[1,2,3]`, + `["a","b"]`, + `[[1],[2]]`, + `[ { "method" : "a" , "id" : 1 } , null ]`, + `[{"method":"m","params":["},{"]}]`, +} + +// TestParseMessage covers the inputs where the envelope split has to make a +// decision. +func TestParseMessage(t *testing.T) { + msg := func(version, method, id, params, result string) *jsonrpcMessage { + m := &jsonrpcMessage{Version: version, Method: method} + if id != "" { + m.ID = json.RawMessage(id) + } + if params != "" { + m.Params = json.RawMessage(params) + } + if result != "" { + m.Result = json.RawMessage(result) + } + return m + } + zero := func() *jsonrpcMessage { return msg("", "", "", "", "") } + + tests := []struct { + name string + input string + batch bool + want []*jsonrpcMessage + }{ + {"empty object", `{}`, false, []*jsonrpcMessage{zero()}}, + { + "call", + `{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}`, + false, []*jsonrpcMessage{msg("2.0", "eth_chainId", "1", "[]", "")}, + }, + // null stays in the raw fields as the literal. isResponse needs a null + // result to count as present. + {"null id", `{"id":null}`, false, []*jsonrpcMessage{msg("", "", "null", "", "")}}, + {"null result", `{"result":null}`, false, []*jsonrpcMessage{msg("", "", "", "", "null")}}, + {"null params", `{"params":null}`, false, []*jsonrpcMessage{msg("", "", "", "null", "")}}, + + {"escaped quote in method", `{"method":"a\"b","id":1}`, false, []*jsonrpcMessage{msg("", `a"b`, "1", "", "")}}, + {"escaped tab in method", `{"method":"tab\there","id":1}`, false, []*jsonrpcMessage{msg("", "tab\there", "1", "", "")}}, + {"duplicate key, last wins", `{"method":"first","method":"second","id":1}`, false, []*jsonrpcMessage{msg("", "second", "1", "", "")}}, + + // field names have one spelling in the spec, any other spelling is an + // unknown key, even where encoding/json would have matched it + {"cased keys ignored", `{"Method":"m","ID":1,"Params":[1]}`, false, []*jsonrpcMessage{zero()}}, + {"upper case keys ignored", `{"METHOD":"m","JSONRPC":"2.0"}`, false, []*jsonrpcMessage{zero()}}, + {"escaped keys ignored", "{\"metho\\u0064\":\"m\",\"i\\u0064\":7}", false, []*jsonrpcMessage{zero()}}, + + // a string holding structural bytes must not end the value early + { + "structural bytes inside a string", + `{"method":"m","params":["a\"},{\"b"],"id":1}`, + false, []*jsonrpcMessage{msg("", "m", "1", `["a\"},{\"b"]`, "")}, + }, + {"space inside params is kept", `{"params": [ 1 , 2 ] ,"id":3}`, false, []*jsonrpcMessage{msg("", "", "3", "[ 1 , 2 ]", "")}}, + {"space around the message", "\n\t{\"method\":\"m\",\"id\":2}\r\n", false, []*jsonrpcMessage{msg("", "m", "2", "", "")}}, + {"unknown fields ignored", `{"method":"m","id":1,"extra":{"a":[1,2,3]},"more":"x"}`, false, []*jsonrpcMessage{msg("", "m", "1", "", "")}}, + + // valid JSON that is not a message leaves a zero one for the handler to reject + {"not an object", `1`, false, []*jsonrpcMessage{zero()}}, + {"bare null", `null`, false, []*jsonrpcMessage{nil}}, + + {"empty batch", `[]`, true, nil}, + {"batch holding one null", `[null]`, true, []*jsonrpcMessage{nil}}, + { + "batch with a null in it", + `[{"method":"a","id":1},null,{"method":"b","id":2}]`, + true, []*jsonrpcMessage{msg("", "a", "1", "", ""), nil, msg("", "b", "2", "", "")}, + }, + {"batch of non-objects", `[1,2,3]`, true, []*jsonrpcMessage{zero(), zero(), zero()}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if !json.Valid([]byte(tc.input)) { + t.Fatalf("test input is not valid JSON: %s", tc.input) + } + got, batch := parseMessage(json.RawMessage(tc.input)) + if batch != tc.batch { + t.Fatalf("batch = %v, want %v", batch, tc.batch) + } + if len(got) != len(tc.want) { + t.Fatalf("got %d messages, want %d", len(got), len(tc.want)) + } + for i := range tc.want { + if (got[i] == nil) != (tc.want[i] == nil) { + t.Fatalf("message %d nil = %v, want %v", i, got[i] == nil, tc.want[i] == nil) + } + if got[i] == nil { + continue + } + if err := sameMessage(got[i], tc.want[i]); err != nil { + t.Errorf("message %d: %v", i, err) + } + } + }) + } +} + +func sameMessage(got, want *jsonrpcMessage) error { + if got.Version != want.Version { + return fmt.Errorf("Version = %q, want %q", got.Version, want.Version) + } + if got.Method != want.Method { + return fmt.Errorf("Method = %q, want %q", got.Method, want.Method) + } + for _, f := range []struct { + name string + got, want json.RawMessage + }{ + {"ID", got.ID, want.ID}, + {"Params", got.Params, want.Params}, + {"Error", got.Error, want.Error}, + {"Result", got.Result, want.Result}, + } { + if (f.got == nil) != (f.want == nil) { + return fmt.Errorf("%s nil = %v, want %v (got %q want %q)", f.name, f.got == nil, f.want == nil, f.got, f.want) + } + // A raw field is a slice of the input, so it should come back byte for byte. + if !bytes.Equal(f.got, f.want) { + return fmt.Errorf("%s = %q, want %q", f.name, f.got, f.want) + } + } + return nil +} + +// selfDecoding stands in for an argument type that unmarshals itself. +type selfDecoding struct { + Text string +} + +func (s *selfDecoding) UnmarshalJSON(input []byte) error { + if len(input) < 2 || input[0] != '"' { + return fmt.Errorf("selfDecoding: not a string: %s", input) + } + s.Text = string(input[1 : len(input)-1]) + return nil +} + +// TestParsePositionalArguments covers how an argument array is cut up, including +// the cases that error. +func TestParsePositionalArguments(t *testing.T) { + var ( + tInt = reflect.TypeOf(int(0)) + tPtr = reflect.TypeOf(new(int)) + tStr = reflect.TypeOf("") + tMap = reflect.TypeOf(map[string]int{}) + tSelf = reflect.TypeOf(selfDecoding{}) + ) + tests := []struct { + name string + args string + types []reflect.Type + want []any + wantErr string + }{ + {"no arguments", `[]`, nil, nil, ""}, + {"two ints", `[1,2]`, []reflect.Type{tInt, tInt}, []any{1, 2}, ""}, + {"space between arguments", `[ 1 , 2 ]`, []reflect.Type{tInt, tInt}, []any{1, 2}, ""}, + {"object argument", `[{"a":1}]`, []reflect.Type{tMap}, []any{map[string]int{"a": 1}}, ""}, + {"structural bytes inside a string", `["},{"]`, []reflect.Type{tStr}, []any{`},{`}, ""}, + {"null into a pointer", `[null]`, []reflect.Type{tPtr}, []any{(*int)(nil)}, ""}, + {"null into a value", `[null]`, []reflect.Type{tInt}, []any{0}, ""}, + {"missing optional argument", `[1]`, []reflect.Type{tInt, tPtr}, []any{1, (*int)(nil)}, ""}, + + {"too many arguments", `[1,2,3]`, []reflect.Type{tInt}, nil, "too many arguments"}, + {"missing required argument", `[1]`, []reflect.Type{tInt, tInt}, nil, "missing value for required argument 1"}, + {"no arguments at all", `[]`, []reflect.Type{tInt}, nil, "missing value for required argument 0"}, + {"wrong type", `["not an int"]`, []reflect.Type{tInt}, nil, "invalid argument 0"}, + + // a self decoding type is handed the value directly, except for null + {"self decoding", `["0x1234"]`, []reflect.Type{tSelf}, []any{selfDecoding{Text: "0x1234"}}, ""}, + {"self decoding null", `[null]`, []reflect.Type{tSelf}, nil, "invalid argument 0"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if !json.Valid([]byte(tc.args)) { + t.Fatalf("test input is not valid JSON: %s", tc.args) + } + got, err := parsePositionalArguments(json.RawMessage(tc.args), tc.types) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want it to mention %q", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("got %d arguments, want %d", len(got), len(tc.want)) + } + for i := range tc.want { + if v := got[i].Interface(); !reflect.DeepEqual(v, tc.want[i]) { + t.Errorf("argument %d = %#v, want %#v", i, v, tc.want[i]) + } + } + }) + } +} + +// TestParsePositionalArgumentsEmpty covers the inputs that reach the function +// when a request carries no params at all. +func TestParsePositionalArgumentsEmpty(t *testing.T) { + for _, args := range []string{"", " ", "null", " null "} { + got, err := parsePositionalArguments(json.RawMessage(args), nil) + if err != nil { + t.Errorf("%q: unexpected error %v", args, err) + } + if len(got) != 0 { + t.Errorf("%q: got %d args, want 0", args, len(got)) + } + } +} + +// benchMessages are request shapes the server sees, from a one line call to a +// payload sized one. +func benchMessages() []struct { + name string + req string +} { + bigArg := func(n int) string { + var buf bytes.Buffer + buf.WriteString(`{"parentHash":"0x1234","transactions":[`) + for i := 0; i < n; i++ { + if i > 0 { + buf.WriteByte(',') + } + buf.WriteByte('"') + buf.WriteString("0x") + for j := 0; j < 1024; j++ { + buf.WriteString("ab") + } + buf.WriteByte('"') + } + buf.WriteString(`]}`) + return buf.String() + } + return []struct { + name string + req string + }{ + {"tiny", `{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}`}, + {"small", `{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["0x1b4",true]}`}, + {"batch10", func() string { + var buf bytes.Buffer + buf.WriteByte('[') + for i := 0; i < 10; i++ { + if i > 0 { + buf.WriteByte(',') + } + fmt.Fprintf(&buf, `{"jsonrpc":"2.0","id":%d,"method":"eth_chainId","params":[]}`, i) + } + buf.WriteByte(']') + return buf.String() + }()}, + {"payload64kb", fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[%s,[],null,[]]}`, bigArg(32))}, + {"payload512kb", fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"engine_newPayloadV4","params":[%s,[],null,[]]}`, bigArg(256))}, + } +} + +// BenchmarkParseMessage measures the envelope split. +func BenchmarkParseMessage(b *testing.B) { + for _, tc := range benchMessages() { + raw := json.RawMessage(tc.req) + b.Run(tc.name, func(b *testing.B) { + b.ReportAllocs() + b.SetBytes(int64(len(raw))) + for b.Loop() { + parseMessage(raw) + } + }) + } +} + +// BenchmarkParsePositionalArguments measures argument decoding for a type that +// decodes itself, which is the shape an engine API payload has. +func BenchmarkParsePositionalArguments(b *testing.B) { + types := []reflect.Type{reflect.TypeOf(selfDecoding{})} + for _, size := range []int{1, 64, 512} { + var buf bytes.Buffer + buf.WriteString(`["0x`) + for i := 0; i < size*512; i++ { + buf.WriteString("ab") + } + buf.WriteString(`"]`) + raw := json.RawMessage(buf.String()) + b.Run(fmt.Sprintf("kb%d", len(raw)/1024), func(b *testing.B) { + b.ReportAllocs() + b.SetBytes(int64(len(raw))) + for b.Loop() { + if _, err := parsePositionalArguments(raw, types); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// FuzzJSONScanFields checks that the field scan agrees with encoding/json on any +// valid JSON object. +func FuzzJSONScanFields(f *testing.F) { + for _, s := range messageCorpus { + f.Add(s) + } + f.Add(`{"a":"😀","b":[1,{"c":null}]}`) + f.Fuzz(func(t *testing.T, input string) { + data := []byte(input) + if !json.Valid(data) { + return + } + var want map[string]json.RawMessage + if err := json.Unmarshal(data, &want); err != nil { + return // not an object + } + got := make(map[string]json.RawMessage) + forEachJSONField(data, func(key, value []byte) { + // The scan hands back the key still escaped, so unescape it the same + // way the map decode did before comparing. + var k string + if err := json.Unmarshal(append(append([]byte{'"'}, key...), '"'), &k); err != nil { + t.Fatalf("key %q does not unescape: %v", key, err) + } + got[k] = value + }) + if len(got) != len(want) { + t.Fatalf("got %d fields, want %d (input %s)", len(got), len(want), input) + } + for k, wv := range want { + gv, ok := got[k] + if !ok { + t.Fatalf("missing field %q (input %s)", k, input) + } + if !bytes.Equal(bytes.TrimSpace(gv), bytes.TrimSpace(wv)) { + t.Fatalf("field %q = %q, want %q (input %s)", k, gv, wv, input) + } + } + }) +} + +// FuzzFillMessage checks the envelope split against encoding/json field by +// field. Field names have one spelling, so the reference picks each one out of +// a decoded map by its exact name. +func FuzzFillMessage(f *testing.F) { + for _, s := range messageCorpus { + f.Add(s) + } + f.Fuzz(func(t *testing.T, input string) { + data := []byte(input) + if !json.Valid(data) { + return + } + if bytes.IndexByte(data, '\\') >= 0 { + // encoding/json unescapes map keys, so an escaped key would match + // in the reference but is an unknown key to fillMessage. Escape + // handling is pinned by the tests above. + return + } + var want jsonrpcMessage + var obj map[string]json.RawMessage + if err := json.Unmarshal(data, &obj); err == nil { + if v, ok := obj["jsonrpc"]; ok { + json.Unmarshal(v, &want.Version) + } + if v, ok := obj["id"]; ok { + want.ID = v + } + if v, ok := obj["method"]; ok { + json.Unmarshal(v, &want.Method) + } + if v, ok := obj["params"]; ok { + want.Params = v + } + if v, ok := obj["error"]; ok { + want.Error = v + } + if v, ok := obj["result"]; ok { + want.Result = v + } + } + got := new(jsonrpcMessage) + fillMessage(data, got) + if err := sameMessage(got, &want); err != nil { + t.Fatalf("%v (input %s)", err, input) + } + }) +} + +// FuzzJSONScanElements checks that the element scan agrees with encoding/json on +// any valid JSON array. +func FuzzJSONScanElements(f *testing.F) { + for _, s := range messageCorpus { + f.Add(s) + } + f.Add(`[1,"two",{"three":3},[4],null,true]`) + f.Fuzz(func(t *testing.T, input string) { + data := []byte(input) + if !json.Valid(data) { + return + } + var want []json.RawMessage + if err := json.Unmarshal(data, &want); err != nil { + return // not an array + } + var got []json.RawMessage + forEachJSONElement(data, func(value []byte) { + got = append(got, value) + }) + if len(got) != len(want) { + t.Fatalf("got %d elements, want %d (input %s)", len(got), len(want), input) + } + for i := range want { + if !bytes.Equal(bytes.TrimSpace(got[i]), bytes.TrimSpace(want[i])) { + t.Fatalf("element %d = %q, want %q (input %s)", i, got[i], want[i], input) + } + } + }) +} diff --git a/rpc/websocket.go b/rpc/websocket.go index 5e1e09c89dca..19eb0ccdc113 100644 --- a/rpc/websocket.go +++ b/rpc/websocket.go @@ -302,8 +302,13 @@ func newWebsocketCodec(conn *websocket.Conn, host string, req http.Header, readL buf = appendBatch(buf[:0], msgs) return conn.WriteMessage(websocket.TextMessage, buf) } + // Every frame is one message, so it can be read in one go and checked once. + readFrame := func() ([]byte, error) { + _, frame, err := conn.ReadMessage() + return frame, err + } wc := &websocketCodec{ - jsonCodec: NewFuncCodec(conn, encodeMsg, encodeBatch, conn.ReadJSON).(*jsonCodec), + jsonCodec: newFuncCodec(conn, encodeMsg, encodeBatch, nil, readFrame), conn: conn, pingReset: make(chan struct{}, 1), pongReceived: make(chan struct{}),