Skip to content

feat(l1): implement hand-rolled ws-based l1 client#3807

Open
brbrr wants to merge 7 commits into
mainfrom
feat/l1-hand-rolled-client-v2
Open

feat(l1): implement hand-rolled ws-based l1 client#3807
brbrr wants to merge 7 commits into
mainfrom
feat/l1-hand-rolled-client-v2

Conversation

@brbrr

@brbrr brbrr commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@brbrr brbrr self-assigned this Jul 9, 2026
@brbrr
brbrr force-pushed the feat/l1-hand-rolled-client-v2 branch from 8f5f35d to 7b248e1 Compare July 9, 2026 13:30
Base automatically changed from feat/l1-settlement-geth to main July 20, 2026 14:26
@brbrr
brbrr force-pushed the feat/l1-hand-rolled-client-v2 branch from 7b248e1 to 760d78f Compare July 22, 2026 14:22
Copilot AI review requested due to automatic review settings July 22, 2026 14:22

Copilot AI left a comment

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.

Pull request overview

This PR adds an alternative L1 backend that uses a hand-rolled WebSocket JSON-RPC client, plus a hand-decoded Starknet core contract event decoder, allowing Juno to run its L1 sync/RPC paths without relying on go-ethereum’s client stack.

Changes:

  • Add --l1-client / l1-client config to select between geth and juno L1 provider implementations.
  • Introduce a custom WS transport/client (unary + eth_subscribe) and a hand-decoded LogStateUpdate event pipeline.
  • Implement EthL1StateProvider with auto-redial on transport drops and comprehensive unit tests.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
node/node.go Adds L1Client config and selects between geth-based vs hand-rolled L1 providers.
node/migration.go Uses the selected L1 provider when fetching the L1 head for prune migrations; tweaks error strings.
l1/eth/contract/starknet.go Hand-decodes Starknet core contract LogStateUpdate events and provides filtering helpers.
l1/eth/contract/starknet_test.go Tests for signature hash, decoding correctness, and filtering behavior.
l1/eth/client/transport_ws.go Implements WS JSON-RPC transport with routing, subscriptions, and ping keep-alives.
l1/eth/client/transport_ws_test.go Transport-level tests for unary calls, subscriptions, ping behavior, and failure modes.
l1/eth/client/subscribe.go Subscription API mirroring go-ethereum semantics and per-subscription dispatch.
l1/eth/client/rpc.go JSON-RPC request/response types and helpers (id parsing, sub id decoding).
l1/eth/client/rpc_test.go Unit tests for JSON-RPC error formatting.
l1/eth/client/filter_query.go FilterQuery type with JSON marshaling tuned for eth_subscribe semantics.
l1/eth/client/clienttest/server.go Shared test WS/HTTP JSON-RPC server used by client/provider tests.
l1/eth/client/client.go High-level client methods (ChainID, block/header, logs, receipts) over WS transport.
l1/eth/client/client_test.go Method-level tests and FilterQuery wire-shape tests.
l1/eth_l1_state_provider.go New L1 state provider backed by the custom client, with auto-redial + subscription forwarding.
l1/eth_l1_state_provider_test.go Provider tests covering redial behavior, listener firing, filtering, watch delivery, and errors.
cmd/juno/juno.go Adds the --l1-client flag and usage text; wires it into categories.
cmd/juno/juno_test.go Extends config precedence expectations to include the L1Client default.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread l1/eth/client/transport_ws.go
Copilot AI review requested due to automatic review settings July 22, 2026 15:40
@brbrr
brbrr marked this pull request as ready for review July 22, 2026 15:41
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 6m 18s —— View job


Review: hand-rolled ws-based L1 client

  • Read core files (transport, client, subscribe, provider, contract decoder)
  • Analyze concurrency / correctness / resource leaks
  • Post inline comments for specific issues
  • Post summary grouped by severity

This is a well-structured PR. The transport's concurrency model is carefully thought through and the comments document the tricky invariants (cancel-vs-register races, single-shutdown fan-out, non-blocking reply delivery). I traced the goroutine lifecycles (readLoop, pingLoop, per-sub dispatch, unsubscribeInBackground, forwarder run) and found no leaks — every one is bounded by t.closed/sub.closed and CloseNow() unblocks the reader on shutdown. go vet is clean; the packages compile (only cmd/juno's final link fails here, due to missing Rust static libs in this environment — unrelated to the Go changes).

