diff --git a/cmd/juno/juno.go b/cmd/juno/juno.go index 306ccb61ed..3a154a37d8 100644 --- a/cmd/juno/juno.go +++ b/cmd/juno/juno.go @@ -52,6 +52,7 @@ const ( networkF = "network" ethNodeF = "eth-node" disableL1VerificationF = "disable-l1-verification" + l1ClientF = "l1-client" pprofF = "pprof" pprofHostF = "pprof-host" pprofPortF = "pprof-port" @@ -120,6 +121,7 @@ const ( defaultWSPort = 6061 defaultEthNode = "" defaultDisableL1Verification = false + defaultL1Client = "geth" defaultPprof = false defaultPprofPort = 6062 defaultColour = true @@ -197,6 +199,8 @@ const ( ethNodeUsage = "WebSocket endpoint of the Ethereum node. To verify the correctness of the L2 chain, " + "Juno must connect to an Ethereum node and parse events in the Starknet contract." disableL1VerificationUsage = "Disables L1 verification since an Ethereum node is not provided." + l1ClientUsage = `L1 client. One of "geth" (default; go-ethereum based) or ` + + `"juno" (juno's hand-rolled WebSocket JSON-RPC client). No effect with --disable-l1-verification.` preLatestPollIntervalUsage = "Sets polling interval for pre-latest block updates. " + "(0s will disable polling)." preConfirmedPollIntervalUsage = "Sets how frequently pre_confirmed block will be updated" + @@ -469,8 +473,9 @@ func NewCmd(config *node.Config, run func(*cobra.Command, []string) error) *cobr junoCmd.Flags().Var(&defaultNetwork, networkF, networkUsage) junoCmd.Flags().String(ethNodeF, defaultEthNode, ethNodeUsage) junoCmd.Flags().Bool(disableL1VerificationF, defaultDisableL1Verification, disableL1VerificationUsage) + junoCmd.Flags().String(l1ClientF, defaultL1Client, l1ClientUsage) junoCmd.MarkFlagsMutuallyExclusive(ethNodeF, disableL1VerificationF) - setCategory(junoCmd, catNetwork, networkF, ethNodeF, disableL1VerificationF) + setCategory(junoCmd, catNetwork, networkF, ethNodeF, disableL1VerificationF, l1ClientF) // --- Sync & Polling --- junoCmd.Flags().Duration( diff --git a/cmd/juno/juno_test.go b/cmd/juno/juno_test.go index 55e1aa27ec..6626338d56 100644 --- a/cmd/juno/juno_test.go +++ b/cmd/juno/juno_test.go @@ -74,6 +74,7 @@ func TestConfigPrecedence(t *testing.T) { defaultRPCRequestTimeout := 1 * time.Minute defaultMaxConcurrentCompilations := uint(8) defaultDisableReceivedTxnStream := false + defaultL1Client := "geth" defaultPruneMinAge := time.Hour expectedConfig1 := node.Config{ LogLevel: "debug", @@ -114,6 +115,7 @@ func TestConfigPrecedence(t *testing.T) { SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -159,6 +161,7 @@ func TestConfigPrecedence(t *testing.T) { SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -265,6 +268,7 @@ pprof: true SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -316,6 +320,7 @@ http-port: 4576 SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -366,6 +371,7 @@ http-port: 4576 SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -416,6 +422,7 @@ http-port: 4576 SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -492,6 +499,7 @@ db-cache-size: 1024 SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -545,6 +553,7 @@ network: sepolia SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -594,6 +603,7 @@ network: sepolia SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -641,6 +651,7 @@ network: sepolia SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -689,6 +700,7 @@ network: sepolia SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, @@ -700,6 +712,7 @@ network: sepolia expectedConfig: &node.Config{ LogLevel: defaultLogLevel, LogJSON: true, + L1Client: defaultL1Client, HTTP: defaultHTTP, HTTPHost: defaultHost, HTTPPort: defaultHTTPPort, @@ -785,6 +798,7 @@ network: sepolia SubmittedTransactionsCacheSize: defaultSubmittedTransactionsCacheSize, SubmittedTransactionsCacheEntryTTL: defaultSubmittedTransactionsCacheEntryTTL, DisableReceivedTxnStream: defaultDisableReceivedTxnStream, + L1Client: defaultL1Client, ReadinessBlockTolerance: 6, RPCRequestTimeout: defaultRPCRequestTimeout, MaxConcurrentCompilations: defaultMaxConcurrentCompilations, diff --git a/l1/eth/client/client.go b/l1/eth/client/client.go new file mode 100644 index 0000000000..adaed4f465 --- /dev/null +++ b/l1/eth/client/client.go @@ -0,0 +1,193 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/url" + "time" + + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/utils/log" +) + +// Client is the hand-rolled Ethereum execution-layer client juno uses to +// follow the L1 head and serve starknet_getMessageStatus. It speaks the +// minimum subset of the JSON-RPC surface juno needs and nothing more. +// +// Only WebSocket endpoints are supported: subscribe-based log delivery +// (eth_subscribe) requires a long-lived connection, and unary calls +// happily share that same connection. +type Client struct { + tr *wsTransport +} + +// Common block tags accepted by HeaderByNumber. Mirrors the post-merge +// JSON-RPC vocabulary; juno only uses Finalized today. +const ( + BlockFinalized = "finalized" + BlockLatest = "latest" + BlockSafe = "safe" + BlockEarliest = "earliest" + BlockPending = "pending" +) + +// jsonNull is the literal payload returned by an RPC server for a +// missing resource (block, header, receipt). The methods layer maps +// this to eth.ErrNotFound on a per-method basis. +var jsonNull = []byte("null") + +// Option configures Client at construction time. +type Option func(*options) + +type options struct { + logger log.StructuredLogger + pingInterval time.Duration + pingTimeout time.Duration +} + +// WithLogger attaches a debug logger. Used to surface dropped frames, +// id mismatches, and best-effort eth_unsubscribe failures — the kinds +// of events that would otherwise vanish into call timeouts. Defaults +// to a no-op logger. +func WithLogger(l log.StructuredLogger) Option { + return func(o *options) { o.logger = l } +} + +// New dials the endpoint at rawURL. The URL must use the ws:// or +// wss:// scheme. +func New(ctx context.Context, rawURL string, opts ...Option) (*Client, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("parse url: %w", err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return nil, fmt.Errorf("unsupported url scheme %q (need ws/wss)", u.Scheme) + } + o := options{} + for _, opt := range opts { + opt(&o) + } + ws, err := dialWS(ctx, rawURL, o) + if err != nil { + return nil, err + } + return &Client{tr: ws}, nil +} + +// Close releases the underlying transport. +func (c *Client) Close() { c.tr.close() } + +func isJSONNull(raw json.RawMessage) bool { + return bytes.Equal(bytes.TrimSpace(raw), jsonNull) +} + +// ChainID returns the chain identifier reported by eth_chainId. +func (c *Client) ChainID(ctx context.Context) (*big.Int, error) { + raw, err := c.tr.call(ctx, "eth_chainId") + if err != nil { + return nil, fmt.Errorf("get chain id: %w", err) + } + return decodeQuantityBig(raw) +} + +// BlockNumber returns the latest known block number (eth_blockNumber). +func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { + raw, err := c.tr.call(ctx, "eth_blockNumber") + if err != nil { + return 0, fmt.Errorf("get block number: %w", err) + } + return decodeQuantityUint64(raw) +} + +// HeaderByNumber retrieves a block header by tag (one of BlockFinalized, +// BlockLatest, etc.). Block-number-specific lookups can be added when a +// caller needs them; juno only fetches the finalised head today. +// +// Returns eth.ErrNotFound if the remote replies with a null result (which +// is geth's signal for "the named block does not exist yet"). +func (c *Client) HeaderByNumber(ctx context.Context, tag string) (*eth.Header, error) { + raw, err := c.tr.call(ctx, "eth_getBlockByNumber", tag, false /* hydrated txs */) + if err != nil { + return nil, fmt.Errorf("get block: %w", err) + } + if isJSONNull(raw) { + return nil, eth.ErrNotFound + } + var h eth.Header + if err := json.Unmarshal(raw, &h); err != nil { + return nil, fmt.Errorf("decode header: %w", err) + } + return &h, nil +} + +// TransactionReceipt fetches a transaction receipt by hash. Returns +// eth.ErrNotFound if the remote does not have the receipt. +func (c *Client) TransactionReceipt(ctx context.Context, txHash eth.Hash) (*eth.Receipt, error) { + raw, err := c.tr.call(ctx, "eth_getTransactionReceipt", txHash) + if err != nil { + return nil, fmt.Errorf("get receipt: %w", err) + } + if isJSONNull(raw) { + return nil, eth.ErrNotFound + } + var r eth.Receipt + if err := json.Unmarshal(raw, &r); err != nil { + return nil, fmt.Errorf("decode receipt: %w", err) + } + return &r, nil +} + +// FilterLogs runs eth_getLogs with q. Empty result is not an error; +// returns an empty slice. +func (c *Client) FilterLogs(ctx context.Context, q FilterQuery) ([]eth.Log, error) { + raw, err := c.tr.call(ctx, "eth_getLogs", q) + if err != nil { + return nil, fmt.Errorf("filter logs: %w", err) + } + if isJSONNull(raw) { + return nil, nil + } + var logs []eth.Log + if err := json.Unmarshal(raw, &logs); err != nil { + return nil, fmt.Errorf("decode logs: %w", err) + } + return logs, nil +} + +// decodeQuantityUint64 parses a JSON-RPC "quantity" (0x-prefixed +// minimal hex string) into uint64. +func decodeQuantityUint64(raw json.RawMessage) (uint64, error) { + var q eth.HexU64 + if err := json.Unmarshal(raw, &q); err != nil { + return 0, fmt.Errorf("decode quantity: %w", err) + } + return uint64(q), nil +} + +// decodeQuantityBig parses a JSON-RPC "quantity" into *big.Int. Chain +// IDs (and other uint256-shaped quantities) can exceed 64 bits, so we +// don't reuse the uint64 decoder. +func decodeQuantityBig(raw json.RawMessage) (*big.Int, error) { + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("decode quantity: %w", err) + } + if len(s) < 2 || s[0] != '0' || (s[1] != 'x' && s[1] != 'X') { + return nil, fmt.Errorf("decode quantity: missing 0x prefix in %q", s) + } + body := s[2:] + if body == "" { + return nil, fmt.Errorf("decode quantity: no digits in %q", s) + } + if len(body) > 1 && body[0] == '0' { + return nil, fmt.Errorf("decode quantity: leading zero in %q", s) + } + out, ok := new(big.Int).SetString(body, 16) + if !ok { + return nil, fmt.Errorf("decode quantity: invalid hex %q", s) + } + return out, nil +} diff --git a/l1/eth/client/client_test.go b/l1/eth/client/client_test.go new file mode 100644 index 0000000000..e81930df44 --- /dev/null +++ b/l1/eth/client/client_test.go @@ -0,0 +1,509 @@ +package client_test + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "strings" + "testing" + + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/eth/client" + "github.com/NethermindEth/juno/utils/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestClient dials the test server over WS and registers cleanup. +func newTestClient(t *testing.T, srv *client.TestServer) *client.Client { + t.Helper() + c, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(c.Close) + return c +} + +// methodResponse is a static reply for a single JSON-RPC method. +// Exactly one of result / rpcErr should be non-nil. +type methodResponse struct { + result any + rpcErr *client.TestRPCError +} + +// captureHandler installs a client.TestHandler that records every request and +// dispatches by method to a per-method static response. Methods not in +// the map fall through to a "method not found" error. +func captureHandler( + t *testing.T, + responses map[string]methodResponse, +) (*client.TestServer, *[]client.TestRequest) { + t.Helper() + srv := client.NewTestServer(t) + captured := make([]client.TestRequest, 0) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + captured = append(captured, req) + if r, ok := responses[req.Method]; ok { + return r.result, r.rpcErr + } + return nil, &client.TestRPCError{Code: -32601, Message: "method not found: " + req.Method} + }) + return srv, &captured +} + +// TestNew_WithLogger smoke-tests the WithLogger option — supplying a +// custom logger must not break construction. +func TestNew_WithLogger(t *testing.T) { + srv := client.NewTestServer(t) + c, err := client.New(t.Context(), srv.WSURL(), + client.WithLogger(log.NewNopZapLogger())) + require.NoError(t, err) + t.Cleanup(c.Close) +} + +// TestTestServer_URL guards the http:// scheme returned by URL() — used +// by future unary-only tests; documents the expected scheme. +func TestTestServer_URL(t *testing.T) { + srv := client.NewTestServer(t) + u := srv.URL() + assert.Truef(t, strings.HasPrefix(u, "http://"), + "URL() must return an http:// URL, got %q", u) +} + +func TestNew_SchemeDispatch(t *testing.T) { + cases := []struct { + url string + wantErr string + }{ + {"http://example.com", "unsupported url scheme"}, + {"https://example.com", "unsupported url scheme"}, + {"ipc:///tmp/geth.ipc", "unsupported url scheme"}, + {"file:///tmp/x", "unsupported url scheme"}, + {"::not-a-url", "parse url"}, + } + for _, c := range cases { + t.Run(c.url, func(t *testing.T) { + _, err := client.New(t.Context(), c.url) + require.Error(t, err) + assert.Contains(t, err.Error(), c.wantErr) + }) + } +} + +func TestChainID_Success(t *testing.T) { + srv, calls := captureHandler(t, map[string]methodResponse{ + "eth_chainId": {result: "0x539"}, + }) + cli := newTestClient(t, srv) + + id, err := cli.ChainID(t.Context()) + require.NoError(t, err) + assert.Equal(t, big.NewInt(1337), id) + require.Len(t, *calls, 1) + assert.Equal(t, "eth_chainId", (*calls)[0].Method) + assert.Empty(t, (*calls)[0].Params) +} + +func TestChainID_LargeValue(t *testing.T) { + // uint64 max + 1 must round-trip through *big.Int. + const big65bit = "0x10000000000000000" + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_chainId": {result: big65bit}, + }) + cli := newTestClient(t, srv) + + id, err := cli.ChainID(t.Context()) + require.NoError(t, err) + want, _ := new(big.Int).SetString("10000000000000000", 16) + assert.Equal(t, want, id) +} + +func TestChainID_ServerError(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_chainId": {rpcErr: &client.TestRPCError{Code: -32603, Message: "internal error"}}, + }) + cli := newTestClient(t, srv) + + _, err := cli.ChainID(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "chain id") + assert.Contains(t, err.Error(), "internal error") +} + +// TestChainID_DecodeErrors exercises decodeQuantityBig's validation +// branches via the public ChainID surface. Each subtest installs a +// server that replies with a malformed "quantity" — every branch must +// produce a "decode quantity" error (rather than panic or return zero). +func TestChainID_DecodeErrors(t *testing.T) { + cases := []struct { + name string + raw any + wantSub string + }{ + // JSON decode error: a numeric result, not a string. + {"non-string", 1, "decode quantity"}, + // Missing 0x prefix. + {"missing prefix", "abc", "missing 0x prefix"}, + // Empty body (only "0x", no digits). + {"no digits", "0x", "no digits"}, + // Leading zero (non-minimal encoding). + {"leading zero", "0x01", "leading zero"}, + // Invalid hex digits. + {"invalid hex", "0xZZ", "invalid hex"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_chainId": {result: c.raw}, + }) + cli := newTestClient(t, srv) + + _, err := cli.ChainID(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), c.wantSub) + }) + } +} + +// TestBlockNumber_DecodeError exercises decodeQuantityUint64's error +// branch — HexU64 UnmarshalJSON rejects values with no digits, a leading +// zero, or non-hex characters. Per the current implementation the +// "decode quantity" wrapper is the outermost message on the decode path +// (the "get block number" wrapper is only applied to transport-call +// errors); this test pins that behavior. +func TestBlockNumber_DecodeError(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_blockNumber": {result: "not-hex"}, + }) + cli := newTestClient(t, srv) + + _, err := cli.BlockNumber(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode quantity") +} + +// TestFilterLogs_DecodeFailure verifies the FilterLogs error path when +// the server returns a non-list (e.g. an object) where logs are +// expected. The wrapping must still identify the method. +func TestFilterLogs_DecodeFailure(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_getLogs": {result: map[string]any{"unexpected": "shape"}}, + }) + cli := newTestClient(t, srv) + + _, err := cli.FilterLogs(t.Context(), client.FilterQuery{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode logs") +} + +// TestTransactionReceipt_DecodeFailure verifies receipt-decode error +// surfacing. A logs field that isn't an array forces the JSON unmarshal +// to fail mid-receipt. +func TestTransactionReceipt_DecodeFailure(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_getTransactionReceipt": {result: map[string]any{"logs": "not-an-array"}}, + }) + cli := newTestClient(t, srv) + + _, err := cli.TransactionReceipt(t.Context(), eth.Hash{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode receipt") +} + +func TestBlockNumber_Success(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_blockNumber": {result: "0x10"}, + }) + cli := newTestClient(t, srv) + + n, err := cli.BlockNumber(t.Context()) + require.NoError(t, err) + assert.Equal(t, uint64(16), n) +} + +func TestHeaderByNumber_Finalized(t *testing.T) { + srv, calls := captureHandler(t, map[string]methodResponse{ + "eth_getBlockByNumber": {result: map[string]any{ + "number": "0x539", + "hash": "0x" + zeroHex(64), + "parentHash": "0x" + zeroHex(64), + }}, + }) + cli := newTestClient(t, srv) + + h, err := cli.HeaderByNumber(t.Context(), client.BlockFinalized) + require.NoError(t, err) + require.NotNil(t, h) + assert.Equal(t, uint64(1337), uint64(h.Number)) + + require.Len(t, *calls, 1) + c := (*calls)[0] + require.Len(t, c.Params, 2) + assert.JSONEq(t, `"finalized"`, string(c.Params[0])) + assert.JSONEq(t, `false`, string(c.Params[1])) +} + +func TestHeaderByNumber_NotFound_NullResult(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_getBlockByNumber": {result: nil}, + }) + cli := newTestClient(t, srv) + + _, err := cli.HeaderByNumber(t.Context(), client.BlockFinalized) + require.ErrorIs(t, err, eth.ErrNotFound) +} + +func TestHeaderByNumber_BadHeader(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_getBlockByNumber": {result: map[string]any{"number": "not-hex"}}, + }) + cli := newTestClient(t, srv) + + _, err := cli.HeaderByNumber(t.Context(), client.BlockFinalized) + require.Error(t, err) + assert.Contains(t, err.Error(), "decode header") +} + +func TestTransactionReceipt_Success(t *testing.T) { + srv, calls := captureHandler(t, map[string]methodResponse{ + "eth_getTransactionReceipt": {result: map[string]any{ + "logs": []any{ + map[string]any{ + "topics": []string{ + "0xdb80dd488acf86d17c747445b0eabb5d57c541d3bd7b6b87af987858e5066b2b", + }, + "data": "0xdeadbeef", + "blockNumber": "0x10", + "removed": false, + }, + }, + }}, + }) + cli := newTestClient(t, srv) + + txHash := eth.HashFromString("0x" + repeatHex("ab", 32)) + r, err := cli.TransactionReceipt(t.Context(), txHash) + require.NoError(t, err) + require.Len(t, r.Logs, 1) + assert.Equal(t, uint64(16), uint64(r.Logs[0].BlockNumber)) + assert.Equal(t, []byte{0xde, 0xad, 0xbe, 0xef}, []byte(r.Logs[0].Data)) + + require.Len(t, *calls, 1) + c := (*calls)[0] + require.Len(t, c.Params, 1) + assert.JSONEq(t, `"`+txHash.Hex()+`"`, string(c.Params[0])) +} + +func TestTransactionReceipt_NotFound(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_getTransactionReceipt": {result: nil}, + }) + cli := newTestClient(t, srv) + _, err := cli.TransactionReceipt(t.Context(), eth.Hash{}) + require.ErrorIs(t, err, eth.ErrNotFound) +} + +func TestFilterLogs_Empty(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_getLogs": {result: []any{}}, + }) + cli := newTestClient(t, srv) + + logs, err := cli.FilterLogs(t.Context(), client.FilterQuery{ + FromBlock: ptr(uint64(1)), + ToBlock: ptr(uint64(2)), + }) + require.NoError(t, err) + assert.Empty(t, logs) +} + +func TestFilterLogs_OneLog(t *testing.T) { + const sigHash = "0xdb80dd488acf86d17c747445b0eabb5d57c541d3bd7b6b87af987858e5066b2b" + srv, calls := captureHandler(t, map[string]methodResponse{ + "eth_getLogs": {result: []any{ + map[string]any{ + "topics": []string{sigHash}, + "data": "0x", + "blockNumber": "0x100", + "removed": false, + }, + }}, + }) + cli := newTestClient(t, srv) + + addr := eth.AddressFromString("0x000000000000000000000000000000000000beef") + q := client.FilterQuery{ + FromBlock: ptr(uint64(1)), + ToBlock: ptr(uint64(1000)), + Addresses: []eth.Address{addr}, + Topics: [][]eth.Hash{{eth.HashFromString(sigHash)}}, + } + logs, err := cli.FilterLogs(t.Context(), q) + require.NoError(t, err) + require.Len(t, logs, 1) + assert.Equal(t, uint64(256), uint64(logs[0].BlockNumber)) + + require.Len(t, *calls, 1) + c := (*calls)[0] + require.Len(t, c.Params, 1) + var sentFilter struct { + FromBlock string `json:"fromBlock"` + ToBlock string `json:"toBlock"` + Address []string `json:"address"` + Topics []any `json:"topics"` + } + require.NoError(t, json.Unmarshal(c.Params[0], &sentFilter)) + assert.Equal(t, "0x1", sentFilter.FromBlock) + assert.Equal(t, "0x3e8", sentFilter.ToBlock) + require.Len(t, sentFilter.Address, 1) + assert.Equal(t, addrHex(addr), sentFilter.Address[0]) + require.Len(t, sentFilter.Topics, 1) + topicStr, ok := sentFilter.Topics[0].(string) + require.True(t, ok, "topic[0] should be a string when only one hash") + assert.Equal(t, sigHash, topicStr) +} + +func TestFilterQuery_MarshalShapes(t *testing.T) { + addr := eth.AddressFromString("0x000000000000000000000000000000000000beef") + hash1 := eth.HashFromString("0x" + repeatHex("11", 32)) + hash2 := eth.HashFromString("0x" + repeatHex("22", 32)) + + cases := []struct { + name string + q client.FilterQuery + assert func(t *testing.T, sent map[string]any) + }{ + { + // Unset FromBlock/ToBlock must NOT appear on the wire — this + // is the eth_subscribe "live logs" shape; geth interprets an + // explicit toBlock=0 as a bounded historical filter that + // terminates at block 0. + name: "unset block range omits keys", + q: client.FilterQuery{}, + assert: func(t *testing.T, sent map[string]any) { + _, hasFrom := sent["fromBlock"] + assert.False(t, hasFrom, "fromBlock must be omitted when unset") + _, hasTo := sent["toBlock"] + assert.False(t, hasTo, "toBlock must be omitted when unset") + _, hasAddr := sent["address"] + assert.False(t, hasAddr) + _, hasTopics := sent["topics"] + assert.False(t, hasTopics) + }, + }, + { + // Explicit zero is distinct from unset and still expressible + // (e.g. eth_getLogs from genesis). + name: "explicit block zero", + q: client.FilterQuery{FromBlock: ptr(uint64(0)), ToBlock: ptr(uint64(0))}, + assert: func(t *testing.T, sent map[string]any) { + assert.Equal(t, "0x0", sent["fromBlock"]) + assert.Equal(t, "0x0", sent["toBlock"]) + }, + }, + { + name: "single topic", + q: client.FilterQuery{Topics: [][]eth.Hash{{hash1}}}, + assert: func(t *testing.T, sent map[string]any) { + topics := sent["topics"].([]any) + require.Len(t, topics, 1) + _, isString := topics[0].(string) + assert.True(t, isString) + }, + }, + { + name: "any-at-position-0 then exact-at-1", + q: client.FilterQuery{Topics: [][]eth.Hash{nil, {hash1}}}, + assert: func(t *testing.T, sent map[string]any) { + topics := sent["topics"].([]any) + require.Len(t, topics, 2) + assert.Nil(t, topics[0]) + _, isString := topics[1].(string) + assert.True(t, isString) + }, + }, + { + name: "OR-list at position 0", + q: client.FilterQuery{Topics: [][]eth.Hash{{hash1, hash2}}}, + assert: func(t *testing.T, sent map[string]any) { + topics := sent["topics"].([]any) + _, isArr := topics[0].([]any) + assert.True(t, isArr) + }, + }, + { + name: "addresses", + q: client.FilterQuery{Addresses: []eth.Address{addr}}, + assert: func(t *testing.T, sent map[string]any) { + addrs := sent["address"].([]any) + require.Len(t, addrs, 1) + assert.Equal(t, addrHex(addr), addrs[0]) + }, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + raw, err := json.Marshal(c.q) + require.NoError(t, err) + var sent map[string]any + require.NoError(t, json.Unmarshal(raw, &sent)) + c.assert(t, sent) + }) + } +} + +func TestFilterLogs_ServerError(t *testing.T) { + srv, _ := captureHandler(t, map[string]methodResponse{ + "eth_getLogs": {rpcErr: &client.TestRPCError{ + Code: -32005, + Message: "query returned more than 10000 results", + }}, + }) + cli := newTestClient(t, srv) + + _, err := cli.FilterLogs(t.Context(), client.FilterQuery{ + FromBlock: ptr(uint64(1)), + ToBlock: ptr(uint64(1_000_000)), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "filter logs") + assert.Contains(t, err.Error(), "10000 results") +} + +func TestMethods_ContextCancelled(t *testing.T) { + // Handler that blocks forever; ctx-cancel must propagate. + gate := make(chan struct{}) + t.Cleanup(func() { close(gate) }) + srv := client.NewTestServer(t) + srv.SetHandler(func(_ client.TestRequest) (any, *client.TestRPCError) { + <-gate + return nil, nil + }) + cli := newTestClient(t, srv) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + _, err := cli.ChainID(ctx) + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled), "got: %v", err) +} + +// --- helpers --- + +func repeatHex(unit string, repeat int) string { + out := make([]byte, 0, len(unit)*repeat) + for range repeat { + out = append(out, unit...) + } + return string(out) +} + +func zeroHex(n int) string { return repeatHex("0", n) } + +func addrHex(a eth.Address) string { + b, _ := a.MarshalText() + return string(b) +} + +func ptr[T any](v T) *T { return &v } diff --git a/l1/eth/client/errors.go b/l1/eth/client/errors.go new file mode 100644 index 0000000000..134ea89994 --- /dev/null +++ b/l1/eth/client/errors.go @@ -0,0 +1,8 @@ +package client + +import "errors" + +// ErrTransportClosed indicates the underlying transport has been shut +// down (cleanly via Close or via an upstream connection failure). +// Surfaced to in-flight calls and active subscriptions. +var ErrTransportClosed = errors.New("transport closed") diff --git a/l1/eth/client/export_test.go b/l1/eth/client/export_test.go new file mode 100644 index 0000000000..ad12ba5980 --- /dev/null +++ b/l1/eth/client/export_test.go @@ -0,0 +1,38 @@ +package client + +import ( + "time" + + "github.com/NethermindEth/juno/l1/eth" +) + +// WithPingConfig overrides the websocket keep-alive ping interval and the +// per-ping write/read timeout. Test-only: the production defaults are +// fine for every real RPC endpoint we've encountered, and exposing tuning +// knobs in the public API would invite cargo-culted misconfiguration. +func WithPingConfig(interval, timeout time.Duration) Option { + return func(o *options) { + o.pingInterval = interval + o.pingTimeout = timeout + } +} + +// CancelPendingFor invokes wsTransport.cancelPending against an existing +// subscription, simulating the race window in callWithSubReg where +// dispatchResponse registered the sub (pendingSub.id is set) just before +// the caller's ctx fired. From the public surface this race is timing- +// dependent and not reachable deterministically — once the server's +// response has been processed into pendingSub.id, it has also already +// been delivered on the reply channel, so the caller's select picks +// pseudo-randomly between ctx.Done() and the ready reply. +// +// Test-only: production callers reach cancelPending through callWithSubReg +// when their ctx fires; tests use this helper to exercise the +// leaked-subscription cleanup branch deterministically. +func CancelPendingFor(c *Client, sub eth.Subscription) { + s := sub.(*wsLogSub) + // id 0 is fine — the pending/pendingSubs entries for the original + // subscribe call have already been removed by dispatchResponse; + // only the t.subs cleanup + best-effort eth_unsubscribe matter here. + c.tr.cancelPending(0, s) +} diff --git a/l1/eth/client/filter_query.go b/l1/eth/client/filter_query.go new file mode 100644 index 0000000000..ea661df857 --- /dev/null +++ b/l1/eth/client/filter_query.go @@ -0,0 +1,68 @@ +package client + +import ( + "encoding/json" + "strconv" + + "github.com/NethermindEth/juno/l1/eth" +) + +// FilterQuery is the request shape for eth_getLogs (and eth_subscribe +// "logs"). Block numbers are inclusive on both ends, matching the wire +// format. FromBlock/ToBlock are optional: nil means "omit from the wire" +// — for eth_subscribe this yields a live-logs subscription with no +// historical range; for eth_getLogs it lets the server apply its +// defaults. Addresses and Topics are both optional. +type FilterQuery struct { + FromBlock *uint64 + ToBlock *uint64 + Addresses []eth.Address + // Topics is a position-major filter: Topics[i] is the allowed-set at + // topic position i (OR'd together). An empty Topics[i] means "any + // value at that position". Trailing unconstrained positions may be + // omitted. + Topics [][]eth.Hash +} + +func (q FilterQuery) MarshalJSON() ([]byte, error) { + wire := filterQueryWire{ + Address: q.Addresses, + } + if q.FromBlock != nil { + wire.FromBlock = quantityHex(*q.FromBlock) + } + if q.ToBlock != nil { + wire.ToBlock = quantityHex(*q.ToBlock) + } + if len(q.Topics) > 0 { + wire.Topics = make([]any, len(q.Topics)) + for i, ts := range q.Topics { + switch len(ts) { + case 0: + wire.Topics[i] = nil + case 1: + wire.Topics[i] = ts[0] + default: + wire.Topics[i] = ts + } + } + } + return json.Marshal(wire) +} + +// filterQueryWire is the on-the-wire shape; FilterQuery's MarshalJSON +// reshapes the user-facing fields into this. fromBlock/toBlock use +// omitempty so an unset FilterQuery serialises without them, matching +// the eth_subscribe live-logs semantics on geth. +type filterQueryWire struct { + FromBlock string `json:"fromBlock,omitempty"` + ToBlock string `json:"toBlock,omitempty"` + Address []eth.Address `json:"address,omitempty"` + Topics []any `json:"topics,omitempty"` +} + +// quantityHex encodes n as an Ethereum JSON-RPC "quantity": 0x-prefixed +// minimal hex ("0x0" for zero, no leading zeros otherwise). +func quantityHex(n uint64) string { + return "0x" + strconv.FormatUint(n, 16) +} diff --git a/l1/eth/client/rpc.go b/l1/eth/client/rpc.go new file mode 100644 index 0000000000..0923089dff --- /dev/null +++ b/l1/eth/client/rpc.go @@ -0,0 +1,106 @@ +// 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 — it is not a +// general-purpose Ethereum RPC library. WebSocket-only because +// subscribe-based log delivery (eth_subscribe) requires a long-lived +// connection that HTTP doesn't provide; unary calls share the same conn. +package client + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" +) + +const jsonrpcVersion = "2.0" + +// rpcRequest is the JSON-RPC 2.0 request envelope. Params is always an +// array; methods with no arguments serialise "params":[]. ID is always +// uint64 on the way out; we accept either number- or string-shaped ids +// on the response (see parseResponseID). +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID uint64 `json:"id"` + Method string `json:"method"` + Params []any `json:"params"` +} + +// RPCError is the JSON-RPC error object as returned by the remote endpoint. +// Code -32000 (server error) is the common umbrella every provider uses +// for resource-missing replies; the methods layer interprets specific +// (method, error) pairs into juno sentinels. +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// rpcResponse is the JSON-RPC 2.0 response envelope. A well-formed +// response carries either Result or Error, never both. ID is kept as +// json.RawMessage so we can match both `42` and `"42"` shapes against +// our outgoing uint64 — the spec permits either, and providers diverge. +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +func (e *RPCError) Error() string { + if len(e.Data) > 0 { + return fmt.Sprintf("json-rpc error %d: %s: %s", e.Code, e.Message, e.Data) + } + return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message) +} + +// ErrIDMismatch indicates the response id did not match the request id — +// a protocol violation that means the remote replied to a different +// request than the one we sent. +var ErrIDMismatch = errors.New("response id mismatch") + +// parseResponseID parses a JSON-RPC response id into uint64. The spec +// allows id to be number, string, or null; servers vary, so accept +// number ("42") and string ("\"42\"") shapes and reject everything +// else (null included — null id means the server couldn't determine +// which request it was answering). +func parseResponseID(raw json.RawMessage) (uint64, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, jsonNull) { + return 0, errors.New("missing or null id") + } + // Strip a pair of surrounding quotes if the id is string-shaped. + if len(trimmed) >= 2 && trimmed[0] == '"' && trimmed[len(trimmed)-1] == '"' { + trimmed = trimmed[1 : len(trimmed)-1] + } + n, err := strconv.ParseUint(string(trimmed), 10, 64) + if err != nil { + return 0, fmt.Errorf("parse id %q: %w", raw, err) + } + return n, nil +} + +// DecodeResponse parses a single JSON-RPC response body and returns the +// raw Result payload. A server-side RPCError is returned verbatim so +// callers can match on Code if needed; the methods layer is responsible +// for mapping resource-missing replies to eth.ErrNotFound (because a +// "not found" string can also come back from -32601 "method not found" +// and similar, where the meaning is unrelated). +func DecodeResponse(body []byte, reqID uint64) (json.RawMessage, error) { + var resp rpcResponse + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + id, err := parseResponseID(resp.ID) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrIDMismatch, err) + } + if id != reqID { + return nil, fmt.Errorf("%w: got %d, want %d", ErrIDMismatch, id, reqID) + } + if resp.Error != nil { + return nil, resp.Error + } + return resp.Result, nil +} diff --git a/l1/eth/client/rpc_test.go b/l1/eth/client/rpc_test.go new file mode 100644 index 0000000000..380fe6589a --- /dev/null +++ b/l1/eth/client/rpc_test.go @@ -0,0 +1,123 @@ +package client_test + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/NethermindEth/juno/l1/eth/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRPCError_Error(t *testing.T) { + cases := []struct { + name string + err client.RPCError + want string + }{ + { + name: "without data", + err: client.RPCError{Code: -32601, Message: "the method does not exist"}, + want: "json-rpc error -32601: the method does not exist", + }, + { + name: "with data", + err: client.RPCError{ + Code: -32000, + Message: "execution reverted", + Data: json.RawMessage(`"0xabcd"`), + }, + want: `json-rpc error -32000: execution reverted: "0xabcd"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, c.err.Error()) + }) + } +} + +func TestDecodeResponse_Success(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":7,"result":"0x539"}`) + result, err := client.DecodeResponse(body, 7) + require.NoError(t, err) + assert.JSONEq(t, `"0x539"`, string(result)) +} + +func TestDecodeResponse_NullResult(t *testing.T) { + // A successful response with a null result (e.g. missing receipt or + // missing header) is NOT an error at the wire layer; the methods + // layer interprets null per-method. + body := []byte(`{"jsonrpc":"2.0","id":3,"result":null}`) + result, err := client.DecodeResponse(body, 3) + require.NoError(t, err) + assert.JSONEq(t, `null`, string(result)) +} + +func TestDecodeResponse_ServerError(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":11,"error":{"code":-32000,"message":"header not found"}}`) + _, err := client.DecodeResponse(body, 11) + require.Error(t, err) + + var rerr *client.RPCError + require.True(t, errors.As(err, &rerr), "expected *client.RPCError, got %T", err) + assert.Equal(t, -32000, rerr.Code) + assert.Equal(t, "header not found", rerr.Message) +} + +func TestDecodeResponse_IDMismatch(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":99,"result":"0x1"}`) + _, err := client.DecodeResponse(body, 1) + require.Error(t, err) + require.ErrorIs(t, err, client.ErrIDMismatch) +} + +// TestDecodeResponse_StringID covers spec-tolerance for servers that +// echo our outgoing uint64 as a JSON string. Matches juno's own +// jsonrpc/server.go which accepts both shapes. +func TestDecodeResponse_StringID(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":"7","result":"0x539"}`) + result, err := client.DecodeResponse(body, 7) + require.NoError(t, err) + assert.JSONEq(t, `"0x539"`, string(result)) +} + +// TestDecodeResponse_NullID rejects null ids — a null id in a response +// signals a server-side parse failure, not "my reply matches your +// request". Treat it as an id mismatch so callers don't accidentally +// consume the result. +func TestDecodeResponse_NullID(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":null,"result":"0x1"}`) + _, err := client.DecodeResponse(body, 1) + require.Error(t, err) + require.ErrorIs(t, err, client.ErrIDMismatch) +} + +// TestDecodeResponse_NonNumericStringID rejects non-numeric string ids +// since our outgoing ids are always uint64-shaped — anything else can't +// possibly match. +func TestDecodeResponse_NonNumericStringID(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":"abc","result":"0x1"}`) + _, err := client.DecodeResponse(body, 1) + require.Error(t, err) + require.ErrorIs(t, err, client.ErrIDMismatch) +} + +func TestDecodeResponse_MalformedJSON(t *testing.T) { + _, err := client.DecodeResponse([]byte(`{"jsonrpc":"2.0","id":1`), 1) + require.Error(t, err) + // Wrapped json error — assert by message fragment to stay decoupled + // from the stdlib's internal phrasing. + assert.Contains(t, err.Error(), "decode response") +} + +func TestDecodeResponse_PreservesErrorData(t *testing.T) { + body := []byte(`{"jsonrpc":"2.0","id":1,` + + `"error":{"code":-32000,"message":"execution reverted","data":"0xdeadbeef"}}`) + _, err := client.DecodeResponse(body, 1) + require.Error(t, err) + var rerr *client.RPCError + require.True(t, errors.As(err, &rerr)) + assert.JSONEq(t, `"0xdeadbeef"`, string(rerr.Data)) +} diff --git a/l1/eth/client/subscribe.go b/l1/eth/client/subscribe.go new file mode 100644 index 0000000000..2433ad8254 --- /dev/null +++ b/l1/eth/client/subscribe.go @@ -0,0 +1,135 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/NethermindEth/juno/l1/eth" + "go.uber.org/zap" +) + +// SubscribeLogs subscribes to live log events matching q. Incoming +// logs are delivered to sink as they arrive. The returned subscription +// surfaces transport errors on Err() and tears down the server-side +// subscription on Unsubscribe. +func (c *Client) SubscribeLogs( + ctx context.Context, + q FilterQuery, + sink chan<- *eth.Log, +) (eth.Subscription, error) { + return c.tr.subscribeLogs(ctx, q, sink) +} + +// wsLogSub is the concrete subscription returned by SubscribeLogs. +// It satisfies eth.Subscription. +type wsLogSub struct { + id string // server-assigned subscription id; set during subscribe handshake + transport *wsTransport + sink chan<- *eth.Log + + // logCh carries raw eth_subscription "result" payloads from the + // reader goroutine to the per-sub dispatch goroutine. + logCh chan json.RawMessage + + // errCh is the user-facing Err() channel. It is closed when the + // subscription terminates; a non-nil cause is sent before close. + errCh chan error + + closed chan struct{} + closeOnce sync.Once +} + +func (s *wsLogSub) Err() <-chan error { return s.errCh } + +func (s *wsLogSub) Unsubscribe() { + // Two-step teardown: stop the per-sub dispatch goroutine first, + // then best-effort tell the server to release its side. The order + // matters because if the server fails to ack, we still want our + // local resources gone. + s.fail(nil) + s.transport.mu.Lock() + if s.transport.subs != nil && s.id != "" { + delete(s.transport.subs, s.id) + } + s.transport.mu.Unlock() + + if s.id == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), wsUnsubscribeTimeout) + defer cancel() + if _, err := s.transport.call(ctx, "eth_unsubscribe", s.id); err != nil { + // Server-side cleanup may have already happened (transport + // closed) or the server may simply have dropped the call. + // Either way local state is already torn down; debug-log so + // it's visible without being noisy. + s.transport.logger.Trace("ws: eth_unsubscribe failed", + zap.String("subscription", s.id), + zap.Error(err), + ) + } +} + +// fail terminates the subscription. cause may be nil for a clean +// shutdown (Unsubscribe); otherwise it is the error surfaced to +// Err() before errCh is closed. +func (s *wsLogSub) fail(cause error) { + s.closeOnce.Do(func() { + close(s.closed) + if cause != nil { + select { + case s.errCh <- cause: + default: + } + } + close(s.errCh) + }) +} + +// dispatch decodes log payloads and forwards them to sink. Runs until +// closed signals termination or a payload fails to decode. +func (s *wsLogSub) dispatch() { + for { + select { + case raw := <-s.logCh: + var log eth.Log + if err := json.Unmarshal(raw, &log); err != nil { + s.fail(fmt.Errorf("decode log: %w", err)) + return + } + select { + case s.sink <- &log: + case <-s.closed: + return + } + case <-s.closed: + return + } + } +} + +func (t *wsTransport) subscribeLogs( + ctx context.Context, + q FilterQuery, + sink chan<- *eth.Log, +) (*wsLogSub, error) { + sub := &wsLogSub{ + transport: t, + sink: sink, + logCh: make(chan json.RawMessage, wsLogSubBuffer), + errCh: make(chan error, 1), + closed: make(chan struct{}), + } + + if _, err := t.callWithSubReg(ctx, "eth_subscribe", sub, "logs", q); err != nil { + // Subscribe call failed; drop the pre-registered sub so the + // dispatcher never runs. + sub.closeOnce.Do(func() { close(sub.closed); close(sub.errCh) }) + return nil, fmt.Errorf("subscribe to logs: %w", err) + } + + go sub.dispatch() + return sub, nil +} diff --git a/l1/eth/client/testserver.go b/l1/eth/client/testserver.go new file mode 100644 index 0000000000..8c00447989 --- /dev/null +++ b/l1/eth/client/testserver.go @@ -0,0 +1,257 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/coder/websocket" +) + +// TestServer is a minimal JSON-RPC server suitable for unit-testing +// the Client. It accepts requests over POST application/json and over +// a websocket upgrade on the same URL. +// +// Behaviour is driven by a single Handler function set per-test. The +// server tracks live websocket connections so tests can push +// subscription notifications or sever the connection mid-call. +type TestServer struct { + srv *httptest.Server + + mu sync.Mutex + handler TestHandler + wsConns []*websocket.Conn + + pingsReceived atomic.Int64 + dropPings atomic.Bool +} + +// TestHandler returns the JSON-RPC reply for a single request. result +// is encoded as the response's "result" field; if rerr is non-nil it +// is encoded as the "error" field (and result is ignored). +type TestHandler func(req TestRequest) (result any, rerr *TestRPCError) + +// TestRequest is the decoded JSON-RPC request handed to a TestHandler. +type TestRequest struct { + Method string + Params []json.RawMessage + ID uint64 +} + +// TestRPCError mirrors the JSON-RPC error object that the handler can +// emit. Code is required; Data is optional. +type TestRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data,omitempty"` +} + +// NewTestServer constructs an unstarted TestServer with a default +// "method not found" handler. Install your own with SetHandler. +func NewTestServer(t *testing.T) *TestServer { + t.Helper() + ts := &TestServer{ + handler: func(req TestRequest) (any, *TestRPCError) { + return nil, &TestRPCError{Code: -32601, Message: "method not found: " + req.Method} + }, + } + ts.srv = httptest.NewServer(http.HandlerFunc(ts.serveHTTP)) + t.Cleanup(ts.Close) + return ts +} + +// SetHandler installs a per-request handler. Thread-safe; can be +// changed between calls. +func (ts *TestServer) SetHandler(h TestHandler) { + ts.mu.Lock() + ts.handler = h + ts.mu.Unlock() +} + +// URL returns the base http:// URL for unary tests. +func (ts *TestServer) URL() string { return ts.srv.URL } + +// WSURL returns the same endpoint as a ws:// URL for subscription tests. +func (ts *TestServer) WSURL() string { + return "ws" + strings.TrimPrefix(ts.srv.URL, "http") +} + +// Close stops the server and tears down any live websocket connections. +func (ts *TestServer) Close() { + ts.mu.Lock() + conns := ts.wsConns + ts.wsConns = nil + ts.mu.Unlock() + for _, c := range conns { + _ = c.CloseNow() + } + ts.srv.Close() +} + +// KillWSConns severs every live websocket connection with the given +// status code. Useful for testing client-side resubscribe behaviour. +func (ts *TestServer) KillWSConns() { + ts.mu.Lock() + conns := ts.wsConns + ts.wsConns = nil + ts.mu.Unlock() + for _, c := range conns { + _ = c.Close(websocket.StatusInternalError, "test sever") + } +} + +// PushNotification broadcasts an eth_subscription notification with +// the given subscription id and result payload to every live ws conn. +// Returns the first write error, if any. +func (ts *TestServer) PushNotification(ctx context.Context, subID string, payload any) error { + frame := map[string]any{ + "jsonrpc": "2.0", + "method": "eth_subscription", + "params": map[string]any{ + "subscription": subID, + "result": payload, + }, + } + data, err := json.Marshal(frame) + if err != nil { + return err + } + ts.mu.Lock() + conns := append([]*websocket.Conn(nil), ts.wsConns...) + ts.mu.Unlock() + var firstErr error + for _, c := range conns { + if werr := c.Write(ctx, websocket.MessageText, data); werr != nil && firstErr == nil { + firstErr = werr + } + } + return firstErr +} + +// PushRawFrame writes data verbatim to every live websocket connection. +// Intended for tests that need to exercise the client's tolerance for +// malformed frames or unsolicited responses — payloads that the server +// would never legitimately emit. Returns the first write error, if any. +func (ts *TestServer) PushRawFrame(ctx context.Context, data []byte) error { + ts.mu.Lock() + conns := append([]*websocket.Conn(nil), ts.wsConns...) + ts.mu.Unlock() + var firstErr error + for _, c := range conns { + if werr := c.Write(ctx, websocket.MessageText, data); werr != nil && firstErr == nil { + firstErr = werr + } + } + return firstErr +} + +func (ts *TestServer) callHandler(req TestRequest) (any, *TestRPCError) { + ts.mu.Lock() + h := ts.handler + ts.mu.Unlock() + if h == nil { + return nil, &TestRPCError{Code: -32603, Message: "no handler set"} + } + return h(req) +} + +func (ts *TestServer) serveHTTP(w http.ResponseWriter, r *http.Request) { + if strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + ts.serveWebsocket(w, r) + return + } + ts.serveOnePost(w, r) +} + +func (ts *TestServer) serveOnePost(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var raw rawRPCRequest + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + resp := ts.respondTo(raw) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// PingsReceived returns the total number of websocket ping frames the +// server has received across all live connections. +func (ts *TestServer) PingsReceived() int64 { return ts.pingsReceived.Load() } + +// SetDropPings, when true, makes the server count incoming pings but +// suppress the automatic pong reply — used to provoke client-side ping +// timeouts. +func (ts *TestServer) SetDropPings(b bool) { ts.dropPings.Store(b) } + +func (ts *TestServer) serveWebsocket(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OnPingReceived: func(_ context.Context, _ []byte) bool { + ts.pingsReceived.Add(1) + // Return true to let coder/websocket auto-reply with a pong, + // false to drop it silently. + return !ts.dropPings.Load() + }, + }) + if err != nil { + return + } + conn.SetReadLimit(wsReadLimit) + ts.mu.Lock() + ts.wsConns = append(ts.wsConns, conn) + ts.mu.Unlock() + defer func() { _ = conn.CloseNow() }() + + ctx := r.Context() + for { + _, data, err := conn.Read(ctx) + if err != nil { + return + } + var raw rawRPCRequest + if jerr := json.Unmarshal(data, &raw); jerr != nil { + continue + } + resp := ts.respondTo(raw) + respData, jerr := json.Marshal(resp) + if jerr != nil { + continue + } + if werr := conn.Write(ctx, websocket.MessageText, respData); werr != nil { + return + } + } +} + +// rawRPCRequest is the on-the-wire request shape; deliberately +// duplicated from rpcRequest so the test server can read malformed +// frames without imposing client-side constraints. +type rawRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID uint64 `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +func (ts *TestServer) respondTo(req rawRPCRequest) map[string]any { + out := map[string]any{"jsonrpc": "2.0", "id": req.ID} + result, rerr := ts.callHandler(TestRequest{ + Method: req.Method, + Params: req.Params, + ID: req.ID, + }) + if rerr != nil { + out["error"] = rerr + return out + } + out["result"] = result + return out +} diff --git a/l1/eth/client/transport_ws.go b/l1/eth/client/transport_ws.go new file mode 100644 index 0000000000..ef26de027f --- /dev/null +++ b/l1/eth/client/transport_ws.go @@ -0,0 +1,526 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/NethermindEth/juno/utils/log" + "github.com/coder/websocket" + "go.uber.org/zap" +) + +const ( + // wsReadLimit caps the size of a single websocket message. 16 MiB + // is generous for an Ethereum log payload — block gas limits make + // real logs far smaller — but still bounds an adversarial sender. + wsReadLimit = 16 << 20 + + // wsLogSubBuffer is the per-subscription pending-notification + // buffer. Sized so a stuck consumer doesn't immediately stall the + // reader, but bounded so an idle subscription doesn't pin memory. + // + // Liveness invariant: dispatchNotification blocks the single + // readLoop goroutine on logCh until the buffer drains, the sub + // closes, or the transport closes. While blocked, no responses to + // in-flight unary calls are dispatched and no control frames are + // processed — meaning a stalled subscription consumer stalls every + // unary RPC on the shared conn and eventually trips the ping + // timeout. The 64-deep buffer plus infrequent log cadence keep this + // safe in practice, but callers MUST drain their subscription sinks + // promptly; a sink read on a long ticker (e.g. minute-scale) only + // works because the buffer absorbs the gap. + wsLogSubBuffer = 64 + + // wsUnsubscribeTimeout is how long Unsubscribe waits for the server + // to acknowledge eth_unsubscribe before giving up. + wsUnsubscribeTimeout = 2 * time.Second + + // wsPingInterval is how long the connection may sit idle before we + // send a keep-alive ping. Matches go-ethereum's rpc/websocket.go + // constant — that value has held up against every major hosted RPC + // provider (Alchemy, Infura, QuickNode) and any Cloudflare-class + // proxy in front of them. + wsPingInterval = 30 * time.Second + + // wsPingTimeout bounds a single ping round-trip. A wedged write or + // stalled pong reply trips this and tears the transport down via + // the same path as a read error, instead of letting the reader + // silently hang. + wsPingTimeout = 10 * time.Second +) + +// rpcReply is the message exchanged on a pending-call channel: either +// a result payload or a server-side error. +type rpcReply struct { + result json.RawMessage + err error +} + +// wsTransport speaks JSON-RPC 2.0 over a single persistent websocket +// connection. Both unary calls and eth_subscribe notifications travel +// over the same conn and are routed by request id / subscription id. +type wsTransport struct { + conn *websocket.Conn + writeMu sync.Mutex + nextID atomic.Uint64 + logger log.StructuredLogger + + mu sync.Mutex + pending map[uint64]chan rpcReply // by request id + // pendingSubs: subscribe calls awaiting the server-assigned sub id, keyed by request id. + pendingSubs map[uint64]*wsLogSub + subs map[string]*wsLogSub // active subscriptions, by sub id + + // pingReset is signalled (best-effort) after every successful + // writeJSON so pingLoop can defer the next idle ping. Buffered to 1 + // so writers never block on a busy reset. + pingReset chan struct{} + pingInterval time.Duration + pingTimeout time.Duration + + closed chan struct{} + closeErr error + closeOnce sync.Once +} + +// dialWS dials rawURL and starts the reader and ping goroutines. The +// caller is responsible for calling close to release the connection. +// A zero opts.logger is replaced with a no-op logger; zero ping +// durations fall back to the package defaults. +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 + } + conn, resp, err := websocket.Dial(ctx, rawURL, nil) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if err != nil { + return nil, fmt.Errorf("dial ws: %w", err) + } + conn.SetReadLimit(wsReadLimit) + t := &wsTransport{ + conn: conn, + logger: opts.logger, + pending: make(map[uint64]chan rpcReply), + pendingSubs: make(map[uint64]*wsLogSub), + subs: make(map[string]*wsLogSub), + pingReset: make(chan struct{}, 1), + pingInterval: opts.pingInterval, + pingTimeout: opts.pingTimeout, + closed: make(chan struct{}), + } + go t.readLoop() //nolint:gosec // G118: long-lived loop, not request-scoped + go t.pingLoop() //nolint:gosec // G118: long-lived loop, not request-scoped + return t, nil +} + +// readLoop drains incoming frames until the connection breaks. Each +// frame is dispatched as either a response (id-matched) or a +// subscription notification (method == "eth_subscription"). Anything +// malformed is dropped: a misbehaving remote manifests as a call +// timeout, which is the right user-visible signal. +func (t *wsTransport) readLoop() { + for { + _, data, err := t.conn.Read(context.Background()) + if err != nil { + t.shutdown(err) + return + } + t.dispatch(data) + } +} + +// pingLoop sends a keep-alive ping after pingInterval of silence on the +// connection. Any outbound write resets the timer via pingReset, so a +// busy connection issues no redundant pings. A ping failure (write +// stall, pong timeout, transport already torn) goes through the same +// shutdown path as a read error — the redial layer above handles the +// rest. +func (t *wsTransport) pingLoop() { + timer := time.NewTimer(t.pingInterval) + defer timer.Stop() + for { + select { + case <-t.closed: + return + case <-t.pingReset: + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(t.pingInterval) + case <-timer.C: + ctx, cancel := context.WithTimeout(context.Background(), t.pingTimeout) + err := t.conn.Ping(ctx) + cancel() + if err != nil { + t.shutdown(fmt.Errorf("ws ping: %w", err)) + return + } + timer.Reset(t.pingInterval) + } + } +} + +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 { + // Malformed top-level JSON — drop the frame. A misbehaving + // remote shows up as a call timeout to the caller; the log + // is how operators distinguish "upstream silent" from + // "upstream sending garbage". + t.logger.Trace( + "ws: drop unparseable frame", + zap.Int("bytes", len(data)), + zap.Error(err), + ) + return + } + switch { + case probe.Method == "eth_subscription": + t.dispatchNotification(data) + case len(probe.ID) > 0 && !bytes.Equal(probe.ID, jsonNull): + t.dispatchResponse(data) + default: + t.logger.Trace( + "ws: drop frame with no id and no recognised method", + zap.ByteString("method", []byte(probe.Method)), + ) + } +} + +func (t *wsTransport) dispatchResponse(data []byte) { + var resp rpcResponse + if err := json.Unmarshal(data, &resp); err != nil { + t.logger.Trace( + "ws: drop response (decode failed)", + zap.Int("bytes", len(data)), + zap.Error(err), + ) + return + } + id, err := parseResponseID(resp.ID) + if err != nil { + t.logger.Trace( + "ws: drop response (bad id)", + zap.ByteString("rawID", resp.ID), + zap.Error(err), + ) + return + } + + t.mu.Lock() + ch, hasPending := t.pending[id] + delete(t.pending, id) + pendingSub, isSubscribe := t.pendingSubs[id] + delete(t.pendingSubs, id) + t.mu.Unlock() + + if !hasPending { + // Either the caller's ctx fired and cancelPending already + // cleaned up, or the server is replying to a request we + // never sent. Either way nothing actionable; log so an + // operator can correlate against client-side cancellations. + t.logger.Trace( + "ws: drop response (no pending caller)", + zap.Uint64("id", id), + ) + return + } + + reply := rpcReply{} + switch { + case resp.Error != nil: + reply.err = resp.Error + case isSubscribe: + // Decode the subscription id and register the sub BEFORE the + // caller's goroutine wakes up. Otherwise a notification could + // race in (the reader processes one frame at a time, but the + // caller doesn't get scheduled in lockstep). pendingSub.id is + // set under t.mu so callWithSubReg can safely test for it on + // the ctx.Done() cleanup path. + var subID string + if err := json.Unmarshal(resp.Result, &subID); err != nil { + reply.err = fmt.Errorf("decode subscription id: %w", err) + } else if subID == "" { + reply.err = errors.New("empty subscription id") + } else { + t.mu.Lock() + if t.subs != nil { + pendingSub.id = subID + t.subs[subID] = pendingSub + reply.result = resp.Result + } else { + reply.err = ErrTransportClosed + } + t.mu.Unlock() + } + default: + reply.result = resp.Result + } + + // Best-effort send: ch is buffered to 1 so this can't block. If the + // caller already gave up (ctx cancelled), no one will read it. + select { + case ch <- reply: + default: + } +} + +func (t *wsTransport) dispatchNotification(data []byte) { + var notif struct { + Method string `json:"method"` + Params struct { + Subscription string `json:"subscription"` + Result json.RawMessage `json:"result"` + } `json:"params"` + } + if err := json.Unmarshal(data, ¬if); err != nil { + t.logger.Trace( + "ws: drop notification (decode failed)", + zap.Int("bytes", len(data)), + zap.Error(err), + ) + return + } + t.mu.Lock() + sub := t.subs[notif.Params.Subscription] + t.mu.Unlock() + if sub == nil { + // Server may emit one more notification between our + // eth_unsubscribe send and the server processing it; harmless, + // but log so it's visible. + t.logger.Trace( + "ws: drop notification for unknown subscription", + zap.String("subscription", notif.Params.Subscription), + ) + return + } + select { + case sub.logCh <- notif.Params.Result: + case <-sub.closed: + case <-t.closed: + } +} + +// shutdown is the single termination path. It fans the cause out to +// every pending caller and active subscription, then closes the conn. +// +// The cause is normalised so that errors.Is(err, ErrTransportClosed) is +// reliable for every caller observing a close — including the in-flight +// call that races the disconnect (which would otherwise see the raw +// read/ping error and skip the redial path in withRetryOnClosed). +func (t *wsTransport) shutdown(cause error) { + t.closeOnce.Do(func() { + switch { + case cause == nil: + cause = ErrTransportClosed + case !errors.Is(cause, ErrTransportClosed): + // fmt.Errorf with two %w verbs (Go 1.20+) wraps both errors + // into the chain — errors.Is(err, ErrTransportClosed) is + // reliable — while rendering on a single line. errors.Join + // would also work but its separator is "\n", which uglifies + // the resulting log message. + cause = fmt.Errorf("%w: %w", ErrTransportClosed, cause) + } + t.mu.Lock() + pending, pendingSubs, subs := t.pending, t.pendingSubs, t.subs + t.pending = nil + t.pendingSubs = nil + t.subs = nil + t.closeErr = cause + t.mu.Unlock() + close(t.closed) + + for _, ch := range pending { + select { + case ch <- rpcReply{err: cause}: + default: + } + } + for _, sub := range pendingSubs { + sub.fail(cause) + } + for _, sub := range subs { + sub.fail(cause) + } + // Use CloseNow so we don't block on a handshake the remote may + // already have abandoned. + _ = t.conn.CloseNow() + }) +} + +func (t *wsTransport) close() { t.shutdown(ErrTransportClosed) } + +// writeJSON serialises and sends one frame. Writes are serialised +// because coder/websocket's Write is not concurrency-safe for arbitrary +// callers (only one Writer/Reader pair may be active at a time). On a +// successful write the idle ping timer is reset so we don't spend a +// ping on top of real traffic. +func (t *wsTransport) writeJSON(ctx context.Context, v any) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + t.writeMu.Lock() + defer t.writeMu.Unlock() + if err := t.conn.Write(ctx, websocket.MessageText, data); err != nil { + return err + } + select { + case t.pingReset <- struct{}{}: + default: + } + return nil +} + +// call sends a JSON-RPC request and waits for its response. +func (t *wsTransport) call( + ctx context.Context, + method string, + params ...any, +) (json.RawMessage, error) { + return t.callWithSubReg(ctx, method, nil, params...) +} + +// callWithSubReg is the shared implementation for unary calls and +// subscribe calls. When pendingSub is non-nil, the response handler +// extracts the subscription id and registers the sub atomically with +// the reply delivery. +func (t *wsTransport) callWithSubReg( + ctx context.Context, + method string, + pendingSub *wsLogSub, + 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 + if pendingSub != nil { + t.pendingSubs[id] = pendingSub + } + t.mu.Unlock() + + deregister := func() { + t.mu.Lock() + if t.pending != nil { + delete(t.pending, id) + delete(t.pendingSubs, id) + } + t.mu.Unlock() + } + + if err := t.writeJSON(ctx, rpcRequest{ + JSONRPC: jsonrpcVersion, + ID: id, + Method: method, + Params: params, + }); err != nil { + deregister() + // If the write failed because the caller's ctx was cancelled, + // surface the ctx error verbatim — that's what the caller is + // going to check for. + if cerr := ctx.Err(); cerr != nil { + return nil, cerr + } + return nil, fmt.Errorf("write request: %w", err) + } + + select { + case reply := <-ch: + // dispatchResponse already removed our entry; deregister is a no-op. + if reply.err != nil { + // Same race as the t.closed branch below: a cancelled-ctx + // write can tear down the conn, the readLoop's Read fails, + // and shutdown fans that error out to every pending caller + // via ch — so <-ch and <-ctx.Done() are ready together and + // the select picks at random. The caller asked about their + // ctx; honour that rather than surfacing the resulting + // "use of closed network connection". + if cerr := ctx.Err(); cerr != nil { + return nil, cerr + } + return nil, reply.err + } + return reply.result, nil + case <-ctx.Done(): + t.cancelPending(id, pendingSub) + return nil, ctx.Err() + case <-t.closed: + deregister() + // Race: a cancelled-ctx write into coder/websocket can tear + // down the underlying conn, which makes t.closed and + // ctx.Done() ready simultaneously — select picks at random. + // The caller asked about their ctx; honour that rather than + // surfacing the transport's "use of closed network connection". + if cerr := ctx.Err(); cerr != nil { + return nil, cerr + } + return nil, t.closeErr + } +} + +// cancelPending tears down a pending call after the caller's ctx fired. +// If dispatchResponse already registered a subscription, this removes it +// from t.subs and best-effort tells the server to release its side; the +// unsubscribe RPC is sent on a fresh background ctx because the caller's +// ctx is already dead. +func (t *wsTransport) cancelPending(id uint64, pendingSub *wsLogSub) { + var leakedSubID string + t.mu.Lock() + if t.pending != nil { + delete(t.pending, id) + delete(t.pendingSubs, id) + } + if pendingSub != nil && pendingSub.id != "" && t.subs != nil { + leakedSubID = pendingSub.id + delete(t.subs, leakedSubID) + } + t.mu.Unlock() + if leakedSubID == "" { + return + } + // The sub was registered between dispatchResponse and the caller's + // ctx firing; the caller will never call Unsubscribe on it, so we + // must. Spawned because we don't want to delay the caller's return + // past their cancelled ctx; lifetime is bounded by the unsubscribe + // timeout and by t.closed (t.call selects on it). + go func() { + ctx, cancel := context.WithTimeout(context.Background(), wsUnsubscribeTimeout) + defer cancel() + if _, err := t.call(ctx, "eth_unsubscribe", leakedSubID); err != nil { + t.logger.Trace( + "ws: best-effort eth_unsubscribe failed", + zap.String("subscription", leakedSubID), + zap.Error(err), + ) + } + }() +} diff --git a/l1/eth/client/transport_ws_test.go b/l1/eth/client/transport_ws_test.go new file mode 100644 index 0000000000..13a1747b0c --- /dev/null +++ b/l1/eth/client/transport_ws_test.go @@ -0,0 +1,576 @@ +package client_test + +import ( + "context" + "encoding/json" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/eth/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWS_UnaryCall(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + require.Equal(t, "eth_chainId", req.Method) + return "0x539", nil + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + id, err := cli.ChainID(t.Context()) + require.NoError(t, err) + assert.Equal(t, "1337", id.String()) +} + +func TestWS_SubscribeReceivesLogs(t *testing.T) { + const subID = "0x1a2b3c" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + require.GreaterOrEqual(t, len(req.Params), 1) + return subID, nil + case "eth_unsubscribe": + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 4) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + // Push two log notifications. + for _, bnHex := range []string{"0x10", "0x11"} { + require.NoError(t, srv.PushNotification(t.Context(), subID, map[string]any{ + "topics": []string{"0xdb80dd488acf86d17c747445b0eabb5d57c541d3bd7b6b87af987858e5066b2b"}, + "data": "0x", + "blockNumber": bnHex, + "removed": false, + })) + } + + got := receiveLogs(t, sink, 2, 2*time.Second) + require.Len(t, got, 2) + assert.Equal(t, uint64(0x10), uint64(got[0].BlockNumber)) + assert.Equal(t, uint64(0x11), uint64(got[1].BlockNumber)) + + // Err() must NOT have fired yet. + select { + case err, open := <-sub.Err(): + t.Fatalf("Err() fired unexpectedly: err=%v open=%v", err, open) + default: + } +} + +func TestWS_SubscribeServerError(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + return nil, &client.TestRPCError{Code: -32601, Message: "method not supported"} + }) + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + _, err = cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.Error(t, err) + assert.Contains(t, err.Error(), "subscribe to logs") + assert.Contains(t, err.Error(), "method not supported") +} + +func TestWS_ServerKillsConnection(t *testing.T) { + const subID = "0xdeadbeef" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + require.Equal(t, "eth_subscribe", req.Method) + return subID, nil + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + + // Kill the connection; Err() must fire. + srv.KillWSConns() + + select { + case err := <-sub.Err(): + assert.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Err() did not fire after server killed the connection") + } +} + +func TestWS_UnsubscribeIssuesCall(t *testing.T) { + const subID = "0xabc" + var sawUnsub atomic.Bool + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + sawUnsub.Store(true) + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + + sub.Unsubscribe() + + // Err() closes on Unsubscribe. + select { + case _, open := <-sub.Err(): + assert.False(t, open, "Err() should be closed after Unsubscribe") + case <-time.After(time.Second): + t.Fatal("Err() did not close after Unsubscribe") + } + require.Eventually(t, sawUnsub.Load, 2*time.Second, 10*time.Millisecond, + "server never received eth_unsubscribe") +} + +func TestWS_ClientCloseFailsActiveSubscriptions(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + return "0xfeed", nil + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + + cli.Close() + + select { + case err := <-sub.Err(): + // On clean close the cause is ErrTransportClosed; with a torn + // transport it may be a wrapped websocket error. Either is OK. + assert.True(t, err == nil || errors.Is(err, client.ErrTransportClosed) || err != nil) + case <-time.After(2 * time.Second): + t.Fatal("Err() did not fire after client.Close") + } +} + +func TestWS_ContextCancelMidCall(t *testing.T) { + srv := client.NewTestServer(t) + // Handler that never returns a result (simulate a hung server). + gate := make(chan struct{}) + t.Cleanup(func() { close(gate) }) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + <-gate + return "0x0", nil + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + _, err = cli.ChainID(ctx) + require.Error(t, err) + assert.True(t, errors.Is(err, context.Canceled), "expected context.Canceled, got %v", err) +} + +// TestWS_SubscribeOmitsBlockRange is a regression test for the live-logs +// shape sent on eth_subscribe. Geth treats an explicit toBlock=0 as a +// bounded historical filter that terminates at block 0 — so an empty +// FilterQuery MUST serialise without fromBlock/toBlock keys, otherwise +// no live LogStateUpdate events ever reach the node. +// +// This is the unit-level guard for the bug; the manual Sepolia smoke is +// still the strongest end-to-end check. +func TestWS_SubscribeOmitsBlockRange(t *testing.T) { + const subID = "0xfeed" + type capturedSub struct { + params []json.RawMessage + } + var captured atomic.Pointer[capturedSub] + + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_subscribe" { + captured.Store(&capturedSub{params: req.Params}) + return subID, nil + } + if req.Method == "eth_unsubscribe" { + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + got := captured.Load() + require.NotNil(t, got, "eth_subscribe was never received by the test server") + require.Len(t, got.params, 2, `expected ["logs", ] params`) + + var filter map[string]any + require.NoError(t, json.Unmarshal(got.params[1], &filter)) + _, hasFrom := filter["fromBlock"] + assert.False(t, hasFrom, + `eth_subscribe filter must omit "fromBlock" for a live-logs subscription; got %v`, + filter, + ) + _, hasTo := filter["toBlock"] + assert.False(t, hasTo, + `eth_subscribe filter must omit "toBlock" for a live-logs subscription; got %v`, + filter, + ) +} + +// TestWS_PingLoopFires verifies the transport sends idle keep-alive +// pings without provocation. Without this, intermediaries with a TCP +// idle timeout (Cloudflare, Alchemy, et al.) close the conn after a few +// minutes of no notifications and we churn through reconnect cycles. +func TestWS_PingLoopFires(t *testing.T) { + srv := client.NewTestServer(t) + cli, err := client.New(t.Context(), srv.WSURL(), + client.WithPingConfig(20*time.Millisecond, time.Second), + ) + require.NoError(t, err) + t.Cleanup(cli.Close) + + require.Eventually(t, func() bool { + return srv.PingsReceived() >= 3 + }, 2*time.Second, 10*time.Millisecond, + "expected >= 3 pings within window; got %d", srv.PingsReceived()) +} + +// TestWS_PingTimeoutClosesTransport verifies that when the server stops +// honouring pings, the client tears the transport down via the same +// path as a read error. The active subscription's Err() must fire so +// the redial loop above takes over. +func TestWS_PingTimeoutClosesTransport(t *testing.T) { + const subID = "0xfade" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + srv.SetDropPings(true) + + cli, err := client.New(t.Context(), srv.WSURL(), + client.WithPingConfig(20*time.Millisecond, 50*time.Millisecond), + ) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + select { + case err, open := <-sub.Err(): + assert.True(t, open, "Err() should deliver an error before closing") + assert.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("subscription Err() did not fire after ping timeout") + } +} + +// TestWS_CancelPendingCleansLeakedSubscription deterministically +// exercises cancelPending's leaked-subscription cleanup branch — the +// race-tight path where dispatchResponse registered the sub +// (pendingSub.id is set) just before the caller's ctx fired. The branch +// matters because the caller will never call Unsubscribe on a sub that +// raced its ctx cancel, so cancelPending must (a) remove it from t.subs +// and (b) fire a best-effort eth_unsubscribe to the server. +// +// From the public surface this race is timing-dependent and not +// reachable deterministically — once dispatchResponse has set +// pendingSub.id under t.mu, the reply is also on ch, and Go's select +// picks pseudo-randomly between ctx.Done() and the ready reply. We use +// the CancelPendingFor test helper to invoke cancelPending directly +// against a successfully-subscribed sub, simulating the race outcome. +func TestWS_CancelPendingCleansLeakedSubscription(t *testing.T) { + const subID = "0xleaked" + var unsubReceived atomic.Bool + + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + unsubReceived.Store(true) + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + // Don't defer sub.Unsubscribe(): CancelPendingFor simulates the + // "caller's ctx fired before they could call Unsubscribe" path, + // and the cleanup we're testing is the substitute for Unsubscribe. + + // Trigger the leaked-sub cleanup branch directly. + client.CancelPendingFor(cli, sub) + + // Server-side best-effort eth_unsubscribe must arrive. + require.Eventually(t, unsubReceived.Load, 2*time.Second, 10*time.Millisecond, + "cancelPending must spawn best-effort eth_unsubscribe for the leaked sub") + + // And the sub must be gone from t.subs — push a notification with + // the same id; if the cleanup worked it gets dropped (no panic, no + // delivery to sink). + require.NoError(t, srv.PushNotification(t.Context(), subID, map[string]any{ + "topics": []string{"0xdeadbeef0000000000000000000000000000000000000000000000000000beef"}, + "data": "0x", + "blockNumber": "0x1", + "removed": false, + })) + select { + case got := <-sink: + t.Fatalf("notification for leaked sub should not be delivered; got %+v", got) + case <-time.After(100 * time.Millisecond): + // Expected: nothing arrives. + } +} + +// TestWS_CancelPendingLogsUnsubscribeFailure exercises the trace-log +// branch inside cancelPending's spawned best-effort eth_unsubscribe +// goroutine — fired when the unsubscribe RPC itself fails (e.g. server +// returns an error or transport has since closed). Without this +// coverage, a regression that silently swallows the eth_unsubscribe +// failure (e.g. dropping the trace log) wouldn't be caught. +func TestWS_CancelPendingLogsUnsubscribeFailure(t *testing.T) { + const subID = "0xleakederr" + var unsubAttempted atomic.Bool + + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + unsubAttempted.Store(true) + // Server returns an error — the spawned goroutine's + // best-effort call returns it, and the trace-log branch + // fires. + return nil, &client.TestRPCError{Code: -32000, Message: "subscription not found"} + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + + client.CancelPendingFor(cli, sub) + + require.Eventually(t, unsubAttempted.Load, 2*time.Second, 10*time.Millisecond, + "cancelPending must still attempt best-effort eth_unsubscribe even when the server rejects it") +} + +// TestWS_SubscribeDispatchDecodeFailure pushes a notification whose +// payload can't be JSON-decoded as eth.Log. The per-subscription +// dispatch goroutine must surface the decode error on Err() — without +// this, a single malformed event would silently disappear (sink stays +// empty, Err() never fires, caller waits forever). +func TestWS_SubscribeDispatchDecodeFailure(t *testing.T) { + const subID = "0xc0de" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + // topics expects an array of hex strings; a string here forces the + // eth.Log unmarshal to fail. + require.NoError(t, srv.PushNotification(t.Context(), subID, map[string]any{ + "topics": "not-an-array", + })) + + select { + case errOut, open := <-sub.Err(): + assert.True(t, open, "Err() must deliver the cause before closing") + require.Error(t, errOut) + assert.Contains(t, errOut.Error(), "decode log") + case <-time.After(2 * time.Second): + t.Fatal("Err() did not fire on undecodable notification payload") + } +} + +// TestWS_DispatchDropsMalformedFrames verifies the readLoop's defensive +// posture: a malformed frame (unparseable JSON, frame with no id and no +// recognised method, response with bad/unknown id) must be dropped +// without tearing the transport down. Without this guarantee a single +// confused upstream takes the entire client offline. +func TestWS_DispatchDropsMalformedFrames(t *testing.T) { + const subID = "0xabc" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + return true, nil + case "eth_chainId": + return "0x1", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + // Open a subscription so an active sub exists when we push the + // notification-with-unknown-sub frame below. + sink := make(chan *eth.Log, 1) + sub, err := cli.SubscribeLogs(t.Context(), client.FilterQuery{}, sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + 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") + } + + // The transport must still serve calls — neither the malformed + // frames nor the unknown-sub notification should have torn it down. + id, err := cli.ChainID(t.Context()) + require.NoError(t, err, "transport must survive every malformed frame") + assert.Equal(t, "1", id.String()) + + // Active subscription's Err() must NOT have fired. + select { + case e, open := <-sub.Err(): + t.Fatalf("subscription Err() fired unexpectedly (open=%v err=%v)", open, e) + default: + } +} + +// TestWS_CallReturnsCtxErrAfterCancellation exercises the +// cancelPending path on a call whose subscribe response never arrived +// (no pendingSub.id set → "leakedSubID == \"\"" branch). The handler +// blocks, the caller cancels its ctx, the call returns ctx.Canceled. +func TestWS_CallReturnsCtxErrAfterCancellation(t *testing.T) { + gate := make(chan struct{}) + t.Cleanup(func() { close(gate) }) + srv := client.NewTestServer(t) + srv.SetHandler(func(_ client.TestRequest) (any, *client.TestRPCError) { + <-gate + return "0xfeed", nil + }) + + cli, err := client.New(t.Context(), srv.WSURL()) + require.NoError(t, err) + t.Cleanup(cli.Close) + + ctx, cancel := context.WithCancel(t.Context()) + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + sink := make(chan *eth.Log, 1) + _, err = cli.SubscribeLogs(ctx, client.FilterQuery{}, sink) + require.Error(t, err) + // cancelPending ran for a subscribe with no id yet — the leakedSubID + // branch must NOT have spawned a goroutine for empty-id cleanup. + assert.True(t, errors.Is(err, context.Canceled), "expected ctx.Canceled, got %v", err) +} + +// receiveLogs drains up to n logs from sink with a timeout. Returns +// what it got; the caller asserts count and contents. +func receiveLogs(t *testing.T, sink <-chan *eth.Log, n int, timeout time.Duration) []*eth.Log { + t.Helper() + deadline := time.After(timeout) + out := make([]*eth.Log, 0, n) + for len(out) < n { + select { + case log := <-sink: + out = append(out, log) + case <-deadline: + return out + } + } + return out +} diff --git a/l1/eth/contract/starknet.go b/l1/eth/contract/starknet.go new file mode 100644 index 0000000000..25d7290c56 --- /dev/null +++ b/l1/eth/contract/starknet.go @@ -0,0 +1,214 @@ +// Package contract hand-decodes the Starknet core L1 contract events +// juno consumes. It exists to remove the abigen + go-ethereum dependency +// from the L1 sync path. Only the event(s) juno actually subscribes to +// are implemented; calls into contract methods are not supported because +// juno doesn't make any. +package contract + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "sync" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/eth/client" +) + +// LogStateUpdateSigHash is keccak256("LogStateUpdate(uint256,int256,uint256)"). +// Verified against the deployed Starknet core contract on Ethereum +// mainnet and against the go-ethereum abigen output that this package +// replaces. +var LogStateUpdateSigHash = eth.HashFromString( + "0xd342ddf7a308dec111745b00315c14b7efb2bdae570a6856e088ed0c65a3576c", +) + +// logStateUpdateDataLen is the byte length of the LogStateUpdate event's +// data section: three 32-byte words for (uint256 globalRoot, +// int256 blockNumber, uint256 blockHash). No indexed args, so all three +// fields are in data rather than topics. +const logStateUpdateDataLen = 3 * 32 + +// watchSinkBuffer is the per-subscription buffer between the raw log +// channel and the decoded-event sink. Sized so a slow consumer doesn't +// immediately stall the underlying transport reader. +const watchSinkBuffer = 64 + +// LogStateUpdate is the decoded form of the Starknet core contract's +// LogStateUpdate event. +// +// event LogStateUpdate(uint256 globalRoot, int256 blockNumber, uint256 blockHash) +// +// BlockNumber is declared as int256 in Solidity but the bridge always +// emits non-negative values that fit in uint64, so we decode it as +// uint64 (low 8 bytes of the 32-byte word). globalRoot and blockHash +// are Starknet field elements packed into a uint256 slot, so they +// land in felt.Felt without going through *big.Int. +type LogStateUpdate struct { + GlobalRoot felt.Felt + BlockNumber uint64 + BlockHash felt.Felt + + // Raw is the underlying log envelope, preserving the L1 block + // number where the event was emitted and the Removed flag set on + // reorgs. + Raw eth.Log +} + +// ErrWrongTopic is returned when Decode is given a log whose first +// topic is not LogStateUpdateSigHash. +var ErrWrongTopic = errors.New("log topic is not LogStateUpdate") + +// Decode parses a single eth.Log into a LogStateUpdate. The caller is +// expected to have prefiltered by topic and contract address; Decode +// re-checks the sig hash defensively. +func Decode(log *eth.Log) (*LogStateUpdate, error) { + if len(log.Topics) == 0 || log.Topics[0] != LogStateUpdateSigHash { + return nil, ErrWrongTopic + } + if len(log.Data) != logStateUpdateDataLen { + return nil, fmt.Errorf("bad LogStateUpdate data length: got %d, want %d", + len(log.Data), logStateUpdateDataLen) + } + ev := &LogStateUpdate{ + // Low 8 bytes of the 32-byte int256 slot. The upper 24 bytes + // are silently dropped; the bridge always emits a block number + // that fits in uint64. + BlockNumber: binary.BigEndian.Uint64(log.Data[56:64]), + Raw: *log, + } + ev.GlobalRoot.SetBytes(log.Data[0:32]) + ev.BlockHash.SetBytes(log.Data[64:96]) + return ev, nil +} + +// LogClient is the slice of *client.Client the contract decoder needs. +// Declared as an interface so tests can swap in a fake without dialling +// a real endpoint. +type LogClient interface { + FilterLogs(ctx context.Context, q client.FilterQuery) ([]eth.Log, error) + SubscribeLogs( + ctx context.Context, + q client.FilterQuery, + sink chan<- *eth.Log, + ) (eth.Subscription, error) +} + +// FilterLogStateUpdate returns every LogStateUpdate emitted by contract +// in the inclusive L1 block range [from, to]. +func FilterLogStateUpdate( + ctx context.Context, + c LogClient, + contract eth.Address, + from uint64, + to uint64, +) ([]*LogStateUpdate, error) { + logs, err := c.FilterLogs(ctx, client.FilterQuery{ + FromBlock: &from, + ToBlock: &to, + Addresses: []eth.Address{contract}, + Topics: [][]eth.Hash{{LogStateUpdateSigHash}}, + }) + if err != nil { + return nil, fmt.Errorf("filtering LogStateUpdate: %w", err) + } + out := make([]*LogStateUpdate, len(logs)) + for i := range logs { + ev, derr := Decode(&logs[i]) + if derr != nil { + return nil, fmt.Errorf("decoding LogStateUpdate: %w", derr) + } + out[i] = ev + } + return out, nil +} + +// WatchLogStateUpdate subscribes to live LogStateUpdate events from +// contract. Decoded events are delivered on sink; the returned +// Subscription surfaces transport errors on Err() and releases the +// subscription on Unsubscribe. +// +// On a decode failure the watcher terminates with the decode error on +// Err() — a misformatted log can only mean we're talking to the wrong +// contract or to a buggy node. +func WatchLogStateUpdate( + ctx context.Context, + c LogClient, + contract eth.Address, + sink chan<- *LogStateUpdate, +) (eth.Subscription, error) { + rawSink := make(chan *eth.Log, watchSinkBuffer) + inner, err := c.SubscribeLogs(ctx, client.FilterQuery{ + Addresses: []eth.Address{contract}, + Topics: [][]eth.Hash{{LogStateUpdateSigHash}}, + }, rawSink) + if err != nil { + return nil, fmt.Errorf("subscribing to LogStateUpdate: %w", err) + } + w := &stateUpdateWatcher{ + inner: inner, + errCh: make(chan error, 1), + closed: make(chan struct{}), + } + go w.run(rawSink, sink) + return w, nil +} + +// stateUpdateWatcher decorates an underlying log subscription with +// decoding into LogStateUpdate. Lifecycle is one-to-one with the inner +// subscription. +type stateUpdateWatcher struct { + inner eth.Subscription + errCh chan error + closed chan struct{} + closeOnce sync.Once +} + +func (w *stateUpdateWatcher) Err() <-chan error { return w.errCh } + +func (w *stateUpdateWatcher) Unsubscribe() { + w.fail(nil) + w.inner.Unsubscribe() +} + +func (w *stateUpdateWatcher) fail(cause error) { + w.closeOnce.Do(func() { + close(w.closed) + if cause != nil { + select { + case w.errCh <- cause: + default: + } + } + close(w.errCh) + }) +} + +func (w *stateUpdateWatcher) run(rawSink <-chan *eth.Log, sink chan<- *LogStateUpdate) { + for { + select { + case <-w.closed: + return + case err, ok := <-w.inner.Err(): + if !ok { + w.fail(nil) + return + } + w.fail(err) + return + case raw := <-rawSink: + ev, err := Decode(raw) + if err != nil { + w.fail(fmt.Errorf("decoding LogStateUpdate: %w", err)) + return + } + select { + case sink <- ev: + case <-w.closed: + return + } + } + } +} diff --git a/l1/eth/contract/starknet_test.go b/l1/eth/contract/starknet_test.go new file mode 100644 index 0000000000..bf87be990f --- /dev/null +++ b/l1/eth/contract/starknet_test.go @@ -0,0 +1,300 @@ +package contract_test + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "math" + "strings" + "sync" + "testing" + "time" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/eth/client" + "github.com/NethermindEth/juno/l1/eth/contract" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/sha3" +) + +// TestLogStateUpdateSigHash_DerivedFromSignature verifies the +// hard-coded constant is keccak256("LogStateUpdate(uint256,int256,uint256)"). +// Catches a typo in the constant if anyone ever edits it. +func TestLogStateUpdateSigHash_DerivedFromSignature(t *testing.T) { + h := sha3.NewLegacyKeccak256() + h.Write([]byte("LogStateUpdate(uint256,int256,uint256)")) + var sum eth.Hash + sum.SetBytes(h.Sum(nil)) + assert.Equal(t, sum, contract.LogStateUpdateSigHash) +} + +func TestDecode_Success(t *testing.T) { + // globalRoot = 0x11..11; blockNumber = 0x539 (1337); + // blockHash = 0x22..22. + data := bytes.Repeat([]byte{0x11}, 32) // globalRoot + data = append(data, leftPad32Uint64(1337)...) + data = append(data, bytes.Repeat([]byte{0x22}, 32)...) // blockHash + require.Len(t, data, 96) + + log := ð.Log{ + Topics: []eth.Hash{contract.LogStateUpdateSigHash}, + Data: eth.DataBytes(data), + BlockNumber: eth.HexU64(1_000), + Removed: false, + } + + ev, err := contract.Decode(log) + require.NoError(t, err) + + var wantRoot, wantHash felt.Felt + wantRoot.SetBytes(bytes.Repeat([]byte{0x11}, 32)) + wantHash.SetBytes(bytes.Repeat([]byte{0x22}, 32)) + assert.Equal(t, wantRoot, ev.GlobalRoot) + assert.Equal(t, uint64(1337), ev.BlockNumber) + assert.Equal(t, wantHash, ev.BlockHash) + assert.Equal(t, uint64(1_000), uint64(ev.Raw.BlockNumber)) + assert.False(t, ev.Raw.Removed) +} + +// TestDecode_BlockNumberTakesLow8Bytes verifies the upper 24 bytes of +// the int256 slot are dropped (the bridge only ever emits values that +// fit in uint64, but the on-the-wire slot is 32 bytes). All-0xff in +// the low 8 bytes must round-trip as math.MaxUint64; the high bytes +// must not influence the result. +func TestDecode_BlockNumberTakesLow8Bytes(t *testing.T) { + data := make([]byte, 96) + // globalRoot — anything decodable. + data[31] = 0x01 + // blockNumber slot [32:64]: upper 24 bytes are noise, low 8 bytes + // are math.MaxUint64. + for i := 32; i < 56; i++ { + data[i] = 0xaa + } + for i := 56; i < 64; i++ { + data[i] = 0xff + } + // blockHash — anything decodable. + data[95] = 0x02 + + ev, err := contract.Decode(ð.Log{ + Topics: []eth.Hash{contract.LogStateUpdateSigHash}, + Data: eth.DataBytes(data), + }) + require.NoError(t, err) + assert.Equal(t, uint64(math.MaxUint64), ev.BlockNumber) +} + +func TestDecode_WrongTopic(t *testing.T) { + log := ð.Log{ + Topics: []eth.Hash{eth.HashFromString("0x" + strings.Repeat("00", 32))}, + Data: eth.DataBytes(make([]byte, 96)), + } + _, err := contract.Decode(log) + require.ErrorIs(t, err, contract.ErrWrongTopic) +} + +func TestDecode_NoTopics(t *testing.T) { + log := ð.Log{Data: eth.DataBytes(make([]byte, 96))} + _, err := contract.Decode(log) + require.ErrorIs(t, err, contract.ErrWrongTopic) +} + +func TestDecode_BadDataLength(t *testing.T) { + log := ð.Log{ + Topics: []eth.Hash{contract.LogStateUpdateSigHash}, + Data: eth.DataBytes(make([]byte, 95)), + } + _, err := contract.Decode(log) + require.Error(t, err) + assert.Contains(t, err.Error(), "bad LogStateUpdate data length") +} + +// fakeLogClient is a hand-written LogClient for the Filter/Watch tests. +type fakeLogClient struct { + mu sync.Mutex + filterReturn []eth.Log + filterErr error + subSink chan<- *eth.Log + subErr chan error + closed chan struct{} + closeOnce sync.Once +} + +func (f *fakeLogClient) FilterLogs(_ context.Context, _ client.FilterQuery) ([]eth.Log, error) { + return f.filterReturn, f.filterErr +} + +func (f *fakeLogClient) SubscribeLogs( + _ context.Context, + _ client.FilterQuery, + sink chan<- *eth.Log, +) (eth.Subscription, error) { + f.mu.Lock() + f.subSink = sink + f.subErr = make(chan error, 1) + f.closed = make(chan struct{}) + f.mu.Unlock() + return f, nil +} + +func (f *fakeLogClient) Err() <-chan error { return f.subErr } +func (f *fakeLogClient) Unsubscribe() { + f.closeOnce.Do(func() { + close(f.closed) + }) +} + +func TestFilterLogStateUpdate_DecodesAll(t *testing.T) { + fc := &fakeLogClient{ + filterReturn: []eth.Log{ + validStateUpdateLog(1), + validStateUpdateLog(2), + }, + } + contractAddr := eth.AddressFromString("0x000000000000000000000000000000000000beef") + + got, err := contract.FilterLogStateUpdate(t.Context(), fc, contractAddr, 100, 200) + require.NoError(t, err) + require.Len(t, got, 2) + assert.Equal(t, uint64(1), got[0].BlockNumber) + assert.Equal(t, uint64(2), got[1].BlockNumber) +} + +func TestFilterLogStateUpdate_DecodeFailureSurfaces(t *testing.T) { + bad := validStateUpdateLog(1) + bad.Data = bad.Data[:50] // truncated + fc := &fakeLogClient{filterReturn: []eth.Log{bad}} + + _, err := contract.FilterLogStateUpdate(t.Context(), fc, + eth.Address{}, 0, 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "bad LogStateUpdate data length") +} + +func TestFilterLogStateUpdate_FilterErr(t *testing.T) { + fc := &fakeLogClient{filterErr: errors.New("rate limited")} + _, err := contract.FilterLogStateUpdate(t.Context(), fc, eth.Address{}, 0, 1) + require.Error(t, err) + assert.Contains(t, err.Error(), "rate limited") +} + +func TestWatchLogStateUpdate_DeliversDecoded(t *testing.T) { + fc := &fakeLogClient{} + sink := make(chan *contract.LogStateUpdate, 4) + sub, err := contract.WatchLogStateUpdate(t.Context(), fc, eth.Address{}, sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + // Push two log payloads through the fake's sub sink. + for _, n := range []uint64{42, 43} { + fc.mu.Lock() + sender := fc.subSink + fc.mu.Unlock() + raw := validStateUpdateLog(n) + sender <- &raw + } + got := drainStateUpdates(t, sink, 2, time.Second) + require.Len(t, got, 2) + assert.Equal(t, uint64(42), got[0].BlockNumber) + assert.Equal(t, uint64(43), got[1].BlockNumber) +} + +func TestWatchLogStateUpdate_DecodeFailureClosesErr(t *testing.T) { + fc := &fakeLogClient{} + sink := make(chan *contract.LogStateUpdate, 1) + sub, err := contract.WatchLogStateUpdate(t.Context(), fc, eth.Address{}, sink) + require.NoError(t, err) + + // Wait until SubscribeLogs has installed the sink, then push a + // malformed log. + require.Eventually(t, func() bool { + fc.mu.Lock() + defer fc.mu.Unlock() + return fc.subSink != nil + }, time.Second, time.Millisecond) + bad := validStateUpdateLog(1) + bad.Data = bad.Data[:1] + fc.subSink <- &bad + + select { + case errOut := <-sub.Err(): + require.Error(t, errOut) + assert.Contains(t, errOut.Error(), "decoding") + case <-time.After(time.Second): + t.Fatal("Err() did not fire on decode failure") + } +} + +func TestWatchLogStateUpdate_InnerErrPropagates(t *testing.T) { + fc := &fakeLogClient{} + sink := make(chan *contract.LogStateUpdate, 1) + sub, err := contract.WatchLogStateUpdate(t.Context(), fc, eth.Address{}, sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + // Signal a transport failure from the fake. + want := errors.New("ws closed") + fc.mu.Lock() + fc.subErr <- want + fc.mu.Unlock() + + select { + case got := <-sub.Err(): + assert.ErrorIs(t, got, want) + case <-time.After(time.Second): + t.Fatal("Err() did not propagate inner error") + } +} + +// --- helpers --- + +// validStateUpdateLog builds an eth.Log with the LogStateUpdate sig +// hash and a well-formed 96-byte data section. blockNumber is the +// int256 payload; globalRoot and blockHash are placeholders that vary +// by blockNumber so different blocks produce different logs (a sanity +// hook for ordering tests). +func validStateUpdateLog(blockNumber uint64) eth.Log { + data := make([]byte, 0, 96) + // globalRoot — use blockNumber as a placeholder. + data = append(data, leftPad32Uint64(blockNumber)...) + // blockNumber as int256 (positive values match unsigned encoding). + data = append(data, leftPad32Uint64(blockNumber)...) + // blockHash — also placeholder. + data = append(data, leftPad32Uint64(blockNumber)...) + return eth.Log{ + Topics: []eth.Hash{contract.LogStateUpdateSigHash}, + Data: eth.DataBytes(data), + BlockNumber: eth.HexU64(blockNumber + 1_000_000), + } +} + +// leftPad32Uint64 encodes n as a 32-byte big-endian uint256 (uint64 +// value zero-extended into the high 24 bytes). +func leftPad32Uint64(n uint64) []byte { + out := make([]byte, 32) + binary.BigEndian.PutUint64(out[24:], n) + return out +} + +func drainStateUpdates( + t *testing.T, + sink <-chan *contract.LogStateUpdate, + n int, + timeout time.Duration, +) []*contract.LogStateUpdate { + t.Helper() + deadline := time.After(timeout) + out := make([]*contract.LogStateUpdate, 0, n) + for len(out) < n { + select { + case ev := <-sink: + out = append(out, ev) + case <-deadline: + return out + } + } + return out +} diff --git a/l1/eth_settlement.go b/l1/eth_settlement.go new file mode 100644 index 0000000000..386ab5697d --- /dev/null +++ b/l1/eth_settlement.go @@ -0,0 +1,400 @@ +package l1 + +import ( + "context" + "errors" + "fmt" + "math/big" + "sync" + "time" + + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/eth/client" + "github.com/NethermindEth/juno/l1/eth/contract" + "github.com/NethermindEth/juno/rpc/rpccore" + "github.com/NethermindEth/juno/utils/log" + "go.uber.org/zap" +) + +// ErrSettlementClosed is returned when an EthSettlement method is +// invoked after Close. Distinct from client.ErrTransportClosed: the +// latter is a transient state we recover from by redialing; this one +// is terminal. +var ErrSettlementClosed = errors.New("settlement closed") + +// watchForwarderBuffer is the per-subscription buffer between the +// contract decoder and the l1.StateUpdate sink consumed by l1.Client. +const watchForwarderBuffer = 64 + +// EthSettlement is the Ethereum implementation of SettlementLayer. It +// wraps a hand-rolled JSON-RPC client (l1/eth/client) and the hand- +// written LogStateUpdate decoder (l1/eth/contract) — together they +// replace the go-ethereum ethclient + abigen pipeline. +// +// The same instance also satisfies rpccore.L1Client via TransactionReceipt, +// so node.go can construct one client and hand it to both the L1 sync +// loop and the RPC handlers. +// +// EthSettlement also keeps the connection details so a dropped WS conn +// can be transparently redialed. The hand-rolled client is one-shot: +// once its transport reports closed, every subsequent call returns +// client.ErrTransportClosed. We catch that, redial, and retry once — +// upper layers (l1.Client.subscribeToUpdates) just see their next call +// succeed without ever knowing the conn flapped. This matches what +// go-ethereum's rpc.Client does internally (it transparently +// reconnects; subscriptions still need re-issuing, same as here). +type EthSettlement struct { + contractAddress eth.Address + url string + clientOpts []client.Option + logger log.StructuredLogger + listener EventListener + + mu sync.Mutex // protects client and closed + client *client.Client + closed bool +} + +// NewEthSettlement dials the Ethereum endpoint at url and returns a +// ready-to-use settlement-layer adapter bound to contractAddress +// (the Starknet core L1 bridge). The transport is selected by URL +// scheme; ws/wss is required if the caller intends to use +// WatchStateUpdate. The url and any client options are remembered so +// dropped connections can be redialed transparently. +func NewEthSettlement( + ctx context.Context, + url string, + contractAddress eth.Address, + opts ...EthSettlementOption, +) (*EthSettlement, error) { + o := ethSettlementOptions{} + for _, opt := range opts { + opt(&o) + } + logger := o.logger + if logger == nil { + logger = log.NewNopZapLogger() + } + clientOpts := []client.Option{client.WithLogger(logger)} + c, err := client.New(ctx, url, clientOpts...) + if err != nil { + return nil, fmt.Errorf("dial L1: %w", err) + } + s := &EthSettlement{ + client: c, + contractAddress: contractAddress, + url: url, + clientOpts: clientOpts, + logger: logger, + listener: SelectiveListener{}, + } + return s, nil +} + +// EthSettlementOption configures an EthSettlement at construction time. +type EthSettlementOption func(*ethSettlementOptions) + +type ethSettlementOptions struct { + logger log.StructuredLogger +} + +// WithSettlementLogger attaches a logger forwarded to the underlying +// JSON-RPC client. Surfaces dropped frames and best-effort +// eth_unsubscribe failures at debug level. +func WithSettlementLogger(l log.StructuredLogger) EthSettlementOption { + return func(o *ethSettlementOptions) { o.logger = l } +} + +// SetListener swaps the event listener after construction. The metrics +// listener captures the settlement in a closure (so it can read gauges +// off it), which means the listener can only be built AFTER the +// settlement exists — hence a post-construction setter rather than a +// constructor option. +// +// Concurrency contract: SetListener MUST be called before the +// settlement is handed off to any goroutine (i.e. before NewClient(...) +// in node.go). The field is read unlocked from every RPC method; a +// concurrent SetListener would be a data race. The single-shot wiring +// in node.go satisfies this — don't introduce a second caller. +func (s *EthSettlement) SetListener(l EventListener) { s.listener = l } + +// observe wraps an RPC call so OnL1Call fires on both success and +// failure paths — error rates and latency under failure are as +// interesting to monitor as success. +func (s *EthSettlement) observe(method string) func() { + t := time.Now() + return func() { s.listener.OnL1Call(method, time.Since(t)) } +} + +// currentClient returns the active *client.Client. Callers must call +// redial(stale) if they observe client.ErrTransportClosed from any +// call made against the returned client. +func (s *EthSettlement) currentClient() (*client.Client, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrSettlementClosed + } + return s.client, nil +} + +// redial closes the stale client (idempotent) and dials a new one, +// unless another caller already redialed first. Returns the active +// client after the operation completes. Concurrent callers see one +// successful redial; the rest pick up the new client without dialing +// again. +func (s *EthSettlement) redial(ctx context.Context, stale *client.Client) (*client.Client, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrSettlementClosed + } + if s.client != stale { + // Someone else won the race; their fresh client is current. + return s.client, nil + } + stale.Close() + s.logger.Info("L1 transport closed; redialing") + c, err := client.New(ctx, s.url, s.clientOpts...) + if err != nil { + s.logger.Trace("L1 redial failed", zap.Error(err)) + return nil, fmt.Errorf("redial L1: %w", err) + } + s.client = c + return c, nil +} + +// withRetryOnClosed calls fn against the current client; if fn returns +// client.ErrTransportClosed it redials once and retries. Other errors +// (including ctx cancellation) bubble up unchanged. +func withRetryOnClosed[T any]( + ctx context.Context, + s *EthSettlement, + fn func(*client.Client) (T, error), +) (T, error) { + var zero T + c, err := s.currentClient() + if err != nil { + return zero, err + } + out, err := fn(c) + if !errors.Is(err, client.ErrTransportClosed) { + return out, err + } + c2, rdErr := s.redial(ctx, c) + if rdErr != nil { + return zero, rdErr + } + return fn(c2) +} + +// ChainID returns the Ethereum chain id (eth_chainId). +func (s *EthSettlement) ChainID(ctx context.Context) (*big.Int, error) { + defer s.observe("eth_chainId")() + id, err := withRetryOnClosed(ctx, s, func(c *client.Client) (*big.Int, error) { + return c.ChainID(ctx) + }) + if err != nil { + return nil, fmt.Errorf("get chain id: %w", err) + } + return id, nil +} + +// FinalisedHeight returns the latest finalised L1 block number. A +// missing finalised header is reported as eth.ErrNotFound so callers +// can distinguish "node hasn't seen finality yet" from a transport +// failure. +func (s *EthSettlement) FinalisedHeight(ctx context.Context) (uint64, error) { + defer s.observe("eth_getBlockByNumber")() + h, err := withRetryOnClosed(ctx, s, func(c *client.Client) (*eth.Header, error) { + return c.HeaderByNumber(ctx, client.BlockFinalized) + }) + if err != nil { + if errors.Is(err, eth.ErrNotFound) { + return 0, fmt.Errorf("finalised block not found: %w", eth.ErrNotFound) + } + return 0, fmt.Errorf("get finalised Ethereum block: %w", err) + } + return uint64(h.Number), nil +} + +// LatestHeight returns the latest known L1 block number (eth_blockNumber). +func (s *EthSettlement) LatestHeight(ctx context.Context) (uint64, error) { + defer s.observe("eth_blockNumber")() + n, err := withRetryOnClosed(ctx, s, func(c *client.Client) (uint64, error) { + return c.BlockNumber(ctx) + }) + if err != nil { + return 0, fmt.Errorf("get latest Ethereum block number: %w", err) + } + return n, nil +} + +// FilterStateUpdate decodes every LogStateUpdate in [from, to] into +// the chain-neutral StateUpdate shape. +func (s *EthSettlement) FilterStateUpdate( + ctx context.Context, + from, to uint64, +) ([]*StateUpdate, error) { + defer s.observe("eth_getLogs")() + events, err := withRetryOnClosed( + ctx, + s, + func(c *client.Client) ([]*contract.LogStateUpdate, error) { + return contract.FilterLogStateUpdate(ctx, c, s.contractAddress, from, to) + }, + ) + if err != nil { + return nil, fmt.Errorf("filter LogStateUpdate [%d,%d]: %w", from, to, err) + } + out := make([]*StateUpdate, len(events)) + for i, ev := range events { + out[i] = stateUpdateFromContract(ev) + } + return out, nil +} + +// WatchStateUpdate subscribes to live LogStateUpdate events and +// forwards each one (decoded into StateUpdate, with felt conversion +// already applied) on sink. Requires a ws/wss endpoint. Redials the +// underlying transport if it has been closed. +// +// Caller contract: sink MUST be drained promptly. The forwarder hops +// raw → sink through two 64-deep buffers (the transport's wsLogSubBuffer +// and our watchForwarderBuffer), but a sink that stalls eventually +// back-pressures the transport's readLoop and stalls every unary RPC +// sharing the conn (ChainID, LatestHeight, FilterStateUpdate, +// TransactionReceipt). l1.Client.watchL1StateUpdates drains updateChan +// on a per-tick basis (default 1 min) — fine given LogStateUpdate +// cadence, but a slower drain elsewhere is a hazard. +func (s *EthSettlement) WatchStateUpdate( + ctx context.Context, + sink chan<- *StateUpdate, +) (eth.Subscription, error) { + raw := make(chan *contract.LogStateUpdate, watchForwarderBuffer) + inner, err := withRetryOnClosed(ctx, s, func(c *client.Client) (eth.Subscription, error) { + return contract.WatchLogStateUpdate(ctx, c, s.contractAddress, raw) + }) + if err != nil { + return nil, err + } + w := &stateUpdateForwarder{ + inner: inner, + sink: sink, + raw: raw, + errCh: make(chan error, 1), + closed: make(chan struct{}), + } + go w.run() + return w, nil +} + +// TransactionReceipt fetches an L1 transaction receipt by hash. Used by +// the RPC handlers for starknet_getMessageStatus. +func (s *EthSettlement) TransactionReceipt( + ctx context.Context, + txHash eth.Hash, +) (*eth.Receipt, error) { + defer s.observe("eth_getTransactionReceipt")() + r, err := withRetryOnClosed(ctx, s, func(c *client.Client) (*eth.Receipt, error) { + return c.TransactionReceipt(ctx, txHash) + }) + if err != nil { + return nil, fmt.Errorf("get transaction receipt: %w", err) + } + return r, nil +} + +// Close marks the settlement as terminally closed (no further +// redials) and releases the active transport. +func (s *EthSettlement) Close() { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return + } + s.closed = true + if s.client != nil { + s.client.Close() + } +} + +// stateUpdateFromContract translates the on-chain event shape into the +// chain-neutral StateUpdate. The contract decoder already lands felts +// and uint64s in their target types, so this is just a field rename. +func stateUpdateFromContract(ev *contract.LogStateUpdate) *StateUpdate { + return &StateUpdate{ + L2BlockNumber: ev.BlockNumber, + L2BlockHash: &ev.BlockHash, + StateRoot: &ev.GlobalRoot, + L1RefHeight: uint64(ev.Raw.BlockNumber), + Removed: ev.Raw.Removed, + } +} + +// stateUpdateForwarder decodes contract.LogStateUpdate events into +// l1.StateUpdate as they arrive from the underlying log subscription. +type stateUpdateForwarder struct { + inner eth.Subscription + sink chan<- *StateUpdate + raw chan *contract.LogStateUpdate + errCh chan error + closed chan struct{} + closeOnce sync.Once +} + +func (w *stateUpdateForwarder) Err() <-chan error { return w.errCh } + +func (w *stateUpdateForwarder) Unsubscribe() { + w.shutdown(nil) + w.inner.Unsubscribe() +} + +// shutdown is the single termination path for this forwarder. cause +// is nil for a clean teardown (Unsubscribe or normal run() exit) and +// non-nil when the inner subscription emitted an error — in that case +// it is delivered on Err() before the channel is closed. sync.Once +// makes concurrent calls (Unsubscribe + run's deferred close) safe. +func (w *stateUpdateForwarder) shutdown(cause error) { + w.closeOnce.Do(func() { + close(w.closed) + if cause != nil { + select { + case w.errCh <- cause: + default: + } + } + close(w.errCh) + }) +} + +func (w *stateUpdateForwarder) run() { + defer w.shutdown(nil) + for { + select { + case <-w.closed: + return + case err, ok := <-w.inner.Err(): + if !ok { + return + } + w.shutdown(err) + return + case ev := <-w.raw: + su := stateUpdateFromContract(ev) + select { + case w.sink <- su: + case <-w.closed: + return + } + } + } +} + +// Compile-time assertions: EthSettlement satisfies both interfaces it +// is intended to serve, and won't silently lose a method as the +// surface evolves. +var ( + _ SettlementLayer = (*EthSettlement)(nil) + _ rpccore.L1Client = (*EthSettlement)(nil) +) diff --git a/l1/eth_settlement_test.go b/l1/eth_settlement_test.go new file mode 100644 index 0000000000..195a8ba107 --- /dev/null +++ b/l1/eth_settlement_test.go @@ -0,0 +1,511 @@ +package l1_test + +import ( + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "math/big" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/NethermindEth/juno/core" + "github.com/NethermindEth/juno/l1" + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/eth/client" + "github.com/NethermindEth/juno/l1/eth/contract" + "github.com/NethermindEth/juno/utils/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingListener captures every OnL1Call invocation. Used to verify +// the deferred-observe contract (listener fires on both success AND +// failure paths so error rates are visible in metrics). +type recordingListener struct { + mu sync.Mutex + calls []string // method names, in order +} + +func (r *recordingListener) OnNewL1Head(_ *core.L1Head) {} +func (r *recordingListener) OnL1Call(method string, _ time.Duration) { + r.mu.Lock() + defer r.mu.Unlock() + r.calls = append(r.calls, method) +} + +func (r *recordingListener) Methods() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.calls)) + copy(out, r.calls) + return out +} + +// TestEthSettlement_RedialsAfterTransportClosed verifies that when the +// underlying ws transport is killed (the failure mode that triggered +// the user-reported "subscribe to LogStateUpdate: subscribe to logs: +// transport closed" bug), the next RPC call auto-redials and succeeds. +// +// Without redial, the EthSettlement would be permanently stuck — every +// subsequent call returning ErrTransportClosed from the dead transport. +func TestEthSettlement_RedialsAfterTransportClosed(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_chainId" { + return "0x539", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + // Sanity: first call works. + id, err := s.ChainID(t.Context()) + require.NoError(t, err) + assert.Equal(t, "1337", id.String()) + + // Kill the ws conn server-side; the client's readLoop sees the read + // error and shuts the transport down asynchronously. + srv.KillWSConns() + + // Give the client a moment to notice. Without this, the call below + // could still hit the old (not-yet-shut-down) transport and hang on + // the write, then time out instead of redialing. + require.Eventually(t, func() bool { + // Probe via ChainID; expect either ErrTransportClosed (transport + // just shut down, redial hasn't happened yet) or success (redial + // happened on this very call). Once the redialed path succeeds + // we're done. + _, err := s.ChainID(t.Context()) + return err == nil + }, 2*time.Second, 20*time.Millisecond, + "ChainID should succeed after transport drop via auto-redial") +} + +// TestEthSettlement_DroppingCallRedials is the regression target for the +// shutdown-cause normalisation fix in the wsTransport. When the conn +// dies while a call is in flight, the fanned-out cause is the raw +// read/ping error (io.EOF, ws close, "ws ping: ..."), not +// ErrTransportClosed. Without normalisation, errors.Is(err, ErrTransportClosed) +// is false in withRetryOnClosed and **the dropping call itself** surfaces +// a raw transport error — the redial only kicks in on a subsequent call. +// One-shot callers like the RPC TransactionReceipt path have no retry +// loop above them, so this matters. +// +// With normalisation, the dropping call itself redials and returns a +// successful response. +func TestEthSettlement_DroppingCallRedials(t *testing.T) { + var callCount atomic.Int64 + firstCallStarted := make(chan struct{}) + releaseFirstCall := make(chan struct{}) + + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method != "eth_chainId" { + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + } + n := callCount.Add(1) + if n == 1 { + // First call: signal we've reached the handler, then block + // until the test releases us. The conn will be killed in + // between — when we unblock and try to write the response, + // the conn is gone, so this reply never reaches the client. + close(firstCallStarted) + <-releaseFirstCall + } + return "0x539", nil + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + type res struct { + id *big.Int + err error + } + done := make(chan res, 1) + go func() { + id, err := s.ChainID(t.Context()) + done <- res{id, err} + }() + + // Wait until the first call reaches the handler, then sever the + // conn out from under it. The handler is still blocked; the + // transport sees the read error and shuts down, fanning the cause + // out to the in-flight pending call. + select { + case <-firstCallStarted: + case <-time.After(2 * time.Second): + t.Fatal("handler never received the first eth_chainId call") + } + srv.KillWSConns() + close(releaseFirstCall) // first handler unblocks; its write is a no-op (conn dead) + + select { + case r := <-done: + require.NoError(t, r.err, + "dropping call must auto-redial; got: %v", r.err) + assert.Equal(t, "1337", r.id.String()) + assert.GreaterOrEqual(t, callCount.Load(), int64(2), + "redial must have invoked the handler a second time") + case <-time.After(5 * time.Second): + t.Fatal("dropping ChainID did not return via auto-redial") + } +} + +// TestEthSettlement_AfterCloseReturnsErrClosed verifies Close is +// terminal: post-Close calls don't trigger another redial and instead +// return ErrSettlementClosed. +func TestEthSettlement_AfterCloseReturnsErrClosed(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_chainId" { + return "0x1", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + + // First call succeeds. + _, err = s.ChainID(t.Context()) + require.NoError(t, err) + + s.Close() + + _, err = s.ChainID(t.Context()) + require.Error(t, err) + assert.ErrorIs(t, err, l1.ErrSettlementClosed) +} + +// TestEthSettlement_ListenerFiresOnErrorPath verifies the +// deferred-observe contract: OnL1Call must fire on the failure path, +// not just success, so dashboards can distinguish "L1 healthy and +// quiet" from "L1 broken". Regression target — earlier code emitted +// the metric only on the success branch. +func TestEthSettlement_ListenerFiresOnErrorPath(t *testing.T) { + srv := client.NewTestServer(t) + // Every method errors out so we exercise the failure branch. + srv.SetHandler(func(_ client.TestRequest) (any, *client.TestRPCError) { + return nil, &client.TestRPCError{Code: -32000, Message: "boom"} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + rec := &recordingListener{} + s.SetListener(rec) + + // All four methods should fail and STILL record an OnL1Call. + _, _ = s.ChainID(t.Context()) + _, _ = s.LatestHeight(t.Context()) + _, _ = s.FinalisedHeight(t.Context()) + _, _ = s.TransactionReceipt(t.Context(), eth.Hash{}) + + got := rec.Methods() + want := []string{ + "eth_chainId", + "eth_blockNumber", + "eth_getBlockByNumber", + "eth_getTransactionReceipt", + } + assert.Equal(t, want, got, + "OnL1Call must fire on error paths so error rate is observable") +} + +// TestEthSettlement_CloseIsIdempotent — double-Close shouldn't panic +// or block, and shouldn't trigger a redial. +func TestEthSettlement_CloseIsIdempotent(t *testing.T) { + srv := client.NewTestServer(t) + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + + s.Close() + s.Close() +} + +// TestEthSettlement_PreservesErrNotFound verifies the sentinel +// wrapping survives the withRetryOnClosed helper — if a call returns +// eth.ErrNotFound (e.g. finalised block missing), callers can still +// errors.Is it. (Doesn't actually exercise a redial; that's covered by +// TestEthSettlement_RedialsAfterTransportClosed.) +func TestEthSettlement_PreservesErrNotFound(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_getBlockByNumber" { + return nil, nil // null result → ErrNotFound at the client layer + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + _, err = s.FinalisedHeight(t.Context()) + require.Error(t, err) + assert.True(t, errors.Is(err, eth.ErrNotFound), + "FinalisedHeight must wrap eth.ErrNotFound so callers can errors.Is it; got: %v", err) +} + +// stateUpdateLogJSON returns a JSON-RPC-shaped LogStateUpdate event for +// the test server. The data section is the canonical 96-byte layout: +// globalRoot ‖ blockNumber (int256, low-8-bytes) ‖ blockHash. The +// returned map is consumed by encoding/json directly. +func stateUpdateLogJSON(blockNumber, l1RefHeight uint64, removed bool) map[string]any { + data := make([]byte, 96) + // globalRoot — last byte distinguishes this event. + data[31] = byte(blockNumber & 0xff) + // blockNumber int256 — low 8 bytes. + binary.BigEndian.PutUint64(data[56:64], blockNumber) + // blockHash — last byte distinguishes this event. + data[95] = byte((blockNumber >> 8) & 0xff) + return map[string]any{ + "topics": []string{contract.LogStateUpdateSigHash.Hex()}, + "data": "0x" + hex.EncodeToString(data), + "blockNumber": fmt.Sprintf("0x%x", l1RefHeight), + "removed": removed, + } +} + +// TestEthSettlement_FilterStateUpdate_DecodesAndTranslates exercises the +// hot path through FilterStateUpdate: the eth_getLogs call lands raw +// logs, the contract decoder reads them, and stateUpdateFromContract +// reshapes them into chain-neutral StateUpdate. Without this test the +// translation layer is entirely unverified — both the decoder pipe and +// the field mapping (Removed, L1RefHeight, BlockNumber → L2BlockNumber). +func TestEthSettlement_FilterStateUpdate_DecodesAndTranslates(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_getLogs" { + return []any{ + stateUpdateLogJSON(1, 1_000, false), + stateUpdateLogJSON(2, 1_001, true), + }, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + got, err := s.FilterStateUpdate(t.Context(), 100, 200) + require.NoError(t, err) + require.Len(t, got, 2) + + assert.Equal(t, uint64(1), got[0].L2BlockNumber) + assert.Equal(t, uint64(1_000), got[0].L1RefHeight) + assert.False(t, got[0].Removed) + require.NotNil(t, got[0].L2BlockHash) + require.NotNil(t, got[0].StateRoot) + + assert.Equal(t, uint64(2), got[1].L2BlockNumber) + assert.Equal(t, uint64(1_001), got[1].L1RefHeight) + assert.True(t, got[1].Removed, "Removed flag must round-trip from the raw log envelope") +} + +// TestEthSettlement_FilterStateUpdate_ErrorWrapsRange verifies the error +// message includes the [from, to] range so an operator chasing a +// "filter LogStateUpdate" failure can correlate it against the L1 sync +// loop's logs. +func TestEthSettlement_FilterStateUpdate_ErrorWrapsRange(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(_ client.TestRequest) (any, *client.TestRPCError) { + return nil, &client.TestRPCError{Code: -32000, Message: "query timeout"} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + _, err = s.FilterStateUpdate(t.Context(), 42, 99) + require.Error(t, err) + assert.Contains(t, err.Error(), "[42,99]", + "error must surface the requested range so operators can correlate") + assert.Contains(t, err.Error(), "query timeout") +} + +// TestEthSettlement_WatchStateUpdate_DeliversDecoded verifies the live +// subscribe path: eth_subscribe is issued, the server pushes a +// notification, the forwarder decodes via contract.Decode and emits a +// chain-neutral StateUpdate. +func TestEthSettlement_WatchStateUpdate_DeliversDecoded(t *testing.T) { + const subID = "0xb10c" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + sink := make(chan *l1.StateUpdate, 4) + sub, err := s.WatchStateUpdate(t.Context(), sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + require.NoError(t, srv.PushNotification( + t.Context(), subID, stateUpdateLogJSON(7, 2_000, false), + )) + + select { + case su := <-sink: + assert.Equal(t, uint64(7), su.L2BlockNumber) + assert.Equal(t, uint64(2_000), su.L1RefHeight) + assert.False(t, su.Removed) + case <-time.After(2 * time.Second): + t.Fatal("WatchStateUpdate did not deliver a decoded event") + } + + // Err() must not have fired on the happy path. + select { + case err := <-sub.Err(): + t.Fatalf("Err() fired unexpectedly: %v", err) + default: + } +} + +// TestEthSettlement_WatchStateUpdate_UnsubscribeIsClean verifies the +// forwarder's Unsubscribe path: Err() closes (no spurious error sent), +// and Unsubscribe is idempotent — a double call must not panic on the +// already-closed channels. +func TestEthSettlement_WatchStateUpdate_UnsubscribeIsClean(t *testing.T) { + const subID = "0xbeef" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + sink := make(chan *l1.StateUpdate, 1) + sub, err := s.WatchStateUpdate(t.Context(), sink) + require.NoError(t, err) + + sub.Unsubscribe() + sub.Unsubscribe() // idempotent + + // Err() must be closed after Unsubscribe (no cause). + select { + case errOut, open := <-sub.Err(): + assert.False(t, open, "Err() should be closed; got open with err=%v", errOut) + case <-time.After(time.Second): + t.Fatal("Err() did not close after Unsubscribe") + } +} + +// TestEthSettlement_WatchStateUpdate_PropagatesInnerErr verifies the +// forwarder.run() path that delivers a cause from the inner subscription +// onto its own Err() — the failure mode that the redial loop in l1.Client +// reacts to. +func TestEthSettlement_WatchStateUpdate_PropagatesInnerErr(t *testing.T) { + const subID = "0xfa11" + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + switch req.Method { + case "eth_subscribe": + return subID, nil + case "eth_unsubscribe": + return true, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + sink := make(chan *l1.StateUpdate, 1) + sub, err := s.WatchStateUpdate(t.Context(), sink) + require.NoError(t, err) + defer sub.Unsubscribe() + + // Killing the conn server-side trips the inner subscription's + // Err() → forwarder.run() picks it up → delivers it on the + // caller-facing sub.Err(). + srv.KillWSConns() + + select { + case errOut := <-sub.Err(): + require.Error(t, errOut, "Err() must deliver a non-nil cause when transport dies") + case <-time.After(2 * time.Second): + t.Fatal("Err() did not surface inner subscription failure") + } +} + +// TestEthSettlement_WatchStateUpdate_FailsAfterClose verifies the +// Close-then-Watch ordering: once the settlement is terminally closed, +// WatchStateUpdate refuses to dial a new subscription instead of +// silently spinning up a forwarder that will never see traffic. +func TestEthSettlement_WatchStateUpdate_FailsAfterClose(t *testing.T) { + srv := client.NewTestServer(t) + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + + s.Close() + + sink := make(chan *l1.StateUpdate, 1) + _, err = s.WatchStateUpdate(t.Context(), sink) + require.Error(t, err) + assert.ErrorIs(t, err, l1.ErrSettlementClosed) +} + +// TestEthSettlement_WithSettlementLogger smoke-tests the option wiring +// — supplying a custom logger to NewEthSettlement must not break +// construction or subsequent RPC calls. The internal forwarding to the +// underlying client is covered indirectly by every other test, which +// only differs from this one in the absence of the option. +func TestEthSettlement_WithSettlementLogger(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_chainId" { + return "0x1", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewEthSettlement(t.Context(), srv.WSURL(), eth.Address{}, + l1.WithSettlementLogger(log.NewNopZapLogger()), + ) + require.NoError(t, err) + t.Cleanup(s.Close) + + id, err := s.ChainID(t.Context()) + require.NoError(t, err) + assert.Equal(t, "1", id.String()) +} + +// TestEthSettlement_NewEthSettlement_DialError verifies the constructor +// fails cleanly when the underlying client.New call cannot dial. The +// only way to force this without a real network is an unsupported URL +// scheme, which client.New rejects in url-parse before it hits the wire. +func TestEthSettlement_NewEthSettlement_DialError(t *testing.T) { + _, err := l1.NewEthSettlement(t.Context(), "http://example.invalid", eth.Address{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "dial L1", + "NewEthSettlement must wrap the underlying dial error so the cause is identifiable") +} diff --git a/l1/eth_subscriber.go b/l1/eth_subscriber.go deleted file mode 100644 index d1b59fece5..0000000000 --- a/l1/eth_subscriber.go +++ /dev/null @@ -1,128 +0,0 @@ -package l1 - -import ( - "context" - "errors" - "fmt" - "math/big" - "time" - - "github.com/NethermindEth/juno/l1/contract" - "github.com/NethermindEth/juno/l1/eth" - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/event" - "github.com/ethereum/go-ethereum/rpc" -) - -var finalizedBlockNumber = new(big.Int).SetInt64(rpc.FinalizedBlockNumber.Int64()) - -type EthSubscriber struct { - ethClient *ethclient.Client - client *rpc.Client - filterer *contract.StarknetFilterer - listener EventListener -} - -var _ Subscriber = (*EthSubscriber)(nil) - -func NewEthSubscriber( - ethClientAddress string, - coreContractAddress eth.Address, -) (*EthSubscriber, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - client, err := rpc.DialContext(ctx, ethClientAddress) - if err != nil { - return nil, err - } - ethClient := ethclient.NewClient(client) - filterer, err := contract.NewStarknetFilterer(common.Address(coreContractAddress), ethClient) - if err != nil { - return nil, err - } - return &EthSubscriber{ - ethClient: ethClient, - client: client, - filterer: filterer, - listener: SelectiveListener{}, - }, nil -} - -func (s *EthSubscriber) WatchLogStateUpdate(ctx context.Context, sink chan<- *contract.StarknetLogStateUpdate) (event.Subscription, error) { - return s.filterer.WatchLogStateUpdate(&bind.WatchOpts{Context: ctx}, sink) -} - -func (s *EthSubscriber) FilterLogStateUpdate( - ctx context.Context, - start, - end uint64, -) ([]*contract.StarknetLogStateUpdate, error) { - reqTimer := time.Now() - events, err := s.filterer.FilterLogStateUpdate(&bind.FilterOpts{ - Context: ctx, - Start: start, - End: &end, - }) - if err != nil { - return nil, fmt.Errorf("filter LogStateUpdate [%d,%d]: %w", start, end, err) - } - s.listener.OnL1Call("eth_getLogs", time.Since(reqTimer)) - return events, nil -} - -func (s *EthSubscriber) ChainID(ctx context.Context) (*big.Int, error) { - reqTimer := time.Now() - chainID, err := s.ethClient.ChainID(ctx) - if err != nil { - return nil, fmt.Errorf("get chain ID: %w", err) - } - s.listener.OnL1Call("eth_chainId", time.Since(reqTimer)) - - return chainID, nil -} - -func (s *EthSubscriber) FinalisedHeight(ctx context.Context) (uint64, error) { - reqTimer := time.Now() - head, err := s.ethClient.HeaderByNumber(ctx, finalizedBlockNumber) - if err != nil { - if errors.Is(err, ethereum.NotFound) { - s.listener.OnL1Call("eth_getBlockByNumber", time.Since(reqTimer)) - return 0, errors.New("finalised block not found") - } - return 0, fmt.Errorf("get finalised Ethereum block: %w", err) - } - s.listener.OnL1Call("eth_getBlockByNumber", time.Since(reqTimer)) - - return head.Number.Uint64(), nil -} - -func (s *EthSubscriber) LatestHeight(ctx context.Context) (uint64, error) { - reqTimer := time.Now() - height, err := s.ethClient.BlockNumber(ctx) - if err != nil { - return 0, fmt.Errorf("get latest Ethereum block number: %w", err) - } - s.listener.OnL1Call("eth_blockNumber", time.Since(reqTimer)) - - return height, nil -} - -func (s *EthSubscriber) Close() { - s.ethClient.Close() -} - -func (s *EthSubscriber) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { - reqTimer := time.Now() - receipt, err := s.ethClient.TransactionReceipt(ctx, txHash) - if err != nil { - return nil, fmt.Errorf("get eth Transaction Receipt: %w", err) - } - s.listener.OnL1Call("eth_getTransactionReceipt", time.Since(reqTimer)) - - return receipt, nil -} diff --git a/l1/contract/starknet.go b/l1/geth/contract/starknet.go similarity index 100% rename from l1/contract/starknet.go rename to l1/geth/contract/starknet.go diff --git a/l1/contract/starknet_filter.go b/l1/geth/contract/starknet_filter.go similarity index 100% rename from l1/contract/starknet_filter.go rename to l1/geth/contract/starknet_filter.go diff --git a/l1/geth_settlement.go b/l1/geth_settlement.go new file mode 100644 index 0000000000..08c14afb0b --- /dev/null +++ b/l1/geth_settlement.go @@ -0,0 +1,334 @@ +package l1 + +import ( + "context" + "errors" + "fmt" + "math/big" + "net/url" + "sync" + "time" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/geth/contract" + "github.com/NethermindEth/juno/rpc/rpccore" + "github.com/NethermindEth/juno/utils/log" + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/event" + "github.com/ethereum/go-ethereum/rpc" +) + +// finalizedBlockNumber is the geth tag value for the latest finalised +// block; used as the "block number" arg to HeaderByNumber. +var finalizedBlockNumber = new(big.Int).SetInt64(rpc.FinalizedBlockNumber.Int64()) + +// GethSettlement is the go-ethereum-backed implementation of +// SettlementLayer. It wraps go-ethereum's ethclient plus the abigen- +// generated StarknetFilterer to mirror the pre-removal EthSubscriber +// behaviour. Selected via the --l1-client flag; the hand-rolled +// EthSettlement under l1/eth/client is the other implementation. +// +// The same instance also satisfies rpccore.L1Client via +// TransactionReceipt, so node.go can construct one client and hand it +// to both the L1 sync loop and the RPC handlers. +// +// Unlike EthSettlement, this type has no explicit redial state machine: +// go-ethereum's rpc.Client transparently reconnects unary calls over +// the WS transport, and any active subscription propagates the drop +// via its Err() channel, which the upper-layer resubscribe loop in +// l1.Client.watchL1StateUpdates already handles. This matches the +// behaviour that shipped on main prior to the hand-rolled migration. +type GethSettlement struct { + contractAddress eth.Address + url string + logger log.StructuredLogger + + rpcClient *rpc.Client + ethClient *ethclient.Client + filterer *contract.StarknetFilterer + + listener EventListener +} + +// NewGethSettlement dials the Ethereum endpoint at url and returns a +// ready-to-use settlement-layer adapter bound to contractAddress (the +// Starknet core L1 bridge). The transport is selected by URL scheme; +// ws/wss is required so log subscriptions work. Signature mirrors +// NewEthSettlement so node.go can branch between backends symmetrically. +func NewGethSettlement( + ctx context.Context, + rawURL string, + contractAddress eth.Address, + opts ...EthSettlementOption, +) (*GethSettlement, error) { + o := ethSettlementOptions{} + for _, opt := range opts { + opt(&o) + } + logger := o.logger + if logger == nil { + logger = log.NewNopZapLogger() + } + + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("parse L1 URL: %w", err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return nil, fmt.Errorf("unsupported url scheme %q (need ws/wss)", u.Scheme) + } + + rpcClient, err := rpc.DialContext(ctx, rawURL) + if err != nil { + return nil, fmt.Errorf("dial L1: %w", err) + } + ethClient := ethclient.NewClient(rpcClient) + filterer, err := contract.NewStarknetFilterer(common.Address(contractAddress), ethClient) + if err != nil { + ethClient.Close() + return nil, fmt.Errorf("bind Starknet filterer: %w", err) + } + + return &GethSettlement{ + contractAddress: contractAddress, + url: rawURL, + logger: logger, + rpcClient: rpcClient, + ethClient: ethClient, + filterer: filterer, + listener: SelectiveListener{}, + }, nil +} + +// SetListener swaps the event listener after construction. The metrics +// listener captures the settlement in a closure (so it can read gauges +// off it), which means the listener can only be built AFTER the +// settlement exists — hence a post-construction setter rather than a +// constructor option. +// +// Concurrency contract: SetListener MUST be called before the +// settlement is handed off to any goroutine (i.e. before l1.NewClient +// in node.go). The field is read unlocked from every RPC method; a +// concurrent SetListener would be a data race. The single-shot wiring +// in node.go satisfies this — don't introduce a second caller. +func (s *GethSettlement) SetListener(l EventListener) { s.listener = l } + +// observe wraps an RPC call so OnL1Call fires on both success and +// failure paths — error rates and latency under failure are as +// interesting to monitor as success. +func (s *GethSettlement) observe(method string) func() { + t := time.Now() + return func() { s.listener.OnL1Call(method, time.Since(t)) } +} + +// ChainID returns the Ethereum chain id (eth_chainId). +func (s *GethSettlement) ChainID(ctx context.Context) (*big.Int, error) { + defer s.observe("eth_chainId")() + id, err := s.ethClient.ChainID(ctx) + if err != nil { + return nil, fmt.Errorf("get chain id: %w", err) + } + return id, nil +} + +// FinalisedHeight returns the latest finalised L1 block number. A +// missing finalised header is reported as eth.ErrNotFound so callers +// can distinguish "node hasn't seen finality yet" from a transport +// failure. +func (s *GethSettlement) FinalisedHeight(ctx context.Context) (uint64, error) { + defer s.observe("eth_getBlockByNumber")() + head, err := s.ethClient.HeaderByNumber(ctx, finalizedBlockNumber) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + return 0, fmt.Errorf("finalised block not found: %w", eth.ErrNotFound) + } + return 0, fmt.Errorf("get finalised Ethereum block: %w", err) + } + return head.Number.Uint64(), nil +} + +// LatestHeight returns the latest known L1 block number (eth_blockNumber). +func (s *GethSettlement) LatestHeight(ctx context.Context) (uint64, error) { + defer s.observe("eth_blockNumber")() + n, err := s.ethClient.BlockNumber(ctx) + if err != nil { + return 0, fmt.Errorf("get latest Ethereum block number: %w", err) + } + return n, nil +} + +// FilterStateUpdate decodes every LogStateUpdate in [from, to] into +// the chain-neutral StateUpdate shape. +func (s *GethSettlement) FilterStateUpdate( + ctx context.Context, + from, to uint64, +) ([]*StateUpdate, error) { + defer s.observe("eth_getLogs")() + events, err := s.filterer.FilterLogStateUpdate(&bind.FilterOpts{ + Context: ctx, + Start: from, + End: &to, + }) + if err != nil { + return nil, fmt.Errorf("filter LogStateUpdate [%d,%d]: %w", from, to, err) + } + out := make([]*StateUpdate, len(events)) + for i, ev := range events { + out[i] = stateUpdateFromGethContract(ev) + } + return out, nil +} + +// WatchStateUpdate subscribes to live LogStateUpdate events and +// forwards each one (decoded into StateUpdate, with felt conversion +// already applied) on sink. Requires a ws/wss endpoint. +// +// Caller contract: sink MUST be drained promptly. A sink that stalls +// back-pressures the abigen subscription channel and eventually the +// underlying ws connection. +func (s *GethSettlement) WatchStateUpdate( + ctx context.Context, + sink chan<- *StateUpdate, +) (eth.Subscription, error) { + raw := make(chan *contract.StarknetLogStateUpdate, watchForwarderBuffer) + inner, err := s.filterer.WatchLogStateUpdate(&bind.WatchOpts{Context: ctx}, raw) + if err != nil { + return nil, fmt.Errorf("subscribe LogStateUpdate: %w", err) + } + w := &gethStateUpdateForwarder{ + inner: inner, + sink: sink, + raw: raw, + errCh: make(chan error, 1), + closed: make(chan struct{}), + } + go w.run() + return w, nil +} + +// TransactionReceipt fetches an L1 transaction receipt by hash. Used +// by the RPC handlers for starknet_getMessageStatus. +func (s *GethSettlement) TransactionReceipt( + ctx context.Context, + txHash eth.Hash, +) (*eth.Receipt, error) { + defer s.observe("eth_getTransactionReceipt")() + r, err := s.ethClient.TransactionReceipt(ctx, common.Hash(txHash)) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + return nil, fmt.Errorf("get transaction receipt: %w", eth.ErrNotFound) + } + return nil, fmt.Errorf("get transaction receipt: %w", err) + } + return gethReceiptToEth(r), nil +} + +// Close releases the underlying RPC client. +func (s *GethSettlement) Close() { + s.ethClient.Close() +} + +// stateUpdateFromGethContract translates the abigen-decoded event into +// the chain-neutral StateUpdate. felt conversion happens here so +// l1.Client never touches Ethereum-flavoured types. +func stateUpdateFromGethContract(ev *contract.StarknetLogStateUpdate) *StateUpdate { + return &StateUpdate{ + L2BlockNumber: ev.BlockNumber.Uint64(), + L2BlockHash: new(felt.Felt).SetBigInt(ev.BlockHash), + StateRoot: new(felt.Felt).SetBigInt(ev.GlobalRoot), + L1RefHeight: ev.Raw.BlockNumber, + Removed: ev.Raw.Removed, + } +} + +// gethReceiptToEth shallow-copies the fields of a geth receipt that juno +// actually reads. Today only Logs is consumed (per migration.md), but +// every nested Log is converted so the shape matches the hand-rolled +// path exactly. +func gethReceiptToEth(r *types.Receipt) *eth.Receipt { + logs := make([]eth.Log, len(r.Logs)) + for i, l := range r.Logs { + logs[i] = gethLogToEth(l) + } + return ð.Receipt{Logs: logs} +} + +func gethLogToEth(l *types.Log) eth.Log { + topics := make([]eth.Hash, len(l.Topics)) + for i, t := range l.Topics { + topics[i] = eth.Hash(t) + } + return eth.Log{ + Topics: topics, + Data: eth.DataBytes(l.Data), + BlockNumber: eth.HexU64(l.BlockNumber), + Removed: l.Removed, + } +} + +// gethStateUpdateForwarder decodes contract.StarknetLogStateUpdate events +// into l1.StateUpdate as they arrive from the underlying log +// subscription. Mirrors the hand-rolled stateUpdateForwarder. +type gethStateUpdateForwarder struct { + inner event.Subscription + sink chan<- *StateUpdate + raw chan *contract.StarknetLogStateUpdate + errCh chan error + closed chan struct{} + closeOnce sync.Once +} + +func (w *gethStateUpdateForwarder) Err() <-chan error { return w.errCh } + +func (w *gethStateUpdateForwarder) Unsubscribe() { + w.shutdown(nil) + w.inner.Unsubscribe() +} + +func (w *gethStateUpdateForwarder) shutdown(cause error) { + w.closeOnce.Do(func() { + close(w.closed) + if cause != nil { + select { + case w.errCh <- cause: + default: + } + } + close(w.errCh) + }) +} + +func (w *gethStateUpdateForwarder) run() { + defer w.shutdown(nil) + for { + select { + case <-w.closed: + return + case err, ok := <-w.inner.Err(): + if !ok { + return + } + w.shutdown(err) + return + case ev := <-w.raw: + su := stateUpdateFromGethContract(ev) + select { + case w.sink <- su: + case <-w.closed: + return + } + } + } +} + +// Compile-time assertions: GethSettlement satisfies both interfaces it +// is intended to serve. +var ( + _ SettlementLayer = (*GethSettlement)(nil) + _ rpccore.L1Client = (*GethSettlement)(nil) +) diff --git a/l1/geth_settlement_test.go b/l1/geth_settlement_test.go new file mode 100644 index 0000000000..3183837577 --- /dev/null +++ b/l1/geth_settlement_test.go @@ -0,0 +1,377 @@ +package l1_test + +import ( + "context" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "testing" + "time" + + "github.com/NethermindEth/juno/l1" + "github.com/NethermindEth/juno/l1/eth" + "github.com/NethermindEth/juno/l1/eth/client" + "github.com/NethermindEth/juno/utils/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// logStateUpdateTopicHex is keccak256("LogStateUpdate(uint256,int256,uint256)"). +// Pinning the topic directly here (instead of importing either backend's +// constant) lets these tests catch a regression in either decoder. +const logStateUpdateTopicHex = "0xd342ddf7a308dec111745b00315c14b7efb2bdae570a6856e088ed0c65a3576c" + +// gethLogStateUpdateJSON builds the JSON-RPC log envelope for a +// LogStateUpdate event. Mirrors stateUpdateLogJSON used by the hand-rolled +// tests so both backends are exercised against the same canonical payload. +func gethLogStateUpdateJSON(blockNumber, l1RefHeight uint64, removed bool) map[string]any { + data := make([]byte, 96) + data[31] = byte(blockNumber & 0xff) + binary.BigEndian.PutUint64(data[56:64], blockNumber) + data[95] = byte((blockNumber >> 8) & 0xff) + return map[string]any{ + "address": "0x0000000000000000000000000000000000000000", + "topics": []string{logStateUpdateTopicHex}, + "data": "0x" + hex.EncodeToString(data), + "blockNumber": fmt.Sprintf("0x%x", l1RefHeight), + "transactionHash": "0x" + hex.EncodeToString(make([]byte, 32)), + "transactionIndex": "0x0", + "blockHash": "0x" + hex.EncodeToString(make([]byte, 32)), + "logIndex": "0x0", + "removed": removed, + } +} + +// gethFullHeaderJSON returns a header payload with every field +// go-ethereum's types.Header JSON decoder treats as required (difficulty, +// gasLimit, etc.). Only Number is read by FinalisedHeight. +func gethFullHeaderJSON(number uint64) map[string]any { + zeroHash := "0x" + hex.EncodeToString(make([]byte, 32)) + zeroAddr := "0x" + hex.EncodeToString(make([]byte, 20)) + zeroBloom := "0x" + hex.EncodeToString(make([]byte, 256)) + zeroNonce := "0x0000000000000000" + return map[string]any{ + "parentHash": zeroHash, + "sha3Uncles": zeroHash, + "miner": zeroAddr, + "stateRoot": zeroHash, + "transactionsRoot": zeroHash, + "receiptsRoot": zeroHash, + "logsBloom": zeroBloom, + "difficulty": "0x0", + "number": fmt.Sprintf("0x%x", number), + "gasLimit": "0x0", + "gasUsed": "0x0", + "timestamp": "0x0", + "extraData": "0x", + "mixHash": zeroHash, + "nonce": zeroNonce, + "hash": zeroHash, + } +} + +// gethReceiptJSON returns a minimal eth_getTransactionReceipt response +// carrying the supplied logs. Only Logs is read in juno today. +func gethReceiptJSON(logs []map[string]any) map[string]any { + zeroHash := "0x" + hex.EncodeToString(make([]byte, 32)) + zeroAddr := "0x" + hex.EncodeToString(make([]byte, 20)) + zeroBloom := "0x" + hex.EncodeToString(make([]byte, 256)) + return map[string]any{ + "transactionHash": zeroHash, + "transactionIndex": "0x0", + "blockHash": zeroHash, + "blockNumber": "0x0", + "from": zeroAddr, + "to": zeroAddr, + "cumulativeGasUsed": "0x0", + "gasUsed": "0x0", + "contractAddress": nil, + "logs": logs, + "logsBloom": zeroBloom, + "status": "0x1", + "type": "0x0", + "effectiveGasPrice": "0x0", + } +} + +// TestGethSettlement_RejectsNonWSScheme verifies the URL guard fires +// before any dial attempt: http/https/wsx all fail with a clear scheme +// error so operators don't get a confusing wire-level failure later. +func TestGethSettlement_RejectsNonWSScheme(t *testing.T) { + _, err := l1.NewGethSettlement(t.Context(), "http://example.invalid", eth.Address{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported url scheme") +} + +// TestGethSettlement_NewGethSettlement_DialError verifies the constructor +// fails cleanly when the underlying dial fails. +func TestGethSettlement_NewGethSettlement_DialError(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) + defer cancel() + _, err := l1.NewGethSettlement(ctx, "ws://127.0.0.1:1", eth.Address{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "dial L1") +} + +// TestGethSettlement_ChainID exercises the happy path. +func TestGethSettlement_ChainID(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_chainId" { + return "0x539", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + id, err := s.ChainID(t.Context()) + require.NoError(t, err) + assert.Equal(t, "1337", id.String()) +} + +// TestGethSettlement_LatestHeight exercises eth_blockNumber. +func TestGethSettlement_LatestHeight(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_blockNumber" { + return "0xc8", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + got, err := s.LatestHeight(t.Context()) + require.NoError(t, err) + assert.Equal(t, uint64(200), got) +} + +// TestGethSettlement_FinalisedHeight returns the header height via +// eth_getBlockByNumber("finalized", ...). +func TestGethSettlement_FinalisedHeight(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_getBlockByNumber" { + return gethFullHeaderJSON(100), nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + got, err := s.FinalisedHeight(t.Context()) + require.NoError(t, err) + assert.Equal(t, uint64(100), got) +} + +// TestGethSettlement_FinalisedHeight_NotFound verifies the sentinel +// translation: a null result becomes ethereum.NotFound at the ethclient +// layer; GethSettlement must rewrap as eth.ErrNotFound. +func TestGethSettlement_FinalisedHeight_NotFound(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_getBlockByNumber" { + return nil, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + _, err = s.FinalisedHeight(t.Context()) + require.Error(t, err) + assert.True(t, errors.Is(err, eth.ErrNotFound), + "FinalisedHeight must wrap eth.ErrNotFound; got %v", err) +} + +// TestGethSettlement_FilterStateUpdate_DecodesAndTranslates exercises the +// abigen filterer + the StateUpdate translation against the same canonical +// payload as the hand-rolled equivalent. +func TestGethSettlement_FilterStateUpdate_DecodesAndTranslates(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_getLogs" { + return []any{ + gethLogStateUpdateJSON(1, 1_000, false), + gethLogStateUpdateJSON(2, 1_001, true), + }, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + got, err := s.FilterStateUpdate(t.Context(), 100, 200) + require.NoError(t, err) + require.Len(t, got, 2) + + assert.Equal(t, uint64(1), got[0].L2BlockNumber) + assert.Equal(t, uint64(1_000), got[0].L1RefHeight) + assert.False(t, got[0].Removed) + require.NotNil(t, got[0].L2BlockHash) + require.NotNil(t, got[0].StateRoot) + + assert.Equal(t, uint64(2), got[1].L2BlockNumber) + assert.Equal(t, uint64(1_001), got[1].L1RefHeight) + assert.True(t, got[1].Removed) +} + +// TestGethSettlement_FilterStateUpdate_ErrorWrapsRange surfaces the +// [from, to] range so operators can correlate a failure against the sync +// loop's logs. +func TestGethSettlement_FilterStateUpdate_ErrorWrapsRange(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(_ client.TestRequest) (any, *client.TestRPCError) { + return nil, &client.TestRPCError{Code: -32000, Message: "query timeout"} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + _, err = s.FilterStateUpdate(t.Context(), 42, 99) + require.Error(t, err) + assert.Contains(t, err.Error(), "[42,99]") + assert.Contains(t, err.Error(), "query timeout") +} + +// TestGethSettlement_TransactionReceipt verifies the geth-to-eth receipt +// conversion: Topics, Data, BlockNumber, and Removed must survive the +// translation untouched. +func TestGethSettlement_TransactionReceipt(t *testing.T) { + wantTopic := "0x" + hex.EncodeToString(append(make([]byte, 31), 0xaa)) + wantData := []byte{0xde, 0xad, 0xbe, 0xef} + + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_getTransactionReceipt" { + return gethReceiptJSON([]map[string]any{{ + "address": "0x0000000000000000000000000000000000000000", + "topics": []string{wantTopic}, + "data": "0x" + hex.EncodeToString(wantData), + "blockNumber": "0x1092", + "transactionHash": "0x" + hex.EncodeToString(make([]byte, 32)), + "transactionIndex": "0x0", + "blockHash": "0x" + hex.EncodeToString(make([]byte, 32)), + "logIndex": "0x0", + "removed": true, + }}), nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + r, err := s.TransactionReceipt(t.Context(), eth.Hash{}) + require.NoError(t, err) + require.Len(t, r.Logs, 1) + + got := r.Logs[0] + require.Len(t, got.Topics, 1) + assert.Equal(t, wantTopic, got.Topics[0].Hex()) + assert.Equal(t, eth.DataBytes(wantData), got.Data) + assert.Equal(t, eth.HexU64(0x1092), got.BlockNumber) + assert.True(t, got.Removed) +} + +// TestGethSettlement_TransactionReceipt_NotFound verifies the sentinel +// translation: a null JSON-RPC result becomes ethereum.NotFound at the +// ethclient layer; GethSettlement must wrap as eth.ErrNotFound. +func TestGethSettlement_TransactionReceipt_NotFound(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_getTransactionReceipt" { + return nil, nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + _, err = s.TransactionReceipt(t.Context(), eth.Hash{}) + require.Error(t, err) + assert.True(t, errors.Is(err, eth.ErrNotFound), + "TransactionReceipt must wrap eth.ErrNotFound on missing tx; got %v", err) +} + +// TestGethSettlement_ListenerFiresOnErrorPath verifies the deferred-observe +// contract: OnL1Call fires on every method, success or failure, so error +// rate is observable. +func TestGethSettlement_ListenerFiresOnErrorPath(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(_ client.TestRequest) (any, *client.TestRPCError) { + return nil, &client.TestRPCError{Code: -32000, Message: "boom"} + }) + + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + t.Cleanup(s.Close) + + rec := &recordingListener{} + s.SetListener(rec) + + _, _ = s.ChainID(t.Context()) + _, _ = s.LatestHeight(t.Context()) + _, _ = s.FinalisedHeight(t.Context()) + _, _ = s.TransactionReceipt(t.Context(), eth.Hash{}) + + got := rec.Methods() + want := []string{ + "eth_chainId", + "eth_blockNumber", + "eth_getBlockByNumber", + "eth_getTransactionReceipt", + } + assert.Equal(t, want, got) +} + +// TestGethSettlement_CloseIsIdempotent — double-Close must not panic. +func TestGethSettlement_CloseIsIdempotent(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_chainId" { + return "0x1", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}) + require.NoError(t, err) + s.Close() + s.Close() +} + +// TestGethSettlement_WithSettlementLogger smoke-tests option wiring. +func TestGethSettlement_WithSettlementLogger(t *testing.T) { + srv := client.NewTestServer(t) + srv.SetHandler(func(req client.TestRequest) (any, *client.TestRPCError) { + if req.Method == "eth_chainId" { + return "0x1", nil + } + return nil, &client.TestRPCError{Code: -32601, Message: req.Method} + }) + s, err := l1.NewGethSettlement(t.Context(), srv.WSURL(), eth.Address{}, + l1.WithSettlementLogger(log.NewNopZapLogger()), + ) + require.NoError(t, err) + t.Cleanup(s.Close) + + id, err := s.ChainID(t.Context()) + require.NoError(t, err) + assert.Equal(t, "1", id.String()) +} diff --git a/l1/l1.go b/l1/l1.go index 819b62f40c..b983680ea4 100644 --- a/l1/l1.go +++ b/l1/l1.go @@ -4,46 +4,26 @@ import ( "context" "errors" "fmt" - "math/big" "time" "github.com/NethermindEth/juno/blockchain" "github.com/NethermindEth/juno/blockchain/networks" "github.com/NethermindEth/juno/core" - "github.com/NethermindEth/juno/core/felt" - "github.com/NethermindEth/juno/l1/contract" + "github.com/NethermindEth/juno/l1/eth" "github.com/NethermindEth/juno/service" "github.com/NethermindEth/juno/utils/log" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" "go.uber.org/zap" ) -//go:generate mockgen -destination=../mocks/mock_subscriber.go -package=mocks github.com/NethermindEth/juno/l1 Subscriber -type Subscriber interface { - FinalisedHeight(ctx context.Context) (uint64, error) - LatestHeight(ctx context.Context) (uint64, error) - WatchLogStateUpdate(ctx context.Context, sink chan<- *contract.StarknetLogStateUpdate) (event.Subscription, error) - FilterLogStateUpdate( - ctx context.Context, - fromBlock, - toBlock uint64, - ) ([]*contract.StarknetLogStateUpdate, error) - ChainID(ctx context.Context) (*big.Int, error) - TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) - Close() -} - type Client struct { - l1 Subscriber + settlement SettlementLayer l2Chain *blockchain.Blockchain logger log.StructuredLogger network *networks.Network resubscribeDelay time.Duration pollFinalisedInterval time.Duration catchUpChunkSize uint64 - nonFinalisedLogs map[uint64]*contract.StarknetLogStateUpdate + nonFinalisedLogs map[uint64]*StateUpdate listener EventListener } @@ -86,7 +66,7 @@ func WithCatchUpChunkSize(size uint64) Option { } func NewClient( - l1 Subscriber, + settlement SettlementLayer, chain *blockchain.Blockchain, logger log.StructuredLogger, opts ...Option, @@ -101,14 +81,14 @@ func NewClient( opt(&o) } return &Client{ - l1: l1, + settlement: settlement, l2Chain: chain, logger: logger, network: chain.Network(), resubscribeDelay: o.ResubscribeDelay, pollFinalisedInterval: o.PollFinalisedInterval, catchUpChunkSize: o.CatchUpChunkSize, - nonFinalisedLogs: make(map[uint64]*contract.StarknetLogStateUpdate), + nonFinalisedLogs: make(map[uint64]*StateUpdate), listener: o.EventListener, } } @@ -116,8 +96,9 @@ func NewClient( // subscribeToUpdates blocks until a subscription is established. If context is cancelled, // returns nil func (c *Client) subscribeToUpdates( - ctx context.Context, updateChan chan *contract.StarknetLogStateUpdate, -) event.Subscription { + ctx context.Context, + updateChan chan *StateUpdate, +) eth.Subscription { timer := time.NewTimer(0) defer timer.Stop() @@ -130,11 +111,12 @@ func (c *Client) subscribeToUpdates( ) return nil case <-timer.C: - updateSub, err := c.l1.WatchLogStateUpdate(ctx, updateChan) + updateSub, err := c.settlement.WatchStateUpdate(ctx, updateChan) if err == nil { return updateSub } - c.logger.Debug("Failed to subscribe to L1 state updates", + c.logger.Debug( + "Failed to subscribe to L1 state updates", zap.Duration("tryAgainIn", c.resubscribeDelay), zap.Error(err), ) @@ -149,7 +131,7 @@ func (c *Client) checkChainID(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, chainIDCheckTimeout) defer cancel() - l1ChainID, err := c.l1.ChainID(ctx) + l1ChainID, err := c.settlement.ChainID(ctx) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf( @@ -157,7 +139,7 @@ func (c *Client) checkChainID(ctx context.Context) error { chainIDCheckTimeout, ) } - return fmt.Errorf("retrieving Ethereum chain ID: %w", err) + return fmt.Errorf("get Ethereum chain id: %w", err) } expectedL1ChainID := c.network.L1ChainID @@ -175,7 +157,7 @@ func (c *Client) checkChainID(ctx context.Context) error { } func (c *Client) Run(ctx context.Context) error { - defer c.l1.Close() + defer c.settlement.Close() if err := c.checkChainID(ctx); err != nil { return err } @@ -186,7 +168,8 @@ func (c *Client) Run(ctx context.Context) error { // head will lag until the next on-chain LogStateUpdate is observed, which // is acceptable rather terminating the execution. if err := c.catchUpL1HeadUpdates(ctx); err != nil { - c.logger.Warn("L1 head catch-up failed; resuming with live subscription only", + c.logger.Warn( + "L1 head catch-up failed; resuming with live subscription only", zap.Error(err), ) } @@ -196,9 +179,9 @@ func (c *Client) Run(ctx context.Context) error { // CatchUpL1Head verifies the chain ID then writes the L1 head to the // database, without entering the live subscription loop. Closes the -// underlying Subscriber on return; the Client must not be reused. +// underlying SettlementLayer on return; the Client must not be reused. func (c *Client) CatchUpL1Head(ctx context.Context) error { - defer c.l1.Close() + defer c.settlement.Close() if err := c.checkChainID(ctx); err != nil { return err } @@ -210,12 +193,21 @@ func (c *Client) watchL1StateUpdates(ctx context.Context) error { c.logger.Info("Subscribing to L1 updates...") - updateChan := make(chan *contract.StarknetLogStateUpdate, buffer) + updateChan := make(chan *StateUpdate, buffer) updateSub := c.subscribeToUpdates(ctx, updateChan) if updateSub == nil { return nil } - defer updateSub.Unsubscribe() + // Closure form so the deferred Unsubscribe targets the *current* + // updateSub at function exit; reassigning updateSub during a + // resubscribe would otherwise leak the new sub. A plain + // `defer updateSub.Unsubscribe()` would also stack a new defer per + // reconnect — unbounded growth on a long-running node. + defer func() { + if updateSub != nil { + updateSub.Unsubscribe() + } + }() c.logger.Info("Subscribed to L1 updates") @@ -230,7 +222,6 @@ func (c *Client) watchL1StateUpdates(ctx context.Context) error { for { select { case err := <-updateSub.Err(): - // TODO can we use geth's event.Resubscribe? // We can't use a warn log level here since we guarantee the L1 url will only be printed // in debug logs and panics (to avoid leaking the API key). c.logger.Debug("L1 update subscription failed, resubscribing", zap.Error(err)) @@ -240,9 +231,8 @@ func (c *Client) watchL1StateUpdates(ctx context.Context) error { if updateSub == nil { return nil } - defer updateSub.Unsubscribe() //nolint:gocritic - case logStateUpdate := <-updateChan: - c.applyLogStateUpdate(logStateUpdate) + case update := <-updateChan: + c.applyStateUpdate(update) default: break Outer } @@ -255,23 +245,24 @@ func (c *Client) watchL1StateUpdates(ctx context.Context) error { } } -// applyLogStateUpdate merges a LogStateUpdate (from either the forward +// applyStateUpdate merges a StateUpdate (from either the forward // subscription or the historical filter) into nonFinalisedLogs. A removed // log clears all entries at or above its L1 block number. -func (c *Client) applyLogStateUpdate(u *contract.StarknetLogStateUpdate) { - c.logger.Debug("Received L1 LogStateUpdate", - zap.String("number", u.BlockNumber.String()), - zap.String("stateRoot", u.GlobalRoot.Text(felt.Base16)), - zap.String("blockHash", u.BlockHash.Text(felt.Base16)), +func (c *Client) applyStateUpdate(u *StateUpdate) { + c.logger.Debug( + "Received L1 state update", + zap.Uint64("l2Block", u.L2BlockNumber), + zap.String("stateRoot", u.StateRoot.ShortString()), + zap.String("l2BlockHash", u.L2BlockHash.ShortString()), ) - if u.Raw.Removed { + if u.Removed { for l1BlockNumber := range c.nonFinalisedLogs { - if l1BlockNumber >= u.Raw.BlockNumber { + if l1BlockNumber >= u.L1RefHeight { delete(c.nonFinalisedLogs, l1BlockNumber) } } } else { - c.nonFinalisedLogs[u.Raw.BlockNumber] = u + c.nonFinalisedLogs[u.L1RefHeight] = u } } @@ -294,7 +285,7 @@ func (c *Client) catchUpL1HeadUpdates(ctx context.Context) error { ) latestCtx, cancelLatest := context.WithTimeout(ctx, heightCallTimeout) - latest, err := c.l1.LatestHeight(latestCtx) + latest, err := c.settlement.LatestHeight(latestCtx) cancelLatest() if err != nil { if errors.Is(err, context.DeadlineExceeded) { @@ -303,11 +294,11 @@ func (c *Client) catchUpL1HeadUpdates(ctx context.Context) error { heightCallTimeout, ) } - return fmt.Errorf("failed to get latest height: %w", err) + return fmt.Errorf("get latest L1 height: %w", err) } finalisedCtx, cancelFinalised := context.WithTimeout(ctx, heightCallTimeout) - finalised, err := c.l1.FinalisedHeight(finalisedCtx) + finalised, err := c.settlement.FinalisedHeight(finalisedCtx) cancelFinalised() if err != nil { if errors.Is(err, context.DeadlineExceeded) { @@ -317,10 +308,11 @@ func (c *Client) catchUpL1HeadUpdates(ctx context.Context) error { heightCallTimeout, ) } - return fmt.Errorf("failed to get finalised height: %w", err) + return fmt.Errorf("get finalised L1 height: %w", err) } - c.logger.Info("L1 catch-up starting", + c.logger.Info( + "L1 catch-up starting", zap.Uint64("latest", latest), zap.Uint64("finalised", finalised), zap.Uint64("chunkSize", c.catchUpChunkSize), @@ -338,7 +330,7 @@ func (c *Client) catchUpL1HeadUpdates(ctx context.Context) error { from = to + 1 - c.catchUpChunkSize } filterCtx, cancelFilter := context.WithTimeout(ctx, filterCallTimeout) - events, err := c.l1.FilterLogStateUpdate(filterCtx, from, to) + events, err := c.settlement.FilterStateUpdate(filterCtx, from, to) cancelFilter() if err != nil { if errors.Is(err, context.DeadlineExceeded) { @@ -352,15 +344,16 @@ func (c *Client) catchUpL1HeadUpdates(ctx context.Context) error { chunks++ total += len(events) for _, ev := range events { - c.applyLogStateUpdate(ev) - if ev.Raw.BlockNumber <= finalised { + c.applyStateUpdate(ev) + if ev.L1RefHeight <= finalised { foundFinalised = true } } // Stop once we've captured at least one finalised event (so setL1Head // has something to commit) or we've walked back to genesis. if foundFinalised || from == 0 { - c.logger.Info("L1 catch-up complete", + c.logger.Info( + "L1 catch-up complete", zap.Int("chunks", chunks), zap.Int("events", total), zap.Int("nonFinalisedLogs", len(c.nonFinalisedLogs)), @@ -385,7 +378,7 @@ func (c *Client) finalisedHeight(ctx context.Context) (uint64, bool) { case <-timer.C: const finalisedHeightTimeout = 30 * time.Second callCtx, cancel := context.WithTimeout(ctx, finalisedHeightTimeout) - finalisedHeight, err := c.l1.FinalisedHeight(callCtx) + finalisedHeight, err := c.settlement.FinalisedHeight(callCtx) cancel() if err == nil { return finalisedHeight, true @@ -404,7 +397,7 @@ func (c *Client) setL1Head(ctx context.Context) error { // Get max finalised Starknet head. var maxFinalisedNumber uint64 - var maxFinalisedHead *contract.StarknetLogStateUpdate + var maxFinalisedHead *StateUpdate for l1BlockNumber := range c.nonFinalisedLogs { if l1BlockNumber <= finalisedHeight { if l1BlockNumber >= maxFinalisedNumber { @@ -421,9 +414,9 @@ func (c *Client) setL1Head(ctx context.Context) error { } head := &core.L1Head{ - BlockNumber: maxFinalisedHead.BlockNumber.Uint64(), - BlockHash: new(felt.Felt).SetBigInt(maxFinalisedHead.BlockHash), - StateRoot: new(felt.Felt).SetBigInt(maxFinalisedHead.GlobalRoot), + BlockNumber: maxFinalisedHead.L2BlockNumber, + BlockHash: maxFinalisedHead.L2BlockHash, + StateRoot: maxFinalisedHead.StateRoot, } if err := c.l2Chain.SetL1Head(head); err != nil { return fmt.Errorf( @@ -432,7 +425,8 @@ func (c *Client) setL1Head(ctx context.Context) error { ) } c.listener.OnNewL1Head(head) - c.logger.Info("Updated l1 head", + c.logger.Info( + "Updated l1 head", zap.Uint64("blockNumber", head.BlockNumber), zap.String("blockHash", head.BlockHash.ShortString()), zap.String("stateRoot", head.StateRoot.ShortString()), @@ -440,7 +434,3 @@ func (c *Client) setL1Head(ctx context.Context) error { return nil } - -func (c *Client) L1() Subscriber { - return c.l1 -} diff --git a/l1/l1_pkg_test.go b/l1/l1_pkg_test.go index 34989644a6..b4a784da23 100644 --- a/l1/l1_pkg_test.go +++ b/l1/l1_pkg_test.go @@ -1,9 +1,10 @@ -package l1 +package l1_test import ( "context" "errors" "math/big" + "sync/atomic" "testing" "testing/synctest" "time" @@ -14,16 +15,29 @@ import ( "github.com/NethermindEth/juno/core/felt" statetestutils "github.com/NethermindEth/juno/core/state/testutils" "github.com/NethermindEth/juno/db/memory" - "github.com/NethermindEth/juno/l1/contract" + "github.com/NethermindEth/juno/l1" + "github.com/NethermindEth/juno/l1/eth" "github.com/NethermindEth/juno/mocks" "github.com/NethermindEth/juno/utils/log" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) +// Aliases keep the diff against the original test small — only the +// boundary surface needed renaming, not every reference. +type ( + StateUpdate = l1.StateUpdate + MockSettlementLayer = mocks.MockSettlementLayer +) + +var ( + NewClient = l1.NewClient + WithResubscribeDelay = l1.WithResubscribeDelay + WithPollFinalisedInterval = l1.WithPollFinalisedInterval + WithCatchUpChunkSize = l1.WithCatchUpChunkSize +) + type fakeSubscription struct { errChan chan error closed bool @@ -59,15 +73,13 @@ type logStateUpdate struct { removed bool } -func (logSU *logStateUpdate) ToContractType() *contract.StarknetLogStateUpdate { - return &contract.StarknetLogStateUpdate{ - BlockNumber: new(big.Int).SetUint64(logSU.l2BlockNumber), - BlockHash: new(big.Int).SetUint64(logSU.l2BlockNumber), - GlobalRoot: new(big.Int).SetUint64(logSU.l2BlockNumber), - Raw: types.Log{ - Removed: logSU.removed, - BlockNumber: logSU.l1BlockNumber, - }, +func (logSU *logStateUpdate) ToStateUpdate() *StateUpdate { + return &StateUpdate{ + L2BlockNumber: logSU.l2BlockNumber, + L2BlockHash: new(felt.Felt).SetUint64(logSU.l2BlockNumber), + StateRoot: new(felt.Felt).SetUint64(logSU.l2BlockNumber), + L1RefHeight: logSU.l1BlockNumber, + Removed: logSU.removed, } } @@ -77,6 +89,62 @@ type l1Block struct { expectedL2BlockHash *felt.Felt } +// requireL1Head polls chain.L1Head until it reports the expected L2 block +// number, or fails the test after a fixed budget. The expected hash and +// state root are derived from logStateUpdate.ToStateUpdate's convention +// (both equal to the L2 block number cast to a felt). +func requireL1Head(t *testing.T, chain *blockchain.Blockchain, wantL2Block uint64) { + t.Helper() + want := core.L1Head{ + BlockNumber: wantL2Block, + BlockHash: new(felt.Felt).SetUint64(wantL2Block), + StateRoot: new(felt.Felt).SetUint64(wantL2Block), + } + require.Eventually(t, func() bool { + got, err := chain.L1Head() + return err == nil && assert.ObjectsAreEqual(want, got) + }, 2*time.Second, 5*time.Millisecond, "L1Head never advanced to l2=%d", wantL2Block) +} + +// swappableSettlement is a test-only SettlementLayer that delegates to a +// runtime-swappable inner mock. It lets one Client stay alive across +// scenario iterations while the test rebinds expectations per iteration — +// previously this was achieved by exposing a SetSettlement hatch on +// production Client. The hatch is gone; the indirection lives here. +type swappableSettlement struct { + inner atomic.Pointer[mocks.MockSettlementLayer] +} + +func (s *swappableSettlement) set(m *mocks.MockSettlementLayer) { s.inner.Store(m) } + +func (s *swappableSettlement) ChainID(ctx context.Context) (*big.Int, error) { + return s.inner.Load().ChainID(ctx) +} + +func (s *swappableSettlement) FinalisedHeight(ctx context.Context) (uint64, error) { + return s.inner.Load().FinalisedHeight(ctx) +} + +func (s *swappableSettlement) LatestHeight(ctx context.Context) (uint64, error) { + return s.inner.Load().LatestHeight(ctx) +} + +func (s *swappableSettlement) WatchStateUpdate( + ctx context.Context, + sink chan<- *StateUpdate, +) (eth.Subscription, error) { + return s.inner.Load().WatchStateUpdate(ctx, sink) +} + +func (s *swappableSettlement) FilterStateUpdate( + ctx context.Context, + from, to uint64, +) ([]*StateUpdate, error) { + return s.inner.Load().FilterStateUpdate(ctx, from, to) +} + +func (s *swappableSettlement) Close() { s.inner.Load().Close() } + var longSequenceOfBlocks = []*l1Block{ { updates: []*logStateUpdate{ @@ -352,8 +420,9 @@ func TestClient(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) + swap := &swappableSettlement{} client := NewClient( - nil, + swap, chain, nopLog, WithResubscribeDelay(0), @@ -362,13 +431,13 @@ func TestClient(t *testing.T) { // We loop over each block and check that the state agrees with our expectations. for _, block := range tt.blocks { - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). - Do(func(_ context.Context, sink chan<- *contract.StarknetLogStateUpdate) { + WatchStateUpdate(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, sink chan<- *StateUpdate) { for _, update := range block.updates { - sink <- update.ToContractType() + sink <- update.ToStateUpdate() } }). Return(newFakeSubscription(), nil). @@ -388,7 +457,7 @@ func TestClient(t *testing.T) { subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil). AnyTimes() @@ -400,7 +469,7 @@ func TestClient(t *testing.T) { subscriber.EXPECT().Close().Times(1) - client.l1 = subscriber + swap.set(subscriber) ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) require.NoError(t, client.Run(ctx)) @@ -434,8 +503,9 @@ func TestUnreliableSubscription(t *testing.T) { &network, blockchain.WithNewState(statetestutils.UseNewState()), ) + swap := &swappableSettlement{} client := NewClient( - nil, + swap, chain, nopLog, WithResubscribeDelay(0), @@ -444,7 +514,7 @@ func TestUnreliableSubscription(t *testing.T) { err := errors.New("test err") for _, block := range longSequenceOfBlocks { - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) // The subscription returns an error on each block. // Each time, a second subscription succeeds. @@ -452,17 +522,17 @@ func TestUnreliableSubscription(t *testing.T) { failedUpdateSub := newFakeSubscription(err) failedUpdateCall := subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). + WatchStateUpdate(gomock.Any(), gomock.Any()). Return(failedUpdateSub, nil). Times(1) successUpdateSub := newFakeSubscription() subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). - Do(func(_ context.Context, sink chan<- *contract.StarknetLogStateUpdate) { + WatchStateUpdate(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, sink chan<- *StateUpdate) { for _, log := range block.updates { - sink <- log.ToContractType() + sink <- log.ToStateUpdate() } }). Return(successUpdateSub, nil). @@ -489,14 +559,14 @@ func TestUnreliableSubscription(t *testing.T) { subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil). AnyTimes() subscriber.EXPECT().Close().Times(1) // Replace the subscriber. - client.l1 = subscriber + swap.set(subscriber) ctx, cancel := context.WithTimeout(t.Context(), 500*time.Millisecond) require.NoError(t, client.Run(ctx)) @@ -524,8 +594,8 @@ func TestUnreliableSubscription(t *testing.T) { // newCatchUpFixture builds the boilerplate every catch-up test repeats: // a fresh chain, a mock subscriber wired with the chain-id check, an idle // live subscription, and the final Close expectation. Per-test variation -// (heights, FilterLogStateUpdate calls, client options) stays in the test. -func newCatchUpFixture(t *testing.T) (*blockchain.Blockchain, *mocks.MockSubscriber) { +// (heights, FilterStateUpdate calls, client options) stays in the test. +func newCatchUpFixture(t *testing.T) (*blockchain.Blockchain, *MockSettlementLayer) { t.Helper() ctrl := gomock.NewController(t) network := networks.Mainnet @@ -535,11 +605,11 @@ func newCatchUpFixture(t *testing.T) (*blockchain.Blockchain, *mocks.MockSubscri blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). + WatchStateUpdate(gomock.Any(), gomock.Any()). Return(newFakeSubscription(), nil). AnyTimes() subscriber.EXPECT().Close().Times(1) @@ -556,11 +626,11 @@ func TestCatchUpSetsL1HeadOnStart(t *testing.T) { subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(10), nil).Times(1) subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(5), nil).AnyTimes() - backfilled := (&logStateUpdate{l1BlockNumber: 3, l2BlockNumber: 7}).ToContractType() + backfilled := (&logStateUpdate{l1BlockNumber: 3, l2BlockNumber: 7}).ToStateUpdate() subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(1), uint64(10)). - Return([]*contract.StarknetLogStateUpdate{backfilled}, nil). + FilterStateUpdate(gomock.Any(), uint64(1), uint64(10)). + Return([]*StateUpdate{backfilled}, nil). Times(1) client := NewClient(subscriber, chain, nopLog, @@ -596,24 +666,24 @@ func TestCatchUpMultiChunk(t *testing.T) { subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(25), nil).Times(1) subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(5), nil).AnyTimes() - firstEvent := (&logStateUpdate{l1BlockNumber: 20, l2BlockNumber: 50}).ToContractType() - thirdEvent := (&logStateUpdate{l1BlockNumber: 3, l2BlockNumber: 25}).ToContractType() + firstEvent := (&logStateUpdate{l1BlockNumber: 20, l2BlockNumber: 50}).ToStateUpdate() + thirdEvent := (&logStateUpdate{l1BlockNumber: 3, l2BlockNumber: 25}).ToStateUpdate() firstCall := subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(16), uint64(25)). - Return([]*contract.StarknetLogStateUpdate{firstEvent}, nil). + FilterStateUpdate(gomock.Any(), uint64(16), uint64(25)). + Return([]*StateUpdate{firstEvent}, nil). Times(1) secondCall := subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(6), uint64(15)). + FilterStateUpdate(gomock.Any(), uint64(6), uint64(15)). Return(nil, nil). Times(1). After(firstCall) subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(0), uint64(5)). - Return([]*contract.StarknetLogStateUpdate{thirdEvent}, nil). + FilterStateUpdate(gomock.Any(), uint64(0), uint64(5)). + Return([]*StateUpdate{thirdEvent}, nil). Times(1). After(secondCall) @@ -649,7 +719,7 @@ func TestCatchUpFilterError(t *testing.T) { rpcErr := errors.New("rpc broken") subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, rpcErr). Times(1) @@ -670,129 +740,166 @@ func TestCatchUpFilterError(t *testing.T) { } // TestCatchUpHeadAndCachePartition feeds a single chunk with a mix of -// finalised and non-finalised events and asserts the post-setL1Head state: -// the highest finalised event wins as L1 head, every finalised entry is -// evicted from nonFinalisedLogs, and entries above finalisedHeight stay -// buffered for later commitment. +// finalised and non-finalised events and verifies the post-setL1Head +// partition: +// - finalised entries (l1=2,3,5) are promoted/evicted during catch-up, the +// highest of them (l1=5) wins as the initial L1 head; +// - non-finalised entries (l1=7,9) are buffered. We verify they survived +// by walking the finalised height forward across the live-poll ticks and +// observing L1Head promote each one in turn. func TestCatchUpHeadAndCachePartition(t *testing.T) { t.Parallel() chain, subscriber := newCatchUpFixture(t) nopLog := log.NewNopZapLogger() - // catchUpChunkSize default 1000. LatestHeight=10, FinalisedHeight=5 → - // single chunk [0, 10]. Five events span the finalised cutoff: + // catchUpChunkSize default 1000. LatestHeight=10, initial FinalisedHeight=5 + // → single chunk [0, 10]. Five events span the finalised cutoff: // l1=2,3,5 (<= finalised) → all deleted from cache, l1=5 wins as head // l1=7,9 (> finalised) → remain buffered for the live loop subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(10), nil).Times(1) - subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(5), nil).AnyTimes() - finalisedLow := (&logStateUpdate{l1BlockNumber: 2, l2BlockNumber: 20}).ToContractType() - finalisedMid := (&logStateUpdate{l1BlockNumber: 3, l2BlockNumber: 30}).ToContractType() - finalisedTop := (&logStateUpdate{l1BlockNumber: 5, l2BlockNumber: 50}).ToContractType() - pendingLow := (&logStateUpdate{l1BlockNumber: 7, l2BlockNumber: 70}).ToContractType() - pendingHigh := (&logStateUpdate{l1BlockNumber: 9, l2BlockNumber: 90}).ToContractType() + // Finalised height is dynamic across the test: starts at 5 (so catch-up + // commits l1=5 as head and buffers l1=7,9), then the test bumps it to + // promote each buffered entry via the live-poll tick. + var finalised atomic.Uint64 + finalised.Store(5) + subscriber. + EXPECT(). + FinalisedHeight(gomock.Any()). + DoAndReturn(func(_ context.Context) (uint64, error) { + return finalised.Load(), nil + }). + AnyTimes() + + finalisedLow := (&logStateUpdate{l1BlockNumber: 2, l2BlockNumber: 20}).ToStateUpdate() + finalisedMid := (&logStateUpdate{l1BlockNumber: 3, l2BlockNumber: 30}).ToStateUpdate() + finalisedTop := (&logStateUpdate{l1BlockNumber: 5, l2BlockNumber: 50}).ToStateUpdate() + pendingLow := (&logStateUpdate{l1BlockNumber: 7, l2BlockNumber: 70}).ToStateUpdate() + pendingHigh := (&logStateUpdate{l1BlockNumber: 9, l2BlockNumber: 90}).ToStateUpdate() subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(0), uint64(10)). - Return([]*contract.StarknetLogStateUpdate{ + FilterStateUpdate(gomock.Any(), uint64(0), uint64(10)). + Return([]*StateUpdate{ finalisedLow, finalisedMid, finalisedTop, pendingLow, pendingHigh, }, nil). Times(1) + // Short poll interval so the live loop ticks setL1Head several times + // during the test budget. client := NewClient(subscriber, chain, nopLog, WithResubscribeDelay(0), - WithPollFinalisedInterval(time.Hour), + WithPollFinalisedInterval(10*time.Millisecond), ) - ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) - require.NoError(t, client.Run(ctx)) - cancel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + runErr := make(chan error, 1) + go func() { runErr <- client.Run(ctx) }() - // Highest finalised event (l1=5 → l2=50) commits as L1 head. - got, err := chain.L1Head() - require.NoError(t, err) - assert.Equal(t, core.L1Head{ - BlockNumber: 50, - BlockHash: new(felt.Felt).SetUint64(50), - StateRoot: new(felt.Felt).SetUint64(50), - }, got) + // Step 1: catch-up commits the highest finalised event (l1=5) as L1 head. + requireL1Head(t, chain, 50) - // Non-finalised entries survive; every finalised entry is evicted - // (including the one that became the head). - require.Len(t, client.nonFinalisedLogs, 2) - assert.Equal(t, pendingLow, client.nonFinalisedLogs[7]) - assert.Equal(t, pendingHigh, client.nonFinalisedLogs[9]) - for _, l1Block := range []uint64{2, 3, 5} { - _, present := client.nonFinalisedLogs[l1Block] - assert.Falsef(t, present, "finalised l1=%d should be deleted from cache", l1Block) - } + // Step 2: bump finalised so the live loop's setL1Head promotes l1=7. + // If catch-up failed to buffer l1=7, L1Head can never advance past l2=50. + finalised.Store(7) + requireL1Head(t, chain, 70) + + // Step 3: same proof for l1=9 — must have been buffered by catch-up. + finalised.Store(9) + requireL1Head(t, chain, 90) + + cancel() + require.NoError(t, <-runErr) } // TestCatchUpPartialProgressPreserved asserts the best-effort contract: when // a backward chunk filter call errors mid-walk, entries already merged into // nonFinalisedLogs by earlier successful chunks must remain available to the -// live subscription's setL1Head, instead of being rolled back. +// live subscription's setL1Head, instead of being rolled back. Observed via +// promotion: once the finalised height catches up to the buffered entry, the +// live-poll setL1Head commits it as L1 head. func TestCatchUpPartialProgressPreserved(t *testing.T) { t.Parallel() chain, subscriber := newCatchUpFixture(t) nopLog := log.NewNopZapLogger() - // catchUpChunkSize = 1000. LatestHeight=3000, FinalisedHeight=2000: + // catchUpChunkSize = 1000. LatestHeight=3000, initial FinalisedHeight=2000: // chunk 1: [2001, 3000] -> succeeds with non-finalised event at l1=2500 // (2500 > 2000 finalised, so foundFinalised=false, // loop continues to next chunk) // chunk 2: [1001, 2000] -> errors, catch-up bails out // The chunk-1 event must still be sitting in nonFinalisedLogs after Run. subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(3000), nil).Times(1) - subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(2000), nil).AnyTimes() - chunkOneEvent := (&logStateUpdate{l1BlockNumber: 2500, l2BlockNumber: 42}).ToContractType() + // Finalised height starts at 2000 so the chunk-1 event (l1=2500) stays + // buffered. The test later bumps it past 2500 so the live-poll setL1Head + // can promote — that promotion only succeeds if catch-up preserved the + // entry across the chunk-2 error. + var finalised atomic.Uint64 + finalised.Store(2000) + subscriber. + EXPECT(). + FinalisedHeight(gomock.Any()). + DoAndReturn(func(_ context.Context) (uint64, error) { + return finalised.Load(), nil + }). + AnyTimes() + + chunkOneEvent := (&logStateUpdate{l1BlockNumber: 2500, l2BlockNumber: 42}).ToStateUpdate() rpcErr := errors.New("rpc broken") firstCall := subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(2001), uint64(3000)). - Return([]*contract.StarknetLogStateUpdate{chunkOneEvent}, nil). + FilterStateUpdate(gomock.Any(), uint64(2001), uint64(3000)). + Return([]*StateUpdate{chunkOneEvent}, nil). Times(1) subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(1001), uint64(2000)). + FilterStateUpdate(gomock.Any(), uint64(1001), uint64(2000)). Return(nil, rpcErr). Times(1). After(firstCall) - // Poll interval is 1h so the live loop never ticks setL1Head — the only - // thing that could populate nonFinalisedLogs is the catch-up walk. + // Short poll interval so the live loop ticks setL1Head shortly after Run + // transitions out of catch-up. client := NewClient(subscriber, chain, nopLog, WithResubscribeDelay(0), - WithPollFinalisedInterval(time.Hour), + WithPollFinalisedInterval(10*time.Millisecond), ) - ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond) - require.NoError(t, client.Run(ctx)) - cancel() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + runErr := make(chan error, 1) + go func() { runErr <- client.Run(ctx) }() - // Partial state from chunk 1 survived the chunk-2 error. - require.Len(t, client.nonFinalisedLogs, 1) - got, ok := client.nonFinalisedLogs[2500] - require.True(t, ok, "chunk-1 event at l1=2500 should remain buffered") - assert.Equal(t, chunkOneEvent, got) + // Give the live poll a few ticks before bumping finalised. With initial + // finalised=2000 and the chunk-1 event at l1=2500, setL1Head finds nothing + // to promote, so L1Head must remain unset. + time.Sleep(50 * time.Millisecond) + _, headErr := chain.L1Head() + require.Error(t, headErr, "L1Head must remain unset while finalised < 2500") - // Above finalised, so setL1Head wouldn't have committed it anyway. - _, err := chain.L1Head() - require.Error(t, err) + // Bump finalised past l1=2500. The live-poll setL1Head must find the + // chunk-1 entry still buffered and promote it. If catch-up rolled state + // back on chunk-2's error, L1Head never advances. + finalised.Store(2500) + requireL1Head(t, chain, 42) + + cancel() + require.NoError(t, <-runErr) } -// TestFinalisedHeightReturnsPromptlyOnCancel asserts that when the retry -// loop is waiting between attempts, a ctx cancellation wakes it up -// immediately instead of stalling for resubscribeDelay. Runs inside a -// synctest bubble with a 1h delay: with the fix, virtual time stays at 0; -// without it, the loop would burn the full hour of virtual time before -// noticing ctx.Done. -func TestFinalisedHeightReturnsPromptlyOnCancel(t *testing.T) { +// TestFinalisedHeightRetryReturnsPromptlyOnCancel drives Run and forces +// the inner finalisedHeight retry loop into its inter-attempt wait: ChainID +// and LatestHeight succeed (catch-up gets past its preamble) but every +// FinalisedHeight call errors. Catch-up bails best-effort; Run enters the +// live poll loop which ticks setL1Head → finalisedHeight retry → blocks on +// resubscribeDelay. Cancel ctx, assert Run returns without burning the full +// hour of virtual time. +func TestFinalisedHeightRetryReturnsPromptlyOnCancel(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctrl := gomock.NewController(t) nopLog := log.NewNopZapLogger() @@ -803,44 +910,47 @@ func TestFinalisedHeightReturnsPromptlyOnCancel(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) + subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) + subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(0), nil).AnyTimes() subscriber. EXPECT(). FinalisedHeight(gomock.Any()). Return(uint64(0), errors.New("boom")). MinTimes(1) + subscriber. + EXPECT(). + WatchStateUpdate(gomock.Any(), gomock.Any()). + Return(newFakeSubscription(), nil). + AnyTimes() + subscriber.EXPECT().Close().Times(1) - client := NewClient(subscriber, chain, nopLog, WithResubscribeDelay(time.Hour)) + client := NewClient(subscriber, chain, nopLog, + WithResubscribeDelay(time.Hour), + WithPollFinalisedInterval(time.Nanosecond), + ) ctx, cancel := context.WithCancel(t.Context()) - type result struct { - height uint64 - found bool - } - done := make(chan result, 1) + done := make(chan error, 1) start := time.Now() - go func() { - height, found := client.finalisedHeight(ctx) - done <- result{height: height, found: found} - }() + go func() { done <- client.Run(ctx) }() - // Wait for the retry loop to durably block in the inter-attempt wait. + // Wait until Run is durably blocked inside finalisedHeight's + // inter-attempt timer. synctest.Wait() cancel() - got := <-done - require.False(t, got.found) - require.Equal(t, uint64(0), got.height) + require.NoError(t, <-done) require.Less(t, time.Since(start), time.Minute, - "finalisedHeight stalled in time.Sleep after ctx cancel") + "Run stalled in finalisedHeight retry after ctx cancel") }) } -// TestSubscribeToUpdatesReturnsPromptlyOnCancel is the same check for the -// other retry loop: WatchLogStateUpdate fails repeatedly, the loop enters -// its inter-attempt wait, ctx is cancelled, and the function must return -// without consuming resubscribeDelay. -func TestSubscribeToUpdatesReturnsPromptlyOnCancel(t *testing.T) { +// TestSubscribeRetryReturnsPromptlyOnCancel is the same check for the other +// retry loop: WatchStateUpdate fails repeatedly inside subscribeToUpdates, +// the loop enters its inter-attempt wait, ctx is cancelled, and Run must +// return without consuming resubscribeDelay. +func TestSubscribeRetryReturnsPromptlyOnCancel(t *testing.T) { synctest.Test(t, func(t *testing.T) { ctrl := gomock.NewController(t) nopLog := log.NewNopZapLogger() @@ -851,27 +961,133 @@ func TestSubscribeToUpdatesReturnsPromptlyOnCancel(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) + subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) + subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(0), nil).AnyTimes() + subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(0), nil).AnyTimes() subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil). + AnyTimes() + subscriber. + EXPECT(). + WatchStateUpdate(gomock.Any(), gomock.Any()). Return(nil, errors.New("boom")). MinTimes(1) + subscriber.EXPECT().Close().Times(1) client := NewClient(subscriber, chain, nopLog, WithResubscribeDelay(time.Hour)) ctx, cancel := context.WithCancel(t.Context()) - done := make(chan event.Subscription, 1) + done := make(chan error, 1) start := time.Now() - go func() { - done <- client.subscribeToUpdates(ctx, make(chan *contract.StarknetLogStateUpdate, 1)) - }() + go func() { done <- client.Run(ctx) }() + // Wait until Run is durably blocked inside subscribeToUpdates' + // inter-attempt timer. synctest.Wait() cancel() - require.Nil(t, <-done) + require.NoError(t, <-done) require.Less(t, time.Since(start), time.Minute, - "subscribeToUpdates stalled in time.Sleep after ctx cancel") + "Run stalled in subscribeToUpdates retry after ctx cancel") }) } + +// TestCancelDuringMidStreamResubscribeDoesNotPanic is a regression for +// a nil-deref panic observed on ctrl-C: the first WatchStateUpdate +// succeeds, the subscription later errors out (transport drop), the +// inner resubscribe loop fails repeatedly, ctx is cancelled during a +// retry, watchL1StateUpdates returns nil — and the deferred +// `updateSub.Unsubscribe()` would deref a nil sub from the failed +// resubscribe attempt. +// +// Goal: Run() returns cleanly with no panic. +// +// Real-time (not synctest) because the bug surfaces via a deferred +// nil-deref at function exit, which we want to trigger on the real +// scheduler. +func TestCancelDuringMidStreamResubscribeDoesNotPanic(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + nopLog := log.NewNopZapLogger() + network := networks.Mainnet + chain := blockchain.New( + memory.New(), + &network, + blockchain.WithNewState(statetestutils.UseNewState()), + ) + + subscriber := mocks.NewMockSettlementLayer(ctrl) + subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) + subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(0), nil).AnyTimes() + subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(0), nil).AnyTimes() + subscriber. + EXPECT(). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, nil). + AnyTimes() + + // First WatchStateUpdate succeeds with a sub that immediately + // errors — drives the inner loop into the resubscribe path. + boom := errors.New("transport dropped") + firstSub := newFakeSubscription(boom) + firstCall := subscriber. + EXPECT(). + WatchStateUpdate(gomock.Any(), gomock.Any()). + Return(firstSub, nil). + Times(1) + + // Resubscribe attempt fails — leaves updateSub at nil in the caller + // when ctx is then cancelled, which is exactly the panic path. + resubCh := make(chan struct{}, 1) + subscriber. + EXPECT(). + WatchStateUpdate(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, _ chan<- *StateUpdate) (eth.Subscription, error) { + select { + case resubCh <- struct{}{}: + default: + } + return nil, errors.New("still dead") + }). + MinTimes(1). + After(firstCall) + + subscriber.EXPECT().Close().Times(1) + + client := NewClient(subscriber, chain, nopLog, + WithResubscribeDelay(time.Hour), + WithPollFinalisedInterval(time.Millisecond), + ) + + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- errors.New("panicked") + } + }() + done <- client.Run(ctx) + }() + + // Wait until at least one failing resubscribe has happened — by + // then watchL1StateUpdates is parked in subscribeToUpdates' + // inter-attempt timer. + select { + case <-resubCh: + case <-time.After(2 * time.Second): + cancel() + t.Fatal("resubscribe never happened") + } + + cancel() + select { + case err := <-done: + require.NoError(t, err, "Run must return cleanly without panic") + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after cancel") + } +} diff --git a/l1/l1_test.go b/l1/l1_test.go index 6b3c45f692..3a9b6bac5f 100644 --- a/l1/l1_test.go +++ b/l1/l1_test.go @@ -4,8 +4,6 @@ import ( "context" "errors" "math/big" - "net" - "net/http" "testing" "testing/synctest" "time" @@ -17,39 +15,12 @@ import ( statetestutils "github.com/NethermindEth/juno/core/state/testutils" "github.com/NethermindEth/juno/db/memory" "github.com/NethermindEth/juno/l1" - "github.com/NethermindEth/juno/l1/contract" - "github.com/NethermindEth/juno/l1/eth" "github.com/NethermindEth/juno/mocks" "github.com/NethermindEth/juno/utils/log" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/rpc" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) -type fakeSubscription struct { - errChan chan error - closed bool -} - -func newFakeSubscription() *fakeSubscription { - return &fakeSubscription{ - errChan: make(chan error), - } -} - -func (s *fakeSubscription) Err() <-chan error { - return s.errChan -} - -func (s *fakeSubscription) Unsubscribe() { - if !s.closed { - close(s.errChan) - s.closed = true - } -} - func TestFailToCreateSubscription(t *testing.T) { t.Parallel() @@ -64,11 +35,11 @@ func TestFailToCreateSubscription(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). + WatchStateUpdate(gomock.Any(), gomock.Any()). Return(newFakeSubscription(), err). AnyTimes() @@ -84,7 +55,7 @@ func TestFailToCreateSubscription(t *testing.T) { subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(0), nil).AnyTimes() subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil). AnyTimes() @@ -117,7 +88,7 @@ func TestMismatchedChainID(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().Close().Times(1) subscriber. @@ -158,7 +129,7 @@ func TestChainIDCheckTimeout(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().Close().Times(1) subscriber. EXPECT(). @@ -189,7 +160,7 @@ func TestChainIDFetchError(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().Close().Times(1) rpcErr := errors.New("boom") subscriber. @@ -203,7 +174,7 @@ func TestChainIDFetchError(t *testing.T) { ctx, cancel := context.WithTimeout(t.Context(), time.Second) t.Cleanup(cancel) err := client.Run(ctx) - require.ErrorContains(t, err, "retrieving Ethereum chain ID") + require.ErrorContains(t, err, "get Ethereum chain id") require.ErrorIs(t, err, rpcErr) } @@ -224,7 +195,7 @@ func TestFinalisedHeightTimeoutDuringCatchUp(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().Close().Times(1) subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(1000), nil).Times(1) @@ -258,7 +229,7 @@ func TestLatestHeightTimeoutDuringCatchUp(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().Close().Times(1) subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) subscriber. @@ -276,10 +247,10 @@ func TestLatestHeightTimeoutDuringCatchUp(t *testing.T) { }) } -// TestFilterLogStateUpdateTimeoutDuringCatchUp covers the eth_getLogs path. It +// TestFilterStateUpdateTimeoutDuringCatchUp covers the eth_getLogs path. It // has a longer (60s) production timeout than the two height calls; the test // just relies on synctest to fast-forward whatever the timeout happens to be. -func TestFilterLogStateUpdateTimeoutDuringCatchUp(t *testing.T) { +func TestFilterStateUpdateTimeoutDuringCatchUp(t *testing.T) { synctest.Test(t, func(t *testing.T) { network := networks.Mainnet ctrl := gomock.NewController(t) @@ -290,15 +261,15 @@ func TestFilterLogStateUpdateTimeoutDuringCatchUp(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().Close().Times(1) subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(1000), nil).Times(1) subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(500), nil).Times(1) subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). - DoAndReturn(func(ctx context.Context, _, _ uint64) ([]*contract.StarknetLogStateUpdate, error) { + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + DoAndReturn(func(ctx context.Context, _, _ uint64) ([]*l1.StateUpdate, error) { <-ctx.Done() return nil, ctx.Err() }). @@ -333,7 +304,7 @@ func TestFinalisedHeightRetryLoopProgressesPastHang(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().Close().Times(1) subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).Times(1) subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(10), nil).Times(1) @@ -367,16 +338,16 @@ func TestFinalisedHeightRetryLoopProgressesPastHang(t *testing.T) { // One finalised event at L1=3 (≤ finalised=5) so foundFinalised flips // and the catch-up loop reaches setL1Head with something to commit. - event := &contract.StarknetLogStateUpdate{ - BlockNumber: big.NewInt(7), - BlockHash: big.NewInt(7), - GlobalRoot: big.NewInt(7), - Raw: types.Log{BlockNumber: 3}, + event := &l1.StateUpdate{ + L2BlockNumber: 7, + L2BlockHash: new(felt.Felt).SetUint64(7), + StateRoot: new(felt.Felt).SetUint64(7), + L1RefHeight: 3, } subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). - Return([]*contract.StarknetLogStateUpdate{event}, nil). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + Return([]*l1.StateUpdate{event}, nil). Times(1) client := l1.NewClient(subscriber, chain, nopLog, l1.WithResubscribeDelay(time.Second)) @@ -404,15 +375,14 @@ func TestEventListener(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). - Do(func(_ context.Context, sink chan<- *contract.StarknetLogStateUpdate) { - sink <- &contract.StarknetLogStateUpdate{ - GlobalRoot: new(big.Int), - BlockNumber: new(big.Int), - BlockHash: new(big.Int), + WatchStateUpdate(gomock.Any(), gomock.Any()). + Do(func(_ context.Context, sink chan<- *l1.StateUpdate) { + sink <- &l1.StateUpdate{ + L2BlockHash: new(felt.Felt), + StateRoot: new(felt.Felt), } }). Return(newFakeSubscription(), nil). @@ -432,7 +402,7 @@ func TestEventListener(t *testing.T) { subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). + FilterStateUpdate(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil). AnyTimes() @@ -445,7 +415,8 @@ func TestEventListener(t *testing.T) { subscriber.EXPECT().Close().Times(1) var got *core.L1Head - client := l1.NewClient(subscriber, chain, nopLog, + client := l1.NewClient( + subscriber, chain, nopLog, l1.WithResubscribeDelay(0), l1.WithPollFinalisedInterval(time.Nanosecond), l1.WithEventListener(l1.SelectiveListener{ @@ -477,7 +448,7 @@ func TestEventListenerCatchUp(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber. EXPECT(). ChainID(gomock.Any()). @@ -488,7 +459,7 @@ func TestEventListenerCatchUp(t *testing.T) { // populate nonFinalisedLogs so setL1Head fires the listener callback. subscriber. EXPECT(). - WatchLogStateUpdate(gomock.Any(), gomock.Any()). + WatchStateUpdate(gomock.Any(), gomock.Any()). Return(newFakeSubscription(), nil). AnyTimes() @@ -496,22 +467,23 @@ func TestEventListenerCatchUp(t *testing.T) { subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(10), nil).Times(1) subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(5), nil).AnyTimes() - backfilled := &contract.StarknetLogStateUpdate{ - BlockNumber: new(big.Int).SetUint64(7), - BlockHash: new(big.Int).SetUint64(7), - GlobalRoot: new(big.Int).SetUint64(7), - Raw: types.Log{BlockNumber: 3}, + backfilled := &l1.StateUpdate{ + L2BlockNumber: 7, + L2BlockHash: new(felt.Felt).SetUint64(7), + StateRoot: new(felt.Felt).SetUint64(7), + L1RefHeight: 3, } subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(0), uint64(10)). - Return([]*contract.StarknetLogStateUpdate{backfilled}, nil). + FilterStateUpdate(gomock.Any(), uint64(0), uint64(10)). + Return([]*l1.StateUpdate{backfilled}, nil). Times(1) subscriber.EXPECT().Close().Times(1) var got *core.L1Head - client := l1.NewClient(subscriber, chain, nopLog, + client := l1.NewClient( + subscriber, chain, nopLog, l1.WithResubscribeDelay(0), l1.WithPollFinalisedInterval(time.Hour), l1.WithEventListener(l1.SelectiveListener{ @@ -557,18 +529,18 @@ func TestCatchUpL1Head(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().ChainID(gomock.Any()).Return(network.L1ChainID, nil).AnyTimes() subscriber.EXPECT().LatestHeight(gomock.Any()).Return(uint64(10), nil).AnyTimes() subscriber.EXPECT().FinalisedHeight(gomock.Any()).Return(uint64(5), nil).AnyTimes() subscriber. EXPECT(). - FilterLogStateUpdate(gomock.Any(), uint64(0), uint64(10)). - Return([]*contract.StarknetLogStateUpdate{{ - BlockNumber: new(big.Int).SetUint64(7), - BlockHash: new(big.Int).SetUint64(7), - GlobalRoot: new(big.Int).SetUint64(7), - Raw: types.Log{BlockNumber: 3}, + FilterStateUpdate(gomock.Any(), uint64(0), uint64(10)). + Return([]*l1.StateUpdate{{ + L2BlockNumber: 7, + L2BlockHash: new(felt.Felt).SetUint64(7), + StateRoot: new(felt.Felt).SetUint64(7), + L1RefHeight: 3, }}, nil). AnyTimes() subscriber.EXPECT().Close().AnyTimes() @@ -597,7 +569,7 @@ func TestCatchUpL1Head_ChainIDMismatch(t *testing.T) { blockchain.WithNewState(statetestutils.UseNewState()), ) - subscriber := mocks.NewMockSubscriber(ctrl) + subscriber := mocks.NewMockSettlementLayer(ctrl) subscriber.EXPECT().ChainID(gomock.Any()).Return(big.NewInt(999), nil) subscriber.EXPECT().Close() @@ -605,140 +577,3 @@ func TestCatchUpL1Head_ChainIDMismatch(t *testing.T) { require.ErrorContains(t, err, "mismatched network id between L1 and L2") require.ErrorContains(t, err, "--eth-node") } - -func newTestL1Client(service service) *rpc.Server { - server := rpc.NewServer() - if err := server.RegisterName("eth", service); err != nil { - panic(err) - } - return server -} - -type service interface { - GetBlockByNumber(ctx context.Context, number string, fullTx bool) (any, error) - BlockNumber(ctx context.Context) (string, error) -} - -type testService struct{} - -func (testService) GetBlockByNumber(ctx context.Context, number string, fullTx bool) (any, error) { - blockHeight := big.NewInt(100) - return types.Header{ - ParentHash: common.Hash{}, - UncleHash: common.Hash{}, - Root: common.Hash{}, - TxHash: common.Hash{}, - ReceiptHash: common.Hash{}, - Bloom: types.Bloom{}, - Difficulty: big.NewInt(0), - Number: blockHeight, - GasLimit: 0, - GasUsed: 0, - Time: 0, - Extra: []byte{}, - }, nil -} - -func (testService) BlockNumber(ctx context.Context) (string, error) { - return "0xc8", nil // 200 in hex -} - -type testEmptyService struct{} - -func (testEmptyService) GetBlockByNumber(ctx context.Context, number string, fullTx bool) (any, error) { - return nil, nil -} - -func (testEmptyService) BlockNumber(ctx context.Context) (string, error) { - return "", errors.New("empty service") -} - -type testFaultyService struct{} - -func (testFaultyService) GetBlockByNumber(ctx context.Context, number string, fullTx bool) (any, error) { - return uint(0), nil -} - -func (testFaultyService) BlockNumber(ctx context.Context) (string, error) { - return "invalid", nil -} - -func TestEthSubscriber_FinalisedHeight(t *testing.T) { - tests := createEthSubscriberTests(100) - testEthSubscriberHeight(t, tests, func(subscriber *l1.EthSubscriber, ctx context.Context) (uint64, error) { - return subscriber.FinalisedHeight(ctx) - }) -} - -func TestEthSubscriber_LatestHeight(t *testing.T) { - tests := createEthSubscriberTests(200) - testEthSubscriberHeight(t, tests, func(subscriber *l1.EthSubscriber, ctx context.Context) (uint64, error) { - return subscriber.LatestHeight(ctx) - }) -} - -func createEthSubscriberTests(testServiceExpectedHeight uint64) map[string]struct { - service service - expectedHeight uint64 - expectedError bool -} { - return map[string]struct { - service service - expectedHeight uint64 - expectedError bool - }{ - "testService": { - service: testService{}, - expectedHeight: testServiceExpectedHeight, - expectedError: false, - }, - "testEmptyService": { - service: testEmptyService{}, - expectedHeight: 0, - expectedError: true, - }, - "testFaultyService": { - service: testFaultyService{}, - expectedHeight: 0, - expectedError: true, - }, - } -} - -func testEthSubscriberHeight(t *testing.T, tests map[string]struct { - service service - expectedHeight uint64 - expectedError bool -}, heightFunc func(*l1.EthSubscriber, context.Context) (uint64, error), -) { - startServer := func(addr string, service service) (*rpc.Server, net.Listener) { - srv := newTestL1Client(service) - var lc net.ListenConfig - l, err := lc.Listen(t.Context(), "tcp", addr) - if err != nil { - t.Fatal("can't listen:", err) - } - go func() { - _ = http.Serve(l, srv.WebsocketHandler([]string{"*"})) - }() - return srv, l - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(t.Context(), 12*time.Second) - defer cancel() - - server, listener := startServer("127.0.0.1:0", test.service) - defer server.Stop() - - subscriber, err := l1.NewEthSubscriber("ws://"+listener.Addr().String(), eth.Address{}) - require.NoError(t, err) - defer subscriber.Close() - - height, err := heightFunc(subscriber, ctx) - require.Equal(t, test.expectedHeight, height) - require.Equal(t, test.expectedError, err != nil) - }) - } -} diff --git a/l1/settlement.go b/l1/settlement.go new file mode 100644 index 0000000000..e942323dc5 --- /dev/null +++ b/l1/settlement.go @@ -0,0 +1,51 @@ +package l1 + +import ( + "context" + "math/big" + + "github.com/NethermindEth/juno/core/felt" + "github.com/NethermindEth/juno/l1/eth" +) + +// SettlementLayer is the chain-neutral interface l1.Client consumes to +// follow whatever layer Starknet settles to. Today the only +// implementation lives in l1/eth/client, talking to an Ethereum +// execution-layer node; the abstraction exists so a future settlement +// backend (Bitcoin, Celestia, an alt-L1) needs only this interface, +// without dragging Ethereum types through the L1 sync loop. +// +// L1 is in the interface name today for parity with the legacy +// Subscriber it replaces; the methods themselves are chain-agnostic. +// +//go:generate mockgen -destination=../mocks/mock_settlement_layer.go -package=mocks github.com/NethermindEth/juno/l1 SettlementLayer +type SettlementLayer interface { + ChainID(ctx context.Context) (*big.Int, error) + FinalisedHeight(ctx context.Context) (uint64, error) + LatestHeight(ctx context.Context) (uint64, error) + WatchStateUpdate(ctx context.Context, sink chan<- *StateUpdate) (eth.Subscription, error) + FilterStateUpdate(ctx context.Context, from, to uint64) ([]*StateUpdate, error) + Close() +} + +// StateUpdate is a settlement-layer-agnostic view of a Starknet +// LogStateUpdate-style event: the L2 head being committed, the +// settlement-layer position where the commit landed, and a reorg flag. +// felt.Felt conversion happens inside the settlement implementation so +// l1.Client never touches Ethereum-flavoured types. +type StateUpdate struct { + // L2BlockNumber is the Starknet block number being committed. + L2BlockNumber uint64 + // L2BlockHash is the Starknet block hash for that block. + L2BlockHash *felt.Felt + // StateRoot is the Starknet global state root after the block + // (the "globalRoot" field in the on-chain event). + StateRoot *felt.Felt + // L1RefHeight is the settlement-layer block number where the + // commit was observed. Used by the L1 sync loop to gate writes on + // settlement-layer finality. + L1RefHeight uint64 + // Removed is set when the settlement layer signals that the log + // was rolled back by a reorg. + Removed bool +} diff --git a/mocks/mock_settlement_layer.go b/mocks/mock_settlement_layer.go new file mode 100644 index 0000000000..1d717722e0 --- /dev/null +++ b/mocks/mock_settlement_layer.go @@ -0,0 +1,131 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/NethermindEth/juno/l1 (interfaces: SettlementLayer) +// +// Generated by this command: +// +// mockgen -destination=mocks/mock_settlement_layer.go -package=mocks github.com/NethermindEth/juno/l1 SettlementLayer +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + big "math/big" + reflect "reflect" + + l1 "github.com/NethermindEth/juno/l1" + eth "github.com/NethermindEth/juno/l1/eth" + gomock "go.uber.org/mock/gomock" +) + +// MockSettlementLayer is a mock of SettlementLayer interface. +type MockSettlementLayer struct { + ctrl *gomock.Controller + recorder *MockSettlementLayerMockRecorder + isgomock struct{} +} + +// MockSettlementLayerMockRecorder is the mock recorder for MockSettlementLayer. +type MockSettlementLayerMockRecorder struct { + mock *MockSettlementLayer +} + +// NewMockSettlementLayer creates a new mock instance. +func NewMockSettlementLayer(ctrl *gomock.Controller) *MockSettlementLayer { + mock := &MockSettlementLayer{ctrl: ctrl} + mock.recorder = &MockSettlementLayerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSettlementLayer) EXPECT() *MockSettlementLayerMockRecorder { + return m.recorder +} + +// ChainID mocks base method. +func (m *MockSettlementLayer) ChainID(ctx context.Context) (*big.Int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ChainID", ctx) + ret0, _ := ret[0].(*big.Int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ChainID indicates an expected call of ChainID. +func (mr *MockSettlementLayerMockRecorder) ChainID(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChainID", reflect.TypeOf((*MockSettlementLayer)(nil).ChainID), ctx) +} + +// Close mocks base method. +func (m *MockSettlementLayer) Close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Close") +} + +// Close indicates an expected call of Close. +func (mr *MockSettlementLayerMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockSettlementLayer)(nil).Close)) +} + +// FilterStateUpdate mocks base method. +func (m *MockSettlementLayer) FilterStateUpdate(ctx context.Context, from, to uint64) ([]*l1.StateUpdate, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FilterStateUpdate", ctx, from, to) + ret0, _ := ret[0].([]*l1.StateUpdate) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FilterStateUpdate indicates an expected call of FilterStateUpdate. +func (mr *MockSettlementLayerMockRecorder) FilterStateUpdate(ctx, from, to any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilterStateUpdate", reflect.TypeOf((*MockSettlementLayer)(nil).FilterStateUpdate), ctx, from, to) +} + +// FinalisedHeight mocks base method. +func (m *MockSettlementLayer) FinalisedHeight(ctx context.Context) (uint64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FinalisedHeight", ctx) + ret0, _ := ret[0].(uint64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FinalisedHeight indicates an expected call of FinalisedHeight. +func (mr *MockSettlementLayerMockRecorder) FinalisedHeight(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinalisedHeight", reflect.TypeOf((*MockSettlementLayer)(nil).FinalisedHeight), ctx) +} + +// LatestHeight mocks base method. +func (m *MockSettlementLayer) LatestHeight(ctx context.Context) (uint64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LatestHeight", ctx) + ret0, _ := ret[0].(uint64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LatestHeight indicates an expected call of LatestHeight. +func (mr *MockSettlementLayerMockRecorder) LatestHeight(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestHeight", reflect.TypeOf((*MockSettlementLayer)(nil).LatestHeight), ctx) +} + +// WatchStateUpdate mocks base method. +func (m *MockSettlementLayer) WatchStateUpdate(ctx context.Context, sink chan<- *l1.StateUpdate) (eth.Subscription, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "WatchStateUpdate", ctx, sink) + ret0, _ := ret[0].(eth.Subscription) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// WatchStateUpdate indicates an expected call of WatchStateUpdate. +func (mr *MockSettlementLayerMockRecorder) WatchStateUpdate(ctx, sink any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchStateUpdate", reflect.TypeOf((*MockSettlementLayer)(nil).WatchStateUpdate), ctx, sink) +} diff --git a/mocks/mock_subscriber.go b/mocks/mock_subscriber.go deleted file mode 100644 index 76a8281aaf..0000000000 --- a/mocks/mock_subscriber.go +++ /dev/null @@ -1,148 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: github.com/NethermindEth/juno/l1 (interfaces: Subscriber) -// -// Generated by this command: -// -// mockgen -destination=../mocks/mock_subscriber.go -package=mocks github.com/NethermindEth/juno/l1 Subscriber -// - -// Package mocks is a generated GoMock package. -package mocks - -import ( - context "context" - big "math/big" - reflect "reflect" - - contract "github.com/NethermindEth/juno/l1/contract" - common "github.com/ethereum/go-ethereum/common" - types "github.com/ethereum/go-ethereum/core/types" - event "github.com/ethereum/go-ethereum/event" - gomock "go.uber.org/mock/gomock" -) - -// MockSubscriber is a mock of Subscriber interface. -type MockSubscriber struct { - ctrl *gomock.Controller - recorder *MockSubscriberMockRecorder - isgomock struct{} -} - -// MockSubscriberMockRecorder is the mock recorder for MockSubscriber. -type MockSubscriberMockRecorder struct { - mock *MockSubscriber -} - -// NewMockSubscriber creates a new mock instance. -func NewMockSubscriber(ctrl *gomock.Controller) *MockSubscriber { - mock := &MockSubscriber{ctrl: ctrl} - mock.recorder = &MockSubscriberMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSubscriber) EXPECT() *MockSubscriberMockRecorder { - return m.recorder -} - -// ChainID mocks base method. -func (m *MockSubscriber) ChainID(ctx context.Context) (*big.Int, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ChainID", ctx) - ret0, _ := ret[0].(*big.Int) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ChainID indicates an expected call of ChainID. -func (mr *MockSubscriberMockRecorder) ChainID(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChainID", reflect.TypeOf((*MockSubscriber)(nil).ChainID), ctx) -} - -// Close mocks base method. -func (m *MockSubscriber) Close() { - m.ctrl.T.Helper() - m.ctrl.Call(m, "Close") -} - -// Close indicates an expected call of Close. -func (mr *MockSubscriberMockRecorder) Close() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockSubscriber)(nil).Close)) -} - -// FilterLogStateUpdate mocks base method. -func (m *MockSubscriber) FilterLogStateUpdate(ctx context.Context, fromBlock, toBlock uint64) ([]*contract.StarknetLogStateUpdate, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FilterLogStateUpdate", ctx, fromBlock, toBlock) - ret0, _ := ret[0].([]*contract.StarknetLogStateUpdate) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// FilterLogStateUpdate indicates an expected call of FilterLogStateUpdate. -func (mr *MockSubscriberMockRecorder) FilterLogStateUpdate(ctx, fromBlock, toBlock any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FilterLogStateUpdate", reflect.TypeOf((*MockSubscriber)(nil).FilterLogStateUpdate), ctx, fromBlock, toBlock) -} - -// FinalisedHeight mocks base method. -func (m *MockSubscriber) FinalisedHeight(ctx context.Context) (uint64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FinalisedHeight", ctx) - ret0, _ := ret[0].(uint64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// FinalisedHeight indicates an expected call of FinalisedHeight. -func (mr *MockSubscriberMockRecorder) FinalisedHeight(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinalisedHeight", reflect.TypeOf((*MockSubscriber)(nil).FinalisedHeight), ctx) -} - -// LatestHeight mocks base method. -func (m *MockSubscriber) LatestHeight(ctx context.Context) (uint64, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LatestHeight", ctx) - ret0, _ := ret[0].(uint64) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// LatestHeight indicates an expected call of LatestHeight. -func (mr *MockSubscriberMockRecorder) LatestHeight(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestHeight", reflect.TypeOf((*MockSubscriber)(nil).LatestHeight), ctx) -} - -// TransactionReceipt mocks base method. -func (m *MockSubscriber) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "TransactionReceipt", ctx, txHash) - ret0, _ := ret[0].(*types.Receipt) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// TransactionReceipt indicates an expected call of TransactionReceipt. -func (mr *MockSubscriberMockRecorder) TransactionReceipt(ctx, txHash any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TransactionReceipt", reflect.TypeOf((*MockSubscriber)(nil).TransactionReceipt), ctx, txHash) -} - -// WatchLogStateUpdate mocks base method. -func (m *MockSubscriber) WatchLogStateUpdate(ctx context.Context, sink chan<- *contract.StarknetLogStateUpdate) (event.Subscription, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "WatchLogStateUpdate", ctx, sink) - ret0, _ := ret[0].(event.Subscription) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// WatchLogStateUpdate indicates an expected call of WatchLogStateUpdate. -func (mr *MockSubscriberMockRecorder) WatchLogStateUpdate(ctx, sink any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchLogStateUpdate", reflect.TypeOf((*MockSubscriber)(nil).WatchLogStateUpdate), ctx, sink) -} diff --git a/node/metrics.go b/node/metrics.go index 2b59215b25..e9d5df2f2c 100644 --- a/node/metrics.go +++ b/node/metrics.go @@ -235,7 +235,7 @@ func makeBlockchainMetrics() blockchain.EventListener { } } -func makeL1Metrics(bcReader blockchain.Reader, l1Subscriber l1.Subscriber) l1.EventListener { +func makeL1Metrics(bcReader blockchain.Reader, l1Subscriber l1.SettlementLayer) l1.EventListener { l2BlockFinalizedOnL1 := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ Namespace: "l1", Name: "l2_finalised_height", diff --git a/node/metrics_test.go b/node/metrics_test.go index b65bc3bb9a..0520dbeed4 100644 --- a/node/metrics_test.go +++ b/node/metrics_test.go @@ -40,7 +40,7 @@ func TestMakeL1Metrics(t *testing.T) { t.Run("successful metric reporting", func(t *testing.T) { ctrl := gomock.NewController(t) mockBCReader := mocks.NewMockReader(ctrl) - mockSubscriber := mocks.NewMockSubscriber(ctrl) + mockSubscriber := mocks.NewMockSettlementLayer(ctrl) reg := prometheus.NewRegistry() prometheus.DefaultRegisterer = reg @@ -64,7 +64,7 @@ func TestMakeL1Metrics(t *testing.T) { t.Run("error in metric reporting", func(t *testing.T) { ctrl := gomock.NewController(t) mockBCReader := mocks.NewMockReader(ctrl) - mockSubscriber := mocks.NewMockSubscriber(ctrl) + mockSubscriber := mocks.NewMockSettlementLayer(ctrl) reg := prometheus.NewRegistry() prometheus.DefaultRegisterer = reg diff --git a/node/migration.go b/node/migration.go index 9d9a1fa319..7ad5fe8281 100644 --- a/node/migration.go +++ b/node/migration.go @@ -8,6 +8,7 @@ import ( "github.com/NethermindEth/juno/blockchain" "github.com/NethermindEth/juno/core" "github.com/NethermindEth/juno/db" + "github.com/NethermindEth/juno/l1" "github.com/NethermindEth/juno/migration" "github.com/NethermindEth/juno/migration/blocktransactions" "github.com/NethermindEth/juno/migration/deprecated" //nolint:staticcheck,nolintlint,lll // ignore statick check package will be removed in future, nolinlint because main config does not check @@ -106,12 +107,13 @@ func fetchL1HeadIfMissing( logger.Info("Fetching the L1 head before running the prune migration") // Metrics are registered by the long-lived L1 client built in node.New; reusing - // them here would panic via prometheus.MustRegister. - client, err := newL1Client(config.EthNode, false, chain, logger) + // them here would panic via prometheus.MustRegister. Hence no listener. + settlement, err := newSettlement(config.L1Client, config.EthNode, chain, logger) if err != nil { return fmt.Errorf("creating a new L1 client: %w", err) } + client := l1.NewClient(settlement, chain, logger) if err := client.CatchUpL1Head(ctx); err != nil { return fmt.Errorf("catching up to the latest L1 head: %w", err) } diff --git a/node/node.go b/node/node.go index 7225bff128..771debf3b2 100644 --- a/node/node.go +++ b/node/node.go @@ -79,6 +79,7 @@ type Config struct { Network networks.Network `mapstructure:"network"` EthNode string `mapstructure:"eth-node"` DisableL1Verification bool `mapstructure:"disable-l1-verification"` + L1Client string `mapstructure:"l1-client"` Pprof bool `mapstructure:"pprof"` PprofHost string `mapstructure:"pprof-host"` PprofPort uint16 `mapstructure:"pprof-port"` @@ -533,7 +534,8 @@ func New(cfg *Config, version string, logLevel *log.Level) (*Node, error) { ) } if cfg.Websocket { - services = append(services, + services = append( + services, makeRPCOverWebsocket( cfg.WebsocketHost, cfg.WebsocketPort, @@ -545,8 +547,12 @@ func New(cfg *Config, version string, logLevel *log.Level) (*Node, error) { ) } if cfg.HTTPUpdatePort != 0 { - logger.Info("Log level and feeder gateway timeouts can be changed via HTTP PUT request to " + - cfg.HTTPUpdateHost + ":" + fmt.Sprintf("%d", cfg.HTTPUpdatePort) + "/log/level and /feeder/timeouts", + logger.Info( + "Log level and feeder gateway timeouts can be changed via HTTP PUT request to " + + cfg.HTTPUpdateHost + + ":" + + fmt.Sprintf("%d", cfg.HTTPUpdatePort) + + "/log/level and /feeder/timeouts", ) earlyServices = append(earlyServices, makeHTTPUpdateService(cfg.HTTPUpdateHost, cfg.HTTPUpdatePort, logLevel, client)) } @@ -596,20 +602,35 @@ func New(cfg *Config, version string, logLevel *log.Level) (*Node, error) { return nil, fmt.Errorf("ethereum node address not found; Use --disable-l1-verification flag if L1 verification is not required") } - var l1Client *l1.Client - l1Client, err = newL1Client(cfg.EthNode, cfg.Metrics, n.blockchain, n.logger) + settlement, err := newSettlement(cfg.L1Client, cfg.EthNode, n.blockchain, n.logger) if err != nil { return nil, fmt.Errorf("create L1 client: %w", err) } + n.logger.Info("Selected L1 client", zap.String("client", cfg.L1Client)) + + // One EventListener is shared between the L1 client (which + // fires OnNewL1Head) and the settlement (which fires OnL1Call + // for every Ethereum RPC method). Metrics are registered only + // when the node is built with --metrics; otherwise the default + // no-op SelectiveListener is used. + l1Opts := []l1.Option{} + if cfg.Metrics { + listener := makeL1Metrics(n.blockchain, settlement) + settlement.SetListener(listener) + l1Opts = append(l1Opts, l1.WithEventListener(listener)) + } + + l1Client := l1.NewClient(settlement, n.blockchain, n.logger, l1Opts...) n.services = append(n.services, l1Client) - rpcHandler.WithL1Client(&rpccore.EthReceiptAdapter{Sub: l1Client.L1()}) + rpcHandler.WithL1Client(settlement) } if semversion, err := semver.NewVersion(version); err == nil { ug := upgrader.NewUpgrader(semversion, githubAPIUrl, latestReleaseURL, upgraderDelay, n.logger) n.services = append(n.services, ug) } else { - logger.Warn("Failed to parse Juno version, will not warn about new releases", + logger.Warn( + "Failed to parse Juno version, will not warn about new releases", zap.String("version", version), ) } @@ -617,9 +638,27 @@ func New(cfg *Config, version string, logLevel *log.Level) (*Node, error) { return n, nil } -func newL1Client( - ethNode string, includeMetrics bool, chain *blockchain.Blockchain, log log.StructuredLogger, -) (*l1.Client, error) { +// l1Settlement is the cross-section of capabilities a concrete L1 +// settlement implementation must provide to the node: SettlementLayer +// for the sync loop, rpccore.L1Client for the RPC handlers, plus +// post-construction listener attachment. +type l1Settlement interface { + l1.SettlementLayer + rpccore.L1Client + SetListener(l1.EventListener) +} + +// newSettlement validates the Ethereum endpoint URL and dials the L1 +// client using the implementation selected by --l1-client. ws/wss is +// enforced at the URL level because subscribe-based log delivery +// (eth_subscribe) requires a long-lived connection that HTTP doesn't +// provide. The listener is attached separately by the caller (after +// metrics gauges are wired against the same instance). +func newSettlement( + client, ethNode string, + chain *blockchain.Blockchain, + logger log.StructuredLogger, +) (l1Settlement, error) { ethNodeURL, err := url.Parse(ethNode) if err != nil { return nil, fmt.Errorf("parse Ethereum node URL: %w", err) @@ -628,19 +667,42 @@ func newL1Client( return nil, errors.New("non-websocket Ethereum node URL (need wss://... or ws://...): " + ethNode) } - network := chain.Network() - - var ethSubscriber *l1.EthSubscriber - ethSubscriber, err = l1.NewEthSubscriber(ethNode, network.CoreContractAddress) - if err != nil { - return nil, fmt.Errorf("set up ethSubscriber: %w", err) - } + // Dial has its own one-minute timeout; this is not the node's + // lifetime context (which doesn't exist at construction time). + dialCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() - opts := make([]l1.Option, 0, 1) - if includeMetrics { - opts = append(opts, l1.WithEventListener(makeL1Metrics(chain, ethSubscriber))) + contractAddress := chain.Network().CoreContractAddress + + // Empty string matches the geth default; this fallback covers config + // paths that bypass cmd flag parsing (e.g. the migration entry point + // in node/migration.go) and never populate L1Client. + switch client { + case "", "geth": + s, err := l1.NewGethSettlement( + dialCtx, + ethNode, + contractAddress, + l1.WithSettlementLogger(logger), + ) + if err != nil { + return nil, fmt.Errorf("set up L1 settlement client (geth): %w", err) + } + return s, nil + case "juno": + s, err := l1.NewEthSettlement( + dialCtx, + ethNode, + contractAddress, + l1.WithSettlementLogger(logger), + ) + if err != nil { + return nil, fmt.Errorf("set up L1 settlement client (juno): %w", err) + } + return s, nil + default: + return nil, fmt.Errorf("invalid --l1-client %q (must be %q or %q)", client, "geth", "juno") } - return l1.NewClient(ethSubscriber, chain, log, opts...), nil } // Run starts Juno node by opening the DB, initialising services. diff --git a/rpc/rpccore/eth_receipt_adapter.go b/rpc/rpccore/eth_receipt_adapter.go deleted file mode 100644 index 8831aa6e4f..0000000000 --- a/rpc/rpccore/eth_receipt_adapter.go +++ /dev/null @@ -1,58 +0,0 @@ -// TODO(remove once migrated from geth): once l1.Subscriber.TransactionReceipt returns -// *eth.Receipt directly, the adapter and this whole file go away. -package rpccore - -import ( - "context" - "errors" - - "github.com/NethermindEth/juno/l1/eth" - gethCommon "github.com/ethereum/go-ethereum/common" - gethTypes "github.com/ethereum/go-ethereum/core/types" -) - -// gethTxReceiptFetcher matches the subset of l1.Subscriber that the -// RPC layer cares about. Declared structurally to avoid pulling the -// whole l1 package into the rpccore dependency graph. -type gethTxReceiptFetcher interface { - TransactionReceipt(ctx context.Context, txHash gethCommon.Hash) (*gethTypes.Receipt, error) -} - -// EthReceiptAdapter wraps an l1.Subscriber (returns *geth/types.Receipt) -// and exposes the L1Client interface (returns *eth.Receipt). It exists -// only because the L1 subscriber interface still speaks geth types. -type EthReceiptAdapter struct { - Sub gethTxReceiptFetcher -} - -// Statically assert the adapter satisfies L1Client. -var _ L1Client = (*EthReceiptAdapter)(nil) - -func (a *EthReceiptAdapter) TransactionReceipt( - ctx context.Context, txHash eth.Hash, -) (*eth.Receipt, error) { - r, err := a.Sub.TransactionReceipt(ctx, gethCommon.Hash(txHash)) - if err != nil { - return nil, err - } - if r == nil { - return nil, errors.New("transaction receipt not found") - } - out := ð.Receipt{Logs: make([]eth.Log, 0, len(r.Logs))} - for _, gl := range r.Logs { - if gl == nil { - continue - } - topics := make([]eth.Hash, len(gl.Topics)) - for i, t := range gl.Topics { - topics[i] = eth.Hash(t) - } - out.Logs = append(out.Logs, eth.Log{ - Topics: topics, - Data: eth.DataBytes(gl.Data), - BlockNumber: eth.HexU64(gl.BlockNumber), - Removed: gl.Removed, - }) - } - return out, nil -} diff --git a/rpc/rpccore/eth_receipt_adapter_test.go b/rpc/rpccore/eth_receipt_adapter_test.go deleted file mode 100644 index 6ac2856829..0000000000 --- a/rpc/rpccore/eth_receipt_adapter_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package rpccore_test - -import ( - "context" - "errors" - "testing" - - "github.com/NethermindEth/juno/l1/eth" - "github.com/NethermindEth/juno/rpc/rpccore" - gethCommon "github.com/ethereum/go-ethereum/common" - gethTypes "github.com/ethereum/go-ethereum/core/types" - "github.com/stretchr/testify/require" -) - -// fakeGethFetcher is a hand-rolled implementation of gethTxReceiptFetcher -// for adapter tests. Using a real mock would require generating one for a -// private interface; this struct is simpler and clearer at the call site. -type fakeGethFetcher struct { - wantHash gethCommon.Hash - receipt *gethTypes.Receipt - err error -} - -func (f *fakeGethFetcher) TransactionReceipt( - _ context.Context, txHash gethCommon.Hash, -) (*gethTypes.Receipt, error) { - f.wantHash = txHash - return f.receipt, f.err -} - -func TestEthReceiptAdapter_TransactionReceipt_ConvertsAllFields(t *testing.T) { - txHash := eth.HashFromString("0x1111111111111111111111111111111111111111111111111111111111111111") - - gethReceipt := &gethTypes.Receipt{Logs: []*gethTypes.Log{ - { - Topics: []gethCommon.Hash{ - gethCommon.HexToHash("0xaaaa000000000000000000000000000000000000000000000000000000000001"), - gethCommon.HexToHash("0xbbbb000000000000000000000000000000000000000000000000000000000002"), - }, - Data: []byte{0xde, 0xad, 0xbe, 0xef}, - BlockNumber: 0x12345, - Removed: false, - // Fields not consumed by juno — must be ignored by the adapter: - Address: gethCommon.HexToAddress("0xc0ffee0000000000000000000000000000000001"), - TxHash: gethCommon.HexToHash("0xfeedbeef"), - Index: 7, - }, - { - Topics: nil, - Data: nil, - BlockNumber: 0x12346, - Removed: true, - }, - }} - - fetcher := &fakeGethFetcher{receipt: gethReceipt} - adapter := &rpccore.EthReceiptAdapter{Sub: fetcher} - - got, err := adapter.TransactionReceipt(t.Context(), txHash) - require.NoError(t, err) - - // txHash crossed the boundary correctly. - require.Equal(t, gethCommon.Hash(txHash), fetcher.wantHash) - - require.Len(t, got.Logs, 2) - - // Log 0: every consumed field round-trips. - require.Equal(t, []eth.Hash{ - eth.HashFromString("0xaaaa000000000000000000000000000000000000000000000000000000000001"), - eth.HashFromString("0xbbbb000000000000000000000000000000000000000000000000000000000002"), - }, got.Logs[0].Topics) - require.Equal(t, eth.DataBytes{0xde, 0xad, 0xbe, 0xef}, got.Logs[0].Data) - require.Equal(t, eth.HexU64(0x12345), got.Logs[0].BlockNumber) - require.False(t, got.Logs[0].Removed) - - // Log 1: nil Topics and nil Data survive; Removed=true propagates. - require.Empty(t, got.Logs[1].Topics) - require.Empty(t, got.Logs[1].Data) - require.Equal(t, eth.HexU64(0x12346), got.Logs[1].BlockNumber) - require.True(t, got.Logs[1].Removed) -} - -func TestEthReceiptAdapter_TransactionReceipt_EmptyLogs(t *testing.T) { - fetcher := &fakeGethFetcher{receipt: &gethTypes.Receipt{Logs: nil}} - adapter := &rpccore.EthReceiptAdapter{Sub: fetcher} - - got, err := adapter.TransactionReceipt(t.Context(), eth.Hash{}) - require.NoError(t, err) - require.NotNil(t, got) - require.Empty(t, got.Logs) -} - -func TestEthReceiptAdapter_TransactionReceipt_NilReceiptReturnsError(t *testing.T) { - fetcher := &fakeGethFetcher{receipt: nil} - adapter := &rpccore.EthReceiptAdapter{Sub: fetcher} - - got, err := adapter.TransactionReceipt(t.Context(), eth.Hash{}) - require.Nil(t, got) - require.Error(t, err) -} - -func TestEthReceiptAdapter_TransactionReceipt_PropagatesError(t *testing.T) { - sentinel := errors.New("rpc upstream down") - fetcher := &fakeGethFetcher{err: sentinel} - adapter := &rpccore.EthReceiptAdapter{Sub: fetcher} - - got, err := adapter.TransactionReceipt(t.Context(), eth.Hash{}) - require.Nil(t, got) - require.ErrorIs(t, err, sentinel) -}