Skip to content

feat(l1): [5] add EthL1StateProvider backed by the ws client - #3873

Draft
brbrr wants to merge 1 commit into
feat/l1-ws-clientfrom
feat/l1-eth-provider
Draft

feat(l1): [5] add EthL1StateProvider backed by the ws client#3873
brbrr wants to merge 1 commit into
feat/l1-ws-clientfrom
feat/l1-eth-provider

Conversation

@brbrr

@brbrr brbrr commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@brbrr
brbrr marked this pull request as draft July 28, 2026 09:02
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 5m 47s —— View job


Review summary

  • Gather context (base branch ws client, geth provider, l1.Client consumers)
  • Review l1/eth/contract/starknet.go
  • Review l1/eth_l1_state_provider.go
  • Review tests
  • Post inline comments

Solid piece of work — the lifecycle handling is careful and I could not find a correctness bug in it. Specifically, I traced and found no issue with: the Close/redial race (the post-dial s.closed re-check at :136-142 correctly avoids leaking the fresh client), double-Close of a stale client (wsTransport.close is closeOnce-guarded), forwarder goroutine leaks (receiveL1StateUpdates has a deferred Unsubscribe, and wsLogSub.dispatch always exits via closed), or the inner-error path not calling inner.Unsubscribe() (the transport self-cleans via removeSubunsubscribeInBackground on both overflow and inner decode failure). The LogStateUpdate sig hash matches the abigen binding, and the [32:56]-must-be-zero check correctly rejects the int256 negative/overflow cases.

Findings below, all posted inline. Nothing here is a blocker.

Important

eth_l1_state_provider.go:103-146 redial only coalesces on success. A failed dial leaves s.client == stale, so each queued caller starts another full 60s redialTimeout serially — caller N waits N × 60s. Reaches the RPC path via TransactionReceipt/starknet_getMessageStatus.
eth_l1_state_provider.go:132 Redial failure logged at Trace. Now that Juno owns reconnection, a total loss of L1 connectivity is invisible at default levels.
contract/starknet.go:60-61 felt.SetBytes silently reduces a uint256 mod p, so an out-of-range globalRoot/blockHash aliases to a different felt instead of being rejected — inconsistent with the explicit blockNumber range check two lines up. SetBytesCanonical fixes it (note: deliberate divergence from the geth path).

Test coverage

eth_l1_state_provider_test.go:249-257 require.NotNil on felt.Felt values is vacuous. Combined with stateUpdateLogJSON writing blockHash=0, transposing GlobalRoot/BlockHash in stateUpdateFromContract — swapping state root and block hash on every L1 head — would keep the suite green.
eth_l1_state_provider_test.go:231-239 Nothing asserts the eth_getLogs request params. Swapped FromBlock/ToBlock, a dropped address, or decimal-instead-of-hex encoding would all pass — and that hand-rolled query construction is the main thing this PR replaces.
eth_l1_state_provider_internal_test.go The coalescing invariant is untested: deleting the s.client != stale fast path breaks nothing. Also, only ChainID exercises withRetryOnClosed; WatchStateUpdate's retry (re-subscribing on the same raw channel against a new client) is the interesting uncovered one.

Nits

eth_l1_state_provider.go:223-233 WatchStateUpdate doesn't wrap its error (geth version did) and is the only method with no observe.
eth_l1_state_provider.go:180-189 FinalisedHeight propagates a bare eth.ErrNotFound — operators see just "not found".

Static review only — go build ./l1/... and go vet ./l1/... pass, but go test -race ./l1/... needed approval I don't have in this environment, so I could not execute the suite.
• branch feat/l1-eth-provider

Comment thread l1/eth_l1_state_provider.go Outdated
Comment on lines +106 to +134
select {
case s.dialSem <- struct{}{}:
case <-ctx.Done():
return nil, ctx.Err()
}
defer func() { <-s.dialSem }()

s.clientMu.Lock()
if s.closed {
s.clientMu.Unlock()
return nil, ErrClosed
}
if s.client != stale {
// Someone else won the race; their fresh client is current.
c := s.client
s.clientMu.Unlock()
return c, nil
}
s.clientMu.Unlock()

