diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e3e88..4d687ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,83 @@ All notable changes to Lantern. +## v1.8.5 (2026-06-30) + +**Head catch-up + bridge-off PDP proving robustness, and head fork-choice +hardening.** A bridge-off node could wedge its head ~10-20 epochs behind the +chain tip and then fail PDP proving with "cannot draw randomness from future +epoch" (reported on v1.8.4-m). Root-caused to two stacked bugs plus a +fork-choice gap on the running head. + +### Fixed + +- **Head catch-up no longer wedges behind the tip** ([#83](https://github.com/Reiers/lantern/issues/83)). + The #71 gossip-fresh skip suspended the polling Sync whenever gossip + installed any block recently — but a node whose gossip is fresh-but-lagging + (installing some blocks while skipping head+N>1 blocks it can't backfill) + would then have neither path reach the tip. The skip is now **lag-aware**: + it consults the gossip-observed tip (`blockingest.ObservedHead()`) and only + skips the catch-up poll when the store head is within a small tolerance of + it, so a lagging node resumes catch-up. Uses the gossip-observed tip, so + no extra upstream RPC — #71's 429-protection is preserved at the tip. + +- **Bridge-off PDP prove randomness** ([#82](https://github.com/Reiers/lantern/issues/82)). + `tipsetForRandomness` compared the requested epoch against the frozen boot + anchor (`Trusted.Epoch`, which never advances) instead of the live header + head, and the only fallback was bridge-only — so bridge-off nodes failed + outright and burned the prove task's retry budget. The ceiling is now the + live head, and when the requested epoch is within a small window above it + (normal sync catch-up) the call waits briefly (bounded) for the header sync + to reach it, then draws locally. No bridge required. Genuinely-future + epochs still error promptly. + +### Added + +- **Heaviest-ParentWeight fork choice on the running gossip head** + ([#79](https://github.com/Reiers/lantern/issues/79)). The head ingestor + advanced head on height-fence + parent-linkage only, so an eclipsed peer + table could feed parent-linked, height-advancing blocks on a + valid-but-lighter fork and walk a node onto it. Lantern now applies + Filecoin's fork-choice rule: adopt a candidate as head only when its + `ParentWeight` strictly exceeds the current head's. A competing lighter + fork is rejected (counted as `rejectedLighter`). Pure header arithmetic — + no proof verification, no ffi. Raises the eclipse cost from "spin up N + sybil peers" to "out-weight the real chain." Does not fully close the + un-finalized-tip split against an adversary with real power — that's F3. + +- **Un-evictable trusted peer floor** ([#80](https://github.com/Reiers/lantern/issues/80)). + The trusted bootstrap/beacon/direct-peer set is now `connmgr`-protected, + so the connection-manager trim path can never drop it. An attacker + flooding the node with dials can no longer fully replace the peer table + with peers it controls — the honest floor survives, keeping the heavier + canonical chain reachable. Complements #79 (which makes a lighter fork + unadoptable). Head-source diversity (≥N distinct peers) is a tracked + follow-up. + +- **Prefetch-on-send** ([#50](https://github.com/Reiers/lantern/issues/50)). + When `eth_sendRawTransaction` publishes a tx locally, its message/receipt + blocks are warmed into the Bitswap cache in the background so the + follow-up receipt poll resolves locally instead of racing a cold + cross-peer fetch. Closes the residual write-confirm availability gap. + +- **Explicit bridge-off head source** ([#50](https://github.com/Reiers/lantern/issues/50)). + New `FallbackRPC` (override the upstream RPC, e.g. your own Forest) and + `NoFallbackRPC` (remove the upstream RPC entirely — head from gossipsub, + cold blocks from gateway+Bitswap). Previously a bridge-off node silently + fell back to Glif when gossip stalled, a hidden third-party dependency; + now a stall surfaces as an observable stalled head. Refuses to start if + `NoFallbackRPC` is set without libp2p (no head source). + +### Changed + +- **`TRUST-MODEL.md` §2.1 corrected for accuracy** + ([#81](https://github.com/Reiers/lantern/issues/81)). The section claimed + election-proof and weight were re-verified on every header; it now states + precisely what is verified (CID integrity, signature shape, parent linkage, + heaviest-ParentWeight fork choice) vs what is not (election-proof VRF, + winning-PoSt, message re-execution) and why (no-ffi design), and points at + F3 + the boot quorum as what carries head trust. + ## v1.8.4 (2026-06-29) **Fix: standalone `lantern daemon` could not fetch cold blocks over Bitswap.** diff --git a/TRUST-MODEL.md b/TRUST-MODEL.md index 5c1c9c0..d312c43 100644 --- a/TRUST-MODEL.md +++ b/TRUST-MODEL.md @@ -36,12 +36,47 @@ any of the following without being detected. ### 2.1 Header chain -* Every header walked back to genesis (or to the trusted anchor checkpoint) - has its BLS signature, election proof, ticket, parent linkage, and weight - re-verified against the local copy of the previous header. -* The header validator (`chain/header/header.go`) refuses any header that - fails any of these checks. Implemented logic lifted from - `lotus@v1.36 chain/sync.go::ValidateBlock`. +Lantern is an **F3-anchored light client**, not a full re-executing +validator. It deliberately does NOT do full Lotus-style block validation, +because cryptographic election-proof / winning-PoSt verification requires +`filecoin-ffi` (Rust), which Lantern's zero-CGo / one-binary design +excludes by construction. Be precise about what that means per-field: + +**What every ingested header IS verified for:** + +* **CID integrity** — the header's CID is re-derived and checked against the + bytes (`header.VerifyBlockHeaderCID`). A peer cannot serve a header under + a CID it doesn't hash to. +* **BLS / signature presence and shape** — structural validation + (`chain/header/header.go::ValidateHeader`, `ValidateTipsetShape`), + modelled on `lotus@v1.36 chain/sync.go`. +* **Parent linkage** — a header is only adopted when its parents are present + in the local store (inline-backfilled if needed). +* **Heaviest-ParentWeight fork choice** (#79) — the running gossip head is + advanced only when a candidate's `ParentWeight` strictly exceeds the + current head's. A competing valid-but-lighter fork is rejected. This is + Filecoin's fork-choice rule applied with pure header arithmetic. + +**What is NOT verified (by design, no-ffi):** + +* **Election-proof VRF validity** — Lantern checks that `ElectionProof` is + *present* for non-genesis heights, but does NOT cryptographically verify + the VRF (that needs the proofs/ffi path). +* **Winning-PoSt** — not verified, same reason. +* **Full message re-execution** — not performed. + +**What carries the trust instead:** the boot anchor (multi-source quorum on +the F3-finalized tipset, §below) plus the F3 finality cert chain (§2.2, +BLS-aggregate-verified). Past the finalized depth, the head is followed +optimistically with the fork-choice + linkage checks above — the same +probabilistic tip every node follows until F3 finalizes it. The residual +eclipse/fork-selection exposure on the un-finalized tip is documented in +§5 and tracked in #79 / #80; finality (F3) is what fully closes it. + +> Note: an earlier version of this section claimed election-proof and +> weight were "re-verified" on every header back to genesis. That +> overstated the code (weight fork-choice landed in #79; election-proof +> VRF is presence-checked, not verified). Corrected for accuracy. ### 2.2 F3 finality diff --git a/chain/header/store/sync.go b/chain/header/store/sync.go index bbd686c..7b912a5 100644 --- a/chain/header/store/sync.go +++ b/chain/header/store/sync.go @@ -80,6 +80,28 @@ type SyncOptions struct { // (the Sync polls every tick, the pre-#71 behavior). GossipFresh func() bool + // GossipObservedHead, when set, reports the highest chain epoch the + // gossip ingestor has observed/installed - i.e. the gossip layer's + // view of the live tip (>= the canonical store head). It makes the + // #71 GossipFresh skip LAG-AWARE (#83): GossipFresh alone only tells + // us the head moved recently, not that it reached the tip. A node + // whose gossip is fresh-but-lagging (installing some blocks while + // skipping head+N>1 blocks it can't backfill) would otherwise have + // Sync skip its catch-up poll forever, wedging the head ~10-20 epochs + // behind the tip. With this set, Sync skips only when the store head + // is within SkipLagTolerance of the observed tip; when further behind + // it runs the catch-up poll regardless of GossipFresh. Returns a + // negative epoch when no observation is available (treated as "can't + // confirm we're at the tip" -> don't skip). Nil keeps the pre-#83 + // behavior (skip purely on GossipFresh). + GossipObservedHead func() abi.ChainEpoch + + // SkipLagTolerance is how many epochs behind the gossip-observed tip + // the store head may be and still skip the catch-up poll (#83). Only + // consulted when GossipObservedHead is set. Default 2 (allows the + // normal 0-1 epoch gossip latency without forcing an RPC poll). + SkipLagTolerance abi.ChainEpoch + // StaleResetThreshold is the lag (live head − store head, in epochs) // beyond which a warm store is considered hopelessly stale and is // re-bootstrapped near the live head instead of attempting a @@ -177,6 +199,18 @@ func (s *Sync) Start(ctx context.Context) error { return nil } +// SetGossipObservedHead wires the gossip-observed-tip callback after +// construction (#83). Pair with SetGossipFresh to make the #71 skip +// lag-aware. tol is the SkipLagTolerance in epochs; <=0 uses the default. +func (s *Sync) SetGossipObservedHead(fn func() abi.ChainEpoch, tol abi.ChainEpoch) { + s.mu.Lock() + defer s.mu.Unlock() + s.opts.GossipObservedHead = fn + if tol > 0 { + s.opts.SkipLagTolerance = tol + } +} + // SetGossipFresh wires the gossip-freshness callback after construction. // The gossipsub ingestor is created after the Sync in both daemon paths, // so the callback can't be passed to NewSync; this lets the caller wire @@ -238,11 +272,45 @@ func (s *Sync) pollAndApply(ctx context.Context) error { // polls so the very first head can be bootstrapped from the RPC. s.mu.Lock() gossipFresh := s.opts.GossipFresh + gossipObservedHead := s.opts.GossipObservedHead + skipLagTol := s.opts.SkipLagTolerance s.mu.Unlock() + if skipLagTol <= 0 { + skipLagTol = 2 + } if gossipFresh != nil && s.store.HeadEpoch() >= 0 && gossipFresh() { - s.mu.Lock() - s.stats.SkippedGossipFresh++ - s.mu.Unlock() + // #83: the gossip-fresh skip must be lag-aware. GossipFresh only + // reports that the head moved recently, not that it reached the + // tip. A node whose gossip installs some blocks while skipping + // head+N>1 blocks it can't backfill stays fresh-but-lagging; if + // we skip the catch-up poll here the head wedges ~10-20 epochs + // behind the tip forever (Beck, v1.8.4-m). Only skip when we can + // confirm the store head is within SkipLagTolerance of the + // gossip-observed tip. When GossipObservedHead is unset or can't + // confirm (negative), fall back to the pre-#83 behavior of + // trusting GossipFresh, since without a tip estimate the only + // alternative is paying the RPC HeadEpoch poll #71 exists to + // avoid. + atTip := true + if gossipObservedHead != nil { + if tip := gossipObservedHead(); tip >= 0 { + atTip = s.store.HeadEpoch() >= tip-skipLagTol + } + } + if atTip { + s.mu.Lock() + s.stats.SkippedGossipFresh++ + s.mu.Unlock() + return nil + } + } + + // #50 part 3: a nil source means no upstream RPC was wired (bridge-off + // NoFallbackRPC). Gossipsub is the sole head driver in that mode, so + // the polling Sync has nothing to do - it stays a no-op rather than + // nil-panicking on HeadEpoch. The store head still advances via the + // gossip ingestor's SetHead calls. + if s.src == nil { return nil } diff --git a/chain/header/store/sync_test.go b/chain/header/store/sync_test.go index 137052a..143ffb1 100644 --- a/chain/header/store/sync_test.go +++ b/chain/header/store/sync_test.go @@ -640,3 +640,107 @@ func TestSyncColdStartPollsDespiteGossipFresh(t *testing.T) { require.NoError(t, sync.PollOnce(context.Background())) require.GreaterOrEqual(t, src.calls(), 1, "cold store must poll HeadEpoch despite GossipFresh") } + +// TestSyncGossipFreshSkipIsLagAware covers #83: the #71 gossip-fresh skip +// must not suspend the catch-up poll when gossip is fresh-but-lagging. +// A node whose gossip installs some blocks while skipping head+N>1 blocks +// it can't backfill would otherwise wedge its head behind the tip forever. +func TestSyncGossipFreshSkipIsLagAware(t *testing.T) { + s, _ := newStore(t, false) + src := newFakeSource() + + // Seed a warm chain 0..4 and bring the store to head 4. + g := mkBlock(t, 0, nil, 1000, "g") + src.put(g) + parents := []cid.Cid{g.Cid()} + for h := abi.ChainEpoch(1); h <= 4; h++ { + b := mkBlock(t, h, parents, 1000, "") + src.put(b) + parents = []cid.Cid{b.Cid()} + } + sync := hstore.NewSync(s, src, hstore.SyncOptions{MaxBacktrack: 50, CatchUpChunk: 200}) + require.NoError(t, sync.PollOnce(context.Background())) + require.Equal(t, abi.ChainEpoch(4), s.HeadEpoch()) + + // Advance the live source far ahead (to 20) - simulating the real tip + // the lagging node hasn't reached. + parents = []cid.Cid{src.canon[4].Cid()} + for h := abi.ChainEpoch(5); h <= 20; h++ { + b := mkBlock(t, h, parents, 1000, "") + src.put(b) + parents = []cid.Cid{b.Cid()} + } + + // Gossip is "fresh" (it installed *something* recently) but its + // observed tip is far above our store head (4) - the fresh-but-lagging + // state. The skip must NOT fire; catch-up must run and advance. + sync.SetGossipFresh(func() bool { return true }) + sync.SetGossipObservedHead(func() abi.ChainEpoch { return 20 }, 0) + + before := sync.Stats().SkippedGossipFresh + require.NoError(t, sync.PollOnce(context.Background())) + require.Equal(t, before, sync.Stats().SkippedGossipFresh, + "poll must not be skipped while gossip is fresh-but-lagging") + require.Greater(t, int64(s.HeadEpoch()), int64(4), + "head must advance toward the tip, not wedge at 4") +} + +// TestSyncGossipFreshSkipAtTip covers the other half of #83: when gossip is +// fresh AND the store head is at (within tolerance of) the observed tip, the +// #71 skip must still fire so a healthy node doesn't hammer the upstream RPC. +func TestSyncGossipFreshSkipAtTip(t *testing.T) { + s, _ := newStore(t, false) + src := newFakeSource() + + g := mkBlock(t, 0, nil, 1000, "g") + src.put(g) + parents := []cid.Cid{g.Cid()} + for h := abi.ChainEpoch(1); h <= 10; h++ { + b := mkBlock(t, h, parents, 1000, "") + src.put(b) + parents = []cid.Cid{b.Cid()} + } + sync := hstore.NewSync(s, src, hstore.SyncOptions{MaxBacktrack: 50, CatchUpChunk: 200}) + require.NoError(t, sync.PollOnce(context.Background())) + require.Equal(t, abi.ChainEpoch(10), s.HeadEpoch()) + + // Gossip fresh and observed tip == store head (at the tip): skip must + // fire (429-protection preserved). + sync.SetGossipFresh(func() bool { return true }) + sync.SetGossipObservedHead(func() abi.ChainEpoch { return 10 }, 0) + + before := sync.Stats().SkippedGossipFresh + require.NoError(t, sync.PollOnce(context.Background())) + require.Equal(t, before+1, sync.Stats().SkippedGossipFresh, + "poll must be skipped when at the tip (preserve #71 429-protection)") + require.Equal(t, abi.ChainEpoch(10), s.HeadEpoch()) +} + +// TestSyncNilSourceIsNoOp covers #50 part 3: a Sync constructed with no +// upstream RPC source (bridge-off NoFallbackRPC, gossip is the sole head +// driver) must not panic - PollOnce is a safe no-op and head is untouched. +func TestSyncNilSourceIsNoOp(t *testing.T) { + s, _ := newStore(t, false) + + // Seed a head via a normal source first, then drop the source. + src := newFakeSource() + g := mkBlock(t, 0, nil, 1000, "g") + src.put(g) + parents := []cid.Cid{g.Cid()} + for h := abi.ChainEpoch(1); h <= 3; h++ { + b := mkBlock(t, h, parents, 1000, "") + src.put(b) + parents = []cid.Cid{b.Cid()} + } + seed := hstore.NewSync(s, src, hstore.SyncOptions{MaxBacktrack: 10}) + require.NoError(t, seed.PollOnce(context.Background())) + require.Equal(t, abi.ChainEpoch(3), s.HeadEpoch()) + + // Now a nil-source Sync: PollOnce must be a no-op, no panic, head intact. + nilSync := hstore.NewSync(s, nil, hstore.SyncOptions{MaxBacktrack: 10}) + require.NotPanics(t, func() { + require.NoError(t, nilSync.PollOnce(context.Background())) + }) + require.Equal(t, abi.ChainEpoch(3), s.HeadEpoch(), + "nil-source Sync must not alter head") +} diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index 01db7ab..5390025 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -1009,6 +1009,9 @@ func cmdDaemon(args []string) error { // relaxed 30s cadence); when gossip goes quiet the Sync resumes. if sync != nil { sync.SetGossipFresh(func() bool { return ing.Fresh(60 * time.Second) }) + // #83: lag-aware skip - resume catch-up when gossip is + // fresh-but-lagging instead of wedging behind the tip. + sync.SetGossipObservedHead(func() abi.ChainEpoch { return ing.ObservedHead() }, 0) } } } diff --git a/net/blockingest/blockingest.go b/net/blockingest/blockingest.go index 8ab27ea..729381e 100644 --- a/net/blockingest/blockingest.go +++ b/net/blockingest/blockingest.go @@ -82,6 +82,7 @@ type Ingestor struct { installed atomic.Uint64 skipped atomic.Uint64 rejected atomic.Uint64 + rejectedLighter atomic.Uint64 // #79: candidates rejected by heaviest-weight fork choice backfilled atomic.Uint64 backfillFailed atomic.Uint64 lastInstallEpoch atomic.Int64 @@ -178,6 +179,33 @@ func (g *Ingestor) process(ctx context.Context, blk *ltypes.BlockMsg) { g.rejected.Add(1) return } + + // #79: heaviest-ParentWeight fork choice on the running head. + // + // The height fence above only guarantees the candidate is *higher* + // than our current head - it does NOT guarantee it's on the canonical + // (heaviest) chain. An attacker who eclipses the peer table can feed + // parent-linked, height-advancing blocks on a valid-but-lighter fork + // and walk us onto it; content addressing doesn't catch this because + // the attacker's blocks hash fine, they're just not canonical. + // + // Filecoin's fork-choice rule is heaviest ParentWeight. A real + // descendant of our current head always has strictly greater + // ParentWeight; a competing lighter fork at a higher height has lower + // or equal weight. So we adopt the candidate as head only when its + // ParentWeight strictly exceeds the current head's. This is pure + // header arithmetic (no proof verification, no ffi) and raises the + // eclipse cost from "spin up N sybil peers" to "out-weight the real + // chain" (i.e. control real storage power). + if cur := g.store.Head(); cur != nil { + cw := cur.ParentWeight() + nw := ts.ParentWeight() + if !cw.Nil() && !nw.Nil() && nw.LessThanEqual(cw) { + g.rejectedLighter.Add(1) + return + } + } + if err := g.store.SetHead(ctx, ts); err != nil { g.rejected.Add(1) return @@ -187,6 +215,26 @@ func (g *Ingestor) process(ctx context.Context, blk *ltypes.BlockMsg) { g.lastInstallNanos.Store(time.Now().UnixNano()) } +// ObservedHead returns the highest block height the ingestor has +// successfully installed into the store. This is the gossip layer's view +// of the chain tip: it tracks the live head (>= the canonical store head, +// since individual high-epoch blocks can be installed before the canonical +// head advances contiguously to them). Returns -1 if nothing installed yet. +// +// The polling Sync uses this to make its #71 gossip-fresh skip lag-aware +// (#83): gossip being "fresh" only means head moved recently, not that +// head is at the tip. Comparing the store head against ObservedHead lets +// Sync skip the catch-up poll only when actually at the tip, and run it +// when gossip is fresh-but-lagging - without paying an upstream HeadEpoch +// RPC call. +func (g *Ingestor) ObservedHead() abi.ChainEpoch { + v := g.lastInstallEpoch.Load() + if v == 0 { + return -1 + } + return abi.ChainEpoch(v) +} + // Fresh reports whether the ingestor installed a block within the last // `within` duration. Used by the polling Sync (#71) to decide whether // gossip is currently keeping the store head live, in which case the @@ -280,6 +328,7 @@ type Stats struct { Installed uint64 Skipped uint64 Rejected uint64 + RejectedLighter uint64 // #79: rejected by heaviest-ParentWeight fork choice Backfilled uint64 BackfillFailed uint64 LastInstallEpoch abi.ChainEpoch @@ -293,6 +342,7 @@ func (g *Ingestor) Stats() Stats { Installed: g.installed.Load(), Skipped: g.skipped.Load(), Rejected: g.rejected.Load(), + RejectedLighter: g.rejectedLighter.Load(), Backfilled: g.backfilled.Load(), BackfillFailed: g.backfillFailed.Load(), LastInstallEpoch: abi.ChainEpoch(g.lastInstallEpoch.Load()), diff --git a/net/blockingest/blockingest_test.go b/net/blockingest/blockingest_test.go index 5e432a1..f72b447 100644 --- a/net/blockingest/blockingest_test.go +++ b/net/blockingest/blockingest_test.go @@ -282,3 +282,50 @@ func TestIngestor_Fresh(t *testing.T) { require.True(t, ing.Fresh(time.Minute), "Fresh must be true right after an install") require.False(t, ing.Fresh(0), "Fresh(0) must be false (no install is within a zero window)") } + +// TestIngestor_ForkChoiceRejectsLighterFork covers #79: a candidate at a +// HIGHER height but with lower-or-equal ParentWeight than the current head +// (an attacker's valid-but-non-canonical lighter fork, fed via an eclipsed +// peer table) must be rejected by heaviest-ParentWeight fork choice, even +// though it passes the height fence and parent linkage. +func TestIngestor_ForkChoiceRejectsLighterFork(t *testing.T) { + // Seed head at height 10. withStore's mkBlock sets ParentWeight == height, + // so the head at h=10 has ParentWeight 10. + s, head := withStore(t, 10) + ing := New(s, nil) + ctx := context.Background() + + // Attacker block: height 11 (passes the height fence) but on a lighter + // fork - explicitly set ParentWeight to 5, BELOW the current head's 10. + // Parent-linked to the real head so linkage passes too. + light := mkBlock(t, head.Height()+1, []cid.Cid{head.Blocks()[0].Cid()}, 1000, "attacker") + light.ParentWeight = ltypes.NewInt(5) + + ing.process(ctx, <ypes.BlockMsg{Header: light}) + + require.Equal(t, uint64(0), ing.installed.Load(), + "lighter fork must NOT be installed as head") + require.Equal(t, uint64(1), ing.Stats().RejectedLighter, + "rejection must be counted as a fork-choice (lighter) reject") + require.Equal(t, head.Height(), s.HeadEpoch(), + "head must stay on the heavier canonical chain") +} + +// TestIngestor_ForkChoiceAcceptsHeavierAdvance is the companion: a normal +// descendant (higher height, strictly greater weight) must still be adopted. +func TestIngestor_ForkChoiceAcceptsHeavierAdvance(t *testing.T) { + s, head := withStore(t, 10) // head weight == 10 + ing := New(s, nil) + ctx := context.Background() + + // Legit next block: height 11, weight 11 (> 10). + next := mkBlock(t, head.Height()+1, []cid.Cid{head.Blocks()[0].Cid()}, 1000, "next") + // mkBlock already sets ParentWeight == height == 11. + + ing.process(ctx, <ypes.BlockMsg{Header: next}) + + require.Equal(t, uint64(1), ing.installed.Load(), + "a strictly-heavier descendant must be adopted as head") + require.Equal(t, uint64(0), ing.Stats().RejectedLighter) + require.Equal(t, head.Height()+1, s.HeadEpoch()) +} diff --git a/net/libp2p/host.go b/net/libp2p/host.go index 8bfdb06..87cee2f 100644 --- a/net/libp2p/host.go +++ b/net/libp2p/host.go @@ -238,6 +238,20 @@ func New(ctx context.Context, cfg HostConfig) (*Host, error) { out.AddCleanup(func() { _ = sub.Close() }) } + // #80: pin the trusted bootstrap/beacon/direct peers as an un-evictable + // floor. connmgr.Protect prevents the trim path (when peer count exceeds + // the high-water-mark) from ever dropping these, so an attacker flooding + // us with dials cannot fully replace our peer table with peers it + // controls - the trusted floor always survives. This is the + // eclipse-resistance complement to the #79 heaviest-weight fork choice: + // #79 makes a lighter fork unadoptable, #80 keeps honest peers in the + // table so we keep hearing the heavier canonical chain. + floor := cfg.GossipSubDirectPeers + if len(floor) == 0 { + floor = cfg.BootstrapPeers + } + out.ProtectPeers(floor, "lantern-trusted-floor") + // Background dial of bootstrap peers (non-blocking). if len(cfg.BootstrapPeers) > 0 { go out.connectBootstrap(ctx, cfg.BootstrapPeers) @@ -246,6 +260,36 @@ func New(ctx context.Context, cfg HostConfig) (*Host, error) { return out, nil } +// ProtectPeers marks the given multiaddr peers as connmgr-protected under +// tag, making them an un-evictable floor the connection-manager trim path +// will never drop (#80). Used for the trusted bootstrap/beacon/direct-peer +// set so a dial flood from an attacker can't fully replace the peer table. +// Safe to call repeatedly; unparseable/empty entries are skipped. Returns +// the count of peers actually protected. +func (h *Host) ProtectPeers(peers []string, tag string) int { + if h == nil || h.H == nil { + return 0 + } + cm := h.H.ConnManager() + if cm == nil { + return 0 + } + n := 0 + for _, p := range peers { + ma, err := multiaddr.NewMultiaddr(p) + if err != nil { + continue + } + info, err := peer.AddrInfoFromP2pAddr(ma) + if err != nil || info.ID == "" { + continue + } + cm.Protect(info.ID, tag) + n++ + } + return n +} + // consumeReachability mirrors EvtLocalReachabilityChanged into the cached // atomic so NetAutoNatStatus is a lock-free read. func (h *Host) consumeReachability(ctx context.Context, sub event.Subscription) { diff --git a/net/libp2p/host_test.go b/net/libp2p/host_test.go index f00b676..16b658e 100644 --- a/net/libp2p/host_test.go +++ b/net/libp2p/host_test.go @@ -85,3 +85,42 @@ func TestKeepaliveStats_Surface(t *testing.T) { require.Equal(t, uint64(0), s.RoutingDial) require.Equal(t, 0, s.LastPeerCount) } + +// TestProtectPeers_UnevictableFloor covers #80 part 1: peers passed to +// ProtectPeers are marked connmgr-protected so the trim path can never +// evict them (the trusted-floor anti-eclipse property). +func TestProtectPeers_UnevictableFloor(t *testing.T) { + if testing.Short() { + t.Skip("skipping in short mode (constructs real libp2p hosts)") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Host under test. + h, err := llibp2p.New(ctx, llibp2p.HostConfig{ + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + }) + require.NoError(t, err) + defer h.Close() + + // A second host to borrow a real peer identity + dialable multiaddr. + other, err := llibp2p.New(ctx, llibp2p.HostConfig{ + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + }) + require.NoError(t, err) + defer other.Close() + + // Build a /p2p/ multiaddr for the other host. + addrs := other.ListenAddrs() + require.NotEmpty(t, addrs) + full := addrs[0] + "/p2p/" + other.ID().String() + + n := h.ProtectPeers([]string{full, "garbage-not-a-multiaddr"}, "test-floor") + require.Equal(t, 1, n, "exactly one valid peer should be protected (garbage skipped)") + + // The connmgr must now report the other peer as protected under the tag. + cm := h.H.ConnManager() + require.NotNil(t, cm) + require.True(t, cm.IsProtected(other.ID(), "test-floor"), + "protected peer must be reported as protected (un-evictable floor)") +} diff --git a/pkg/daemon/daemon.go b/pkg/daemon/daemon.go index d8b4c84..db96015 100644 --- a/pkg/daemon/daemon.go +++ b/pkg/daemon/daemon.go @@ -119,6 +119,24 @@ type Config struct { // connected Bitswap providers (beacon nodes typically). BitswapPeers string + // FallbackRPC overrides the Lotus-compatible RPC URL used as the + // polling Sync head source and the cold state-block fallback. Empty + // uses the built-in Glif URL for the active network (the historical + // default). Point this at your own Forest/Lotus node to remove the + // Glif dependency without going fully bridge-off (lantern#50 part 3). + FallbackRPC string + + // NoFallbackRPC, when true, wires NO upstream RPC as the Sync head + // source or cold-block fallback - the node relies purely on gossipsub + // for the head and Bitswap for cold blocks (lantern#50 part 3). This + // makes the bridge-off trust posture EXPLICIT: under the old default a + // bridge-off node silently fell back to Glif whenever gossip stalled, + // a hidden third-party dependency. With this set, a gossip stall + // surfaces as a stalled head (observable) instead of a silent Glif + // fetch. Intended for operators who have a healthy swarm/beacon set + // and want a provably-Glif-free node. Overrides FallbackRPC. + NoFallbackRPC bool + // VMBridgeRPC is an upstream Forest/Lotus JSON-RPC URL for the VM // bridge (needed for AllowBlockSubmit=true). Empty disables bridge. VMBridgeRPC string @@ -279,6 +297,12 @@ type Daemon struct { mpool *mpool.Pool // gossipsub mempool publisher (#45 Stage 4) bitswap *bitswap.Client // libp2p block source on the embedded fetcher (#50) + // sendWarmer pre-warms a sent tx's message/receipt blocks into the + // Bitswap cache in the background so the receipt poll resolves locally + // (lantern#50 prefetch-on-send). Wired to chainAPI.OnSentTx in + // startInternal; best-effort, read-only. + sendWarmer *sendWarmer + // Internal cancellation: derived from caller's ctx in Start. cancel context.CancelFunc } diff --git a/pkg/daemon/sendwarm.go b/pkg/daemon/sendwarm.go new file mode 100644 index 0000000..8ab7b53 --- /dev/null +++ b/pkg/daemon/sendwarm.go @@ -0,0 +1,185 @@ +package daemon + +// lantern#50 prefetch-on-send. +// +// When eth_sendRawTransaction publishes a tx locally (#45 Stage 4), the +// receipt poll that follows (eth_getTransactionReceipt -> StateSearchMsg) +// has to fetch the freshly-produced message block, its message AMTs, and +// the receipt block over Bitswap. Bridge-off, those blocks live only on the +// peer set, and a just-produced block isn't reliably served by random +// gossip peers inside the client's poll window. The result was the residual +// "net/bitswap: context canceled" miss documented in #50. +// +// The warmer closes that gap proactively: the moment we publish a tx, we +// start polling StateSearchMsg for its message CID in the BACKGROUND, on a +// generous standalone context that isn't tied to any client RPC deadline. +// That background search drives Bitswap to pull the message + AMT + receipt +// blocks into the local blockstore as soon as the tx lands. By the time the +// client's own (short-deadline) receipt poll runs, the blocks are warm and +// the search resolves from cache instead of racing a cold cross-peer fetch. +// +// This is purely additive and best-effort: +// - It never affects the send result (fired non-blocking from the handler). +// - It only READS (StateSearchMsg); it never publishes or mutates state. +// - A nil warmer (hook unset) means the old behavior, unchanged. +// - It self-bounds: each warm goroutine stops on success, on the message +// becoming un-findable past its lookback, or after maxWarmDuration. + +import ( + "context" + "sync" + "time" + + "github.com/ipfs/go-cid" + + "github.com/Reiers/lantern/chain/types" + "github.com/Reiers/lantern/rpc/handlers" +) + +const ( + // warmPollInterval is how often the background warmer re-runs + // StateSearchMsg for an in-flight tx. Matches the chain's ~30s block + // cadence loosely; a tx typically lands within 1-3 polls. + warmPollInterval = 5 * time.Second + + // maxWarmDuration bounds a single tx's warming loop. A calibration/ + // mainnet tx that hasn't landed in this window is either dropped or + // stuck (an #47 concern), not a Bitswap-availability problem, so we + // stop warming and let the normal poll/bridge path handle it. + maxWarmDuration = 10 * time.Minute + + // maxConcurrentWarms caps in-flight warm goroutines so a burst of + // sends can't spawn unbounded work. The SP write->confirm loop only + // has a handful of txs in flight at once; well past that we simply + // skip warming (the receipt path still works, just without the warm + // assist). + maxConcurrentWarms = 64 +) + +// searchFn resolves a message CID, returning (found, err). It is the seam +// the warmer drives in a loop; production wires it to ChainAPI.StateSearchMsg +// (which pulls the needed blocks over Bitswap as a side effect). Tests inject +// a deterministic stand-in. +type searchFn func(ctx context.Context, msgCID cid.Cid) (found bool, err error) + +// sendWarmer runs background StateSearchMsg polls for recently-sent txs to +// pre-warm their message/receipt blocks into the Bitswap cache (#50). +type sendWarmer struct { + ctx context.Context + search searchFn + + // pollInterval / maxDuration / maxConcurrent are fields (not the bare + // consts) so tests can drive the loop fast. Production uses the const + // defaults via newSendWarmer. + pollInterval time.Duration + maxDuration time.Duration + maxConcurrent int + + mu sync.Mutex + inFlight map[cid.Cid]struct{} + active int +} + +// newSendWarmer builds a warmer bound to the daemon's root context. The +// chainAPI must be the same handler the RPC server uses (so StateSearchMsg +// reads through the embedded Bitswap-backed BlockGetter). +func newSendWarmer(ctx context.Context, chain *handlers.ChainAPI) *sendWarmer { + w := newSendWarmerWithSearch(ctx, func(sctx context.Context, msgCID cid.Cid) (bool, error) { + lookup, err := chain.StateSearchMsg(sctx, types.TipSetKey{}, msgCID, 0, false) + return lookup != nil, err + }) + // A nil chain means "no real handler"; mark the warmer inert so Warm + // is a no-op (mirrors the old `w.chain == nil` guard). + if chain == nil { + w.search = nil + } + return w +} + +// newSendWarmerWithSearch is the testable constructor: it takes the search +// seam directly. +func newSendWarmerWithSearch(ctx context.Context, search searchFn) *sendWarmer { + return &sendWarmer{ + ctx: ctx, + search: search, + pollInterval: warmPollInterval, + maxDuration: maxWarmDuration, + maxConcurrent: maxConcurrentWarms, + inFlight: make(map[cid.Cid]struct{}), + } +} + +// Warm starts a background warming loop for msgCID. Safe to call from the +// send hot path: it returns immediately and dedups concurrent calls for the +// same message. A no-op if the warmer or its chain handle is nil. +func (w *sendWarmer) Warm(msgCID cid.Cid) { + if w == nil || w.search == nil || !msgCID.Defined() { + return + } + + w.mu.Lock() + if _, dup := w.inFlight[msgCID]; dup { + w.mu.Unlock() + return + } + if w.active >= w.maxConcurrent { + // Over budget: skip warming. The receipt path still resolves the + // tx (just without the warm assist), so this only loses the + // optimization under heavy burst, never correctness. + w.mu.Unlock() + return + } + w.inFlight[msgCID] = struct{}{} + w.active++ + w.mu.Unlock() + + go w.run(msgCID) +} + +func (w *sendWarmer) run(msgCID cid.Cid) { + defer func() { + w.mu.Lock() + delete(w.inFlight, msgCID) + w.active-- + w.mu.Unlock() + }() + + deadline := time.Now().Add(w.maxDuration) + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + + for { + // Each search gets a bounded child context so a single hung + // Bitswap round can't pin the goroutine; the standalone budget + // (well above the StateSearchMsg internal 18s retry window) is + // what makes this a real warm rather than a client-deadline race. + sctx, cancel := context.WithTimeout(w.ctx, warmSearchBudget) + found, err := w.search(sctx, msgCID) + cancel() + + switch { + case err != nil: + // Transient (e.g. a block still uncached). Keep polling; the + // whole point is to drive Bitswap to fetch it. + case found: + // Landed and resolved locally: blocks are now warm, the + // client's receipt poll will hit cache. Done. + return + } + + if time.Now().After(deadline) { + return + } + select { + case <-w.ctx.Done(): + return + case <-ticker.C: + } + } +} + +// warmSearchBudget is the per-attempt ceiling for a background warm search. +// It must comfortably exceed StateSearchMsg's internal retry window (18s) +// so a warm attempt contains a full set of Bitswap rounds rather than being +// cut short the way a client's short receipt-poll deadline would be. +const warmSearchBudget = 25 * time.Second diff --git a/pkg/daemon/sendwarm_test.go b/pkg/daemon/sendwarm_test.go new file mode 100644 index 0000000..d29217e --- /dev/null +++ b/pkg/daemon/sendwarm_test.go @@ -0,0 +1,178 @@ +package daemon + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" +) + +// testCID returns a distinct, defined CID for test n. +func testCID(t *testing.T, n byte) cid.Cid { + t.Helper() + h, err := mh.Sum([]byte{n, n, n, n}, mh.SHA2_256, -1) + if err != nil { + t.Fatalf("multihash: %v", err) + } + return cid.NewCidV1(cid.DagCBOR, h) +} + +// newTestWarmer builds a warmer with fast timings around an injected search. +func newTestWarmer(ctx context.Context, search searchFn) *sendWarmer { + w := newSendWarmerWithSearch(ctx, search) + w.pollInterval = 1 * time.Millisecond + w.maxDuration = 2 * time.Second + return w +} + +// TestWarm_StopsOnFound: the loop ends as soon as the search reports found, +// and does not keep polling afterward. +func TestWarm_StopsOnFound(t *testing.T) { + var calls int32 + foundAfter := int32(3) + done := make(chan struct{}) + + w := newTestWarmer(context.Background(), func(_ context.Context, _ cid.Cid) (bool, error) { + n := atomic.AddInt32(&calls, 1) + if n >= foundAfter { + close(done) + return true, nil + } + return false, nil // not yet on-chain + }) + + w.Warm(testCID(t, 1)) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("warm loop never reported found") + } + + // Give the goroutine a beat to exit, then confirm it stopped polling. + time.Sleep(20 * time.Millisecond) + stable := atomic.LoadInt32(&calls) + time.Sleep(30 * time.Millisecond) + if got := atomic.LoadInt32(&calls); got != stable { + t.Fatalf("warmer kept polling after found: %d -> %d", stable, got) + } + if w.activeCount() != 0 { + t.Fatalf("active count not drained: %d", w.activeCount()) + } +} + +// TestWarm_Dedup: concurrent Warm calls for the SAME cid run a single loop. +func TestWarm_Dedup(t *testing.T) { + release := make(chan struct{}) + var distinct sync.Map + var running int32 + + w := newTestWarmer(context.Background(), func(_ context.Context, c cid.Cid) (bool, error) { + distinct.Store(c, true) + atomic.AddInt32(&running, 1) + <-release // hold the loop open so dedup is observable + return true, nil + }) + + c := testCID(t, 2) + for i := 0; i < 8; i++ { + w.Warm(c) + } + // Let the single goroutine enter search. + time.Sleep(30 * time.Millisecond) + + if w.activeCount() != 1 { + t.Fatalf("expected exactly 1 active warm for a deduped cid, got %d", w.activeCount()) + } + if got := atomic.LoadInt32(&running); got != 1 { + t.Fatalf("expected 1 concurrent search, got %d", got) + } + close(release) +} + +// TestWarm_ConcurrencyCap: at most maxConcurrent loops run at once; extra +// sends are dropped (no goroutine), preserving correctness without unbounded +// fan-out. +func TestWarm_ConcurrencyCap(t *testing.T) { + release := make(chan struct{}) + w := newTestWarmer(context.Background(), func(_ context.Context, _ cid.Cid) (bool, error) { + <-release + return true, nil + }) + w.maxConcurrent = 4 + + for i := 0; i < 10; i++ { + w.Warm(testCID(t, byte(100+i))) + } + time.Sleep(30 * time.Millisecond) + + if got := w.activeCount(); got != 4 { + t.Fatalf("expected active capped at 4, got %d", got) + } + close(release) +} + +// TestWarm_ContextCancelStops: cancelling the root context ends the loop even +// if the search never reports found. +func TestWarm_ContextCancelStops(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + exited := make(chan struct{}) + + w := newSendWarmerWithSearch(ctx, func(sctx context.Context, _ cid.Cid) (bool, error) { + return false, nil // never lands + }) + w.pollInterval = 1 * time.Millisecond + w.maxDuration = time.Hour // would otherwise run a long time + + // Wrap run completion detection via a tiny shim cid. + go func() { + w.Warm(testCID(t, 9)) + // Warm spawns its own goroutine; poll activeCount to detect exit. + for { + if w.activeCount() == 0 { + close(exited) + return + } + time.Sleep(2 * time.Millisecond) + } + }() + + time.Sleep(20 * time.Millisecond) + cancel() + + select { + case <-exited: + case <-time.After(2 * time.Second): + t.Fatal("warm loop did not stop on context cancel") + } +} + +// TestWarm_NilSafe: a nil warmer and a nil-search warmer are both no-ops. +func TestWarm_NilSafe(t *testing.T) { + var nilW *sendWarmer + nilW.Warm(testCID(t, 1)) // must not panic + + inert := newSendWarmer(context.Background(), nil) // nil chain -> inert + inert.Warm(testCID(t, 1)) + if inert.activeCount() != 0 { + t.Fatalf("inert warmer started a loop: active=%d", inert.activeCount()) + } + + // Undefined CID is ignored. + w := newTestWarmer(context.Background(), func(_ context.Context, _ cid.Cid) (bool, error) { + t.Fatal("search called for undefined cid") + return false, nil + }) + w.Warm(cid.Undef) +} + +// activeCount is a test helper exposing the live warm count. +func (w *sendWarmer) activeCount() int { + w.mu.Lock() + defer w.mu.Unlock() + return w.active +} diff --git a/pkg/daemon/start.go b/pkg/daemon/start.go index bc772e5..d2bc3c4 100644 --- a/pkg/daemon/start.go +++ b/pkg/daemon/start.go @@ -98,21 +98,40 @@ func (d *Daemon) startInternal(ctx context.Context) error { // cmd/lantern wiring (without bitswap, since libp2p isn't mounted // here yet — the gateway+glif pair covers cold state-tree reads). cache := hamt.NewMemBlockStore() - // glifURL is the RPC fallback for the polling Sync head source AND the - // gossipsub ingestor's head+N backfill source. Gossipsub is the PRIMARY - // (Glif-free) head source when libp2p is enabled; this URL is the + // fallbackRPC is the Lotus-compatible RPC used as the polling Sync head + // source AND the gossipsub ingestor's head+N backfill source AND the + // last-resort cold state-block fetcher. Gossipsub is the PRIMARY + // (RPC-free) head source when libp2p is enabled; this URL is the // fallback that keeps head advancing when the gossip mesh is cold or - // stalls. Previously empty on mainnet, which left the mainnet daemon - // with no working catch-up source: when gossip stalled, head froze. - // Callers can override via Config.Gateway / a future Config.FallbackRPC. - glifURL := "https://api.node.glif.io/rpc/v1" - if network == build.Calibration { - glifURL = "https://api.calibration.node.glif.io/rpc/v1" + // stalls. + // + // lantern#50 part 3: bridge-off trust is now EXPLICIT. + // - Config.FallbackRPC overrides the URL (e.g. operator's own Forest). + // - Config.NoFallbackRPC removes the upstream RPC entirely: head comes + // only from gossipsub, cold blocks only from gateway+Bitswap. A + // gossip stall then surfaces as a stalled head (observable) instead + // of a silent Glif fetch. Previously a bridge-off node fell back to + // Glif here with no way to opt out - a hidden third-party dependency. + fallbackRPC := d.cfg.FallbackRPC + if fallbackRPC == "" { + fallbackRPC = "https://api.node.glif.io/rpc/v1" + if network == build.Calibration { + fallbackRPC = "https://api.calibration.node.glif.io/rpc/v1" + } } - 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(glifURL, 20*time.Second), Timeout: 20 * time.Second}, - ) + noFallback := d.cfg.NoFallbackRPC + + fetcherSources := []combined.Source{ + {Name: "gateway", Getter: hsync.NewClient([]string{gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}, + } + if !noFallback { + fetcherSources = append(fetcherSources, + combined.Source{Name: "glif", Getter: glif.New(fallbackRPC, 20*time.Second), Timeout: 20 * time.Second}) + } else { + log.Infow("bridge-off: no upstream RPC fallback wired (lantern#50)", + "head_source", "gossipsub-only", "cold_blocks", "gateway+bitswap") + } + fetcher := combined.New(cache, fetcherSources...) chainAPI := handlers.New(tr, fetcher, d.cfg.Wallet, nil, network.String()) @@ -147,8 +166,6 @@ func (d *Daemon) startInternal(ctx context.Context) error { dist.Start() chainAPI.HeadNotify = dist - src := glif.New(glifURL, 8*time.Second) - // #40: when libp2p is enabled, gossipsub is the PRIMARY head // source (0-1 epoch latency, no upstream-RPC dependency) and the // polling Sync drops to a relaxed cadence as the catch-up fallback, @@ -156,6 +173,25 @@ func (d *Daemon) startInternal(ctx context.Context) error { // curio-core default today), Sync stays at the configured interval // as the sole head source. libp2pEnabled := !d.cfg.NoLibp2p && d.cfg.P2PListen != "" + + // #50 part 3: when NoFallbackRPC is set there is no RPC Sync source. + // Gossipsub must be the head driver; the polling Sync becomes a no-op. + // IMPORTANT: build syncSrc / backfillSrc as truly-nil INTERFACES (not + // a typed-nil *glif.Client), so the nil guards in hstore.Sync and the + // gossip ingestor fire correctly. A typed-nil wrapped in an interface + // is non-nil and would nil-panic on first method call. + var syncSrc hstore.RPCSource + var backfillSrc blockingest.BackfillSource + if !noFallback { + gc := glif.New(fallbackRPC, 8*time.Second) + syncSrc = gc + backfillSrc = gc + } else if !libp2pEnabled { + // No RPC fallback AND no gossip head source = no way to track + // head at all. Refuse rather than silently freeze. + _ = store.Close() + return fmt.Errorf("NoFallbackRPC requires libp2p/gossipsub enabled (it is the only head source); enable P2PListen or unset NoFallbackRPC") + } syncInterval := d.cfg.SyncInterval if libp2pEnabled { syncInterval = 30 * time.Second @@ -167,7 +203,7 @@ func (d *Daemon) startInternal(ctx context.Context) error { // the rest next poll rather than aborting). MaxBacktrack is raised // so reorg/backfill depth comfortably exceeds the deepest realistic // single-wait lag. - sync := hstore.NewSync(store, src, hstore.SyncOptions{ + sync := hstore.NewSync(store, syncSrc, hstore.SyncOptions{ Interval: syncInterval, MaxBacktrack: 900, // ~7.5h at 30s blocks; covers long proving waits BootstrapDepth: 3, @@ -200,7 +236,7 @@ func (d *Daemon) startInternal(ctx context.Context) error { // (the polling Sync still tracks head). src doubles as the // ingestor's bounded inline-backfill source for head+N>1 arrivals. if libp2pEnabled { - if err := d.startGossipHead(ctx, store, src, network, chainAPI, fetcher); err != nil { + if err := d.startGossipHead(ctx, store, backfillSrc, network, chainAPI, fetcher); err != nil { log.Warnw("gossipsub head-tracking unavailable; falling back to polling Sync", "err", err) } } @@ -260,6 +296,14 @@ func (d *Daemon) startInternal(ctx context.Context) error { chainAPI.OnLocalMiss = pf.AddAddr } + // lantern#50 prefetch-on-send: when eth_sendRawTransaction publishes a + // tx locally, warm its message/receipt blocks into the Bitswap cache in + // the background so the follow-up receipt poll resolves locally instead + // of racing a cold cross-peer fetch. Bound to the daemon root context + // (cancelled on Stop). Best-effort and read-only; nil hook = unchanged. + d.sendWarmer = newSendWarmer(ctx, chainAPI) + chainAPI.OnSentTx = d.sendWarmer.Warm + // Stash for accessor-style reads (LocalEthCallStats, etc.). d.mu.Lock() d.chainAPI = chainAPI @@ -428,6 +472,10 @@ func (d *Daemon) startGossipHead(ctx context.Context, store *hstore.Store, src b freshWindow = d.cfg.SyncInterval * 2 } hsync.SetGossipFresh(func() bool { return ing.Fresh(freshWindow) }) + // #83: make the gossip-fresh skip lag-aware so a fresh-but-lagging + // node (gossip skipping head+N>1 blocks it can't backfill) resumes + // catch-up instead of wedging ~10-20 epochs behind the tip. + hsync.SetGossipObservedHead(func() abi.ChainEpoch { return ing.ObservedHead() }, 0) } // lantern#45 Stage 4: wire the gossipsub mempool publisher on the same diff --git a/rpc/handlers/chain_api.go b/rpc/handlers/chain_api.go index 349c91e..0978330 100644 --- a/rpc/handlers/chain_api.go +++ b/rpc/handlers/chain_api.go @@ -102,6 +102,19 @@ type ChainAPI struct { // cheap + non-blocking; the handler calls it on the hot path. OnLocalMiss func(addr string) + // OnSentTx, when set, is invoked (non-blocking, on the send hot path) + // with the Filecoin message CID every time eth_sendRawTransaction + // publishes a tx locally (lantern#50 prefetch-on-send). curio-core / + // the daemon wires this to a background warmer that polls + // StateSearchMsg for the message until it lands, which drives the + // embedded Bitswap source to pull the freshly-produced message + AMT + + // receipt blocks into cache. By the time the client's own receipt poll + // runs, those blocks are warm, so the receipt resolves locally instead + // of racing (and losing to) a cold cross-peer Bitswap fetch inside the + // poll window — the residual that kept #50 open. Must be cheap + + // non-blocking; the handler fires it in a goroutine-friendly way. + OnSentTx func(msgCID cid.Cid) + // BeaconParams is the drand-round mapping for the active network. // Defaults to mainnet quicknet if zero-value. BeaconParams lbeacon.QuicknetParams @@ -757,16 +770,61 @@ func (c *ChainAPI) StateGetBeaconEntry(ctx context.Context, epoch abi.ChainEpoch // back through null rounds. Uses the header store when configured; // otherwise returns the synthesized current-head tipset if randEpoch is // the current head, else an error. -func (c *ChainAPI) tipsetForRandomness(_ context.Context, randEpoch abi.ChainEpoch) (*types.TipSet, error) { +func (c *ChainAPI) tipsetForRandomness(ctx context.Context, randEpoch abi.ChainEpoch) (*types.TipSet, error) { if c.Trusted == nil { return nil, errors.New("trusted root not initialised") } if randEpoch < 0 { return nil, fmt.Errorf("randomness epoch %d cannot be negative", randEpoch) } - if randEpoch > c.Trusted.Epoch { - return nil, fmt.Errorf("cannot draw randomness from future epoch %d (head %d)", randEpoch, c.Trusted.Epoch) + + // #82: the ceiling for "is this a future epoch?" must be the LIVE head + // (HeaderStore.Head()), not the frozen boot anchor (c.Trusted.Epoch). + // c.Trusted is set once at construction and never advances (see #48), + // so comparing randEpoch against it incorrectly reports epochs the node + // has actually reached as "future". A PDP prove task asks for randomness + // at its challenge epoch the instant the window opens; on a node whose + // header sync is a few epochs behind the chain tip, that epoch can be + // just above the current head. Bridge-off there is no upstream to fall + // to, so instead of hard-failing we wait briefly for the header sync to + // reach randEpoch, then draw locally. The wait is bounded so a genuinely + // future epoch still errors promptly. + liveHead := c.Trusted.Epoch + if c.HeaderStore != nil { + if ts := c.HeaderStore.Head(); ts != nil { + liveHead = ts.Height() + } } + + if randEpoch > liveHead { + // Bounded wait-for-head: only worth waiting when randEpoch is + // within a small window above the live head (the normal + // sync-catch-up case). Anything far ahead is a genuinely future + // epoch and errors immediately. + const ( + randWaitWindow abi.ChainEpoch = 10 + randWaitTotal = 20 * time.Second + randWaitPoll = 500 * time.Millisecond + ) + if c.HeaderStore == nil || randEpoch > liveHead+randWaitWindow { + return nil, fmt.Errorf("cannot draw randomness from future epoch %d (head %d)", randEpoch, liveHead) + } + deadline := time.Now().Add(randWaitTotal) + for randEpoch > liveHead { + if time.Now().After(deadline) { + return nil, fmt.Errorf("cannot draw randomness from future epoch %d (head %d after waiting %s for sync)", randEpoch, liveHead, randWaitTotal) + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(randWaitPoll): + } + if ts := c.HeaderStore.Head(); ts != nil { + liveHead = ts.Height() + } + } + } + if c.HeaderStore != nil { ts, err := c.HeaderStore.GetTipSetByHeight(randEpoch) if err == nil { diff --git a/rpc/handlers/extra_writepath_tx.go b/rpc/handlers/extra_writepath_tx.go index a6c20b4..759b0d7 100644 --- a/rpc/handlers/extra_writepath_tx.go +++ b/rpc/handlers/extra_writepath_tx.go @@ -153,6 +153,13 @@ func (c *ChainAPI) EthSendRawTransaction(ctx context.Context, signedTxHex string c.sentTx().put(strings.ToLower(hashHex), sentTxRecord{msgCID: msgCID, tx: tx, from: sender}) log.Infow("eth_sendRawTransaction: published locally", "ethHash", hashHex, "msgCID", msgCID, "from", smsg.Message.From) + // lantern#50: kick off background warming of this message's blocks so + // the receipt poll hits a warm Bitswap cache instead of racing a cold + // cross-peer fetch. Non-blocking and best-effort: a nil hook (or a + // slow warmer) never affects the send result. + if c.OnSentTx != nil { + c.OnSentTx(msgCID) + } return hashHex, nil } diff --git a/rpc/handlers/randomness_futureepoch_test.go b/rpc/handlers/randomness_futureepoch_test.go new file mode 100644 index 0000000..a9456a6 --- /dev/null +++ b/rpc/handlers/randomness_futureepoch_test.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "context" + "strings" + "testing" + "time" + + abi "github.com/filecoin-project/go-state-types/abi" +) + +// TestTipsetForRandomness_FutureEpochFailsFast covers #82: an epoch far +// above the head (no header store wired, so liveHead == frozen anchor) is +// genuinely in the future and must error promptly without entering the +// bounded wait-for-head loop (which only applies within a small window +// above the live head and only when a header store is present). +func TestTipsetForRandomness_FutureEpochFailsFast(t *testing.T) { + c := newCAPI() // Trusted.Epoch = 6_000_000, no HeaderStore + + start := time.Now() + _, err := c.tipsetForRandomness(context.Background(), abi.ChainEpoch(6_000_500)) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected future-epoch error, got nil") + } + if !strings.Contains(err.Error(), "future epoch") { + t.Fatalf("expected 'future epoch' error, got: %v", err) + } + // Must not have spun in the wait loop: no header store => immediate + // error, well under the 20s wait budget. + if elapsed > 2*time.Second { + t.Fatalf("future-epoch error took %s, expected near-immediate (no wait without header store)", elapsed) + } +} + +// TestTipsetForRandomness_NegativeEpoch guards the basic input validation. +func TestTipsetForRandomness_NegativeEpoch(t *testing.T) { + c := newCAPI() + if _, err := c.tipsetForRandomness(context.Background(), abi.ChainEpoch(-1)); err == nil { + t.Fatal("expected error for negative randomness epoch") + } +}