You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
feat(evm): opt-in short-circuit for future blocks without served-tip - #33
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
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
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_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:
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
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
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
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.
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.
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.
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.
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:
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).
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.
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.
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.
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.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Kept separate from
feat/websocket-supporton purpose. Retarget tomainonce 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 treatnullas "not yet". No upstream has the block, so each upstream returnsnullin 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.tryShortCircuitFutureBlockalready returnsnullfromForwardbefore any dispatch when the block is above every upstream's head. But it only runs on networks that enable served-tip forlatest, so networks without it (the default) still fan out. Enabling served-tip to get it also changes whatlatestresolves to.Change
New network option
evm.shortCircuitFutureBlocks(default off, inheritable fromnetworkDefaults.evm). The short-circuit runs when it is true or when served-tip is enabled forlatest, so nothing changes for anyone who does not set it.latestis 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 returnsnullonly 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:skipWhenSyncingsaysemptyResultConfidence: finalizedBlock. That setting decides how an empty upstream answer is treated, not whether an upstream can have the blockSo 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
Forwardpublishes the request to the selection policy's probe bus. Before,probeExcludedstill mirrored requests that were answered with the syntheticnullto 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 handleeth_getBlockByNumber, so nothing else changes order.This also changes the short-circuit for networks that already enable served-tip for
latest: it usedevmHeadReference(...).Available(policy-eligible, non-syncing upstreams, finalized axis underfinalizedBlockconfidence). 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 ownnewHeads) getsnullinstead of being served. That is acceptable where heads arrive promptly over WebSocket and the clients retry anull, but not as a default for every network. The docs say so.Files
common/config.go:EvmNetworkConfig.ShortCircuitFutureBlocksandShortCircuitFutureBlocksEnabled()common/defaults.go: inherit fromnetworkDefaults.evmerpc/networks.go: gate onShortCircuitFutureBlocksEnabled(),futureBlockCeiling, short-circuit before the probe-bus publishtypescript/config/src/generated.ts:shortCircuitFutureBlocks?: booleandocs/pages/config/projects/networks.mdx: "Future-block polling" section; agent reference schema row,networkDefaultsinheritance list, edge cases 23, 24 and 31, and thefuture_block.short_circuitspan attributeTests
erpc/networks_future_block_test.go(existing tests unchanged). With the option on and served-tip off:OptInWithoutServedTip_ShortCircuitsToNull: a block above every head returnsnulland no upstream receives the requestOptInWithoutServedTip_OnlyMostAheadHasBlock_Dispatches: a block above the corroborated head that the most-ahead upstream has is dispatchedOptIn_SyncingUpstreamAhead_Dispatches: a syncing upstream ahead of the others means the block is dispatchedOptIn_UpstreamWithUnknownHead_Dispatches: one upstream with no known head means the block is dispatchedOptIn_CordonedFallbackAhead_Dispatches: primaries at 100, fallbacks at 110 cordoned by the default policy with failover enabled, block 105 is dispatchedOptIn_FinalizedConfidence_UnfinalizedBlock_Dispatches:emptyResultConfidence: finalizedBlock, finalized 90, latest 98-100, block 95 is dispatchedOptIn_ShortCircuitNotMirroredToExcluded: default policy with cordoned fallbacks, 5 short-circuited requests reach no upstream, mirrored probes included. Fails 3/3 with the old orderThe syncing, unknown-head and cordoned-fallback tests fail with the previous
Availableceiling; 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: atruedefault is inherited, and an explicit network-levelfalseoverrides itTestEvmNetworkConfig_ShortCircuitFutureBlocksEnabled: nil, unset, false, true, and served-tip forlatestvsfinalizedonlygo build ./...,go vet ./erpc(only the existingquery_executor_test.go:341warning)go test ./common/ ./architecture/evm/passgo test ./erpc/ -timeout 40m: everything passes except two tests that also fail on the base branch:TestHttpServer_H2C_ShutdownGoAwaysAndDrainsInFlightStreamTestNetwork_Forward/ForwardLlamaRPCEndpointRateLimitResponseSingle(10/10 failures with and without this change)