fix(#119): persist pending mpool messages across daemon restart#121
Merged
Conversation
#47's rebroadcast loop tracked pending signed messages in memory only. A daemon restart threw away the pending set, so any message the user pushed pre-restart: - had a nonce consumed on the sender's on-chain account view, - was gossiped once but may not have been mined, - was no longer tracked (no retry, no confirm poll, no OnFailed fire). If the message wasn't mined before the restart, it was silently dead. The next MpoolPush from the same account then hit an 'expected nonce X, got X+1' gap and stalled indefinitely. #51 covers 'chain state can be rebuilt from the network.' Signed pending messages cannot be rebuilt (the key signed them once, only the daemon that pushed them has the bytes), so they need durable persistence. ## What's new net/mpool/persist.go: append-only JSONL journal + in-memory index. - 'add' lines carry the raw signed bytes (base64) + metadata. - 'retry' lines bump the rebroadcast counter + anchored epoch. - 'tombstone' lines mark confirmed / max-retries / Forget removals. - On Open, replay all lines; later entries win. Automatic compaction when tombstones dominate (compactMinLines=8, compactRatio=2), and on corrupt-tail recovery. - Fsync on every write so a crash after Publish keeps the message. Pool integration (net/mpool/mpool.go): - Config.PersistPath opts in. When set, New() opens the journal + re- registers every live entry into the in-memory pending map. - Publish() fsyncs an 'add' line after gossipsub push. If the fsync fails, we surface the error to the caller (safe to retry: identical bytes = same CID = same nonce). - Forget() writes a tombstone. - Close() flushes and releases the journal handle. - New Stats.Restored + Stats.PersistPath surface observability. Reconcile integration (net/mpool/reconcile.go): - Confirmed branch writes a tombstone (so restart doesn't re-register a message that already landed). - Max-retries branch writes a tombstone (OnFailed fires exactly once even across a crash-and-restart: on load the entry is gone, no restore). - Rebroadcast writes a retry line with the bumped counter + anchor. pkg/daemon wiring: - Config.MpoolPersistDisabled (zero value = enabled) toggles persistence. - Config.MpoolPersistPath overrides the default path. - start.go derives <DataDir>/<Network>/mpool/pending.jsonl by default and passes it to mpool.New. - New Daemon.MpoolStats() accessor exposes counters for dashboard + tests. ## Guarantees preserved - Rebroadcast IS byte-identical (same nonce, same CID). We store the ORIGINAL raw bytes and never re-sign or bump fee. RBF stays out of scope. - Never touches private keys, JWT, or tokens. The journal lives in <net>/mpool/, chain-side of the secrets boundary. - 'lantern reset --chain-state' does NOT wipe pending.jsonl (user's pending sends are user state; the reset allow-list only knows about 'headerstore' + 'bootstrap-anchor.json'). - Backwards-compatible: PersistPath='' preserves pre-#119 memory-only behaviour. All #47 tests still pass unchanged. - Nonce coordination is transparent: MpoolGetNonce already reads Pending() (rpc/handlers/chain_api.go), and the restored set is loaded before New() returns, so the very first MpoolGetNonce after restart sees the pre-outage pending nonces. ## Tests - 10 unit tests in net/mpool/persist_test.go: add/reload, remove-then-reload, update-on-rebroadcast reload, no-op on missing cid, tombstone-churn compaction, corrupt-tail recovery, journal-line shape, empty-path rejection, parent-dir mkdir. - 5 pool-level restart integration tests in net/mpool/persist_pool_test.go: restart-preserves-pending, confirmed-drops-across-restart, forget-drops-across-restart, retry-count-survives-restart, empty-path-is-memory-only. - All existing #47 reconcile tests + mpool tests remain green. - Full 'go test -count=1 -short ./...' green across the tree. ## Second half of the peace-of-mind contract Companion to #118 (auto-refresh stale anchor across restart). Together: a Lantern user can stop and restart the daemon at any time, after any duration, and the node catches up on its own AND their pending sends still land, without them needing to know a single reset command exists. Fixes #119.
This was referenced Jul 11, 2026
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>
This was referenced Jul 12, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #119. Companion to #120 (#118 auto-refresh stale anchor). Together they close the peace-of-mind contract: a Lantern user can stop and restart the daemon at any time, after any duration, and both chain state and their pending sends survive without operator intervention.
Why
#47's rebroadcast loop tracked pending signed messages in memory only. A daemon restart threw away the pending set, so any message the user pushed pre-restart:OnFailedfire).If the message wasn't mined before the restart, it was silently dead. The next
MpoolPushfrom the same account then hit an "expected nonce X, got X+1" gap and stalled indefinitely.#51covers "chain state can be rebuilt from the network." Signed pending messages cannot be rebuilt (the key signed them once, only the daemon that pushed them has the bytes), so they need durable persistence with the same discipline as user state.What
`net/mpool/persist.go`: append-only JSONL journal + in-memory index.
Pool integration (`net/mpool/mpool.go`):
Reconcile integration (`net/mpool/reconcile.go`):
`pkg/daemon` wiring:
Guarantees
Tests
10 unit tests in `net/mpool/persist_test.go`:
5 pool-level restart integration tests in `net/mpool/persist_pool_test.go`:
Full `go test -count=1 -short ./...` green across the tree.
Live smoke
Not run as part of this PR: an end-to-end smoke would need a funded wallet + a real `MpoolPushMessage` on calibration, which isn't safe to run mechanically. The unit + pool-level restart tests cover the load-bearing paths (Publish → journal → Close → reopen → restore → Reconcile) end-to-end at the mpool boundary. Integration verification on `pkg/daemon` is the config wiring pass-through covered by the existing `pkg/daemon` test suite.
Peace-of-mind contract now closed
The user contract Lantern implies is now: push a message and I'll keep gossiping it until it lands or a bounded retry budget runs out, restart-safe.
Depends on / relates to