From b3c35d539bc91eb9b7743dd8083ee57b25bde1b4 Mon Sep 17 00:00:00 2001 From: Reiers Date: Thu, 2 Jul 2026 00:19:08 +0200 Subject: [PATCH] feat(#96): FRC-0089 EC finality calculator - observed-data finality without F3 Port of lotus chain/ecfinality (itself a Go port of the FRC-0089 Python reference). Math kept verbatim so the lotus/Python test vectors apply unchanged - all reference vectors pass. What it gives Lantern: an upper bound on reorg probability computed from OBSERVED block counts per epoch, with no external trust. Healthy chain (~5 blk/epoch) meets 2^-30 around depth ~30 (~15 min) instead of the static worst-case 900 epochs. This quantifies how deep a tip-range eclipse could plausibly reorg us (head-trust epic #100), and becomes the retention-depth input for #92 and the ec-finalized half of the v2 'finalized'/'safe' selector semantics (#99). Lantern adaptations (cache.go, new): - HeaderSource interface over chain/header/store (Head + GetTipSet) - Windowed-store aware: the walk stops gracefully where history runs out; Status reports the achieved WindowEpochs, and a window below MinWindow (30 epochs) reports 'not computable' (-1) instead of an over-confident number. Nodes with a deeper tail (#91) converge to the exact reference behavior. - On-demand + cached per head tipset: an idle node pays nothing; the dashboard poll is the only thing that pays the Skellam cost. Wiring: - cmd/lantern: dashboard dev page gets an 'EC finality (FRC-0089)' card (threshold depth / ec-finalized epoch / observed window / recomputes) plus the previously-invisible held_uncorrob / held_diverged / rejected_lighter ingestor counters. - pkg/daemon: Daemon.ECFinality() accessor for embedded consumers. - TRUST-MODEL.md 2.7: computed bound, not a proof; F3 precedence; finalized = max(ec, f3). New dep: github.com/rvagg/go-skellam-pmf v0.0.2 (tiny, pure Go, same dep lotus uses). --- CHANGELOG.md | 17 ++ TRUST-MODEL.md | 27 +++ chain/ecfinality/cache.go | 154 +++++++++++++++++ chain/ecfinality/cache_test.go | 161 +++++++++++++++++ chain/ecfinality/calculator.go | 259 ++++++++++++++++++++++++++++ chain/ecfinality/calculator_test.go | 176 +++++++++++++++++++ cmd/lantern/dashboard.go | 18 ++ cmd/lantern/dashboard/index.html | 25 +++ cmd/lantern/gossip_block.go | 11 ++ cmd/lantern/main.go | 3 + go.mod | 1 + go.sum | 2 + pkg/daemon/daemon.go | 25 +++ pkg/daemon/start.go | 4 + 14 files changed, 883 insertions(+) create mode 100644 chain/ecfinality/cache.go create mode 100644 chain/ecfinality/cache_test.go create mode 100644 chain/ecfinality/calculator.go create mode 100644 chain/ecfinality/calculator_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c601b7..f07284f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to Lantern. ## Unreleased (v1.9.0) +### Added + +- **FRC-0089 EC finality calculator** ([#96](https://github.com/Reiers/lantern/issues/96)). + New `chain/ecfinality` package (ported from lotus, math verbatim so the + reference test vectors apply unchanged) computes an upper bound on reorg + probability from **observed** block counts per epoch: on a healthy chain + the 2^-30 guarantee is met around depth ~30 (~15 min) instead of the + static worst-case 900 epochs. On-demand + cached per head (an idle node + pays nothing). Windowed-store aware: a node with a shallow observed + window reports "not computable" instead of an over-confident number. + Surfaced on the dashboard dev page (new "EC finality" card) and via + `Daemon.ECFinality()` for embedded consumers. Semantics for consumers: + `finalized = max(ec-finalized, f3-finalized)` (matches lotus v2 API). + New dep: `github.com/rvagg/go-skellam-pmf` (tiny, pure Go, same as + lotus). TRUST-MODEL.md section 2.7 documents what it does and does NOT + guarantee. + ### Security / head-path hardening - **Head-source corroboration** ([#80](https://github.com/Reiers/lantern/issues/80) part 2). diff --git a/TRUST-MODEL.md b/TRUST-MODEL.md index 31e8372..b6c41e6 100644 --- a/TRUST-MODEL.md +++ b/TRUST-MODEL.md @@ -119,6 +119,21 @@ eclipse/fork-selection exposure on the un-finalized tip is documented in --- +### 2.7 Observed-data EC finality (FRC-0089) — a computed bound, not a proof + +`chain/ecfinality` (#96) computes an upper bound on the probability that +a tipset at depth D could be reorged, from the OBSERVED block counts per +epoch in the local header store. Under healthy conditions the 2^-30 +bound is met around depth ~30 (~15 min) instead of the static worst-case +900. Lantern uses it as an honesty instrument (dashboard, stats) and as +an input for retention decisions - NOT as a substitute for F3: the +calculation assumes the observed chain is the honest heavy chain, so its +guarantees are only as good as the head path that observed it (see 3.3). +Where F3 finality is available it always takes precedence; the useful +semantics for consumers is `finalized = max(ec-finalized, f3-finalized)`. +A node with a shallow observed window (< 30 epochs of history) reports +"not computable" rather than an over-confident number. + ## 3. What Lantern does NOT trust These are deliberately untrusted. A Lantern node treats responses from any @@ -143,6 +158,18 @@ Inbound block + message gossip is decoded then validated by the consumer (`chain/header.ValidateBlock` for blocks; `crypto/sigs.Verify` for messages). Malformed traffic is dropped. +Head adoption additionally applies (v1.9.0): heaviest-ParentWeight fork +choice, the divergence gate (independent-source head monitor), and +optional head-source corroboration (#80): a head advance requires +forwarding by N distinct peers or one trusted floor peer. Peers earn +score through first-delivery history on the blocks/msgs topics (#97) and +lose it for invalid messages and IP-colocation; the trusted floor +(bootstrap/beacon/direct peers) is connmgr-protected and cannot be +evicted by a dial flood. Honest boundary: peer IDs are Sybil-cheap, so +corroboration raises eclipse cost only in combination with scoring and +the floor. It is hardening, not finality; finality comes from F3 +(section 2.2) and the observed-data EC finality bound (section 2.7). + ### 3.4 Forest / Lotus nodes that are NOT the wired bridge A Forest or Lotus node that happens to be a libp2p peer of ours is in the diff --git a/chain/ecfinality/cache.go b/chain/ecfinality/cache.go new file mode 100644 index 0000000..9373c0f --- /dev/null +++ b/chain/ecfinality/cache.go @@ -0,0 +1,154 @@ +package ecfinality + +import ( + "errors" + "math" + "sync" + + "github.com/filecoin-project/go-state-types/abi" + + ltypes "github.com/Reiers/lantern/chain/types" +) + +// MinWindow is the minimum number of epochs of observed history required +// before the calculator reports a threshold at all. Below this the L +// distribution has too little data to be meaningful, so Status reports +// depth -1 (not computable) rather than an over-confident number. 30 +// epochs (~15 min of history) is the depth at which a healthy chain +// typically meets 2^-30 in the first place. +const MinWindow = 30 + +// HeaderSource is the minimal store surface the cache walks. Lantern's +// chain/header/store.Store satisfies it. +type HeaderSource interface { + Head() *ltypes.TipSet + GetTipSet(tsk ltypes.TipSetKey) (*ltypes.TipSet, error) +} + +// Status is the resolved EC finality state for a given head. +type Status struct { + // ThresholdDepth is the shallowest epoch depth at which the reorg + // probability drops below 2^-30. -1 when not met or not computable + // (degraded chain, or observed window < MinWindow). + ThresholdDepth int + // FinalizedEpoch is head.Height() - ThresholdDepth, or -1 when + // ThresholdDepth is -1. + FinalizedEpoch abi.ChainEpoch + // HeadEpoch is the head height the computation ran against. + HeadEpoch abi.ChainEpoch + // WindowEpochs is how many epochs of observed history the calculator + // actually had (honesty signal: a freshly-booted node has only its + // anchor depth until #91/#92 give it a deeper tail). + WindowEpochs int +} + +// Cache computes the FRC-0089 threshold for the current head and caches +// the result, recomputing only when the head changes. Computation is +// on-demand (dashboard/stats pulls), NOT on every head change: the cost +// is dominated by the Skellam PMF loops, so an idle node pays nothing. +// Safe for concurrent use. +type Cache struct { + src HeaderSource + finality int // L-distribution lookback (900 mainnet/calibration) + + mu sync.Mutex + cached *Status + calls uint64 // lifetime Status calls + comps uint64 // lifetime recomputes (cache misses) +} + +// NewCache builds a Cache over the given header source. finality is the +// lookback depth for the L distribution (900 for both mainnet and +// calibration). +func NewCache(src HeaderSource, finality int) *Cache { + return &Cache{src: src, finality: finality} +} + +// Status returns the EC finality state for the current head, cached per +// head tipset key. Errors are not cached; a transient store failure +// allows retry on the next call. +func (c *Cache) Status() (*Status, error) { + if c == nil || c.src == nil { + return nil, errors.New("ecfinality: no header source") + } + head := c.src.Head() + if head == nil { + return nil, errors.New("ecfinality: no head") + } + + c.mu.Lock() + defer c.mu.Unlock() + c.calls++ + + if c.cached != nil && c.cached.HeadEpoch == head.Height() { + return c.cached, nil + } + c.comps++ + + chain, err := c.walkChain(head) + if err != nil { + return nil, err + } + + st := &Status{ + ThresholdDepth: -1, + FinalizedEpoch: -1, + HeadEpoch: head.Height(), + WindowEpochs: len(chain), + } + if len(chain) >= MinWindow { + guarantee := math.Pow(2, float64(DefaultSafetyExponent)) + st.ThresholdDepth = FindThresholdDepth(chain, c.finality, DefaultBlocksPerEpoch, DefaultByzantineFraction, guarantee) + if st.ThresholdDepth >= 0 { + st.FinalizedEpoch = head.Height() - abi.ChainEpoch(st.ThresholdDepth) + } + } + + c.cached = st + return st, nil +} + +// Stats returns (lifetime Status calls, lifetime recomputes). +func (c *Cache) Stats() (calls, recomputes uint64) { + c.mu.Lock() + defer c.mu.Unlock() + return c.calls, c.comps +} + +// walkChain walks back from head collecting block counts per epoch, oldest +// first. Null rounds appear as 0 entries so the array index maps directly +// to epoch height differences. +// +// Lantern's header store is windowed (anchor-rooted, pruning per #92), so +// the walk stops gracefully where history runs out: a missing parent ends +// the walk instead of erroring, and Status reports the achieved window. +// The target is finality+5 epochs (the reference lookback); nodes with a +// deeper tail (#91) converge to the exact reference behavior. +func (c *Cache) walkChain(head *ltypes.TipSet) ([]int, error) { + needed := c.finality + 5 + chain := make([]int, 0, min(needed, 1024)) + ts := head + for { + chain = append(chain, len(ts.Cids())) + if len(chain) >= needed { + break + } + if ts.Height() == 0 { + break // genesis + } + parent, err := c.src.GetTipSet(ts.Parents()) + if err != nil || parent == nil { + break // window exhausted: use what we observed + } + // Insert 0 entries for null rounds between this tipset and parent. + for nulls := int(ts.Height()-parent.Height()) - 1; nulls > 0 && len(chain) < needed; nulls-- { + chain = append(chain, 0) + } + ts = parent + } + // Reverse to chronological order (oldest first). + for i, j := 0, len(chain)-1; i < j; i, j = i+1, j-1 { + chain[i], chain[j] = chain[j], chain[i] + } + return chain, nil +} diff --git a/chain/ecfinality/cache_test.go b/chain/ecfinality/cache_test.go new file mode 100644 index 0000000..0c081c8 --- /dev/null +++ b/chain/ecfinality/cache_test.go @@ -0,0 +1,161 @@ +package ecfinality + +import ( + "fmt" + "testing" + + "github.com/filecoin-project/go-address" + "github.com/filecoin-project/go-state-types/abi" + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" + "github.com/stretchr/testify/require" + + ltypes "github.com/Reiers/lantern/chain/types" +) + +func tCID(t *testing.T, s string) cid.Cid { + t.Helper() + hash, err := mh.Sum([]byte(s), mh.SHA2_256, -1) + require.NoError(t, err) + return cid.NewCidV1(cid.DagCBOR, hash) +} + +func tHeader(t *testing.T, h abi.ChainEpoch, parents []cid.Cid, tag string) *ltypes.BlockHeader { + t.Helper() + miner, err := address.NewIDAddress(1000) + require.NoError(t, err) + return <ypes.BlockHeader{ + Miner: miner, + Ticket: <ypes.Ticket{VRFProof: []byte("t-" + tag)}, + ElectionProof: <ypes.ElectionProof{WinCount: 1, VRFProof: []byte("e-" + tag)}, + Parents: parents, + ParentWeight: ltypes.NewInt(uint64(h)), + Height: h, + ParentStateRoot: tCID(t, "state-"+tag), + ParentMessageReceipts: tCID(t, "receipts-"+tag), + Messages: tCID(t, "msgs-"+tag), + Timestamp: 1_700_000_000 + uint64(h)*30, + ParentBaseFee: ltypes.NewInt(100), + } +} + +// fakeSource is an in-memory HeaderSource over a linked chain of tipsets. +type fakeSource struct { + head *ltypes.TipSet + byK map[string]*ltypes.TipSet +} + +func (f *fakeSource) Head() *ltypes.TipSet { return f.head } +func (f *fakeSource) GetTipSet(tsk ltypes.TipSetKey) (*ltypes.TipSet, error) { + ts, ok := f.byK[tsk.String()] + if !ok { + return nil, fmt.Errorf("not found") + } + return ts, nil +} + +// buildChain builds a linked single-block-per-epoch chain of n epochs +// starting at startEpoch, optionally skipping heights in skip (null +// rounds). Blocks-per-epoch is 1 for simplicity; blockCount overrides via +// widthAt (height -> number of blocks in the tipset). +func buildChain(t *testing.T, startEpoch abi.ChainEpoch, n int, widthAt func(abi.ChainEpoch) int, skip map[abi.ChainEpoch]bool) *fakeSource { + t.Helper() + src := &fakeSource{byK: make(map[string]*ltypes.TipSet)} + var prev []cid.Cid + for i := 0; i < n; i++ { + h := startEpoch + abi.ChainEpoch(i) + if skip != nil && skip[h] { + continue // null round: no tipset at this height + } + width := 1 + if widthAt != nil { + width = widthAt(h) + } + var blks []*ltypes.BlockHeader + for w := 0; w < width; w++ { + blks = append(blks, tHeader(t, h, prev, fmt.Sprintf("h%d-w%d", h, w))) + } + ts, err := ltypes.NewTipSet(blks) + require.NoError(t, err) + src.byK[ts.Key().String()] = ts + src.head = ts + prev = ts.Cids() + } + return src +} + +// TestCache_HealthyWindowFindsThreshold: a healthy 5-wide chain with 120 +// epochs of history reports a threshold around the ~30-epoch mark. +func TestCache_HealthyWindowFindsThreshold(t *testing.T) { + src := buildChain(t, 100, 120, func(abi.ChainEpoch) int { return 5 }, nil) + c := NewCache(src, 900) + + st, err := c.Status() + require.NoError(t, err) + require.Equal(t, 120, st.WindowEpochs, "walk must cover the full available window") + require.Greater(t, st.ThresholdDepth, 0, "healthy chain must meet the guarantee") + require.Less(t, st.ThresholdDepth, 35, "healthy chain finalizes shallow") + require.Equal(t, st.HeadEpoch-abi.ChainEpoch(st.ThresholdDepth), st.FinalizedEpoch) +} + +// TestCache_ShallowWindowNotComputable: a node with only a few epochs of +// observed history (fresh boot from anchor) must report -1, not an +// over-confident threshold. +func TestCache_ShallowWindowNotComputable(t *testing.T) { + src := buildChain(t, 100, MinWindow-5, func(abi.ChainEpoch) int { return 5 }, nil) + c := NewCache(src, 900) + + st, err := c.Status() + require.NoError(t, err) + require.Equal(t, MinWindow-5, st.WindowEpochs) + require.Equal(t, -1, st.ThresholdDepth, "shallow window must not report a threshold") + require.Equal(t, abi.ChainEpoch(-1), st.FinalizedEpoch) +} + +// TestCache_NullRoundsCounted: a gap in heights (null rounds) appears as +// 0-entries in the walked window, so WindowEpochs counts epochs, not +// tipsets. +func TestCache_NullRoundsCounted(t *testing.T) { + skip := map[abi.ChainEpoch]bool{150: true, 151: true} + src := buildChain(t, 100, 100, func(abi.ChainEpoch) int { return 5 }, skip) + c := NewCache(src, 900) + + st, err := c.Status() + require.NoError(t, err) + require.Equal(t, 100, st.WindowEpochs, "null rounds count as epochs (0 blocks)") + require.Greater(t, st.ThresholdDepth, 0) +} + +// TestCache_RecomputesOnlyOnHeadChange: repeated Status calls on the same +// head are cache hits; a head advance triggers exactly one recompute. +func TestCache_RecomputesOnlyOnHeadChange(t *testing.T) { + src := buildChain(t, 100, 120, func(abi.ChainEpoch) int { return 5 }, nil) + c := NewCache(src, 900) + + _, err := c.Status() + require.NoError(t, err) + _, err = c.Status() + require.NoError(t, err) + calls, comps := c.Stats() + require.Equal(t, uint64(2), calls) + require.Equal(t, uint64(1), comps, "same head must be a cache hit") + + // Advance head by one epoch. + newHead := tHeader(t, src.head.Height()+1, src.head.Cids(), "advance") + ts, err := ltypes.NewTipSet([]*ltypes.BlockHeader{newHead}) + require.NoError(t, err) + src.byK[ts.Key().String()] = ts + src.head = ts + + _, err = c.Status() + require.NoError(t, err) + _, comps = c.Stats() + require.Equal(t, uint64(2), comps, "head advance must recompute once") +} + +// TestCache_NoHead: an empty source errors cleanly. +func TestCache_NoHead(t *testing.T) { + c := NewCache(&fakeSource{byK: map[string]*ltypes.TipSet{}}, 900) + _, err := c.Status() + require.Error(t, err) +} diff --git a/chain/ecfinality/calculator.go b/chain/ecfinality/calculator.go new file mode 100644 index 0000000..c6e7aa0 --- /dev/null +++ b/chain/ecfinality/calculator.go @@ -0,0 +1,259 @@ +// Package ecfinality implements the FRC-0089 EC finality calculator (#96). +// +// The calculator computes an upper bound on the probability that a confirmed +// tipset could be reorganized out of the canonical chain by an adversarial +// fork, using OBSERVED chain data (block counts per epoch). Under healthy +// network conditions (~5 blocks/epoch), the 2^-30 finality guarantee +// (roughly one-in-a-billion chance of reorg) is typically achieved within +// ~30 epochs (~15 minutes), compared to the static 900-epoch (~7.5 hour) +// EC finality assumption which is based on worst-case conditions. +// +// Why Lantern wants this: F3 gives BLS-verified finality certificates, but +// where F3 finality is not available at the tip (or not live on a network), +// the fallback is the static 900-epoch worst case. FRC-0089 gives a +// principled, locally-computed finality depth with no external trust: it +// quantifies how deep a tip-range eclipse could plausibly reorg us. It +// feeds the head-trust story (#100), retention pruning depth (#92), and +// the v2 "finalized"/"safe" selector semantics (#99: +// finalized = max(ec-finalized, F3-finalized)). +// +// This is a port of lotus chain/ecfinality (itself a Go port of the +// FRC-0089 Python reference), adapted to Lantern's header store. The math +// is kept verbatim so the lotus/Python test vectors apply unchanged. +// +// Reference: https://github.com/filecoin-project/FIPs/blob/master/FRCs/frc-0089.md +// Python reference: https://github.com/consensus-shipyard/ec-finality-calculator +package ecfinality + +import ( + "math" + + skellampmf "github.com/rvagg/go-skellam-pmf" +) + +const ( + // BisectLow and BisectHigh define the search range for the bisect + // algorithm that finds the epoch depth at which the finality guarantee + // is met. A low bound of 3 avoids evaluating trivially shallow depths; + // a high bound of 450 accommodates degraded chains that take longer to + // finalize. + BisectLow = 3 + BisectHigh = 450 + + // DefaultBlocksPerEpoch is the Filecoin expected block production rate + // (mainnet and calibration both target 5). + DefaultBlocksPerEpoch = 5.0 + + // DefaultByzantineFraction is the standard Filecoin security assumption + // for adversarial mining power. + DefaultByzantineFraction = 0.3 + + // DefaultSafetyExponent is the target reorg probability as a power of 2. + // 2^-30 (~one-in-a-billion) is the standard Filecoin finality guarantee. + DefaultSafetyExponent = -30 +) + +// CalcValidatorProb computes the upper-bound probability that a confirmed +// tipset could be reorganized out of the canonical chain. Verbatim port of +// the FRC-0089 reference (finality_calc_validator.py) via lotus. +// +// Parameters: +// - chain: block counts per epoch (index 0 = earliest epoch; null rounds +// appear as 0 entries) +// - finality: lookback depth for the L distribution (900 on mainnet) +// - blocksPerEpoch: expected blocks per epoch (5 for mainnet) +// - byzantineFraction: upper bound on adversarial power fraction (e.g. 0.3) +// - currentEpoch: index into chain for the current epoch +// - targetEpoch: index into chain for the epoch being evaluated +func CalcValidatorProb(chain []int, finality int, blocksPerEpoch float64, byzantineFraction float64, currentEpoch int, targetEpoch int) float64 { + if currentEpoch <= targetEpoch || targetEpoch < 0 || currentEpoch >= len(chain) { + return 1.0 + } + + const negligibleThreshold = 1e-25 + + maxKL := 400 + maxKB := (currentEpoch - targetEpoch) * int(blocksPerEpoch) + maxKM := 400 + maxIM := 100 + + rateMaliciousBlocks := blocksPerEpoch * byzantineFraction + rateHonestBlocks := blocksPerEpoch - rateMaliciousBlocks + + // Compute L: adversarial lead distribution at target epoch + prL := make([]float64, maxKL+1) + + for k := 0; k <= maxKL; k++ { + sumExpectedAdversarialBlocksI := 0.0 + sumChainBlocksI := 0 + + for i := targetEpoch; i > max(0, currentEpoch-finality); i-- { + sumExpectedAdversarialBlocksI += rateMaliciousBlocks + sumChainBlocksI += chain[i-1] + prLi := poissonProb(sumExpectedAdversarialBlocksI, float64(k+sumChainBlocksI)) + prL[k] = max(prL[k], prLi) + } + if k > 1 && prL[k] < negligibleThreshold && prL[k] < prL[k-1] { + maxKL = k + prL = prL[:k+1] + break + } + } + + prL[0] += 1 - sumFloat64(prL) + + // Compute B: adversarial blocks during settlement period + prB := make([]float64, maxKB+1) + + for k := 0; k <= maxKB; k++ { + prB[k] = poissonProb(float64(currentEpoch-targetEpoch)*rateMaliciousBlocks, float64(k)) + + if k > 1 && prB[k] < negligibleThreshold && prB[k] < prB[k-1] { + maxKB = k + prB = prB[:k+1] + break + } + } + + // Compute M: adversarial mining advantage in the future (Skellam distribution) + prHgt0 := 1 - poissonProb(rateHonestBlocks, 0) + + expZ := 0.0 + for k := 0; k < int(4*blocksPerEpoch); k++ { + pmf := poissonProb(rateMaliciousBlocks, float64(k)) + expZ += ((rateHonestBlocks + float64(k)) / math.Pow(2, float64(k))) * pmf + } + + ratePublicChain := prHgt0 * expZ + + prM := make([]float64, maxKM+1) + for k := 0; k <= maxKM; k++ { + for i := maxIM; i > 0; i-- { + probMI := skellampmf.SkellamPMF(k, float64(i)*rateMaliciousBlocks, float64(i)*ratePublicChain) + + if probMI < negligibleThreshold && probMI < prM[k] { + break + } + prM[k] = max(prM[k], probMI) + } + + if k > 1 && prM[k] < negligibleThreshold && prM[k] < prM[k-1] { + maxKM = k + prM = prM[:k+1] + break + } + } + + prM[0] += 1 - sumFloat64(prM) + + // Compute reorg probability upper bound via convolution + cumsumL := cumsum(prL) + cumsumB := cumsum(prB) + cumsumM := cumsum(prM) + + k := sumInt(chain[targetEpoch:currentEpoch]) + + sumLgeK := cumsumL[len(cumsumL)-1] + if k > 0 { + sumLgeK -= cumsumL[min(k-1, maxKL)] + } + + doubleSum := 0.0 + + for l := range k { + sumBgeKminL := cumsumB[len(cumsumB)-1] + if k-l-1 > 0 { + sumBgeKminL -= cumsumB[min(k-l-1, maxKB)] + } + doubleSum += prL[min(l, maxKL)] * sumBgeKminL + + for b := 0; b < k-l; b++ { + sumMgeKminLminB := cumsumM[len(cumsumM)-1] + if k-l-b-1 > 0 { + sumMgeKminLminB -= cumsumM[min(k-l-b-1, maxKM)] + } + doubleSum += prL[min(l, maxKL)] * prB[min(b, maxKB)] * sumMgeKminLminB + } + } + + prError := sumLgeK + doubleSum + + return min(prError, 1.0) +} + +// FindThresholdDepth performs a bisect search to find the shallowest depth +// at which the reorg probability drops below the given guarantee. Returns +// -1 if the guarantee is not met within the search range. +func FindThresholdDepth(chain []int, finality int, blocksPerEpoch float64, byzantineFraction float64, guarantee float64) int { + currentEpoch := len(chain) - 1 + low, high := BisectLow, min(BisectHigh, currentEpoch) + + if low >= high { + return -1 + } + + probLow := CalcValidatorProb(chain, finality, blocksPerEpoch, byzantineFraction, currentEpoch, currentEpoch-low) + if probLow < guarantee { + return low + } + + probHigh := CalcValidatorProb(chain, finality, blocksPerEpoch, byzantineFraction, currentEpoch, currentEpoch-high) + if probHigh > guarantee { + return -1 + } + + for low < high { + mid := (low + high) / 2 + prob := CalcValidatorProb(chain, finality, blocksPerEpoch, byzantineFraction, currentEpoch, currentEpoch-mid) + if prob < guarantee { + high = mid + } else { + low = mid + 1 + } + } + return low +} + +func poissonProb(lambda float64, x float64) float64 { + return math.Exp(poissonLogProb(lambda, x)) +} + +func poissonLogProb(lambda float64, x float64) float64 { + if x < 0 || math.Floor(x) != x { + return math.Inf(-1) + } + if lambda == 0 { + if x == 0 { + return 0 // P(X=0 | lambda=0) = 1, log(1) = 0 + } + return math.Inf(-1) + } + lg, _ := math.Lgamma(math.Floor(x) + 1) + return x*math.Log(lambda) - lambda - lg +} + +func sumFloat64(s []float64) float64 { + var total float64 + for _, v := range s { + total += v + } + return total +} + +func sumInt(s []int) int { + var total int + for _, v := range s { + total += v + } + return total +} + +func cumsum(arr []float64) []float64 { + result := make([]float64, len(arr)) + var s float64 + for i, v := range arr { + s += v + result[i] = s + } + return result +} diff --git a/chain/ecfinality/calculator_test.go b/chain/ecfinality/calculator_test.go new file mode 100644 index 0000000..0024c9a --- /dev/null +++ b/chain/ecfinality/calculator_test.go @@ -0,0 +1,176 @@ +package ecfinality + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +// The finality parameter used by the Python reference (and Filecoin mainnet). +const testFinality = 900 + +// Test vectors generated by the Python reference implementation from FRC-0089: +// https://github.com/consensus-shipyard/ec-finality-calculator (finality_calc_validator.py) +// +// Parameters: blocks_per_epoch=5.0, byzantine_fraction=0.3 +// Chain: 905 epochs generated with numpy.random.default_rng(0).poisson(4.5, 905) +// Python: scipy 1.15.2, numpy 2.2.3 +// current_epoch = 904, target_epoch = current_epoch - depth +var pythonReferenceChain = []int{ + 2, 3, 4, 3, 4, 7, 5, 6, 5, 5, 4, 2, 3, 3, 10, 7, 3, 8, 6, 3, + 2, 3, 5, 3, 7, 6, 5, 3, 4, 6, 6, 8, 6, 3, 2, 6, 5, 2, 4, 4, + 4, 6, 5, 7, 8, 6, 3, 0, 10, 8, 3, 7, 4, 6, 4, 6, 5, 2, 5, 5, + 7, 6, 2, 1, 3, 5, 3, 5, 10, 4, 0, 5, 11, 6, 8, 6, 4, 8, 3, 4, + 3, 2, 5, 6, 6, 5, 3, 9, 5, 2, 9, 3, 6, 5, 4, 6, 2, 3, 4, 7, + 5, 8, 2, 6, 0, 3, 5, 6, 6, 4, 3, 6, 5, 2, 3, 4, 6, 1, 5, 3, + 5, 7, 2, 4, 11, 3, 4, 8, 5, 3, 6, 6, 7, 5, 1, 2, 1, 4, 4, 5, + 6, 4, 2, 6, 5, 5, 1, 2, 5, 5, 0, 4, 4, 7, 4, 10, 6, 4, 9, 5, + 5, 1, 0, 3, 7, 1, 6, 4, 3, 5, 7, 6, 10, 3, 5, 4, 1, 6, 2, 2, + 2, 5, 4, 7, 4, 2, 5, 6, 3, 8, 4, 6, 6, 5, 3, 3, 3, 2, 5, 5, + 6, 6, 4, 7, 4, 1, 3, 6, 10, 3, 3, 4, 6, 3, 6, 5, 4, 3, 7, 6, + 2, 4, 2, 3, 1, 9, 5, 1, 5, 6, 4, 3, 8, 3, 6, 3, 2, 2, 1, 2, + 3, 6, 2, 4, 2, 4, 5, 5, 4, 4, 7, 8, 8, 8, 8, 6, 2, 3, 3, 4, + 4, 3, 3, 1, 4, 5, 6, 3, 4, 7, 4, 1, 2, 2, 10, 2, 2, 3, 3, 5, + 4, 5, 3, 5, 1, 8, 4, 2, 6, 4, 9, 4, 7, 2, 2, 4, 4, 3, 3, 4, + 7, 6, 4, 2, 8, 1, 4, 3, 4, 7, 4, 0, 6, 7, 4, 4, 6, 3, 5, 7, + 4, 8, 2, 2, 6, 4, 5, 3, 3, 3, 1, 4, 2, 4, 3, 5, 2, 3, 0, 6, + 4, 7, 3, 6, 3, 4, 4, 6, 3, 3, 2, 7, 4, 4, 5, 3, 4, 5, 3, 4, + 4, 3, 4, 1, 5, 4, 4, 5, 4, 2, 4, 5, 3, 6, 5, 6, 3, 4, 4, 4, + 5, 4, 4, 5, 4, 4, 2, 5, 2, 4, 2, 1, 6, 6, 5, 5, 4, 9, 3, 2, + 6, 4, 2, 4, 7, 7, 5, 5, 7, 8, 2, 5, 4, 5, 1, 4, 5, 2, 5, 6, + 5, 4, 4, 8, 5, 5, 6, 6, 0, 2, 4, 5, 5, 3, 5, 4, 8, 2, 4, 6, + 7, 3, 5, 5, 7, 1, 2, 5, 3, 10, 5, 10, 1, 10, 3, 5, 5, 2, 6, 2, + 5, 4, 2, 1, 5, 9, 2, 4, 4, 2, 2, 5, 5, 6, 4, 1, 6, 5, 5, 2, + 6, 1, 9, 4, 7, 3, 8, 5, 4, 5, 6, 8, 5, 4, 3, 3, 2, 3, 3, 4, + 4, 7, 7, 3, 4, 4, 4, 6, 3, 3, 4, 5, 4, 1, 3, 8, 5, 4, 5, 7, + 5, 8, 2, 7, 9, 5, 3, 7, 5, 6, 6, 5, 6, 8, 4, 6, 3, 5, 4, 6, + 2, 2, 6, 5, 4, 6, 3, 3, 4, 5, 2, 3, 3, 6, 6, 4, 5, 4, 3, 8, + 4, 8, 3, 5, 3, 6, 4, 6, 1, 3, 3, 4, 8, 5, 7, 4, 5, 5, 1, 3, + 6, 5, 3, 6, 3, 5, 5, 6, 5, 6, 5, 7, 6, 4, 7, 6, 5, 3, 3, 2, + 4, 8, 4, 5, 1, 4, 8, 1, 2, 2, 2, 4, 11, 1, 3, 3, 2, 1, 7, 7, + 3, 4, 5, 2, 5, 6, 3, 6, 3, 9, 3, 0, 4, 2, 5, 4, 3, 2, 7, 4, + 2, 10, 7, 4, 3, 5, 8, 5, 5, 2, 3, 3, 8, 6, 5, 6, 6, 6, 9, 3, + 3, 2, 6, 5, 4, 4, 4, 2, 5, 2, 8, 4, 3, 2, 2, 3, 3, 7, 5, 0, + 7, 3, 5, 3, 3, 3, 6, 3, 3, 1, 3, 5, 7, 5, 4, 5, 2, 4, 3, 7, + 9, 2, 4, 2, 7, 6, 5, 3, 2, 6, 3, 6, 3, 5, 6, 3, 3, 3, 3, 3, + 7, 5, 3, 4, 4, 9, 5, 7, 9, 4, 9, 2, 4, 3, 1, 4, 6, 1, 3, 5, + 5, 6, 4, 4, 2, 7, 7, 4, 5, 3, 1, 4, 5, 2, 4, 5, 2, 7, 2, 11, + 5, 4, 8, 6, 4, 3, 3, 6, 5, 4, 3, 4, 7, 2, 2, 2, 4, 3, 5, 4, + 5, 3, 6, 5, 5, 2, 6, 1, 11, 3, 3, 5, 5, 6, 2, 5, 3, 4, 5, 5, + 7, 7, 7, 9, 3, 4, 6, 3, 3, 2, 6, 6, 1, 3, 1, 5, 7, 5, 7, 8, + 4, 5, 2, 6, 6, 5, 7, 5, 5, 6, 4, 2, 7, 6, 5, 5, 9, 4, 3, 3, + 1, 1, 4, 5, 5, 6, 7, 2, 4, 6, 3, 5, 5, 5, 4, 2, 4, 3, 3, 5, + 2, 4, 4, 5, 6, 3, 6, 4, 5, 4, 5, 2, 8, 6, 5, 6, 7, 6, 2, 4, + 9, 1, 3, 5, 4, 7, 2, 5, 4, 7, 9, 2, 3, 2, 2, 7, 4, 1, 2, 6, + 5, 10, 2, 4, 3, +} + +// pythonReferenceResults maps depth -> reorg probability from the Python reference. +var pythonReferenceResults = map[int]float64{ + 5: 1.58182730260265891863e-03, + 10: 1.67515743138728720072e-04, + 15: 2.80696481196116546684e-06, + 20: 6.84359796410981096872e-08, + 25: 1.46218662028857760238e-09, + 30: 4.62723254179747158594e-12, + 40: 3.53912692038794048900e-17, + 50: 1.37790735432279053542e-20, + 75: 2.40782990048672131651e-24, + 100: 3.21616912956779552478e-24, +} + +func TestCalcValidatorProb_PythonReference(t *testing.T) { + req := require.New(t) + chain := pythonReferenceChain + currentEpoch := len(chain) - 1 + + for depth, wantProb := range pythonReferenceResults { + targetEpoch := currentEpoch - depth + gotProb := CalcValidatorProb(chain, testFinality, 5.0, 0.3, currentEpoch, targetEpoch) + + relErr := math.Abs(gotProb-wantProb) / math.Abs(wantProb) + req.LessOrEqualf(relErr, 1e-12, + "depth=%d: got %.15e, want %.15e (relErr=%.3e)", depth, gotProb, wantProb, relErr) + } +} + +func TestCalcValidatorProb_HealthyChain(t *testing.T) { + req := require.New(t) + + // A perfectly healthy chain with 5 blocks per epoch should achieve + // 2^-30 finality well within 30 epochs + chain := make([]int, 905) + for i := range chain { + chain[i] = 5 + } + currentEpoch := len(chain) - 1 + guarantee := math.Pow(2, -30) + + prob30 := CalcValidatorProb(chain, testFinality, 5.0, 0.3, currentEpoch, currentEpoch-30) + req.Less(prob30, guarantee, "healthy chain at depth 30 should be below 2^-30") + + prob5 := CalcValidatorProb(chain, testFinality, 5.0, 0.3, currentEpoch, currentEpoch-5) + req.Greater(prob5, prob30, "shallower depth should have higher reorg probability") +} + +func TestCalcValidatorProb_DegradedChain(t *testing.T) { + req := require.New(t) + + // A degraded chain with only 2 blocks per epoch should have much worse + // finality than a healthy chain at the same depth + chain := make([]int, 905) + for i := range chain { + chain[i] = 2 + } + currentEpoch := len(chain) - 1 + guarantee := math.Pow(2, -30) + + prob30 := CalcValidatorProb(chain, testFinality, 5.0, 0.3, currentEpoch, currentEpoch-30) + req.GreaterOrEqual(prob30, guarantee, "degraded chain at depth 30 should NOT achieve 2^-30") +} + +func TestFindThresholdDepth_HealthyChain(t *testing.T) { + req := require.New(t) + + chain := make([]int, 905) + for i := range chain { + chain[i] = 5 + } + guarantee := math.Pow(2, -30) + + depth := FindThresholdDepth(chain, testFinality, 5.0, 0.3, guarantee) + req.Greater(depth, 0, "healthy chain should find a threshold") + req.Less(depth, 35, "healthy chain should finalize well before depth 35") +} + +func TestFindThresholdDepth_DegradedChain(t *testing.T) { + req := require.New(t) + + // All-1s chain is too degraded to achieve 2^-30 within the bisect + // search range (BisectHigh=450), so threshold is not found + chain := make([]int, 905) + for i := range chain { + chain[i] = 1 + } + guarantee := math.Pow(2, -30) + + depth := FindThresholdDepth(chain, testFinality, 5.0, 0.3, guarantee) + req.Equal(-1, depth, "severely degraded chain should not find threshold within search range") +} + +func TestFindThresholdDepth_MildlyDegradedChain(t *testing.T) { + req := require.New(t) + + // All-3s chain is degraded but should still find a threshold, + // just deeper than a healthy chain + chain := make([]int, 905) + for i := range chain { + chain[i] = 3 + } + guarantee := math.Pow(2, -30) + + depth := FindThresholdDepth(chain, testFinality, 5.0, 0.3, guarantee) + req.Greater(depth, 35, "mildly degraded chain should require more depth than healthy") + req.Less(depth, BisectHigh, "mildly degraded chain should still find a threshold") +} diff --git a/cmd/lantern/dashboard.go b/cmd/lantern/dashboard.go index f947272..83c740b 100644 --- a/cmd/lantern/dashboard.go +++ b/cmd/lantern/dashboard.go @@ -31,6 +31,7 @@ import ( "github.com/Reiers/lantern/build" "github.com/Reiers/lantern/chain/bootstrap" + "github.com/Reiers/lantern/chain/ecfinality" "github.com/Reiers/lantern/chain/header/store" "github.com/Reiers/lantern/chain/trustedroot" "github.com/Reiers/lantern/internal/buildinfo" @@ -75,6 +76,8 @@ type dashboardDeps struct { hello *hello.Service // Issue #17: ChainExchange responder activity (received / rejected). xchg *chainxchg.Service + // #96: FRC-0089 EC finality calculator (observed-data finality depth). + ecfin *ecfinality.Cache } // registerDashboard attaches /dashboard and /api/dashboard/* to the mux. @@ -367,6 +370,21 @@ func (d *dashboardDeps) syncSnapshot() map[string]any { "last_install_epoch": int64(s.LastInstallEpoch), } } + if d.ecfin != nil { + // #96: on-demand + cached per head, so the dev page is the only + // thing that pays the (small) Skellam cost. + if st, err := d.ecfin.Status(); err == nil { + calls, comps := d.ecfin.Stats() + out["ec_finality"] = map[string]any{ + "threshold_depth": st.ThresholdDepth, + "finalized_epoch": int64(st.FinalizedEpoch), + "head_epoch": int64(st.HeadEpoch), + "window_epochs": st.WindowEpochs, + "calls": calls, + "recomputes": comps, + } + } + } if d.host != nil { ks := d.host.KeepaliveStats() out["keepalive"] = map[string]any{ diff --git a/cmd/lantern/dashboard/index.html b/cmd/lantern/dashboard/index.html index ea5d069..67822c5 100644 --- a/cmd/lantern/dashboard/index.html +++ b/cmd/lantern/dashboard/index.html @@ -446,6 +446,19 @@

Chain

Skipped
Backfilled
Backfill fail
+
Held (uncorrob.)
+
Held (diverged)
+
Rejected (lighter fork)
+ + + +
+

EC finality (FRC-0089, observed)

+
+
2⁻³⁰ depth
+
EC-finalized epoch
+
Observed window
+
Recomputes
@@ -897,6 +910,18 @@

Connected peers

$("d-g-skip").textContent = fmt.int(g.skipped); $("d-g-back").textContent = fmt.int(g.backfilled); $("d-g-backfail").textContent = fmt.int(g.backfill_failed); + $("d-g-helduc").textContent = fmt.int(g.held_uncorrob); + $("d-g-helddiv").textContent = fmt.int(g.held_diverged); + $("d-g-rejlight").textContent = fmt.int(g.rejected_lighter); + + // #96: FRC-0089 observed-data EC finality. + const ef = s.ec_finality || {}; + if (ef.head_epoch != null) { + $("d-ef-depth").textContent = ef.threshold_depth >= 0 ? ef.threshold_depth + " epochs" : "not met"; + $("d-ef-final").textContent = ef.finalized_epoch >= 0 ? fmt.int(ef.finalized_epoch) : "—"; + $("d-ef-window").textContent = fmt.int(ef.window_epochs); + $("d-ef-comps").textContent = fmt.int(ef.recomputes); + } $("d-q-epoch").textContent = fmt.int(o.epoch); $("d-q-f3i").textContent = (o.f3_instance && o.f3_instance > 0) ? fmt.int(o.f3_instance) : "awaiting finality"; diff --git a/cmd/lantern/gossip_block.go b/cmd/lantern/gossip_block.go index cdc479b..ce2cafe 100644 --- a/cmd/lantern/gossip_block.go +++ b/cmd/lantern/gossip_block.go @@ -25,6 +25,7 @@ import ( pubsub "github.com/libp2p/go-libp2p-pubsub" + "github.com/Reiers/lantern/chain/ecfinality" hstore "github.com/Reiers/lantern/chain/header/store" "github.com/Reiers/lantern/net/blockingest" "github.com/Reiers/lantern/net/blockpub" @@ -48,3 +49,13 @@ func startGossipBlocks(ctx context.Context, ps *pubsub.PubSub, store *hstore.Sto func(format string, args ...any) { fmt.Fprintf(os.Stderr, format, args...) }) return ing, pub, nil } + +// newECFinality builds the #96 FRC-0089 EC finality cache over the header +// store, or nil when no store is configured. The 900-epoch lookback is +// Filecoin's ChainFinality (same on mainnet + calibration). +func newECFinality(store ecfinality.HeaderSource) *ecfinality.Cache { + if store == nil { + return nil + } + return ecfinality.NewCache(store, 900) +} diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index f09220d..2f1ffe9 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -1200,6 +1200,9 @@ func cmdDaemon(args []string) error { gatewayURL: *gw, hello: helloSvc, xchg: xchgSvc, + // #96: observed-data EC finality (FRC-0089). Nil-safe in + // the handler; store==nil (no header store) leaves it off. + ecfin: newECFinality(store), } dashboardURL = fmt.Sprintf("http://%s/dashboard/", *metricsListen) } diff --git a/go.mod b/go.mod index c0c52e9..d37c6cb 100644 --- a/go.mod +++ b/go.mod @@ -146,6 +146,7 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/webtransport-go v0.10.0 // indirect + github.com/rvagg/go-skellam-pmf v0.0.2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect github.com/wlynxg/anet v0.0.5 // indirect diff --git a/go.sum b/go.sum index dd1156d..185fdf0 100644 --- a/go.sum +++ b/go.sum @@ -548,6 +548,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rvagg/go-skellam-pmf v0.0.2 h1:UZpUgaBSLjChrD6s8yVkZy1q6CJ2fdm1Y8B6lfg+E08= +github.com/rvagg/go-skellam-pmf v0.0.2/go.mod h1:/xSBO272x+iW1BjHnPL6p2O7kCpVTh7QK9hZcXE/Kqk= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index 6e5eaff..174d90f 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -40,6 +40,7 @@ import ( lapi "github.com/Reiers/lantern/api" "github.com/Reiers/lantern/build" + "github.com/Reiers/lantern/chain/ecfinality" "github.com/Reiers/lantern/chain/headcheck" hstore "github.com/Reiers/lantern/chain/header/store" "github.com/Reiers/lantern/chain/headnotify" @@ -335,6 +336,10 @@ type Daemon struct { headerSync *hstore.Sync headNotify *headnotify.Distributor + // #96: FRC-0089 EC finality calculator over the header store. + // Populated with headerStore; on-demand + cached per head. + ecFinality *ecfinality.Cache + // fevmPrefetch is the on-head-advance state-block warmer // (lantern#44). Populated when Config.FEVMPrefetchAddrs is set. // Nil-able; Stats() on a nil-receiver returns zero values. @@ -523,6 +528,26 @@ func (d *Daemon) BlockCacheStats() (statecache.Stats, bool) { return bc.Stats(), true } +// ECFinality returns the current FRC-0089 observed-data finality status +// (#96): the shallowest depth at which reorg probability drops below +// 2^-30 given the observed block counts, the finalized epoch at that +// depth, and how many epochs of history the calculator had. Returns +// (nil, false) when no header store is configured. Computation is +// on-demand and cached per head. +func (d *Daemon) ECFinality() (*ecfinality.Status, bool) { + d.mu.Lock() + c := d.ecFinality + d.mu.Unlock() + if c == nil { + return nil, false + } + st, err := c.Status() + if err != nil { + return nil, false + } + return st, true +} + func (d *Daemon) HeadCorroboration() (headcheck.Result, bool) { d.mu.Lock() mon := d.headcheck diff --git a/pkg/daemon/start.go b/pkg/daemon/start.go index b8e7302..85a30f2 100644 --- a/pkg/daemon/start.go +++ b/pkg/daemon/start.go @@ -47,6 +47,7 @@ import ( "github.com/Reiers/lantern/build" "github.com/Reiers/lantern/chain/bootstrap" + "github.com/Reiers/lantern/chain/ecfinality" "github.com/Reiers/lantern/chain/f3/subscriber" "github.com/Reiers/lantern/chain/fullvalidate" "github.com/Reiers/lantern/chain/headcheck" @@ -280,6 +281,9 @@ func (d *Daemon) startInternal(ctx context.Context) error { d.headerStore = store d.headerSync = sync d.headNotify = dist + // #96: observed-data EC finality over the header store (FRC-0089). + // 900 = Filecoin ChainFinality (mainnet + calibration). + d.ecFinality = ecfinality.NewCache(store, 900) d.mu.Unlock() log.Infow("header store wired",