Skip to content

Call SetFinality when processing a milestone - #2226

Open
sduchesneau wants to merge 4 commits into
0xPolygon:developfrom
streamingfast:fix/finality
Open

sduchesneau wants to merge 4 commits into
0xPolygon:developfrom
streamingfast:fix/finality

Conversation

@sduchesneau

@sduchesneau sduchesneau commented May 14, 2026 •

Copy link
Copy Markdown
Contributor

Description

bc.CurrentFinalBlock() is currently always nil on bor nodes, because the only writers of blockchain.SetFinalized in upstream geth are the Engine API (eth/catalyst), which bor does not use. As a result:

  • The headFinalizedBlockGauge / headSafeBlockGauge metrics never advance.
  • Any consumer that uses the standard geth BlockChain API to read finality — live tracers (core.BlockchainLogger), external indexers, downstream tooling — sees no finality on bor, even though Heimdall milestones are by definition the chain's strongest finality signal.

Bor already knows what's finalized: eth/api.go:getFinalizedBlockNumber derives it from Downloader().GetWhitelistedMilestone() to answer the eth_getBlockByNumber("finalized") RPC. This PR closes the gap by also writing that information into the canonical place (bc.SetFinalized) the moment a milestone has been successfully processed in ethHandler.handleMilestone, right after the existing ProcessMilestone call. The header is fetched defensively via GetHeaderByHash; an unknown hash is a no-op.

This PR only writes to CurrentFinalBlock() from milestones, because milestones are the strongest/live signal. The RPC finalized-tag path in getFinalizedBlockNumber (milestone → checkpoint fallback) is unchanged.

Changes

  • Bugfix (non-breaking change that solves an issue)
  • Hotfix
  • New feature
  • Breaking change
  • Changes only for a subset of nodes

Breaking changes

None at the protocol or RPC level. Two observable effects:

  1. headFinalizedBlockGauge and headSafeBlockGauge will now advance on bor nodes (they were stuck at 0). Dashboards may need to acknowledge this.
  2. The SetHead invalidated finalized block log path in core/blockchain.go becomes reachable. By construction it only triggers on a rewind below a confirmed Heimdall milestone, which is already a pathological situation — making it visible is the desired behavior.

he JSON-RPC eth_getBlockByNumber("finalized") response is unchanged: it still flows through getFinalizedBlockNumber and the downloader whitelist.

Testing

  • Unit tests
  • Manual on mainnet:
    • eth_getBlockByNumber("finalized") continues to return the latest milestoned block (unchanged).
    • headFinalizedBlockGauge advances after the first milestone is processed (new behavior).
    • Live tracer / BlockChain.CurrentFinalBlock() returns the milestone header (new behavior, previously nil).

Copilot AI review requested due to automatic review settings May 14, 2026 20:07

@claude claude Bot 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@pratikspatil024

Copy link
Copy Markdown
Member

@claude review once

@claude

claude Bot commented May 15, 2026 •

Copy link
Copy Markdown

Claude finished @pratikspatil024's task in 1m 33s —— View job


Review: Call SetFinalized when processing a milestone

  • Gather context and read changed files
  • Review the diff against origin/develop
  • Analyze correctness, security, and edge cases
  • Post review findings

Overall: This is a well-motivated, clean fix. The PR description accurately identifies the gap — Bor's milestone finality was never written to BlockChain.SetFinalized, leaving CurrentFinalBlock() permanently nil. The placement is correct: right after ProcessMilestone succeeds, with a defensive nil check on the header lookup.

Correctness

Looks good. The change is placed after ProcessMilestone(num, hash) at eth/handler_bor.go:129, which only runs when verifier.verify has succeeded (errors return early at lines 97–117). The GetHeaderByHash nil check at line 132 is the right defensive pattern — if the block has been pruned or is somehow missing, it's a no-op rather than a panic.

Thread safety is fine: SetFinalized uses atomic.Pointer[types.Header] for the in-memory store (core/blockchain.go:390, :1130), and rawdb.WriteFinalizedBlockHash is a single DB write.

Persistence across restarts

SetFinalized calls rawdb.WriteFinalizedBlockHash (core/blockchain.go:1133), and loadLastState reads it back on startup (core/blockchain.go:992-998). So the finalized marker will survive node restarts — good.

Suggestions (non-blocking)

1. Consider also calling SetSafe

In the Engine API path (eth/catalyst/api.go:403-417), both SetFinalized and SetSafe are set. On startup, loadLastState also sets both from the finalized hash (core/blockchain.go:994-997). However, this PR only calls SetFinalized, leaving currentSafeBlock as nil until the next restart (when loadLastState will copy finalized → safe).

For Bor, milestones represent the strongest finality signal, so the finalized block is by definition also "safe." Adding eth.blockchain.SetSafe(header) alongside SetFinalized would keep headSafeBlockGauge advancing in real-time too, and match the behavior consumers expect (safe ≤ finalized).

if header := eth.blockchain.GetHeaderByHash(hash); header != nil {
    eth.blockchain.SetFinalized(header)
    eth.blockchain.SetSafe(header)
}

2. Re-processing on the 1-second ticker

handleMilestone is called every 1 second via the ticker at eth/backend.go:893-901 to re-process the same milestone. With this PR, that means SetFinalized + WriteFinalizedBlockHash (a DB write) fires every second for the same block. This is functionally harmless (idempotent write of the same value), but worth noting. If you want to avoid the redundant DB writes, a simple guard like checking bc.CurrentFinalBlock() before writing would do — but this is a micro-optimization, not a correctness concern.

3. No monotonicity check

There's no check that the new finalized block number is ≥ the current finalized block number. In practice this should always hold because milestones are monotonically increasing, but a defensive check would make the invariant explicit:

if header := eth.blockchain.GetHeaderByHash(hash); header != nil {
    if current := eth.blockchain.CurrentFinalBlock(); current == nil || header.Number.Uint64() > current.Number.Uint64() {
        eth.blockchain.SetFinalized(header)
    }
}

Again, non-blocking — the milestone protocol already guarantees monotonicity.

No issues found

  • No security concerns (no new external input, no panics in consensus path)
  • No concurrency issues (atomic store, single DB write)
  • Defensive nil check is present
  • Correctly placed after verification succeeds

LGTM with the optional SetSafe addition as a nice-to-have.


@claude claude Bot 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.

LGTM — small, defensive 5-line change that mirrors the existing ProcessMilestone call.

Extended reasoning...

Overview

Single-file, 5-line addition in eth/handler_bor.go. After the existing h.downloader.ProcessMilestone(num, hash) call in handleMilestone, the PR fetches the corresponding header via GetHeaderByHash and, if non-nil, calls eth.blockchain.SetFinalized(header). This brings bor's finality signal into the canonical geth BlockChain API (CurrentFinalBlock()), which was previously always nil on bor since the only upstream writer is the (unused) Engine API.

Security risks

None identified. SetFinalized is a simple setter that stores the header pointer, writes the finalized hash to rawdb, and updates a metrics gauge — no consensus or state-transition impact. GetHeaderByHash is a safe read and returns nil for unknown hashes; the unknown-hash case is handled as a defensive no-op. No new external inputs, auth, or crypto surface area.

Level of scrutiny

Low. The change is purely additive metadata propagation: it makes existing geth machinery (metrics, the SetHead safety guard, tracer-visible finality) reflect a value bor already computes elsewhere (getFinalizedBlockNumber via the downloader whitelist). The two observable effects called out in the description (gauge advancing, SetHead invalidated finalized block log becoming reachable on rewinds below a milestone) are intended and benign.

Other factors

SonarQube quality gate passed. Maintainer (pratikspatil024) explicitly requested review on this PR. The change has been manually tested on mainnet per the PR description. No bugs flagged by the bug hunting system.

@pratikspatil024
pratikspatil024 requested a review from a team May 15, 2026 07:56
Comment thread eth/handler_bor.go Outdated
@sonarqubecloud

Copy link
Copy Markdown

kamuikatsurgi
kamuikatsurgi previously approved these changes May 22, 2026
lucca30
lucca30 previously approved these changes Jun 10, 2026
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

This PR is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Jul 2, 2026
@maoueh

maoueh commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

This is still valid, PR approved was just missing a merge window.

@github-actions github-actions Bot removed the Stale label Jul 14, 2026
@pratikspatil024

Copy link
Copy Markdown
Member

@claude review once

@claude claude Bot 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.

LGTM — the change is a small, well-reviewed addition that wires milestone processing into BlockChain.SetFinalized. The three findings are all nits (a stale comment, a non-blocking monotonicity suggestion already raised and accepted as non-blocking in the original review, and a subtle safe==finalized resurfacing on restart via the pre-existing loadLastState path) — none affect correctness or introduce security/consensus risk, and the core placement/nil-checks are correct.

Extended reasoning...

Overview

The diff adds a single call, eth.blockchain.SetFinalized(block.Header()), in handleMilestone (eth/handler_bor.go), right after ProcessMilestone succeeds. It re-resolves the milestone's end block by number and checks the hash matches before publishing, guarding against a reorg between verification and this write. This wires Heimdall milestone finality into the standard geth BlockChain finality bookkeeping (atomic pointer + headFinalizedBlockGauge + rawdb.WriteFinalizedBlockHash), which was previously always nil/zero on Bor since only the (unused) Engine API called SetFinalized upstream.

Security risks

No new external input surface is introduced — the milestone data was already verified by verifier.verify before this call executes, and the block is re-resolved defensively by canonical number->hash mapping rather than trusting a stale reference. The only consumers of CurrentFinalBlock() are metrics, live tracers, and downstream tooling — Bor's actual reorg-safety mechanism (the whitelist finality.IsValidChain) is untouched. The RPC eth_getBlockByNumber("finalized") path is also unchanged (still goes through the downloader whitelist). I confirmed the three inline findings are all real relative to the current code (checked SetFinalized/SetSafe/loadLastState in core/blockchain.go), but all are Nit-severity: none allow an external actor to corrupt state, bypass validation, or crash the node.

Level of scrutiny

This is a narrowly-scoped, single-function change (7 added lines) touching a non-consensus-critical bookkeeping path (finality metrics/API surface, not block validation or whitelist logic). It underwent a full review cycle with two maintainers (kamuikatsurgi, maoueh) explicitly debating and resolving the SetSafe question, and a maintainer reconfirmed on 2026-07-13 that it remains valid and was previously approved pending a merge window. Given the limited blast radius and the depth of prior human review, a light-touch confirmation is appropriate here rather than blocking again on nits that don't change behavior in any harmful way.

Other factors

All three inline findings are explicitly non-blocking suggestions (documentation staleness, a defensive monotonicity check already proposed and consciously deferred in the original review, and a restart-time edge case in pre-existing upstream loadLastState logic that this PR merely activates for the first time on Bor). None require a design decision beyond what's already been discussed in the thread.

Comment thread eth/handler_bor.go Outdated
Comment thread eth/handler_bor.go Outdated
Comment thread eth/handler_bor.go Outdated
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

This PR is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Aug 5, 2026
@maoueh

maoueh commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

This is still valid and was accepted previously.

@github-actions github-actions Bot removed the Stale label Aug 7, 2026
@github-actions

Copy link
Copy Markdown

This PR is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Aug 28, 2026
@pratikspatil024

Copy link
Copy Markdown
Member

Hi @sduchesneau - could you please merge/rebase develop?

@github-actions github-actions Bot removed the Stale label Sep 4, 2026
@github-actions

Copy link
Copy Markdown

This PR is stale because it has been open 21 days with no activity. Remove stale label or comment or this will be closed in 14 days.

@github-actions github-actions Bot added the Stale label Sep 25, 2026
@maoueh

maoueh commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Hi @sduchesneau - could you please merge/rebase develop?

Yes will do

@maoueh

maoueh commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

@pratikspatil024 Branch should now be mergeable again

@github-actions github-actions Bot removed the Stale label Sep 26, 2026
@pratikspatil024

Copy link
Copy Markdown
Member

@claude review

@claude claude Bot 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked whether the rawdb.WriteFinalizedBlockHash call inside SetFinalized could introduce a new crash path on a transient KV-store write error — it uses the same existing log.Crit-on-write-failure pattern already used by other Write* accessors in core/rawdb, so this isn't a novel risk introduced by this PR.

Extended reasoning...

The diff adds a post-milestone call to BlockChain.SetFinalized in eth/handler_bor.go, re-resolving the milestone block by number and checking its hash before promoting it to finalized; this is security/consensus-adjacent (finality state used by RPC and gauges) but small and already reviewed. A confirmed finding (safe block gauge/tag going stale after restart because loadLastState copies the persisted finalized hash into currentSafeBlock) is the same mechanism I flagged in my prior review on this PR, so I did not restate it. I additionally checked whether SetFinalized's rawdb write could newly crash the node on a transient error, and ruled that out since it reuses an existing log.Crit pattern common to other rawdb Write* functions, which is new information worth recording briefly.

Comment thread eth/handler_bor.go Outdated
@pratikspatil024

Copy link
Copy Markdown
Member

@maoueh - the PR looks good, thank you!
Could you please verify your commit signatures? That's the only blocker for merging.
If not, I can do it from my end. Thanks!

write time

handleMilestone now resolves the milestone block via GetBlockByNumber
and
verifies the canonical hash matches milestone.Hash before calling
SetFinalized/SetSafe to avoid promoting a stale header if a reorg
lands before this write.

Calls SetSafe alongside SetFinalized so both advance in real time.
@maoueh

maoueh commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

@pratikspatil024 Done, I've fixed the commits to be co-authored by me.

@pratikspatil024

Copy link
Copy Markdown
Member

@maoueh Thanks! The change looks sound; I found no consensus-safety blocker. Could you add tests for the finality update to fix diffguard? One edge case: SetFinalized persists the hash, and on restart loadLastState restores it into both finalized and safe. Later milestones only update finalized, so the "safe" RPC stays stuck at the restart-time block. Could we keep safe consistently absent, as intended? A guard against older milestones moving finality backward would also be helpful.

Milestones only advance the finalized block, but loadLastState seeded the
safe block from the persisted finalized hash on restart, leaving "safe"
frozen at the restart-time block. Skip that seeding on Bor chains so safe
stays consistently absent, and ignore milestones that are not ahead of the
current finalized block so finality never moves backward.
@maoueh
maoueh dismissed stale reviews from lucca30 and kamuikatsurgi via 910d6eb October 19, 2026 15:47
@maoueh

maoueh commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

@pratikspatil024 Added requested changes

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.

5 participants