diff --git a/l1/eth/client/options.go b/l1/eth/client/options.go new file mode 100644 index 0000000000..74a2bd1b1c --- /dev/null +++ b/l1/eth/client/options.go @@ -0,0 +1,35 @@ +// Package client speaks JSON-RPC 2.0 to an Ethereum execution-layer node +// over WebSocket. It implements the small surface juno needs to follow the +// L1 head and serve starknet_getMessageStatus. +package client + +import ( + "time" + + "github.com/NethermindEth/juno/utils/log" +) + +type Option func(*options) + +type options struct { + logger log.StructuredLogger + pingInterval time.Duration + pingTimeout time.Duration + dialTimeout time.Duration +} + +func WithLogger(l log.StructuredLogger) Option { + return func(o *options) { o.logger = l } +} + +func WithDialTimeout(d time.Duration) Option { + return func(o *options) { o.dialTimeout = d } +} + +// WithPingConfig falls back to the defaults (30s/10s) for non-positive values. +func WithPingConfig(interval, timeout time.Duration) Option { + return func(o *options) { + o.pingInterval = interval + o.pingTimeout = timeout + } +} diff --git a/l1/eth/client/transport_ws.go b/l1/eth/client/transport_ws.go new file mode 100644 index 0000000000..092072565d --- /dev/null +++ b/l1/eth/client/transport_ws.go @@ -0,0 +1,338 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/NethermindEth/juno/jsonrpc" + "github.com/NethermindEth/juno/utils/log" + "github.com/coder/websocket" + "go.uber.org/zap" +) + +var ErrTransportClosed = errors.New("transport closed") + +const ( + // wsReadLimit (16 MiB) is far above any real payload; it only stops a + // malicious server from forcing unbounded allocations. + wsReadLimit = 16 << 20 + + wsPingInterval = 30 * time.Second + wsPingTimeout = 10 * time.Second + wsDialTimeout = time.Minute + + // wsWriteTimeout bounds a frame write independently of any caller's ctx: + // coder/websocket closes the whole conn if a write ctx cancels mid-frame, + // so one caller's cancel must not flap the shared conn. + wsWriteTimeout = 10 * time.Second +) + +type rpcReply struct { + result json.RawMessage + err error +} + +// rpcError adapts a server jsonrpc.Error into a Go error at the client boundary. +type rpcError struct{ err *jsonrpc.Error } + +func (e rpcError) Error() string { + if e.err.Data != nil { + return fmt.Sprintf("jsonrpc %d: %s: %v", e.err.Code, e.err.Message, e.err.Data) + } + return fmt.Sprintf("jsonrpc %d: %s", e.err.Code, e.err.Message) +} + +// wsTransport multiplexes unary calls over one conn, routed by request id. +type wsTransport struct { + conn *websocket.Conn + nextID atomic.Uint64 + logger log.StructuredLogger + + mu sync.Mutex + pending map[uint64]chan rpcReply // by request id + + pingInterval time.Duration + pingTimeout time.Duration + + closed chan struct{} + // cancelLoops ends readLoop and pingLoop; called from shutdown. + cancelLoops context.CancelFunc + closeErr error + closeOnce sync.Once +} + +func dialWS(ctx context.Context, rawURL string, opts options) (*wsTransport, error) { + if opts.logger == nil { + opts.logger = log.NewNopZapLogger() + } + if opts.pingInterval <= 0 { + opts.pingInterval = wsPingInterval + } + if opts.pingTimeout <= 0 { + opts.pingTimeout = wsPingTimeout + } + dialTimeout := opts.dialTimeout + if dialTimeout <= 0 { + dialTimeout = wsDialTimeout + } + dialCtx, cancelDial := context.WithTimeout(ctx, dialTimeout) + defer cancelDial() + // nil DialOptions is deliberate: juno's endpoints authenticate via + // key-in-URL (no custom headers), and compression stays off by default. + conn, resp, err := websocket.Dial(dialCtx, rawURL, nil) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + return nil, fmt.Errorf("dialing ws: %w", err) + } + conn.SetReadLimit(wsReadLimit) + t := &wsTransport{ + conn: conn, + logger: opts.logger, + pending: make(map[uint64]chan rpcReply), + pingInterval: opts.pingInterval, + pingTimeout: opts.pingTimeout, + closed: make(chan struct{}), + } + // The loops outlive every caller by design — the Client retires them via + // Close or redial (shutdown cancels) — so Background is their true parent. + loopCtx, cancel := context.WithCancel(context.Background()) + t.cancelLoops = cancel + go t.readLoop(loopCtx) //nolint:gosec // G118: long-lived loop, not request-scoped + go t.pingLoop(loopCtx) //nolint:gosec // G118: long-lived loop, not request-scoped + return t, nil +} + +// readLoop drops malformed frames rather than tearing the transport down — +// a misbehaving remote manifests as a call timeout. +func (t *wsTransport) readLoop(ctx context.Context) { + for { + _, data, err := t.conn.Read(ctx) + if err != nil { + t.shutdown(err) + return + } + t.dispatch(data) + } +} + +// pingLoop pings unconditionally: a ping is a round-trip probe, so it also +// catches half-open conns that successful writes alone would mask. A ping +// failure shuts the transport down via the same path as a read error. +func (t *wsTransport) pingLoop(ctx context.Context) { + ticker := time.NewTicker(t.pingInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pingCtx, cancel := context.WithTimeout(ctx, t.pingTimeout) + err := t.conn.Ping(pingCtx) + cancel() + if err != nil { + t.shutdown(fmt.Errorf("pinging ws: %w", err)) + return + } + } + } +} + +func (t *wsTransport) dispatch(data []byte) { + var probe struct { + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method,omitempty"` + } + if err := json.Unmarshal(data, &probe); err != nil { + t.logger.Trace( + "drop unparseable frame", + zap.Int("bytes", len(data)), + zap.Error(err), + ) + return + } + switch { + case len(probe.ID) > 0 && !isJSONNull(probe.ID): + t.dispatchResponse(data, probe.ID) + default: + t.logger.Trace( + "drop frame with no id and no recognised method", + zap.ByteString("method", []byte(probe.Method)), + ) + } +} + +func (t *wsTransport) dispatchResponse(data []byte, rawID json.RawMessage) { + id, err := strconv.ParseUint(string(rawID), 10, 64) + if err != nil { + t.logger.Trace( + "drop response (bad id)", + zap.ByteString("rawID", rawID), + zap.Error(err), + ) + return + } + var rawResult json.RawMessage + resp := jsonrpc.Response{Result: &rawResult} + if err := json.Unmarshal(data, &resp); err != nil { + t.logger.Trace( + "drop response (decode failed)", + zap.Int("bytes", len(data)), + zap.Error(err), + ) + return + } + + t.mu.Lock() + ch, hasPending := t.pending[id] + delete(t.pending, id) + t.mu.Unlock() + + if !hasPending { + // Caller's ctx fired before the reply landed, or an unsolicited reply. + t.logger.Trace( + "drop response (no pending caller)", + zap.Uint64("id", id), + ) + return + } + + reply := rpcReply{} + if resp.Error != nil { + reply.err = rpcError{resp.Error} + } else { + reply.result = rawResult + } + + // Non-blocking: ch is buffered to 1; a gone caller (ctx cancelled) leaves it unread. + select { + case ch <- reply: + default: + } +} + +// shutdown is the single termination path. The cause is normalised so +// errors.Is(err, ErrTransportClosed) holds for every observer, including the +// in-flight call that races the disconnect and must redial. +func (t *wsTransport) shutdown(cause error) { + t.closeOnce.Do(func() { + switch { + case cause == nil: + cause = ErrTransportClosed + case !errors.Is(cause, ErrTransportClosed): + // Wrap so errors.Is holds; single-line (errors.Join splits across log lines). + cause = fmt.Errorf("%w: %w", ErrTransportClosed, cause) + } + t.mu.Lock() + pending := t.pending + t.pending = nil + t.closeErr = cause + t.mu.Unlock() + close(t.closed) + + for _, ch := range pending { + select { + case ch <- rpcReply{err: cause}: + default: + } + } + if t.cancelLoops != nil { + t.cancelLoops() + } + // CloseNow: don't block on a handshake the remote may have abandoned. + _ = t.conn.CloseNow() + }) +} + +func (t *wsTransport) close() { t.shutdown(ErrTransportClosed) } + +// writeJSON bounds writes by wsWriteTimeout, not the caller's ctx (see the +// const); caller cancellation applies only while awaiting the reply. +func (t *wsTransport) writeJSON(v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), wsWriteTimeout) + defer cancel() + if err := t.conn.Write(ctx, websocket.MessageText, data); err != nil { + t.shutdown(fmt.Errorf("writing frame: %w", err)) + return fmt.Errorf("%w: writing frame: %w", ErrTransportClosed, err) + } + return nil +} + +func (t *wsTransport) call( + ctx context.Context, + method string, + params ...any, +) (json.RawMessage, error) { + if params == nil { + params = []any{} + } + id := t.nextID.Add(1) + ch := make(chan rpcReply, 1) + + t.mu.Lock() + if t.pending == nil { + t.mu.Unlock() + return nil, ErrTransportClosed + } + t.pending[id] = ch + t.mu.Unlock() + + deregister := func() { + t.mu.Lock() + if t.pending != nil { + delete(t.pending, id) + } + t.mu.Unlock() + } + + if err := t.writeJSON(jsonrpc.Request{ + Version: "2.0", + ID: id, + Method: method, + Params: params, + }); err != nil { + deregister() + return nil, err + } + + select { + case reply := <-ch: + // dispatchResponse already removed our entry; deregister is a no-op. + if reply.err != nil { + // A concurrent shutdown fans an error into ch as the caller cancels; prefer the ctx error. + if cerr := ctx.Err(); cerr != nil { + return nil, cerr + } + return nil, reply.err + } + return reply.result, nil + case <-ctx.Done(): + deregister() + return nil, ctx.Err() + case <-t.closed: + deregister() + // Same ctx/close race as the reply branch above; prefer the + // caller's ctx error. + if cerr := ctx.Err(); cerr != nil { + return nil, cerr + } + return nil, t.closeErr + } +} + +func isJSONNull(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + return len(trimmed) == 0 || string(trimmed) == "null" +} diff --git a/l1/eth/client/transport_ws_internal_test.go b/l1/eth/client/transport_ws_internal_test.go new file mode 100644 index 0000000000..fc521deb92 --- /dev/null +++ b/l1/eth/client/transport_ws_internal_test.go @@ -0,0 +1,41 @@ +// White-box tests. These two assertions cannot be made through the public API: +// a dead conn triggers both the write path and readLoop's shutdown, so a +// black-box test can't pin WHICH path classified the error, and pendingSubs +// retention is only observable by inspecting the map itself. +package client + +import ( + "context" + "testing" + + "github.com/NethermindEth/juno/l1/internal/clienttest" + "github.com/coder/websocket" + "github.com/stretchr/testify/require" +) + +func TestWS_WriteFailureClassifiedAsTransportClosed(t *testing.T) { + srv := clienttest.NewTestServer(t) + conn, resp, err := websocket.Dial(t.Context(), srv.WSURL(), nil) + require.NoError(t, err) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + require.NoError(t, conn.CloseNow()) + + tr := &wsTransport{ + conn: conn, + pending: make(map[uint64]chan rpcReply), + closed: make(chan struct{}), + } + + _, err = tr.call(context.Background(), "eth_chainId") + require.Error(t, err) + require.ErrorIs(t, err, ErrTransportClosed, + "write failures must classify as ErrTransportClosed so the caller redials") + + select { + case <-tr.closed: + default: + t.Fatal("a failed write must shut the transport down, not leave it half-alive") + } +} diff --git a/l1/eth/client/transport_ws_test.go b/l1/eth/client/transport_ws_test.go new file mode 100644 index 0000000000..4fb9768c73 --- /dev/null +++ b/l1/eth/client/transport_ws_test.go @@ -0,0 +1,225 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/NethermindEth/juno/l1/internal/clienttest" + "github.com/NethermindEth/juno/utils/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestTransport(t *testing.T, srv *clienttest.TestServer, opts ...Option) *wsTransport { + t.Helper() + o := options{logger: log.NewNopZapLogger()} + for _, opt := range opts { + opt(&o) + } + tr, err := dialWS(t.Context(), srv.WSURL(), o) + require.NoError(t, err) + t.Cleanup(tr.close) + return tr +} + +func TestWS_UnaryCall(t *testing.T) { + srv := clienttest.NewTestServer(t) + srv.SetHandler(func(req clienttest.TestRequest) (any, *clienttest.TestRPCError) { + require.Equal(t, "eth_chainId", req.Method) + return "0x539", nil + }) + + tr := newTestTransport(t, srv) + + raw, err := tr.call(t.Context(), "eth_chainId") + require.NoError(t, err) + assert.Equal(t, `"0x539"`, string(raw)) +} + +func TestWS_ContextCancelMidCall(t *testing.T) { + srv := clienttest.NewTestServer(t) + // Handler signals on arrival then blocks, so the caller cancels with the + // call reliably in flight - no wall-clock guessing. + received := make(chan struct{}) + gate := make(chan struct{}) + t.Cleanup(func() { close(gate) }) + srv.SetHandler(func(req clienttest.TestRequest) (any, *clienttest.TestRPCError) { + close(received) + <-gate + return "0x0", nil + }) + + tr := newTestTransport(t, srv) + + ctx, cancel := context.WithCancel(t.Context()) + go func() { + <-received + cancel() + }() + _, err := tr.call(ctx, "eth_chainId") + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got %v", err) +} + +func TestWS_PingLoopFires(t *testing.T) { + srv := clienttest.NewTestServer(t) + tr := newTestTransport(t, srv, WithPingConfig(20*time.Millisecond, time.Second)) + _ = tr + + require.Eventually(t, func() bool { + return srv.PingsReceived() >= 3 + }, 2*time.Second, 10*time.Millisecond, + "expected >= 3 pings within window; got %d", srv.PingsReceived()) +} + +func TestWS_PingTimeoutClosesTransport(t *testing.T) { + srv := clienttest.NewTestServer(t) + srv.SetDropPings(true) + tr := newTestTransport(t, srv, WithPingConfig(20*time.Millisecond, 50*time.Millisecond)) + + select { + case <-tr.closed: + case <-time.After(2 * time.Second): + t.Fatal("transport did not shut down after ping timeout") + } + _, err := tr.call(t.Context(), "eth_chainId") + require.ErrorIs(t, err, ErrTransportClosed) +} + +func TestWS_DispatchDropsMalformedFrames(t *testing.T) { + const subID = "0xabc" + srv := clienttest.NewTestServer(t) + srv.SetHandler(func(req clienttest.TestRequest) (any, *clienttest.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + return true, nil + case "eth_chainId": + return "0x1", nil + } + return nil, &clienttest.TestRPCError{Code: -32601, Message: req.Method} + }) + + tr := newTestTransport(t, srv) + + frames := [][]byte{ + // Unparseable top-level JSON. + []byte(`{not json`), + // No id and no recognised method → "drop frame" branch. + []byte(`{"jsonrpc":"2.0","method":"unknown_method"}`), + // Response with a string id that isn't numeric — parseResponseID errors. + []byte(`{"jsonrpc":"2.0","id":"not-a-number","result":"0x1"}`), + // Response with a numeric id that doesn't match any in-flight call. + []byte(`{"jsonrpc":"2.0","id":999999,"result":"0x1"}`), + // Notification for an unknown subscription id. + []byte(`{"jsonrpc":"2.0","method":"eth_subscription",` + + `"params":{"subscription":"0xdead","result":{}}}`), + // Notification with broken envelope (decode fails on params). + []byte(`{"jsonrpc":"2.0","method":"eth_subscription","params":"oops"}`), + // Response with malformed body (id present, but result not decodable as JSON). + []byte(`{"jsonrpc":"2.0","id":1,"result":`), + } + for _, f := range frames { + require.NoError(t, srv.PushRawFrame(t.Context(), f), + "server-side write should not fail") + } + + raw, err := tr.call(t.Context(), "eth_chainId") + require.NoError(t, err, "transport must survive every malformed frame") + assert.Equal(t, `"0x1"`, string(raw)) +} + +// TestWS_ConcurrentCallsGetTheirOwnReplies drives the id-routing table with +// parallel callers; a mis-keyed reply surfaces as a caller receiving another +// caller's block number. +func TestWS_ConcurrentCallsGetTheirOwnReplies(t *testing.T) { + srv := clienttest.NewTestServer(t) + srv.SetHandler(func(req clienttest.TestRequest) (any, *clienttest.TestRPCError) { + if req.Method != "eth_getBlockByNumber" { + return nil, &clienttest.TestRPCError{Code: -32601, Message: req.Method} + } + // Echo the requested tag back as the block number, correlating + // each reply with exactly one call. + var tag string + if err := json.Unmarshal(req.Params[0], &tag); err != nil { + return nil, &clienttest.TestRPCError{Code: -32602, Message: err.Error()} + } + return tag, nil + }) + + tr := newTestTransport(t, srv) + + const goroutines, callsEach = 8, 25 + var wg sync.WaitGroup + errCh := make(chan error, goroutines*callsEach) + for g := range goroutines { + wg.Add(1) + go func() { + defer wg.Done() + for i := range callsEach { + want := fmt.Sprintf("%q", fmt.Sprintf("0x%x", uint64(g*callsEach+i+1))) + raw, err := tr.call(t.Context(), "eth_getBlockByNumber", fmt.Sprintf("0x%x", uint64(g*callsEach+i+1))) + if err != nil { + errCh <- err + return + } + if string(raw) != want { + errCh <- fmt.Errorf("cross-wired reply: got %s, want %s", raw, want) + return + } + } + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Error(err) + } +} + +// TestWS_CloseRacesInFlightCalls verifies no caller hangs or panics when the +// client closes with calls in flight: each gets a result or ErrTransportClosed. +func TestWS_CloseRacesInFlightCalls(t *testing.T) { + received := make(chan struct{}, 1) + gate := make(chan struct{}) + t.Cleanup(func() { close(gate) }) + srv := clienttest.NewTestServer(t) + srv.SetHandler(func(_ clienttest.TestRequest) (any, *clienttest.TestRPCError) { + select { + case received <- struct{}{}: + default: + } + <-gate + return "0x1", nil + }) + + tr := newTestTransport(t, srv) + + const callers = 16 + done := make(chan error, callers) + for range callers { + go func() { + _, err := tr.call(t.Context(), "eth_chainId") + done <- err + }() + } + + <-received // at least one call is in the server's hands + tr.close() + + for range callers { + select { + case err := <-done: + require.Error(t, err) + require.ErrorIs(t, err, ErrTransportClosed) + case <-time.After(5 * time.Second): + t.Fatal("caller hung after Close") + } + } +}