From 896f41b00e43c1b331a29ae28b096779ddaf643a Mon Sep 17 00:00:00 2001 From: Reiers Date: Thu, 2 Jul 2026 12:40:48 +0200 Subject: [PATCH 1/2] feat(#76): --no-fallback-rpc for a provably Glif-free standalone daemon The standalone `lantern daemon` CLI wired Glif unconditionally as the last-resort runtime source (fetch fan-out + polling Sync head + gossip inline-backfill), with no way to opt out. pkg/daemon already had Config.NoFallbackRPC; this exposes the same capability on the CLI. --no-fallback-rpc removes Glif from all four runtime sites: - initial combined fetcher (gateway-only cold blocks) - polling Sync head source -> truly-nil RPCSource (nil-guard no-op) - gossip inline-backfill source -> truly-nil BackfillSource - rebuilt bitswap fetcher (bitswap+gateway, no glif) Head then comes ONLY from gossipsub; a stall surfaces as an observable stalled head instead of a silent Glif fetch. Guard: refuses to start without libp2p (gossip is the sole head source). The one-time boot anchor (#54) still uses multi-source agreement (gateway+Glif) and is separate from runtime fetch counters -> follow-up for anchor Glif-independence. Truly-nil interface helpers (nilRPCSource/nilBackfillSource) avoid the typed-nil-in-interface trap the consumers' nil-guards depend on. Build+vet+gofmt clean; hstore + blockingest nil-guard tests pass. Soak evidence: #104 Setup checkbox 4. --- cmd/lantern/bitswap_backed_source.go | 11 +++++ cmd/lantern/main.go | 61 +++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/cmd/lantern/bitswap_backed_source.go b/cmd/lantern/bitswap_backed_source.go index b33bd60..067844e 100644 --- a/cmd/lantern/bitswap_backed_source.go +++ b/cmd/lantern/bitswap_backed_source.go @@ -103,3 +103,14 @@ var ( _ blockingest.BackfillSource = (*bitswapBackedSource)(nil) _ blockGetter = (*combined.Fetcher)(nil) ) + +// nilRPCSource / nilBackfillSource return TRULY-NIL interfaces (not a +// typed-nil *bitswapBackedSource) for the bridge-off path (#76, +// --no-fallback-rpc). A typed-nil wrapped in an interface is non-nil and +// would nil-panic on first method call; returning the untyped nil through +// the interface return type makes the nil guards in hstore.Sync +// (src == nil) and net/blockingest (g.src == nil) fire correctly, so no +// Glif client is ever constructed and the polling Sync + gossip inline +// backfill become no-ops (gossipsub is the sole head source). +func nilRPCSource() hstore.RPCSource { return nil } +func nilBackfillSource() blockingest.BackfillSource { return nil } diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index bc95e9f..d8891e1 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -727,6 +727,15 @@ func cmdDaemon(args []string) error { // alarms on divergence. Requires --vm-bridge-rpc. vmCrossCheck := fs.Bool("vm-crosscheck", false, "Audit the local chain against the VM bridge node (observe-only divergence alarm). Requires --vm-bridge-rpc.") vmCrossCheckInterval := fs.Duration("vm-crosscheck-interval", crosscheck.DefaultInterval, "Interval between VM-bridge cross-checks.") + // #76 bridge-off: wire NO upstream RPC (Glif) as a runtime fallback. + // Head comes ONLY from gossipsub; cold blocks ONLY from gateway+Bitswap; + // the polling Sync + gossip inline-backfill become no-ops. A gossip stall + // then surfaces as an observable stalled head instead of a silent Glif + // fetch. Requires libp2p/gossipsub (it is the sole head source). Mirrors + // pkg/daemon Config.NoFallbackRPC for the standalone CLI. The one-time + // boot anchor (#54) still uses multi-source agreement (gateway+Glif) and + // is separate from runtime fetch counters. + noFallbackRPC := fs.Bool("no-fallback-rpc", false, "#76 bridge-off: wire no upstream RPC (Glif) fallback. Head=gossipsub-only, cold-blocks=gateway+bitswap. Requires libp2p/gossipsub.") fs.Parse(args) // #58: --allow-empty-passphrase is sugar for the env the keystore guard @@ -753,6 +762,13 @@ func cmdDaemon(args []string) error { // network instead of the package default ('mainnet'). buildinfo.SetNetwork(network.String()) + // #76 bridge-off guard: with no RPC fallback, gossipsub is the ONLY head + // source. Refuse to start without libp2p rather than silently freeze the + // head (mirrors pkg/daemon/start.go). + if *noFallbackRPC && (*noLibp2p || *p2pListen == "") { + return fmt.Errorf("--no-fallback-rpc requires libp2p/gossipsub enabled (it is the only head source); set --p2p-listen and do not pass --no-libp2p") + } + // V1.3 per-network data dir: migrate any legacy mainnet-only state // at dataDir() to dataDir()/mainnet/ on first boot. Idempotent for // fresh installs or already-migrated state. @@ -848,10 +864,16 @@ func cmdDaemon(args []string) error { cache = hamt.NewMemBlockStore() fmt.Printf(" node tier: %s (in-memory cache)\n", profile.Tier) } - fetcher := combined.New(cache, - combined.Source{Name: "gateway", Getter: hsync.NewClient([]string{*gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}, - combined.Source{Name: "glif", Getter: glif.New(glifURLForNetwork(network), 20*time.Second), Timeout: 20 * time.Second}, - ) + fetcherSources := []combined.Source{ + {Name: "gateway", Getter: hsync.NewClient([]string{*gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}, + } + if !*noFallbackRPC { + fetcherSources = append(fetcherSources, + combined.Source{Name: "glif", Getter: glif.New(glifURLForNetwork(network), 20*time.Second), Timeout: 20 * time.Second}) + } else { + fmt.Printf(" bridge-off: no upstream RPC fallback wired (#76); head=gossipsub-only, cold-blocks=gateway+bitswap\n") + } + fetcher := combined.New(cache, fetcherSources...) if persistentCache != nil { defer persistentCache.Close() } @@ -915,7 +937,14 @@ func cmdDaemon(args []string) error { // mainnet daemon pulls from mainnet Glif. Without the network split // the header store fills with the wrong chain's headers (silent // corruption). - src := newBitswapBackedSource(glif.New(glifURLForNetwork(network), 8*time.Second), func() blockGetter { return fetcher }) + // #76: bridge-off => no Glif Sync source (truly-nil interface so the + // hstore.Sync nil-guard fires and the polling Sync is a no-op; head is + // gossipsub-only). Otherwise the bitswap-backed adapter (Glif head + + // content-addressed backfill). + src := nilRPCSource() + if !*noFallbackRPC { + src = newBitswapBackedSource(glif.New(glifURLForNetwork(network), 8*time.Second), func() blockGetter { return fetcher }) + } // #71: when libp2p/gossipsub is enabled it is the PRIMARY head source // (0-1 epoch latency, no Glif), so relax the polling-Sync cadence to // 30s as a catch-up fallback instead of polling Glif's ChainHead every @@ -1084,7 +1113,13 @@ func cmdDaemon(args []string) error { // Glif client, so a slow Glif drove backfillFail up and desynced the // node despite live blocks arriving fine over gossipsub. if store != nil && p2pHost.PubSub != nil { - gossipSrc := newBitswapBackedSource(glif.New(glifURLForNetwork(network), 8*time.Second), func() blockGetter { return fetcher }) + // #76: bridge-off => no Glif inline-backfill source (truly-nil + // interface so the ingestor nil-guard fires; parent backfill is + // served from the content-addressed fetcher / bitswap only). + gossipSrc := nilBackfillSource() + if !*noFallbackRPC { + gossipSrc = newBitswapBackedSource(glif.New(glifURLForNetwork(network), 8*time.Second), func() blockGetter { return fetcher }) + } if ing, blockPub, gerr := startGossipBlocks(ctx, p2pHost.PubSub, store, gossipSrc, network.GossipTopicBlocks()); gerr != nil { fmt.Printf(" gossipsub-blocks: failed to start: %v (continuing without)\n", gerr) } else { @@ -1157,11 +1192,15 @@ func cmdDaemon(args []string) error { // falling through to the gateway) now complete in low seconds. // Glif stays as the sequential last-resort fallback (different // shape, slower, public-service rate-limited). - fetcher = combined.New(cache, - combined.Source{Name: "bitswap", Getter: bsClient, Timeout: *bitswapFullDL, Race: true}, - combined.Source{Name: "gateway", Getter: hsync.NewClient([]string{*gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}, - combined.Source{Name: "glif", Getter: glif.New(glifURLForNetwork(network), 20*time.Second), Timeout: 20 * time.Second}, - ) + rebuiltSources := []combined.Source{ + {Name: "bitswap", Getter: bsClient, Timeout: *bitswapFullDL, Race: true}, + {Name: "gateway", Getter: hsync.NewClient([]string{*gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}, + } + if !*noFallbackRPC { + rebuiltSources = append(rebuiltSources, + combined.Source{Name: "glif", Getter: glif.New(glifURLForNetwork(network), 20*time.Second), Timeout: 20 * time.Second}) + } + fetcher = combined.New(cache, rebuiltSources...) rebindBlockGetter(chainAPI, fetcher) fmt.Printf(" bitswap: enabled (preferred=%d, fast=%s, full=%s)\n", len(preferred), bitswapFastDL.String(), bitswapFullDL.String()) From 476a58291bd6b40bacf9c9d64399abadc40c209f Mon Sep 17 00:00:00 2001 From: Reiers Date: Thu, 2 Jul 2026 15:52:37 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(#76):=20bridge-off=20scaffolding=20?= =?UTF-8?q?=E2=80=94=20prefer=20quorum=20anchor,=20parent-walk=20backfill,?= =?UTF-8?q?=20anchor=20seed,=20fail-loud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clean, non-panicking bridge-off (--no-fallback-rpc) plumbing: - Daemon prefers the persisted multi-source quorum anchor (bootstrap-anchor.json from init/repair) over re-probing gateway+glif; gateway/RPC becomes true last resort. Bridge-off refuses to boot without a usable persisted anchor (clear error, not a panic). - bitswapBackedSource is nil-glif-safe: HeadEpoch/TipsetCIDsByHeight return a sentinel (no nil-deref); FetchBlock serves bitswap-only. - Ingestor gains an explicit parent-walk backfill mode (SetParentWalkBackfill): resolve head+N gaps by CID-walking the gossip block's Parents instead of the RPC-shaped height->CID lookup. Height-based path unchanged for glif. - Bridge-off seeds the REAL anchor tipset (replacing the synthetic head) from the block source so gossip has a genuine content-addressed base. KNOWN GAP (not yet functional end-to-end): block RETRIEVAL. Bridge-off needs to fetch specific chain blocks by CID from peers. Bitswap has no calibration chain-block providers (glif carries 100% of fetches on the soak node) and the only gateway is mainnet. The Filecoin-native fix is a ChainExchange/BlockSync client (#91) against the same libp2p quorum peers; Lantern today only has a certexchange (F3) client + a chainxchg NotFound responder. This scaffolding is the layer #91 plugs into. build/vet/gofmt clean; blockingest + hstore tests pass. --- cmd/lantern/bitswap_backed_source.go | 18 ++++ cmd/lantern/main.go | 131 +++++++++++++++++++++++++-- net/blockingest/blockingest.go | 118 ++++++++++++++++++++++++ 3 files changed, 258 insertions(+), 9 deletions(-) diff --git a/cmd/lantern/bitswap_backed_source.go b/cmd/lantern/bitswap_backed_source.go index 067844e..4525454 100644 --- a/cmd/lantern/bitswap_backed_source.go +++ b/cmd/lantern/bitswap_backed_source.go @@ -15,6 +15,7 @@ package main import ( "context" + "errors" "github.com/filecoin-project/go-state-types/abi" "github.com/ipfs/go-cid" @@ -61,15 +62,29 @@ func newBitswapBackedSource(g rpcBlockSource, getFetcher func() blockGetter) *bi return &bitswapBackedSource{glif: g, getFetcher: getFetcher} } +// errBridgeOffNoRPC is returned by the RPC-shaped methods when the adapter +// is constructed with a nil Glif client (the #76 bridge-off path). Head +// comes from gossipsub and blocks come from bitswap, so these two calls +// simply have no source — returning a sentinel (instead of nil-panicking) +// lets callers that only need FetchBlock (the gossip parent-walk backfill) +// use the adapter safely. +var errBridgeOffNoRPC = errors.New("bitswap-backed source: no upstream RPC (bridge-off #76): head=gossipsub, blocks=bitswap") + // HeadEpoch delegates to Glif (live head also arrives via gossipsub, so in // steady state this is rarely the limiting call). func (s *bitswapBackedSource) HeadEpoch(ctx context.Context) (abi.ChainEpoch, error) { + if s.glif == nil { + return 0, errBridgeOffNoRPC + } return s.glif.HeadEpoch(ctx) } // TipsetCIDsByHeight delegates to Glif (RPC-shaped: maps height -> the // canonical tipset's block CIDs). func (s *bitswapBackedSource) TipsetCIDsByHeight(ctx context.Context, h abi.ChainEpoch) ([]cid.Cid, error) { + if s.glif == nil { + return nil, errBridgeOffNoRPC + } return s.glif.TipsetCIDsByHeight(ctx, h) } @@ -92,6 +107,9 @@ func (s *bitswapBackedSource) FetchBlock(ctx context.Context, k cid.Cid) (*types // Glif rather than fail the backfill outright. } } + if s.glif == nil { + return nil, errBridgeOffNoRPC + } return s.glif.FetchBlock(ctx, k) } diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index d8891e1..b09fe69 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -671,6 +671,52 @@ func fetchVerifiedTrustedHead(ctx context.Context, gw string, network build.Netw return tr, nil } +// loadBootstrapAnchorForNetwork reads the persisted quorum anchor +// (bootstrap-anchor.json, written by `lantern init` / `lantern repair`) +// and enforces that it matches the running network. Returns (nil, nil) +// when no anchor file exists. +func loadBootstrapAnchorForNetwork(dir string, network build.Network) (*BootstrapAnchor, error) { + a, err := ReadBootstrapAnchor(dir) + if err != nil || a == nil { + return a, err + } + if a.Network != "" && a.Network != string(network) { + return nil, fmt.Errorf("persisted anchor network mismatch: file=%s runtime=%s", a.Network, network) + } + return a, nil +} + +// trustedRootFromBootstrapAnchor builds a TrustedRoot from a persisted +// quorum anchor so the daemon can boot off the multi-source anchor +// (#76: gateway/RPC last resort) instead of re-probing on every start. +func trustedRootFromBootstrapAnchor(a *BootstrapAnchor) (*trustedroot.TrustedRoot, error) { + if a == nil { + return nil, fmt.Errorf("nil bootstrap anchor") + } + tskCids := make([]cid.Cid, 0, len(a.TipSetKey)) + for _, s := range a.TipSetKey { + c, err := cid.Parse(s) + if err != nil { + return nil, fmt.Errorf("parse anchor tipset cid %q: %w", s, err) + } + tskCids = append(tskCids, c) + } + if len(tskCids) == 0 { + return nil, fmt.Errorf("anchor has no tipset CIDs") + } + sr, err := cid.Parse(a.StateRoot) + if err != nil { + return nil, fmt.Errorf("parse anchor state root %q: %w", a.StateRoot, err) + } + return &trustedroot.TrustedRoot{ + Epoch: abi.ChainEpoch(a.Epoch), + TipSetKey: types.NewTipSetKey(tskCids...), + StateRoot: sr, + AcceptedAt: a.CapturedAt, + F3Instance: a.Instance, + }, nil +} + // --- daemon --- func cmdDaemon(args []string) error { @@ -822,9 +868,32 @@ func cmdDaemon(args []string) error { fmt.Printf("Lantern daemon — Lotus-compatible RPC (network: %s)\n", network) fmt.Printf(" data dir: %s\n", netDir) fmt.Println("Fetching trusted head from", *gw) - tr, err := fetchVerifiedTrustedHead(ctx, *gw, network, *insecureAnchor) - if err != nil { - return err + // #76: prefer the persisted multi-source quorum anchor (written by + // `lantern init` / `lantern repair`) so a normal boot needs no gateway + // or RPC. Bridge-off refuses to fall back to a re-probe; a normal node + // still falls back to the verified gateway+Glif anchor when there is no + // persisted anchor yet. + var tr *trustedroot.TrustedRoot + var err error + if a, aerr := loadBootstrapAnchorForNetwork(netDir, network); aerr != nil { + fmt.Fprintf(os.Stderr, " warn: persisted anchor: %v\n", aerr) + } else if a != nil { + if parsed, perr := trustedRootFromBootstrapAnchor(a); perr != nil { + fmt.Fprintf(os.Stderr, " warn: persisted anchor unusable: %v\n", perr) + } else { + tr = parsed + fmt.Printf(" anchor: persisted quorum anchor (epoch %d, captured %s) — gateway/RPC last resort (#76)\n", + tr.Epoch, a.CapturedAt.Format("2006-01-02T15:04:05Z")) + } + } + if tr == nil { + if *noFallbackRPC { + return fmt.Errorf("--no-fallback-rpc: no usable persisted quorum anchor at %s; run `lantern init` or `lantern repair --bootstrap-quorum` first (bridge-off has no gateway/RPC anchor fallback)", filepath.Join(netDir, "bootstrap-anchor.json")) + } + tr, err = fetchVerifiedTrustedHead(ctx, *gw, network, *insecureAnchor) + if err != nil { + return err + } } fmt.Printf(" head epoch: %d\n state root: %s\n", tr.Epoch, tr.StateRoot) @@ -1113,17 +1182,24 @@ func cmdDaemon(args []string) error { // Glif client, so a slow Glif drove backfillFail up and desynced the // node despite live blocks arriving fine over gossipsub. if store != nil && p2pHost.PubSub != nil { - // #76: bridge-off => no Glif inline-backfill source (truly-nil - // interface so the ingestor nil-guard fires; parent backfill is - // served from the content-addressed fetcher / bitswap only). - gossipSrc := nilBackfillSource() - if !*noFallbackRPC { - gossipSrc = newBitswapBackedSource(glif.New(glifURLForNetwork(network), 8*time.Second), func() blockGetter { return fetcher }) + // #76: bridge-off keeps a bitswap-backed backfill source (no Glif) + // so the ingestor can parent-walk gaps via CID-addressed fetches; + // the RPC-shaped HeadEpoch/TipsetCIDsByHeight are never called on + // this path (parent-walk mode enabled below). + gossipSrc := newBitswapBackedSource(glif.New(glifURLForNetwork(network), 8*time.Second), func() blockGetter { return fetcher }) + if *noFallbackRPC { + gossipSrc = newBitswapBackedSource(nil, func() blockGetter { return fetcher }) } if ing, blockPub, gerr := startGossipBlocks(ctx, p2pHost.PubSub, store, gossipSrc, network.GossipTopicBlocks()); gerr != nil { fmt.Printf(" gossipsub-blocks: failed to start: %v (continuing without)\n", gerr) } else { gossipIngestor = ing + // #76: with no upstream RPC, resolve head+N gaps by walking + // the gossip block's Parents via bitswap instead of a + // height->CID RPC lookup. + if *noFallbackRPC { + ing.SetParentWalkBackfill(true) + } // #80 part 2: head adoption requires corroboration from // distinct scored peers (trusted floor peers super-vote; // requirement clamps to connected-peer count so a small @@ -1204,6 +1280,43 @@ func cmdDaemon(args []string) error { rebindBlockGetter(chainAPI, fetcher) fmt.Printf(" bitswap: enabled (preferred=%d, fast=%s, full=%s)\n", len(preferred), bitswapFastDL.String(), bitswapFullDL.String()) + + // #76: bridge-off has no polling Sync to seed the store from the + // anchor (the polling src is a no-op). The header store boots with a + // SYNTHETIC head at the anchor epoch (Miner f00, ticket "lantern-synth") + // so ChainHead answers immediately - but its block CIDs are fake, so + // real gossip blocks (whose Parents point at the REAL anchor block CIDs) + // can never connect to it and every parent-walk runs to the cap. Replace + // the synthetic head with the REAL anchor tipset fetched via bitswap so + // gossip has a genuine, content-addressed base to parent-walk onto. + // Only when the store hasn't already advanced past the anchor (fresh / + // synthetic boot); fail loud if the anchor blocks aren't retrievable. + if *noFallbackRPC && store != nil && store.HeadEpoch() <= tr.Epoch { + seedSrc := newBitswapBackedSource(nil, func() blockGetter { return fetcher }) + anchorCids := tr.TipSetKey.Cids() + if len(anchorCids) == 0 { + return fmt.Errorf("bridge-off seed: anchor has no tipset CIDs") + } + sctx, scancel := context.WithTimeout(ctx, 45*time.Second) + seedBlocks := make([]*types.BlockHeader, 0, len(anchorCids)) + for _, c := range anchorCids { + b, ferr := seedSrc.FetchBlock(sctx, c) + if ferr != nil { + scancel() + return fmt.Errorf("bridge-off seed: fetch anchor block %s via bitswap: %w", c, ferr) + } + seedBlocks = append(seedBlocks, b) + } + scancel() + seedTS, terr := types.NewTipSet(seedBlocks) + if terr != nil { + return fmt.Errorf("bridge-off seed: build anchor tipset: %w", terr) + } + if serr := store.SetHead(ctx, seedTS); serr != nil { + return fmt.Errorf("bridge-off seed: set store head: %w", serr) + } + fmt.Printf(" seed: store head seeded from anchor @ %d (%d blocks) via bitswap (#76)\n", tr.Epoch, len(seedBlocks)) + } } // Phase 10 Part B: /metrics endpoint exposes per-source hit counts so diff --git a/net/blockingest/blockingest.go b/net/blockingest/blockingest.go index 61221a7..489e979 100644 --- a/net/blockingest/blockingest.go +++ b/net/blockingest/blockingest.go @@ -28,6 +28,7 @@ package blockingest import ( "context" "fmt" + "sort" "sync/atomic" "time" @@ -83,6 +84,12 @@ type Ingestor struct { src BackfillSource backfillCap abi.ChainEpoch + // parentWalk selects the bridge-off backfill strategy (#76): resolve a + // gap by walking the gossip block's own Parents chain via CID-addressed + // FetchBlock (bitswap), instead of the RPC-shaped height->CID lookup. + // Enabled by the daemon only when there is no upstream RPC source. + parentWalk bool + incoming chan *ltypes.BlockMsg seen map[cid.Cid]struct{} @@ -382,6 +389,9 @@ func (g *Ingestor) allParentsPresent(parents []cid.Cid) bool { // bh.Height, fetching missing tipsets via the RPC source and installing // each. Bounded by backfillCap (epoch-depth). func (g *Ingestor) inlineBackfill(ctx context.Context, bh *ltypes.BlockHeader) error { + if g.parentWalk { + return g.parentWalkBackfill(ctx, bh) + } curHead := g.store.HeadEpoch() needFrom := curHead + 1 needTo := bh.Height - 1 @@ -431,6 +441,114 @@ func (g *Ingestor) inlineBackfill(ctx context.Context, bh *ltypes.BlockHeader) e // markSeen inserts the CID into the dedupe set with simple LRU eviction. func (g *Ingestor) markSeen(c cid.Cid) { + g.markSeenImpl(c) +} + +// SetParentWalkBackfill enables the #76 bridge-off backfill strategy, where +// a gap is filled by CID-walking the gossip block's Parents via bitswap +// FetchBlock instead of the RPC-shaped height->CID lookup. Call once at +// construction time before Run. +func (g *Ingestor) SetParentWalkBackfill(on bool) { g.parentWalk = on } + +// parentWalkBackfill resolves a head+N (N>1) gap without any RPC by walking +// the target block's Parents chain via CID-addressed FetchBlock (bitswap), +// descending until every CID at a level is already in the store (the seeded +// anchor / current head), then installing the collected tipsets oldest-first +// so the head ends at the highest backfilled tipset. Bounded by backfillCap. +func (g *Ingestor) parentWalkBackfill(ctx context.Context, bh *ltypes.BlockHeader) error { + curHead := g.store.HeadEpoch() + needTo := bh.Height - 1 + if curHead >= needTo { + return nil + } + if gap := needTo - curHead; gap > g.backfillCap { + return fmt.Errorf("bridge-off backfill gap %d > cap %d", gap, g.backfillCap) + } + if len(bh.Parents) == 0 { + return fmt.Errorf("bridge-off backfill: gossip block @ %d has no parents", bh.Height) + } + + bctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + collected := map[abi.ChainEpoch][]*ltypes.BlockHeader{} + seen := map[cid.Cid]struct{}{} + frontier := append([]cid.Cid(nil), bh.Parents...) + for len(frontier) > 0 { + if abi.ChainEpoch(len(collected)) > g.backfillCap { + return fmt.Errorf("bridge-off backfill exceeded cap %d", g.backfillCap) + } + var next []cid.Cid + levelAllPresent := true + for _, c := range frontier { + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + if _, err := g.store.Get(c); err == nil { + continue // already present: reached the store head / seeded anchor + } + levelAllPresent = false + b, ferr := g.src.FetchBlock(bctx, c) + if ferr != nil { + return fmt.Errorf("bridge-off backfill fetch %s: %w", c, ferr) + } + if verr := header.VerifyBlockHeaderCID(b, c); verr != nil { + return fmt.Errorf("bridge-off backfill cid verify %s: %w", c, verr) + } + if b.Height <= curHead { + continue // walked below the store head on this branch; stop it + } + collected[b.Height] = append(collected[b.Height], b) + next = append(next, b.Parents...) + } + if levelAllPresent { + break + } + frontier = next + } + if len(collected) == 0 { + return nil + } + + heights := make([]abi.ChainEpoch, 0, len(collected)) + for h := range collected { + heights = append(heights, h) + } + sort.Slice(heights, func(i, j int) bool { return heights[i] < heights[j] }) + for _, h := range heights { + ts, err := ltypes.NewTipSet(dedupBlocksByCID(collected[h])) + if err != nil { + return fmt.Errorf("bridge-off backfill tipset @ %d: %w", h, err) + } + if err := g.store.SetHead(ctx, ts); err != nil { + return fmt.Errorf("bridge-off backfill set head @ %d: %w", h, err) + } + } + return nil +} + +// dedupBlocksByCID removes duplicate block headers (same CID reached via +// multiple parent paths) while preserving order. +func dedupBlocksByCID(in []*ltypes.BlockHeader) []*ltypes.BlockHeader { + if len(in) < 2 { + return in + } + seen := make(map[cid.Cid]struct{}, len(in)) + out := in[:0:0] + for _, b := range in { + c := b.Cid() + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, b) + } + return out +} + +// markSeenImpl inserts the CID into the dedupe set with simple LRU eviction. +func (g *Ingestor) markSeenImpl(c cid.Cid) { g.seen[c] = struct{}{} g.seenOrder = append(g.seenOrder, c) if len(g.seenOrder) > g.seenCap {