dash(embedded): E2d daemonless MN-set seed — pinned checkpoint + forward replay (#738)#899
Draft
frstrtr wants to merge 1 commit into
Draft
dash(embedded): E2d daemonless MN-set seed — pinned checkpoint + forward replay (#738)#899frstrtr wants to merge 1 commit into
frstrtr wants to merge 1 commit into
Conversation
…ard replay (#738) c2pool could not mine DASH without an external dashd because it could not obtain the payout-bearing masternode set on its own. The P2P Simplified MN List omits scriptPayout and nLastPaidHeight, and neither is committed in merkleRootMNList, so no payout-bearing set can be header-authenticated. The only cold-start source was dashd RPC `protx list valid true` (E2c); the no-RPC arm printed "seed UNAVAILABLE" and every template kept routing to the dashd fallback. That was the last structurally daemon-dependent input on the daemonless path. This lands the settled design: a release-pinned masternode-set checkpoint compiled into the binary, replayed forward to the tip through the existing block-connect ingest. The fully trustless DIP-3-height replay (~1.5M block bodies) stays available as a later opt-in verify-mode and is NOT built here. THE COST IS A TRUST ANCHOR, AND IT IS DOCUMENTED WHERE USERS WILL SEE IT. A node cold-starting from the checkpoint trusts the release build for the set contents at the anchor height. That is stated in README ("DASH daemonless masternode-set checkpoint — trust anchor"), in KNOWN_ISSUES, in CHANGELOG, in src/impl/dash/coin/checkpoints/README.md, and at runtime on every load — not only in a source comment. What the node still verifies for itself, with no trust: * chain position — the anchor's blockhash must match our own X11-PoW + DGW validated header chain at the anchor height; * integrity — a SHA-256 digest over every non-comment line (an integrity check on the file, NOT a signature; it catches accident, not malice); * forward consistency — every replayed block re-derives the projected payee and compares it with that block's real coinbase, so a wrong anchor is falsified within a few blocks. FAIL CLOSED, LOUDLY. Missing, unpinned, corrupt, wrong-network, wrong chain position, staler than --embedded-mn-bridge-max (default 20000 blocks, ~34 days), a replay gap, or a replay payee-desync: log at ERROR, refuse to serve embedded templates, keep routing to the dashd fallback. A wrong payee is a coinbase the network rejects — a lost block. Refusing is always cheaper. Design note — why the bridge runs on a PRIVATE MnStateMachine rather than publishing the anchor and letting the maintainer fold forward: apply_block is forward-contiguous, and during a cold start the live feed is simultaneously delivering blocks at the CURRENT tip. Publishing at the anchor height would arm MN-readiness against a queue thousands of blocks stale, then trip gap_detected on the first live block — a served bad-cb-payee window followed by a latched-dead arm. The lane instead folds only the exact next height, ignores live tip blocks mid-bridge, and publishes ONCE, at the tip, through the same leg-4 event the RPC seed uses. Also closes an anti-mint hole that only becomes reachable now that the arm can run without an RPC: with no seed the payee set starts empty and the apply cursor is 0, so the first block carrying a ProRegTx would register one masternode, flip MN-readiness, and serve a template whose entire payment queue was that single accidental registration. CoinStateMaintainer now supports an opt-in latch requiring an authoritative height-stamped snapshot; main_dash enables it for the embedded arm. Default off, so no existing construction site changes behaviour. Re-pinning is a scripted release step, not manual surgery: tools/dash/gen_mn_checkpoint.py pin (height-stable protx fetch bracketed by getblockcount, chain cross-check, deterministic ordering, prints the provenance block for the release notes) and ... verify PATH for the release checklist. The shipped mainnet and testnet anchors are UNPINNED in this change — pinning needs RPC access to a synced dashd, which is a release-time step. Until pinned, daemonless DASH fails closed, which is the intended safe default. Lock trace: the lane holds no lock and is io-thread confined. Its pump() calls self-locking HeaderChain reads and is driven from HeaderChain's on_tip_changed callback, which is dispatched with m_mutex RELEASED (add_header/add_headers copy the pending tip change out inside the lock scope, close it, then invoke) — so the callee is reachable, not dead code. The pre-existing tip_advance_from_chain(*hc, ...) call in the same lambda already depends on that property and is proven live. Tests fold into the EXISTING allowlisted test_dash_node_reception_wire target (a new add_executable would silently report "Not Run"). No #ifdef guards in the new TU, so every registered case actually executes.
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.
What this closes
c2pool could not mine DASH without an external dashd, because it could not obtain the payout-bearing masternode set on its own. The P2P Simplified MN List omits
scriptPayoutandnLastPaidHeight, and neither is committed inmerkleRootMNList, so a payout-bearing set cannot be header-authenticated. The only cold-start source was dashd RPCprotx list valid true(E2c,main_dash.cpp); the no-RPC arm printedseed UNAVAILABLE ... E2d follow-upand every template kept routing to the dashd fallback. This was the last structurally daemon-dependent input on the daemonless path.This implements the settled design: release-pinned checkpoint + forward replay. Fully trustless DIP-3-height replay (~1.5M block bodies) stays available as a later opt-in verify-mode and is not built here.
⚠ The cost is a trust anchor, and it is documented where users will find it
A node cold-starting from the checkpoint is trusting this release build for the masternode set contents at the anchor height. That is stated:
README.md→ "DASH daemonless masternode-set checkpoint — trust anchor"KNOWN_ISSUES.mdCHANGELOG.md→[Unreleased]src/impl/dash/coin/checkpoints/README.mdsrc/impl/dash/coin/mn_checkpoint.hppWhat is still verified locally, with no trust
blockhashmust equal what our own X11-PoW + DGW-validatedHeaderChainholds atheight. Wrong chain / wrong fork → refused.digestline. This is an integrity check on the file, not a signature: anyone who can change the source can recompute it. It catches accident, not malice — and the docs say exactly that.heightand progressively falsifiable after it.Not verified (this is the trust): the membership and per-masternode payout state of the set at the anchor height. Nothing on the DASH P2P network can prove it.
Checkpoint format and re-pinning
Format
c2pool-dash-mn-checkpoint/1— ASCII, line-oriented, digest-committed, deliberately diff-reviewable rather than an opaque blob (a trust anchor a reviewer cannot read is worse than no anchor). Spec inmn_checkpoint.hpp; summary incheckpoints/README.md.Compiled into the binary (
.inc= the same text as adjacent C++ string literals), so a daemonless cold start cannot depend on a file an operator could lose, swap, or forget to install.Re-pinning is scripted, per the requirement that a stale anchor must not go unnoticed:
pinbracketsprotx list valid true <h>withgetblockcountbefore/after and refetches on a mid-flight tip move (a mislabelled height seeds the payment cursor wrong = servedbad-cb-payee), refuses ifgetblockchaininfo.chaindisagrees with--network, convertspayoutAddress → scriptPayoutonce at release time, sorts byproTxHashfor readable re-pin diffs, and prints a provenance block for the release notes.verifyis a release-checklist / post-rebase step; on an unpinned file it saysUNPINNEDand exits 2 rather than "corrupt".🔴 The shipped anchors are UNPINNED in this PR
Pinning needs RPC access to a synced mainnet/testnet dashd, which I do not have from this environment (the hotel mainnet node is not reachable over SSH from here). Rather than fabricate a masternode set, both
.incfiles ship as explicit UNPINNED sentinels and daemonless DASH fails closed until a release pins them. That is the intended safe default and is documented as such. This is the one thing in this PR I could not verify end-to-end against mainnet.The mechanism is exercised against real captured testnet data:
test/dash_mn_checkpoint_testnet_1519543.inc, produced by this PR's generator from a realprotx list valid true 1519543, replayed over the real accepted blocks 1519544–1519546.Wiring
src/impl/dash/coin/mn_checkpoint.hpp(new)src/impl/dash/coin/mn_checkpoint_lane.hpp(new)src/impl/dash/coin/checkpoints/(new)tools/dash/gen_mn_checkpoint.py(new)--embedded-mn-bridge-maxsrc/c2pool/main_dash.cppsrc/impl/dash/coin/coin_state_maintainer.hppWhy the bridge runs on a PRIVATE
MnStateMachinerather than publishing the anchor and letting the maintainer fold forward — the obvious wiring is actively dangerous.apply_blockis forward-contiguous: after loading a snapshot ath, the only block it may fold ish+1. During a cold start the live feed is simultaneously delivering blocks at the current tip (thenew_blockinv →request_blockleg). Publishing ath=CKPTwould arm MN-readiness immediately against a payment queue thousands of blocks stale — a servedbad-cb-payeewindow — and then the first live tip block would tripgap_detected→ wipe → demote → request an authoritative re-seed, which on the daemonless arm does not exist, latching the arm dead.So the lane folds only the exact next height it is waiting for, ignores live tip blocks arriving mid-bridge, and publishes once, at the tip, through the same leg-4 event (
Node::mn_list_update) the E2c RPC seed uses — so the maintainer takes it as an ordinary authoritative resync with its snapshot fence and apply cursor set correctly for the next live block.Anti-mint latch (new hole, only reachable once the arm can run RPC-less): with no seed, the payee set starts empty and the apply cursor is 0, so
apply_block's contiguity guard is inactive. The first connected block carrying aProRegTxregisters one masternode,size() != 0flipsm_have_mn, and with the tip half livepopulated()starts serving templates whose entire DIP-3 payment queue is that one accidental registration.CoinStateMaintainer::set_require_seeded_mn_set(true)now requires an authoritative height-stamped snapshot. Default off, so every existing construction site (KATs, the hotel/RPC posture) is behaviourally identical;main_dashopts the embedded arm in.The RPC path is untouched. When a coin RPC is configured,
protx listis authoritative and the checkpoint is never consulted.Fail-closed behaviour, and how it was proven
UnpinnedPayloadIsReportedDistinctly,RefusedCheckpointArmsTerminallyFailedClosedDigestMismatchRefusesEverythingscriptPayout/ duplicate proTxHash /-1/12abc/ all-zero blockhashChainPositionMismatchFailsClosedStaleAnchorBeyondBridgeBoundFailsClosedReplayGapFailsClosedPayeeDesyncOnWrongAnchorFailsClosedWaitsQuietlyUntilHeadersReachTheAnchorLive proof of the fail-closed banner (real binary,
--embedded-mainnet, no RPC, no anchor):★ Caller-side lock trace (#878 / #881 dead-code class)
The new wiring adds two calls that reach self-locking methods. Both are reachable, and here is why:
mn_ckpt_lane->pump()insideheader_chain->set_on_tip_changed(...).pump()callsHeaderChain::height()andget_header_by_height(), both of which takeHeaderChain::m_mutex.HeaderChaindispatchesm_on_tip_changedwith that mutex released:add_header()andadd_headers()each copy the pending tip-change out inside alock_guardscope, close the scope, and only then invoke the callback (header_chain.hpp— the twom_on_tip_changed(...)call sites sit outside everylock_guardblock). Independent confirmation: the pre-existing line two below mine in the same lambda already callstip_advance_from_chain(*hc, ...), which takes the same lock, and that path is proven live in production.mn_ckpt_lane->on_block_connected(...)from theblock_connectedsubscription. Fires from the live-feed bridge'sfull_blockhandler on the io thread. No tracker or maintainer lock is held across it; the lane itself takes no lock and is io-thread confined (same posture asUtxoLane).Reachability is also proven empirically, not just by reading:
DashMnCheckpointBridge.ReplaysRealBlocksAndPublishesAtTipdrivespump()→request_block_fn→on_block_connected→ publish and asserts the published set, andDashMnCheckpointE2e.BridgePublishFlipsPopulatedThroughTheRealLeg4Eventcarries that publish through the realwire_mn_list_ingestsubscription untilpopulated()flips. If the callees were dead, both would fail.I also found and fixed a self-inflicted variant during testing:
request_window()originally setm_statusafter issuing requests, so a synchronously-answered request that failed the bridge had its status overwritten by the caller — the "callee ran but its effect was clobbered" shape. Status is now set before, and the request loop bails the moment the lane is no longerBridging.Test evidence (real output)
Folded into the existing allowlisted
test_dash_node_reception_wiretarget — never a newadd_executable(silent "Not Run"). Per #895: no#ifdefguards anywhere in the new TU, so every registered case genuinely executes; the counts below move if a case is skipped.No regressions in the suites the maintainer change can reach:
c2pool-dashbuilds clean;python3 tools/ci/check_test_target_allowlist.py→CI drift-guard OK: 121 per-coin test target(s) ... all present.Negative controls
NegativeControlCorruptAnchorLeavesArmUnpopulated— byte-identical end-to-end rig to the passing case; the only difference is a corrupted anchor.populated()stays false. If the fail-closed path were decorative this would flip.NegativeControlAntiMintLatchBlocksUnseededArming— an unheighted masternode set must not arm the arm; then the same set with its height arms it normally on the same object (positive control in the same test, so a stuck-false assertion cannot pass by accident).PayeeDesyncOnWrongAnchorFailsClosed— a structurally valid but semantically wrong anchor (onelastPaidHeightmoved to the top of the queue) replayed over a real block 1519544; the real coinbase falsifies it and the bridge never publishes.StaleAnchorBeyondBridgeBoundFailsClosedassertsrequested.empty()— a refused anchor must not even start pulling block bodies.Bridge correctness against real data — anchor h=1519543 (6 real masternodes) replayed over real blocks 1519544–1519546, published at
as_of=1519546, with dashd's actual attribution:And
CheckpointSetIsFieldIdenticalToRpcSeedasserts the checkpoint-derivedMNStates are field-identical to whatparse_protx_list_seedproduces from the same dashd JSON — the two seed paths cannot diverge silently.What I could not verify
kWindow = 64) and the stall re-request are reasoned + unit-tested, not soaked. Per the repo's ownEMPIRICAL SOAK > reviewrule, treat those numbers as unproven until a real bridge runs.Coordination
Touches
src/c2pool/main_dash.cppbut not its arm-resolution wiring (#891's territory): includes, file-scope anchor constants, the lane declaration/construction, one capture + one call added to the tip-changed lambda, the no-RPCelsebranch, and one argument-parsing case. If #891 lands first I will rebase onto it. Avoided entirely:core/socket.cpp,core/web_server.cpp,impl/*/protocol_legacy.cpp, coinsend_shares/broadcast_share,.github/workflows/.Draft — do not merge. Wants review of the trust-anchor disclosure wording as much as the code.