From 16abdc3f7e6288971282667e13738888c808f31e Mon Sep 17 00:00:00 2001 From: Reiers Date: Fri, 3 Jul 2026 12:57:13 +0200 Subject: [PATCH 1/2] feat(#109): seed boot anchor from ChainExchange peers under --no-fallback-rpc The bridge-off boot anchor seed previously fetched the anchor tipset via a bitswap-only source. On calibration (and any network without a same-network gateway) this fails: bitswap has no peers serving that historical block and gateway.lantern.reiers.io is mainnet-only. So a --no-fallback-rpc cold boot could never seed and fell over. Wire the #91 ChainExchange client (PR #110) into the seed path: - construct the client on the libp2p host alongside the responder - seed the anchor tipset via FetchTipsetChain(anchorCids, 1): headers only, CID-verified against the multi-source quorum anchor (a peer can only refuse, never splice - verifyChain requires Chain[0]==anchor CIDs) - bounded wait (-seed-peer-wait, default 120s) for peers to connect + advertise /fil/chain/xchg/0.0.1; fail loud on timeout, never silently fall back to a gateway/RPC in bridge-off mode - also SetChainFetcher on the gossip ingestor so runtime parent-walk gap backfill prefers peer-sourced headers over the per-CID bitswap walk Gateway-free, Glif-free boot: calibration/mainnet Lotus + Forest peers already serve the standard ChainExchange protocol. Closes #109. build+vet+gofmt clean; net/... + cmd/... suites green; linux/amd64 cross-build ok. --- cmd/lantern/main.go | 80 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 18 deletions(-) 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)) } } From d6ff5730faee51842a149f9e226d2c3f58fc0765 Mon Sep 17 00:00:00 2001 From: Reiers Date: Fri, 3 Jul 2026 13:23:12 +0200 Subject: [PATCH 2/2] feat(#91): iterative chunked ChainExchange backfill for large anchor->tip gaps A single FetchTipsetChain is capped at MaxRequestLength (900) tipsets, so the previous one-shot chainExchangeBackfill could not bridge a stale quorum anchor thousands of epochs behind live head: it fetched only the top 900, tried to install a non-contiguous chunk, and failed (backfillFail). Snapshot-free boot inherently means bridging a large anchor->tip gap, so loop the request in <=900 chunks, rooting each at the previous batch's oldest tipset's parents, until the store head is reached; then install the whole gap oldest-first so the head walks up contiguously. Bounded by maxBackfillTipsets (20k) and a no-progress guard against a misbehaving peer. Every level stays CID-verified by the client. Validated live on calibration: bridge-off Light boot seeded the anchor from chainxchg peers (gateway-free, Glif-free) then this closes the gap to live head. build+vet+gofmt clean; net/blockingest + net/chainxchg tests green; linux/amd64 cross-build ok. --- net/blockingest/blockingest.go | 65 +++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 17 deletions(-) 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 }