No blockers. A few things worth considering:

Important

  • Head-of-line blocking on the shared connection (transport_ws.go:270). readLoop dispatches notifications synchronously, so a full per-sub buffer blocks the reader and stalls all unary RPCs. Since one provider serves both the sync loop and the RPC TransactionReceipt path, a backed-up log sink can starve RPC handling. Documented as a caller contract, but geth's approach (fail the slow subscription rather than block the read loop) is worth weighing. Details inline.

Nits

  • Write failures aren't classified as ErrTransportClosed (transport_ws.go:386), so the call that first hits a dead conn won't auto-redial; recovery waits for the read side to notice. Inline.
  • Silent int256uint64 truncation of the LogStateUpdate block number (contract/starknet.go:57). Fine for the live contract; a zero-check on the upper bytes would fail loudly on malformed input. Inline.

On the earlier Copilot comment (pingLoop Ping without writeMu)

I agree with @brbrr — this is a non-issue for coder/websocket (v1.8.15). Unlike gorilla/websocket, coder serializes all frame writes (data and control frames, including Ping) through its internal frame-write lock, and Ping is explicitly documented as safe to call concurrently with the reader. No external writeMu guard is needed. The existing writeMu around writeJSON is fine (and harmless) as-is.

Nice touches

  • Cancel/register/shutdown interleavings are all handled and each ends with the server-side sub released — no orphaned subscriptions.
  • Redial is idempotent under concurrent callers (redial compares against stale), and Close is terminal vs. the transient transport error — good separation.
  • Tests avoid time.Sleep-based synchronization; the shared clienttest server is a clean fixture.
    • branch feat/l1-hand-rolled-client-v2

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.

Comment thread l1/eth/client/transport_ws.go
Comment thread l1/eth/client/transport_ws.go
Comment thread l1/eth/contract/starknet.go Outdated
Comment thread l1/eth_l1_state_provider.go Outdated
Comment thread l1/eth_l1_state_provider.go
Comment thread l1/eth_l1_state_provider.go Outdated
Comment thread l1/eth_l1_state_provider.go
Comment thread node/node.go Outdated
Comment thread node/node.go
Comment thread cmd/juno/juno.go
Copilot AI review requested due to automatic review settings July 23, 2026 12:34
@brbrr
brbrr force-pushed the feat/l1-hand-rolled-client-v2 branch from 85a2391 to fda1546 Compare July 23, 2026 12:34
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 3m 46s —— View job


Review: hand-rolled ws-based L1 client

  • Read core files (transport, client, subscribe, provider, contract decoder)
  • Analyze concurrency / correctness / resource leaks
  • Re-trace subscription lifecycle & redial paths
  • Post summary grouped by severity

I re-reviewed the full diff (17 files, go vet ./l1/eth/... clean). The current tree is unchanged since the last automated review — the same three commits, and the three earlier inline findings still apply verbatim. Rather than duplicate those threads, I'm summarizing them here plus what a fresh concurrency re-trace confirmed.

The transport's concurrency model holds up under close reading. I re-walked every cancel/register/shutdown interleaving in dispatchResponsecancelPendingregisterSub (all synchronized on t.mu, every path ends with the server-side sub released) and every goroutine lifecycle (readLoop, pingLoop, per-sub dispatch, unsubscribeInBackground, forwarder run) — all bounded by t.closed/sub.closed, and CloseNow() unblocks the reader on shutdown. No leaks, no new races found.

No blockers.

Important (standing)

  • Head-of-line blocking on the shared conntransport_ws.go:270-274. readLoop dispatches notifications synchronously; a full sub.logCh (buffer 64) blocks the single reader and stalls all in-flight unary RPCs. Since one EthL1StateProvider serves both the sync loop and the TransactionReceipt RPC path, a backed-up log sink can starve RPC handling. Documented as a caller contract, but geth's approach — fail the slow sub (ErrSubscriptionQueueOverflow) rather than block the reader — is worth weighing. Fix this →

Nits (standing)

  • Write failures aren't wrapped as ErrTransportClosedtransport_ws.go:388. writeJSON failure returns writing request: <err>, which withRetryOnClosed won't auto-redial on, so the call that first hits the dead conn fails without a redial (recovery waits for the read side to run shutdown). Not a correctness bug, but wrapping it would let auto-redial cover the triggering call too. Fix this →
  • Silent int256uint64 truncation of the LogStateUpdate block number — contract/starknet.go:58. Data[56:64] drops the upper 24 bytes. Correct for the live contract, but a malformed/adversarial log with non-zero upper bytes decodes to a silently wrong block number. A cheap check rejecting non-zero Data[0:56] would fail loudly. Fix this →

On the Copilot pingLoop comment

Agree with @brbrr — non-issue for coder/websocket (v1.8.15). It serializes all frame writes (data and control frames, including Ping) through its internal frame-write lock, and Ping is documented as safe to call concurrently with Reader. No external writeMu guard needed.

Note on the review-comment requests

@rodrodros's comments (doc rewrites, invert type decls, boolean --use-new-l1-client flag, split L1 code into node/l1.go) are code-quality asks directed at the author. I haven't implemented them — the trigger here didn't ask me to make changes, only to review. Happy to take any of them on if you tag me with an explicit "implement X".
• branch feat/l1-hand-rolled-client-v2

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

l1/eth/client/clienttest/server.go:97

  • KillWSConns doesn't accept a status code parameter, but the comment claims it severs connections "with the given status code". Update the comment to match the current API so callers don't go hunting for a missing argument.
// KillWSConns severs every live websocket connection with the given
// status code. Useful for testing client-side resubscribe behaviour.

Comment thread l1/eth/client/clienttest/server.go Outdated
Comment thread l1/eth/client/transport_ws.go Outdated
@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.24281% with 163 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.53%. Comparing base (3d05584) to head (77300d2).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
l1/eth/client/transport_ws.go 83.68% 34 Missing and 13 partials ⚠️
node/l1.go 16.07% 45 Missing and 2 partials ⚠️
l1/eth/client/clienttest/server.go 76.42% 21 Missing and 8 partials ⚠️
l1/eth_l1_state_provider.go 88.06% 16 Missing and 5 partials ⚠️
l1/eth/client/rpc.go 71.42% 3 Missing and 3 partials ⚠️
node/node.go 0.00% 6 Missing ⚠️
l1/eth/client/client.go 95.23% 2 Missing and 2 partials ⚠️
node/migration.go 33.33% 2 Missing ⚠️
l1/eth/client/subscribe.go 98.21% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3807      +/-   ##
==========================================
+ Coverage   75.42%   75.53%   +0.11%     
==========================================
  Files         440      451      +11     
  Lines       39605    40551     +946     
==========================================
+ Hits        29871    30632     +761     
- Misses       7662     7810     +148     
- Partials     2072     2109      +37     

☔ 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.

Copilot AI review requested due to automatic review settings July 23, 2026 21:34
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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


Review: hand-rolled ws-based L1 client (re-review)

  • Read core files (transport, client, subscribe, provider, contract decoder, node wiring)
  • Re-check the three standing findings
  • Analyze concurrency / correctness / resource leaks on the current tree
  • Post inline comment for the one remaining nit
  • go vet ./l1/eth/... ./l1/ ./node/ — clean

All three standing findings from the earlier reviews are now resolved, and the code-quality asks (@rodrodros, Copilot) have been implemented. I re-traced the full concurrency model against the current tree and found no new races or leaks.

Standing findings — all fixed

  • Head-of-line blocking (transport_ws.go:279-291) — dispatchNotification now does a non-blocking send and, on a full logCh, deletes the sub, calls sub.fail(ErrSubscriptionQueueOverflow), and unsubscribes in the background. The shared readLoop no longer stalls unary RPCs behind a slow subscriber. ✅
  • Write failures not classified as ErrTransportClosed (transport_ws.go:347) — writeJSON now wraps with %w: writing frame: %w against ErrTransportClosed, so the triggering call auto-redials via withRetryOnClosed. ✅
  • Silent int256uint64 truncation (contract/starknet.go:50-55) — Decode now rejects a non-zero Data[32:56] before taking Data[56:64], failing loudly on a malformed/oversized block number. ✅

Also addressed

  • Unbounded pendingSubs growth on cancelled subscribes (Copilot) — now split into orphanedSubs (an map[uint64]struct{}), so a cancelled subscribe no longer retains the full *wsLogSub. ✅
  • Boolean flag--l1-client geth|juno--use-new-l1-client bool (cmd/juno/juno.go), plus the config field UseNewL1Client. ✅
  • L1 wiring split out — provider selection moved from node/node.go (now −66 lines) into a sibling node/l1.go. ✅

Concurrency re-trace (no findings)

  • dispatchResponsecancelPendingregisterSub interleavings all synchronized on t.mu; every cancel/register/shutdown path ends with the server-side sub released.
  • Goroutine lifecycles (readLoop, pingLoop, per-sub dispatch, unsubscribeInBackground, forwarder run) are all bounded by t.closed / sub.closed; CloseNow() unblocks the reader on shutdown.
  • Forwarder ↔ inner-sub shutdown: when the inner sub self-terminates, run() returns without a redundant Unsubscribe; when the caller unsubscribes, w.closedinner.Unsubscribe unblocks the inner dispatch's send into w.raw. No leak either way.

Nit (optional, non-blocking)

  • Decode-error path in subscribe.go dispatch() leaves the sub in t.subs (self-heals via the overflow branch after 64 more notifications). Effectively unreachable for LogStateUpdate; inline note left for future reuse of the transport.

No blockers. The PR is in good shape.
• branch feat/l1-hand-rolled-client-v2

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

l1/eth/client/clienttest/server.go:123

  • Comment typo/grammar: "frames a" should be "frames that a" (or reflowed) so the sentence reads correctly.
// PushRawFrame writes data verbatim, so tests can inject malformed frames a
// well-behaved server would never emit.

Comment thread l1/eth/client/clienttest/server.go Outdated
Comment thread l1/eth/client/subscribe.go
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Yaroslav Kukharuk <i.kukharuk@gmail.com>
Copilot AI review requested due to automatic review settings July 23, 2026 21:58
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 3m 42s —— View job


Review: hand-rolled ws-based L1 client (re-review after sync)

  • Read core files (transport, client, subscribe, provider, contract decoder, node wiring)
  • Analyze concurrency / correctness / resource leaks on the current tree
  • Check the latest commits for regressions
  • go vet ./l1/eth/... ./l1/ ./node/ — clean

I re-reviewed the full diff after the latest sync (fix typo, chore: address feedback and add more tests). The three standing findings and all code-quality asks are confirmed resolved on the current tree, and I re-traced the concurrency model with no new races or leaks. go vet is clean.

No blockers.

Nit (optional, non-blocking)

  • Forwarder decode error leaks the inner subscriptioneth_l1_state_provider.go:296-299. When contract.Decode fails in stateUpdateForwarder.run(), it shuts down the forwarder but never calls w.inner.Unsubscribe(). The inner wsLogSub stays live: its dispatch() goroutine blocks forever on raw <- log (nothing drains w.raw after run() returns) and the server-side sub stays open — a goroutine + server-sub leak. This is the second decode step (eth.LogLogStateUpdate); the first surfaces via inner.Err(), so this branch is only reachable with a correctly-filtered-but-malformed LogStateUpdate body — effectively unreachable for the live contract, but a buggy/adversarial endpoint could hit it. Inline note with a targeted fix.

Re-verified as sound (no findings)

  • Head-of-line blocking (transport_ws.go:279-291) — non-blocking send + fail(ErrSubscriptionQueueOverflow) + background unsubscribe. readLoop no longer stalls unary RPCs behind a slow subscriber. ✅
  • Write failures wrapped as ErrTransportClosed (transport_ws.go:347) — %w: writing frame: %w, so the triggering call auto-redials via withRetryOnClosed. ✅
  • int256uint64 truncation (contract/starknet.go:50-55) — rejects non-zero Data[32:56] before taking Data[56:64]. ✅
  • Unbounded pendingSubs growth — split into orphanedSubs (map[uint64]struct{}); a cancelled subscribe no longer retains the full *wsLogSub. ✅
  • Cancel/register/shutdown interleavings in dispatchResponsecancelPendingregisterSub are all synchronized on t.mu; every path releases the server-side sub. Goroutine lifecycles (readLoop, pingLoop, per-sub dispatch, unsubscribeInBackground, forwarder run) are bounded by t.closed/sub.closed; CloseNow() unblocks the reader on shutdown.
  • --use-new-l1-client boolean flag and the node/l1.go split are in place and wired correctly.