stale.Close()
s.logger.Info("L1 transport closed; redialing")
dialCtx, cancel := context.WithTimeout(ctx, redialTimeout)
defer cancel()
c, err := client.New(dialCtx, s.url, s.clientOpts...)
if err != nil {
s.logger.Trace("L1 redial failed", zap.Error(err))
return nil, fmt.Errorf("redialing L1: %w", err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — failed dials aren't remembered, so callers queue up N × 60s

The coalescing only works when a dial succeeds. On failure s.client is left pointing at stale, so the next caller that acquires dialSem re-evaluates s.client != stale as false and starts another full redialTimeout (60s) dial:

  • caller A takes the sem, dials 60s, fails, releases
  • caller B has been blocked on s.dialSem <- struct{}{} that whole time, now takes it and dials another 60s
  • caller C waits 120s before even starting…

During a sustained L1 outage every queued caller serialises behind a fresh minute-long dial. That includes TransactionReceipt, which rpccore.L1Client calls from the starknet_getMessageStatus handler — so RPC requests can hang for minutes rather than failing fast (the caller's own ctx deadline is the only bound, and redial doesn't shorten it).

Worth recording the last failure under clientMu and short-circuiting subsequent callers within a small backoff window, so a burst of callers shares one dial attempt and its outcome:

lastDialErr  error
lastDialFail time.Time

…checked alongside the s.closed / s.client != stale conditions at line 113.

Fix this →

Comment thread l1/eth_l1_state_provider.go Outdated
defer cancel()
c, err := client.New(dialCtx, s.url, s.clientOpts...)
if err != nil {
s.logger.Trace("L1 redial failed", zap.Error(err))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — a failed redial is invisible at default log levels

Trace hides this. Previously reconnection was go-ethereum's problem; now that Juno owns it, a failing redial means the node has lost L1 connectivity entirely and the operator gets no signal (the only other line, "L1 transport closed; redialing" at :127, is Info and logs the attempt, not the outcome). Suggest Warn, matching how l1.Client.ensureChainID logs its retry loop.

Suggested change
s.logger.Trace("L1 redial failed", zap.Error(err))
s.logger.Warn("L1 redial failed", zap.Error(err))

Comment thread l1/eth/contract/starknet.go Outdated
Comment on lines +60 to +61
ev.GlobalRoot.SetBytes(log.Data[0:32])
ev.BlockHash.SetBytes(log.Data[64:96])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — SetBytes silently reduces mod p, which is inconsistent with the rest of this decoder

globalRoot and blockHash are uint256 on-chain but felt.Felt here. felt.SetBytes reduces modulo the STARK prime and cannot fail, so a 256-bit value ≥ p silently aliases to a different felt rather than being rejected. StateRoot ends up in core.L1Head and is served over RPC, so a silent alias is a correctness hazard, and this is untrusted input from whatever L1 endpoint the operator configured.

That sits oddly next to the defensive posture two lines up, where an out-of-range blockNumber is explicitly rejected rather than truncated. felt.SetBytesCanonical returns an error on non-canonical input and would make the three fields consistent:

	ev := &LogStateUpdate{
		BlockNumber: binary.BigEndian.Uint64(log.Data[56:64]),
		Raw:         *log,
	}
	if err := ev.GlobalRoot.SetBytesCanonical(log.Data[0:32]); err != nil {
		return nil, fmt.Errorf("globalRoot not a canonical felt: %w", err)
	}
	if err := ev.BlockHash.SetBytesCanonical(log.Data[64:96]); err != nil {
		return nil, fmt.Errorf("blockHash not a canonical felt: %w", err)
	}
	return ev, nil

Note this is a deliberate behaviour change vs. the geth path (stateUpdateFromGethContract uses SetBigInt, which also reduces silently) — flagging so it's a conscious call rather than an accident of the rewrite. If bug-for-bug parity during the migration is preferred, a comment saying so would help.

Comment on lines +249 to +257
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")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important — these two assertions are vacuous, and they're guarding the one mapping nothing else covers

StateUpdate.L2BlockHash and StateUpdate.StateRoot are felt.Felt values, not pointers (l1/l1_state_provider.go:16,19). require.NotNil on a non-nillable struct always passes, so lines 252-253 assert nothing.

That matters because stateUpdateFromContract (eth_l1_state_provider.go:268) is the only place GlobalRoot → StateRoot / BlockHash → L2BlockHash is wired up, and no test in the PR checks it. starknet_test.go:TestDecode_Success verifies the contract-level decode with distinct 0x11…/0x22… values, but if the two fields were transposed in stateUpdateFromContract — swapping the Starknet state root and block hash on every L1 head — the whole suite would still be green. stateUpdateLogJSON makes it worse: for blockNumber=7 it writes globalRoot=7 and blockHash=0, so the watch test wouldn't catch a swap either.

Give the two fields distinct, asserted values:

Suggested change
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")
assert.Equal(t, uint64(1), got[0].L2BlockNumber)
assert.Equal(t, uint64(1_000), got[0].L1RefHeight)
assert.False(t, got[0].Removed)
assert.Equal(t, "0x1", got[0].StateRoot.String())
assert.Equal(t, "0x0", got[0].L2BlockHash.String())
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")

(better still: change stateUpdateLogJSON to take independent globalRoot/blockHash args so the two can never collide.)

Fix this →

Comment on lines +231 to +239
srv.SetHandler(func(req clienttest.TestRequest) (any, *clienttest.TestRPCError) {
if req.Method == "eth_getLogs" {
return []any{
stateUpdateLogJSON(1, 1_000, false),
stateUpdateLogJSON(2, 1_001, true),
}, nil
}
return nil, &clienttest.TestRPCError{Code: -32601, Message: req.Method}
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test coverage — nothing asserts the eth_getLogs request is well-formed

The handler ignores req.Params and unconditionally returns logs, and contract's own tests use fakeLogClient, which also discards the query. So no test in the PR verifies that FilterStateUpdate actually sends the contract address, the LogStateUpdate topic filter, or the right fromBlock/toBlock.

That leaves the newly hand-rolled FilterQuery marshalling unguarded: swapping FromBlock/ToBlock in contract.FilterLogStateUpdate (starknet.go:88-89), dropping the address, or emitting the range as decimal instead of hex would all pass. Since the whole point of this PR is replacing abigen's query construction, that's the part most worth pinning down. All the tests also pass eth.Address{}, so an omitted address is indistinguishable from a zero one.

Suggest asserting inside the handler with a real address:

srv.SetHandler(func(req clienttest.TestRequest) (any, *clienttest.TestRPCError) {
	if req.Method == "eth_getLogs" {
		gotQuery = req.Params  // assert address / topics / 0x64 / 0xc8 after the call
		return []any{...}, nil
	}
	...
})

case <-time.After(time.Second):
t.Fatal("redial ignored ctx cancellation while another dial was in flight")
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test coverage — the coalescing invariant itself is untested

This covers ctx cancellation while a dial is in flight, which is good, but the documented contract of redial is "coalesces concurrent callers: losers wait on dialSem and pick up the winner's client without dialing again" — and nothing asserts that. The s.client != stale fast path at eth_l1_state_provider.go:118 could be deleted and the whole suite would stay green, while every caller would then redial independently and thrash the endpoint.

A white-box test in this file could fire N goroutines at redial(ctx, c0) after killing the conn and assert exactly one new connection was established (the clienttest server can count accepted conns), plus that all N receive the same *client.Client pointer.

Worth adding a retry test for at least one non-ChainID method too — FilterStateUpdate, TransactionReceipt and WatchStateUpdate all go through withRetryOnClosed but only ChainID exercises the redial path (eth_l1_state_provider_test.go:47, :75). WatchStateUpdate is the interesting one, since its retry re-subscribes on the same raw channel against a different client.

Fix this →

Comment on lines +223 to +233
func (s *EthL1StateProvider) WatchStateUpdate(
ctx context.Context,
sink chan<- *StateUpdate,
) (Subscription, error) {
raw := make(chan *eth.Log, watchForwarderBuffer)
inner, err := withRetryOnClosed(ctx, s, func(c *client.Client) (Subscription, error) {
return c.SubscribeLogs(ctx, contract.LogStateUpdateFilter(s.contractAddress), raw)
})
if err != nil {
return nil, err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — missing error wrap

Every other method's failure carries a call-site prefix, and the geth implementation wraps this one as fmt.Errorf("subscribing to LogStateUpdate: %w", err) (geth_l1_state_provider.go:175). Here a subscribe failure surfaces as a bare subscribing to logs: … from the client layer, with no indication it was the state-update watch. l1.Client.subscribeToUpdates logs this error on every retry, so the context is worth keeping.

Separately, WatchStateUpdate is the one L1StateProvider method with no s.observe(...) — the geth provider has the same gap, so this is parity rather than a regression, but if subscribe latency/failure rate is worth a metric, this is the place.

Comment on lines +180 to +189
func (s *EthL1StateProvider) 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 {
return 0, err
}
return uint64(h.Number), nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — the not-found error reaches callers with no context

client.HeaderByNumber returns a bare eth.ErrNotFound for a null result (l1/eth/client/client.go:87), not a wrapped one, so FinalisedHeight propagates the literal string "not found" with nothing identifying the call. The geth path produced "finalised block not found: not found" (geth_l1_state_provider.go:122). TestEthL1StateProvider_PreservesErrNotFound only checks errors.Is, so the lost context isn't visible in the suite.

	if err != nil {
		return 0, fmt.Errorf("getting finalised Ethereum block: %w", err)
	}

still satisfies errors.Is(err, eth.ErrNotFound) while restoring the operator-facing detail.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.12230% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.58%. Comparing base (52f029f) to head (bb4dce5).

Files with missing lines Patch % Lines
l1/eth_l1_state_provider.go 96.11% 4 Missing ⚠️
Additional details and impacted files
@@                  Coverage Diff                  @@
##           feat/l1-ws-client    #3873      +/-   ##
=====================================================
+ Coverage              75.49%   75.58%   +0.08%     
=====================================================
  Files                    448      450       +2     
  Lines                  40484    40623     +139     
=====================================================
+ Hits                   30563    30704     +141     
- Misses                  7797     7800       +3     
+ Partials                2124     2119       -5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@brbrr
brbrr force-pushed the feat/l1-eth-provider branch 2 times, most recently from c727702 to 05fe09e Compare July 28, 2026 14:33
@brbrr
brbrr force-pushed the feat/l1-ws-client branch from 026a54a to 311a8d4 Compare July 28, 2026 15:08
@brbrr
brbrr force-pushed the feat/l1-eth-provider branch 2 times, most recently from a102ccc to 6b9df44 Compare July 28, 2026 15:21
@brbrr
brbrr force-pushed the feat/l1-ws-client branch from 311a8d4 to b7686e2 Compare July 28, 2026 15:21
@brbrr brbrr changed the title feat(l1): add EthL1StateProvider backed by the ws client feat(l1): [5] add EthL1StateProvider backed by the ws client Jul 28, 2026
@brbrr
brbrr force-pushed the feat/l1-eth-provider branch from 6b9df44 to f1bff2a Compare July 28, 2026 17:31
@brbrr
brbrr force-pushed the feat/l1-ws-client branch from b7686e2 to 4a82034 Compare July 28, 2026 17:31
@brbrr
brbrr force-pushed the feat/l1-eth-provider branch from f1bff2a to 9e317a9 Compare July 28, 2026 18:51
@brbrr
brbrr force-pushed the feat/l1-ws-client branch from 4a82034 to 1dd9cf5 Compare July 28, 2026 18:51
@brbrr
brbrr force-pushed the feat/l1-eth-provider branch from 9e317a9 to 54e9f7a Compare July 28, 2026 18:54
@brbrr
brbrr force-pushed the feat/l1-ws-client branch 2 times, most recently from 0badb8c to bd117c2 Compare July 28, 2026 19:15
@brbrr
brbrr force-pushed the feat/l1-eth-provider branch from 54e9f7a to 15d5092 Compare July 28, 2026 19:15
@brbrr
brbrr force-pushed the feat/l1-eth-provider branch from 15d5092 to bb4dce5 Compare July 28, 2026 19:38
@brbrr
brbrr force-pushed the feat/l1-ws-client branch from bd117c2 to 52f029f Compare July 28, 2026 19:38
@brbrr brbrr added the no-stale label Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant