Skip to content

feat(evm): opt-in short-circuit for future blocks without served-tip - #33

Draft
shpookas wants to merge 6 commits into
feat/websocket-supportfrom
fix/future-block-short-circuit
Draft

shpookas wants to merge 6 commits into
feat/websocket-supportfrom
fix/future-block-short-circuit

Conversation

@shpookas

@shpookas shpookas commented Oct 9, 2026 •

Copy link
Copy Markdown

Kept separate from feat/websocket-support on purpose. Retarget to main once the WebSocket branch is merged upstream.

Problem

Clients that follow a chain by number (for example rollup nodes reading L1) poll eth_getBlockByNumber(head+1) before the block exists and treat null as "not yet". No upstream has the block, so each upstream returns null in turn. The request walks every routed upstream, fallbacks included, and the retry layer repeats the walk. On a network that routes all tiers in one list, almost all fallback traffic ends up being these nulls.

tryShortCircuitFutureBlock already returns null from Forward before any dispatch when the block is above every upstream's head. But it only runs on networks that enable served-tip for latest, so networks without it (the default) still fan out. Enabling served-tip to get it also changes what latest resolves to.

Change

New network option evm.shortCircuitFutureBlocks (default off, inheritable from networkDefaults.evm). The short-circuit runs when it is true or when served-tip is enabled for latest, so nothing changes for anyone who does not set it. latest is unchanged.

The short-circuit compares the requested block against futureBlockCeiling: the highest effective latest head (static caps included) across every upstream of the network. It returns null only when the block is above that, and dispatches as usual while any upstream's head is unknown. It deliberately does not try to predict routing:

  • selection policy is ignored, so a fallback the policy cordons off but the per-request escape can still reach counts, as do method-scoped policy slots
  • syncing upstreams count, whatever skipWhenSyncing says
  • heads are always latest, also with emptyResultConfidence: finalizedBlock. That setting decides how an empty upstream answer is treated, not whether an upstream can have the block

So it never nulls a block any upstream reports having. The cost is that it fires less often: an upstream whose head is never learned (for example a broken endpoint) keeps it off for that network, which is today's behaviour.

The short-circuit now also runs before Forward publishes the request to the selection policy's probe bus. Before, probeExcluded still mirrored requests that were answered with the synthetic null to the excluded upstreams (28 mirrored calls for 5 short-circuited requests in the test below). The network pre-forward hook it now runs ahead of does not handle eth_getBlockByNumber, so nothing else changes order.

This also changes the short-circuit for networks that already enable served-tip for latest: it used evmHeadReference(...).Available (policy-eligible, non-syncing upstreams, finalized axis under finalizedBlock confidence). The new ceiling is never lower, so it only ever dispatches more.

Why opt-in and not just dropping the served-tip gate

The first version of this PR removed the gate. That breaks the issue 934 tests (TestNetworkAvailability_Issue934_*, TestNetworkAvailability_Window_ExactLowerUpper): the head is what eRPC last saw from the state poller and head subscriptions, so a block the node has produced since then (and a client already saw via the node's own newHeads) gets null instead of being served. That is acceptable where heads arrive promptly over WebSocket and the clients retry a null, but not as a default for every network. The docs say so.

Files

  • common/config.go: EvmNetworkConfig.ShortCircuitFutureBlocks and ShortCircuitFutureBlocksEnabled()
  • common/defaults.go: inherit from networkDefaults.evm
  • erpc/networks.go: gate on ShortCircuitFutureBlocksEnabled(), futureBlockCeiling, short-circuit before the probe-bus publish
  • typescript/config/src/generated.ts: shortCircuitFutureBlocks?: boolean
  • docs/pages/config/projects/networks.mdx: "Future-block polling" section; agent reference schema row, networkDefaults inheritance list, edge cases 23, 24 and 31, and the future_block.short_circuit span attribute

Tests

erpc/networks_future_block_test.go (existing tests unchanged). With the option on and served-tip off:

  • OptInWithoutServedTip_ShortCircuitsToNull: a block above every head returns null and no upstream receives the request
  • OptInWithoutServedTip_OnlyMostAheadHasBlock_Dispatches: a block above the corroborated head that the most-ahead upstream has is dispatched
  • OptIn_SyncingUpstreamAhead_Dispatches: a syncing upstream ahead of the others means the block is dispatched
  • OptIn_UpstreamWithUnknownHead_Dispatches: one upstream with no known head means the block is dispatched
  • OptIn_CordonedFallbackAhead_Dispatches: primaries at 100, fallbacks at 110 cordoned by the default policy with failover enabled, block 105 is dispatched
  • OptIn_FinalizedConfidence_UnfinalizedBlock_Dispatches: emptyResultConfidence: finalizedBlock, finalized 90, latest 98-100, block 95 is dispatched
  • OptIn_ShortCircuitNotMirroredToExcluded: default policy with cordoned fallbacks, 5 short-circuited requests reach no upstream, mirrored probes included. Fails 3/3 with the old order

The syncing, unknown-head and cordoned-fallback tests fail with the previous Available ceiling; the finalized one fails with the finalized-axis comparison. The forwarding tests register their mocks before network setup and match only the requested block number, so the state poller's polls never hit them.

common/config_test.go:

  • TestNetworkConfig_SetDefaults_ShortCircuitFutureBlocksInheritsFromDefaults: a true default is inherited, and an explicit network-level false overrides it

  • TestEvmNetworkConfig_ShortCircuitFutureBlocksEnabled: nil, unset, false, true, and served-tip for latest vs finalized only

  • go build ./..., go vet ./erpc (only the existing query_executor_test.go:341 warning)

  • go test ./common/ ./architecture/evm/ pass

  • go test ./erpc/ -timeout 40m: everything passes except two tests that also fail on the base branch:

    • TestHttpServer_H2C_ShutdownGoAwaysAndDrainsInFlightStream
    • TestNetwork_Forward/ForwardLlamaRPCEndpointRateLimitResponseSingle (10/10 failures with and without this change)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The short-circuit can return null despite a routable syncing or fallback upstream having the requested block.

1 open finding
What changed in this PR

Enables future-block short-circuiting when served-tip is disabled.

Changes:

  • Removes the served-tip eligibility gate.
  • Updates tests for default-mode short-circuiting and ahead-upstream dispatch.
File Description
erpc/​networks.go Enables short-circuiting for all EVM networks.
erpc/​networks_future_block_test.go Tests disabled served-tip behavior.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread erpc/networks.go Outdated
tryShortCircuitFutureBlock returns null for a numbered eth_getBlockByNumber
above the highest head any eligible upstream has (static caps included), so
it never nulls a block an upstream has. It only runs when served-tip is
enabled for latest.

Clients that poll the next block by number get a null from each upstream in
turn on networks without served-tip, so every poll walks every routed
upstream, fallbacks included, and the retry layer repeats the walk.

Add evm.shortCircuitFutureBlocks to enable the short-circuit on its own,
without changing what latest resolves to. It is off by default: the head is
what eRPC last saw, so a block produced since then can briefly get null
(the issue 934 case), which only suits networks whose heads arrive promptly.
@shpookas
shpookas force-pushed the fix/future-block-short-circuit branch from d93186c to b5a30a3 Compare October 9, 2026 09:40
@shpookas shpookas changed the title fix(network): short-circuit future blocks without served-tip feat(evm): opt-in short-circuit for future blocks without served-tip Oct 9, 2026
@shpookas
shpookas requested a balanced review from Copilot October 9, 2026 09:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Documentation completeness, inheritance coverage, and deterministic gock setup need correction.

5 open findings
1 resolved since last review

🧠 Review effort: Balanced

Comment thread common/defaults.go
Comment thread erpc/networks_future_block_test.go Outdated
Comment thread common/config.go Outdated
Comment thread docs/pages/config/projects/networks.mdx Outdated
Comment thread docs/pages/config/projects/networks.mdx Outdated
The future-block short-circuit compares against the head reference, which
leaves syncing upstreams out. An upstream that reports syncing still receives
requests unless evm.skipWhenSyncing is set, so it may already have a block
above that reference. Dispatch when one of them is at or above the block.

Also:
- test networkDefaults inheritance and an explicit false override
- register gock mocks before network setup in the new forwarding tests
- docs: config schema row, edge cases, span attribute, comment style

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Finalized-block confidence can incorrectly short-circuit blocks already available at an upstream’s latest head.

2 open findings
4 resolved since last review

🧠 Review effort: Balanced

Comment thread erpc/networks.go Outdated
The future-block short-circuit used finalized heads when
emptyResultConfidence is finalizedBlock, so a block between an upstream's
finalized and latest head returned null without being dispatched.
emptyResultConfidence decides how an empty upstream answer is treated; it
should not decide whether an upstream can have the block. Always compare
against latest heads, including for dispatchable syncing upstreams.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The short circuit can incorrectly return null when dispatchable upstream heads are unknown or omitted from wildcard policy candidates.

0 open findings

2 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Fail open when any dispatchable upstream has an unknown head

erpc/​networks.go:940

Available only fails open when every candidate lacks a head; evmTipBallot silently drops each individual upstream whose poller/effective latest head is unknown. With one known upstream at 100 and another dispatchable upstream with an unknown head, block 101 is therefore synthesized as null even though the latter may already serve it. Before short-circuiting, fail open if any potentially dispatchable candidate has no positive latest-head observation, and add a mixed known/unknown regression case.

Medium severity Include fallback-escape upstreams when computing the request ceiling

erpc/​networks.go:943

This ceiling uses the wildcard selection-policy candidates rather than every upstream this request may dispatch to. The default policy excludes tier:fallback, but onDefaultsExhausted can later add those upstreams via GetFallbackEscapeUpstreams; thus primaries at head 100 plus a fallback at 110 will return an undispatched null for block 105 instead of letting the fallback serve it. Method-/boundary-scoped policy slots can similarly differ from the wildcard slot. Build the ceiling from the selected request upstreams plus eligible fallback-escape candidates.

🧠 Review effort: Balanced

The future-block ceiling came from the selection policy's wildcard
candidates plus a separate pass over syncing upstreams. That missed
upstreams a request can still reach: fallbacks the policy cordons off but
the per-request escape brings back, and method-scoped policy slots. It also
skipped upstreams with an unknown head, so a block one of them already had
returned null.

Compute the ceiling over every upstream of the network instead, regardless
of policy, tier or syncing state, and fail open while any upstream's head is
unknown. Replaces dispatchableSyncingHead.
@shpookas

shpookas commented Oct 9, 2026

Copy link
Copy Markdown
Author

Addressed both "previously missed" findings from the last review in 8d4b27d. Instead of mirroring routing (policy candidates per method, fallback escape, skipWhenSyncing), the ceiling is now the highest latest head across every upstream of the network, and the short-circuit fails open while any upstream's head is unknown:

  • unknown head: OptIn_UpstreamWithUnknownHead_Dispatches
  • fallback cordoned by policy but reachable through the escape: OptIn_CordonedFallbackAhead_Dispatches

Both fail with the previous Available ceiling. Trade-off: an upstream whose head is never learned keeps the short-circuit off for that network (documented in edge case 24).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

Probe publication can still dispatch short-circuited requests to excluded upstreams.

2 open findings
Previously missed (1)

In code that hasn't changed since last review

Medium severity Probe dispatch bypasses future-block short-circuit

erpc/​networks.go:915

Forward publishes this request to the policy probe bus before calling this helper (erpc/networks.go:2173-2175). Because the default policy enables probeExcluded, an excluded upstream can still receive an asynchronous mirrored eth_getBlockByNumber even when this returns the synthetic null, contradicting the option's no-dispatch guarantee and preserving upstream load. Move probe publication after the future-block short-circuit, and cover an active excluded-upstream prober in the test.

🧠 Review effort: Balanced

Comment thread common/config.go Outdated
Comment thread docs/pages/config/projects/networks.mdx Outdated
Forward published each request to the selection policy's probe bus before
the future-block short-circuit, so probeExcluded still mirrored requests
that were answered with a synthetic null to excluded upstreams. Run the
short-circuit first. It only matches eth_getBlockByNumber, which the
network pre-forward hook does not handle, so nothing else moves.

Also describe the ceiling as every upstream of the network in the config
comment, TypeScript type and docs, and point edge case 24 at the
short-circuit code.
@shpookas

shpookas commented Oct 9, 2026

Copy link
Copy Markdown
Author

Addressed the last review in decea44:

  • probe dispatch: the short-circuit now runs before the probe-bus publish, so a synthetic null is never mirrored to excluded upstreams. OptIn_ShortCircuitNotMirroredToExcluded fails 3/3 with the old order (28 mirrored calls for 5 requests) and passes with the fix. The network pre-forward hook it now runs ahead of does not handle eth_getBlockByNumber.
  • "eligible" wording: the config comment, TypeScript type and docs snippets now say every upstream of the network, including that it never fires while any upstream's head is unknown.
  • edge case 24 now links to tryShortCircuitFutureBlock and futureBlockCeiling.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Safety documentation overstates guarantees despite the acknowledged stale-head behavior.

0 open findings

2 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Low severity Limit dispatch guarantee to latest observed effective heads

docs/​pages/​config/​projects/​networks.mdx:104

“A block any upstream has is still dispatched” contradicts the documented stale-head behavior below: an upstream may have produced the block after its last reported head, in which case this option returns null. Limit this guarantee to the latest observed effective heads.

Low severity Clarify safety claim to reference last observed effective heads

erpc/​networks.go:890

This safety claim is stronger than the implementation: the ceiling uses the last observed heads, so the function can return null for a block that an upstream already serves but whose poller/subscription has not reported it yet (the PR documents this stale-head case). Phrase this in terms of the last effective-head observations rather than actual upstream capability so the function contract does not contradict the opt-in caveat.

🧠 Review effort: Balanced

The short-circuit compares against the last heads eRPC observed, so it never nulls a block at or below a reported head, but a block produced after the last observed head can still get null. Say that in the tryShortCircuitFutureBlock comment and the docs instead of promising any block an upstream has is dispatched.
@shpookas

shpookas commented Oct 9, 2026

Copy link
Copy Markdown
Author

Addressed the two wording findings in dbecbcb: the tryShortCircuitFutureBlock comment and the docs now state the guarantee in terms of the last observed heads (never nulls a block at or below a head an upstream has reported), and say that a block produced after the last observed head can still get null, matching the stale-head caveat. Comment and docs only, no logic change.

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