The PR is in good shape — the one nit above is optional and practically unreachable for the live LogStateUpdate subscription.
• branch feat/l1-hand-rolled-client-v2

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Comment thread node/migration.go Outdated
Comment thread l1/eth_l1_state_provider.go
Copilot AI review requested due to automatic review settings July 24, 2026 11:46
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 2m 55s —— View job


Review: hand-rolled ws-based L1 client (re-review after sync)

  • Read core files (transport, client, subscribe, provider, contract decoder, node wiring)
  • Verify latest commit (fix(l1): release sub on decode failure) resolves the standing forwarder-leak nit
  • Re-trace concurrency / correctness / resource leaks on the current tree
  • go vet ./l1/eth/... ./l1/ — clean

I re-reviewed the diff after the latest sync. The last remaining nit from the previous review — the forwarder decode-error path leaking the inner subscription — is now fixed, and the rest of the tree is unchanged and still sound.

No blockers.

Standing nit — now fixed ✅

  • Forwarder decode error leaks the inner subscription (eth_l1_state_provider.go:299). stateUpdateForwarder.run() now calls w.inner.Unsubscribe() on the contract.Decode failure path. I traced the fix: Unsubscribe()s.fail(nil) closes the inner sub's closed channel, which unblocks the inner dispatch() goroutine that would otherwise sit forever on s.sink <- &log (== w.raw, no longer drained after run() returns), and issues the best-effort eth_unsubscribe so the server-side sub is released. Ordering is correct (shutdown first, then Unsubscribe), and the run() goroutine absorbing the ≤2s wsUnsubscribeTimeout is harmless. Good targeted test in TestEthL1StateProvider_WatchStateUpdate_DecodeFailureReleasesInnerSub (asserts eth_unsubscribe fires from the decode path itself, with no deferred Unsubscribe).

Re-verified as sound (no findings)

  • Head-of-line blocking (transport_ws.go:279-291) — non-blocking send + delete(subs) + fail(ErrSubscriptionQueueOverflow) + background unsubscribe; the shared readLoop no longer stalls unary RPCs behind a slow subscriber.
  • Write failures wrapped as ErrTransportClosed (transport_ws.go:347) — %w: writing frame: %w, so the triggering call auto-redials via withRetryOnClosed.
  • int256uint64 truncation (contract/starknet.go:50-55) — rejects non-zero Data[32:56] before taking Data[56:64], failing loudly on an oversized block number.
  • pendingSubs growth on cancelled subscribes — split into orphanedSubs (map[uint64]struct{}); a cancelled subscribe no longer retains the full *wsLogSub.
  • Cancel/register/shutdown interleavings in dispatchResponsecancelPendingregisterSub are all synchronized on t.mu; every path releases the server-side sub. Goroutine lifecycles (readLoop, pingLoop, per-sub dispatch, unsubscribeInBackground, forwarder run) are bounded by t.closed/sub.closed; CloseNow() unblocks the reader on shutdown.
  • --use-new-l1-client boolean flag and the node/l1.go split are in place and wired correctly.

The PR is in good shape — all previously raised findings and code-quality asks are resolved. No new races, leaks, or correctness issues found on the current tree.
• branch feat/l1-hand-rolled-client-v2

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Comment thread l1/eth/client/transport_ws.go
Copilot AI review requested due to automatic review settings July 27, 2026 11:03
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Claude finished @brbrr's task in 6m 19s —— View job


Review: hand-rolled ws-based L1 client (re-review after sync)

  • Diff the latest commit (fix(l1): improve ws transport teardown and provider redial)
  • Re-read core files (transport, client, subscribe, provider, contract decoder, node wiring)
  • Audit the new teardown/redial logic for races, leaks and nil-map panics
  • Verify the standing Copilot finding (writeJSON not shutting the transport down)
  • go vet ./l1/... ./node/... — clean

