Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cmd/lantern/bitswap_backed_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package main

import (
"context"
"errors"

"github.com/filecoin-project/go-state-types/abi"
"github.com/ipfs/go-cid"
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}

Expand All @@ -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 }
178 changes: 165 additions & 13 deletions cmd/lantern/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading