feat(chains): opt-in no-finality flag for chains without finalized block tag#191
Open
robbeverhelst wants to merge 1 commit into
Open
feat(chains): opt-in no-finality flag for chains without finalized block tag#191robbeverhelst wants to merge 1 commit into
robbeverhelst wants to merge 1 commit into
Conversation
…ock tag
Some EVM-compatible networks do not expose a finalized block: Hyperledger
Besu QBFT, classic Clique / PoA, consortium chains. Calling
eth_getBlockByNumber("finalized") against them returns -39001 "Unknown
block" forever. NodeCore's upstream lifecycle keeps such an upstream out
of the chain supervisor's upstreamStates map because no
BlockUpstreamStateEvent for FinalizedBlock ever fires and
StatusUpstreamStateEvent{Status: Available} from the health validator
dedups against the upstream's initial Available state. Result: the
router answers "no available upstreams to process a request" to every
call past eth_chainId.
This commit adds an opt-in no-finality flag on the chain Settings
struct. When set:
- block_processor skips the finalized poll loop entirely (no log spam,
no wasted RPC calls);
- upstream piggybacks a StateUpstreamEvent on the first head
publication so the chain supervisor learns the upstream from head
data alone.
Default is false; existing chains are unaffected. Configured per-chain
in chain-settings.protocols[].chains[].settings.no-finality.
Validated in production against a real Besu QBFT chain (DALP staging,
chain-id 0xbb1a, two upstream nodes). Before the patch: supervisor
log "State of ...: height=N, statuses=[]" forever, every consumer
RPC fails with "no available upstreams". After: "statuses=[AVAILABLE/2]"
within ~30s of pod start, the full Blockscout / dapp / indexer stack
serves traffic normally.
Tests cover:
- block processor skips SendRequest entirely when noFinality
- IsNoFinalityChain reads Settings.NoFinality correctly
- defaults to false for embedded chains and UnknownChain
Docs section added to docs/nodecore/05-upstream-config.md.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
Adds an opt-in
no-finality: trueflag on a chain'ssettingsblock. When enabled, NodeCore skips finalized-block polling and lets the chain supervisor promote upstreams toAvailableusing head data alone. Default isfalse, so every existing chain inpkg/chains/public/chains.yamlkeeps current behavior.Why
Some EVM-compatible networks do not expose a finalized block: Hyperledger Besu QBFT, classic Clique / PoA, consortium chains. Calling
eth_getBlockByNumber("finalized", ...)against them returns-39001: Unknown blockevery poll cycle.On those chains today, NodeCore's upstream lifecycle never promotes upstreams to
Available. The chain supervisor only stores an entry inupstreamStateswhen an upstream publishes aStateUpstreamEvent, and for non-finality chains that publication never happens.block_processor.poll(FinalizedBlock)logscouldn't detect finalized block of upstream XanddisableDetection.Add(FinalizedBlock)stops further polling, but noBlockUpstreamStateEventis ever published for FinalizedBlock.HealthValidatoremitsStatusUpstreamStateEvent{Status: Available}, which dedups against the upstream's initialAvailablestate inprocessStateEvents(default branch hitsstateEvent.Same(state)→continue), so noStateUpstreamEventis published either. The chain supervisor'supstreamStatesmap stays empty (statuses=[]inchain_supervisor.go:317), and every request pasteth_chainIdreturns{"error":"no available upstreams to process a request","code":1}.Three existing knobs were tried in a live cluster and none of them populated the supervisor's
upstreamStatesmap:upstream-config.integrity.enabled: false,chain-defaults.<chain>.options.disable-chain-validation: true, andchain-defaults.<chain>.options.disable-validation: true(the nuclear option). They all leftstatuses=[]empty. That makes sense reading the source — they gate validation paths, but state-publication for non-finality chains never happens regardless of validation.How
pkg/chains/chains.goAdds
NoFinality bool yaml:"no-finality"to the per-chainSettingsstruct. The existingdeepMerge+yaml.Unmarshalflow inconfigureChainspicks it up automatically from any chain'ssettings:block, so the embeddedchains.yamland any future extra-chains loader behave identically.A small helper exposes the flag:
internal/upstreams/blocks/block_processor.goTakes a
noFinality boolargument inNewEthLikeBlockProcessor. InStart(), skips both the initialb.poll(FinalizedBlock)and the ticker-driven follow-ups whennoFinalityis true, which stops the error-log spam and the useless RPC calls on chains where finalized never resolves.internal/upstreams/upstream_events.goIn
processStateEvents, on the firstHeadUpstreamStateEventfor a no-finality chain (whenvalidUpstreamis already true), publishes a piggybackStateUpstreamEventso the chain supervisor'supstreamStatesmap gets populated. Subsequent head events continue to publish onlyHeadUpstreamEventas today, and a localstateBroadcastbool ensures the piggyback fires exactly once per upstream lifetime.internal/upstreams/upstream_factory.go+upstream_processors.gocreateBlockProcessornow receives the full*chains.ConfiguredChaininstead of justchains.BlockchainTypeso it can readconfiguredChain.Settings.NoFinality. Small refactor with one caller; the originalBlockchainTypeparameter becameconfiguredChain.Typeat the switch site.Cache policies
Cache policies that key on
finalization-type: finalizedsimply do not cache on no-finality chains because no finalized event ever fires. The docs section calls this out and recommends a TTL policy for no-finality chains. No code change incache_processoris needed for the opt-in to work safely.Production validation
This was developed in response to a real deployment of Hyperledger Besu QBFT (DALP staging, chain id
0xbb1a, two upstream nodes). Before the patch the chain supervisor stayed atstatuses=[]indefinitely and every consumer call pasteth_chainIdreturnedno available upstreams to process a request. After deploying a build with this patch:loaded extra chain definitionsat startup, nocouldn't detect finalized blockerrors thereafter.State of SETTLEMINT-BESU: height=N, statuses=[AVAILABLE/2]within ~30s of pod start.eth_blockNumber,eth_getBlockByNumber,eth_getLogs,trace_block,trace_replayBlockTransactions,debug_traceBlockByNumberall route correctly.Tests
internal/upstreams/blocks/block_processor_test.go::TestEthLikeBlockProcessorSkipsFinalizedPollWhenNoFinalityasserts the processor never callsSendRequestand never adds FinalizedBlock todisableDetectionwhennoFinality: true.pkg/chains/chains_test.go::TestIsNoFinalityChain_*covers three cases: defaults false for embedded chains, returns false forUnknownChain, returns true whenSettings.NoFinalityis set.go vet ./...is clean.Docs
New section "Chains without a finalized block tag" in
docs/nodecore/05-upstream-config.mdwith the rationale, the failure mode, a minimal chain entry example, and a note on cache policy implications. The constructor signature forblocks.NewEthLikeBlockProcessorgained anoFinality boolparameter, which is an internal API with only one caller (createBlockProcessor), and the existing tests have been updated to passfalse. No new dependencies have been introduced, and there are no changes to public RPC behavior on finality-having chains.Notes for reviewers
IsNoFinalityChainhelper is a thin convenience on top ofGetChain(chain.String()).Settings.NoFinality, and I am happy to inline it if maintainers prefer.StateUpstreamEventfires once per upstream lifetime (gated bystateBroadcast), so the cost is one extra publish per upstream startup on opt-in chains.chain-defaultsoption for operator override.