From 8001366ae23120beee0109c4755452b643de78cd Mon Sep 17 00:00:00 2001 From: Reiers Date: Thu, 2 Jul 2026 00:30:10 +0200 Subject: [PATCH] feat(#98): VM-bridge cross-check auditor - observe-only divergence alarm The VM bridge is normally a production dependency (state roots for block production). --vm-crosscheck reuses the same connection as an AUDITOR for the read path: every interval (default 60s) Lantern asks the bridge node for the canonical tipset at head-3 (default depth) and compares it against the local header store. - Agreement: counter. Divergence: loud DIVERGE error log + counter + dashboard card + OnDiverge hook. STRICTLY observe-only: reads stay 100% local, the head path is never blocked or rewritten. - No false alarms by construction: bridge lag, null rounds, shallow chains, unreachable bridge and unparseable responses are all skips, and the target depth (head-3) sits behind legitimate near-tip reorgs. - No new trust: the bridge is already trusted for block production (TRUST-MODEL 4.2 documents the auditor role). This catches the attack class full re-execution (#89 Stage C) would catch - headers that verify but state that was never honestly executed - at ~zero cost, and doubles as Stage C's future vector generator. Wiring: --vm-crosscheck + --vm-crosscheck-interval (requires --vm-bridge-rpc + header store), dashboard dev-page card, CHANGELOG. Tests: agree / diverge (+hook) / bridge-lag skip / bridge-down skip / shallow-chain skip (bridge not even called) / config validation. --- CHANGELOG.md | 12 ++ TRUST-MODEL.md | 15 +- chain/crosscheck/crosscheck.go | 254 ++++++++++++++++++++++++++++ chain/crosscheck/crosscheck_test.go | 175 +++++++++++++++++++ cmd/lantern/dashboard.go | 15 ++ cmd/lantern/dashboard/index.html | 23 +++ cmd/lantern/main.go | 37 +++- 7 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 chain/crosscheck/crosscheck.go create mode 100644 chain/crosscheck/crosscheck_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d9369e7..770a5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,18 @@ All notable changes to Lantern. lotus). TRUST-MODEL.md section 2.7 documents what it does and does NOT guarantee. +- **VM-bridge cross-check auditor** ([#98](https://github.com/Reiers/lantern/issues/98)). + New opt-in `--vm-crosscheck` (requires `--vm-bridge-rpc`): once a minute + Lantern asks the operator's own Forest/Lotus bridge node for the + canonical tipset at head-3 and compares it against the local header + store. A mismatch (state-root-level attack, eclipse, or bug) raises a + loud `DIVERGE` alarm (log + counters + dashboard card) but is strictly + **observe-only**: reads stay 100% local, the head path is never blocked + or rewritten, and bridge lag / null rounds are skipped, never + false-alarmed. No new trust: the bridge is already trusted for block + production; this reuses the connection as an auditor. Pragmatic + precursor to full pure-Go re-execution (#89). + - **Wallet import/export + import-from-Lotus** ([#93](https://github.com/Reiers/lantern/issues/93)). `lantern wallet export ` / `wallet import [hex|-]` speak Lotus's hex-KeyInfo wire format, so keys round-trip between Lotus and Lantern diff --git a/TRUST-MODEL.md b/TRUST-MODEL.md index b6c41e6..ddc3f93 100644 --- a/TRUST-MODEL.md +++ b/TRUST-MODEL.md @@ -231,7 +231,18 @@ A malicious or compromised bridge upstream **cannot**: In short: the bridge is bounded to message-execution semantics, on the operator's own active path. It is NOT a chain-level oracle. -### 4.2 Recommended operator configurations +### 4.2 The bridge as an auditor (`--vm-crosscheck`, #98) + +The same bridge connection can optionally AUDIT the read path: once a +minute Lantern compares its canonical tipset at head-3 against the +bridge node's and alarms loudly on divergence. This adds no new trust +(the bridge is already trusted for block production) and changes no +behavior (observe-only; reads are never answered by the bridge). It +catches the class of attack full re-execution would catch - a fabricated +chain whose headers verify but whose state was never honestly executed - +as long as the operator's bridge node is honest and unpartitioned. + +### 4.3 Recommended operator configurations | Profile | Bridge | Trust posture | |------------------------|--------|---------------| @@ -240,7 +251,7 @@ operator's own active path. It is NOT a chain-level oracle. | Light + deal flow | Operator's own Forest as a sidecar | Bridge used only for the few `StateCall` paths Curio needs (e.g. storage-market PSD verification). All chain reads still verified locally. | | **Don't do this** | A third-party public RPC | Equivalent to "trust your RPC provider." Defeats the point of running Lantern in the first place. We don't ship a default public bridge for this reason. | -### 4.3 Bridge provenance + auditability +### 4.4 Bridge provenance + auditability `vm/bridge.Bridge.Provenance()` returns an opaque tag (`forest@`, `lotus@`, etc.) that Lantern uses in trace logs. When StateCall diff --git a/chain/crosscheck/crosscheck.go b/chain/crosscheck/crosscheck.go new file mode 100644 index 0000000..1edfd0a --- /dev/null +++ b/chain/crosscheck/crosscheck.go @@ -0,0 +1,254 @@ +// Package crosscheck implements the VM-bridge auditor (#98). +// +// The VM bridge (vm/bridge) is normally a PRODUCTION dependency: it +// computes post-execution state roots for block production. This package +// turns the same connection into an AUDITOR for the read path: it +// periodically asks the operator's own Forest/Lotus node for the +// canonical tipset at a settled depth and compares it against Lantern's +// header-store view. A mismatch means Lantern's head path and the +// operator's full node disagree about the canonical chain - a state-root +// level attack, an eclipse, or a bug - and is worth a loud alarm. +// +// Why this is honest trust-wise (TRUST-MODEL 4.x): the bridge is the +// operator's own node, already trusted for block production. Using it to +// CROSS-CHECK adds no new trust; the read path stays 100% local and is +// never answered by the bridge. The check is observe-only: a divergence +// alarms (log + counter + dashboard) but never blocks or rewrites +// Lantern's head. This delivers most of the practical security value of +// full re-execution (#89 Stage C) at ~zero cost, and doubles as the +// vector generator Stage C will need. +// +// Sampling: checks run at a fixed interval (default 60s) against depth +// head-K (default 3 epochs) so legitimate near-tip reorgs don't produce +// false alarms. Null rounds are skipped (heights must match to compare). +package crosscheck + +import ( + "context" + "encoding/json" + "fmt" + "sync/atomic" + "time" + + "github.com/filecoin-project/go-state-types/abi" + + logging "github.com/ipfs/go-log/v2" + + ltypes "github.com/Reiers/lantern/chain/types" +) + +var log = logging.Logger("lantern/crosscheck") + +// DefaultInterval is the default wall-clock spacing between checks. +const DefaultInterval = 60 * time.Second + +// DefaultDepth is how many epochs behind head the check targets. 3 +// epochs (~90s) is deep enough that honest near-tip reorgs have settled, +// shallow enough that an attack alarms within ~2 minutes. +const DefaultDepth = 3 + +// RPC is the bridge surface the checker needs. vm/bridge.ForestBridge +// (and the Bridge interface) satisfy it. +type RPC interface { + RawJSONRPC(ctx context.Context, method string, params json.RawMessage) (json.RawMessage, error) + Provenance() string +} + +// Source is the local chain surface the checker audits. +// chain/header/store.Store satisfies it. +type Source interface { + Head() *ltypes.TipSet + GetTipSetByHeight(epoch abi.ChainEpoch) (*ltypes.TipSet, error) +} + +// Config wires a Checker. +type Config struct { + Bridge RPC + Source Source + Interval time.Duration // 0 => DefaultInterval + Depth int // 0 => DefaultDepth + // OnDiverge, when set, fires on every confirmed divergence (after + // the height-match guard). Wire alerts/notifications here. + OnDiverge func(epoch abi.ChainEpoch, ours, theirs string) +} + +// Stats is a snapshot of checker counters. +type Stats struct { + Checks uint64 + Agrees uint64 + Diverges uint64 + Skipped uint64 // bridge lag / null round / shallow chain / errors + LastCheckedEpoch abi.ChainEpoch + LastResult string // "agree" | "DIVERGE" | "skip" | "" + Provenance string +} + +// Checker periodically cross-checks the local canonical chain against +// the operator's bridge node. Observe-only: never mutates local state. +type Checker struct { + cfg Config + interval time.Duration + depth abi.ChainEpoch + + checks atomic.Uint64 + agrees atomic.Uint64 + diverges atomic.Uint64 + skipped atomic.Uint64 + lastEpoch atomic.Int64 + lastRes atomic.Value // string +} + +// New builds a Checker. Bridge and Source are required. +func New(cfg Config) (*Checker, error) { + if cfg.Bridge == nil || cfg.Source == nil { + return nil, fmt.Errorf("crosscheck: Bridge and Source are required") + } + iv := cfg.Interval + if iv <= 0 { + iv = DefaultInterval + } + d := cfg.Depth + if d <= 0 { + d = DefaultDepth + } + c := &Checker{cfg: cfg, interval: iv, depth: abi.ChainEpoch(d)} + c.lastRes.Store("") + return c, nil +} + +// Start launches the periodic check loop; returns immediately. The loop +// exits when ctx is cancelled. +func (c *Checker) Start(ctx context.Context) { + go func() { + t := time.NewTicker(c.interval) + defer t.Stop() + log.Infow("vm-bridge cross-check auditor started", + "provenance", c.cfg.Bridge.Provenance(), "interval", c.interval, "depth", int64(c.depth)) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.CheckOnce(ctx) + } + } + }() +} + +// bridgeTipSet is the JSON shape of Filecoin.ChainGetTipSetByHeight. +type bridgeTipSet struct { + Cids []cidJSON `json:"Cids"` + Height int64 `json:"Height"` + Blocks []struct { + ParentStateRoot cidJSON `json:"ParentStateRoot"` + } `json:"Blocks"` +} + +type cidJSON struct { + Slash string `json:"/"` +} + +// CheckOnce performs one cross-check. Exposed for tests and for a +// dashboard "check now" action. +func (c *Checker) CheckOnce(ctx context.Context) { + head := c.cfg.Source.Head() + if head == nil || head.Height() <= c.depth { + c.skip("no head / shallow chain") + return + } + target := head.Height() - c.depth + + ours, err := c.cfg.Source.GetTipSetByHeight(target) + if err != nil || ours == nil { + c.skip("local tipset unavailable") + return + } + if ours.Height() != target { + // Null round at target locally; comparing across a null round + // invites false positives. Skip this tick. + c.skip("null round") + return + } + + params, _ := json.Marshal([]interface{}{target, nil}) + cctx, cancel := context.WithTimeout(ctx, 20*time.Second) + raw, err := c.cfg.Bridge.RawJSONRPC(cctx, "Filecoin.ChainGetTipSetByHeight", params) + cancel() + if err != nil { + c.skip("bridge unreachable: " + err.Error()) + return + } + var theirs bridgeTipSet + if err := json.Unmarshal(raw, &theirs); err != nil { + c.skip("bridge response parse: " + err.Error()) + return + } + if theirs.Height != int64(target) { + // The bridge resolved a null round to an earlier tipset, or it + // lags behind target. Either way: not comparable this tick. + c.skip("bridge height mismatch (lag or null round)") + return + } + + c.checks.Add(1) + c.lastEpoch.Store(int64(target)) + + if tipsetKeysEqual(ours, &theirs) { + c.agrees.Add(1) + c.lastRes.Store("agree") + return + } + + // Confirmed divergence at settled depth: our canonical tipset and + // the operator's full node disagree. Loud, but observe-only. + c.diverges.Add(1) + c.lastRes.Store("DIVERGE") + oursStr := fmt.Sprintf("%v", ours.Cids()) + theirsStr := fmt.Sprintf("%v", theirs.Cids) + log.Errorw("VM-BRIDGE CROSS-CHECK DIVERGENCE: local canonical tipset disagrees with bridge node", + "epoch", int64(target), "local", oursStr, "bridge", theirsStr, + "provenance", c.cfg.Bridge.Provenance()) + if c.cfg.OnDiverge != nil { + c.cfg.OnDiverge(target, oursStr, theirsStr) + } +} + +func (c *Checker) skip(reason string) { + c.skipped.Add(1) + c.lastRes.Store("skip") + log.Debugw("cross-check skipped", "reason", reason) +} + +// tipsetKeysEqual compares our tipset's block CIDs with the bridge's, +// order-insensitively (both sides canonicalize by ticket, but a set +// compare is robust and cheap at <=15 blocks). +func tipsetKeysEqual(ours *ltypes.TipSet, theirs *bridgeTipSet) bool { + oc := ours.Cids() + if len(oc) != len(theirs.Cids) { + return false + } + set := make(map[string]struct{}, len(oc)) + for _, c := range oc { + set[c.String()] = struct{}{} + } + for _, tc := range theirs.Cids { + if _, ok := set[tc.Slash]; !ok { + return false + } + } + return true +} + +// Stats returns a snapshot of counters. +func (c *Checker) Stats() Stats { + res, _ := c.lastRes.Load().(string) + return Stats{ + Checks: c.checks.Load(), + Agrees: c.agrees.Load(), + Diverges: c.diverges.Load(), + Skipped: c.skipped.Load(), + LastCheckedEpoch: abi.ChainEpoch(c.lastEpoch.Load()), + LastResult: res, + Provenance: c.cfg.Bridge.Provenance(), + } +} diff --git a/chain/crosscheck/crosscheck_test.go b/chain/crosscheck/crosscheck_test.go new file mode 100644 index 0000000..2e11757 --- /dev/null +++ b/chain/crosscheck/crosscheck_test.go @@ -0,0 +1,175 @@ +package crosscheck + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/filecoin-project/go-address" + "github.com/filecoin-project/go-state-types/abi" + "github.com/ipfs/go-cid" + mh "github.com/multiformats/go-multihash" + "github.com/stretchr/testify/require" + + ltypes "github.com/Reiers/lantern/chain/types" +) + +func tCID(t *testing.T, s string) cid.Cid { + t.Helper() + hash, err := mh.Sum([]byte(s), mh.SHA2_256, -1) + require.NoError(t, err) + return cid.NewCidV1(cid.DagCBOR, hash) +} + +func tTipSet(t *testing.T, h abi.ChainEpoch, tag string) *ltypes.TipSet { + t.Helper() + miner, err := address.NewIDAddress(1000) + require.NoError(t, err) + bh := <ypes.BlockHeader{ + Miner: miner, + Ticket: <ypes.Ticket{VRFProof: []byte("t-" + tag)}, + ElectionProof: <ypes.ElectionProof{WinCount: 1, VRFProof: []byte("e-" + tag)}, + Parents: []cid.Cid{tCID(t, "parent-"+tag)}, + ParentWeight: ltypes.NewInt(uint64(h)), + Height: h, + ParentStateRoot: tCID(t, "state-"+tag), + ParentMessageReceipts: tCID(t, "rcpt-"+tag), + Messages: tCID(t, "msgs-"+tag), + Timestamp: 1_700_000_000 + uint64(h)*30, + ParentBaseFee: ltypes.NewInt(100), + } + ts, err := ltypes.NewTipSet([]*ltypes.BlockHeader{bh}) + require.NoError(t, err) + return ts +} + +type fakeSource struct { + head *ltypes.TipSet + at map[abi.ChainEpoch]*ltypes.TipSet +} + +func (f *fakeSource) Head() *ltypes.TipSet { return f.head } +func (f *fakeSource) GetTipSetByHeight(e abi.ChainEpoch) (*ltypes.TipSet, error) { + ts, ok := f.at[e] + if !ok { + return nil, fmt.Errorf("not found") + } + return ts, nil +} + +type fakeBridge struct { + respondHeight int64 + respondCids []cid.Cid + err error + calls int +} + +func (f *fakeBridge) Provenance() string { return "fake@test" } +func (f *fakeBridge) RawJSONRPC(_ context.Context, method string, _ json.RawMessage) (json.RawMessage, error) { + f.calls++ + if method != "Filecoin.ChainGetTipSetByHeight" { + return nil, fmt.Errorf("unexpected method %s", method) + } + if f.err != nil { + return nil, f.err + } + cids := make([]map[string]string, 0, len(f.respondCids)) + for _, c := range f.respondCids { + cids = append(cids, map[string]string{"/": c.String()}) + } + return json.Marshal(map[string]interface{}{ + "Cids": cids, + "Height": f.respondHeight, + "Blocks": []map[string]interface{}{}, + }) +} + +// TestCheckOnce_Agree: bridge returns the same tipset key -> agree. +func TestCheckOnce_Agree(t *testing.T) { + target := tTipSet(t, 97, "x") + src := &fakeSource{head: tTipSet(t, 100, "head"), at: map[abi.ChainEpoch]*ltypes.TipSet{97: target}} + br := &fakeBridge{respondHeight: 97, respondCids: target.Cids()} + c, err := New(Config{Bridge: br, Source: src}) + require.NoError(t, err) + + c.CheckOnce(context.Background()) + st := c.Stats() + require.Equal(t, uint64(1), st.Checks) + require.Equal(t, uint64(1), st.Agrees) + require.Equal(t, uint64(0), st.Diverges) + require.Equal(t, "agree", st.LastResult) + require.Equal(t, abi.ChainEpoch(97), st.LastCheckedEpoch) +} + +// TestCheckOnce_Diverge: bridge disagrees at settled depth -> DIVERGE + +// OnDiverge hook fires. Observe-only: nothing else happens. +func TestCheckOnce_Diverge(t *testing.T) { + ours := tTipSet(t, 97, "ours") + other := tTipSet(t, 97, "theirs") + src := &fakeSource{head: tTipSet(t, 100, "head"), at: map[abi.ChainEpoch]*ltypes.TipSet{97: ours}} + br := &fakeBridge{respondHeight: 97, respondCids: other.Cids()} + + fired := false + c, err := New(Config{Bridge: br, Source: src, OnDiverge: func(e abi.ChainEpoch, _, _ string) { + fired = true + require.Equal(t, abi.ChainEpoch(97), e) + }}) + require.NoError(t, err) + + c.CheckOnce(context.Background()) + st := c.Stats() + require.Equal(t, uint64(1), st.Diverges) + require.Equal(t, "DIVERGE", st.LastResult) + require.True(t, fired, "OnDiverge must fire") +} + +// TestCheckOnce_BridgeLagSkips: bridge returns an older height (lag or +// null-round resolution) -> skip, never a false DIVERGE. +func TestCheckOnce_BridgeLagSkips(t *testing.T) { + ours := tTipSet(t, 97, "ours") + src := &fakeSource{head: tTipSet(t, 100, "head"), at: map[abi.ChainEpoch]*ltypes.TipSet{97: ours}} + br := &fakeBridge{respondHeight: 95, respondCids: ours.Cids()} + c, err := New(Config{Bridge: br, Source: src}) + require.NoError(t, err) + + c.CheckOnce(context.Background()) + st := c.Stats() + require.Equal(t, uint64(0), st.Checks) + require.Equal(t, uint64(0), st.Diverges) + require.Equal(t, uint64(1), st.Skipped) + require.Equal(t, "skip", st.LastResult) +} + +// TestCheckOnce_BridgeDownSkips: unreachable bridge is a skip, not an +// error state; reads are unaffected by design. +func TestCheckOnce_BridgeDownSkips(t *testing.T) { + ours := tTipSet(t, 97, "ours") + src := &fakeSource{head: tTipSet(t, 100, "head"), at: map[abi.ChainEpoch]*ltypes.TipSet{97: ours}} + br := &fakeBridge{err: fmt.Errorf("connection refused")} + c, err := New(Config{Bridge: br, Source: src}) + require.NoError(t, err) + + c.CheckOnce(context.Background()) + st := c.Stats() + require.Equal(t, uint64(1), st.Skipped) + require.Equal(t, uint64(0), st.Diverges) +} + +// TestCheckOnce_ShallowChainSkips: head shallower than depth -> skip. +func TestCheckOnce_ShallowChainSkips(t *testing.T) { + src := &fakeSource{head: tTipSet(t, 2, "head"), at: map[abi.ChainEpoch]*ltypes.TipSet{}} + br := &fakeBridge{} + c, err := New(Config{Bridge: br, Source: src}) + require.NoError(t, err) + + c.CheckOnce(context.Background()) + require.Equal(t, uint64(1), c.Stats().Skipped) + require.Equal(t, 0, br.calls, "bridge must not be called for a shallow chain") +} + +// TestNew_RequiresDeps: nil bridge or source is a config error. +func TestNew_RequiresDeps(t *testing.T) { + _, err := New(Config{}) + require.Error(t, err) +} diff --git a/cmd/lantern/dashboard.go b/cmd/lantern/dashboard.go index 83c740b..10f4e4a 100644 --- a/cmd/lantern/dashboard.go +++ b/cmd/lantern/dashboard.go @@ -31,6 +31,7 @@ import ( "github.com/Reiers/lantern/build" "github.com/Reiers/lantern/chain/bootstrap" + "github.com/Reiers/lantern/chain/crosscheck" "github.com/Reiers/lantern/chain/ecfinality" "github.com/Reiers/lantern/chain/header/store" "github.com/Reiers/lantern/chain/trustedroot" @@ -78,6 +79,8 @@ type dashboardDeps struct { xchg *chainxchg.Service // #96: FRC-0089 EC finality calculator (observed-data finality depth). ecfin *ecfinality.Cache + // #98: VM-bridge cross-check auditor (nil when --vm-crosscheck off). + xcheck *crosscheck.Checker } // registerDashboard attaches /dashboard and /api/dashboard/* to the mux. @@ -385,6 +388,18 @@ func (d *dashboardDeps) syncSnapshot() map[string]any { } } } + if d.xcheck != nil { + xs := d.xcheck.Stats() + out["crosscheck"] = map[string]any{ + "checks": xs.Checks, + "agrees": xs.Agrees, + "diverges": xs.Diverges, + "skipped": xs.Skipped, + "last_epoch": int64(xs.LastCheckedEpoch), + "last": xs.LastResult, + "provenance": xs.Provenance, + } + } if d.host != nil { ks := d.host.KeepaliveStats() out["keepalive"] = map[string]any{ diff --git a/cmd/lantern/dashboard/index.html b/cmd/lantern/dashboard/index.html index 67822c5..d191e57 100644 --- a/cmd/lantern/dashboard/index.html +++ b/cmd/lantern/dashboard/index.html @@ -452,6 +452,18 @@

Chain

+
+

VM-bridge cross-check (auditor)

+
+
Checks
+
Agrees
+
Diverges
+
Skipped
+
Last epoch
+
Last result
+
+
+

EC finality (FRC-0089, observed)

@@ -914,6 +926,17 @@

Connected peers

$("d-g-helddiv").textContent = fmt.int(g.held_diverged); $("d-g-rejlight").textContent = fmt.int(g.rejected_lighter); + // #98: VM-bridge cross-check auditor (card only populated when on). + const xc = s.crosscheck || {}; + if (xc.provenance) { + $("d-xc-checks").textContent = fmt.int(xc.checks); + $("d-xc-agrees").textContent = fmt.int(xc.agrees); + $("d-xc-div").textContent = fmt.int(xc.diverges); + $("d-xc-skip").textContent = fmt.int(xc.skipped); + $("d-xc-epoch").textContent = fmt.int(xc.last_epoch); + $("d-xc-last").textContent = xc.last || "—"; + } + // #96: FRC-0089 observed-data EC finality. const ef = s.ec_finality || {}; if (ef.head_epoch != null) { diff --git a/cmd/lantern/main.go b/cmd/lantern/main.go index 6831dc4..bc95e9f 100644 --- a/cmd/lantern/main.go +++ b/cmd/lantern/main.go @@ -47,6 +47,7 @@ import ( "github.com/Reiers/lantern/build" "github.com/Reiers/lantern/chain/anchorverify" + "github.com/Reiers/lantern/chain/crosscheck" "github.com/Reiers/lantern/chain/f3/subscriber" "github.com/Reiers/lantern/chain/fullvalidate" hstore "github.com/Reiers/lantern/chain/header/store" @@ -721,6 +722,11 @@ func cmdDaemon(args []string) error { // Light/PDP: off). 0 = off. N>0 = require N distinct forwarding peers // (or one trusted floor peer) before adopting a gossip head. headCorroPeers := fs.Int("head-corroboration-peers", -1, "Distinct gossip peers required to corroborate a head advance before adoption. -1=auto (Full tier 2, others off), 0=off.") + // #98: VM-bridge cross-check auditor. Observe-only: compares the local + // canonical tipset at head-3 against the bridge node once a minute and + // alarms on divergence. Requires --vm-bridge-rpc. + vmCrossCheck := fs.Bool("vm-crosscheck", false, "Audit the local chain against the VM bridge node (observe-only divergence alarm). Requires --vm-bridge-rpc.") + vmCrossCheckInterval := fs.Duration("vm-crosscheck-interval", crosscheck.DefaultInterval, "Interval between VM-bridge cross-checks.") fs.Parse(args) // #58: --allow-empty-passphrase is sugar for the env the keystore guard @@ -862,12 +868,13 @@ func cmdDaemon(args []string) error { if effAllowBlockSubmit && *vmBridgeRPC == "" { return fmt.Errorf("block submission (tier=%s or --allow-block-submit) requires --vm-bridge-rpc to be set (see issue #4 in repo)", profile.Tier) } + var vmBr *bridge.ForestBridge if *vmBridgeRPC != "" { token := *vmBridgeToken if token == "" { token = os.Getenv("LANTERN_VM_BRIDGE_TOKEN") } - vmBr := bridge.NewForestBridge(*vmBridgeRPC, token, *vmBridgeTimeout) + vmBr = bridge.NewForestBridge(*vmBridgeRPC, token, *vmBridgeTimeout) chainAPI.WithBridge(vmBr) chainAPI.AllowBlockSubmit = effAllowBlockSubmit fmt.Printf(" vm-bridge: %s", vmBr.Provenance()) @@ -1164,6 +1171,32 @@ func cmdDaemon(args []string) error { // operators can see Bitswap carrying load. Issue #5 added the dashboard // on the same listener. v1.5.5 enables both by default on 127.0.0.1:9092 // so a fresh `lantern daemon` always has a webui without extra flags. + // #98: VM-bridge cross-check auditor. Observe-only; needs both the + // bridge and the header store. Divergences alarm via log + counters + // (dashboard card below); reads are never answered by the bridge. + var xchecker *crosscheck.Checker + if *vmCrossCheck { + switch { + case vmBr == nil: + return fmt.Errorf("--vm-crosscheck requires --vm-bridge-rpc") + case store == nil: + return fmt.Errorf("--vm-crosscheck requires the header store (remove --header-store \"\" overrides)") + default: + xc, xerr := crosscheck.New(crosscheck.Config{ + Bridge: vmBr, + Source: store, + Interval: *vmCrossCheckInterval, + }) + if xerr != nil { + return xerr + } + xc.Start(ctx) + xchecker = xc + fmt.Printf(" vm-crosscheck: on (auditing local chain vs %s every %s, observe-only)\n", + vmBr.Provenance(), vmCrossCheckInterval.String()) + } + } + var dashboardURL string if *metricsListen != "" { // SECURITY #57: the dashboard/metrics listener exposes node internals @@ -1209,6 +1242,8 @@ func cmdDaemon(args []string) error { // #96: observed-data EC finality (FRC-0089). Nil-safe in // the handler; store==nil (no header store) leaves it off. ecfin: newECFinality(store), + // #98: VM-bridge cross-check auditor stats (nil when off). + xcheck: xchecker, } dashboardURL = fmt.Sprintf("http://%s/dashboard/", *metricsListen) }