From 25e3212d4c8b1ba0aef8e83fd36501ed6f12ebad Mon Sep 17 00:00:00 2001 From: Nicklas Reiersen Date: Thu, 23 Jul 2026 16:52:21 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20network-selection=20safety=20=E2=80=94?= =?UTF-8?q?=20flag=20consistency,=20anchor=20sanity=20gate,=20per-network?= =?UTF-8?q?=20gateway=20+=20address=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four related footguns found while standing up a calibration node on the cluster (2026-07-23): 1. CLI flag chimera: daemon/beacon/auth use --network for the FILECOIN network, but init/doctor used --network for the F3 wire name. 'lantern init --network calibration' produced a mainnet data dir + mainnet bootstrap sources + a 'calibration' F3 protocol name, and wrote a WRONG-NETWORK trust anchor claiming success. init/doctor now interpret filecoin-network values passed via --network as --filecoin-network (resolveNetworkFlags, shared). 2. No anchor sanity gate: the chimera above anchored to an F3 cert ~392k epochs (~136 days) behind the wall-clock expected head and still declared quorum success. Filecoin epochs are wall-clock scheduled, so the expected epoch is computable from genesis time alone: init now refuses to write an anchor >2880 epochs (24h) from expected (--allow-stale-anchor overrides). build.Network gains GenesisUnix() + ExpectedHeadEpoch() with a unit test pinned to live observations from both networks. 3. Wrong-network gateway poisoning: the daemon raced the MAINNET gateway into the fetch chain on every network. On calibration each cold-block fetch burned the gateway's full race timeout before a correct source was tried, starving state reads (WalletBalance timed out >60s live). Calibration now disables the default gateway (explicit --gateway still honored) and the fetch chain skips the gateway source when unset. 4. Address prefix: address.CurrentNetwork was never set, so calibration nodes printed f-prefixed addresses. Faucets/explorers on calibration expect t-prefix. daemon + init now set the prefix from the network (matching lotus). --- .gitignore | 4 ++ build/network.go | 46 ++++++++++++++++++++++ build/network_genesis_test.go | 25 ++++++++++++ cmd/lantern/doctor.go | 18 +++------ cmd/lantern/init.go | 72 +++++++++++++++++++++++++++++------ cmd/lantern/main.go | 54 ++++++++++++++++++++++---- 6 files changed, 188 insertions(+), 31 deletions(-) create mode 100644 build/network_genesis_test.go diff --git a/.gitignore b/.gitignore index 69d832c..1c233eb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ internal-notes/ # apps/. curio-milestone-*.md scripts/devnet/parity-check.py + +# local ops artifacts (never commit: infra hostnames) +/soak-logs/ +/scripts/soak-sample.sh diff --git a/build/network.go b/build/network.go index f5f5499..e8e7c40 100644 --- a/build/network.go +++ b/build/network.go @@ -148,6 +148,52 @@ func (n Network) F3Manifest() []byte { } } +// GenesisUnix returns the wall-clock unix timestamp of epoch 0 for the +// selected network, or 0 when unknown (unconfigured devnet). Used for +// wall-clock sanity checks on bootstrap anchors: expected head epoch ≈ +// (now - genesis) / BlockDelaySecs. +func (n Network) GenesisUnix() int64 { + switch n { + case Calibration: + return CalibnetGenesisUnix + case Devnet: + if IsDevnetConfigured() { + if cfg := GetDevnetConfig(); cfg != nil && cfg.GenesisTime > 0 { + return int64(cfg.GenesisTime) + } + } + return 0 + default: + return MainnetGenesisUnix + } +} + +// ExpectedHeadEpoch returns the epoch the network head should be at for +// the given unix time, or -1 when the genesis time is unknown. The +// answer is exact for a healthy chain (Filecoin epochs are wall-clock +// scheduled); real heads trail it by at most a few epochs. +func (n Network) ExpectedHeadEpoch(nowUnix int64) int64 { + genesis := n.GenesisUnix() + if genesis <= 0 || nowUnix < genesis { + return -1 + } + delay := int64(BlockDelaySecs) + if n == Devnet && IsDevnetConfigured() { + if cfg := GetDevnetConfig(); cfg != nil && cfg.BlockDelaySecs > 0 { + delay = int64(cfg.BlockDelaySecs) + } + } + return (nowUnix - genesis) / delay +} + +// MainnetGenesisUnix is the unix timestamp of mainnet epoch 0 +// (2020-08-24 22:00:00 UTC). +const MainnetGenesisUnix = 1598306400 + +// CalibnetGenesisUnix is the unix timestamp of the current calibration +// network's epoch 0 (2022-11-01 18:13:00 UTC, the post-reset genesis). +const CalibnetGenesisUnix = 1667326380 + // DefaultNetwork is what Lantern targets when no --network flag is // passed. Mainnet, preserving V1.2.1 behavior. const DefaultNetwork = Mainnet diff --git a/build/network_genesis_test.go b/build/network_genesis_test.go new file mode 100644 index 0000000..f95e983 --- /dev/null +++ b/build/network_genesis_test.go @@ -0,0 +1,25 @@ +package build + +import "testing" + +// Wall-clock epoch math: Filecoin epochs are exactly scheduled, so the +// expected head epoch is (now - genesis) / 30s. Verified against live +// observations on 2026-07-23: mainnet head 6216984 and calibration head +// 3916343 at ~14:20 UTC (unix 1784816400). +func TestExpectedHeadEpoch(t *testing.T) { + const obsUnix = 1784816400 + if got := Mainnet.ExpectedHeadEpoch(obsUnix); got < 6216950 || got > 6217050 { + t.Fatalf("mainnet expected-epoch at %d: got %d, want ~6217000", obsUnix, got) + } + if got := Calibration.ExpectedHeadEpoch(obsUnix); got < 3916300 || got > 3916400 { + t.Fatalf("calibration expected-epoch at %d: got %d, want ~3916334", obsUnix, got) + } + // Genesis instant = epoch 0. + if got := Mainnet.ExpectedHeadEpoch(MainnetGenesisUnix); got != 0 { + t.Fatalf("mainnet at genesis: got %d, want 0", got) + } + // Before genesis = unknown. + if got := Mainnet.ExpectedHeadEpoch(MainnetGenesisUnix - 1); got != -1 { + t.Fatalf("mainnet before genesis: got %d, want -1", got) + } +} diff --git a/cmd/lantern/doctor.go b/cmd/lantern/doctor.go index d950446..5dd589c 100644 --- a/cmd/lantern/doctor.go +++ b/cmd/lantern/doctor.go @@ -37,12 +37,9 @@ func cmdDoctor(args []string) error { fs.Var(&peers, "peer", "Additional source URL (repeatable)") fs.Parse(args) - filNet := build.Network(*filNetwork) - if !filNet.Valid() { - return fmt.Errorf("invalid --filecoin-network %q: want one of mainnet, calibration", *filNetwork) - } - if *network == "filecoin" && filNet == build.Calibration { - *network = "calibrationnet2" + filNet, err := resolveNetworkFlags(network, filNetwork) + if err != nil { + return err } if *quorum == 0 { if filNet == build.Calibration { @@ -114,12 +111,9 @@ func cmdRepair(args []string) error { fs.Var(&peers, "peer", "Additional source URL (repeatable)") fs.Parse(args) - filNet := build.Network(*filNetwork) - if !filNet.Valid() { - return fmt.Errorf("invalid --filecoin-network %q: want one of mainnet, calibration", *filNetwork) - } - if *network == "filecoin" && filNet == build.Calibration { - *network = "calibrationnet2" + filNet, err := resolveNetworkFlags(network, filNetwork) + if err != nil { + return err } if *quorum == 0 { if filNet == build.Calibration { diff --git a/cmd/lantern/init.go b/cmd/lantern/init.go index e4dac1c..542a729 100644 --- a/cmd/lantern/init.go +++ b/cmd/lantern/init.go @@ -42,6 +42,40 @@ type peerList []string func (p *peerList) String() string { return strings.Join(*p, ",") } func (p *peerList) Set(v string) error { *p = append(*p, v); return nil } +// resolveNetworkFlags reconciles the deprecated --network flag (an F3 +// wire name like "filecoin" / "calibrationnet2") with --filecoin-network +// (mainnet | calibration). +// +// Footgun guard: `daemon`/`beacon`/`auth` use --network for the FILECOIN +// network, but init/doctor historically used --network for the F3 wire +// name. An operator typing the obvious `lantern init --network +// calibration` used to get a chimera: mainnet data dir + mainnet +// bootstrap sources + a "calibration" F3 protocol name — and a +// wrong-network trust anchor written on "success". Filecoin-network +// values passed via --network are now interpreted as --filecoin-network. +func resolveNetworkFlags(network, filNetwork *string) (build.Network, error) { + if fn := build.Network(*network); fn.Valid() { + if *filNetwork != string(build.DefaultNetwork) && *filNetwork != string(fn) { + return "", fmt.Errorf("conflicting flags: --network %s vs --filecoin-network %s", *network, *filNetwork) + } + fmt.Printf(" note: interpreting --network %s as --filecoin-network %s (F3 name auto-derived)\n", *network, fn) + *filNetwork = string(fn) + *network = "filecoin" + } + filNet := build.Network(*filNetwork) + if !filNet.Valid() { + return "", fmt.Errorf("invalid --filecoin-network %q: want one of mainnet, calibration", *filNetwork) + } + // Auto-resolve the F3 NetworkName from the selected filecoin-network + // if the caller didn't override --network. Mainnet's F3 NetworkName is + // 'filecoin'; calibration's is 'calibrationnet2' (per the embedded + // f3manifest_*.json files). + if *network == "filecoin" && filNet == build.Calibration { + *network = "calibrationnet2" + } + return filNet, nil +} + func cmdInit(args []string) error { fs := flag.NewFlagSet("init", flag.ExitOnError) noWallet := fs.Bool("no-wallet", false, "Skip creating a wallet") @@ -51,23 +85,18 @@ func cmdInit(args []string) error { countGateway := fs.Bool("count-gateway", false, "Count the Lantern gateway in the quorum tally (default false; not recommended)") noLibp2p := fs.Bool("no-libp2p", false, "Skip libp2p sources (use only HTTP RPC sources). Useful for environments without inbound networking.") libp2pSettle := fs.Duration("libp2p-settle", 15*time.Second, "Wait this long for libp2p bootstrap connections to settle before running the quorum probe (higher = more reliable first-try quorum on cold boot)") - network := fs.String("network", "filecoin", "F3 network name. DEPRECATED: prefer --filecoin-network which selects the F3 manifest automatically.") + network := fs.String("network", "filecoin", "F3 network name. DEPRECATED: prefer --filecoin-network which selects the F3 manifest automatically. Values 'mainnet' and 'calibration' are interpreted as --filecoin-network for consistency with `lantern daemon --network`.") filNetwork := fs.String("filecoin-network", string(build.DefaultNetwork), "Filecoin network: mainnet | calibration. Drives bootstrap peers, public RPC sources, and F3 manifest selection.") + allowStaleAnchor := fs.Bool("allow-stale-anchor", false, "Accept a quorum anchor whose epoch is far from the wall-clock expected head (NOT recommended; a stale anchor can pin the node to an old chain view)") var peers peerList fs.Var(&peers, "peer", "Additional finality source URL (repeatable). Format: URL or URL|TOKEN") fs.Parse(args) - filNet := build.Network(*filNetwork) - if !filNet.Valid() { - return fmt.Errorf("invalid --filecoin-network %q: want one of mainnet, calibration", *filNetwork) - } - // Auto-resolve F3 NetworkName from the selected filecoin-network if - // the caller didn't override --network. Mainnet F3 NetworkName is - // 'filecoin'; calibration's is 'calibrationnet2' (per the embedded - // f3manifest_*.json files). - if *network == "filecoin" && filNet == build.Calibration { - *network = "calibrationnet2" + filNet, err := resolveNetworkFlags(network, filNetwork) + if err != nil { + return err } + applyAddressNetwork(filNet) // Resolve the quorum default based on the selected network. Mainnet // has 5+ independent public sources; calibration today has 1 // (Glif calibration). We drop to 3-of-N for calibration to allow @@ -130,6 +159,27 @@ func cmdInit(args []string) error { fmt.Println(" Run `lantern doctor` for a detailed per-source report.") return err } + // Wall-clock sanity gate: Filecoin epochs are wall-clock scheduled, + // so the expected head epoch is computable from genesis time alone. + // A quorum anchor far from it means the sources answered for the + // wrong network or served stale finality — either way, writing it + // would pin the node to a wrong chain view while claiming success. + if exp := filNet.ExpectedHeadEpoch(time.Now().Unix()); exp > 0 { + lag := exp - int64(fin.Epoch) + if lag < 0 { + lag = -lag + } + const maxAnchorLagEpochs = 2880 // 24h at 30s epochs + if lag > maxAnchorLagEpochs && !*allowStaleAnchor { + fmt.Println() + fmt.Println("✗ Anchor sanity check FAILED — refusing to write trust anchor.") + return fmt.Errorf("quorum anchor epoch %d is %d epochs (~%.1f days) away from the wall-clock expected head %d for %s; the sources likely answered for the wrong network or served stale finality (override with --allow-stale-anchor)", + fin.Epoch, lag, float64(lag)/2880.0, exp, filNet) + } + if lag > maxAnchorLagEpochs { + fmt.Printf(" ⚠ anchor is %d epochs from wall-clock expected head %d — accepted due to --allow-stale-anchor\n", lag, exp) + } + } if err := writeBootstrapAnchor(dir, fin, filNet); err != nil { return fmt.Errorf("persist bootstrap anchor: %w", err) } diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index 46aaa3b..f14393d 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -560,6 +560,20 @@ func glifURLForNetwork(n build.Network) string { // We also attempt a best-effort F3 latest-cert probe so F3Instance is // populated when the dashboard renders. Failure is non-fatal: F3 is // observability, not consensus, at this layer. +// applyAddressNetwork sets the go-address display prefix for the +// selected network: mainnet addresses render as f1/f3/..., every test +// network (calibration, devnet) renders t1/t3/... — matching lotus. +// Parsing accepts both prefixes regardless; this only affects encoding. +// Without it a calibration node printed f-addresses, which faucets and +// explorers on calibration reject or mis-render. +func applyAddressNetwork(n build.Network) { + if n == build.Mainnet { + addr.CurrentNetwork = addr.Mainnet + } else { + addr.CurrentNetwork = addr.Testnet + } +} + func fetchTrustedHead(ctx context.Context, gw string, network build.Network) (*trustedroot.TrustedRoot, error) { now := time.Now().UTC() hc := hsync.NewClient([]string{gw}, 5*time.Second) @@ -888,14 +902,28 @@ func cmdDaemon(args []string) error { // anchor + cold-block fetches traverse this URL; HTTP has no transport // integrity, so a MITM could seed a bad anchor (the CID-verify backstop // protects state under a root, not the choice of root — see #54). - if err := validateGatewayScheme(*gw, *insecureGateway); err != nil { - return err - } - network := build.Network(*networkFlag) if !network.Valid() { return fmt.Errorf("invalid --network %q: want one of mainnet, calibration", *networkFlag) } + applyAddressNetwork(network) + + // The default Lantern gateway serves MAINNET blocks. Racing it into + // the calibration fetch chain poisons every cold-block fetch: the + // wrong-network gateway can never serve the requested CIDs, so each + // fetch burns the gateway's full race timeout before the correct + // source is tried. No Lantern gateway exists for calibration today; + // disable the source unless the operator explicitly points one. + if *gw == defaultGateway && network == build.Calibration { + *gw = "" + fmt.Println(" calibration: default gateway disabled (mainnet-only service); cold blocks use bitswap + chainxchg + Glif calibration. Pass --gateway to re-enable.") + } + + if *gw != "" { + if err := validateGatewayScheme(*gw, *insecureGateway); err != nil { + return err + } + } // Propagate the active network into buildinfo so Filecoin.Version, // libp2p UserAgent, and other identity surfaces reflect the actual @@ -1003,7 +1031,9 @@ 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) + if *gw != "" { + fmt.Println("Fetching trusted head from", *gw) + } // #118: bridge-off auto-stale-reset. Runs BEFORE the anchor is loaded // so a stale anchor is refreshed on disk first and the load below picks @@ -1073,6 +1103,9 @@ func cmdDaemon(args []string) error { 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")) } + if *gw == "" { + return fmt.Errorf("no persisted quorum anchor at %s and no gateway configured; run `lantern init --filecoin-network %s` first", filepath.Join(netDir, "bootstrap-anchor.json"), network) + } tr, err = fetchVerifiedTrustedHead(ctx, *gw, network, *insecureAnchor) if err != nil { return err @@ -1116,8 +1149,10 @@ func cmdDaemon(args []string) error { cache = hamt.NewMemBlockStore() fmt.Printf(" node tier: %s (in-memory cache)\n", profile.Tier) } - fetcherSources := []combined.Source{ - {Name: "gateway", Getter: hsync.NewClient([]string{*gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}, + var fetcherSources []combined.Source + if *gw != "" { + fetcherSources = append(fetcherSources, + combined.Source{Name: "gateway", Getter: hsync.NewClient([]string{*gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}) } if !*noFallbackRPC { // When --race-fallback-rpc is set, Glif joins the race tier so its @@ -1606,7 +1641,10 @@ func cmdDaemon(args []string) error { // shape, slower, public-service rate-limited). 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 *gw != "" { + rebuiltSources = append(rebuiltSources, + combined.Source{Name: "gateway", Getter: hsync.NewClient([]string{*gw}, 20*time.Second), Timeout: 5 * time.Second, Race: true}) } if !*noFallbackRPC { rebuiltSources = append(rebuiltSources,