Skip to content

feat(devnet): first-class support for local Curio devnet#122

Merged
Reiers merged 1 commit into
mainfrom
feat/devnet-support
Jul 11, 2026
Merged

feat(devnet): first-class support for local Curio devnet#122
Reiers merged 1 commit into
mainfrom
feat/devnet-support

Conversation

@Reiers

@Reiers Reiers commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Adds a build.Devnet Network variant and a lantern devnet-init command so Lantern can point at a locally-hosted Curio docker devnet (curio-fork/docker/, make devnet/up) and run against it as a first-class network.

Why

The docker devnet runs at 4s block time (build/params_2k.go in the curio fork) with FWSS + PDP + Multicall3 + registry contracts pre-deployed. That's a much faster loop for exercising the full Lantern + Curio Core stack than calibration (30s epochs, external dependency) or mainnet (real FIL).

The blocker was that Lantern hardcoded Mainnet + Calibration as the only build.Network variants: no bootstrap peers, no gossip topics, no genesis CID, no wire-name mapping for a devnet.

What's in

build/devnet.go (new)

  • Devnet Network = \"devnet\" constant.
  • DevnetConfig struct capturing the per-boot devnet identity: NetworkName, GenesisCID, LotusRPC, BootstrapPeers.
  • ConfigureDevnet(cfg) runtime setter + GetDevnetConfig() accessor + IsDevnetConfigured() predicate.
  • LoadDevnetConfig(path) / SaveDevnetConfig(path, cfg) for the on-disk JSON at <data-dir>/devnet/devnet-config.json, atomic tmp+rename write.
  • Every Network method dispatch panics with help when Devnet is used before ConfigureDevnet — pointing operators at lantern devnet-init in the error message.

build/network.go

Every getter switch (NetworkName, GenesisCID, GossipTopicBlocks/Messages, BootstrapPeers, F3Manifest, Valid) now handles Devnet. F3Manifest returns nil for devnet (F3 does not run there); every other getter reads from the configured DevnetConfig.

net/glif/client.go

Two new lightweight JSON-RPC methods (StateNetworkName, FetchGenesis) so the devnet-init CLI can introspect a running lotus without pulling in Lotus API types.

cmd/lantern/devnet.go (new)

lantern devnet-init --lotus-rpc <URL>:

  1. Queries the devnet lotus via three JSON-RPC calls (Filecoin.StateNetworkName, Filecoin.ChainGetGenesis, Filecoin.ChainHead).
  2. Writes <data-dir>/devnet/devnet-config.json with the discovered identity.
  3. Seeds <data-dir>/devnet/bootstrap-anchor.json from ChainHead so the daemon can start immediately — no separate lantern init.
  4. Refuses to overwrite existing files without --force.
  5. Prints a copy-pasteable daemon command at the end.

cmd/lantern/main.go

cmdDaemon now handles --network devnet:

  • Loads devnet-config.json from the network data dir and calls build.ConfigureDevnet(cfg) so all subsequent Network method calls see the devnet values.
  • Auto-enables --insecure-anchor (single-endpoint trust is honest for an operator-owned devnet).
  • Auto-enables --insecure-gateway (devnet lotus speaks HTTP; TLS on localhost adds no security).
  • Auto-disables --auto-stale-reset (the bridge-off boot: auto-reset stale anchor + headerstore when persisted state is beyond parentWalkCap (#51 gap) #118 quorum probe would only hit the same single endpoint; no value).
  • Routes --gateway at the devnet lotus by default.
  • Announces every implicit flag flip with a log line at boot.

Trust posture

The operator OWNS the devnet — make devnet/up on their own machine minted the genesis. So:

  • Single-source trust is honest. No multi-source quorum, no F3, no gateway/glif cross-verification.
  • Never touches keys, JWT, or tokens. Same allow-list discipline as calibration + mainnet.
  • lantern reset --chain-state still works on a devnet data dir without wiping the devnet config (only headerstore + bootstrap-anchor.json are on the allow-list, so devnet-config.json survives).

Tests

7 unit tests in build/devnet_test.go:

  • TestDevnetConfig_SaveThenLoadRoundtrip
  • TestDevnetConfig_LoadMissingIsNilNil
  • TestDevnetConfig_LoadRejectsMissingRequired
  • TestDevnetConfig_ConfigureAndGet (includes defensive-copy verification)
  • TestDevnet_MethodsPanicWhenUnconfigured (5 subtests, one per Network method)
  • TestDevnet_MethodsAfterConfigure (all methods including gossip topic + F3 nil check)
  • TestDevnet_SaveConfigAtomicRename (no orphan .tmp left behind)

3 end-to-end tests in cmd/lantern/devnet*_test.go:

  • TestDevnetInit_EndToEnd — full devnet-init against a httptest.NewServer fake lotus RPC, asserts both files written with correct content.
  • TestDevnetInit_RefusesOverwriteWithoutForce — safety on re-init.
  • TestDevnet_DevnetInitPersistsConfig — full devnet-init → load → configure → Network method dispatch round-trip. This is the handshake the daemon uses at boot.

Full go test -count=1 -short ./... green across the tree.

Docs

docs/DEVNET.md — quick-start (docker devnet → devnet-init → daemon), trust-posture explanation, tear-down + re-init flow, testing tips, cross-references to #118 + #119.

What this unlocks

Not in this PR

  • The Curio-side docker directory and Makefile targets already exist upstream (they live in curio-fork/docker/), so nothing needs to change there.
  • Butterflynet or any future Filecoin testnet becomes a trivial follow-up: drop-in another Network variant with hardcoded constants (Devnet uses runtime config; Butterflynet would follow the calibration pattern).
  • No live docker run in this PR — the local Mac mini doesn't have docker installed. Tests cover the RPC parse + config lifecycle paths that a live run would exercise; a live end-to-end soak is the natural next step once someone spins up a devnet.

Quick start (once docker is available)

cd $CURIO_REPO && make devnet/up
lantern devnet-init --lotus-rpc http://127.0.0.1:1234/rpc/v1
lantern daemon --network devnet

A locally-hosted Curio docker devnet (curio-fork/docker/, `make devnet/up`)
runs at 4s block time with FWSS + PDP + Multicall3 pre-deployed. That's a
much faster loop for exercising the full Lantern + Curio Core stack than
calibration (30s epochs, external dependency) or mainnet (real FIL).

The blocker was that Lantern hardcoded Mainnet + Calibration as the only
build.Network variants: no bootstrap peers, no gossip topics, no genesis
CID, no wire-name mapping for a devnet.

## What's new

**build/devnet.go** — new file with:
- `Devnet Network = "devnet"` constant.
- `DevnetConfig` struct capturing the per-boot devnet identity.
- `ConfigureDevnet(cfg)` runtime setter + `GetDevnetConfig()` accessor
  + `IsDevnetConfigured()` predicate. Called by the CLI before any
  Network method dispatch.
- `LoadDevnetConfig(path)` / `SaveDevnetConfig(path, cfg)` for the
  on-disk JSON at `<data-dir>/devnet/devnet-config.json` (atomic
  tmp+rename write).
- Every Network method dispatch panics-with-help when Devnet is used
  before ConfigureDevnet — pointing operators at `lantern devnet-init`.

**build/network.go** — every getter switch (`NetworkName`,
`GenesisCID`, `GossipTopicBlocks/Messages`, `BootstrapPeers`,
`F3Manifest`, `Valid`) now handles `Devnet`. `F3Manifest` returns nil
for devnet (F3 does not run there); every other getter reads from
the configured DevnetConfig.

**net/glif/client.go** — two new lightweight JSON-RPC methods
(`StateNetworkName`, `FetchGenesis`) so the devnet-init CLI can
introspect a running lotus without pulling in Lotus API types.

**cmd/lantern/devnet.go** — new `lantern devnet-init --lotus-rpc <URL>`
subcommand:
- Queries the running devnet lotus via three JSON-RPC calls
  (`Filecoin.StateNetworkName`, `Filecoin.ChainGetGenesis`,
  `Filecoin.ChainHead`).
- Writes `<data-dir>/devnet/devnet-config.json` with the discovered
  identity (network name, genesis CID, lotus RPC, bootstrap peers).
- Seeds `<data-dir>/devnet/bootstrap-anchor.json` from ChainHead so
  the daemon can start immediately after (no separate `lantern init`
  needed).
- Refuses to overwrite existing files without `--force`.

**cmd/lantern/main.go** — `cmdDaemon` now handles `--network devnet`:
- Loads `devnet-config.json` from the network data dir.
- Calls `build.ConfigureDevnet(cfg)` so all subsequent Network method
  calls see the devnet values.
- Auto-enables `--insecure-anchor` (single-endpoint trust is honest for
  an operator-owned devnet).
- Auto-enables `--insecure-gateway` (devnet lotus speaks HTTP; TLS on
  localhost adds no security).
- Auto-disables `--auto-stale-reset` (the #118 quorum probe would only
  hit the same single endpoint; no value).
- Routes `--gateway` at the devnet lotus by default.
- Announces every implicit flag flip with a log line at boot.

## Trust posture

The operator OWNS the devnet — `make devnet/up` on their own machine
minted the genesis. So:
- **Single-source trust is honest.** No multi-source quorum, no F3,
  no gateway/glif verification.
- **Never touches keys, JWT, or tokens.** Same allow-list discipline
  as calibration + mainnet.
- **`lantern reset --chain-state` still works** on a devnet data dir
  without wiping the devnet config (only `headerstore` +
  `bootstrap-anchor.json` are on the allow-list).

## Tests

**7 unit tests in build/devnet_test.go:**
- Save/Load roundtrip
- Missing file → nil (cold boot)
- Missing required fields → error
- Configure/Get + defensive copy
- Panic-with-help subtests for every Network method
- All methods after Configure (including gossip topic + F3 nil check)
- Atomic tmp+rename verification

**3 end-to-end tests in cmd/lantern/devnet*_test.go:**
- `TestDevnetInit_EndToEnd` — full devnet-init against a
  `httptest.NewServer` fake lotus RPC, asserts both files written
  with correct content.
- `TestDevnetInit_RefusesOverwriteWithoutForce` — safety on
  re-init.
- `TestDevnet_DevnetInitPersistsConfig` — full devnet-init → load
  → configure → Network method dispatch round-trip. This is the
  handshake the daemon uses at boot.

Full `go test -count=1 -short ./...` green across the tree.

## Docs

**docs/DEVNET.md** — quick-start (docker devnet → devnet-init → daemon),
trust-posture explanation, tear-down + re-init flow, testing tips,
cross-references to #118 + #119.

## What this unlocks

- End-to-end soak testing of #119 mpool persistence at 4s block time:
  MpoolPushMessage → gossipsub → mine → StateSearchMsg → journal drop.
- #118 auto-stale-reset validation on a compressed timescale (a 12h
  `--anchor-max-age` becomes ~1500 devnet epochs).
- Full PDP proving loop at 4s epochs to hunt scheduling bugs faster
  than calibration allows.
- The #104 Phase-1 tester-experience script can run against an
  isolated devnet instead of shared calibration.

Butterflynet + any future Filecoin testnet becomes a trivial follow-up:
drop-in another Network variant with hardcoded constants.
@Reiers Reiers merged commit ea738e1 into main Jul 11, 2026
1 check passed
@Reiers Reiers deleted the feat/devnet-support branch July 11, 2026 13:36
Reiers pushed a commit that referenced this pull request Jul 12, 2026
…evnet

Every change is gated on network == "devnet"; mainnet + calibration
paths are byte-identical to pre-change behaviour.

- eth_chainId + net_version return the devnet's actual EIP-155 chainId
  instead of the mainnet fallback 0x13a / "314". devnet-init reads
  eth_chainId from the running devnet lotus and persists it into
  devnet-config.json as ethChainID. This is the P0 finding: an EVM
  client signing with the wrong chainId gets "invalid signature" back
  from lotus and takes hours to diagnose.

- Filecoin.Version.BlockDelay returns the devnet's actual cadence
  instead of hardcoded 30. devnet-init reads BlockDelay from
  Filecoin.Version and persists as blockDelaySecs.

- Filecoin.StateNetworkName returns the devnet's localnet-uuid instead
  of the literal string "devnet". Handler now reads from the persisted
  devnet-config instead of a literal. Fixes gossipsub topic identity
  (/fil/blocks/<name>) and DHT protocol prefix alignment.

- Filecoin.ChainGetGenesis on devnet returns the persisted genesis CID
  instead of "not implemented". Compile-time genesis constants exist
  only for mainnet + calibration; devnet's genesis is per-boot.

- Filecoin.MpoolGetConfig returns a stable snapshot instead of HTTP 500.
  Values match lotus defaults so consumers (curio at boot) don't have
  to special-case Lantern. PriorityAddrs nil (marshals to JSON null,
  matching lotus's wire shape byte-for-byte).

- Filecoin.MpoolPushMessage now works on single-node devnet. The
  gossipsub mesh can't form on a single-node docker devnet, so the
  standard mpool.Pool had nothing to publish onto. Daemon now wires a
  Pool whose Config.Sink POSTs directly to the devnet lotus via
  Filecoin.MpoolPush. All other Pool semantics (persist journal,
  pending set, nonce derivation, #47 reconcile/rebroadcast, #121
  restart-persist) work identically; only the wire transport changes.
  Trust posture unchanged from #122: devnet is single-source by design
  (operator owns both the lotus and the Lantern client).

- Devnet mode auto-wires the devnet lotus as VMBridge when the operator
  did not pass --vm-bridge-rpc. Fixes eth_getCode / eth_call /
  eth_getStorageAt timeouts on cold state (single-node devnet has no
  libp2p, so bitswap can't fetch state blocks; daemon fell through to
  errBridgeUnconfigured).

- mpool.Config.Sink hook lets Pool run with a nil pubsub instance.
  When Sink is non-nil and ps is nil, New() skips the gossipsub
  join+subscribe entirely and Publish/Rebroadcast route through the
  sink. Only used by devnet mode; mainnet/calibration paths continue
  to require a pubsub instance.

- net/glif.Client.EthChainID, .BlockDelaySecs, .MpoolPush — three new
  methods on the shared JSON-RPC client. Used by devnet-init (boot-time
  discovery) and by the daemon (devnet mpool sink).

- build.DevnetConfig gained EthChainID + BlockDelaySecs fields. Older
  configs written before this change have zero values; handlers hint
  `lantern devnet-init --force` to re-capture from the running lotus.

- 5 new unit tests in net/mpool/sink_test.go cover the nil-pubsub +
  Sink path: nil-pubsub requires Sink; Publish invokes Sink instead of
  gossipsub byte-identically; persist journal entry matches published
  raw; Close is a no-op on nil topic/subscription; sink error surfaces
  from Publish without recording pending.

- Existing cmd/lantern devnet-init tests updated: fake lotus stub now
  answers eth_chainId + Filecoin.Version so the boot-time discovery
  succeeds; new asserts check EthChainID = 31415926 and BlockDelaySecs
  = 4 land in the persisted config.

- Full suite green: go test ./... reports 51 packages ok, no failures.

- ChainHead head-poll lag on devnet (#123 findings 8+9) and eth_getCode
  proxy-address slowness (finding 7). Both are latency issues, not
  correctness. Folding into a separate --devnet-head-poll-interval knob
  PR after this batch lands.
Reiers added a commit that referenced this pull request Jul 12, 2026
…evnet (#125)

* fix(#123): close all ten devnet RPC parity gaps against local Curio devnet

Every change is gated on network == "devnet"; mainnet + calibration
paths are byte-identical to pre-change behaviour.

- eth_chainId + net_version return the devnet's actual EIP-155 chainId
  instead of the mainnet fallback 0x13a / "314". devnet-init reads
  eth_chainId from the running devnet lotus and persists it into
  devnet-config.json as ethChainID. This is the P0 finding: an EVM
  client signing with the wrong chainId gets "invalid signature" back
  from lotus and takes hours to diagnose.

- Filecoin.Version.BlockDelay returns the devnet's actual cadence
  instead of hardcoded 30. devnet-init reads BlockDelay from
  Filecoin.Version and persists as blockDelaySecs.

- Filecoin.StateNetworkName returns the devnet's localnet-uuid instead
  of the literal string "devnet". Handler now reads from the persisted
  devnet-config instead of a literal. Fixes gossipsub topic identity
  (/fil/blocks/<name>) and DHT protocol prefix alignment.

- Filecoin.ChainGetGenesis on devnet returns the persisted genesis CID
  instead of "not implemented". Compile-time genesis constants exist
  only for mainnet + calibration; devnet's genesis is per-boot.

- Filecoin.MpoolGetConfig returns a stable snapshot instead of HTTP 500.
  Values match lotus defaults so consumers (curio at boot) don't have
  to special-case Lantern. PriorityAddrs nil (marshals to JSON null,
  matching lotus's wire shape byte-for-byte).

- Filecoin.MpoolPushMessage now works on single-node devnet. The
  gossipsub mesh can't form on a single-node docker devnet, so the
  standard mpool.Pool had nothing to publish onto. Daemon now wires a
  Pool whose Config.Sink POSTs directly to the devnet lotus via
  Filecoin.MpoolPush. All other Pool semantics (persist journal,
  pending set, nonce derivation, #47 reconcile/rebroadcast, #121
  restart-persist) work identically; only the wire transport changes.
  Trust posture unchanged from #122: devnet is single-source by design
  (operator owns both the lotus and the Lantern client).

- Devnet mode auto-wires the devnet lotus as VMBridge when the operator
  did not pass --vm-bridge-rpc. Fixes eth_getCode / eth_call /
  eth_getStorageAt timeouts on cold state (single-node devnet has no
  libp2p, so bitswap can't fetch state blocks; daemon fell through to
  errBridgeUnconfigured).

- mpool.Config.Sink hook lets Pool run with a nil pubsub instance.
  When Sink is non-nil and ps is nil, New() skips the gossipsub
  join+subscribe entirely and Publish/Rebroadcast route through the
  sink. Only used by devnet mode; mainnet/calibration paths continue
  to require a pubsub instance.

- net/glif.Client.EthChainID, .BlockDelaySecs, .MpoolPush — three new
  methods on the shared JSON-RPC client. Used by devnet-init (boot-time
  discovery) and by the daemon (devnet mpool sink).

- build.DevnetConfig gained EthChainID + BlockDelaySecs fields. Older
  configs written before this change have zero values; handlers hint
  `lantern devnet-init --force` to re-capture from the running lotus.

- 5 new unit tests in net/mpool/sink_test.go cover the nil-pubsub +
  Sink path: nil-pubsub requires Sink; Publish invokes Sink instead of
  gossipsub byte-identically; persist journal entry matches published
  raw; Close is a no-op on nil topic/subscription; sink error surfaces
  from Publish without recording pending.

- Existing cmd/lantern devnet-init tests updated: fake lotus stub now
  answers eth_chainId + Filecoin.Version so the boot-time discovery
  succeeds; new asserts check EthChainID = 31415926 and BlockDelaySecs
  = 4 land in the persisted config.

- Full suite green: go test ./... reports 51 packages ok, no failures.

- ChainHead head-poll lag on devnet (#123 findings 8+9) and eth_getCode
  proxy-address slowness (finding 7). Both are latency issues, not
  correctness. Folding into a separate --devnet-head-poll-interval knob
  PR after this batch lands.

* fix(#123): auto-wire devnet VMBridge in standalone CLI + skip gossipsub mpool wiring on devnet

Two follow-up fixes surfaced by a live smoke against a running docker
devnet (curio-fork make docker/devnet, lotus v1.35.1, BlockDelay=4):

1. cmd/lantern/main.go standalone daemon didn't share the pkg/daemon
   VMBridge auto-wire I added in the previous commit. When `lantern
   daemon --network devnet` runs without an explicit --vm-bridge-rpc,
   any eth_call / eth_getCode / eth_getStorageAt returned
   "FEVM method requires --vm-bridge-rpc" instead of transparently
   using the devnet's own lotus. Now the CLI applies the same
   `--network devnet && !--vm-bridge-rpc → devnetCfg.LotusRPC` fallback.
   Verified live: after the fix, eth_getCode against the deployed
   PDPVerifier reaches the bridge (result is served instead of erroring).

2. pkg/daemon/start.go's startGossipHead was joining the gossipsub
   mempool topic on devnet EVEN THOUGH there's no gossipsub mesh on a
   single-node devnet. Once that mpool was in place, my devnet
   lotus-RPC sink block (which triggers only when chainAPI.Mpool == nil)
   never fired, so MpoolPushMessage silently dropped signed messages
   into the void. Fix: startGossipHead now short-circuits before the
   gossipsub mpool wiring on devnet, logs the reason, and leaves
   chainAPI.Mpool nil so the devnet sink block in startInternal
   activates. Bitswap wiring after the mpool block is preserved via
   a goto over a scoped variable-declaration block (Go-legal, verified
   with go build + go vet).

Live-verified end-to-end on the docker devnet:
  Filecoin.StateNetworkName  → "localnet-1069c685-..." (was "devnet")
  eth_chainId                → 0x1df5e76 (31415926) (was 0x13a)
  net_version                → "31415926" (was "314")
  Filecoin.Version.BlockDelay→ 4 (was 30)
  Filecoin.ChainGetGenesis   → real CID (was not-implemented)
  Filecoin.MpoolGetConfig    → lotus-shape defaults (was HTTP 500)
  vm-bridge auto-wired to devnet lotus (bridge log line + eth_getCode reaches bridge)

eth_getCode is still slow on cold state (local hamt walk tries bitswap
first, and single-node devnet has no bitswap peers, so the walk waits
the full 15s timeout before falling through to the bridge). That's
#123 finding #7 in a narrower form. Fix candidate: short-circuit the
local-first order to bridge-first on devnet. Out of scope for this
batch; filing a follow-up.

Tests: full suite (`go test ./...`) 51/51 packages ok.

---------

Co-authored-by: Nicklas Reiersen <nicklas@reiers.io>
Reiers added a commit that referenced this pull request Jul 12, 2026
…tStorageAt / eth_call (#126)

Closes the last correctness gap from lantern#123 (finding 7).

On a single-node docker devnet the local hamt walk in localEthGetCode
/ localEthGetStorageAt / localEthCall always burns its full retry
budget before falling through to the bridge: there are no bitswap peers
to fetch cold storage-trie blocks from. Symptom: eth_getCode against
the deployed PDPVerifier proxy takes 15+ seconds (retry budget = 2 x
8s) and any real consumer (curio-core, ethers, viem) times out first.

The devnet lotus IS the source of truth for the devnet chain (single-
source by design, trust posture unchanged from #122), so the local walk
adds latency without adding trust. Handlers now short-circuit straight
to the auto-wired VMBridge when NetworkName == "devnet" && Bridge != nil.

Live smoke, curio-fork docker devnet, height 17600+:

  eth_getCode(PDPVerifier)      34 ms   (was 15 s timeout)
  eth_getStorageAt(slot 0)      28 ms   (returns real 32-byte value)
  eth_call(bad selector)        13 ms   (bridge returns correct revert)

Mainnet + calibration paths byte-identical (bridge-first block gated on
network == "devnet"). Escape hatch: if the operator explicitly drops
the bridge on devnet (--vm-bridge-rpc=""), reads return
errBridgeUnconfigured instead of silently hanging — same as pre-#123.

Extracted the bridge-forward tail of EthCall into bridgeEthCall so the
devnet short-circuit and the mainnet/calibration local-miss fallback
share the same code.

5 new unit tests in rpc/handlers/devnet_bridge_first_test.go:
- devnet EthGetCode / EthGetStorageAt / EthCall each call bridge exactly
  once (never touch local)
- devnet EthGetCode / EthCall without a bridge return errBridgeUnconfigured
- mainnet EthGetCode with no accessor still falls back to bridge (local
  path unchanged)

Full suite: go test ./... reports 52 packages ok, no failures.

Co-authored-by: Nicklas Reiersen <nicklas@reiers.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant