Skip to content

refactor(l1): Add L1 Eth client#3727

Draft
brbrr wants to merge 18 commits into
mainfrom
add/l1-eth-client
Draft

refactor(l1): Add L1 Eth client#3727
brbrr wants to merge 18 commits into
mainfrom
add/l1-eth-client

Conversation

@brbrr

@brbrr brbrr commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

NOTE: This PR wouldn't be merged. The contents of this PR will split up in two. Keeping it as the reference.

Add a minimal Ethereum execution-layer client living under l1/eth/:

  • l1/eth/client — JSON-RPC 2.0 over a single WebSocket, id-multiplexed so unary calls and eth_subscribe notifications share one conn. Surface: ChainID, BlockNumber, HeaderByNumber,
    TransactionReceipt, FilterLogs, SubscribeLogs.
  • l1/eth/contract — Hand-decoded LogStateUpdate event (3×32-byte data, no indexed args). Replaces the abigen output.
  • l1/eth_settlement.go — EthSettlement implements the new SettlementLayer interface used by l1.Client. All Ethereum-flavoured types (*big.Int, eth.Log, eth.Hash) stay inside this adapter; the L1 sync loop consumes a chain neutral StateUpdate.

Boundary changes:

  • New l1.SettlementLayer interface replaces the old Subscriber. Methods are chain-neutral so a non-Ethereum settlement backend could slot in without touching l1.Client.
  • The same EthSettlement instance also satisfies rpccore.L1Client (for starknet_getMessageStatus), so node.go constructs one client and hands it to both the sync loop and the RPC
    handlers.
  • Deleted: l1/eth_subscriber.go, l1/contract/, rpc/rpccore/eth_receipt_adapter*.go, mocks/mock_subscriber.go.

Notes:

  • go-ethereum dependencies are still present in tests. They will be removed in the follow-up PR.

@brbrr brbrr self-assigned this Jun 15, 2026
@brbrr brbrr requested a review from EgeCaner June 15, 2026 13:22
@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.62476% with 190 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.46%. Comparing base (7b987fd) to head (356cf22).
⚠️ Report is 25 commits behind head on main.

Files with missing lines Patch % Lines
l1/geth_settlement.go 66.18% 45 Missing and 2 partials ⚠️
node/node.go 6.52% 43 Missing ⚠️
l1/eth/client/transport_ws.go 85.32% 26 Missing and 12 partials ⚠️
l1/eth/client/testserver.go 76.42% 21 Missing and 8 partials ⚠️
l1/eth_settlement.go 90.90% 11 Missing and 4 partials ⚠️
l1/eth/contract/starknet.go 90.78% 6 Missing and 1 partial ⚠️
l1/eth/client/subscribe.go 90.38% 4 Missing and 1 partial ⚠️
l1/eth/client/client.go 94.87% 2 Missing and 2 partials ⚠️
l1/l1.go 97.77% 1 Missing ⚠️
node/migration.go 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3727      +/-   ##
==========================================
+ Coverage   75.12%   75.46%   +0.33%     
==========================================
  Files         425      432       +7     
  Lines       38331    39228     +897     
==========================================
+ Hits        28798    29603     +805     
- Misses       7541     7609      +68     
- Partials     1992     2016      +24     

☔ 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 marked this pull request as ready for review June 16, 2026 11:36
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — Add L1 Eth client

I reviewed the production code (l1/eth/client/*, l1/eth/contract, l1/eth_settlement.go, l1/settlement.go, l1/l1.go, node/node.go) with a focus on concurrency, resource leaks, and error handling.

Overall this is a high-quality refactor — the transport's shutdown fan-out, the sync.Once-guarded subscription teardown chains, the ctx-vs-t.closed race handling, and the layered forwarders are all carefully reasoned and well-documented. The concurrency model holds up. No blockers; findings are robustness notes and nits.

Important

  • withRetryOnClosed doesn't redial on the call that actually drops the connection (eth_settlement.go:169). A real network drop fans out the raw read/ping error (not ErrTransportClosed), so the in-flight call surfaces a raw error and the transparent redial only fires on a subsequent call. Self-heals via the higher-level retry loops in l1.Client, but the "next call succeeds without ever knowing the conn flapped" claim is slightly overstated for one-shot callers like the RPC TransactionReceipt path.
  • Head-of-line blocking on the shared conn (transport_ws.go:306). A stalled subscription consumer (full logCh) blocks readLoop, stalling all unary RPCs and eventually tripping the ping timeout. Bounded/self-healing and low risk given infrequent logs, but worth documenting that unary-call liveness depends on the sub sink being drained (the updateChan drain only runs on the poll ticker, default 1 min).

Nit

  • "both interfaces" comment vs single assertion (eth_settlement.go:384). Only SettlementLayer is asserted; add rpccore.L1Client (watch for import cycle) or soften the wording.
  • CatchUpChunkSize == 0 spins forever (l1/l1.go, catchUpL1HeadUpdates). If a caller passes WithCatchUpChunkSize(0), then from = to + 1 (inverted range) and to = from - 1 never decreases → infinite loop with invalid eth_getLogs ranges. NewClient doesn't guard 0; cheap to fall back to defaultCatchUpChunkSize.
  • Minor test-flakiness watch: the negative assertion after time.Sleep(50ms) in l1/l1_pkg_test.go:881 ("L1Head must remain unset") could flake under a loaded CI runner, though the window is generous.

Nice touches worth calling out: spec-compliant quantity decoding (leading-zero rejection), the defensive sig-hash recheck in contract.Decode, dropping *big.Int for LogStateUpdate felts, and OnL1Call firing on both success and failure paths (with a regression test).
• branch add/l1-eth-client

Comment thread l1/eth_settlement.go
Comment thread l1/eth/client/transport_ws.go
Comment thread l1/eth_settlement.go
@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — Add L1 Eth client (re-review after address ai feedback)

I re-reviewed the production code after the latest commit, building ./l1/... and ./node/... locally (compiles clean) and re-checking the concurrency-sensitive paths.

Prior round — all three findings resolved ✅

  • withRetryOnClosed not redialing on the dropping call → fixed. wsTransport.shutdown now normalises the cause so errors.Is(err, ErrTransportClosed) holds for the in-flight call racing a real disconnect (errors.Join(ErrTransportClosed, cause), transport_ws.go:331-338), and there's a focused regression test (TestEthSettlement_DroppingCallRedials) that severs the conn mid-call and asserts the same call self-heals.
  • Head-of-line blocking on the shared conn → documented. The liveness invariant is now spelled out on both wsLogSubBuffer (transport_ws.go:24-38) and WatchStateUpdate (eth_settlement.go:261-269), including the "drain sinks promptly" caller contract.
  • "both interfaces" comment vs single assertion → fixed. Both SettlementLayer and rpccore.L1Client are now asserted (eth_settlement.go:397-400), and no import cycle (the assertion compiles).

Nit (1, still open)

  • catchUpChunkSize == 0 can spin foreverWithCatchUpChunkSize(0) is still unguarded in NewClient; against a provider that returns empty (rather than erroring) for an inverted eth_getLogs range, catchUpL1HeadUpdates loops forever on invalid [to+1, to] queries. Cheap one-line guard suggested inline.

Other checks — clean

  • Layered subscription teardown (stateUpdateForwarderstateUpdateWatcherwsLogSub) is sync.Once-guarded at every level; no goroutine leak on the subscribe-fails or ctx-cancel paths.
  • Quantity decoding is spec-compliant (0x required, leading-zero rejection, empty-body rejection); decodeQuantityBig correctly avoids the uint64 path for chain IDs.
  • node.go wiring satisfies the SetListener-before-handoff contract (single-shot, pre-NewClient); one settlement instance feeds both the sync loop and the RPC handlers as intended.
  • The l1_pkg_test.go:881 negative assertion the prior review flagged is actually safe — finalised is still 2000 during the time.Sleep(50ms), so setL1Head has nothing to promote regardless of timing; not a flake risk.

Verdict: no blockers, no important issues. The one open item is a minor robustness guard. Nice work addressing the prior round.
• branch add/l1-eth-client

Comment thread l1/l1.go
Comment thread l1/eth/contract/starknet.go Outdated
Comment thread l1/eth/contract/starknet.go Outdated
Comment thread l1/eth/contract/starknet.go Outdated
Comment thread node/node.go Outdated
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — Add L1 Eth client (synchronize re-review)

  • Read production code (l1/eth/client/*, l1/eth/contract, l1/eth_settlement.go, l1/settlement.go, l1/l1.go, node/*)
  • Check concurrency / resource leaks / error handling
  • Verify prior-round findings status
  • Post findings + summary

I re-read the full production surface with a focus on the concurrency-sensitive transport, the layered subscription teardown, and the redial path. No blockers and no new important issues — the prior two rounds' findings all remain resolved, and the one open item is a cheap robustness guard.

Verified resolved (prior rounds) ✅

  • Dropping-call redialwsTransport.shutdown normalises the cause (fmt.Errorf("%w: %w", ErrTransportClosed, cause), transport_ws.go:331-343) so errors.Is(err, ErrTransportClosed) holds for the in-flight call racing a real disconnect, and withRetryOnClosed redials on it.
  • Head-of-line blocking — documented as a liveness invariant on both wsLogSubBuffer (transport_ws.go:24-38) and WatchStateUpdate (eth_settlement.go:261-269), with the "drain sinks promptly" caller contract.
  • Dual-interface assertion — both SettlementLayer and rpccore.L1Client asserted (eth_settlement.go:397-400), no import cycle.

Concurrency / leak checks — clean

  • Three-layer subscription stack (stateUpdateForwarderstateUpdateWatcherwsLogSub) is sync.Once-guarded at every level; teardown propagates cleanly and no goroutine leaks on subscribe-fail, ctx-cancel, or transport-drop paths. A sub failing while still in pendingSubs is fail-ed by shutdown before its dispatch goroutine ever starts, so no orphan.
  • dispatchResponse registers the server-assigned sub id under t.mu before waking the caller, closing the notification-race window; cancelPending reads pendingSub.id under the same lock and best-effort eth_unsubscribes a sub that raced registration against ctx cancellation.
  • Buffered (cap-1) reply channels + best-effort sends mean a duplicate/late response is dropped rather than blocking readLoop; ping timeout → shutdownclose(t.closed) unblocks a wedged dispatchNotification, so the HOL-blocking case is self-healing.
  • s.listener unlocked-read is safe given the single-shot SetListener-before-handoff wiring in node.go:599-618.

Error handling / decoding — clean

  • Quantity decoding is spec-compliant (0x required, empty-body and leading-zero rejection, "0x0" accepted); decodeQuantityBig correctly avoids the uint64 path for chain IDs.
  • contract.Decode defensively re-checks the sig hash and data length; felts land directly via SetBytes without *big.Int.
  • Not-found is mapped to eth.ErrNotFound per-method, distinct from transport errors, and FinalisedHeight preserves it for the catch-up caller.

Nit (still open — only actionable item)

  • catchUpChunkSize == 0 can spin forever (l1/l1.go:64 / catchUpL1HeadUpdates). NewClient defaults the size, but WithCatchUpChunkSize(0) is unguarded. With 0, from = to + 1 (inverted range) and to = from - 1 never decreases; against a provider that returns empty (rather than erroring) for an inverted eth_getLogs range, the loop never terminates. One-line guard in NewClient:
    if o.CatchUpChunkSize == 0 {
        o.CatchUpChunkSize = defaultCatchUpChunkSize
    }
    Fix this →
  • Open style nits from @EgeCaner on l1/eth/contract/starknet.go (one-param-per-line; index-assign over append at :122; gerund decoding LogStateUpdate: %w at :203) are cosmetic and still unaddressed.

Verdict: ship-ready modulo the optional catchUpChunkSize guard. Clean, careful concurrency work with the rationale documented where it counts.
• branch add/l1-eth-client

@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — Add L1 Eth client (synchronize re-review)

  • Gather context (diff vs origin/main, new commits)
  • Review newest changes (geth client switch, CLI wiring)
  • Re-check concurrency / leaks / error handling on the eth client
  • Verify status of prior-round open items
  • Post findings + summary

I re-reviewed after the latest commit (feat(l1/client): Add back geth client implementation switchable via cli command), which re-introduces a go-ethereum backend selectable via --l1-client. Built ./l1/... and ./node/... and ran go vet on the L1 packages locally — both clean. No blockers and no new important issues.

New code this round — l1/geth_settlement.go + --l1-client wiring ✅

  • GethSettlement faithfully mirrors the reviewed EthSettlement: same SettlementLayer + rpccore.L1Client surface, same dual compile-time assertion (geth_settlement.go:331-334), same observe()-on-both-paths metrics, same not-found → eth.ErrNotFound mapping, and the same SetListener-before-handoff concurrency contract documented inline.
  • gethStateUpdateForwarder is sync.Once-guarded at teardown, watches inner.Err() and w.closed, and best-effort-sends on a cap-1 errCh before closing it — no goroutine leak on unsubscribe, ctx-cancel, inner-error, or inner-Err()-closed paths; the top-level defer w.shutdown(nil) covers the clean-exit case.
  • node.go wiring is sound: one settlement instance feeds both the sync loop and the RPC handlers; the metrics listener is attached only under --metrics (default SelectiveListener{} otherwise), satisfying the single-shot pre-handoff contract. --l1-client defaults to "geth" (preserves main's behaviour), and the ""/"geth" switch arm covers the node/migration.go path that bypasses flag parsing. Invalid values error cleanly at node.New.

Prior-round findings — all remain resolved ✅

  • Dropping-call redial normalises the cause so errors.Is(err, ErrTransportClosed) holds (transport_ws.go).
  • Head-of-line-blocking liveness invariant documented on wsLogSubBuffer and WatchStateUpdate (and now GethSettlement.WatchStateUpdate carries the same "drain sink promptly" caller contract).
  • Dual-interface assertions present on both EthSettlement and GethSettlement, no import cycle.

Nit (1, still open — only actionable item)

  • catchUpChunkSize == 0 can spin forever (l1/l1.go:91). WithCatchUpChunkSize(0) is still unguarded in NewClient; against a provider that returns empty (rather than erroring) for the resulting inverted eth_getLogs range, catchUpL1HeadUpdates loops forever. One-line fallback to defaultCatchUpChunkSize suggested inline.
  • @EgeCaner's l1/eth/contract/starknet.go style nits (one-param-per-line; index-assign over append; gerund error wraps) remain cosmetic / golangci-lint territory and are unaddressed — non-blocking.

Verdict: ship-ready modulo the optional catchUpChunkSize guard. The geth re-introduction is a clean, symmetric mirror of the already-vetted hand-rolled backend.
• branch add/l1-eth-client

Comment thread l1/l1.go
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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


PR Review — Add L1 Eth client (synchronize re-review)

  • Gather context (latest commit, diff vs origin/main)
  • Re-check concurrency / leaks / error handling
  • Verify status of prior-round open items (catchUpChunkSize == 0, style nits)
  • Post findings + summary

I re-reviewed after the latest commit (address feedback), which addresses @EgeCaner's style nits. Diff is scoped to l1/eth/contract/starknet.go, its test, and node/node.go. No blockers and no new important issues.

This round — address feedback

  • FilterLogStateUpdate now takes one param per line; out is pre-sized and index-assigned (out[i] = ev) instead of append — correct, since len(out) == len(logs) upfront.
  • Error wraps switched to gerund form (filtering / decoding / subscribing LogStateUpdate: %w); the matching test assertion (starknet_test.go:225) was updated from "decode""decoding", so the rename is covered.
  • node.go newSettlement calls now use one-param-per-line for both NewGethSettlement and NewEthSettlement. No behavioural change.

Prior-round findings — all remain resolved ✅

  • Dropping-call redialshutdown normalises the cause (%w: %w wrap of ErrTransportClosed) so errors.Is(err, ErrTransportClosed) holds for the in-flight call racing a real disconnect; withRetryOnClosed redials on it.
  • Head-of-line blocking — documented as a liveness invariant on wsLogSubBuffer and WatchStateUpdate (and GethSettlement.WatchStateUpdate carries the same "drain sink promptly" contract).
  • Dual-interface assertions — both SettlementLayer and rpccore.L1Client asserted on EthSettlement and GethSettlement; no import cycle.
  • EgeCaner style nits — all addressed this round (see above).

Concurrency / leak / error-handling spot-checks — clean

  • Layered subscription teardown (stateUpdateForwarderstateUpdateWatcherwsLogSub, and the geth mirror) is sync.Once-guarded at every level; no goroutine leak on subscribe-fail, ctx-cancel, inner-error, or transport-drop paths.
  • Not-found is mapped to eth.ErrNotFound per-method and preserved by FinalisedHeight for the catch-up caller; quantity decoding remains spec-compliant (0x required, leading-zero/empty-body rejection).
  • node.go wiring satisfies the single-shot SetListener-before-handoff contract; one settlement instance feeds both the sync loop and the RPC handlers.

Nit (1, still open — only actionable item)

  • catchUpChunkSize == 0 can spin forever (l1/l1.go:82). WithCatchUpChunkSize(0) is still unguarded in NewClient; against a provider that returns empty (rather than erroring) for the resulting inverted eth_getLogs range, catchUpL1HeadUpdates loops forever on invalid [to+1, to] queries. One-line fallback to defaultCatchUpChunkSize after the options loop fixes it.

Verdict: ship-ready modulo the optional catchUpChunkSize guard. The feedback round is a clean, focused cleanup with no functional risk.
• branch add/l1-eth-client

Comment thread l1/l1.go
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.

2 participants