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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
46 changes: 46 additions & 0 deletions build/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions build/network_genesis_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
18 changes: 6 additions & 12 deletions cmd/lantern/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
72 changes: 61 additions & 11 deletions cmd/lantern/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
54 changes: 46 additions & 8 deletions cmd/lantern/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <url> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading