diff --git a/common/config.go b/common/config.go index 086211fcd..9af78f052 100644 --- a/common/config.go +++ b/common/config.go @@ -2704,6 +2704,17 @@ type EvmNetworkConfig struct { // finalized head; an unfinalized block's empty is treated as not-yet-confirmed. EmptyResultConfidence AvailbilityConfidence `yaml:"emptyResultConfidence,omitempty" json:"emptyResultConfidence,omitempty"` + // ShortCircuitFutureBlocks returns null for a numbered eth_getBlockByNumber + // above the highest latest head across every upstream of the network + // (regardless of selection policy, tier or syncing state), without + // dispatching it; it never fires while any upstream's head is unknown. + // Clients that poll the next block by number otherwise walk every routed + // upstream on each poll. Always on when servedTip is enabled for "latest". + // The head is what the state poller and head subscriptions last reported, so + // a block an upstream produced since then gets null until eRPC sees it: only + // enable it where heads arrive promptly (e.g. over WebSocket). + ShortCircuitFutureBlocks *bool `yaml:"shortCircuitFutureBlocks,omitempty" json:"shortCircuitFutureBlocks,omitempty"` + // SafeBlockSource is an upstream id/tag selector for standard JSON-RPC // requests carrying the `safe` block tag. Matching upstreams define and // serve `safe`; empty (without an inherited network default) keeps existing @@ -2784,6 +2795,20 @@ type EvmServedTipConfig struct { TrajectoryWindow *Duration `yaml:"trajectoryWindow,omitempty" json:"trajectoryWindow,omitempty"` } +// ShortCircuitFutureBlocksEnabled reports whether a numbered eth_getBlockByNumber +// above every upstream's head returns null without dispatching: when +// ShortCircuitFutureBlocks is true, or servedTip is enabled for "latest". +// Nil-receiver safe. +func (c *EvmNetworkConfig) ShortCircuitFutureBlocksEnabled() bool { + if c == nil { + return false + } + if c.ShortCircuitFutureBlocks != nil && *c.ShortCircuitFutureBlocks { + return true + } + return c.ServedTipEnabledFor("latest") +} + // ServedTipEnabledFor reports whether the majority served tip is enabled for // the given block axis ("latest" or "finalized"). The "safe" tag resolves to the // finalized axis, so listing "safe" in EnabledFor enables it for "finalized". diff --git a/common/config_test.go b/common/config_test.go index 9963e5f5f..89873afc6 100644 --- a/common/config_test.go +++ b/common/config_test.go @@ -1297,6 +1297,32 @@ func TestNetworkConfig_SetDefaults_StripSubscribeFromBlockZeroInheritsFromDefaul assert.Equal(t, &strip, n.Evm.StripSubscribeFromBlockZero) } +func TestNetworkConfig_SetDefaults_ShortCircuitFutureBlocksInheritsFromDefaults(t *testing.T) { + enabled, disabled := true, false + defaults := &NetworkDefaults{Evm: &EvmNetworkConfig{ShortCircuitFutureBlocks: &enabled}} + + inherited := &NetworkConfig{Architecture: ArchitectureEvm, Evm: &EvmNetworkConfig{ChainId: 1}} + assert.NoError(t, inherited.SetDefaults(nil, defaults)) + assert.True(t, inherited.Evm.ShortCircuitFutureBlocksEnabled()) + + // An explicit network-level false wins over a true default. + overridden := &NetworkConfig{Architecture: ArchitectureEvm, Evm: &EvmNetworkConfig{ChainId: 1, ShortCircuitFutureBlocks: &disabled}} + assert.NoError(t, overridden.SetDefaults(nil, defaults)) + assert.False(t, overridden.Evm.ShortCircuitFutureBlocksEnabled()) +} + +func TestEvmNetworkConfig_ShortCircuitFutureBlocksEnabled(t *testing.T) { + var nilCfg *EvmNetworkConfig + assert.False(t, nilCfg.ShortCircuitFutureBlocksEnabled()) + assert.False(t, (&EvmNetworkConfig{}).ShortCircuitFutureBlocksEnabled()) + enabled, disabled := true, false + assert.False(t, (&EvmNetworkConfig{ShortCircuitFutureBlocks: &disabled}).ShortCircuitFutureBlocksEnabled()) + assert.True(t, (&EvmNetworkConfig{ShortCircuitFutureBlocks: &enabled}).ShortCircuitFutureBlocksEnabled()) + // servedTip for "latest" keeps the short-circuit on, as before. + assert.True(t, (&EvmNetworkConfig{ServedTip: &EvmServedTipConfig{EnabledFor: []string{"latest"}}}).ShortCircuitFutureBlocksEnabled()) + assert.False(t, (&EvmNetworkConfig{ServedTip: &EvmServedTipConfig{EnabledFor: []string{"finalized"}}}).ShortCircuitFutureBlocksEnabled()) +} + func TestUpstreamConfig_ValidateRateLimitCountMode(t *testing.T) { cfg := &Config{} base := func(mode RateLimitCountMode) *UpstreamConfig { diff --git a/common/defaults.go b/common/defaults.go index ba247afac..9d08d3bec 100644 --- a/common/defaults.go +++ b/common/defaults.go @@ -2280,6 +2280,9 @@ func (n *NetworkConfig) SetDefaults(upstreams []*UpstreamConfig, defaults *Netwo if n.Evm.EmptyResultConfidence == 0 && defaults.Evm.EmptyResultConfidence != 0 { n.Evm.EmptyResultConfidence = defaults.Evm.EmptyResultConfidence } + if n.Evm.ShortCircuitFutureBlocks == nil && defaults.Evm.ShortCircuitFutureBlocks != nil { + n.Evm.ShortCircuitFutureBlocks = defaults.Evm.ShortCircuitFutureBlocks + } if n.Evm.StripSubscribeFromBlockZero == nil && defaults.Evm.StripSubscribeFromBlockZero != nil { n.Evm.StripSubscribeFromBlockZero = defaults.Evm.StripSubscribeFromBlockZero } diff --git a/docs/pages/config/projects/networks.mdx b/docs/pages/config/projects/networks.mdx index aa3089462..120217f44 100644 --- a/docs/pages/config/projects/networks.mdx +++ b/docs/pages/config/projects/networks.mdx @@ -99,6 +99,54 @@ export default createConfig({ Leave unset for chains where upstreams handle `fromBlock: "0x0"` in an `eth_subscribe` filter correctly (most do — the flag is a targeted workaround, not a universal default). It can also be set once for every network under `networkDefaults.evm`. +## Future-block polling + +Clients that follow a chain by number (for example rollup nodes reading L1) poll `eth_getBlockByNumber` for the block after the head and treat `null` as "not produced yet". No upstream has that block, so each one returns `null` in turn and every poll walks every routed upstream, fallbacks included. Enable `shortCircuitFutureBlocks` to return `null` for a numbered `eth_getBlockByNumber` above the latest head of every upstream of the network (static availability caps included), without dispatching it. A block at or below the last head any upstream reported is still dispatched, and the request is dispatched as usual while any upstream's head is unknown. It is always on when `servedTip.enabledFor` includes `"latest"`. + + + +```yaml filename="erpc.yaml" +projects: + - id: main + networks: + - architecture: evm + evm: + chainId: 1 + # Returns null for eth_getBlockByNumber(N) when N is above every + # upstream's head, instead of asking each upstream. + # default: false + shortCircuitFutureBlocks: true +``` + + +```ts filename="erpc.ts" +import { createConfig } from "@erpc-cloud/config"; + +export default createConfig({ + projects: [ + { + id: "main", + networks: [ + { + architecture: "evm", + evm: { + chainId: 1, + // Returns null for eth_getBlockByNumber(N) when N is above every + // upstream's head, instead of asking each upstream. + // default: false + shortCircuitFutureBlocks: true, + }, + }, + ], + }, + ], +}); +``` + + + +The head is what eRPC last saw from the state poller and head subscriptions. A block an upstream produced after that gets `null` until eRPC sees it, so a client that learned of the block elsewhere (for example from the node's own `newHeads`) can briefly get `null` for a block that exists. Only enable it where heads arrive promptly, such as networks with WebSocket head subscriptions, and where clients retry a `null`. + ## Failover By default a request only reaches the upstreams the [selection policy](/config/projects/selection-policies) currently routes to; upstreams tagged `tier:fallback` join once the policy promotes them, which happens on its evaluation interval. `failover.onDefaultsExhausted` adds a per-request escape for the moments before that: when every routed upstream failed with an error that another upstream might not repeat, the same request is retried once on the `tier:fallback` upstreams it has not tried yet — even while the policy still keeps them cordoned. @@ -286,6 +334,7 @@ https://docs.erpc.cloud/config/projects/networks.llms.txt`} | `idempotentTransactionBroadcast` | `*bool` | `nil` = **enabled** () | "Already known"/"nonce too low"-verified errors become success-with-tx-hash, making retry/hedge safe for `eth_sendRawTransaction`. | | `markEmptyAsErrorMethods` | `[]string` | `eth_blockNumber`, `eth_getBlockByNumber`, `eth_getTransactionByHash`, and 8 others () | Methods where empty response = error (retried, upstream scored down). `eth_getTransactionReceipt` deliberately excluded. | | `emptyResultConfidence` | `blockHead` \| `finalizedBlock` | `blockHead` () | How confirmed a block must be for empty point-lookups to be retried as missing data. | +| `shortCircuitFutureBlocks` | `*bool` | `nil` = **off**; inherited from `networkDefaults.evm` () | Returns `null` for a numbered `eth_getBlockByNumber` above the highest latest head across every upstream of the network (static caps included; selection policy, tier and syncing state ignored), without dispatching. Fails open while any upstream's head is unknown. Uses latest heads regardless of `emptyResultConfidence`. Always on when `servedTip.enabledFor` includes `latest` (). Does not change what `latest` resolves to. | | `safeBlockSource` | `string` (upstream selector) | `""` = inherit or provider-defined routing | Routes standard block-tagged JSON-RPC requests carrying `safe` to matching upstreams and skips cache reads. The tag stays verbatim so those upstreams define both height and hash. Operator routing overrides client `use-upstream`; retry/consensus still applies inside the matching pool. An empty network value inherits `networkDefaults.evm.safeBlockSource` when present. Does not affect `eth_query*` or direct gRPC Query APIs. | | `evm.integrity.enforceHighestBlock` | `*bool` | `true` () | **Deprecated** — migrated into `directiveDefaults.enforceHighestBlock` at `SetDefaults` time when the directive is unset (). Prefer `directiveDefaults.enforceHighestBlock`. | | `evm.integrity.enforceGetLogsBlockRange` | `*bool` | `true` () | **Deprecated** — same migration path as `enforceHighestBlock`. | @@ -336,7 +385,7 @@ Not present on `SvmNetworkConfig`: there is no `maxSlotsPerSignaturesQuery`. An | `failsafe[]` | `[]FailsafeConfig` | `nil` | Network has none → deep-copied wholesale. Network has some → per-entry merge from the FIRST compatible default (wildcard method + finality match); break on first match (). | | `selectionPolicy` | `SelectionPolicyConfig` | `nil` | Shallow-copied when network's is nil (). | | `directiveDefaults` | `DirectiveDefaultsConfig` | `nil` | Shallow-copied when network's is nil — **no per-field merge** (). | -| `evm` | `EvmNetworkConfig` | `nil` | Struct-copied wholesale when network has no `evm` block — **except onto SVM networks, which never receive it**. Otherwise per-field fill for: `integrity`, `fallbackStatePollerDebounce`, `dynamicBlockTimeDebounceMultiplier`, `blockUnavailableDelayMultiplier`, `fallbackFinalityDepth`, `getLogsMaxAllowed*`, `getLogs*`, `traceFilter*`, `servedTip`, `emptyResultConfidence`. NOT inherited individually: `chainId`, `enforceBlockAvailability`, `maxRetryableBlockDistance`, `markEmptyAsErrorMethods`, `idempotentTransactionBroadcast` (). | +| `evm` | `EvmNetworkConfig` | `nil` | Struct-copied wholesale when network has no `evm` block — **except onto SVM networks, which never receive it**. Otherwise per-field fill for: `integrity`, `fallbackStatePollerDebounce`, `dynamicBlockTimeDebounceMultiplier`, `blockUnavailableDelayMultiplier`, `fallbackFinalityDepth`, `getLogsMaxAllowed*`, `getLogs*`, `traceFilter*`, `servedTip`, `emptyResultConfidence`, `shortCircuitFutureBlocks`. NOT inherited individually: `chainId`, `enforceBlockAvailability`, `maxRetryableBlockDistance`, `markEmptyAsErrorMethods`, `idempotentTransactionBroadcast` (). | | `svm` | `SvmNetworkConfig` | `nil` | Per-field fill: `chain`, `commitment`, `statePollerDebounce`, `maxFinalizedSlotLag`, `enforceBlockAvailability` (). **`cluster` is deliberately NOT inherited** — it is network identity, and inheriting it would let two networks alias one another. The two pointer fields are tested for `nil`, not for their zero value, so an explicit `maxFinalizedSlotLag: 0` or `enforceBlockAvailability: false` set here survives the merge. | | `multiplexing` | `*bool` | `nil` | Value-copied when network's `multiplexing` is nil (). No deep merge — a network must set its own `multiplexing` to deviate from the project-wide default. | @@ -694,14 +743,15 @@ POST /main → body {"networkId": "svm:fogo:mainnet", ...} 20. **Failed lazy bootstrap is user-retryable** — non-fatal bootstrap tasks are retried by the background auto-retry loop and by the next request's `ExecuteTasks`. Only `resolveNetworkConfig` format errors are `TaskFatal` and never retried. [`erpc/networks_registry.go:L263-266`](https://github.com/erpc/erpc/blob/main/erpc/networks_registry.go#L263-L266) 21. **Provider `onlyNetworks` bypasses vendor support checks entirely** — a networkId listed in `onlyNetworks` returns `SupportsNetwork = true` without consulting the vendor's dynamic check; `ignoreNetworks` is evaluated first and short-circuits to false. [`thirdparty/provider.go:L34-49`](https://github.com/erpc/erpc/blob/main/thirdparty/provider.go#L34-L49) 22. **Synthesized lazy network configs become permanently visible** — `ExposeNetworkConfig` appends to `project.Config.Networks` (admin API surface) and never overwrites an existing entry, so lazy networks persist in the admin response after first use. [`erpc/projects.go:L58-77`](https://github.com/erpc/erpc/blob/main/erpc/projects.go#L58-L77) -23. **Syncing upstreams are excluded from every head computation** — max mode, cluster mode, lowest-finalized, and guaranteed-method floor calculations all skip upstreams in `EvmSyncingStateSyncing`. [`erpc/networks.go:L327-330`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L327-L330) -24. **Future-block short-circuit compares against the MAX eligible head, not the cluster-min served tip** — it never nulls a block that the most-ahead upstream reports having. The synthetic null response is never cached, and the short-circuit only fires for concrete block numbers (not tags or hashes). [`erpc/networks.go:L570-610`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L570-L610) +23. **Syncing upstreams are excluded from every head computation** — max mode, cluster mode, lowest-finalized, and guaranteed-method floor calculations all skip upstreams in `EvmSyncingStateSyncing`. The future-block short-circuit is the exception: it counts every upstream of the network, syncing ones included ([`erpc/networks.go:L935-954`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L935-L954)). [`erpc/networks.go:L327-330`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L327-L330) +24. **Future-block short-circuit compares against the MAX head across every upstream of the network, not the cluster-min served tip** — selection policy, tier (a cordoned fallback can still serve through the escape) and syncing state are ignored, and it fails open while any upstream's head is unknown, so it never nulls a block that any upstream reports having. One upstream whose head is never learned (for example a broken endpoint) therefore keeps it from firing on that network. It runs only when `evm.shortCircuitFutureBlocks` is true or `servedTip.enabledFor` includes `latest`, and always compares latest heads: `emptyResultConfidence: finalizedBlock` does not make it null an unfinalized block an upstream already has. The synthetic null response is never cached, and the short-circuit only fires for concrete block numbers (not tags or hashes). [`erpc/networks.go:L901-954`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L901-L954) 25. **Wrong-empty misbehavior is suppressed for out-of-bounds blocks** — if the block number in a missing-data response falls outside an upstream's configured `blockAvailability` bounds, the empty response is expected and no misbehavior is recorded against that upstream. [`erpc/networks.go:L1566-1587`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L1566-L1587) 26. **`getLogsMaxAllowedRange: 0` is rejected by validation** — the field must be > 0 after defaults (`common/validation.go:L1309-1311`). Unlike `getLogsMaxAllowedAddresses` and `getLogsMaxAllowedTopics` (which default to 0 = unlimited), `getLogsMaxAllowedRange` defaults to 30 000 and cannot be explicitly zeroed out; setting `0` in a network inheriting from defaults is treated as "unset" and inherits the parent value. [`common/defaults.go:L2092-2094`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L2092-L2094) 27. **Zero projects → implicit `main` project + catch-all aliasing rule** — when the config has no projects, eRPC auto-creates `{matchDomain: "*", serveProject: "main"}`, so `/evm/123` works without a project path segment. [`common/defaults.go:L100-110`](https://github.com/erpc/erpc/blob/main/common/defaults.go#L100-L110) 28. **Selector-scoped tips never pollute network gauges** — stateless scoped picks (unmatched or non-simple selectors) use a sentinel lane and emit no Prometheus gauge; equivalent selectors dedup into one partition keyed by matched-set hash; the cap of 16 partitions is enforced globally per network. [`erpc/networks.go:L98-105`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L98-L105) 29. **Static response params matching has no wildcard** — there is no glob or `*` support; every params slot must match exactly. To catch a method regardless of params, add an entry with an empty `params` array. To match block 0 (`"0x0"`), be exact — `"0x00"` will not match. [`common/static_response.go:L14-99`](https://github.com/erpc/erpc/blob/main/common/static_response.go#L14-L99) 30. **Same-chain networks share Redis cache keys unless `cacheKeySuffix` is set** — partition keys are `{networkId}:{blockRef}` (`evm:998:64321354`). Two projects on chainId 998 with a shared Redis will HIT each other's bodies. `X-ERPC-Skip-Cache-Read` still writes, so the last fetch poisons the other project. Set `cacheKeySuffix: systx` on one network to get `evm:998:systx:{blockRef}`. Colon is rejected. Reverse-index wildcard is `{networkId}:{suffix}:*`, passed from the known network id and suffix rather than inferred from the concrete key. Memory/Redis `Delete` must use the same `WithReverseIndex` ctx as `Set`. A chain-wide main-key flush of `evm:998:*` still matches suffixed data keys. [`common/config.go:L3022-L3028`](https://github.com/erpc/erpc/blob/main/common/config.go#L3022-L3028), [`data/connector.go`](https://github.com/erpc/erpc/blob/main/data/connector.go) +31. **`shortCircuitFutureBlocks` can briefly null a block that exists** — the head it compares against is what the state poller and head subscriptions last reported. A block an upstream produced after that gets `null` until eRPC sees it, even if the client already saw it via the node's own `newHeads` (the case covered by `TestNetworkAvailability_Issue934_*`, which run with the option off). Enable it only where heads arrive promptly (WebSocket head subscriptions) and clients retry a `null`. [`erpc/networks.go:L901-926`](https://github.com/erpc/erpc/blob/main/erpc/networks.go#L901-L926) ### Observability @@ -734,6 +784,9 @@ POST /main → body {"networkId": "svm:fogo:mainnet", ...} - `"failed to build static response"` (error) — `NewJsonRpcResponse` failed on a matched static entry. - Provider task: `"registering N upstream(s) from provider"` (`upstream/registry.go`). +**Span attributes** (on the `Network.Forward` span): +- `future_block.short_circuit=true` — the future-block short-circuit returned `null` without dispatching (`evm.shortCircuitFutureBlocks` or served-tip for `latest`). No metric counts these; they are not dispatched, so they do not appear in upstream request metrics. + ### Source code entry points - [`erpc/networks_registry.go:L83-376`](https://github.com/erpc/erpc/blob/main/erpc/networks_registry.go#L83-L376) — `NetworksRegistry`: lazy `GetNetwork`, bootstrap tasks, `resolveNetworkConfig` synthesis, alias map register/resolve, `NewNetwork` constructor. diff --git a/erpc/networks.go b/erpc/networks.go index a9aae806f..2cafd468f 100644 --- a/erpc/networks.go +++ b/erpc/networks.go @@ -513,7 +513,6 @@ func (n *Network) gatherEvmTipInputsForMethod( func evmTipBallot(upstreams []common.Upstream, useFinalized bool) (common.ServedTipPick, servedTipReference) { live := make([]common.ServedTipInput, 0, len(upstreams)) var capped []common.ServedTipInput - var available int64 for _, cu := range upstreams { u, ok := cu.(common.EvmUpstream) if !ok || u.EvmStatePoller() == nil { @@ -526,7 +525,6 @@ func evmTipBallot(upstreams []common.Upstream, useFinalized bool) (common.Served if blk <= 0 { continue } - available = max(available, blk) in := common.ServedTipInput{ UpstreamID: u.Id(), BlockNumber: blk, @@ -542,10 +540,10 @@ func evmTipBallot(upstreams []common.Upstream, useFinalized bool) (common.Served // the only observations available. (No live head means no live reference // for the regression guard either; it falls back to its own last // corroborated pick, see guardServedTipRegression.) - return common.PickServedTip(capped), servedTipReference{Available: available} + return common.PickServedTip(capped), servedTipReference{} } pick := common.PickServedTip(live) - return pick, servedTipReference{Corroborated: pick.Freshest, Max: pick.Max, Available: available} + return pick, servedTipReference{Corroborated: pick.Freshest, Max: pick.Max} } // servedTipReference is what the LIVE (non-capped) heads of one ballot say, @@ -582,12 +580,6 @@ type servedTipReference struct { // pick in the very shape the guard exists for (one live witness left, two // stuck upstreams outvoting it). Max int64 - - // Available is the highest effective head across EVERY candidate, live or - // static-capped: the bound above which no candidate holds the block. Only - // the future-block short-circuit reads it; the ballot and the guard ignore - // caps, but a capped archive still serves the blocks below its cap. - Available int64 } // evmTipObservation returns an upstream's effective head for the axis, plus @@ -872,12 +864,12 @@ func (n *Network) servedTipEnabledFor(tag string) bool { return n.cfg != nil && n.cfg.Evm != nil && n.cfg.Evm.ServedTipEnabledFor(tag) } -// evmHeadReference is the corroborated head, the raw live max and the servable -// ceiling over the eligible, non-syncing upstreams for one axis — the same -// ballot the served tip votes on. It serves the default (non-served-tip) head -// accessors and the future-block short-circuit. A cap-only set has no live -// reference, so its caps stand in: they are the only heads anyone in it can -// serve. +// evmHeadReference is the corroborated head and the raw live max over the +// eligible, non-syncing upstreams for one axis — the same ballot the served tip +// votes on. It serves the default (non-served-tip) head accessors; the +// future-block short-circuit uses futureBlockCeiling instead. A cap-only set has +// no live reference, so its caps stand in: they are the only heads anyone in it +// can serve. func (n *Network) evmHeadReference(ctx context.Context, useFinalized bool) servedTipReference { pick, ref := evmTipBallot(n.tipCandidateUpstreams(ctx, "*"), useFinalized) if ref.Max <= 0 && pick.Inputs > 0 { @@ -889,20 +881,24 @@ func (n *Network) evmHeadReference(ctx context.Context, useFinalized bool) serve // tryShortCircuitFutureBlock returns a truthful null response (ok=true) when // `req` is a concrete-numbered eth_getBlockByNumber lookup whose target block is -// beyond every eligible upstream's head (at the network's emptyResultConfidence level). +// beyond every upstream's latest head. // No upstream can serve such a block yet, so dispatching + hedging across all of // them only burns latency and load before they each return empty — returning the // null here skips that fan-out entirely. // -// Safety: it compares against the highest effective head across eligible -// upstreams, static caps included (servedTipReference.Available), so it never -// nulls out a block any upstream actually serves. It is gated on served-tip being enabled for the latest -// axis — the same opt-in that makes the head trustworthy — and the synthesized +// Safety: it compares against the highest last-observed effective latest head +// across every upstream of the network, static caps included, and fails open +// when any of them has no observed head (see futureBlockCeiling), so it never +// nulls out a block at or below a head an upstream has reported. A block an +// upstream produced after its last observed head can still get null until the +// poller or a head subscription reports it. It runs when the network sets +// evm.shortCircuitFutureBlocks or enables served-tip for the latest axis (see +// EvmNetworkConfig.ShortCircuitFutureBlocksEnabled), and the synthesized // response is returned directly from Forward, so it is never written to cache // (the block will exist later). The post-forward empty guard remains as // defense-in-depth for the in-flight case where an upstream advances mid-request. func (n *Network) tryShortCircuitFutureBlock(ctx context.Context, req *common.NormalizedRequest, method string) (*common.NormalizedResponse, bool) { - if n.cfg == nil || n.cfg.Evm == nil || !n.servedTipEnabledFor("latest") { + if n.cfg == nil || !n.cfg.Evm.ShortCircuitFutureBlocksEnabled() { return nil, false } if !strings.EqualFold(method, "eth_getBlockByNumber") { @@ -914,9 +910,8 @@ func (n *Network) tryShortCircuitFutureBlock(ctx context.Context, req *common.No // params carry no concrete future number — never short-circuit. return nil, false } - useFinalized := n.cfg.Evm.EmptyResultConfidence == common.AvailbilityConfidenceFinalized - maxHead := n.evmHeadReference(ctx, useFinalized).Available - if maxHead <= 0 || bn <= maxHead { + maxHead, known := n.futureBlockCeiling(ctx) + if !known || bn <= maxHead { // Unknown head (fail open) or block within reach of some upstream. return nil, false } @@ -929,6 +924,34 @@ func (n *Network) tryShortCircuitFutureBlock(ctx context.Context, req *common.No return resp, true } +// futureBlockCeiling returns the highest effective latest head across every +// upstream of the network, regardless of selection policy, tier or syncing +// state: any of them can end up serving a request (policy order, the fallback +// escape, syncing upstreams without skipWhenSyncing). It uses latest heads +// whatever emptyResultConfidence says: that setting decides how an empty answer +// is treated, not whether an upstream can have the block. known is false when +// the network has no upstreams or any upstream's head is unknown. +func (n *Network) futureBlockCeiling(ctx context.Context) (head int64, known bool) { + if n.upstreamsRegistry == nil { + return 0, false + } + ups := n.upstreamsRegistry.GetNetworkUpstreams(ctx, n.networkId) + if len(ups) == 0 { + return 0, false + } + for _, u := range ups { + if u.EvmStatePoller() == nil { + return 0, false + } + blk, _ := evmTipObservation(u, false) + if blk <= 0 { + return 0, false + } + head = max(head, blk) + } + return head, true +} + // servedTip computes the majority served tip for one axis over the // selection-policy-eligible upstreams — the freshest block a strict majority of // them already has (see common.PickServedTip) — lets the trajectory referee prefer @@ -1497,7 +1520,7 @@ func (n *Network) SvmEnforceBlockAvailability() bool { // This is used only by the networkPreForward_getBlock guard — its job is to // avoid short-circuiting requests that ANY upstream can serve. It therefore // uses MAX, not the majority (median) tip. This mirrors how EVM's -// tryShortCircuitFutureBlock uses evmHeadReference.Max (not PickServedTip.Tip): +// tryShortCircuitFutureBlock uses the max head across upstreams (not PickServedTip.Tip): // "never null out a block the most-ahead upstream actually has." // SvmHighestLatestSlot / SvmHighestFinalizedSlot remain median-based because // they advertise the chain head to clients — a different, conservative goal. @@ -2156,10 +2179,23 @@ func (n *Network) Forward(ctx context.Context, req *common.NormalizedRequest) (* // Set upstreams on the request req.SetUpstreams(upsList) + // Future-block short-circuit: a concrete block number beyond every + // upstream's head cannot be served yet — return the truthful null instead of + // dispatching + hedging across upstreams that will all return empty. Runs + // before rate limiting so a non-dispatched request consumes no permit, and + // before the probe-bus publish so it is never mirrored to excluded upstreams. + if resp, ok := n.tryShortCircuitFutureBlock(ctx, req, method); ok { + forwardSpan.SetAttributes(attribute.Bool("future_block.short_circuit", true)) + if mlx != nil { + mlx.Close(ctx, resp, nil) + } + return resp, nil + } + // Feed the per-network probe-bus AFTER we know the request is // actually going to dispatch to an upstream (i.e. not a - // cache-hit / static-response / follower-multiplexer - // short-circuit, all of which returned earlier). The publish is + // cache-hit / static-response / follower-multiplexer / + // future-block short-circuit, all of which returned earlier). The publish is // non-blocking and drops on overflow — request latency is never // affected. The Prober (if any) samples from this feed to mirror // the request against currently-excluded upstreams so their @@ -2186,18 +2222,6 @@ func (n *Network) Forward(ctx context.Context, req *common.NormalizedRequest) (* } } - // Future-block short-circuit: a concrete block number beyond every eligible - // upstream's head cannot be served yet — return the truthful null instead of - // dispatching + hedging across upstreams that will all return empty. Runs - // before rate limiting so a non-dispatched request consumes no permit. - if resp, ok := n.tryShortCircuitFutureBlock(ctx, req, method); ok { - forwardSpan.SetAttributes(attribute.Bool("future_block.short_circuit", true)) - if mlx != nil { - mlx.Close(ctx, resp, nil) - } - return resp, nil - } - // 3) Check if we should handle this method on this network if err := n.shouldHandleMethod(req, method, upsList); err != nil { if mlx != nil { diff --git a/erpc/networks_future_block_test.go b/erpc/networks_future_block_test.go index 007a1659a..db5367630 100644 --- a/erpc/networks_future_block_test.go +++ b/erpc/networks_future_block_test.go @@ -4,7 +4,9 @@ import ( "context" "net/http" "strings" + "sync/atomic" "testing" + "time" "github.com/erpc/erpc/common" "github.com/erpc/erpc/util" @@ -168,3 +170,238 @@ func TestForward_FutureBlock_LatestTag_Dispatches(t *testing.T) { assert.Contains(t, jrr.GetResultString(), "0x270f", "latest tag has no concrete future number; must dispatch normally") } + +// mockGetBlockByNumberAt makes every listed upstream answer +// eth_getBlockByNumber for blockHex only, counting each dispatch. Matching the +// concrete number keeps the state poller's latest/finalized polls off it, so it +// can be registered before the network is set up. +func mockGetBlockByNumberAt(blockHex, result string, dispatched *atomic.Int64, ids ...string) { + for _, id := range ids { + gock.New("http://" + id + ".localhost"). + Post(""). + Persist(). + Filter(func(r *http.Request) bool { + body := util.SafeReadBody(r) + if !strings.Contains(body, "eth_getBlockByNumber") || !strings.Contains(body, `"`+blockHex+`"`) { + return false + } + dispatched.Add(1) + return true + }). + Reply(200). + JSON([]byte(`{"jsonrpc":"2.0","id":1,"result":` + result + `}`)) + } +} + +const futureBlockSentinel = `{"number":"0x270f","hash":"0xabc"}` + +func forwardGetBlockByNumber(t *testing.T, ctx context.Context, network *Network, blockHex string) *common.NormalizedResponse { + t.Helper() + req := common.NewNormalizedRequest([]byte( + `{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["` + blockHex + `",false]}`)) + resp, err := network.Forward(ctx, req) + require.NoError(t, err) + require.NotNil(t, resp) + return resp +} + +// evm.shortCircuitFutureBlocks enables the short-circuit without served-tip: a +// block above every head returns null and reaches no upstream. +func TestForward_FutureBlock_OptInWithoutServedTip_ShortCircuitsToNull(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + util.SetupMocksForEvmStatePoller() + var dispatched atomic.Int64 + mockGetBlockByNumberAt("0x69", "null", &dispatched, "fb1", "fb2", "fb3") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + network, _ := setupServedTipNetworkWith(t, ctx, []servedTipFixture{ + {id: "fb1", chainID: 123, latestBlock: 100}, + {id: "fb2", chainID: 123, latestBlock: 99}, + {id: "fb3", chainID: 123, latestBlock: 98}, + }, nil) + network.cfg.Evm.ShortCircuitFutureBlocks = util.BoolPtr(true) + + // max head = 100; block 105 (0x69) is beyond every upstream. + resp := forwardGetBlockByNumber(t, ctx, network, "0x69") + assert.True(t, resp.IsResultEmptyish(ctx), + "block 105 > max head 100 must short-circuit to null") + assert.Zero(t, dispatched.Load(), + "a block beyond every upstream's head must not be dispatched to any upstream") +} + +// Without served-tip "latest" is the corroborated (second-highest) head, but a +// block only the most-ahead upstream has is still servable: with the opt-in it +// must be dispatched, not nulled. +func TestForward_FutureBlock_OptInWithoutServedTip_OnlyMostAheadHasBlock_Dispatches(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + util.SetupMocksForEvmStatePoller() + var dispatched atomic.Int64 + mockGetBlockByNumberAt("0x64", futureBlockSentinel, &dispatched, "fb1", "fb2", "fb3") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + network, _ := setupServedTipNetworkWith(t, ctx, []servedTipFixture{ + {id: "fb1", chainID: 123, latestBlock: 100}, + {id: "fb2", chainID: 123, latestBlock: 99}, + {id: "fb3", chainID: 123, latestBlock: 98}, + }, nil) + network.cfg.Evm.ShortCircuitFutureBlocks = util.BoolPtr(true) + + // block 100 (0x64) is above the corroborated head (99) but fb1 has it. + resp := forwardGetBlockByNumber(t, ctx, network, "0x64") + jrr, err := resp.JsonRpcResponse(ctx) + require.NoError(t, err) + assert.Contains(t, jrr.GetResultString(), "0x270f", + "a block the most-ahead upstream has must be dispatched, not short-circuited") +} + +// Every upstream of the network counts toward the ceiling, syncing ones +// included: a block at or below a syncing upstream's head must be dispatched. +func TestForward_FutureBlock_OptIn_SyncingUpstreamAhead_Dispatches(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + util.SetupMocksForEvmStatePoller() + var dispatched atomic.Int64 + mockGetBlockByNumberAt("0x69", futureBlockSentinel, &dispatched, "fb1", "fb2", "sync1") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + network, _ := setupServedTipNetworkWith(t, ctx, []servedTipFixture{ + {id: "fb1", chainID: 123, latestBlock: 100}, + {id: "fb2", chainID: 123, latestBlock: 99}, + {id: "sync1", chainID: 123, latestBlock: 110, syncing: true}, + }, nil) + network.cfg.Evm.ShortCircuitFutureBlocks = util.BoolPtr(true) + + // block 105 (0x69) is above every non-syncing head (100) but below sync1's. + resp := forwardGetBlockByNumber(t, ctx, network, "0x69") + jrr, err := resp.JsonRpcResponse(ctx) + require.NoError(t, err) + assert.Contains(t, jrr.GetResultString(), "0x270f", + "a syncing upstream may have the block; it must be dispatched") +} + +// An upstream with no known head may already have the block, so the +// short-circuit fails open and the request is dispatched. +func TestForward_FutureBlock_OptIn_UpstreamWithUnknownHead_Dispatches(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + util.SetupMocksForEvmStatePoller() + var dispatched atomic.Int64 + mockGetBlockByNumberAt("0x65", futureBlockSentinel, &dispatched, "fb1", "fb2", "nohead") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + network, _ := setupServedTipNetworkWith(t, ctx, []servedTipFixture{ + {id: "fb1", chainID: 123, latestBlock: 100}, + {id: "fb2", chainID: 123, latestBlock: 99}, + // no latestBlock: the poller never learns a head for this upstream + {id: "nohead", chainID: 123}, + }, nil) + network.cfg.Evm.ShortCircuitFutureBlocks = util.BoolPtr(true) + + // block 101 (0x65) is above every known head (100). + resp := forwardGetBlockByNumber(t, ctx, network, "0x65") + jrr, err := resp.JsonRpcResponse(ctx) + require.NoError(t, err) + assert.Contains(t, jrr.GetResultString(), "0x270f", + "an upstream with an unknown head may have the block; it must be dispatched") + assert.Positive(t, dispatched.Load()) +} + +// emptyResultConfidence: finalizedBlock decides how an empty upstream answer is +// treated, not whether an upstream can have the block. A block between the +// finalized and latest heads must still be dispatched. +func TestForward_FutureBlock_OptIn_FinalizedConfidence_UnfinalizedBlock_Dispatches(t *testing.T) { + util.ResetGock() + defer util.ResetGock() + util.SetupMocksForEvmStatePoller() + var dispatched atomic.Int64 + mockGetBlockByNumberAt("0x5f", futureBlockSentinel, &dispatched, "fb1", "fb2", "fb3") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + network, ups := setupServedTipNetworkWith(t, ctx, []servedTipFixture{ + {id: "fb1", chainID: 123, latestBlock: 100}, + {id: "fb2", chainID: 123, latestBlock: 99}, + {id: "fb3", chainID: 123, latestBlock: 98}, + }, nil) + network.cfg.Evm.ShortCircuitFutureBlocks = util.BoolPtr(true) + network.cfg.Evm.EmptyResultConfidence = common.AvailbilityConfidenceFinalized + for _, u := range ups { + u.EvmStatePoller().SuggestFinalizedBlock(90) + } + + // block 95 (0x5f) is above every finalized head (90) but below the latest ones. + resp := forwardGetBlockByNumber(t, ctx, network, "0x5f") + jrr, err := resp.JsonRpcResponse(ctx) + require.NoError(t, err) + assert.Contains(t, jrr.GetResultString(), "0x270f", + "an unfinalized block the upstreams already have must be dispatched") +} + +// A fallback the selection policy cordons off can still serve through the +// per-request escape, so its head counts toward the ceiling: a block between +// the primaries' head and the fallbacks' head is dispatched, not nulled. +func TestForward_FutureBlock_OptIn_CordonedFallbackAhead_Dispatches(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer util.ResetGock() + + var dispatched atomic.Int64 + network, _, _ := setupFailoverFixture(t, ctx, failoverFixtureOpts{ + primaryLatest: "0x64", // 100 + fallbackLatest: "0x6e", // 110 + enableFailover: true, + mocks: func() { + mockGetBlockByNumberAt("0x69", "null", &dispatched, "rpc1", "rpc2", "rpc3", "rpc4") + }, + network: func(cfg *common.NetworkConfig) { + cfg.Evm.ShortCircuitFutureBlocks = util.BoolPtr(true) + }, + }) + + // block 105 (0x69) is above the primaries' head but below the fallbacks'. + forwardGetBlockByNumber(t, ctx, network, "0x69") + assert.Positive(t, dispatched.Load(), + "a block a cordoned fallback may have must be dispatched, not short-circuited") +} + +// The default selection policy mirrors sampled requests to the upstreams it +// excludes (probeExcluded). A short-circuited request is never dispatched, so it +// must not be mirrored either. +func TestForward_FutureBlock_OptIn_ShortCircuitNotMirroredToExcluded(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer util.ResetGock() + + var dispatched atomic.Int64 + network, _, _ := setupFailoverFixture(t, ctx, failoverFixtureOpts{ + primaryLatest: "0x64", // 100 + fallbackLatest: "0x64", // 100, cordoned by the default policy + enableFailover: true, + mocks: func() { + mockGetBlockByNumberAt("0x69", "null", &dispatched, "rpc1", "rpc2", "rpc3", "rpc4") + }, + network: func(cfg *common.NetworkConfig) { + cfg.Evm.ShortCircuitFutureBlocks = util.BoolPtr(true) + }, + }) + + for i := 0; i < 5; i++ { + resp := forwardGetBlockByNumber(t, ctx, network, "0x69") + assert.True(t, resp.IsResultEmptyish(ctx)) + } + // Mirrors are asynchronous; give the prober time to dispatch any. + time.Sleep(500 * time.Millisecond) + assert.Zero(t, dispatched.Load(), + "a short-circuited request must reach no upstream, mirrored probes included") +} diff --git a/typescript/config/src/generated.ts b/typescript/config/src/generated.ts index 87bb3ecb1..eb0efdd97 100644 --- a/typescript/config/src/generated.ts +++ b/typescript/config/src/generated.ts @@ -1718,6 +1718,18 @@ export interface EvmNetworkConfig { * finalized head; an unfinalized block's empty is treated as not-yet-confirmed. */ emptyResultConfidence?: AvailbilityConfidence; + /** + * ShortCircuitFutureBlocks returns null for a numbered eth_getBlockByNumber + * above the highest latest head across every upstream of the network + * (regardless of selection policy, tier or syncing state), without + * dispatching it; it never fires while any upstream's head is unknown. + * Clients that poll the next block by number otherwise walk every routed + * upstream on each poll. Always on when servedTip is enabled for "latest". + * The head is what the state poller and head subscriptions last reported, so + * a block an upstream produced since then gets null until eRPC sees it: only + * enable it where heads arrive promptly (e.g. over WebSocket). + */ + shortCircuitFutureBlocks?: boolean; /** * SafeBlockSource is an upstream id/tag selector for standard JSON-RPC * requests carrying the `safe` block tag. Matching upstreams define and