diff --git a/cmd/lantern/bitswap_backed_source.go b/cmd/lantern/bitswap_backed_source.go index b33bd60..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) } @@ -103,3 +121,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..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 { @@ -727,6 +773,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 +808,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. @@ -806,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) @@ -848,10 +933,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 +1006,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,11 +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 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 @@ -1157,14 +1268,55 @@ 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()) + + // #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 {