diff --git a/CHANGELOG.md b/CHANGELOG.md index 192fa556af3..a9582a28257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - fix(eth): handle nil address in trace_filter for failed contract creates ([filecoin-project/lotus#13549](https://github.com/filecoin-project/lotus/pull/13549)) - fix(gas): stricter bounds for GasEstimateGasPremium lookback ([filecoin-project/lotus#13555](https://github.com/filecoin-project/lotus/pull/13555)) - fix(api): `StateSearchMsg` should respect `lookbackLimit` [filecoin-project/lotus#13562](https://github.com/filecoin-project/lotus/pull/13562) +- fix(ecfinality): account for null rounds in EC finality calculator chain walk, aligning with FRC-0089 theoretical model and fixing depth-to-height conversion ([filecoin-project/lotus#13565](https://github.com/filecoin-project/lotus/pull/13565)) # Node and Miner v1.35.0 / 2026-02-19 diff --git a/chain/ecfinality/cache.go b/chain/ecfinality/cache.go index 30a683c9b15..87d8af61d7d 100644 --- a/chain/ecfinality/cache.go +++ b/chain/ecfinality/cache.go @@ -116,18 +116,28 @@ func (c *ECFinalityCache) GetFinalizedTipSet(ctx context.Context) (*types.TipSet return s.FinalizedTipSet, nil } -// walkChain walks back from head collecting block counts for the calculator. -// Each LoadTipSet call typically hits the ChainStore's ARC cache. +// walkChain walks back from head collecting block counts per epoch for the +// calculator. Null rounds (epochs with no blocks) are included as 0 entries +// so that the calculator sees the real timeline and the returned array depth +// corresponds directly to epoch height differences. Each LoadTipSet call +// typically hits the ChainStore's ARC cache. func (c *ECFinalityCache) walkChain(ctx context.Context, head *types.TipSet) ([]int, error) { needed := c.windowSize chain := make([]int, 0, needed) ts := head - for len(chain) < needed { + for { chain = append(chain, len(ts.Cids())) + if len(chain) >= needed { + break + } parent, err := c.cs.LoadTipSet(ctx, ts.Parents()) if err != nil { return nil, err } + // Insert 0 entries for null rounds between this tipset and its 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). diff --git a/chain/ecfinality/calculator.go b/chain/ecfinality/calculator.go index 50b07fefcec..cb0e15f0640 100644 --- a/chain/ecfinality/calculator.go +++ b/chain/ecfinality/calculator.go @@ -22,9 +22,9 @@ 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 - // 200 accommodates degraded chains that take longer to finalize. + // 450 accommodates degraded chains that take longer to finalize. BisectLow = 3 - BisectHigh = 200 + BisectHigh = 450 // DefaultBlocksPerEpoch is the Filecoin mainnet expected block production rate. DefaultBlocksPerEpoch = 5.0 diff --git a/chain/ecfinality/calculator_test.go b/chain/ecfinality/calculator_test.go index 880eda520df..0024c9a30eb 100644 --- a/chain/ecfinality/calculator_test.go +++ b/chain/ecfinality/calculator_test.go @@ -147,11 +147,11 @@ func TestFindThresholdDepth_HealthyChain(t *testing.T) { func TestFindThresholdDepth_DegradedChain(t *testing.T) { req := require.New(t) - // All-2s chain is too degraded to achieve 2^-30 within the bisect - // search range (BisectHigh=200), so threshold is not found + // 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] = 2 + chain[i] = 1 } guarantee := math.Pow(2, -30) diff --git a/cmd/lotus-shed/finality.go b/cmd/lotus-shed/finality.go index f97504ddb8c..30693993ff8 100644 --- a/cmd/lotus-shed/finality.go +++ b/cmd/lotus-shed/finality.go @@ -83,13 +83,20 @@ machine-readable output of all 900 epochs.`, headEpoch = int(head.Height()) readLength := int(policy.ChainFinality) + 5 chain = append(chain, len(head.Cids())) - for range readLength - 1 { - head, err = api.ChainGetTipSet(ctx, head.Parents()) + for len(chain) < readLength { + parent, err := api.ChainGetTipSet(ctx, head.Parents()) if err != nil { return err } - chain = append(chain, len(head.Cids())) + // Insert 0 entries for null rounds between this tipset and its parent. + for nulls := int(head.Height()-parent.Height()) - 1; nulls > 0 && len(chain) < readLength; nulls-- { + chain = append(chain, 0) + } + chain = append(chain, len(parent.Cids())) + head = parent } + // Trim to exact length in case null round insertion overshot. + chain = chain[:readLength] // API walk produces most-recent-first; reverse to match the // expected ordering (index 0 = earliest epoch). slices.Reverse(chain)