I reviewed 77300d2 on top of the previously-reviewed tree. The commit resolves the outstanding Copilot finding and hardens several teardown paths. One new finding, plus everything I re-verified.

No blockers.

Important

  • redial's dialMu is not ctx-awareeth_l1_state_provider.go:105. The mutex is held across the whole client.New, so a loser blocks in sync.Mutex.Lock() for up to the new redialTimeout (60s) past its own deadline. The same provider serves the RPC path (TransactionReceiptstarknet_getMessageStatus), so a short-deadline handler goroutine can be pinned for a minute against a blackholed L1 endpoint. Same shape as the old s.mu version, but the explicit 60s cap makes the worst case concrete. A chan struct{} semaphore selected against ctx.Done() keeps the coalescing and lets cancelled callers bail — inline, with a sketch.

What this commit fixes — verified

  • writeJSON now shuts the transport down on write failure (transport_ws.go:352). This closes the Copilot finding: previously a proven-dead conn kept its subs and pending calls alive until readLoop/pingLoop noticed. No reentrancy problem — every writeJSON caller (callWithSubReg, Unsubscribe, unsubscribeInBackground) has already released t.mu, and shutdown calls sub.fail outside the lock. The deregister() that follows a failed write is a safe no-op against the now-nil maps. Good test coverage in TestWS_WriteFailureClassifiedAsTransportClosed, which now asserts both t.closed and that active subs are failed.
  • writeMu removal is correct — coder/websocket serialises all frame writes internally, so the external mutex was redundant (consistent with the earlier pingLoop/Ping discussion).
  • Idempotent unsubscribeUnsubscribe and the new removeSub both clear s.id under t.mu before issuing eth_unsubscribe, so only one of two racing teardowns sends the RPC. This also makes stateUpdateForwarder.Unsubscribe() vs. the forwarder's decode-error inner.Unsubscribe() safe against a double release.
  • Decode-failure path in wsLogSub.dispatch now calls removeSub, so a failed sub is deleted from t.subs and released server-side immediately instead of self-healing 64 notifications later — resolves the asymmetry I flagged in the previous review.
  • Close racing an in-flight dialredial re-checks s.closed after client.New and closes the fresh client rather than leaking it (:131-137). Lock order is consistently dialMu → clientMu; Close/currentClient take only clientMu, so no deadlock.
  • decodeQuantityBig sign rejection"0x-1" previously parsed to a negative big.Int via SetString; now rejected. Consistent with eth.HexU64, which rejects signs via strconv.ParseUint. Both new test cases are on the right branch.
  • Logger wiringnode/l1.go:43,73 now passes WithEthL1StateProviderLogger, so redial/trace logs from the new provider actually surface (they were silently going to the nop logger before).

Re-verified as sound (no findings)

  • No nil-map write in cancelPending: after shutdown nils the maps, the awaitingReply lookup on a nil pendingSubs is false, so t.orphanedSubs[id] = struct{}{} is never reached. delete on the nil maps is a no-op and guarded anyway.
  • removeSub from dispatchNotification runs on readLoop but only backgrounds the unsubscribe, so the reader still can't stall.
  • Head-of-line blocking, ErrTransportClosed classification, the int256uint64 guard in contract/starknet.go, and the orphanedSubs split all still hold as previously reviewed.
  • Goroutine lifecycles (readLoop, pingLoop, per-sub dispatch, unsubscribeInBackground, forwarder run) remain bounded by t.closed/sub.closed.

Note on verification

go vet ./l1/... ./node/... is clean. I wasn't able to run the test suite here — go test isn't in this job's allowed tools — so the assessment above is from reading the code, not from a -race run. Worth confirming go test -race ./l1/... in CI before merge.
• branch feat/l1-hand-rolled-client-v2

Comment on lines +105 to +106
s.dialMu.Lock()
defer s.dialMu.Unlock()

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] dialMu is not ctx-aware: a request-scoped caller can block here for up to redialTimeout (60s) past its own deadline.

dialMu is held across the whole client.New (line 125), so losers block in sync.Mutex.Lock() — which cannot be interrupted by ctx. Since one EthL1StateProvider serves both the sync loop and the RPC handlers (rpccore.L1Client.TransactionReceiptstarknet_getMessageStatus), an RPC request with a 1–2s deadline that trips ErrTransportClosed will sit here until the winner's dial finishes — up to a minute — before it can even observe its own cancellation. Against a blackholed/SYN-dropping endpoint that's the common case, and the handler goroutines pile up for the full window.

The previous s.mu-for-the-whole-dial version had the same shape, but the explicit 60s cap makes the worst case concrete and long, so it seems worth closing now. A ctx-aware semaphore keeps the coalescing while letting cancelled callers bail immediately:

// field: dialSem chan struct{} // buffered, cap 1
select {
case s.dialSem <- struct{}{}:
    defer func() { <-s.dialSem }()
case <-ctx.Done():
    return nil, ctx.Err()
}

Two smaller notes on the same function, both benign as written:

  • stale.Close() (line 121) runs before s.client is replaced, so for the whole dial window currentClient() hands out a client that is already known-dead. It self-heals — fn(c) fails, redial blocks on dialMu, the loser picks up the winner's client — but every caller that starts in that window burns one wasted round-trip first.
  • Lock order is consistently dialMu → clientMu, and Close()/currentClient() only take clientMu, so there's no deadlock. The new s.closed re-check after the dial (lines 131-137) correctly plugs the Close-races-dial leak. 👍

Fix this →

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.

Comment thread node/node.go
Comment on lines 629 to 632
if !n.cfg.DisableL1Verification {
// Due to mutually exclusive flag we can do the following.
if n.cfg.EthNode == "" {
return nil, fmt.Errorf("ethereum node address not found; Use --disable-l1-verification flag if L1 verification is not required")
}
Comment thread cmd/juno/juno.go
Comment on lines +214 to +216

useNewL1ClientUsage = "Use the experimental L1 client implementation."

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.

no spaces between usage lines, please remove the lines above and below

Comment thread l1/eth/client/rpc.go
// it was answering, so there is no caller to route the reply to.
func parseResponseID(raw json.RawMessage) (uint64, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, jsonNull) {

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.

Why not using the isJSonNull utility here?

switch {
case probe.Method == "eth_subscription":
t.dispatchNotification(data)
case len(probe.ID) > 0 && !bytes.Equal(probe.ID, jsonNull):

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.

isJsonNull?

Comment thread l1/eth/client/client.go

const BlockFinalized = "finalized"

var jsonNull = []byte("null")

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.

This can be declared direcly inside the isJsonNUll helper

Comment on lines +113 to +117
// readLoop drops malformed frames rather than tearing the transport down —
// a misbehaving remote manifests as a call timeout.
func (t *wsTransport) readLoop() {
for {
_, data, err := t.conn.Read(context.Background())

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.

Why is the parent context not being passed?

Comment thread l1/eth/client/client.go
Comment on lines +65 to +68
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), jsonNull)
}

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.

Doesn't it makes more sense for this helper method to be moved to some other place like the end of the file? It sits a bit randomly between methods

Comment thread l1/eth/client/client.go
Comment on lines +69 to +75
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("getting chain ID: %w", err)
}
return decodeQuantityBig(raw)
}

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.

Why a big.Int instead of a uint256?

Comment thread l1/eth/client/client.go

// HeaderByNumber maps a null result — the server's signal for "the named
// block does not exist yet" — to eth.ErrNotFound.
func (c *Client) HeaderByNumber(ctx context.Context, tag string) (*eth.Header, error) {

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.

Why returning a pointer?

Comment thread l1/eth/client/client.go

// TransactionReceipt maps a null result (receipt unknown to the node) to
// eth.ErrNotFound.
func (c *Client) TransactionReceipt(ctx context.Context, txHash eth.Hash) (*eth.Receipt, error) {

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.

Why returning a pointer?

Comment on lines +267 to +273
return &StateUpdate{
L2BlockNumber: ev.BlockNumber,
L2BlockHash: ev.BlockHash,
StateRoot: ev.GlobalRoot,
L1RefHeight: uint64(ev.Raw.BlockNumber),
Removed: ev.Raw.Removed,
}

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.

Why return a pointer?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants