diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index b09fe69..42e40bf 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -44,6 +44,7 @@ import ( "github.com/filecoin-project/go-state-types/abi" "github.com/filecoin-project/go-state-types/big" "github.com/ipfs/go-cid" + "github.com/libp2p/go-libp2p/core/peer" "github.com/Reiers/lantern/build" "github.com/Reiers/lantern/chain/anchorverify" @@ -782,6 +783,7 @@ func cmdDaemon(args []string) error { // 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.") + seedPeerWait := fs.Duration("seed-peer-wait", 120*time.Second, "#91/#109 bridge-off: max time to wait for ChainExchange peers to seed the boot anchor tipset before failing loud.") fs.Parse(args) // #58: --allow-empty-passphrase is sugar for the env the keystore guard @@ -1072,8 +1074,10 @@ func cmdDaemon(args []string) error { var gossipIngestor *gossipBlockIngestor var helloSvc *hello.Service var xchgSvc *chainxchg.Service + var xchgClient *chainxchg.Client _ = helloSvc // kept around for future stats wiring; lifecycle is goroutine-managed _ = xchgSvc + _ = xchgClient if !*noLibp2p && *p2pListen != "" { listeners := splitCSV(*p2pListen) // Peer count sizing for issue #16/#18 follow-up: @@ -1166,7 +1170,25 @@ func cmdDaemon(args []string) error { // connmgr trim passes. xchgSvc = chainxchg.NewService(p2pHost.H) xchgSvc.Register() - fmt.Printf(" chainxchg: /fil/chain/xchg/0.0.1 active (NotFound responder)\n") + // #91: the requesting half - fetch verified header chains from real + // Filecoin peers over the SAME standard protocol, so a bridge-off + // node seeds its boot anchor + backfills gossip gaps with zero Glif + // and zero gateway. Calibration/mainnet Lotus + Forest peers already + // serve /fil/chain/xchg/0.0.1; a malicious peer can only refuse, never + // splice (the client verifies Chain[0] == the quorum-anchor CIDs). + xchgClient = chainxchg.NewClient(p2pHost.H) + // Trusted-floor / bootstrap peers are tried first for the anchor seed. + hostRefX := p2pHost + xchgClient.SetPreferredPeers(func() []peer.ID { + var out []peer.ID + for _, p := range hostRefX.H.Network().Peers() { + if hostRefX.IsTrustedPeer(p) { + out = append(out, p) + } + } + return out + }) + fmt.Printf(" chainxchg: /fil/chain/xchg/0.0.1 active (responder + peer-fetch client, #91)\n") // Issue #1: subscribe to /fil/blocks/ on gossipsub so // new heads land in the header store within ~1-3s of network @@ -1200,6 +1222,12 @@ func cmdDaemon(args []string) error { if *noFallbackRPC { ing.SetParentWalkBackfill(true) } + // #91: prefer peer-sourced ChainExchange for parent-walk gap + // backfill (verified headers from real Filecoin peers, no Glif, + // no gateway) over the per-CID bitswap walk. + if xchgClient != nil { + ing.SetChainFetcher(xchgClient) + } // #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 @@ -1281,33 +1309,49 @@ func cmdDaemon(args []string) error { 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 + // #76/#91/#109: 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. + // the synthetic head with the REAL anchor tipset, fetched from live + // Filecoin peers over ChainExchange (#91) - headers only, CID-verified + // against the multi-source quorum anchor. This is the gateway-free, + // Glif-free boot path (#109): a malicious peer can only refuse, never + // splice, because the client requires Chain[0] == the anchor CIDs. + // The seed runs moments after libp2p start, so wait (bounded) for enough + // peers to connect + advertise the protocol. Fail loud on timeout - we + // never silently fall back to a gateway/RPC in bridge-off mode. 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) + if xchgClient == nil { + return fmt.Errorf("bridge-off seed: no ChainExchange client (libp2p is required as the sole peer-seed source in bridge-off mode)") + } + var seedBlocks []*types.BlockHeader + seedDeadline := time.Now().Add(*seedPeerWait) + var lastErr error + for { + fctx, fcancel := context.WithTimeout(ctx, 30*time.Second) + chain, ferr := xchgClient.FetchTipsetChain(fctx, anchorCids, 1) + fcancel() + if ferr == nil && len(chain) > 0 && len(chain[0]) > 0 { + seedBlocks = chain[0] + break + } + lastErr = ferr + if time.Now().After(seedDeadline) { + return fmt.Errorf("bridge-off seed: no ChainExchange peer served the anchor tipset @ %d within %s (#91/#109): %w", tr.Epoch, (*seedPeerWait).String(), lastErr) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(3 * time.Second): } - seedBlocks = append(seedBlocks, b) } - scancel() seedTS, terr := types.NewTipSet(seedBlocks) if terr != nil { return fmt.Errorf("bridge-off seed: build anchor tipset: %w", terr) @@ -1315,7 +1359,7 @@ func cmdDaemon(args []string) error { 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)) + fmt.Printf(" seed: store head seeded from anchor @ %d (%d blocks) via chainxchg peers - gateway-free, Glif-free (#91/#109)\n", tr.Epoch, len(seedBlocks)) } } diff --git a/net/blockingest/blockingest.go b/net/blockingest/blockingest.go index 7ad0de9..d8c7723 100644 --- a/net/blockingest/blockingest.go +++ b/net/blockingest/blockingest.go @@ -561,28 +561,59 @@ func (g *Ingestor) parentWalkBackfill(ctx context.Context, bh *ltypes.BlockHeade return nil } -// chainExchangeBackfill fills [storeHead+1, bh.Height-1] with one -// ChainExchange request rooted at bh.Parents (#91). The returned chain -// is newest->oldest and already CID-verified; install oldest-first so -// the store head lands on the newest backfilled tipset. +// maxBackfillTipsets bounds a single catch-up so a stale anchor (or a +// misbehaving peer) can't drive unbounded memory. ~20k tipsets of +// headers is a few hundred MiB worst case and covers ~7 days of +// calibration epochs - well beyond any healthy quorum-anchor age. +const maxBackfillTipsets = 20000 + +// chainExchangeBackfill fills [storeHead+1, bh.Height-1] via ChainExchange +// (#91), rooted at bh.Parents. A single request is capped at +// MaxRequestLength (900) tipsets, but a snapshot-free boot from a stale +// quorum anchor can be thousands of epochs behind live head - so we LOOP, +// rooting each successive request at the previous batch's oldest tipset's +// parents, until we reach the store head. Each returned chain is +// newest->oldest and already CID-verified (a peer can only refuse, never +// splice); we install the whole gap oldest-first so the store head walks +// contiguously up to the gossip block. func (g *Ingestor) chainExchangeBackfill(ctx context.Context, bh *ltypes.BlockHeader) error { curHead := g.store.HeadEpoch() - // Number of tipsets between the store head and the gossip block, - // inclusive of null-round slack: ask for the full epoch gap; the - // response naturally contains only real tipsets. - gap := int(bh.Height - 1 - curHead) - if gap < 1 { - gap = 1 - } - bctx, cancel := context.WithTimeout(ctx, 45*time.Second) + bctx, cancel := context.WithTimeout(ctx, 120*time.Second) defer cancel() - chain, err := g.chainFetcher.FetchTipsetChain(bctx, bh.Parents, gap) - if err != nil { - return err + + var full [][]*ltypes.BlockHeader + nextHead := bh.Parents + oldest := bh.Height + for oldest > curHead+1 { + if len(full) >= maxBackfillTipsets { + return fmt.Errorf("chainxchg backfill: gap exceeds %d tipsets (store head %d, target %d) - anchor too stale", maxBackfillTipsets, curHead, bh.Height) + } + gap := int(oldest - 1 - curHead) + if gap < 1 { + gap = 1 + } + chain, err := g.chainFetcher.FetchTipsetChain(bctx, nextHead, gap) + if err != nil { + return err + } + if len(chain) == 0 { + return fmt.Errorf("chainxchg backfill: peer returned empty chain at height %d", oldest) + } + full = append(full, chain...) + last := chain[len(chain)-1] + newOldest := last[0].Height + if newOldest >= oldest { + // No downward progress: guard against a peer that echoes the + // same level forever. + return fmt.Errorf("chainxchg backfill: no progress at height %d", oldest) + } + oldest = newOldest + nextHead = last[0].Parents } + // Install oldest-first, skipping tipsets at/below the store head. - for i := len(chain) - 1; i >= 0; i-- { - blocks := chain[i] + for i := len(full) - 1; i >= 0; i-- { + blocks := full[i] if blocks[0].Height <= curHead { continue }