Skip to content

feat(l1): introduce SettlementLayer and EthMessagingClient abstractions#3738

Open
brbrr wants to merge 7 commits into
mainfrom
feat/l1-settlement-geth
Open

feat(l1): introduce SettlementLayer and EthMessagingClient abstractions#3738
brbrr wants to merge 7 commits into
mainfrom
feat/l1-settlement-geth

Conversation

@brbrr

@brbrr brbrr commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

This is the first part of #3727 (deprecated) that adds an abstraction layer between the Geth L1 client and Juno, separating the settlement layer and the actual RPC client.

@brbrr brbrr self-assigned this Jun 18, 2026
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.43558% with 27 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.24%. Comparing base (ada24e9) to head (4de2d88).
⚠️ Report is 24 commits behind head on main.

Files with missing lines Patch % Lines
l1/geth_settlement.go 86.84% 13 Missing and 2 partials ⚠️
node/node.go 38.88% 9 Missing and 2 partials ⚠️
node/migration.go 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3738      +/-   ##
==========================================
+ Coverage   75.13%   75.24%   +0.11%     
==========================================
  Files         425      426       +1     
  Lines       38331    38489     +158     
==========================================
+ Hits        28799    28961     +162     
+ Misses       7538     7522      -16     
- Partials     1994     2006      +12     

☔ 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 19, 2026 11:44
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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


PR Review: L1 SettlementLayer abstraction

I reviewed the diff against origin/main (and the branch base, since the branch predates current main). The packages build cleanly, and the refactor in l1.go preserves the existing sync/catch-up logic 1:1 — only the types change (SubscriberSettlementLayer, contract.StarknetLogStateUpdateStateUpdate). Nicely documented throughout.

Reviewed:

  • l1/geth_settlement.go (new adapter + forwarder)
  • l1/settlement.go + l1/l1.go
  • node/node.go, node/migration.go, node/metrics.go wiring
  • rpc/rpccore + RPC GetMessageStatus receipt consumption
  • tests + stale-reference / dead-code sweep

This is a clean, well-structured refactor. Logic is correct; I found no blockers — one test-coverage gap worth addressing and two nits.

Important

  • Forwarder is untestedgethStateUpdateForwarder / WatchStateUpdate is the only concurrency-sensitive code in the PR (goroutine lifecycle, closeOnce shutdown, non-blocking Err() delivery, sink back-pressure) and has no direct coverage. The HTTP test server can't exercise WS subscriptions, and the l1_*_test.go suites mock WatchStateUpdate at the interface boundary, so the real forwarder never runs. Codecov's 54 missing lines in this file are largely this block. Details + a suggested 4-case test left inline. The logic itself looks correct on inspection — shutdown is once-guarded, no send-after-close, no double-writer on sink, and Unsubscribe unblocks run via <-w.closed — it just isn't pinned down by a test.

Nits

  • rpc/rpccore/rpccore.go:38EthMessagingClient alias is added but unused in this PR (it's for the next commit per the description). Fine as a forward-declaration, but it's currently dead code; consider landing it together with its first consumer so it doesn't linger unused.
  • node/node.go newGethSettlement dials with a fresh context.Background()+1m timeout, so in node/migration.go:fetchL1HeadIfMissing the migration's ctx cancellation won't cancel the dial. Harmless given the bounded dial timeout, but threading the caller's ctx would be more consistent.

Verified (no action needed)

  • Partial gethReceiptToEth copy (only Logs) is safe — GetMessageStatus (rpc/v10/l1.go) reads only Logs[].Topics/Data, all of which are converted.
  • No stale references to the removed Subscriber / EthSubscriber / EthReceiptAdapter / Client.L1() remain.
  • Concurrent settlement RPC calls (prometheus gauge scrapes vs. the sync loop) are safe — listener is set once before hand-off and go-ethereum's rpc.Client is concurrency-safe.

Comment thread l1/geth_settlement.go Outdated
Comment on lines +276 to +328
// gethStateUpdateForwarder decodes contract.StarknetLogStateUpdate events
// into l1.StateUpdate as they arrive from the underlying log subscription.
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
}
}
}
}

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 (test gap): gethStateUpdateForwarder and GethSettlement.WatchStateUpdate are the most concurrency-sensitive code in this PR — goroutine lifecycle, closeOnce shutdown, non-blocking error delivery, sink back-pressure — yet they have no direct test. The new HTTP-based gethJSONRPCServer can only exercise the unary calls; WatchStateUpdate needs a WS endpoint, so nothing here is covered (Codecov flags 54 missing lines, mostly this block).

The l1_*_test.go suites mock WatchStateUpdate at the SettlementLayer boundary, so the real forwarder logic is never run.

A focused unit test driving a fake event.Subscription through the four paths would lock this down:

  1. normal: event on raw → decoded StateUpdate on sink
  2. inner errors → Err() delivers the cause exactly once, then closes
  3. Unsubscribe()run goroutine exits and inner.Unsubscribe() is called (assert no leak via goleak)
  4. sink blocked + Unsubscribe() concurrently → run unblocks via <-w.closed and returns

Fix this →

@brbrr brbrr changed the title Feat/l1 settlement geth feat(l1): introduce SettlementLayer and EthMessagingClient abstractions Jun 19, 2026
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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


PR Review: L1 SettlementLayer abstraction — follow-up

  • Read PR context & prior review
  • Review new l1/geth_forwarder_test.go
  • Re-review l1/geth_settlement.go forwarder logic
  • Verify the two prior nits were addressed
  • Build affected packages

Re-reviewed after the address feedback commit (fcd716d9). All three items from the prior review are resolved, and ./l1/..., ./node/..., ./rpc/rpccore/... build cleanly.

Resolved

  • Forwarder test gap (the one "Important")l1/geth_forwarder_test.go now drives gethStateUpdateForwarder directly through a fake event.Subscription, covering exactly the four paths flagged: normal forward + decode, inner-error Err() delivery-then-close, Unsubscribe() goroutine exit (asserts inner.Unsubscribe() ran), and sink-stall + Unsubscribe() unblocking via <-w.closed. The fake's closeOnce-equivalent (CompareAndSwap) and the buffered errCh/raw match the production wiring, so the assertions exercise the real logic rather than a re-implementation. Good coverage of the concurrency-sensitive block.
  • Unused EthMessagingClient alias — removed from rpc/rpccore/rpccore.go (deferred to the commit that introduces its first consumer).
  • ctx threading into newGethSettlementnode/migration.go now passes the migration's ctx, and node/node.go layers the 1-minute dial timeout on the caller's context. node.New still passes context.Background(), which is correct — there's no node-lifetime context at construction time, and that's documented.

Nits (non-blocking)

  • l1/geth_forwarder_test.go:144 TestGethStateUpdateForwarder_SinkStalledThenUnsubscribe — because raw is buffered (cap 1), after Unsubscribe() closes w.closed the run loop's top-level select may pick the <-w.closed arm before ever dequeuing from raw, so the intended "blocked on w.sink <- su" path isn't guaranteed to execute on every run. The test still passes either way (it asserts only shutdown + unsubscribe), so this is a determinism nit, not a correctness issue — the stall arm is already covered deterministically by inspection. If you want to pin it, have the test wait for the goroutine to reach the send (e.g. an unbuffered raw so the w.raw <- send only completes once run has dequeued) before calling Unsubscribe().

Verdict

No blockers. The previously-flagged test gap is closed and both nits are addressed — this is good to merge from my side (note: I cannot approve PRs). I was unable to run go test ./l1/ -race in this environment (command requires approval), but the forwarder logic and test assertions check out on inspection and the packages compile.
• branch feat/l1-settlement-geth

@rodrodros rodrodros 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.

This PR looks more complicated than it should, let's discuss it more on Monday

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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


PR Review: SettlementLayer & EthMessagingClient abstractions

  • Read new abstractions (settlement.go, geth_settlement.go)
  • Review forwarder concurrency / resource leaks
  • Review l1.go rewiring & node wiring
  • Review tests for meaningful assertions
  • Build affected packages (./l1/... ./node/... ./rpc/... — OK)

I reviewed this against the branch base (ada24e91e, since the branch predates current origin/main). This is a clean, well-structured refactor and I found no correctness, concurrency, or resource-leak blockers. The l1.Client sync/catch-up logic is preserved 1:1 — only types change (SubscriberSettlementLayer, contract.StarknetLogStateUpdateStateUpdate).

Verified (no action needed)

  • Forwarder concurrency (geth_settlement.go:276-328) — shutdown is closeOnce-guarded; no send-after-close (errCh send is non-blocking then closed exactly once); run unblocks a stalled sink <- su via <-w.closed; Unsubscribe exits the goroutine and forwards to inner. The new geth_forwarder_test.go drives all four paths (normal forward, inner-error delivery+close, unsubscribe exit, sink-stall+unsubscribe) directly against a fake event.Subscription — the previously-flagged test gap is closed.
  • SetListener data race — the field is read unlocked from every RPC method, but node.go calls SetListener once before any goroutine starts and the contract is documented (geth_settlement.go:111-122). Single-shot wiring is respected.
  • Resource lifecyclesettlement.Close() is deferred in Client.Run/CatchUpL1Head; the migration path (node/migration.go) builds a listener-less settlement and closes it the same way. No leak.
  • FinalisedHeight not-found maps ethereum.NotFoundeth.ErrNotFound, distinguishing "no finality yet" from transport failure — covered by test.
  • gethReceiptToEth copies only Logs, which is all starknet_getMessageStatus reads (rpc/v10/l1.go) — safe, and documented as such.
  • No stale references to the removed Subscriber / EthSubscriber / EthReceiptAdapter / Client.L1() remain.
  • Tests: meaningful assertions, no flaky time.Sleep (the two time.Sleep matches are only failure-message strings), race-sensitive forwarder tests included.

Nits (non-blocking)

  • gethReceiptToEth is a latent footgun — it silently drops every receipt field except Logs. Correct today, but a future L1Client consumer reading e.g. Status or BlockNumber off the receipt would get a zero value with no compile-time signal. The doc comment mitigates this; consider it a known trade-off.
  • PR title scope — the title says "and EthMessagingClient abstractions", but per the description that type is deferred to a later commit and is not present in this diff. Title is slightly ahead of the contents (purely cosmetic).
  • SinkStalledThenUnsubscribe determinism (geth_forwarder_test.go:144) — because raw is buffered (cap 1), run may take the <-w.closed arm before dequeuing, so the intended "blocked on send" path isn't guaranteed every run. Test still passes (it only asserts shutdown + unsubscribe); an unbuffered raw would pin the stall path deterministically. Already noted in a prior review.

Note: I could not run go test ./l1/ -race in this environment (command requires approval), but the package builds cleanly and the forwarder logic + assertions check out on inspection.

Re @rodrodros's "more complicated than it should be" — that's a design-altitude question (worth the Monday discussion) rather than a correctness one; from a bug/race/leak standpoint the implementation is sound.
• branch feat/l1-settlement-geth

@github-actions

github-actions Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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


PR Review: SettlementLayer & EthMessagingClient abstractions

  • Read new abstractions (settlement.go, geth_settlement.go)
  • Review forwarder concurrency / resource leaks
  • Review l1.go rewiring & node wiring
  • Review tests
  • Build affected packages (./l1/... ./node/... ./rpc/... — OK)

Re-reviewed after the latest Simplify StateUpdate production commit (4de2d8887), against the branch base ada24e91e (the branch predates current origin/main, so there's no merge base with it). No correctness, concurrency, or resource-leak blockers.

The simplification is a real improvement

The hand-rolled gethStateUpdateForwarder (manual closeOnce / closed channel / shutdown(cause) plumbing) is gone, replaced by forwardStateUpdates built on go-ethereum's event.NewSubscription (geth_settlement.go:273-295). This is the right call — event.NewSubscription already owns once-only close, idempotent Unsubscribe, error delivery, and goroutine teardown, so the adapter is now just type translation + the stall-vs-quit select. Net −34 lines and the concurrency-sensitive surface shrank to something the runtime library guarantees. This directly addresses @rodrodros's "more complicated than it should be" note. Lifecycle re-verified:

  • defer inner.Unsubscribe() runs on every producer exit (error, quit, or stall+quit) → no upstream subscription leak.
  • sink <- ... is guarded by <-quit, so a stalled sink can't wedge the goroutine after Unsubscribe.
  • Unsubscribe() blocks until the producer returns → deterministic teardown, no send-after-close.

Verified (no action needed)

  • l1.Client logic preserved 1:1 (l1.go) — only types change (SubscriberSettlementLayer, contract.StarknetLogStateUpdateStateUpdate); applyStateUpdate, reorg-clear, setL1Head finality gating all unchanged.
  • SetListener race contract — read unlocked from RPC methods, but set once in node.go before hand-off and documented (geth_settlement.go:110-121). Single-shot wiring respected.
  • Resource lifecyclesettlement.Close() deferred in Run/CatchUpL1Head; migration path builds a listener-less settlement and closes it the same way.
  • ctx threadingnewGethSettlement layers a 1-min dial timeout on the caller's ctx (node.go:648); migration passes its ctx; node.New correctly uses context.Background() (no node-lifetime ctx at construction).
  • gethReceiptToEth copies only Logs, which is all starknet_getMessageStatus reads — safe and documented.
  • rpccore.L1Client is now documented and the previously-unused EthMessagingClient alias was removed (deferred to its first consumer).
  • Forwarder tests rewired to drive forwardStateUpdates directly through a fake event.Subscription, covering all four paths (normal forward, inner-error delivery+close, unsubscribe exit, sink-stall+unsubscribe).

Nits (non-blocking)

  • SinkStalledThenUnsubscribe determinism (geth_forwarder_test.go:143) — raw is buffered (cap 1), so the producer's outer select may take <-quit before dequeuing, meaning the intended "blocked on sink <- ..." path isn't guaranteed each run. Test still passes (it only asserts shutdown + unsubscribe). An unbuffered raw would pin the stall path. Carried over from prior reviews.
  • gethReceiptToEth latent footgun — silently drops every receipt field except Logs; a future L1Client consumer reading e.g. Status/BlockNumber would get a zero value with no compile-time signal. Mitigated by the doc comment; known trade-off.
  • PR title scopeEthMessagingClient is deferred to a later commit and isn't in this diff; title is slightly ahead of contents (cosmetic).

Note: build is clean, but I couldn't run go test ./l1/ -race in this environment (command requires approval). The forwarder logic and test assertions check out on inspection.
• branch feat/l1-settlement-geth

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